diff --git a/.github/ISSUE_TEMPLATE/release.yml b/.github/ISSUE_TEMPLATE/release.yml index 7ac55427a9..582998d492 100644 --- a/.github/ISSUE_TEMPLATE/release.yml +++ b/.github/ISSUE_TEMPLATE/release.yml @@ -1,6 +1,6 @@ name: Release checklist description: Checklist for each release. This template is only for the core team. -title: "[Release] Element Android v" +title: "[Release] Element Android v" labels: [🚀 Release] assignees: - bmarty @@ -10,7 +10,7 @@ body: id: checklist attributes: label: Release checklist - description: For the template example, we are releasing the version 1.1.10. Replace 1.1.10 with the version in the issue body. + 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: | @@ -22,35 +22,41 @@ body: ### Do the release - - [ ] Create release with gitflow, branch name `release/1.1.10` + - [ ] Create release with gitflow, branch name `release/1.2.3` - [ ] Check the crashes from the PlayStore - - [ ] Check the rageshake with the current dev version: https://github.com/matrix-org/element-android-rageshakes/labels/1.1.10-dev + - [ ] 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 - - [ ] Run towncrier: `towncrier --version v1.1.10 --draft` (remove `--draft` do write the file CHANGES.md) + - [ ] 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 --version v1.2.3 --draft` (remove `--draft` do write the file CHANGES.md) + - [ ] Check that the folder `changelog.d` is empty. It can happen that some remaining files stay here + - [ ] 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 - - [ ] 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 - - [ ] Push `main` and the new tag `v1.1.10` to origin + - [ ] (optional) Push the branch and start a draft PR (will not be merged), to check that the CI is happy with all the changes. + - [ ] Finish release with gitflow, delete the draft PR (if created) + - [ ] Push `main` and the new tag `v1.2.3` to origin - [ ] Checkout `develop` - - [ ] Increase version in `./vector/build.gradle` + - [ ] Increase version (versionPatch + 2) in `./vector/build.gradle` - [ ] Change the value of SDK_VERSION in the file `./matrix-sdk-android/build.gradle` - [ ] Commit and push `develop` - [ ] Wait for [Buildkite](https://buildkite.com/matrix-dot-org/element-android/builds?branch=main) to build the `main` branch. - [ ] Run the script `~/scripts/releaseElement.sh`. It will download the APKs from Buildkite check them and sign them. - [ ] Install the APK on your phone to check that the upgrade went well (no init sync, etc.) + - [ ] Create the release on gitHub [from the tag](https://github.com/vector-im/element-android/tags), copy paste the block from the file CHANGES.md + - [ ] Add the 4 signed APKs to the GitHub release + - [ ] Ping the Android Internal room + + ### Once tested and validated internally + - [ ] Create a new beta release on the GooglePlay console and upload the 4 signed Apks. - [ ] Check that the version codes are correct - [ ] Copy the fastlane change to the GooglePlay console in the section en-GB. - [ ] Push to beta release to 100% of the users - - [ ] 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 - - [ ] Add an entry in the internal diary + - [ ] Notify the F-Droid team so that they can schedule the publication on F-Droid ### Once Live on PlayStore - [ ] Ping the Android public room and update its topic + - [ ] Add an entry in the internal diary ### After at least 2 days @@ -62,6 +68,8 @@ body: ### Android SDK2 + 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 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index eb30c18fcf..7096e6bdb5 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,9 +1,41 @@ -### Pull Request Checklist + + +## Type of change - +- [ ] Feature +- [ ] Bugfix +- [ ] Technical +- [ ] Other : + +## Content + + + +## Motivation and context + + + +## Screenshots / GIFs + + + +## Tests + + + +- Step 1 +- Step 2 +- Step ... + +## Tested devices + +- [ ] Physical +- [ ] Emulator +- OS version(s): + +## Checklist + + - [ ] Changes has been tested on an Android device or Android emulator with API 21 - [ ] UI change has been tested on both light and dark themes diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index bf78356947..cac35fb1fc 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -180,6 +180,7 @@ jobs: body="$(cat ./matrix-sdk-android/build/outputs/androidTest-results/connected/*.xml | grep " - -Porg.gradle.jvmargs=-Xmx2g + -Porg.gradle.jvmargs=-Xmx4g -Porg.gradle.parallel=false jobs: @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - api-level: [ 29 ] + api-level: [ 28 ] steps: - uses: actions/checkout@v2 with: @@ -57,9 +57,11 @@ jobs: - name: Run sanity tests on API ${{ matrix.api-level }} uses: reactivecircus/android-emulator-runner@v2 with: - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none api-level: ${{ matrix.api-level }} - profile: 24 # Pixel 5 + arch: x86 + profile: Nexus 5X + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none emulator-build: 7425822 # workaround to emulator bug: https://github.com/ReactiveCircus/android-emulator-runner/issues/160 script: | adb root @@ -67,12 +69,11 @@ jobs: touch emulator.log chmod 777 emulator.log adb logcat >> emulator.log & - ./gradlew $CI_GRADLE_ARG_PROPERTIES -PallWarningsAsErrors=false connectedGplayDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=im.vector.app.ui.UiAllScreensSanityTest || adb pull storage/emulated/0/Pictures/failure_screenshots && exit 1 - - name: Upload Failing Test Report Log + ./gradlew $CI_GRADLE_ARG_PROPERTIES -PallWarningsAsErrors=false connectedGplayDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=im.vector.app.ui.UiAllScreensSanityTest || adb pull storage/emulated/0/Pictures/failure_screenshots + - name: Upload Test Report Log uses: actions/upload-artifact@v2 - if: failure() with: name: sanity-error-results path: | emulator.log - failure_screenshots/ \ No newline at end of file + failure_screenshots/ diff --git a/.github/workflows/triage-labelled.yml b/.github/workflows/triage-labelled.yml index 71b1cde40d..eeddf2e785 100644 --- a/.github/workflows/triage-labelled.yml +++ b/.github/workflows/triage-labelled.yml @@ -202,3 +202,53 @@ jobs: env: PROJECT_ID: "PN_kwDOAM0swc3m-g" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + + move_ftue_issues: + name: Z-FTUE to Mobile FTUE board + runs-on: ubuntu-latest + # Skip in forks + if: > + github.repository == 'vector-im/element-android' && + contains(github.event.issue.labels.*.name, 'Z-FTUE') + steps: + - uses: octokit/graphql-action@v2.x + with: + headers: '{"GraphQL-Features": "projects_next_graphql"}' + query: | + mutation add_to_project($projectid:ID!,$contentid:ID!) { + addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { + projectNextItem { + id + } + } + } + projectid: ${{ env.PROJECT_ID }} + contentid: ${{ github.event.issue.node_id }} + env: + PROJECT_ID: "PN_kwDOAM0swc4AAqVx" + GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + + move_WTF_issues: + name: Z-WTF to WTF board + runs-on: ubuntu-latest + # Skip in forks + if: > + github.repository == 'vector-im/element-android' && + contains(github.event.issue.labels.*.name, 'Z-WTF') + steps: + - uses: octokit/graphql-action@v2.x + with: + headers: '{"GraphQL-Features": "projects_next_graphql"}' + query: | + mutation add_to_project($projectid:ID!,$contentid:ID!) { + addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { + projectNextItem { + id + } + } + } + projectid: ${{ env.PROJECT_ID }} + contentid: ${{ github.event.issue.node_id }} + env: + PROJECT_ID: "PN_kwDOAM0swc4AArk0" + GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/CHANGES.md b/CHANGES.md index 4d2394db1f..3e0c5ff4ae 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,55 @@ +Changes in Element v1.4.0 (2022-02-09) +====================================== + +Features ✨ +---------- + - Initial implementation of thread messages ([#4746](https://github.com/vector-im/element-android/issues/4746)) + - Support message bubbles in timeline. ([#4937](https://github.com/vector-im/element-android/issues/4937)) + - Support generic location pin ([#5146](https://github.com/vector-im/element-android/issues/5146)) + - Retrieve map style url from .well-known ([#5175](https://github.com/vector-im/element-android/issues/5175)) + +Bugfixes 🐛 +---------- + - Fixes non sans-serif font weights being ignored ([#3907](https://github.com/vector-im/element-android/issues/3907)) + - Fixing missing/intermittent notifications on the google play variant when wifi is enabled ([#5038](https://github.com/vector-im/element-android/issues/5038)) + - Fixes call statuses in the timeline for missed/rejected calls and connected calls. ([#5088](https://github.com/vector-im/element-android/issues/5088)) + - Fix fallback permalink when threads are disabled ([#5128](https://github.com/vector-im/element-android/issues/5128)) + - Analytics: aligns use case identifying with iOS implementation ([#5142](https://github.com/vector-im/element-android/issues/5142)) + - Fix location rendering in timeline if map cannot be loaded ([#5143](https://github.com/vector-im/element-android/issues/5143)) + +Other changes +------------- + - "Invite users to space" dialog now closed when user choose invite method ([#4295](https://github.com/vector-im/element-android/issues/4295)) + - Changed layout for space card and room card used at "explore room" screen and space/room invite dialogs ([#4304](https://github.com/vector-im/element-android/issues/4304)) + - Removed spaces restricted search hint dialogs ([#4315](https://github.com/vector-im/element-android/issues/4315)) + - Remove Search from room options if not available ([#4641](https://github.com/vector-im/element-android/issues/4641)) + - Qr code scanning fragments merged into one ([#4873](https://github.com/vector-im/element-android/issues/4873)) + - Fix CI/CD errors after merges for quality and integration tests ([#5118](https://github.com/vector-im/element-android/issues/5118)) + - Added automation for the Z-FTUE label to add issues to the FTUE Project Board ([#5120](https://github.com/vector-im/element-android/issues/5120)) + - Added automation for WTF labels to move to WTF project board ([#5148](https://github.com/vector-im/element-android/issues/5148)) + - Update WTF automation to fix it ([#5173](https://github.com/vector-im/element-android/issues/5173)) + + +Changes in Element v1.3.18 (2022-02-03) +======================================= + +Bugfixes 🐛 +---------- + - Avoid deleting root event of CurrentState on gappy sync. In order to restore lost Events an initial sync may be triggered. ([#5137](https://github.com/vector-im/element-android/issues/5137)) + + +Changes in Element v1.3.17 (2022-01-31) +======================================= + +Bugfixes 🐛 +---------- + - Display static map images in the timeline and improve Location sharing feature ([#5084](https://github.com/vector-im/element-android/issues/5084)) + - Show the legal mention of mapbox when sharing location ([#5062](https://github.com/vector-im/element-android/issues/5062)) + - Poll cannot end in some unencrypted rooms ([#5067](https://github.com/vector-im/element-android/issues/5067)) + - Selecting Transfer in a call should immediately put the other person on hold until the call connects or the Transfer is cancelled. ([#5081](https://github.com/vector-im/element-android/issues/5081)) + - Fixing crashes when quickly scrolling or restoring the room timeline ([#5091](https://github.com/vector-im/element-android/issues/5091)) + + Changes in Element 1.3.16 (2022-01-25) ====================================== diff --git a/README.md b/README.md index dedc9da2dd..d784841e2c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ It is a total rewrite of [Riot-Android](https://github.com/vector-im/riot-androi [Get it on Google Play](https://play.google.com/store/apps/details?id=im.vector.app) [Get it on F-Droid](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 build: [![Buildkite](https://badge.buildkite.com/ad0065c1b70f557cd3b1d3d68f9c2154010f83c4d6f71706a9.svg?branch=develop)](https://buildkite.com/matrix-dot-org/element-android/builds?branch=develop) Nighly sanity test status: [![allScreensTest](https://github.com/vector-im/element-android/actions/workflows/sanity_test.yml/badge.svg)](https://github.com/vector-im/element-android/actions/workflows/sanity_test.yml) + # New Android SDK diff --git a/build.gradle b/build.gradle index 5fdeba24de..2ac948f0a9 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,12 @@ allprojects { apply plugin: "org.jlleitschuh.gradle.ktlint" repositories { + mavenCentral { + content { + groups.mavenCentral.regex.each { includeGroupByRegex it } + groups.mavenCentral.group.each { includeGroup it } + } + } maven { url 'https://jitpack.io' content { @@ -59,12 +65,6 @@ allprojects { groups.google.group.each { includeGroup it } } } - mavenCentral { - content { - groups.mavenCentral.regex.each { includeGroupByRegex it } - groups.mavenCentral.group.each { includeGroup it } - } - } //noinspection JcenterRepositoryObsolete jcenter { content { @@ -144,6 +144,11 @@ project(":diff-match-patch") { } } +// Global configurations across all modules +ext { + isThreadingEnabled = true +} + //project(":matrix-sdk-android") { // sonarqube { // properties { diff --git a/changelog.d/4640.bugfix b/changelog.d/4640.bugfix new file mode 100644 index 0000000000..f5fa5a5bde --- /dev/null +++ b/changelog.d/4640.bugfix @@ -0,0 +1 @@ +Right align the notifications badge in the rooms list (and DMs) so that it's always in a consistent place on the screen. \ No newline at end of file diff --git a/changelog.d/5067.bugfix b/changelog.d/5067.bugfix deleted file mode 100644 index 7ad88b608d..0000000000 --- a/changelog.d/5067.bugfix +++ /dev/null @@ -1 +0,0 @@ -Poll cannot end in some unencrypted rooms \ No newline at end of file diff --git a/changelog.d/5178.bugfix b/changelog.d/5178.bugfix new file mode 100644 index 0000000000..73021a0485 --- /dev/null +++ b/changelog.d/5178.bugfix @@ -0,0 +1 @@ +Remove redundant highlight on add poll option button \ No newline at end of file diff --git a/changelog.d/5183.sdk b/changelog.d/5183.sdk new file mode 100644 index 0000000000..66d2c3793d --- /dev/null +++ b/changelog.d/5183.sdk @@ -0,0 +1 @@ +`join` and `leave` methods moved from MembershipService to RoomService and SpaceService to split logic for rooms and spaces \ No newline at end of file diff --git a/changelog.d/5195.bugfix b/changelog.d/5195.bugfix new file mode 100644 index 0000000000..50d47e089e --- /dev/null +++ b/changelog.d/5195.bugfix @@ -0,0 +1 @@ +Reliably display crash report prompt diff --git a/changelog.d/5198.buxfix b/changelog.d/5198.buxfix new file mode 100644 index 0000000000..3fce6906d5 --- /dev/null +++ b/changelog.d/5198.buxfix @@ -0,0 +1 @@ +Fix for rooms with virtual rooms not showing call status events in the timeline. \ No newline at end of file diff --git a/changelog.d/5201.bugfix b/changelog.d/5201.bugfix new file mode 100644 index 0000000000..f77ddcce84 --- /dev/null +++ b/changelog.d/5201.bugfix @@ -0,0 +1 @@ +Fix for call transfer with consult failing to make outgoing consultation call. \ No newline at end of file diff --git a/changelog.d/5204.feature b/changelog.d/5204.feature new file mode 100644 index 0000000000..a107342de7 --- /dev/null +++ b/changelog.d/5204.feature @@ -0,0 +1 @@ +Improve UI of reactions in timeline, including quick add reaction. \ No newline at end of file diff --git a/changelog.d/5207.sdk b/changelog.d/5207.sdk new file mode 100644 index 0000000000..3ba3e06fb7 --- /dev/null +++ b/changelog.d/5207.sdk @@ -0,0 +1 @@ +Adds support for MSC3283, additional homeserver capabilities \ No newline at end of file diff --git a/changelog.d/5209.misc b/changelog.d/5209.misc new file mode 100644 index 0000000000..a238da9d22 --- /dev/null +++ b/changelog.d/5209.misc @@ -0,0 +1 @@ +Reduce verbosity of debug logging, diff --git a/changelog.d/5210.misc b/changelog.d/5210.misc new file mode 100644 index 0000000000..0b68e8b23a --- /dev/null +++ b/changelog.d/5210.misc @@ -0,0 +1 @@ +Standardise emulator versions of GHA integration tests. diff --git a/docs/ui-tests.md b/docs/ui-tests.md index 6ebb52abe8..05eb50f525 100644 --- a/docs/ui-tests.md +++ b/docs/ui-tests.md @@ -104,3 +104,76 @@ fun initAccount() { existingSession = createAccountAndSync(matrix, userName, password, true) } ``` + +### Contributing to the UiAllScreensSanityTest + +The `UiAllScreensSanityTest` makes use of the Robot pattern in order to model pages, components and interactions. +Each Robot aims to return the UI back to its original state after the interaction, allowing for a reusable and consistent DSL. + +```kotlin +// launches and closes settings after executing the block +elementRobot.settings { + // whilst in the settings, launches and closes the advanced settings sub screen + advancedSettings { + // crawls all the pages within the advanced settings + crawl() + } +} + +// enables developer mode by navigating to the settings, enabling the toggle and then returning to the starting point to execute the block +// on block completion the Robot disables developer mode by navigating back to the settings and finally returning to the original starting point +elementRobot.withDeveloperMode { + // the same starting point as the example above + settings { + advancedSettings { crawlDeveloperOptions() } + } +} +``` + +The Robots used in the example above... + +```kotlin +class ElementRobot { + fun settings(block: SettingsRobot.() -> Unit) { + // double check we're where we think we are + waitUntilViewVisible(withId(R.id.bottomNavigationView)) + + // navigate to the settings + openDrawer() + clickOn(R.id.homeDrawerHeaderSettingsView) + + // execute the robot with the context of the settings screen + block(SettingsRobot()) + + // close the settings and ensure we're back at the starting point + pressBack() + waitUntilViewVisible(withId(R.id.bottomNavigationView)) + } + + fun withDeveloperMode(block: ElementRobot.() -> Unit) { + settings { toggleDeveloperMode() } + block() + settings { toggleDeveloperMode() } + } +} + +class SettingsRobot { + fun toggleDeveloperMode() { + advancedSettings { + toggleDeveloperMode() + } + } + + fun advancedSettings(block: SettingsAdvancedRobot.() -> Unit) { + clickOn(R.string.settings_advanced_settings) + block(SettingsAdvancedRobot()) + pressBack() + } +} + +class SettingsAdvancedRobot { + fun toggleDeveloperMode() { + clickOn(R.string.settings_developer_mode_summary) + } +} +``` \ No newline at end of file diff --git a/fastlane/metadata/android/cs-CZ/changelogs/40103130.txt b/fastlane/metadata/android/cs-CZ/changelogs/40103130.txt new file mode 100644 index 0000000000..dab96ddd72 --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/changelogs/40103130.txt @@ -0,0 +1,2 @@ +Hlavní změny v této verzi: Změna na úvodních obrazovkách, včetně přihlášení do služby Analytics. V experimentálních funkcích byla přidána podpora pro události s matematikou. +Úplný seznam změn: https://github.com/vector-im/element-android/releases/tag/v1.3.13 diff --git a/fastlane/metadata/android/cs-CZ/changelogs/40103140.txt b/fastlane/metadata/android/cs-CZ/changelogs/40103140.txt new file mode 100644 index 0000000000..8d07600bc1 --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/changelogs/40103140.txt @@ -0,0 +1,2 @@ +Hlavní změny v této verzi: Změna na úvodních obrazovkách, včetně přihlášení do služby Analytics. V experimentálních funkcích byla přidána podpora pro události s matematikou. +Úplný seznam změn: https://github.com/vector-im/element-android/releases/tag/v1.3.14 diff --git a/fastlane/metadata/android/cs-CZ/changelogs/40103150.txt b/fastlane/metadata/android/cs-CZ/changelogs/40103150.txt new file mode 100644 index 0000000000..260011b49d --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/changelogs/40103150.txt @@ -0,0 +1,2 @@ +Hlavní změny v této verzi: Změna na úvodních obrazovkách, včetně přihlášení do služby Analytics. V experimentálních funkcích byla přidána podpora pro události s matematikou. +Úplný seznam změn: https://github.com/vector-im/element-android/releases/tag/v1.3.15 diff --git a/fastlane/metadata/android/cs-CZ/changelogs/40103160.txt b/fastlane/metadata/android/cs-CZ/changelogs/40103160.txt new file mode 100644 index 0000000000..3701cfe4ac --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/changelogs/40103160.txt @@ -0,0 +1,2 @@ +Hlavní změny v této verzi: Odeslání vlastní polohy do libovolné místnosti. Možnost úpravy hlasování. +Úplný seznam změn: https://github.com/vector-im/element-android/releases/tag/v1.3.16 diff --git a/fastlane/metadata/android/de-DE/changelogs/40103130.txt b/fastlane/metadata/android/de-DE/changelogs/40103130.txt new file mode 100644 index 0000000000..82aaadd5f3 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/40103130.txt @@ -0,0 +1,2 @@ +Hauptänderungen: Neues Onboarding, Unterstützung für Mathematische Ausdrücke in Labs +Änderungsliste: https://github.com/vector-im/element-android/releases/tag/v1.3.13 diff --git a/fastlane/metadata/android/de-DE/changelogs/40103140.txt b/fastlane/metadata/android/de-DE/changelogs/40103140.txt new file mode 100644 index 0000000000..6032784f64 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/40103140.txt @@ -0,0 +1,2 @@ +Hauptänderungen: Neues Onboarding, Unterstützung für Mathematische Ausdrücke in Labs +Änderungsliste: https://github.com/vector-im/element-android/releases/tag/v1.3.14 diff --git a/fastlane/metadata/android/de-DE/changelogs/40103150.txt b/fastlane/metadata/android/de-DE/changelogs/40103150.txt new file mode 100644 index 0000000000..6e324d4ef5 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/40103150.txt @@ -0,0 +1,2 @@ +Hauptänderungen: Neues Onboarding, Unterstützung für Mathematische Ausdrücke in Labs +Änderungsliste: https://github.com/vector-im/element-android/releases/tag/v1.3.15 diff --git a/fastlane/metadata/android/de-DE/changelogs/40103160.txt b/fastlane/metadata/android/de-DE/changelogs/40103160.txt new file mode 100644 index 0000000000..78fac9a7c2 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/40103160.txt @@ -0,0 +1,2 @@ +Hauptänderungen: Du kannst ab sofort deinen Standort an deine Räume senden und Abstimmungen bearbeiten. +Alle Änderungen: https://github.com/vector-im/element-android/releases/tag/v1.3.16 diff --git a/fastlane/metadata/android/en-US/changelogs/40103170.txt b/fastlane/metadata/android/en-US/changelogs/40103170.txt new file mode 100644 index 0000000000..2d0062bb23 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40103170.txt @@ -0,0 +1,2 @@ +Main changes in this version: send your location to any room. Edit poll. +Full changelog: https://github.com/vector-im/element-android/releases/tag/v1.3.17 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/40103180.txt b/fastlane/metadata/android/en-US/changelogs/40103180.txt new file mode 100644 index 0000000000..66e51f422a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40103180.txt @@ -0,0 +1,2 @@ +Main changes in this version: send your location to any room. Edit poll. +Full changelog: https://github.com/vector-im/element-android/releases/tag/v1.3.18 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/40104000.txt b/fastlane/metadata/android/en-US/changelogs/40104000.txt new file mode 100644 index 0000000000..e102edbaad --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40104000.txt @@ -0,0 +1,2 @@ +Main changes in this version: Initial implementation of thread messages. Message bubbles. +Full changelog: https://github.com/vector-im/element-android/releases/tag/v1.4.0 \ No newline at end of file diff --git a/fastlane/metadata/android/et/changelogs/40103130.txt b/fastlane/metadata/android/et/changelogs/40103130.txt new file mode 100644 index 0000000000..b2c8054559 --- /dev/null +++ b/fastlane/metadata/android/et/changelogs/40103130.txt @@ -0,0 +1,2 @@ +Põhilised muutused selles versioonis: liitumisvaate täiendused, võimalus saata meile analüütikat. Katsete alla on lisandunud üritused ning matemaatiliste valemite kirjutamise võimalus. +Kogu ingliskeelne muudatuste logi: https://github.com/vector-im/element-android/releases/tag/v1.3.13 diff --git a/fastlane/metadata/android/et/changelogs/40103140.txt b/fastlane/metadata/android/et/changelogs/40103140.txt new file mode 100644 index 0000000000..14c34169d0 --- /dev/null +++ b/fastlane/metadata/android/et/changelogs/40103140.txt @@ -0,0 +1,2 @@ +Põhilised muutused selles versioonis: liitumisvaate täiendused, võimalus saata meile analüütikat. Katsete alla on lisandunud üritused ning matemaatiliste valemite kirjutamise võimalus. +Kogu ingliskeelne muudatuste logi: https://github.com/vector-im/element-android/releases/tag/v1.3.14 diff --git a/fastlane/metadata/android/et/changelogs/40103150.txt b/fastlane/metadata/android/et/changelogs/40103150.txt new file mode 100644 index 0000000000..ce86924106 --- /dev/null +++ b/fastlane/metadata/android/et/changelogs/40103150.txt @@ -0,0 +1,2 @@ +Põhilised muutused selles versioonis: liitumisvaate täiendused, võimalus saata meile analüütikat. Katsete alla on lisandunud üritused ning matemaatiliste valemite kirjutamise võimalus. +Kogu ingliskeelne muudatuste logi: https://github.com/vector-im/element-android/releases/tag/v1.3.15 diff --git a/fastlane/metadata/android/et/changelogs/40103160.txt b/fastlane/metadata/android/et/changelogs/40103160.txt new file mode 100644 index 0000000000..76f1abbeca --- /dev/null +++ b/fastlane/metadata/android/et/changelogs/40103160.txt @@ -0,0 +1,2 @@ +Põhilised muutused selles versioonis: oma asukoha saatmine jututuppa ja küsitluste muutmise võimalus. +Kogu ingliskeelne muudatuste logi: https://github.com/vector-im/element-android/releases/tag/v1.3.16 diff --git a/fastlane/metadata/android/fa/changelogs/40103130.txt b/fastlane/metadata/android/fa/changelogs/40103130.txt new file mode 100644 index 0000000000..9a67eebd44 --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/40103130.txt @@ -0,0 +1,2 @@ +تغییرات عمده در این نگارشنخستین تغییر در صفحه‌های راه‌اندازی شامل وارد شدن به تجزیه‌ها. پشتیبانی از رویدادهایی با ریاضیات افزوده در آزمایشگاه‌ها. +گزارش دگرگونی کامل: https://github.com/vector-im/element-android/releases/tag/v1.3.13 diff --git a/fastlane/metadata/android/fa/changelogs/40103140.txt b/fastlane/metadata/android/fa/changelogs/40103140.txt new file mode 100644 index 0000000000..e4b01ecd11 --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/40103140.txt @@ -0,0 +1,2 @@ +تغییرات عمده در این نگارشنخستین تغییر در صفحه‌های راه‌اندازی شامل وارد شدن به تجزیه‌ها. پشتیبانی از رویدادهایی با ریاضیات افزوده در آزمایشگاه‌ها. +گزارش دگرگونی کامل: https://github.com/vector-im/element-android/releases/tag/v1.3.14 diff --git a/fastlane/metadata/android/fa/changelogs/40103150.txt b/fastlane/metadata/android/fa/changelogs/40103150.txt new file mode 100644 index 0000000000..e10ddf156c --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/40103150.txt @@ -0,0 +1,2 @@ +تغییرات عمده در این نگارشنخستین تغییر در صفحه‌های راه‌اندازی شامل وارد شدن به تجزیه‌ها. پشتیبانی از رویدادهایی با ریاضیات افزوده در آزمایشگاه‌ها. +گزارش دگرگونی کامل: https://github.com/vector-im/element-android/releases/tag/v1.3.15 diff --git a/fastlane/metadata/android/fa/changelogs/40103160.txt b/fastlane/metadata/android/fa/changelogs/40103160.txt new file mode 100644 index 0000000000..0699f4536d --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/40103160.txt @@ -0,0 +1,2 @@ +تغییرات عمده در این نگارش: فرستادن مکانتان به هر اتاقی. ویرایش نظرسنجی. +گزارش دگرگونی کامل: https://github.com/vector-im/element-android/releases/tag/v1.3.16 diff --git a/fastlane/metadata/android/fr-FR/changelogs/40103130.txt b/fastlane/metadata/android/fr-FR/changelogs/40103130.txt new file mode 100644 index 0000000000..ba950fe819 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/40103130.txt @@ -0,0 +1,2 @@ +Principaux changements pour cette version : Premier changement dans l’écran de bienvenue, y compris l’adhésion aux données d’analyses. Support des événements avec opération mathématiques ajoutées dans les labs. +Intégralité des changements : https://github.com/vector-im/element-android/releases/tag/v1.3.13 diff --git a/fastlane/metadata/android/fr-FR/changelogs/40103140.txt b/fastlane/metadata/android/fr-FR/changelogs/40103140.txt new file mode 100644 index 0000000000..24779bc59e --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/40103140.txt @@ -0,0 +1,2 @@ +Principaux changements pour cette version : Premier changement dans l’écran de bienvenue, y compris l’adhésion aux données d’analyses. Support des événements avec opération mathématiques ajoutées dans les labs. +Intégralité des changements : https://github.com/vector-im/element-android/releases/tag/v1.3.14 diff --git a/fastlane/metadata/android/fr-FR/changelogs/40103150.txt b/fastlane/metadata/android/fr-FR/changelogs/40103150.txt new file mode 100644 index 0000000000..32fe3f6593 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/40103150.txt @@ -0,0 +1,2 @@ +Principaux changements pour cette version : Premier changement dans l’écran de bienvenue, y compris l’adhésion aux données d’analyses. Support des événements avec opération mathématiques ajoutées dans les labs. +Intégralité des changements : https://github.com/vector-im/element-android/releases/tag/v1.3.15 diff --git a/fastlane/metadata/android/fr-FR/changelogs/40103160.txt b/fastlane/metadata/android/fr-FR/changelogs/40103160.txt new file mode 100644 index 0000000000..a7437a1f10 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/40103160.txt @@ -0,0 +1,2 @@ +Principaux changements pour cette version : envoi de votre position dans n’importe quelle salon. Édition des sondage. +Intégralité des changements : https://github.com/vector-im/element-android/releases/tag/v1.3.16 diff --git a/fastlane/metadata/android/hu-HU/changelogs/40103130.txt b/fastlane/metadata/android/hu-HU/changelogs/40103130.txt new file mode 100644 index 0000000000..4c39f156f9 --- /dev/null +++ b/fastlane/metadata/android/hu-HU/changelogs/40103130.txt @@ -0,0 +1,2 @@ +Fő változás ebben a verzióban: Első változások a bemutató képernyőn, beleértve az analitikai adatküldés engedélyezésének lehetőségét. Matematikai formulák támogatása a Laborok között. +Teljes változásnapló: https://github.com/vector-im/element-android/releases/tag/v1.3.13 diff --git a/fastlane/metadata/android/hu-HU/changelogs/40103140.txt b/fastlane/metadata/android/hu-HU/changelogs/40103140.txt new file mode 100644 index 0000000000..b53f857896 --- /dev/null +++ b/fastlane/metadata/android/hu-HU/changelogs/40103140.txt @@ -0,0 +1,2 @@ +Fő változás ebben a verzióban: Első változások a bemutató képernyőn, beleértve az analitikai adatküldés engedélyezésének lehetőségét. Matematikai formulák támogatása a Laborok között. +Teljes változásnapló: https://github.com/vector-im/element-android/releases/tag/v1.3.14 diff --git a/fastlane/metadata/android/hu-HU/changelogs/40103150.txt b/fastlane/metadata/android/hu-HU/changelogs/40103150.txt new file mode 100644 index 0000000000..abb4bf336a --- /dev/null +++ b/fastlane/metadata/android/hu-HU/changelogs/40103150.txt @@ -0,0 +1,2 @@ +Fő változás ebben a verzióban: Első változások a bemutató képernyőn, beleértve az analitikai adatküldés engedélyezésének lehetőségét. Matematikai formulák támogatása a Laborok között. +Teljes változásnapló: https://github.com/vector-im/element-android/releases/tag/v1.3.15 diff --git a/fastlane/metadata/android/hu-HU/changelogs/40103160.txt b/fastlane/metadata/android/hu-HU/changelogs/40103160.txt new file mode 100644 index 0000000000..204a224222 --- /dev/null +++ b/fastlane/metadata/android/hu-HU/changelogs/40103160.txt @@ -0,0 +1,2 @@ +Fő változás ebben a verzióban: földrajzi helyzet küldése bármely szobába. Szavazás szerkesztése +Teljes változásnapló: https://github.com/vector-im/element-android/releases/tag/v1.3.16 diff --git a/fastlane/metadata/android/id/changelogs/40103130.txt b/fastlane/metadata/android/id/changelogs/40103130.txt new file mode 100644 index 0000000000..de10de53d5 --- /dev/null +++ b/fastlane/metadata/android/id/changelogs/40103130.txt @@ -0,0 +1,2 @@ +Perubahan utama dalam versi ini: Perubahan pertama di layar permulaan, termasuk analitik opt-in. Dukungan untuk Peristiwa dengan Matematika ditambahkan di Uji Coba. +Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.13 diff --git a/fastlane/metadata/android/id/changelogs/40103140.txt b/fastlane/metadata/android/id/changelogs/40103140.txt new file mode 100644 index 0000000000..dfefff307f --- /dev/null +++ b/fastlane/metadata/android/id/changelogs/40103140.txt @@ -0,0 +1,2 @@ +Perubahan utama dalam versi ini: Perubahan pertama di layar permulaan, termasuk analitik opt-in. Dukungan untuk Peristiwa dengan Matematika ditambahkan di Uji Coba. +Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.14 diff --git a/fastlane/metadata/android/id/changelogs/40103150.txt b/fastlane/metadata/android/id/changelogs/40103150.txt new file mode 100644 index 0000000000..c46e661d47 --- /dev/null +++ b/fastlane/metadata/android/id/changelogs/40103150.txt @@ -0,0 +1,2 @@ +Perubahan utama dalam versi ini: Perubahan pertama di layar permulaan, termasuk analitik opt-in. Dukungan untuk Peristiwa dengan Matematika ditambahkan di Uji Coba. +Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.15 diff --git a/fastlane/metadata/android/id/changelogs/40103160.txt b/fastlane/metadata/android/id/changelogs/40103160.txt new file mode 100644 index 0000000000..8a6e62be0c --- /dev/null +++ b/fastlane/metadata/android/id/changelogs/40103160.txt @@ -0,0 +1,2 @@ +Perubahan utama dalam versi ini: Kirim lokasi Anda ke ruangan apa saja. Edit poll. +Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.16 diff --git a/fastlane/metadata/android/it-IT/changelogs/40103130.txt b/fastlane/metadata/android/it-IT/changelogs/40103130.txt new file mode 100644 index 0000000000..d113b7ac66 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/40103130.txt @@ -0,0 +1,2 @@ +Modifiche principali in questa versione: prima modifica nelle schermate onboarding, incluso l'opt-in di Analytics. Supporto agli eventi con Math aggiunto nei laboratori. +Cronologia completa: https://github.com/vector-im/element-android/releases/tag/v1.3.13 diff --git a/fastlane/metadata/android/it-IT/changelogs/40103140.txt b/fastlane/metadata/android/it-IT/changelogs/40103140.txt new file mode 100644 index 0000000000..b875832368 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/40103140.txt @@ -0,0 +1,2 @@ +Modifiche principali in questa versione: prima modifica nelle schermate onboarding, incluso l'opt-in di Analytics. Supporto agli eventi con Math aggiunto nei laboratori. +Cronologia completa: https://github.com/vector-im/element-android/releases/tag/v1.3.14 diff --git a/fastlane/metadata/android/it-IT/changelogs/40103150.txt b/fastlane/metadata/android/it-IT/changelogs/40103150.txt new file mode 100644 index 0000000000..0e7586be19 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/40103150.txt @@ -0,0 +1,2 @@ +Modifiche principali in questa versione: prima modifica nelle schermate onboarding, incluso l'opt-in di Analytics. Supporto agli eventi con Math aggiunto nei laboratori. +Cronologia completa: https://github.com/vector-im/element-android/releases/tag/v1.3.15 diff --git a/fastlane/metadata/android/it-IT/changelogs/40103160.txt b/fastlane/metadata/android/it-IT/changelogs/40103160.txt new file mode 100644 index 0000000000..fae2b8f1f9 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/40103160.txt @@ -0,0 +1,2 @@ +Modifiche principali in questa versione: invia la tua posizione in qualsiasi stanza. Modifica sondaggi. +Cronologia completa: https://github.com/vector-im/element-android/releases/tag/v1.3.16 diff --git a/fastlane/metadata/android/pt-BR/changelogs/40103130.txt b/fastlane/metadata/android/pt-BR/changelogs/40103130.txt new file mode 100644 index 0000000000..6f4975d01c --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/40103130.txt @@ -0,0 +1,2 @@ +Principais mudanças nesta versão: Primeira mudança em telas de onboarding, incluindo opt-in de Analítica. Suporte para Eventos com Matemática adicionado nos labs. +Changelog completo: https://github.com/vector-im/element-android/releases/tag/v1.3.13 diff --git a/fastlane/metadata/android/pt-BR/changelogs/40103140.txt b/fastlane/metadata/android/pt-BR/changelogs/40103140.txt new file mode 100644 index 0000000000..ab7470b96b --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/40103140.txt @@ -0,0 +1,2 @@ +Principais mudanças nesta versão: Primeira mudança em telas de onboarding, incluindo opt-in de Analítica. Suporte para Eventos com Matemática adicionado nos labs. +Changelog completo: https://github.com/vector-im/element-android/releases/tag/v1.3.14 diff --git a/fastlane/metadata/android/pt-BR/changelogs/40103150.txt b/fastlane/metadata/android/pt-BR/changelogs/40103150.txt new file mode 100644 index 0000000000..40439e3f94 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/40103150.txt @@ -0,0 +1,2 @@ +Principais mudanças nesta versão: Primeira mudança em telas de onboarding, incluindo opt-in de Analítica. Suporte para Eventos com Matemática adicionado nos labs. +Changelog completo: https://github.com/vector-im/element-android/releases/tag/v1.3.15 diff --git a/fastlane/metadata/android/pt-BR/changelogs/40103160.txt b/fastlane/metadata/android/pt-BR/changelogs/40103160.txt new file mode 100644 index 0000000000..59ade40e9e --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/40103160.txt @@ -0,0 +1,2 @@ +Principais mudanças nesta versão: envie sua localização para qualquer sala. Editar sondagem. +Changelog completo: https://github.com/vector-im/element-android/releases/tag/v1.3.16 diff --git a/fastlane/metadata/android/ru-RU/changelogs/40103160.txt b/fastlane/metadata/android/ru-RU/changelogs/40103160.txt new file mode 100644 index 0000000000..fb8e01c009 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40103160.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: отправьте свое местоположение в любую комнату. Редактирование опроса. +Полный список изменений: https://github.com/vector-im/element-android/releases/tag/v1.3.16 diff --git a/fastlane/metadata/android/sk/changelogs/40103130.txt b/fastlane/metadata/android/sk/changelogs/40103130.txt new file mode 100644 index 0000000000..31f6cf31db --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/40103130.txt @@ -0,0 +1,2 @@ +Hlavné zmeny v tejto verzii: Prvá zmena v obrazovkách pri vstupe do systému vrátane prihlásenia do služby Analytics. Pridanie podpory pre udalosti s matematikou v laboratóriách. +Úplný zoznam zmien: https://github.com/vector-im/element-android/releases/tag/v1.3.13 diff --git a/fastlane/metadata/android/sk/changelogs/40103140.txt b/fastlane/metadata/android/sk/changelogs/40103140.txt new file mode 100644 index 0000000000..8e21829163 --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/40103140.txt @@ -0,0 +1,2 @@ +Hlavné zmeny v tejto verzii: Prvá zmena v obrazovkách pri vstupe do systému vrátane prihlásenia do služby Analytics. Pridanie podpory pre udalosti s matematikou v laboratóriách. +Úplný zoznam zmien: https://github.com/vector-im/element-android/releases/tag/v1.3.14 diff --git a/fastlane/metadata/android/sk/changelogs/40103150.txt b/fastlane/metadata/android/sk/changelogs/40103150.txt new file mode 100644 index 0000000000..7016270d76 --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/40103150.txt @@ -0,0 +1,2 @@ +Hlavné zmeny v tejto verzii: Prvá zmena v obrazovkách pri vstupe do systému vrátane prihlásenia do služby Analytics. Pridanie podpory pre udalosti s matematikou v laboratóriách. +Úplný zoznam zmien: https://github.com/vector-im/element-android/releases/tag/v1.3.15 diff --git a/fastlane/metadata/android/sk/changelogs/40103160.txt b/fastlane/metadata/android/sk/changelogs/40103160.txt new file mode 100644 index 0000000000..9d2ad76715 --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/40103160.txt @@ -0,0 +1,2 @@ +Hlavné zmeny v tejto verzii: odoslanie polohy do ľubovoľnej miestnosti. Úprava ankety. +Úplný zoznam zmien: https://github.com/vector-im/element-android/releases/tag/v1.3.16 diff --git a/fastlane/metadata/android/sq/changelogs/40103140.txt b/fastlane/metadata/android/sq/changelogs/40103140.txt new file mode 100644 index 0000000000..b8231bdf78 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40103140.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Ndryshimi i parë në skenat e mirëseardhjes, përfshi zgjedhje për pjesëmarrje në Analiza. Në laboratorë u shtua mbulim për Akte me Formula Matematikore. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases/tag/v1.3.14 diff --git a/fastlane/metadata/android/sq/changelogs/40103150.txt b/fastlane/metadata/android/sq/changelogs/40103150.txt new file mode 100644 index 0000000000..510fbab96e --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40103150.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Ndryshimi i parë në skenat e mirëseardhjes, përfshi zgjedhje për pjesëmarrje në Analiza. Në laboratorë u shtua mbulim për Akte me Formula Matematikore. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases/tag/v1.3.15 diff --git a/fastlane/metadata/android/sq/changelogs/40103160.txt b/fastlane/metadata/android/sq/changelogs/40103160.txt new file mode 100644 index 0000000000..1a0dd3bf5d --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40103160.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: dërgojeni vendndodhjen tuaj te cilado dhomë. Përpunoni pyetësor. +Regjistër i plotës ndryshimesh: https://github.com/vector-im/element-android/releases/tag/v1.3.16 diff --git a/fastlane/metadata/android/sv-SE/changelogs/40103130.txt b/fastlane/metadata/android/sv-SE/changelogs/40103130.txt new file mode 100644 index 0000000000..6c9bd42f34 --- /dev/null +++ b/fastlane/metadata/android/sv-SE/changelogs/40103130.txt @@ -0,0 +1,2 @@ +Huvudsakliga ändringar i den här versionen: Första ändringen på introduktionsskärmar, inklusive opt-in för statistik. Stöd för händelser med matte tillagd i experiment. +Full ändringslogg: https://github.com/vector-im/element-android/releases/tag/v1.3.13 diff --git a/fastlane/metadata/android/sv-SE/changelogs/40103140.txt b/fastlane/metadata/android/sv-SE/changelogs/40103140.txt new file mode 100644 index 0000000000..a5209920d6 --- /dev/null +++ b/fastlane/metadata/android/sv-SE/changelogs/40103140.txt @@ -0,0 +1,2 @@ +Huvudsakliga ändringar i den här versionen: Första ändringen på introduktionsskärmar, inklusive opt-in för statistik. Stöd för händelser med matte tillagd i experiment. +Full ändringslogg: https://github.com/vector-im/element-android/releases/tag/v1.3.14 diff --git a/fastlane/metadata/android/sv-SE/changelogs/40103150.txt b/fastlane/metadata/android/sv-SE/changelogs/40103150.txt new file mode 100644 index 0000000000..0fac22f0ab --- /dev/null +++ b/fastlane/metadata/android/sv-SE/changelogs/40103150.txt @@ -0,0 +1,2 @@ +Huvudsakliga ändringar i den här versionen: Första ändringen på introduktionsskärmar, inklusive opt-in för statistik. Stöd för händelser med matte tillagd i experiment. +Full ändringslogg: https://github.com/vector-im/element-android/releases/tag/v1.3.15 diff --git a/fastlane/metadata/android/sv-SE/changelogs/40103160.txt b/fastlane/metadata/android/sv-SE/changelogs/40103160.txt new file mode 100644 index 0000000000..0f324c9047 --- /dev/null +++ b/fastlane/metadata/android/sv-SE/changelogs/40103160.txt @@ -0,0 +1,2 @@ +Huvudsakliga ändringar i den här versionen: skicka din plats till vilket rum som helst. Redigera omröstningar. +Full ändringslogg: https://github.com/vector-im/element-android/releases/tag/v1.3.16 diff --git a/fastlane/metadata/android/uk/changelogs/40103130.txt b/fastlane/metadata/android/uk/changelogs/40103130.txt new file mode 100644 index 0000000000..ad0bcda6e6 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/40103130.txt @@ -0,0 +1,2 @@ +Основні зміни у цій версії: перша зміна на екрані привітання, включно з увімкненням аналітики. У лабораторії додано підтримку подій з математичними формулами. +Вичерпний перелік змін: https://github.com/vector-im/element-android/releases/tag/v1.3.13 diff --git a/fastlane/metadata/android/uk/changelogs/40103140.txt b/fastlane/metadata/android/uk/changelogs/40103140.txt new file mode 100644 index 0000000000..355f8add3e --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/40103140.txt @@ -0,0 +1,2 @@ +Основні зміни у цій версії: перша зміна на екрані привітання, включно з увімкненням аналітики. У лабораторії додано підтримку подій з математичними формулами. +Вичерпний перелік змін: https://github.com/vector-im/element-android/releases/tag/v1.3.14 diff --git a/fastlane/metadata/android/uk/changelogs/40103150.txt b/fastlane/metadata/android/uk/changelogs/40103150.txt new file mode 100644 index 0000000000..0d9d702f02 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/40103150.txt @@ -0,0 +1,2 @@ +Основні зміни у цій версії: перша зміна на екрані привітання, включно з увімкненням аналітики. У лабораторії додано підтримку подій з математичними формулами. +Вичерпний перелік змін: https://github.com/vector-im/element-android/releases/tag/v1.3.15 diff --git a/fastlane/metadata/android/uk/changelogs/40103160.txt b/fastlane/metadata/android/uk/changelogs/40103160.txt new file mode 100644 index 0000000000..db0f1e4c62 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/40103160.txt @@ -0,0 +1,2 @@ +Основні зміни у цій версії: надсилання свого місцеперебування у будь-яку кімнату. Редагування опитувань. +Вичерпний перелік змін: https://github.com/vector-im/element-android/releases/tag/v1.3.16 diff --git a/fastlane/metadata/android/zh-TW/changelogs/40103130.txt b/fastlane/metadata/android/zh-TW/changelogs/40103130.txt new file mode 100644 index 0000000000..e0f9b47e16 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/40103130.txt @@ -0,0 +1,2 @@ +此版本中的主要變動:首次使用畫面的第一個變化,包含了選擇加入的分析功能。新增對數學活動的支援至實驗室中。 +完整的變更紀錄:https://github.com/vector-im/element-android/releases/tag/v1.3.13 diff --git a/fastlane/metadata/android/zh-TW/changelogs/40103140.txt b/fastlane/metadata/android/zh-TW/changelogs/40103140.txt new file mode 100644 index 0000000000..8366a01265 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/40103140.txt @@ -0,0 +1,2 @@ +此版本中的主要變動:首次使用畫面的第一個變化,包含了選擇加入的分析功能。新增對數學活動的支援至實驗室中。 +完整的變更紀錄:https://github.com/vector-im/element-android/releases/tag/v1.3.14 diff --git a/fastlane/metadata/android/zh-TW/changelogs/40103150.txt b/fastlane/metadata/android/zh-TW/changelogs/40103150.txt new file mode 100644 index 0000000000..a6ddd5aa8c --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/40103150.txt @@ -0,0 +1,2 @@ +此版本中的主要變動:首次使用畫面的第一個變化,包含了選擇加入的分析功能。新增對數學活動的支援至實驗室中。 +完整的變更紀錄:https://github.com/vector-im/element-android/releases/tag/v1.3.15 diff --git a/fastlane/metadata/android/zh-TW/changelogs/40103160.txt b/fastlane/metadata/android/zh-TW/changelogs/40103160.txt new file mode 100644 index 0000000000..04e51e013c --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/40103160.txt @@ -0,0 +1,2 @@ +此版本中的主要變動:將您的位置傳送給任何聊天室。編輯投票。 +完整的變更紀錄:https://github.com/vector-im/element-android/releases/tag/v1.3.16 diff --git a/gradle.properties b/gradle.properties index 5c99297107..6de52be607 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ # The setting is particularly useful for tweaking memory settings. # Build Time Optimizations -org.gradle.jvmargs=-Xmx3g -Xms512M -XX:MaxPermSize=2048m -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC +org.gradle.jvmargs=-Xmx4g -Xms512M -XX:MaxPermSize=2048m -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC org.gradle.configureondemand=true org.gradle.parallel=true org.gradle.vfs.watch=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 7454180f2a..41d9927a4d 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ee6ba9a3ac..dcf5e2cb7b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=c9490e938b221daf0094982288e4038deed954a3f12fb54cbf270ddf4e37d879 -distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip +distributionSha256Sum=cd5c2958a107ee7f0722004a12d0f8559b4564c34daad7df06cffd4d12a426d0 +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/library/ui-styles/src/main/res/anim/animation_slide_in_left.xml b/library/ui-styles/src/main/res/anim/animation_slide_in_left.xml new file mode 100644 index 0000000000..77861c99f6 --- /dev/null +++ b/library/ui-styles/src/main/res/anim/animation_slide_in_left.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/library/ui-styles/src/main/res/anim/animation_slide_in_right.xml b/library/ui-styles/src/main/res/anim/animation_slide_in_right.xml new file mode 100644 index 0000000000..cf7488cc1a --- /dev/null +++ b/library/ui-styles/src/main/res/anim/animation_slide_in_right.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/library/ui-styles/src/main/res/anim/animation_slide_out_left.xml b/library/ui-styles/src/main/res/anim/animation_slide_out_left.xml new file mode 100644 index 0000000000..2afa66ceab --- /dev/null +++ b/library/ui-styles/src/main/res/anim/animation_slide_out_left.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/library/ui-styles/src/main/res/anim/animation_slide_out_right.xml b/library/ui-styles/src/main/res/anim/animation_slide_out_right.xml new file mode 100644 index 0000000000..49348f1dac --- /dev/null +++ b/library/ui-styles/src/main/res/anim/animation_slide_out_right.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/vector/src/main/res/drawable/bg_voice_playback.xml b/library/ui-styles/src/main/res/drawable/bg_media_pill.xml similarity index 82% rename from vector/src/main/res/drawable/bg_voice_playback.xml rename to library/ui-styles/src/main/res/drawable/bg_media_pill.xml index 4474c00345..2ad9ca9918 100644 --- a/vector/src/main/res/drawable/bg_voice_playback.xml +++ b/library/ui-styles/src/main/res/drawable/bg_media_pill.xml @@ -2,9 +2,6 @@ - - - - + + - - + diff --git a/library/ui-styles/src/main/res/values-v23/dimens.xml b/library/ui-styles/src/main/res/values-v23/dimens.xml new file mode 100644 index 0000000000..18b8a81a7e --- /dev/null +++ b/library/ui-styles/src/main/res/values-v23/dimens.xml @@ -0,0 +1,4 @@ + + + 28dp + \ No newline at end of file diff --git a/library/ui-styles/src/main/res/values/colors.xml b/library/ui-styles/src/main/res/values/colors.xml index 9df2794a1a..48ac48a8ca 100644 --- a/library/ui-styles/src/main/res/values/colors.xml +++ b/library/ui-styles/src/main/res/values/colors.xml @@ -58,9 +58,9 @@ - #FF61708B - #FF61708B - #FF61708B + @color/palette_gray_200 + @color/palette_gray_250 + @color/palette_gray_250 @android:color/white @@ -137,4 +137,5 @@ @color/palette_gray_100 @color/palette_gray_450 + diff --git a/library/ui-styles/src/main/res/values/colors_message_bubble.xml b/library/ui-styles/src/main/res/values/colors_message_bubble.xml new file mode 100644 index 0000000000..7ac68574b6 --- /dev/null +++ b/library/ui-styles/src/main/res/values/colors_message_bubble.xml @@ -0,0 +1,11 @@ + + + + + + #E8EDF4 + #21262C + + #E7F8F3 + #133A34 + \ No newline at end of file diff --git a/library/ui-styles/src/main/res/values/dimens.xml b/library/ui-styles/src/main/res/values/dimens.xml index d184cd06df..be57f75dc8 100644 --- a/library/ui-styles/src/main/res/values/dimens.xml +++ b/library/ui-styles/src/main/res/values/dimens.xml @@ -15,6 +15,8 @@ 72dp 16dp + 32dp + 40dp 60dp @@ -42,12 +44,23 @@ 8dp + 160dp + + 24dp + 48dp + 48dp 56dp 52dp 1dp + + 28dp + 62dp + 300dp + 12dp + 0.05 0.95 diff --git a/library/ui-styles/src/main/res/values/stylable_message_bubble.xml b/library/ui-styles/src/main/res/values/stylable_message_bubble.xml new file mode 100644 index 0000000000..f7a877e3ed --- /dev/null +++ b/library/ui-styles/src/main/res/values/stylable_message_bubble.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/library/ui-styles/src/main/res/values/styles_progress.xml b/library/ui-styles/src/main/res/values/styles_progress.xml index 712e7e98b6..04a0e01b58 100644 --- a/library/ui-styles/src/main/res/values/styles_progress.xml +++ b/library/ui-styles/src/main/res/values/styles_progress.xml @@ -6,6 +6,7 @@ diff --git a/library/ui-styles/src/main/res/values/styles_timeline.xml b/library/ui-styles/src/main/res/values/styles_timeline.xml index 7fd7eac0ec..c86eeb8efb 100644 --- a/library/ui-styles/src/main/res/values/styles_timeline.xml +++ b/library/ui-styles/src/main/res/values/styles_timeline.xml @@ -4,12 +4,34 @@ + + + + + + + \ No newline at end of file diff --git a/library/ui-styles/src/main/res/values/theme_dark.xml b/library/ui-styles/src/main/res/values/theme_dark.xml index b828855721..b1d95c5439 100644 --- a/library/ui-styles/src/main/res/values/theme_dark.xml +++ b/library/ui-styles/src/main/res/values/theme_dark.xml @@ -31,6 +31,8 @@ @color/vctr_waiting_background_color_dark @color/vctr_chat_effect_snow_background_dark @color/element_system_dark + @color/vctr_message_bubble_inbound_dark + @color/vctr_message_bubble_outbound_dark #61708B @@ -105,9 +107,6 @@ never - - sans - @style/PreferenceThemeOverlay.v14.Material @style/PinCodeScreenStyle diff --git a/library/ui-styles/src/main/res/values/theme_light.xml b/library/ui-styles/src/main/res/values/theme_light.xml index 790a0bfc7c..dba39c97ca 100644 --- a/library/ui-styles/src/main/res/values/theme_light.xml +++ b/library/ui-styles/src/main/res/values/theme_light.xml @@ -31,6 +31,8 @@ @color/vctr_waiting_background_color_light @color/vctr_chat_effect_snow_background_light @color/element_background_light + @color/vctr_message_bubble_inbound_light + @color/vctr_message_bubble_outbound_light #61708B @@ -105,9 +107,6 @@ never - - sans - @style/PreferenceThemeOverlay.v14.Material @style/PinCodeScreenStyle 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 42c1476b79..826f584f6a 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 @@ -32,6 +32,8 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.toOptional +typealias ThreadRootEvent = TimelineEvent + class FlowRoom(private val room: Room) { fun liveRoomSummary(): Flow> { @@ -98,6 +100,20 @@ class FlowRoom(private val room: Room) { fun liveNotificationState(): Flow { return room.getLiveRoomNotificationState().asFlow() } + + fun liveThreadList(): Flow> { + return room.getAllThreadsLive().asFlow() + .startWith(room.coroutineDispatchers.io) { + room.getAllThreads() + } + } + + fun liveLocalUnreadThreadList(): Flow> { + return room.getMarkedThreadNotificationsLive().asFlow() + .startWith(room.coroutineDispatchers.io) { + room.getMarkedThreadNotifications() + } + } } fun Room.flow(): FlowRoom { diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index 57f40d1889..083c198720 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -31,13 +31,15 @@ android { // that the app's state is completely cleared between tests. testInstrumentationRunnerArguments clearPackageData: 'true' - buildConfigField "String", "SDK_VERSION", "\"1.3.18\"" + buildConfigField "String", "SDK_VERSION", "\"1.4.2\"" buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\"" resValue "string", "git_sdk_revision", "\"${gitRevision()}\"" resValue "string", "git_sdk_revision_unix_date", "\"${gitRevisionUnixDate()}\"" resValue "string", "git_sdk_revision_date", "\"${gitRevisionDate()}\"" + // Indicates whether or not threading support is enabled + buildConfigField "Boolean", "THREADING_ENABLED", "${isThreadingEnabled}" defaultConfig { consumerProguardFiles 'proguard-rules.pro' } @@ -139,6 +141,9 @@ dependencies { kapt 'dk.ilios:realmfieldnameshelper:2.0.0' + // Shared Preferences + implementation libs.androidx.preferenceKtx + // Work implementation libs.androidx.work @@ -164,7 +169,7 @@ dependencies { implementation libs.apache.commonsImaging // Phone number https://github.com/google/libphonenumber - implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.41' + implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.43' testImplementation libs.tests.junit testImplementation 'org.robolectric:robolectric:4.7.3' 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 3cb699378f..031d0a8bcf 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 @@ -157,14 +157,20 @@ class CommonTestHelper(context: Context) { /** * Will send nb of messages provided by count parameter but waits every 10 messages to avoid gap in sync */ - private fun sendTextMessagesBatched(timeline: Timeline, room: Room, message: String, count: Int, timeout: Long): List { + private fun sendTextMessagesBatched(timeline: Timeline, room: Room, message: String, count: Int, timeout: Long, rootThreadEventId: String? = null): List { val sentEvents = ArrayList(count) (1 until count + 1) .map { "$message #$it" } .chunked(10) .forEach { batchedMessages -> batchedMessages.forEach { formattedMessage -> - room.sendTextMessage(formattedMessage) + if (rootThreadEventId != null) { + room.replyInThread( + rootThreadEventId = rootThreadEventId, + replyInThreadText = formattedMessage) + } else { + room.sendTextMessage(formattedMessage) + } } waitWithLatch(timeout) { latch -> val timelineListener = object : Timeline.Listener { @@ -196,6 +202,27 @@ class CommonTestHelper(context: Context) { return sentEvents } + /** + * Reply in a thread + * @param room the room where to send the messages + * @param message the message to send + * @param numberOfMessages the number of time the message will be sent + */ + fun replyInThreadMessage( + room: Room, + message: String, + numberOfMessages: Int, + rootThreadEventId: String, + timeout: Long = TestConstants.timeOutMillis): List { + val timeline = room.createTimeline(null, TimelineSettings(10)) + timeline.start() + val sentEvents = sendTextMessagesBatched(timeline, room, message, numberOfMessages, timeout, rootThreadEventId) + timeline.dispose() + // Check that all events has been created + assertEquals("Message number do not match $sentEvents", numberOfMessages.toLong(), sentEvents.size.toLong()) + return sentEvents + } + // PRIVATE METHODS ***************************************************************************** /** diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/threads/ThreadMessagingTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/threads/ThreadMessagingTest.kt new file mode 100644 index 0000000000..6aa4f4cc32 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/threads/ThreadMessagingTest.kt @@ -0,0 +1,339 @@ +/* + * 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.session.room.threads + +import org.amshove.kluent.shouldBe +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeFalse +import org.amshove.kluent.shouldBeNull +import org.amshove.kluent.shouldBeTrue +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.junit.runners.MethodSorters +import org.matrix.android.sdk.InstrumentedTest +import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId +import org.matrix.android.sdk.api.session.events.model.isTextMessage +import org.matrix.android.sdk.api.session.events.model.isThread +import org.matrix.android.sdk.api.session.room.timeline.Timeline +import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings +import org.matrix.android.sdk.common.CommonTestHelper +import org.matrix.android.sdk.common.CryptoTestHelper +import java.util.concurrent.CountDownLatch + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +class ThreadMessagingTest : InstrumentedTest { + + @Test + fun reply_in_thread_should_create_a_thread() { + val commonTestHelper = CommonTestHelper(context()) + val cryptoTestHelper = CryptoTestHelper(commonTestHelper) + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceInARoom(false) + + val aliceSession = cryptoTestData.firstSession + val aliceRoomId = cryptoTestData.roomId + + val aliceRoom = aliceSession.getRoom(aliceRoomId)!! + + // Let's send a message in the normal timeline + val textMessage = "This is a normal timeline message" + val sentMessages = commonTestHelper.sendTextMessage( + room = aliceRoom, + message = textMessage, + nbOfMessages = 1) + + val initMessage = sentMessages.first() + + initMessage.root.isThread().shouldBeFalse() + initMessage.root.isTextMessage().shouldBeTrue() + initMessage.root.getRootThreadEventId().shouldBeNull() + initMessage.root.threadDetails?.isRootThread?.shouldBeFalse() + + // Let's reply in timeline to that message + val repliesInThread = commonTestHelper.replyInThreadMessage( + room = aliceRoom, + message = "Reply In the above thread", + numberOfMessages = 1, + rootThreadEventId = initMessage.root.eventId.orEmpty()) + + val replyInThread = repliesInThread.first() + replyInThread.root.isThread().shouldBeTrue() + replyInThread.root.isTextMessage().shouldBeTrue() + replyInThread.root.getRootThreadEventId().shouldBeEqualTo(initMessage.root.eventId) + + // The init normal message should now be a root thread event + val timeline = aliceRoom.createTimeline(null, TimelineSettings(30)) + timeline.start() + + aliceSession.startSync(true) + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + val initMessageThreadDetails = snapshot.firstOrNull { + it.root.eventId == initMessage.root.eventId + }?.root?.threadDetails + initMessageThreadDetails?.isRootThread?.shouldBeTrue() + initMessageThreadDetails?.numberOfThreads?.shouldBe(1) + true + } + timeline.addListener(eventsListener) + commonTestHelper.await(lock, 600_000) + } + aliceSession.stopSync() + } + + @Test + fun reply_in_thread_should_create_a_thread_from_other_user() { + val commonTestHelper = CommonTestHelper(context()) + val cryptoTestHelper = CryptoTestHelper(commonTestHelper) + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(false) + + val aliceSession = cryptoTestData.firstSession + val aliceRoomId = cryptoTestData.roomId + val aliceRoom = aliceSession.getRoom(aliceRoomId)!! + + // Let's send a message in the normal timeline + val textMessage = "This is a normal timeline message" + val sentMessages = commonTestHelper.sendTextMessage( + room = aliceRoom, + message = textMessage, + nbOfMessages = 1) + + val initMessage = sentMessages.first() + + initMessage.root.isThread().shouldBeFalse() + initMessage.root.isTextMessage().shouldBeTrue() + initMessage.root.getRootThreadEventId().shouldBeNull() + initMessage.root.threadDetails?.isRootThread?.shouldBeFalse() + + // Let's reply in timeline to that message from another user + val bobSession = cryptoTestData.secondSession!! + val bobRoomId = cryptoTestData.roomId + val bobRoom = bobSession.getRoom(bobRoomId)!! + + val repliesInThread = commonTestHelper.replyInThreadMessage( + room = bobRoom, + message = "Reply In the above thread", + numberOfMessages = 1, + rootThreadEventId = initMessage.root.eventId.orEmpty()) + + val replyInThread = repliesInThread.first() + replyInThread.root.isThread().shouldBeTrue() + replyInThread.root.isTextMessage().shouldBeTrue() + replyInThread.root.getRootThreadEventId().shouldBeEqualTo(initMessage.root.eventId) + + // The init normal message should now be a root thread event + val timeline = aliceRoom.createTimeline(null, TimelineSettings(30)) + timeline.start() + + aliceSession.startSync(true) + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + val initMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == initMessage.root.eventId }?.root?.threadDetails + initMessageThreadDetails?.isRootThread?.shouldBeTrue() + initMessageThreadDetails?.numberOfThreads?.shouldBe(1) + true + } + timeline.addListener(eventsListener) + commonTestHelper.await(lock, 600_000) + } + aliceSession.stopSync() + + bobSession.startSync(true) + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + val initMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == initMessage.root.eventId }?.root?.threadDetails + initMessageThreadDetails?.isRootThread?.shouldBeTrue() + initMessageThreadDetails?.numberOfThreads?.shouldBe(1) + true + } + timeline.addListener(eventsListener) + commonTestHelper.await(lock, 600_000) + } + bobSession.stopSync() + } + + @Test + fun reply_in_thread_to_timeline_message_multiple_times() { + val commonTestHelper = CommonTestHelper(context()) + val cryptoTestHelper = CryptoTestHelper(commonTestHelper) + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceInARoom(false) + + val aliceSession = cryptoTestData.firstSession + val aliceRoomId = cryptoTestData.roomId + + val aliceRoom = aliceSession.getRoom(aliceRoomId)!! + + // Let's send 5 messages in the normal timeline + val textMessage = "This is a normal timeline message" + val sentMessages = commonTestHelper.sendTextMessage( + room = aliceRoom, + message = textMessage, + nbOfMessages = 5) + + sentMessages.forEach { + it.root.isThread().shouldBeFalse() + it.root.isTextMessage().shouldBeTrue() + it.root.getRootThreadEventId().shouldBeNull() + it.root.threadDetails?.isRootThread?.shouldBeFalse() + } + // let's start the thread from the second message + val selectedInitMessage = sentMessages[1] + + // Let's reply 40 times in the timeline to the second message + val repliesInThread = commonTestHelper.replyInThreadMessage( + room = aliceRoom, + message = "Reply In the above thread", + numberOfMessages = 40, + rootThreadEventId = selectedInitMessage.root.eventId.orEmpty()) + + repliesInThread.forEach { + it.root.isThread().shouldBeTrue() + it.root.isTextMessage().shouldBeTrue() + it.root.getRootThreadEventId()?.shouldBeEqualTo(selectedInitMessage.root.eventId.orEmpty()) ?: assert(false) + } + + // The init normal message should now be a root thread event + val timeline = aliceRoom.createTimeline(null, TimelineSettings(30)) + timeline.start() + + aliceSession.startSync(true) + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + val initMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == selectedInitMessage.root.eventId }?.root?.threadDetails + // Selected init message should be the thread root + initMessageThreadDetails?.isRootThread?.shouldBeTrue() + // All threads should be 40 + initMessageThreadDetails?.numberOfThreads?.shouldBeEqualTo(40) + true + } + // Because we sent more than 30 messages we should paginate a bit more + timeline.paginate(Timeline.Direction.BACKWARDS, 50) + timeline.addListener(eventsListener) + commonTestHelper.await(lock, 600_000) + } + aliceSession.stopSync() + } + + @Test + fun thread_summary_advanced_validation_after_multiple_messages_in_multiple_threads() { + val commonTestHelper = CommonTestHelper(context()) + val cryptoTestHelper = CryptoTestHelper(commonTestHelper) + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(false) + + val aliceSession = cryptoTestData.firstSession + val aliceRoomId = cryptoTestData.roomId + + val aliceRoom = aliceSession.getRoom(aliceRoomId)!! + + // Let's send 5 messages in the normal timeline + val textMessage = "This is a normal timeline message" + val sentMessages = commonTestHelper.sendTextMessage( + room = aliceRoom, + message = textMessage, + nbOfMessages = 5) + + sentMessages.forEach { + it.root.isThread().shouldBeFalse() + it.root.isTextMessage().shouldBeTrue() + it.root.getRootThreadEventId().shouldBeNull() + it.root.threadDetails?.isRootThread?.shouldBeFalse() + } + // let's start the thread from the second message + val firstMessage = sentMessages[0] + val secondMessage = sentMessages[1] + + // Alice will reply in thread to the second message 35 times + val aliceThreadRepliesInSecondMessage = commonTestHelper.replyInThreadMessage( + room = aliceRoom, + message = "Alice reply In the above second thread message", + numberOfMessages = 35, + rootThreadEventId = secondMessage.root.eventId.orEmpty()) + + // Let's reply in timeline to that message from another user + val bobSession = cryptoTestData.secondSession!! + val bobRoomId = cryptoTestData.roomId + val bobRoom = bobSession.getRoom(bobRoomId)!! + + // Bob will reply in thread to the first message 35 times + val bobThreadRepliesInFirstMessage = commonTestHelper.replyInThreadMessage( + room = bobRoom, + message = "Bob reply In the above first thread message", + numberOfMessages = 42, + rootThreadEventId = firstMessage.root.eventId.orEmpty()) + + // Bob will also reply in second thread 5 times + val bobThreadRepliesInSecondMessage = commonTestHelper.replyInThreadMessage( + room = bobRoom, + message = "Another Bob reply In the above second thread message", + numberOfMessages = 20, + rootThreadEventId = secondMessage.root.eventId.orEmpty()) + + aliceThreadRepliesInSecondMessage.forEach { + it.root.isThread().shouldBeTrue() + it.root.isTextMessage().shouldBeTrue() + it.root.getRootThreadEventId()?.shouldBeEqualTo(secondMessage.root.eventId.orEmpty()) ?: assert(false) + } + + bobThreadRepliesInFirstMessage.forEach { + it.root.isThread().shouldBeTrue() + it.root.isTextMessage().shouldBeTrue() + it.root.getRootThreadEventId()?.shouldBeEqualTo(firstMessage.root.eventId.orEmpty()) ?: assert(false) + } + + bobThreadRepliesInSecondMessage.forEach { + it.root.isThread().shouldBeTrue() + it.root.isTextMessage().shouldBeTrue() + it.root.getRootThreadEventId()?.shouldBeEqualTo(secondMessage.root.eventId.orEmpty()) ?: assert(false) + } + + // The init normal message should now be a root thread event + val timeline = aliceRoom.createTimeline(null, TimelineSettings(30)) + timeline.start() + + aliceSession.startSync(true) + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + val firstMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == firstMessage.root.eventId }?.root?.threadDetails + val secondMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == secondMessage.root.eventId }?.root?.threadDetails + + // first & second message should be the thread root + firstMessageThreadDetails?.isRootThread?.shouldBeTrue() + secondMessageThreadDetails?.isRootThread?.shouldBeTrue() + + // First thread message should contain 42 + firstMessageThreadDetails?.numberOfThreads shouldBeEqualTo 42 + // Second thread message should contain 35+20 + secondMessageThreadDetails?.numberOfThreads shouldBeEqualTo 55 + + true + } + // Because we sent more than 30 messages we should paginate a bit more + timeline.paginate(Timeline.Direction.BACKWARDS, 50) + timeline.paginate(Timeline.Direction.BACKWARDS, 50) + timeline.addListener(eventsListener) + commonTestHelper.await(lock, 600_000) + } + aliceSession.stopSync() + } +} diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceHierarchyTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceHierarchyTest.kt index 5fbfaf99a0..20faa81bb6 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceHierarchyTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceHierarchyTest.kt @@ -344,7 +344,6 @@ class SpaceHierarchyTest : InstrumentedTest { // Test part one of the rooms val bRoomId = spaceBInfo.roomIds.first() - val bRoom = session.getRoom(bRoomId) commonTestHelper.waitWithLatch { latch -> val flatAChildren = session.getFlattenRoomSummaryChildrenOfLive(spaceAInfo.spaceId) @@ -360,7 +359,7 @@ class SpaceHierarchyTest : InstrumentedTest { } // part from b room - bRoom!!.leave(null) + session.leaveRoom(bRoomId) // The room should have disapear from flat children flatAChildren.observeForever(childObserver) } 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 aad5fce33e..df57ca5681 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 @@ -25,9 +25,14 @@ import org.matrix.android.sdk.api.session.crypto.MXCryptoError 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.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.relation.RelationDefaultContent +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 +import org.matrix.android.sdk.api.util.ContentUtils import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent @@ -98,6 +103,9 @@ data class Event( @Transient var sendStateDetails: String? = null + @Transient + var threadDetails: ThreadDetails? = null + fun sendStateError(): MatrixError? { return sendStateDetails?.let { val matrixErrorAdapter = MoshiProvider.providesMoshi().adapter(MatrixError::class.java) @@ -123,6 +131,7 @@ data class Event( it.mCryptoErrorReason = mCryptoErrorReason it.sendState = sendState it.ageLocalTs = ageLocalTs + it.threadDetails = threadDetails } } @@ -185,6 +194,51 @@ data class Event( return contentMap?.let { JSONObject(adapter.toJson(it)).toString(4) } } + /** + * Returns a user friendly content depending on the message type. + * It can be used especially for message summaries. + * It will return a decrypted text message or an empty string otherwise. + */ + fun getDecryptedTextSummary(): String? { + if (isRedacted()) return "Message Deleted" + val text = getDecryptedValue() ?: return null + return when { + isReplyRenderedInThread() || isQuote() -> ContentUtils.extractUsefulTextFromReply(text) + isFileMessage() -> "sent a file." + isAudioMessage() -> "sent an audio file." + isImageMessage() -> "sent an image." + isVideoMessage() -> "sent a video." + isSticker() -> "sent a sticker" + isPoll() -> getPollQuestion() ?: "created a poll." + else -> text + } + } + + private fun Event.isQuote(): Boolean { + if (isReplyRenderedInThread()) return false + return getDecryptedValue("formatted_body")?.contains("
") ?: false + } + + /** + * Determines whether or not current event has mentioned the user + */ + fun isUserMentioned(userId: String): Boolean { + return getDecryptedValue("formatted_body")?.contains(userId) ?: false + } + + /** + * Decrypt the message, or return the pure payload value if there is no encryption + */ + private fun getDecryptedValue(key: String = "body"): String? { + return if (isEncrypted()) { + @Suppress("UNCHECKED_CAST") + val decryptedContent = mxDecryptionResult?.payload?.get("content") as? JsonDict + decryptedContent?.get(key) as? String + } else { + content?.get(key) as? String + } + } + /** * Tells if the event is redacted */ @@ -217,7 +271,7 @@ data class Event( if (mCryptoError != other.mCryptoError) return false if (mCryptoErrorReason != other.mCryptoErrorReason) return false if (sendState != other.sendState) return false - + if (threadDetails != other.threadDetails) return false return true } @@ -236,6 +290,8 @@ data class Event( result = 31 * result + (mCryptoError?.hashCode() ?: 0) result = 31 * result + (mCryptoErrorReason?.hashCode() ?: 0) result = 31 * result + sendState.hashCode() + result = 31 * result + threadDetails.hashCode() + return result } } @@ -243,70 +299,101 @@ data class Event( fun Event.isTextMessage(): Boolean { return getClearType() == EventType.MESSAGE && when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) { - MessageType.MSGTYPE_TEXT, - MessageType.MSGTYPE_EMOTE, - MessageType.MSGTYPE_NOTICE -> true - else -> false - } + MessageType.MSGTYPE_TEXT, + MessageType.MSGTYPE_EMOTE, + MessageType.MSGTYPE_NOTICE -> true + else -> false + } } fun Event.isImageMessage(): Boolean { return getClearType() == EventType.MESSAGE && when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) { - MessageType.MSGTYPE_IMAGE -> true - else -> false - } + MessageType.MSGTYPE_IMAGE -> true + else -> false + } } fun Event.isVideoMessage(): Boolean { return getClearType() == EventType.MESSAGE && when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) { - MessageType.MSGTYPE_VIDEO -> true - else -> false - } + MessageType.MSGTYPE_VIDEO -> true + else -> false + } } fun Event.isAudioMessage(): Boolean { return getClearType() == EventType.MESSAGE && when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) { - MessageType.MSGTYPE_AUDIO -> true - else -> false - } + MessageType.MSGTYPE_AUDIO -> true + else -> false + } } fun Event.isFileMessage(): Boolean { return getClearType() == EventType.MESSAGE && when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) { - MessageType.MSGTYPE_FILE -> true - else -> false - } + MessageType.MSGTYPE_FILE -> true + else -> false + } } fun Event.isAttachmentMessage(): Boolean { return getClearType() == EventType.MESSAGE && when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) { - MessageType.MSGTYPE_IMAGE, - MessageType.MSGTYPE_AUDIO, - MessageType.MSGTYPE_VIDEO, - MessageType.MSGTYPE_FILE -> true - else -> false - } + MessageType.MSGTYPE_IMAGE, + MessageType.MSGTYPE_AUDIO, + MessageType.MSGTYPE_VIDEO, + MessageType.MSGTYPE_FILE -> true + else -> false + } } +fun Event.isPoll(): Boolean = getClearType() == EventType.POLL_START || getClearType() == EventType.POLL_END + +fun Event.isSticker(): Boolean = getClearType() == EventType.STICKER + fun Event.getRelationContent(): RelationDefaultContent? { return if (isEncrypted()) { content.toModel()?.relatesTo } else { - content.toModel()?.relatesTo + content.toModel()?.relatesTo ?: run { + // Special case to handle stickers, while there is only a local msgtype for stickers + if (getClearType() == EventType.STICKER) { + getClearContent().toModel()?.relatesTo + } else { + null + } + } } } +/** + * Returns the poll question or null otherwise + */ +fun Event.getPollQuestion(): String? = + getPollContent()?.pollCreationInfo?.question?.question + +/** + * Returns the relation content for a specific type or null otherwise + */ +fun Event.getRelationContentForType(type: String): RelationDefaultContent? = + getRelationContent()?.takeIf { it.type == type } + fun Event.isReply(): Boolean { return getRelationContent()?.inReplyTo?.eventId != null } +fun Event.isReplyRenderedInThread(): Boolean { + return isReply() && getRelationContent()?.inReplyTo?.shouldRenderInThread() == true +} + +fun Event.isThread(): Boolean = getRelationContentForType(RelationType.IO_THREAD)?.eventId != null + +fun Event.getRootThreadEventId(): String? = getRelationContentForType(RelationType.IO_THREAD)?.eventId + fun Event.isEdition(): Boolean { - return getRelationContent()?.takeIf { it.type == RelationType.REPLACE }?.eventId != null + return getRelationContentForType(RelationType.REPLACE)?.eventId != null } fun Event.getPresenceContent(): PresenceContent? { @@ -315,3 +402,7 @@ fun Event.getPresenceContent(): PresenceContent? { fun Event.isInvitation(): Boolean = type == EventType.STATE_ROOM_MEMBER && content?.toModel()?.membership == Membership.INVITE + +fun Event.getPollContent(): MessagePollContent? { + return content.toModel() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt index f67efc50ba..fb26264ad7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt @@ -28,9 +28,9 @@ object RelationType { /** Lets you define an event which references an existing event.*/ const val REFERENCE = "m.reference" - /** Lets you define an thread event that belongs to another existing event.*/ -// const val THREAD = "m.thread" // m.thread is not yet released in the backend - const val THREAD = "io.element.thread" // io.element.thread will be replaced by m.thread when it is released + /** Lets you define an event which is a thread reply to an existing event.*/ + const val THREAD = "m.thread" + const val IO_THREAD = "io.element.thread" /** Lets you define an event which adds a response to an existing event.*/ const val RESPONSE = "org.matrix.response" diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt index 3ed6a7ebb2..2256dfb8f0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt @@ -21,6 +21,18 @@ data class HomeServerCapabilities( * True if it is possible to change the password of the account. */ val canChangePassword: Boolean = true, + /** + * True if it is possible to change the display name of the account. + */ + val canChangeDisplayName: Boolean = true, + /** + * True if it is possible to change the avatar of the account. + */ + val canChangeAvatar: Boolean = true, + /** + * True if it is possible to change the 3pid associations of the account. + */ + val canChange3pid: Boolean = true, /** * Max size of file which can be uploaded to the homeserver in bytes. [MAX_UPLOAD_FILE_SIZE_UNKNOWN] if unknown or not retrieved yet */ @@ -76,6 +88,7 @@ data class HomeServerCapabilities( } } } + fun isFeatureSupported(feature: String, byRoomVersion: String): Boolean { if (roomVersions?.capabilities == null) return false val info = roomVersions.capabilities[feature] ?: return false diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/media/PreviewUrlData.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/media/PreviewUrlData.kt index 33fc8b052b..bfba43a82d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/media/PreviewUrlData.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/media/PreviewUrlData.kt @@ -47,5 +47,9 @@ data class PreviewUrlData( // Value of field "og:description" val description: String?, // Value of field "og:image" - val mxcUrl: String? + val mxcUrl: String?, + // Value of field "og:image:width" + val imageWidth: Int?, + // Value of field "og:image:height" + val imageHeight: Int? ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt index 6c0e730499..d930a5d0fd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt @@ -32,6 +32,7 @@ import org.matrix.android.sdk.api.session.room.send.DraftService import org.matrix.android.sdk.api.session.room.send.SendService import org.matrix.android.sdk.api.session.room.state.StateService import org.matrix.android.sdk.api.session.room.tags.TagsService +import org.matrix.android.sdk.api.session.room.threads.ThreadsService import org.matrix.android.sdk.api.session.room.timeline.TimelineService import org.matrix.android.sdk.api.session.room.typing.TypingService import org.matrix.android.sdk.api.session.room.uploads.UploadsService @@ -45,6 +46,7 @@ import org.matrix.android.sdk.api.util.Optional */ interface Room : TimelineService, + ThreadsService, SendService, DraftService, ReadService, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt index e4bd498990..bca432320d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt @@ -76,6 +76,13 @@ interface RoomService { thirdPartySigned: SignInvitationResult ) + /** + * Leave the room, or reject an invitation. + * @param roomId the roomId of the room to leave + * @param reason optional reason for leaving the room + */ + suspend fun leaveRoom(roomId: String, reason: String? = null) + /** * Get a room from a roomId * @param roomId the roomId to look for. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/members/MembershipService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/members/MembershipService.kt index d5bc65c142..6c8e2d310c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/members/MembershipService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/members/MembershipService.kt @@ -81,14 +81,4 @@ interface MembershipService { @Deprecated("Use remove instead", ReplaceWith("remove(userId, reason)")) suspend fun kick(userId: String, reason: String? = null) = remove(userId, reason) - - /** - * Join the room, or accept an invitation. - */ - suspend fun join(reason: String? = null, viaServers: List = emptyList()) - - /** - * Leave the room, or reject an invitation. - */ - suspend fun leave(reason: String? = null) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt index bf51e7177b..d07bd2d73a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt @@ -63,5 +63,13 @@ data class MessageLocationContent( @Json(name = "org.matrix.msc1767.text") val text: String? = null ) : MessageContent { - fun getUri() = locationInfo?.geoUri ?: geoUri + fun getBestGeoUri() = locationInfo?.geoUri ?: geoUri + + /** + * @return true if the location asset is a user location, not a generic one. + */ + fun isSelfLocation(): Boolean { + // Should behave like m.self if locationAsset is null + return locationAsset?.type == null || locationAsset.type == LocationAssetType.SELF + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt index 763d4bb892..09114436f0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.api.session.room.model.relation import androidx.lifecycle.LiveData import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary +import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.PollType import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.util.Cancelable @@ -45,6 +46,9 @@ import org.matrix.android.sdk.api.util.Optional * m.reference - lets you define an event which references an existing event. * When aggregated, currently doesn't do anything special, but in future could bundle chains of references (i.e. threads). * These are primarily intended for handling replies (and in future threads). + * + * m.thread - lets you define an event which is a thread reply to an existing event. + * When aggregated, returns the most thread event */ interface RelationService { @@ -62,8 +66,8 @@ interface RelationService { * @param targetEventId the id of the event being reacted * @param reaction the reaction (preferably emoji) */ - fun undoReaction(targetEventId: String, - reaction: String): Cancelable + suspend fun undoReaction(targetEventId: String, + reaction: String): Cancelable /** * Edit a poll. @@ -118,10 +122,15 @@ interface RelationService { * @param eventReplied the event referenced by the reply * @param replyText the reply text * @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present + * @param showInThread If true, relation will be added to the reply in order to be visible from within threads + * @param rootThreadEventId If show in thread is true then we need the rootThreadEventId to generate the relation */ fun replyToMessage(eventReplied: TimelineEvent, replyText: CharSequence, - autoMarkdown: Boolean = false): Cancelable? + autoMarkdown: Boolean = false, + showInThread: Boolean = false, + rootThreadEventId: String? = null + ): Cancelable? /** * Get the current EventAnnotationsSummary @@ -136,4 +145,31 @@ interface RelationService { * @return the LiveData of EventAnnotationsSummary */ fun getEventAnnotationsSummaryLive(eventId: String): LiveData> + + /** + * Creates a thread reply for an existing timeline event + * The replyInThreadText can be a Spannable and contains special spans (MatrixItemSpan) that will be translated + * by the sdk into pills. + * @param rootThreadEventId the root thread eventId + * @param replyInThreadText the reply text + * @param msgType the message type: MessageType.MSGTYPE_TEXT (default) or MessageType.MSGTYPE_EMOTE + * @param formattedText The formatted body using MessageType#FORMAT_MATRIX_HTML + * @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present + * @param eventReplied the event referenced by the reply within a thread + */ + fun replyInThread(rootThreadEventId: String, + replyInThreadText: CharSequence, + msgType: String = MessageType.MSGTYPE_TEXT, + autoMarkdown: Boolean = false, + formattedText: String? = null, + eventReplied: TimelineEvent? = null): Cancelable? + + /** + * Get all the thread replies for the specified rootThreadEventId + * The return list will contain the original root thread event and all the thread replies to that event + * Note: We will use a large limit value in order to avoid using pagination until it would be 100% ready + * from the backend + * @param rootThreadEventId the root thread eventId + */ + suspend fun fetchThreadTimeline(rootThreadEventId: String): Boolean } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt index 251328bea2..412a1bfca9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt @@ -21,5 +21,8 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class ReplyToContent( - @Json(name = "event_id") val eventId: String? = null + @Json(name = "event_id") val eventId: String? = null, + @Json(name = "render_in") val renderIn: List? = null ) + +fun ReplyToContent.shouldRenderInThread(): Boolean = renderIn?.contains("m.thread") == true diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt index 20d00394df..913dbfd010 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt @@ -64,7 +64,7 @@ interface SendService { * @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present * @return a [Cancelable] */ - fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean): Cancelable + fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, rootThreadEventId: String? = null): Cancelable /** * Method to send a media asynchronously. @@ -72,11 +72,13 @@ interface SendService { * @param compressBeforeSending set to true to compress images before sending them * @param roomIds set of roomIds to where the media will be sent. The current roomId will be add to this set if not present. * It can be useful to send media to multiple room. It's safe to include the current roomId in this set + * @param rootThreadEventId when this param is not null, the Media will be sent in this specific thread * @return a [Cancelable] */ fun sendMedia(attachment: ContentAttachmentData, compressBeforeSending: Boolean, - roomIds: Set): Cancelable + roomIds: Set, + rootThreadEventId: String? = null): Cancelable /** * Method to send a list of media asynchronously. @@ -84,11 +86,13 @@ interface SendService { * @param compressBeforeSending set to true to compress images before sending them * @param roomIds set of roomIds to where the media will be sent. The current roomId will be add to this set if not present. * It can be useful to send media to multiple room. It's safe to include the current roomId in this set + * @param rootThreadEventId when this param is not null, all the Media will be sent in this specific thread * @return a [Cancelable] */ fun sendMedias(attachments: List, compressBeforeSending: Boolean, - roomIds: Set): Cancelable + roomIds: Set, + rootThreadEventId: String? = null): Cancelable /** * Send a poll to the room. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt new file mode 100644 index 0000000000..e4d1d979e1 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.room.threads + +import androidx.lifecycle.LiveData +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent + +/** + * This interface defines methods to interact with threads related features. + * It's implemented at the room level within the main timeline. + */ +interface ThreadsService { + + /** + * Returns a [LiveData] list of all the thread root TimelineEvents that exists at the room level + */ + fun getAllThreadsLive(): LiveData> + + /** + * Returns a list of all the thread root TimelineEvents that exists at the room level + */ + fun getAllThreads(): List + + /** + * Returns a [LiveData] list of all the marked unread threads that exists at the room level + */ + fun getMarkedThreadNotificationsLive(): LiveData> + + /** + * Returns a list of all the marked unread threads that exists at the room level + */ + fun getMarkedThreadNotifications(): List + + /** + * Returns whether or not the current user is participating in the thread + * @param rootThreadEventId the eventId of the current thread + */ + fun isUserParticipatingInThread(rootThreadEventId: String): Boolean + + /** + * Enhance the provided root thread TimelineEvent [List] by adding the latest + * message edition for that thread + * @return the enhanced [List] with edited updates + */ + fun mapEventsWithEdition(threads: List): List + + /** + * Marks the current thread as read in local DB. + * note: read receipts within threads are not yet supported with the API + * @param rootThreadEventId the root eventId of the current thread + */ + suspend fun markThreadAsRead(rootThreadEventId: String) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt index 241e5f3b9b..d47a656798 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt @@ -43,7 +43,7 @@ interface Timeline { /** * This must be called before any other method after creating the timeline. It ensures the underlying database is open */ - fun start() + fun start(rootThreadEventId: String? = null) /** * This must be called when you don't need the timeline. It ensures the underlying database get closed. 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 3f7d2d1278..6f8bae876b 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 @@ -22,7 +22,9 @@ 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.isEdition +import org.matrix.android.sdk.api.session.events.model.isPoll import org.matrix.android.sdk.api.session.events.model.isReply +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.room.model.EventAnnotationsSummary import org.matrix.android.sdk.api.session.room.model.ReadReceipt @@ -149,6 +151,13 @@ fun TimelineEvent.isEdition(): Boolean { return root.isEdition() } +fun TimelineEvent.isPoll(): Boolean = + root.isPoll() + +fun TimelineEvent.isSticker(): Boolean { + return root.isSticker() +} + /** * Get the latest message body, after a possible edition, stripping the reply prefix if necessary */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineSettings.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineSettings.kt index ceffedb234..6548453c8a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineSettings.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineSettings.kt @@ -27,5 +27,14 @@ data class TimelineSettings( /** * If true, will build read receipts for each event. */ - val buildReadReceipts: Boolean = true -) + val buildReadReceipts: Boolean = true, + /** + * The root thread eventId if this is a thread timeline, or null if this is NOT a thread timeline + */ + val rootThreadEventId: String? = null) { + + /** + * Returns true if this is a thread timeline or false otherwise + */ + fun isThreadTimeline() = rootThreadEventId != null +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/Space.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/Space.kt index 207050be7d..f0ed9daac5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/Space.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/Space.kt @@ -26,8 +26,6 @@ interface Space { val spaceId: String - suspend fun leave(reason: String? = null) - /** * A current snapshot of [RoomSummary] associated with the space */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/SpaceService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/SpaceService.kt index 357c0b941a..41c4e7eed1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/SpaceService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/SpaceService.kt @@ -87,6 +87,13 @@ interface SpaceService { suspend fun rejectInvite(spaceId: String, reason: String?) + /** + * Leave the space, or reject an invitation. + * @param spaceId the spaceId of the space to leave + * @param reason optional reason for leaving the space + */ + suspend fun leaveSpace(spaceId: String, reason: String? = null) + // fun getSpaceParentsOfRoom(roomId: String) : List /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt new file mode 100644 index 0000000000..fafe17b2c0 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.threads + +import org.matrix.android.sdk.api.session.room.sender.SenderInfo + +/** + * This class contains all the details needed for threads. + * Is is mainly used from within an Event. + */ +data class ThreadDetails( + val isRootThread: Boolean = false, + val numberOfThreads: Int = 0, + val threadSummarySenderInfo: SenderInfo? = null, + val threadSummaryLatestTextMessage: String? = null, + val lastMessageTimestamp: Long? = null, + var threadNotificationState: ThreadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE, + val isThread: Boolean = false, + val lastRootThreadEdition: String? = null +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadNotificationBadgeState.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadNotificationBadgeState.kt new file mode 100644 index 0000000000..8e861e73de --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadNotificationBadgeState.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.threads + +/** + * This class defines the state of a thread notification badge + */ +data class ThreadNotificationBadgeState( + val numberOfLocalUnreadThreads: Int = 0, + val isUserMentioned: Boolean = false +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadNotificationState.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadNotificationState.kt new file mode 100644 index 0000000000..8566d68aa5 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadNotificationState.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.threads + +/** + * This class defines the state of a thread notification + */ +enum class ThreadNotificationState { + + // There are no new message + NO_NEW_MESSAGE, + + // There is at least one new message + NEW_MESSAGE, + + // The is at least one new message that should be highlighted + // ex. "Hello @aris.kotsomitopoulos" + NEW_HIGHLIGHTED_MESSAGE; +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadTimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadTimelineEvent.kt new file mode 100644 index 0000000000..7b433566b8 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadTimelineEvent.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.threads + +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent + +/** + * This class contains a thread TimelineEvent along with a boolean that + * determines if the current user has participated in that event + */ +data class ThreadTimelineEvent( + val timelineEvent: TimelineEvent, + val isParticipating: Boolean +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt index bb62dbbfe9..298e116199 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt @@ -46,7 +46,9 @@ internal abstract class AuthModule { @JvmStatic @Provides @AuthDatabase - fun providesRealmConfiguration(context: Context, realmKeysUtils: RealmKeysUtils): RealmConfiguration { + fun providesRealmConfiguration(context: Context, + realmKeysUtils: RealmKeysUtils, + authRealmMigration: AuthRealmMigration): RealmConfiguration { val old = File(context.filesDir, "matrix-sdk-auth") if (old.exists()) { old.renameTo(File(context.filesDir, "matrix-sdk-auth.realm")) @@ -58,8 +60,8 @@ internal abstract class AuthModule { } .name("matrix-sdk-auth.realm") .modules(AuthRealmModule()) - .schemaVersion(AuthRealmMigration.SCHEMA_VERSION) - .migration(AuthRealmMigration) + .schemaVersion(authRealmMigration.schemaVersion) + .migration(authRealmMigration) .build() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/AuthRealmMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/AuthRealmMigration.kt index c2104690b3..59b6471a05 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/AuthRealmMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/AuthRealmMigration.kt @@ -16,102 +16,31 @@ package org.matrix.android.sdk.internal.auth.db -import android.net.Uri import io.realm.DynamicRealm import io.realm.RealmMigration -import org.matrix.android.sdk.api.auth.data.Credentials -import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig -import org.matrix.android.sdk.api.auth.data.sessionId -import org.matrix.android.sdk.internal.di.MoshiProvider +import org.matrix.android.sdk.internal.auth.db.migration.MigrateAuthTo001 +import org.matrix.android.sdk.internal.auth.db.migration.MigrateAuthTo002 +import org.matrix.android.sdk.internal.auth.db.migration.MigrateAuthTo003 +import org.matrix.android.sdk.internal.auth.db.migration.MigrateAuthTo004 import timber.log.Timber +import javax.inject.Inject -internal object AuthRealmMigration : RealmMigration { +internal class AuthRealmMigration @Inject constructor() : RealmMigration { + /** + * Forces all AuthRealmMigration instances to be equal + * Avoids Realm throwing when multiple instances of the migration are set + */ + override fun equals(other: Any?) = other is AuthRealmMigration + override fun hashCode() = 4000 - // Current schema version - const val SCHEMA_VERSION = 4L + val schemaVersion = 4L override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { Timber.d("Migrating Auth Realm from $oldVersion to $newVersion") - if (oldVersion <= 0) migrateTo1(realm) - if (oldVersion <= 1) migrateTo2(realm) - if (oldVersion <= 2) migrateTo3(realm) - if (oldVersion <= 3) migrateTo4(realm) - } - - private fun migrateTo1(realm: DynamicRealm) { - Timber.d("Step 0 -> 1") - Timber.d("Create PendingSessionEntity") - - realm.schema.create("PendingSessionEntity") - .addField(PendingSessionEntityFields.HOME_SERVER_CONNECTION_CONFIG_JSON, String::class.java) - .setRequired(PendingSessionEntityFields.HOME_SERVER_CONNECTION_CONFIG_JSON, true) - .addField(PendingSessionEntityFields.CLIENT_SECRET, String::class.java) - .setRequired(PendingSessionEntityFields.CLIENT_SECRET, true) - .addField(PendingSessionEntityFields.SEND_ATTEMPT, Integer::class.java) - .setRequired(PendingSessionEntityFields.SEND_ATTEMPT, true) - .addField(PendingSessionEntityFields.RESET_PASSWORD_DATA_JSON, String::class.java) - .addField(PendingSessionEntityFields.CURRENT_SESSION, String::class.java) - .addField(PendingSessionEntityFields.IS_REGISTRATION_STARTED, Boolean::class.java) - .addField(PendingSessionEntityFields.CURRENT_THREE_PID_DATA_JSON, String::class.java) - } - - private fun migrateTo2(realm: DynamicRealm) { - Timber.d("Step 1 -> 2") - Timber.d("Add boolean isTokenValid in SessionParamsEntity, with value true") - - realm.schema.get("SessionParamsEntity") - ?.addField(SessionParamsEntityFields.IS_TOKEN_VALID, Boolean::class.java) - ?.transform { it.set(SessionParamsEntityFields.IS_TOKEN_VALID, true) } - } - - private fun migrateTo3(realm: DynamicRealm) { - Timber.d("Step 2 -> 3") - Timber.d("Update SessionParamsEntity primary key, to allow several sessions with the same userId") - - realm.schema.get("SessionParamsEntity") - ?.removePrimaryKey() - ?.addField(SessionParamsEntityFields.SESSION_ID, String::class.java) - ?.setRequired(SessionParamsEntityFields.SESSION_ID, true) - ?.transform { - val credentialsJson = it.getString(SessionParamsEntityFields.CREDENTIALS_JSON) - - val credentials = MoshiProvider.providesMoshi() - .adapter(Credentials::class.java) - .fromJson(credentialsJson) - - it.set(SessionParamsEntityFields.SESSION_ID, credentials!!.sessionId()) - } - ?.addPrimaryKey(SessionParamsEntityFields.SESSION_ID) - } - - private fun migrateTo4(realm: DynamicRealm) { - Timber.d("Step 3 -> 4") - Timber.d("Update SessionParamsEntity to add HomeServerConnectionConfig.homeServerUriBase value") - - val adapter = MoshiProvider.providesMoshi() - .adapter(HomeServerConnectionConfig::class.java) - - realm.schema.get("SessionParamsEntity") - ?.transform { - val homeserverConnectionConfigJson = it.getString(SessionParamsEntityFields.HOME_SERVER_CONNECTION_CONFIG_JSON) - - val homeserverConnectionConfig = adapter - .fromJson(homeserverConnectionConfigJson) - - val homeserverUrl = homeserverConnectionConfig?.homeServerUri?.toString() - // Special case for matrix.org. Old session may use "https://matrix.org", newer one may use - // "https://matrix-client.matrix.org". So fix that here - val alteredHomeserverConnectionConfig = - if (homeserverUrl == "https://matrix.org" || homeserverUrl == "https://matrix-client.matrix.org") { - homeserverConnectionConfig.copy( - homeServerUri = Uri.parse("https://matrix.org"), - homeServerUriBase = Uri.parse("https://matrix-client.matrix.org") - ) - } else { - homeserverConnectionConfig - } - it.set(SessionParamsEntityFields.HOME_SERVER_CONNECTION_CONFIG_JSON, adapter.toJson(alteredHomeserverConnectionConfig)) - } + if (oldVersion < 1) MigrateAuthTo001(realm).perform() + if (oldVersion < 2) MigrateAuthTo002(realm).perform() + if (oldVersion < 3) MigrateAuthTo003(realm).perform() + if (oldVersion < 4) MigrateAuthTo004(realm).perform() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/migration/MigrateAuthTo001.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/migration/MigrateAuthTo001.kt new file mode 100644 index 0000000000..627f4e16bc --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/migration/MigrateAuthTo001.kt @@ -0,0 +1,41 @@ +/* + * 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.auth.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.auth.db.PendingSessionEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator +import timber.log.Timber + +class MigrateAuthTo001(realm: DynamicRealm) : RealmMigrator(realm, 1) { + + override fun doMigrate(realm: DynamicRealm) { + Timber.d("Create PendingSessionEntity") + + realm.schema.create("PendingSessionEntity") + .addField(PendingSessionEntityFields.HOME_SERVER_CONNECTION_CONFIG_JSON, String::class.java) + .setRequired(PendingSessionEntityFields.HOME_SERVER_CONNECTION_CONFIG_JSON, true) + .addField(PendingSessionEntityFields.CLIENT_SECRET, String::class.java) + .setRequired(PendingSessionEntityFields.CLIENT_SECRET, true) + .addField(PendingSessionEntityFields.SEND_ATTEMPT, Integer::class.java) + .setRequired(PendingSessionEntityFields.SEND_ATTEMPT, true) + .addField(PendingSessionEntityFields.RESET_PASSWORD_DATA_JSON, String::class.java) + .addField(PendingSessionEntityFields.CURRENT_SESSION, String::class.java) + .addField(PendingSessionEntityFields.IS_REGISTRATION_STARTED, Boolean::class.java) + .addField(PendingSessionEntityFields.CURRENT_THREE_PID_DATA_JSON, String::class.java) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/migration/MigrateAuthTo002.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/migration/MigrateAuthTo002.kt new file mode 100644 index 0000000000..6b133f8580 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/migration/MigrateAuthTo002.kt @@ -0,0 +1,33 @@ +/* + * 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.auth.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.auth.db.SessionParamsEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator +import timber.log.Timber + +class MigrateAuthTo002(realm: DynamicRealm) : RealmMigrator(realm, 2) { + + override fun doMigrate(realm: DynamicRealm) { + Timber.d("Add boolean isTokenValid in SessionParamsEntity, with value true") + + realm.schema.get("SessionParamsEntity") + ?.addField(SessionParamsEntityFields.IS_TOKEN_VALID, Boolean::class.java) + ?.transform { it.set(SessionParamsEntityFields.IS_TOKEN_VALID, true) } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/migration/MigrateAuthTo003.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/migration/MigrateAuthTo003.kt new file mode 100644 index 0000000000..9319ec9987 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/migration/MigrateAuthTo003.kt @@ -0,0 +1,47 @@ +/* + * 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.auth.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.api.auth.data.Credentials +import org.matrix.android.sdk.api.auth.data.sessionId +import org.matrix.android.sdk.internal.auth.db.SessionParamsEntityFields +import org.matrix.android.sdk.internal.di.MoshiProvider +import org.matrix.android.sdk.internal.util.database.RealmMigrator +import timber.log.Timber + +class MigrateAuthTo003(realm: DynamicRealm) : RealmMigrator(realm, 3) { + + override fun doMigrate(realm: DynamicRealm) { + Timber.d("Update SessionParamsEntity primary key, to allow several sessions with the same userId") + + realm.schema.get("SessionParamsEntity") + ?.removePrimaryKey() + ?.addField(SessionParamsEntityFields.SESSION_ID, String::class.java) + ?.setRequired(SessionParamsEntityFields.SESSION_ID, true) + ?.transform { + val credentialsJson = it.getString(SessionParamsEntityFields.CREDENTIALS_JSON) + + val credentials = MoshiProvider.providesMoshi() + .adapter(Credentials::class.java) + .fromJson(credentialsJson) + + it.set(SessionParamsEntityFields.SESSION_ID, credentials!!.sessionId()) + } + ?.addPrimaryKey(SessionParamsEntityFields.SESSION_ID) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/migration/MigrateAuthTo004.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/migration/MigrateAuthTo004.kt new file mode 100644 index 0000000000..4a9b9022d5 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/migration/MigrateAuthTo004.kt @@ -0,0 +1,57 @@ +/* + * 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.auth.db.migration + +import android.net.Uri +import io.realm.DynamicRealm +import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig +import org.matrix.android.sdk.internal.auth.db.SessionParamsEntityFields +import org.matrix.android.sdk.internal.di.MoshiProvider +import org.matrix.android.sdk.internal.util.database.RealmMigrator +import timber.log.Timber + +class MigrateAuthTo004(realm: DynamicRealm) : RealmMigrator(realm, 4) { + + override fun doMigrate(realm: DynamicRealm) { + Timber.d("Update SessionParamsEntity to add HomeServerConnectionConfig.homeServerUriBase value") + + val adapter = MoshiProvider.providesMoshi() + .adapter(HomeServerConnectionConfig::class.java) + + realm.schema.get("SessionParamsEntity") + ?.transform { + val homeserverConnectionConfigJson = it.getString(SessionParamsEntityFields.HOME_SERVER_CONNECTION_CONFIG_JSON) + + val homeserverConnectionConfig = adapter + .fromJson(homeserverConnectionConfigJson) + + val homeserverUrl = homeserverConnectionConfig?.homeServerUri?.toString() + // Special case for matrix.org. Old session may use "https://matrix.org", newer one may use + // "https://matrix-client.matrix.org". So fix that here + val alteredHomeserverConnectionConfig = + if (homeserverUrl == "https://matrix.org" || homeserverUrl == "https://matrix-client.matrix.org") { + homeserverConnectionConfig.copy( + homeServerUri = Uri.parse("https://matrix.org"), + homeServerUriBase = Uri.parse("https://matrix-client.matrix.org") + ) + } else { + homeserverConnectionConfig + } + it.set(SessionParamsEntityFields.HOME_SERVER_CONNECTION_CONFIG_JSON, adapter.toJson(alteredHomeserverConnectionConfig)) + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/CryptoModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/CryptoModule.kt index fe388b44e2..3130a6382f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/CryptoModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/CryptoModule.kt @@ -112,7 +112,8 @@ internal abstract class CryptoModule { @SessionScope fun providesRealmConfiguration(@SessionFilesDirectory directory: File, @UserMd5 userMd5: String, - realmKeysUtils: RealmKeysUtils): RealmConfiguration { + realmKeysUtils: RealmKeysUtils, + realmCryptoStoreMigration: RealmCryptoStoreMigration): RealmConfiguration { return RealmConfiguration.Builder() .directory(directory) .apply { @@ -121,8 +122,8 @@ internal abstract class CryptoModule { .name("crypto_store.realm") .modules(RealmCryptoStoreModule()) .allowWritesOnUiThread(true) - .schemaVersion(RealmCryptoStoreMigration.CRYPTO_STORE_SCHEMA_VERSION) - .migration(RealmCryptoStoreMigration) + .schemaVersion(realmCryptoStoreMigration.schemaVersion) + .migration(realmCryptoStoreMigration) .build() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXOutboundSessionInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXOutboundSessionInfo.kt index 5a68937868..b70e6c1f80 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXOutboundSessionInfo.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXOutboundSessionInfo.kt @@ -35,7 +35,7 @@ internal class MXOutboundSessionInfo( val sessionLifetime = System.currentTimeMillis() - creationTime if (useCount >= rotationPeriodMsgs || sessionLifetime >= rotationPeriodMs) { - Timber.v("## needsRotation() : Rotating megolm session after " + useCount + ", " + sessionLifetime + "ms") + Timber.v("## needsRotation() : Rotating megolm session after $useCount, ${sessionLifetime}ms") needsRotation = true } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt index f73cbaf480..685b2d2967 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt @@ -16,560 +16,54 @@ package org.matrix.android.sdk.internal.crypto.store.db -import com.squareup.moshi.Moshi -import com.squareup.moshi.Types import io.realm.DynamicRealm import io.realm.RealmMigration -import io.realm.RealmObjectSchema -import org.matrix.android.sdk.api.extensions.tryOrNull -import org.matrix.android.sdk.api.util.JsonDict -import org.matrix.android.sdk.internal.crypto.model.MXDeviceInfo -import org.matrix.android.sdk.internal.crypto.model.OlmInboundGroupSessionWrapper -import org.matrix.android.sdk.internal.crypto.model.OlmInboundGroupSessionWrapper2 -import org.matrix.android.sdk.internal.crypto.store.db.mapper.CrossSigningKeysMapper -import org.matrix.android.sdk.internal.crypto.store.db.model.CrossSigningInfoEntityFields -import org.matrix.android.sdk.internal.crypto.store.db.model.CryptoMetadataEntityFields -import org.matrix.android.sdk.internal.crypto.store.db.model.CryptoRoomEntityFields -import org.matrix.android.sdk.internal.crypto.store.db.model.DeviceInfoEntityFields -import org.matrix.android.sdk.internal.crypto.store.db.model.GossipingEventEntityFields -import org.matrix.android.sdk.internal.crypto.store.db.model.IncomingGossipingRequestEntityFields -import org.matrix.android.sdk.internal.crypto.store.db.model.KeyInfoEntityFields -import org.matrix.android.sdk.internal.crypto.store.db.model.KeysBackupDataEntityFields -import org.matrix.android.sdk.internal.crypto.store.db.model.MyDeviceLastSeenInfoEntityFields -import org.matrix.android.sdk.internal.crypto.store.db.model.OlmInboundGroupSessionEntityFields -import org.matrix.android.sdk.internal.crypto.store.db.model.OlmSessionEntityFields -import org.matrix.android.sdk.internal.crypto.store.db.model.OutboundGroupSessionInfoEntityFields -import org.matrix.android.sdk.internal.crypto.store.db.model.OutgoingGossipingRequestEntityFields -import org.matrix.android.sdk.internal.crypto.store.db.model.SharedSessionEntityFields -import org.matrix.android.sdk.internal.crypto.store.db.model.TrustLevelEntityFields -import org.matrix.android.sdk.internal.crypto.store.db.model.UserEntityFields -import org.matrix.android.sdk.internal.crypto.store.db.model.WithHeldSessionEntityFields -import org.matrix.android.sdk.internal.di.MoshiProvider -import org.matrix.android.sdk.internal.di.SerializeNulls -import org.matrix.androidsdk.crypto.data.MXOlmInboundGroupSession2 +import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo001Legacy +import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo002Legacy +import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo003RiotX +import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo004 +import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo005 +import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo006 +import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo007 +import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo008 +import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo009 +import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo010 +import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo011 +import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo012 +import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo013 +import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo014 import timber.log.Timber -import org.matrix.androidsdk.crypto.data.MXDeviceInfo as LegacyMXDeviceInfo +import javax.inject.Inject -internal object RealmCryptoStoreMigration : RealmMigration { +internal class RealmCryptoStoreMigration @Inject constructor() : RealmMigration { + /** + * Forces all RealmCryptoStoreMigration instances to be equal + * Avoids Realm throwing when multiple instances of the migration are set + */ + override fun equals(other: Any?) = other is RealmCryptoStoreMigration + override fun hashCode() = 5000 // 0, 1, 2: legacy Riot-Android // 3: migrate to RiotX schema // 4, 5, 6, 7, 8, 9: migrations from RiotX (which was previously 1, 2, 3, 4, 5, 6) - const val CRYPTO_STORE_SCHEMA_VERSION = 14L - - private fun RealmObjectSchema.addFieldIfNotExists(fieldName: String, fieldType: Class<*>): RealmObjectSchema { - if (!hasField(fieldName)) { - addField(fieldName, fieldType) - } - return this - } - - private fun RealmObjectSchema.removeFieldIfExists(fieldName: String): RealmObjectSchema { - if (hasField(fieldName)) { - removeField(fieldName) - } - return this - } - - private fun RealmObjectSchema.setRequiredIfNotAlready(fieldName: String, isRequired: Boolean): RealmObjectSchema { - if (isRequired != isRequired(fieldName)) { - setRequired(fieldName, isRequired) - } - return this - } + val schemaVersion = 14L override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { - Timber.v("Migrating Realm Crypto from $oldVersion to $newVersion") + Timber.d("Migrating Realm Crypto from $oldVersion to $newVersion") - if (oldVersion <= 0) migrateTo1Legacy(realm) - if (oldVersion <= 1) migrateTo2Legacy(realm) - if (oldVersion <= 2) migrateTo3RiotX(realm) - if (oldVersion <= 3) migrateTo4(realm) - if (oldVersion <= 4) migrateTo5(realm) - if (oldVersion <= 5) migrateTo6(realm) - if (oldVersion <= 6) migrateTo7(realm) - if (oldVersion <= 7) migrateTo8(realm) - if (oldVersion <= 8) migrateTo9(realm) - if (oldVersion <= 9) migrateTo10(realm) - if (oldVersion <= 10) migrateTo11(realm) - if (oldVersion <= 11) migrateTo12(realm) - if (oldVersion <= 12) migrateTo13(realm) - if (oldVersion <= 13) migrateTo14(realm) - } - - private fun migrateTo1Legacy(realm: DynamicRealm) { - Timber.d("Step 0 -> 1") - Timber.d("Add field lastReceivedMessageTs (Long) and set the value to 0") - - realm.schema.get("OlmSessionEntity") - ?.addField(OlmSessionEntityFields.LAST_RECEIVED_MESSAGE_TS, Long::class.java) - ?.transform { - it.setLong(OlmSessionEntityFields.LAST_RECEIVED_MESSAGE_TS, 0) - } - } - - private fun migrateTo2Legacy(realm: DynamicRealm) { - Timber.d("Step 1 -> 2") - Timber.d("Update IncomingRoomKeyRequestEntity format: requestBodyString field is exploded into several fields") - - realm.schema.get("IncomingRoomKeyRequestEntity") - ?.addFieldIfNotExists("requestBodyAlgorithm", String::class.java) - ?.addFieldIfNotExists("requestBodyRoomId", String::class.java) - ?.addFieldIfNotExists("requestBodySenderKey", String::class.java) - ?.addFieldIfNotExists("requestBodySessionId", String::class.java) - ?.transform { dynamicObject -> - try { - val requestBodyString = dynamicObject.getString("requestBodyString") - // It was a map before - val map: Map? = deserializeFromRealm(requestBodyString) - - map?.let { - dynamicObject.setString("requestBodyAlgorithm", it["algorithm"]) - dynamicObject.setString("requestBodyRoomId", it["room_id"]) - dynamicObject.setString("requestBodySenderKey", it["sender_key"]) - dynamicObject.setString("requestBodySessionId", it["session_id"]) - } - } catch (e: Exception) { - Timber.e(e, "Error") - } - } - ?.removeFieldIfExists("requestBodyString") - - Timber.d("Update IncomingRoomKeyRequestEntity format: requestBodyString field is exploded into several fields") - - realm.schema.get("OutgoingRoomKeyRequestEntity") - ?.addFieldIfNotExists("requestBodyAlgorithm", String::class.java) - ?.addFieldIfNotExists("requestBodyRoomId", String::class.java) - ?.addFieldIfNotExists("requestBodySenderKey", String::class.java) - ?.addFieldIfNotExists("requestBodySessionId", String::class.java) - ?.transform { dynamicObject -> - try { - val requestBodyString = dynamicObject.getString("requestBodyString") - // It was a map before - val map: Map? = deserializeFromRealm(requestBodyString) - - map?.let { - dynamicObject.setString("requestBodyAlgorithm", it["algorithm"]) - dynamicObject.setString("requestBodyRoomId", it["room_id"]) - dynamicObject.setString("requestBodySenderKey", it["sender_key"]) - dynamicObject.setString("requestBodySessionId", it["session_id"]) - } - } catch (e: Exception) { - Timber.e(e, "Error") - } - } - ?.removeFieldIfExists("requestBodyString") - - Timber.d("Create KeysBackupDataEntity") - - if (!realm.schema.contains("KeysBackupDataEntity")) { - realm.schema.create("KeysBackupDataEntity") - .addField(KeysBackupDataEntityFields.PRIMARY_KEY, Integer::class.java) - .addPrimaryKey(KeysBackupDataEntityFields.PRIMARY_KEY) - .setRequired(KeysBackupDataEntityFields.PRIMARY_KEY, true) - .addField(KeysBackupDataEntityFields.BACKUP_LAST_SERVER_HASH, String::class.java) - .addField(KeysBackupDataEntityFields.BACKUP_LAST_SERVER_NUMBER_OF_KEYS, Integer::class.java) - } - } - - private fun migrateTo3RiotX(realm: DynamicRealm) { - Timber.d("Step 2 -> 3") - Timber.d("Migrate to RiotX model") - - realm.schema.get("CryptoRoomEntity") - ?.addFieldIfNotExists(CryptoRoomEntityFields.SHOULD_ENCRYPT_FOR_INVITED_MEMBERS, Boolean::class.java) - ?.setRequiredIfNotAlready(CryptoRoomEntityFields.SHOULD_ENCRYPT_FOR_INVITED_MEMBERS, false) - - // Convert format of MXDeviceInfo, package has to be the same. - realm.schema.get("DeviceInfoEntity") - ?.transform { obj -> - try { - val oldSerializedData = obj.getString("deviceInfoData") - deserializeFromRealm(oldSerializedData)?.let { legacyMxDeviceInfo -> - val newMxDeviceInfo = MXDeviceInfo( - deviceId = legacyMxDeviceInfo.deviceId, - userId = legacyMxDeviceInfo.userId, - algorithms = legacyMxDeviceInfo.algorithms, - keys = legacyMxDeviceInfo.keys, - signatures = legacyMxDeviceInfo.signatures, - unsigned = legacyMxDeviceInfo.unsigned, - verified = legacyMxDeviceInfo.mVerified - ) - - obj.setString("deviceInfoData", serializeForRealm(newMxDeviceInfo)) - } - } catch (e: Exception) { - Timber.e(e, "Error") - } - } - - // Convert MXOlmInboundGroupSession2 to OlmInboundGroupSessionWrapper - realm.schema.get("OlmInboundGroupSessionEntity") - ?.transform { obj -> - try { - val oldSerializedData = obj.getString("olmInboundGroupSessionData") - deserializeFromRealm(oldSerializedData)?.let { mxOlmInboundGroupSession2 -> - val sessionKey = mxOlmInboundGroupSession2.mSession.sessionIdentifier() - val newOlmInboundGroupSessionWrapper = OlmInboundGroupSessionWrapper(sessionKey, false) - .apply { - olmInboundGroupSession = mxOlmInboundGroupSession2.mSession - roomId = mxOlmInboundGroupSession2.mRoomId - senderKey = mxOlmInboundGroupSession2.mSenderKey - keysClaimed = mxOlmInboundGroupSession2.mKeysClaimed - forwardingCurve25519KeyChain = mxOlmInboundGroupSession2.mForwardingCurve25519KeyChain - } - - obj.setString("olmInboundGroupSessionData", serializeForRealm(newOlmInboundGroupSessionWrapper)) - } - } catch (e: Exception) { - Timber.e(e, "Error") - } - } - } - - // Version 4L added Cross Signing info persistence - private fun migrateTo4(realm: DynamicRealm) { - Timber.d("Step 3 -> 4") - - if (realm.schema.contains("TrustLevelEntity")) { - Timber.d("Skipping Step 3 -> 4 because entities already exist") - return - } - - Timber.d("Create KeyInfoEntity") - val trustLevelEntityEntitySchema = realm.schema.create("TrustLevelEntity") - .addField(TrustLevelEntityFields.CROSS_SIGNED_VERIFIED, Boolean::class.java) - .setNullable(TrustLevelEntityFields.CROSS_SIGNED_VERIFIED, true) - .addField(TrustLevelEntityFields.LOCALLY_VERIFIED, Boolean::class.java) - .setNullable(TrustLevelEntityFields.LOCALLY_VERIFIED, true) - - val keyInfoEntitySchema = realm.schema.create("KeyInfoEntity") - .addField(KeyInfoEntityFields.PUBLIC_KEY_BASE64, String::class.java) - .addField(KeyInfoEntityFields.SIGNATURES, String::class.java) - .addRealmListField(KeyInfoEntityFields.USAGES.`$`, String::class.java) - .addRealmObjectField(KeyInfoEntityFields.TRUST_LEVEL_ENTITY.`$`, trustLevelEntityEntitySchema) - - Timber.d("Create CrossSigningInfoEntity") - - val crossSigningInfoSchema = realm.schema.create("CrossSigningInfoEntity") - .addField(CrossSigningInfoEntityFields.USER_ID, String::class.java) - .addPrimaryKey(CrossSigningInfoEntityFields.USER_ID) - .addRealmListField(CrossSigningInfoEntityFields.CROSS_SIGNING_KEYS.`$`, keyInfoEntitySchema) - - Timber.d("Updating UserEntity table") - realm.schema.get("UserEntity") - ?.addRealmObjectField(UserEntityFields.CROSS_SIGNING_INFO_ENTITY.`$`, crossSigningInfoSchema) - - Timber.d("Updating CryptoMetadataEntity table") - realm.schema.get("CryptoMetadataEntity") - ?.addField(CryptoMetadataEntityFields.X_SIGN_MASTER_PRIVATE_KEY, String::class.java) - ?.addField(CryptoMetadataEntityFields.X_SIGN_USER_PRIVATE_KEY, String::class.java) - ?.addField(CryptoMetadataEntityFields.X_SIGN_SELF_SIGNED_PRIVATE_KEY, String::class.java) - - val moshi = Moshi.Builder().add(SerializeNulls.JSON_ADAPTER_FACTORY).build() - val listMigrationAdapter = moshi.adapter>(Types.newParameterizedType( - List::class.java, - String::class.java, - Any::class.java - )) - val mapMigrationAdapter = moshi.adapter(Types.newParameterizedType( - Map::class.java, - String::class.java, - Any::class.java - )) - - realm.schema.get("DeviceInfoEntity") - ?.addField(DeviceInfoEntityFields.USER_ID, String::class.java) - ?.addField(DeviceInfoEntityFields.ALGORITHM_LIST_JSON, String::class.java) - ?.addField(DeviceInfoEntityFields.KEYS_MAP_JSON, String::class.java) - ?.addField(DeviceInfoEntityFields.SIGNATURE_MAP_JSON, String::class.java) - ?.addField(DeviceInfoEntityFields.UNSIGNED_MAP_JSON, String::class.java) - ?.addField(DeviceInfoEntityFields.IS_BLOCKED, Boolean::class.java) - ?.setNullable(DeviceInfoEntityFields.IS_BLOCKED, true) - ?.addRealmObjectField(DeviceInfoEntityFields.TRUST_LEVEL_ENTITY.`$`, trustLevelEntityEntitySchema) - ?.transform { obj -> - - try { - val oldSerializedData = obj.getString("deviceInfoData") - deserializeFromRealm(oldSerializedData)?.let { oldDevice -> - - val trustLevel = realm.createObject("TrustLevelEntity") - when (oldDevice.verified) { - MXDeviceInfo.DEVICE_VERIFICATION_UNKNOWN -> { - obj.setNull(DeviceInfoEntityFields.TRUST_LEVEL_ENTITY.`$`) - } - MXDeviceInfo.DEVICE_VERIFICATION_BLOCKED -> { - trustLevel.setNull(TrustLevelEntityFields.LOCALLY_VERIFIED) - trustLevel.setNull(TrustLevelEntityFields.CROSS_SIGNED_VERIFIED) - obj.setBoolean(DeviceInfoEntityFields.IS_BLOCKED, oldDevice.isBlocked) - obj.setObject(DeviceInfoEntityFields.TRUST_LEVEL_ENTITY.`$`, trustLevel) - } - MXDeviceInfo.DEVICE_VERIFICATION_UNVERIFIED -> { - trustLevel.setBoolean(TrustLevelEntityFields.LOCALLY_VERIFIED, false) - trustLevel.setBoolean(TrustLevelEntityFields.CROSS_SIGNED_VERIFIED, false) - obj.setObject(DeviceInfoEntityFields.TRUST_LEVEL_ENTITY.`$`, trustLevel) - } - MXDeviceInfo.DEVICE_VERIFICATION_VERIFIED -> { - trustLevel.setBoolean(TrustLevelEntityFields.LOCALLY_VERIFIED, true) - trustLevel.setBoolean(TrustLevelEntityFields.CROSS_SIGNED_VERIFIED, false) - obj.setObject(DeviceInfoEntityFields.TRUST_LEVEL_ENTITY.`$`, trustLevel) - } - } - - obj.setString(DeviceInfoEntityFields.USER_ID, oldDevice.userId) - obj.setString(DeviceInfoEntityFields.IDENTITY_KEY, oldDevice.identityKey()) - obj.setString(DeviceInfoEntityFields.ALGORITHM_LIST_JSON, listMigrationAdapter.toJson(oldDevice.algorithms)) - obj.setString(DeviceInfoEntityFields.KEYS_MAP_JSON, mapMigrationAdapter.toJson(oldDevice.keys)) - obj.setString(DeviceInfoEntityFields.SIGNATURE_MAP_JSON, mapMigrationAdapter.toJson(oldDevice.signatures)) - obj.setString(DeviceInfoEntityFields.UNSIGNED_MAP_JSON, mapMigrationAdapter.toJson(oldDevice.unsigned)) - } - } catch (failure: Throwable) { - Timber.w(failure, "Crypto Data base migration error") - // an unfortunate refactor did modify that class, making deserialization failing - // so we just skip and ignore.. - } - } - ?.removeField("deviceInfoData") - } - - private fun migrateTo5(realm: DynamicRealm) { - Timber.d("Step 4 -> 5") - realm.schema.remove("OutgoingRoomKeyRequestEntity") - realm.schema.remove("IncomingRoomKeyRequestEntity") - - // Not need to migrate existing request, just start fresh? - - realm.schema.create("GossipingEventEntity") - .addField(GossipingEventEntityFields.TYPE, String::class.java) - .addIndex(GossipingEventEntityFields.TYPE) - .addField(GossipingEventEntityFields.CONTENT, String::class.java) - .addField(GossipingEventEntityFields.SENDER, String::class.java) - .addIndex(GossipingEventEntityFields.SENDER) - .addField(GossipingEventEntityFields.DECRYPTION_RESULT_JSON, String::class.java) - .addField(GossipingEventEntityFields.DECRYPTION_ERROR_CODE, String::class.java) - .addField(GossipingEventEntityFields.AGE_LOCAL_TS, Long::class.java) - .setNullable(GossipingEventEntityFields.AGE_LOCAL_TS, true) - .addField(GossipingEventEntityFields.SEND_STATE_STR, String::class.java) - - realm.schema.create("IncomingGossipingRequestEntity") - .addField(IncomingGossipingRequestEntityFields.REQUEST_ID, String::class.java) - .addIndex(IncomingGossipingRequestEntityFields.REQUEST_ID) - .addField(IncomingGossipingRequestEntityFields.TYPE_STR, String::class.java) - .addIndex(IncomingGossipingRequestEntityFields.TYPE_STR) - .addField(IncomingGossipingRequestEntityFields.OTHER_USER_ID, String::class.java) - .addField(IncomingGossipingRequestEntityFields.REQUESTED_INFO_STR, String::class.java) - .addField(IncomingGossipingRequestEntityFields.OTHER_DEVICE_ID, String::class.java) - .addField(IncomingGossipingRequestEntityFields.REQUEST_STATE_STR, String::class.java) - .addField(IncomingGossipingRequestEntityFields.LOCAL_CREATION_TIMESTAMP, Long::class.java) - .setNullable(IncomingGossipingRequestEntityFields.LOCAL_CREATION_TIMESTAMP, true) - - realm.schema.create("OutgoingGossipingRequestEntity") - .addField(OutgoingGossipingRequestEntityFields.REQUEST_ID, String::class.java) - .addIndex(OutgoingGossipingRequestEntityFields.REQUEST_ID) - .addField(OutgoingGossipingRequestEntityFields.RECIPIENTS_DATA, String::class.java) - .addField(OutgoingGossipingRequestEntityFields.REQUESTED_INFO_STR, String::class.java) - .addField(OutgoingGossipingRequestEntityFields.TYPE_STR, String::class.java) - .addIndex(OutgoingGossipingRequestEntityFields.TYPE_STR) - .addField(OutgoingGossipingRequestEntityFields.REQUEST_STATE_STR, String::class.java) - } - - private fun migrateTo6(realm: DynamicRealm) { - Timber.d("Step 5 -> 6") - Timber.d("Updating CryptoMetadataEntity table") - realm.schema.get("CryptoMetadataEntity") - ?.addField(CryptoMetadataEntityFields.KEY_BACKUP_RECOVERY_KEY, String::class.java) - ?.addField(CryptoMetadataEntityFields.KEY_BACKUP_RECOVERY_KEY_VERSION, String::class.java) - } - - private fun migrateTo7(realm: DynamicRealm) { - Timber.d("Step 6 -> 7") - Timber.d("Updating KeyInfoEntity table") - val crossSigningKeysMapper = CrossSigningKeysMapper(MoshiProvider.providesMoshi()) - - val keyInfoEntities = realm.where("KeyInfoEntity").findAll() - try { - keyInfoEntities.forEach { - val stringSignatures = it.getString(KeyInfoEntityFields.SIGNATURES) - val objectSignatures: Map>? = deserializeFromRealm(stringSignatures) - val jsonSignatures = crossSigningKeysMapper.serializeSignatures(objectSignatures) - it.setString(KeyInfoEntityFields.SIGNATURES, jsonSignatures) - } - } catch (failure: Throwable) { - } - - // Migrate frozen classes - val inboundGroupSessions = realm.where("OlmInboundGroupSessionEntity").findAll() - inboundGroupSessions.forEach { dynamicObject -> - dynamicObject.getString(OlmInboundGroupSessionEntityFields.OLM_INBOUND_GROUP_SESSION_DATA)?.let { serializedObject -> - try { - deserializeFromRealm(serializedObject)?.let { oldFormat -> - val newFormat = oldFormat.exportKeys()?.let { - OlmInboundGroupSessionWrapper2(it) - } - dynamicObject.setString(OlmInboundGroupSessionEntityFields.OLM_INBOUND_GROUP_SESSION_DATA, serializeForRealm(newFormat)) - } - } catch (failure: Throwable) { - Timber.e(failure, "## OlmInboundGroupSessionEntity migration failed") - } - } - } - } - - private fun migrateTo8(realm: DynamicRealm) { - Timber.d("Step 7 -> 8") - realm.schema.create("MyDeviceLastSeenInfoEntity") - .addField(MyDeviceLastSeenInfoEntityFields.DEVICE_ID, String::class.java) - .addPrimaryKey(MyDeviceLastSeenInfoEntityFields.DEVICE_ID) - .addField(MyDeviceLastSeenInfoEntityFields.DISPLAY_NAME, String::class.java) - .addField(MyDeviceLastSeenInfoEntityFields.LAST_SEEN_IP, String::class.java) - .addField(MyDeviceLastSeenInfoEntityFields.LAST_SEEN_TS, Long::class.java) - .setNullable(MyDeviceLastSeenInfoEntityFields.LAST_SEEN_TS, true) - - val now = System.currentTimeMillis() - realm.schema.get("DeviceInfoEntity") - ?.addField(DeviceInfoEntityFields.FIRST_TIME_SEEN_LOCAL_TS, Long::class.java) - ?.setNullable(DeviceInfoEntityFields.FIRST_TIME_SEEN_LOCAL_TS, true) - ?.transform { deviceInfoEntity -> - tryOrNull { - deviceInfoEntity.setLong(DeviceInfoEntityFields.FIRST_TIME_SEEN_LOCAL_TS, now) - } - } - } - - // Fixes duplicate devices in UserEntity#devices - private fun migrateTo9(realm: DynamicRealm) { - Timber.d("Step 8 -> 9") - val userEntities = realm.where("UserEntity").findAll() - userEntities.forEach { - try { - val deviceList = it.getList(UserEntityFields.DEVICES.`$`) - ?: return@forEach - val distinct = deviceList.distinctBy { it.getString(DeviceInfoEntityFields.DEVICE_ID) } - if (distinct.size != deviceList.size) { - deviceList.clear() - deviceList.addAll(distinct) - } - } catch (failure: Throwable) { - Timber.w(failure, "Crypto Data base migration error for migrateTo9") - } - } - } - - // Version 10L added WithHeld Keys Info (MSC2399) - private fun migrateTo10(realm: DynamicRealm) { - Timber.d("Step 9 -> 10") - realm.schema.create("WithHeldSessionEntity") - .addField(WithHeldSessionEntityFields.ROOM_ID, String::class.java) - .addField(WithHeldSessionEntityFields.ALGORITHM, String::class.java) - .addField(WithHeldSessionEntityFields.SESSION_ID, String::class.java) - .addIndex(WithHeldSessionEntityFields.SESSION_ID) - .addField(WithHeldSessionEntityFields.SENDER_KEY, String::class.java) - .addIndex(WithHeldSessionEntityFields.SENDER_KEY) - .addField(WithHeldSessionEntityFields.CODE_STRING, String::class.java) - .addField(WithHeldSessionEntityFields.REASON, String::class.java) - - realm.schema.create("SharedSessionEntity") - .addField(SharedSessionEntityFields.ROOM_ID, String::class.java) - .addField(SharedSessionEntityFields.ALGORITHM, String::class.java) - .addField(SharedSessionEntityFields.SESSION_ID, String::class.java) - .addIndex(SharedSessionEntityFields.SESSION_ID) - .addField(SharedSessionEntityFields.USER_ID, String::class.java) - .addIndex(SharedSessionEntityFields.USER_ID) - .addField(SharedSessionEntityFields.DEVICE_ID, String::class.java) - .addIndex(SharedSessionEntityFields.DEVICE_ID) - .addField(SharedSessionEntityFields.CHAIN_INDEX, Long::class.java) - .setNullable(SharedSessionEntityFields.CHAIN_INDEX, true) - } - - // Version 11L added deviceKeysSentToServer boolean to CryptoMetadataEntity - private fun migrateTo11(realm: DynamicRealm) { - Timber.d("Step 10 -> 11") - realm.schema.get("CryptoMetadataEntity") - ?.addField(CryptoMetadataEntityFields.DEVICE_KEYS_SENT_TO_SERVER, Boolean::class.java) - } - - // Version 12L added outbound group session persistence - private fun migrateTo12(realm: DynamicRealm) { - Timber.d("Step 11 -> 12") - val outboundEntitySchema = realm.schema.create("OutboundGroupSessionInfoEntity") - .addField(OutboundGroupSessionInfoEntityFields.SERIALIZED_OUTBOUND_SESSION_DATA, String::class.java) - .addField(OutboundGroupSessionInfoEntityFields.CREATION_TIME, Long::class.java) - .setNullable(OutboundGroupSessionInfoEntityFields.CREATION_TIME, true) - - realm.schema.get("CryptoRoomEntity") - ?.addRealmObjectField(CryptoRoomEntityFields.OUTBOUND_SESSION_INFO.`$`, outboundEntitySchema) - } - - // Version 13L delete unreferenced TrustLevelEntity - private fun migrateTo13(realm: DynamicRealm) { - Timber.d("Step 12 -> 13") - - // Use a trick to do that... Ref: https://stackoverflow.com/questions/55221366 - val trustLevelEntitySchema = realm.schema.get("TrustLevelEntity") - - /* - Creating a new temp field called isLinked which is set to true for those which are - references by other objects. Rest of them are set to false. Then removing all - those which are false and hence duplicate and unnecessary. Then removing the temp field - isLinked - */ - var mainCounter = 0 - var deviceInfoCounter = 0 - var keyInfoCounter = 0 - val deleteCounter: Int - - trustLevelEntitySchema - ?.addField("isLinked", Boolean::class.java) - ?.transform { obj -> - // Setting to false for all by default - obj.set("isLinked", false) - mainCounter++ - } - - realm.schema.get("DeviceInfoEntity")?.transform { obj -> - // Setting to true for those which are referenced in DeviceInfoEntity - deviceInfoCounter++ - obj.getObject("trustLevelEntity")?.set("isLinked", true) - } - - realm.schema.get("KeyInfoEntity")?.transform { obj -> - // Setting to true for those which are referenced in KeyInfoEntity - keyInfoCounter++ - obj.getObject("trustLevelEntity")?.set("isLinked", true) - } - - // Removing all those which are set as false - realm.where("TrustLevelEntity") - .equalTo("isLinked", false) - .findAll() - .also { deleteCounter = it.size } - .deleteAllFromRealm() - - trustLevelEntitySchema?.removeField("isLinked") - - Timber.w("TrustLevelEntity cleanup: $mainCounter entities") - Timber.w("TrustLevelEntity cleanup: $deviceInfoCounter entities referenced in DeviceInfoEntities") - Timber.w("TrustLevelEntity cleanup: $keyInfoCounter entities referenced in KeyInfoEntity") - Timber.w("TrustLevelEntity cleanup: $deleteCounter entities deleted!") - if (mainCounter != deviceInfoCounter + keyInfoCounter + deleteCounter) { - Timber.e("TrustLevelEntity cleanup: Something is not correct...") - } - } - - // Version 14L Update the way we remember key sharing - private fun migrateTo14(realm: DynamicRealm) { - Timber.d("Step 13 -> 14") - realm.schema.get("SharedSessionEntity") - ?.addField(SharedSessionEntityFields.DEVICE_IDENTITY_KEY, String::class.java) - ?.addIndex(SharedSessionEntityFields.DEVICE_IDENTITY_KEY) - ?.transform { - val sharedUserId = it.getString(SharedSessionEntityFields.USER_ID) - val sharedDeviceId = it.getString(SharedSessionEntityFields.DEVICE_ID) - val knownDevice = realm.where("DeviceInfoEntity") - .equalTo(DeviceInfoEntityFields.USER_ID, sharedUserId) - .equalTo(DeviceInfoEntityFields.DEVICE_ID, sharedDeviceId) - .findFirst() - it.setString(SharedSessionEntityFields.DEVICE_IDENTITY_KEY, knownDevice?.getString(DeviceInfoEntityFields.IDENTITY_KEY)) - } + if (oldVersion < 1) MigrateCryptoTo001Legacy(realm).perform() + if (oldVersion < 2) MigrateCryptoTo002Legacy(realm).perform() + if (oldVersion < 3) MigrateCryptoTo003RiotX(realm).perform() + if (oldVersion < 4) MigrateCryptoTo004(realm).perform() + if (oldVersion < 5) MigrateCryptoTo005(realm).perform() + if (oldVersion < 6) MigrateCryptoTo006(realm).perform() + if (oldVersion < 7) MigrateCryptoTo007(realm).perform() + if (oldVersion < 8) MigrateCryptoTo008(realm).perform() + if (oldVersion < 9) MigrateCryptoTo009(realm).perform() + if (oldVersion < 10) MigrateCryptoTo010(realm).perform() + if (oldVersion < 11) MigrateCryptoTo011(realm).perform() + if (oldVersion < 12) MigrateCryptoTo012(realm).perform() + if (oldVersion < 13) MigrateCryptoTo013(realm).perform() + if (oldVersion < 14) MigrateCryptoTo014(realm).perform() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo001Legacy.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo001Legacy.kt new file mode 100644 index 0000000000..0e44689428 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo001Legacy.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.crypto.store.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.crypto.store.db.model.OlmSessionEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator +import timber.log.Timber + +class MigrateCryptoTo001Legacy(realm: DynamicRealm) : RealmMigrator(realm, 1) { + + override fun doMigrate(realm: DynamicRealm) { + Timber.d("Add field lastReceivedMessageTs (Long) and set the value to 0") + + realm.schema.get("OlmSessionEntity") + ?.addField(OlmSessionEntityFields.LAST_RECEIVED_MESSAGE_TS, Long::class.java) + ?.transform { + it.setLong(OlmSessionEntityFields.LAST_RECEIVED_MESSAGE_TS, 0) + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo002Legacy.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo002Legacy.kt new file mode 100644 index 0000000000..84e627a688 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo002Legacy.kt @@ -0,0 +1,86 @@ +/* + * 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.crypto.store.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.crypto.store.db.deserializeFromRealm +import org.matrix.android.sdk.internal.crypto.store.db.model.KeysBackupDataEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator +import timber.log.Timber + +class MigrateCryptoTo002Legacy(realm: DynamicRealm) : RealmMigrator(realm, 2) { + + override fun doMigrate(realm: DynamicRealm) { + Timber.d("Update IncomingRoomKeyRequestEntity format: requestBodyString field is exploded into several fields") + realm.schema.get("IncomingRoomKeyRequestEntity") + ?.addFieldIfNotExists("requestBodyAlgorithm", String::class.java) + ?.addFieldIfNotExists("requestBodyRoomId", String::class.java) + ?.addFieldIfNotExists("requestBodySenderKey", String::class.java) + ?.addFieldIfNotExists("requestBodySessionId", String::class.java) + ?.transform { dynamicObject -> + try { + val requestBodyString = dynamicObject.getString("requestBodyString") + // It was a map before + val map: Map? = deserializeFromRealm(requestBodyString) + + map?.let { + dynamicObject.setString("requestBodyAlgorithm", it["algorithm"]) + dynamicObject.setString("requestBodyRoomId", it["room_id"]) + dynamicObject.setString("requestBodySenderKey", it["sender_key"]) + dynamicObject.setString("requestBodySessionId", it["session_id"]) + } + } catch (e: Exception) { + Timber.e(e, "Error") + } + } + ?.removeFieldIfExists("requestBodyString") + + Timber.d("Update IncomingRoomKeyRequestEntity format: requestBodyString field is exploded into several fields") + realm.schema.get("OutgoingRoomKeyRequestEntity") + ?.addFieldIfNotExists("requestBodyAlgorithm", String::class.java) + ?.addFieldIfNotExists("requestBodyRoomId", String::class.java) + ?.addFieldIfNotExists("requestBodySenderKey", String::class.java) + ?.addFieldIfNotExists("requestBodySessionId", String::class.java) + ?.transform { dynamicObject -> + try { + val requestBodyString = dynamicObject.getString("requestBodyString") + // It was a map before + val map: Map? = deserializeFromRealm(requestBodyString) + + map?.let { + dynamicObject.setString("requestBodyAlgorithm", it["algorithm"]) + dynamicObject.setString("requestBodyRoomId", it["room_id"]) + dynamicObject.setString("requestBodySenderKey", it["sender_key"]) + dynamicObject.setString("requestBodySessionId", it["session_id"]) + } + } catch (e: Exception) { + Timber.e(e, "Error") + } + } + ?.removeFieldIfExists("requestBodyString") + + Timber.d("Create KeysBackupDataEntity") + if (!realm.schema.contains("KeysBackupDataEntity")) { + realm.schema.create("KeysBackupDataEntity") + .addField(KeysBackupDataEntityFields.PRIMARY_KEY, Integer::class.java) + .addPrimaryKey(KeysBackupDataEntityFields.PRIMARY_KEY) + .setRequired(KeysBackupDataEntityFields.PRIMARY_KEY, true) + .addField(KeysBackupDataEntityFields.BACKUP_LAST_SERVER_HASH, String::class.java) + .addField(KeysBackupDataEntityFields.BACKUP_LAST_SERVER_NUMBER_OF_KEYS, Integer::class.java) + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo003RiotX.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo003RiotX.kt new file mode 100644 index 0000000000..b468a56af6 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo003RiotX.kt @@ -0,0 +1,83 @@ +/* + * 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.crypto.store.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.crypto.model.OlmInboundGroupSessionWrapper +import org.matrix.android.sdk.internal.crypto.store.db.deserializeFromRealm +import org.matrix.android.sdk.internal.crypto.store.db.model.CryptoRoomEntityFields +import org.matrix.android.sdk.internal.crypto.store.db.serializeForRealm +import org.matrix.android.sdk.internal.util.database.RealmMigrator +import org.matrix.androidsdk.crypto.data.MXDeviceInfo +import org.matrix.androidsdk.crypto.data.MXOlmInboundGroupSession2 +import timber.log.Timber + +class MigrateCryptoTo003RiotX(realm: DynamicRealm) : RealmMigrator(realm, 3) { + + override fun doMigrate(realm: DynamicRealm) { + Timber.d("Migrate to RiotX model") + realm.schema.get("CryptoRoomEntity") + ?.addFieldIfNotExists(CryptoRoomEntityFields.SHOULD_ENCRYPT_FOR_INVITED_MEMBERS, Boolean::class.java) + ?.setRequiredIfNotAlready(CryptoRoomEntityFields.SHOULD_ENCRYPT_FOR_INVITED_MEMBERS, false) + + // Convert format of MXDeviceInfo, package has to be the same. + realm.schema.get("DeviceInfoEntity") + ?.transform { obj -> + try { + val oldSerializedData = obj.getString("deviceInfoData") + deserializeFromRealm(oldSerializedData)?.let { legacyMxDeviceInfo -> + val newMxDeviceInfo = org.matrix.android.sdk.internal.crypto.model.MXDeviceInfo( + deviceId = legacyMxDeviceInfo.deviceId, + userId = legacyMxDeviceInfo.userId, + algorithms = legacyMxDeviceInfo.algorithms, + keys = legacyMxDeviceInfo.keys, + signatures = legacyMxDeviceInfo.signatures, + unsigned = legacyMxDeviceInfo.unsigned, + verified = legacyMxDeviceInfo.mVerified + ) + + obj.setString("deviceInfoData", serializeForRealm(newMxDeviceInfo)) + } + } catch (e: Exception) { + Timber.e(e, "Error") + } + } + + // Convert MXOlmInboundGroupSession2 to OlmInboundGroupSessionWrapper + realm.schema.get("OlmInboundGroupSessionEntity") + ?.transform { obj -> + try { + val oldSerializedData = obj.getString("olmInboundGroupSessionData") + deserializeFromRealm(oldSerializedData)?.let { mxOlmInboundGroupSession2 -> + val sessionKey = mxOlmInboundGroupSession2.mSession.sessionIdentifier() + val newOlmInboundGroupSessionWrapper = OlmInboundGroupSessionWrapper(sessionKey, false) + .apply { + olmInboundGroupSession = mxOlmInboundGroupSession2.mSession + roomId = mxOlmInboundGroupSession2.mRoomId + senderKey = mxOlmInboundGroupSession2.mSenderKey + keysClaimed = mxOlmInboundGroupSession2.mKeysClaimed + forwardingCurve25519KeyChain = mxOlmInboundGroupSession2.mForwardingCurve25519KeyChain + } + + obj.setString("olmInboundGroupSessionData", serializeForRealm(newOlmInboundGroupSessionWrapper)) + } + } catch (e: Exception) { + Timber.e(e, "Error") + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo004.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo004.kt new file mode 100644 index 0000000000..20a4814b8d --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo004.kt @@ -0,0 +1,139 @@ +/* + * 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.crypto.store.db.migration + +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import io.realm.DynamicRealm +import org.matrix.android.sdk.api.util.JsonDict +import org.matrix.android.sdk.internal.crypto.model.MXDeviceInfo +import org.matrix.android.sdk.internal.crypto.store.db.deserializeFromRealm +import org.matrix.android.sdk.internal.crypto.store.db.model.CrossSigningInfoEntityFields +import org.matrix.android.sdk.internal.crypto.store.db.model.CryptoMetadataEntityFields +import org.matrix.android.sdk.internal.crypto.store.db.model.DeviceInfoEntityFields +import org.matrix.android.sdk.internal.crypto.store.db.model.KeyInfoEntityFields +import org.matrix.android.sdk.internal.crypto.store.db.model.TrustLevelEntityFields +import org.matrix.android.sdk.internal.crypto.store.db.model.UserEntityFields +import org.matrix.android.sdk.internal.di.SerializeNulls +import org.matrix.android.sdk.internal.util.database.RealmMigrator +import timber.log.Timber + +// Version 4L added Cross Signing info persistence +class MigrateCryptoTo004(realm: DynamicRealm) : RealmMigrator(realm, 4) { + + override fun doMigrate(realm: DynamicRealm) { + if (realm.schema.contains("TrustLevelEntity")) { + Timber.d("Skipping Step 3 -> 4 because entities already exist") + return + } + + Timber.d("Create KeyInfoEntity") + val trustLevelEntityEntitySchema = realm.schema.create("TrustLevelEntity") + .addField(TrustLevelEntityFields.CROSS_SIGNED_VERIFIED, Boolean::class.java) + .setNullable(TrustLevelEntityFields.CROSS_SIGNED_VERIFIED, true) + .addField(TrustLevelEntityFields.LOCALLY_VERIFIED, Boolean::class.java) + .setNullable(TrustLevelEntityFields.LOCALLY_VERIFIED, true) + + val keyInfoEntitySchema = realm.schema.create("KeyInfoEntity") + .addField(KeyInfoEntityFields.PUBLIC_KEY_BASE64, String::class.java) + .addField(KeyInfoEntityFields.SIGNATURES, String::class.java) + .addRealmListField(KeyInfoEntityFields.USAGES.`$`, String::class.java) + .addRealmObjectField(KeyInfoEntityFields.TRUST_LEVEL_ENTITY.`$`, trustLevelEntityEntitySchema) + + Timber.d("Create CrossSigningInfoEntity") + + val crossSigningInfoSchema = realm.schema.create("CrossSigningInfoEntity") + .addField(CrossSigningInfoEntityFields.USER_ID, String::class.java) + .addPrimaryKey(CrossSigningInfoEntityFields.USER_ID) + .addRealmListField(CrossSigningInfoEntityFields.CROSS_SIGNING_KEYS.`$`, keyInfoEntitySchema) + + Timber.d("Updating UserEntity table") + realm.schema.get("UserEntity") + ?.addRealmObjectField(UserEntityFields.CROSS_SIGNING_INFO_ENTITY.`$`, crossSigningInfoSchema) + + Timber.d("Updating CryptoMetadataEntity table") + realm.schema.get("CryptoMetadataEntity") + ?.addField(CryptoMetadataEntityFields.X_SIGN_MASTER_PRIVATE_KEY, String::class.java) + ?.addField(CryptoMetadataEntityFields.X_SIGN_USER_PRIVATE_KEY, String::class.java) + ?.addField(CryptoMetadataEntityFields.X_SIGN_SELF_SIGNED_PRIVATE_KEY, String::class.java) + + val moshi = Moshi.Builder().add(SerializeNulls.JSON_ADAPTER_FACTORY).build() + val listMigrationAdapter = moshi.adapter>(Types.newParameterizedType( + List::class.java, + String::class.java, + Any::class.java + )) + val mapMigrationAdapter = moshi.adapter(Types.newParameterizedType( + Map::class.java, + String::class.java, + Any::class.java + )) + + realm.schema.get("DeviceInfoEntity") + ?.addField(DeviceInfoEntityFields.USER_ID, String::class.java) + ?.addField(DeviceInfoEntityFields.ALGORITHM_LIST_JSON, String::class.java) + ?.addField(DeviceInfoEntityFields.KEYS_MAP_JSON, String::class.java) + ?.addField(DeviceInfoEntityFields.SIGNATURE_MAP_JSON, String::class.java) + ?.addField(DeviceInfoEntityFields.UNSIGNED_MAP_JSON, String::class.java) + ?.addField(DeviceInfoEntityFields.IS_BLOCKED, Boolean::class.java) + ?.setNullable(DeviceInfoEntityFields.IS_BLOCKED, true) + ?.addRealmObjectField(DeviceInfoEntityFields.TRUST_LEVEL_ENTITY.`$`, trustLevelEntityEntitySchema) + ?.transform { obj -> + + try { + val oldSerializedData = obj.getString("deviceInfoData") + deserializeFromRealm(oldSerializedData)?.let { oldDevice -> + + val trustLevel = realm.createObject("TrustLevelEntity") + when (oldDevice.verified) { + MXDeviceInfo.DEVICE_VERIFICATION_UNKNOWN -> { + obj.setNull(DeviceInfoEntityFields.TRUST_LEVEL_ENTITY.`$`) + } + MXDeviceInfo.DEVICE_VERIFICATION_BLOCKED -> { + trustLevel.setNull(TrustLevelEntityFields.LOCALLY_VERIFIED) + trustLevel.setNull(TrustLevelEntityFields.CROSS_SIGNED_VERIFIED) + obj.setBoolean(DeviceInfoEntityFields.IS_BLOCKED, oldDevice.isBlocked) + obj.setObject(DeviceInfoEntityFields.TRUST_LEVEL_ENTITY.`$`, trustLevel) + } + MXDeviceInfo.DEVICE_VERIFICATION_UNVERIFIED -> { + trustLevel.setBoolean(TrustLevelEntityFields.LOCALLY_VERIFIED, false) + trustLevel.setBoolean(TrustLevelEntityFields.CROSS_SIGNED_VERIFIED, false) + obj.setObject(DeviceInfoEntityFields.TRUST_LEVEL_ENTITY.`$`, trustLevel) + } + MXDeviceInfo.DEVICE_VERIFICATION_VERIFIED -> { + trustLevel.setBoolean(TrustLevelEntityFields.LOCALLY_VERIFIED, true) + trustLevel.setBoolean(TrustLevelEntityFields.CROSS_SIGNED_VERIFIED, false) + obj.setObject(DeviceInfoEntityFields.TRUST_LEVEL_ENTITY.`$`, trustLevel) + } + } + + obj.setString(DeviceInfoEntityFields.USER_ID, oldDevice.userId) + obj.setString(DeviceInfoEntityFields.IDENTITY_KEY, oldDevice.identityKey()) + obj.setString(DeviceInfoEntityFields.ALGORITHM_LIST_JSON, listMigrationAdapter.toJson(oldDevice.algorithms)) + obj.setString(DeviceInfoEntityFields.KEYS_MAP_JSON, mapMigrationAdapter.toJson(oldDevice.keys)) + obj.setString(DeviceInfoEntityFields.SIGNATURE_MAP_JSON, mapMigrationAdapter.toJson(oldDevice.signatures)) + obj.setString(DeviceInfoEntityFields.UNSIGNED_MAP_JSON, mapMigrationAdapter.toJson(oldDevice.unsigned)) + } + } catch (failure: Throwable) { + Timber.w(failure, "Crypto Data base migration error") + // an unfortunate refactor did modify that class, making deserialization failing + // so we just skip and ignore.. + } + } + ?.removeField("deviceInfoData") + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo005.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo005.kt new file mode 100644 index 0000000000..8365d34464 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo005.kt @@ -0,0 +1,66 @@ +/* + * 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.crypto.store.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.crypto.store.db.model.GossipingEventEntityFields +import org.matrix.android.sdk.internal.crypto.store.db.model.IncomingGossipingRequestEntityFields +import org.matrix.android.sdk.internal.crypto.store.db.model.OutgoingGossipingRequestEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateCryptoTo005(realm: DynamicRealm) : RealmMigrator(realm, 5) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.remove("OutgoingRoomKeyRequestEntity") + realm.schema.remove("IncomingRoomKeyRequestEntity") + + // Not need to migrate existing request, just start fresh? + + realm.schema.create("GossipingEventEntity") + .addField(GossipingEventEntityFields.TYPE, String::class.java) + .addIndex(GossipingEventEntityFields.TYPE) + .addField(GossipingEventEntityFields.CONTENT, String::class.java) + .addField(GossipingEventEntityFields.SENDER, String::class.java) + .addIndex(GossipingEventEntityFields.SENDER) + .addField(GossipingEventEntityFields.DECRYPTION_RESULT_JSON, String::class.java) + .addField(GossipingEventEntityFields.DECRYPTION_ERROR_CODE, String::class.java) + .addField(GossipingEventEntityFields.AGE_LOCAL_TS, Long::class.java) + .setNullable(GossipingEventEntityFields.AGE_LOCAL_TS, true) + .addField(GossipingEventEntityFields.SEND_STATE_STR, String::class.java) + + realm.schema.create("IncomingGossipingRequestEntity") + .addField(IncomingGossipingRequestEntityFields.REQUEST_ID, String::class.java) + .addIndex(IncomingGossipingRequestEntityFields.REQUEST_ID) + .addField(IncomingGossipingRequestEntityFields.TYPE_STR, String::class.java) + .addIndex(IncomingGossipingRequestEntityFields.TYPE_STR) + .addField(IncomingGossipingRequestEntityFields.OTHER_USER_ID, String::class.java) + .addField(IncomingGossipingRequestEntityFields.REQUESTED_INFO_STR, String::class.java) + .addField(IncomingGossipingRequestEntityFields.OTHER_DEVICE_ID, String::class.java) + .addField(IncomingGossipingRequestEntityFields.REQUEST_STATE_STR, String::class.java) + .addField(IncomingGossipingRequestEntityFields.LOCAL_CREATION_TIMESTAMP, Long::class.java) + .setNullable(IncomingGossipingRequestEntityFields.LOCAL_CREATION_TIMESTAMP, true) + + realm.schema.create("OutgoingGossipingRequestEntity") + .addField(OutgoingGossipingRequestEntityFields.REQUEST_ID, String::class.java) + .addIndex(OutgoingGossipingRequestEntityFields.REQUEST_ID) + .addField(OutgoingGossipingRequestEntityFields.RECIPIENTS_DATA, String::class.java) + .addField(OutgoingGossipingRequestEntityFields.REQUESTED_INFO_STR, String::class.java) + .addField(OutgoingGossipingRequestEntityFields.TYPE_STR, String::class.java) + .addIndex(OutgoingGossipingRequestEntityFields.TYPE_STR) + .addField(OutgoingGossipingRequestEntityFields.REQUEST_STATE_STR, String::class.java) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo006.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo006.kt new file mode 100644 index 0000000000..a29a791826 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo006.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.crypto.store.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.crypto.store.db.model.CryptoMetadataEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator +import timber.log.Timber + +class MigrateCryptoTo006(realm: DynamicRealm) : RealmMigrator(realm, 6) { + + override fun doMigrate(realm: DynamicRealm) { + Timber.d("Updating CryptoMetadataEntity table") + realm.schema.get("CryptoMetadataEntity") + ?.addField(CryptoMetadataEntityFields.KEY_BACKUP_RECOVERY_KEY, String::class.java) + ?.addField(CryptoMetadataEntityFields.KEY_BACKUP_RECOVERY_KEY_VERSION, String::class.java) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo007.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo007.kt new file mode 100644 index 0000000000..7ae58e7fc0 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo007.kt @@ -0,0 +1,65 @@ +/* + * 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.crypto.store.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.crypto.model.OlmInboundGroupSessionWrapper +import org.matrix.android.sdk.internal.crypto.model.OlmInboundGroupSessionWrapper2 +import org.matrix.android.sdk.internal.crypto.store.db.deserializeFromRealm +import org.matrix.android.sdk.internal.crypto.store.db.mapper.CrossSigningKeysMapper +import org.matrix.android.sdk.internal.crypto.store.db.model.KeyInfoEntityFields +import org.matrix.android.sdk.internal.crypto.store.db.model.OlmInboundGroupSessionEntityFields +import org.matrix.android.sdk.internal.crypto.store.db.serializeForRealm +import org.matrix.android.sdk.internal.di.MoshiProvider +import org.matrix.android.sdk.internal.util.database.RealmMigrator +import timber.log.Timber + +class MigrateCryptoTo007(realm: DynamicRealm) : RealmMigrator(realm, 7) { + + override fun doMigrate(realm: DynamicRealm) { + Timber.d("Updating KeyInfoEntity table") + val crossSigningKeysMapper = CrossSigningKeysMapper(MoshiProvider.providesMoshi()) + + val keyInfoEntities = realm.where("KeyInfoEntity").findAll() + try { + keyInfoEntities.forEach { + val stringSignatures = it.getString(KeyInfoEntityFields.SIGNATURES) + val objectSignatures: Map>? = deserializeFromRealm(stringSignatures) + val jsonSignatures = crossSigningKeysMapper.serializeSignatures(objectSignatures) + it.setString(KeyInfoEntityFields.SIGNATURES, jsonSignatures) + } + } catch (failure: Throwable) { + } + + // Migrate frozen classes + val inboundGroupSessions = realm.where("OlmInboundGroupSessionEntity").findAll() + inboundGroupSessions.forEach { dynamicObject -> + dynamicObject.getString(OlmInboundGroupSessionEntityFields.OLM_INBOUND_GROUP_SESSION_DATA)?.let { serializedObject -> + try { + deserializeFromRealm(serializedObject)?.let { oldFormat -> + val newFormat = oldFormat.exportKeys()?.let { + OlmInboundGroupSessionWrapper2(it) + } + dynamicObject.setString(OlmInboundGroupSessionEntityFields.OLM_INBOUND_GROUP_SESSION_DATA, serializeForRealm(newFormat)) + } + } catch (failure: Throwable) { + Timber.e(failure, "## OlmInboundGroupSessionEntity migration failed") + } + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo008.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo008.kt new file mode 100644 index 0000000000..e3bd3f035a --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo008.kt @@ -0,0 +1,46 @@ +/* + * 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.crypto.store.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.internal.crypto.store.db.model.DeviceInfoEntityFields +import org.matrix.android.sdk.internal.crypto.store.db.model.MyDeviceLastSeenInfoEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateCryptoTo008(realm: DynamicRealm) : RealmMigrator(realm, 8) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.create("MyDeviceLastSeenInfoEntity") + .addField(MyDeviceLastSeenInfoEntityFields.DEVICE_ID, String::class.java) + .addPrimaryKey(MyDeviceLastSeenInfoEntityFields.DEVICE_ID) + .addField(MyDeviceLastSeenInfoEntityFields.DISPLAY_NAME, String::class.java) + .addField(MyDeviceLastSeenInfoEntityFields.LAST_SEEN_IP, String::class.java) + .addField(MyDeviceLastSeenInfoEntityFields.LAST_SEEN_TS, Long::class.java) + .setNullable(MyDeviceLastSeenInfoEntityFields.LAST_SEEN_TS, true) + + val now = System.currentTimeMillis() + realm.schema.get("DeviceInfoEntity") + ?.addField(DeviceInfoEntityFields.FIRST_TIME_SEEN_LOCAL_TS, Long::class.java) + ?.setNullable(DeviceInfoEntityFields.FIRST_TIME_SEEN_LOCAL_TS, true) + ?.transform { deviceInfoEntity -> + tryOrNull { + deviceInfoEntity.setLong(DeviceInfoEntityFields.FIRST_TIME_SEEN_LOCAL_TS, now) + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo009.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo009.kt new file mode 100644 index 0000000000..ed705318f9 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo009.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.crypto.store.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.crypto.store.db.model.DeviceInfoEntityFields +import org.matrix.android.sdk.internal.crypto.store.db.model.UserEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator +import timber.log.Timber + +// Fixes duplicate devices in UserEntity#devices +class MigrateCryptoTo009(realm: DynamicRealm) : RealmMigrator(realm, 9) { + + override fun doMigrate(realm: DynamicRealm) { + val userEntities = realm.where("UserEntity").findAll() + userEntities.forEach { + try { + val deviceList = it.getList(UserEntityFields.DEVICES.`$`) + ?: return@forEach + val distinct = deviceList.distinctBy { it.getString(DeviceInfoEntityFields.DEVICE_ID) } + if (distinct.size != deviceList.size) { + deviceList.clear() + deviceList.addAll(distinct) + } + } catch (failure: Throwable) { + Timber.w(failure, "Crypto Data base migration error for migrateTo9") + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo010.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo010.kt new file mode 100644 index 0000000000..8d69ee5558 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo010.kt @@ -0,0 +1,50 @@ +/* + * 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.crypto.store.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.crypto.store.db.model.SharedSessionEntityFields +import org.matrix.android.sdk.internal.crypto.store.db.model.WithHeldSessionEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +// Version 10L added WithHeld Keys Info (MSC2399) +class MigrateCryptoTo010(realm: DynamicRealm) : RealmMigrator(realm, 10) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.create("WithHeldSessionEntity") + .addField(WithHeldSessionEntityFields.ROOM_ID, String::class.java) + .addField(WithHeldSessionEntityFields.ALGORITHM, String::class.java) + .addField(WithHeldSessionEntityFields.SESSION_ID, String::class.java) + .addIndex(WithHeldSessionEntityFields.SESSION_ID) + .addField(WithHeldSessionEntityFields.SENDER_KEY, String::class.java) + .addIndex(WithHeldSessionEntityFields.SENDER_KEY) + .addField(WithHeldSessionEntityFields.CODE_STRING, String::class.java) + .addField(WithHeldSessionEntityFields.REASON, String::class.java) + + realm.schema.create("SharedSessionEntity") + .addField(SharedSessionEntityFields.ROOM_ID, String::class.java) + .addField(SharedSessionEntityFields.ALGORITHM, String::class.java) + .addField(SharedSessionEntityFields.SESSION_ID, String::class.java) + .addIndex(SharedSessionEntityFields.SESSION_ID) + .addField(SharedSessionEntityFields.USER_ID, String::class.java) + .addIndex(SharedSessionEntityFields.USER_ID) + .addField(SharedSessionEntityFields.DEVICE_ID, String::class.java) + .addIndex(SharedSessionEntityFields.DEVICE_ID) + .addField(SharedSessionEntityFields.CHAIN_INDEX, Long::class.java) + .setNullable(SharedSessionEntityFields.CHAIN_INDEX, true) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo011.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo011.kt new file mode 100644 index 0000000000..c9825a7f3d --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo011.kt @@ -0,0 +1,30 @@ +/* + * 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.crypto.store.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.crypto.store.db.model.CryptoMetadataEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +// Version 11L added deviceKeysSentToServer boolean to CryptoMetadataEntity +class MigrateCryptoTo011(realm: DynamicRealm) : RealmMigrator(realm, 11) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("CryptoMetadataEntity") + ?.addField(CryptoMetadataEntityFields.DEVICE_KEYS_SENT_TO_SERVER, Boolean::class.java) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo012.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo012.kt new file mode 100644 index 0000000000..6b1460d9d6 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo012.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.crypto.store.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.crypto.store.db.model.CryptoRoomEntityFields +import org.matrix.android.sdk.internal.crypto.store.db.model.OutboundGroupSessionInfoEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +// Version 12L added outbound group session persistence +class MigrateCryptoTo012(realm: DynamicRealm) : RealmMigrator(realm, 12) { + + override fun doMigrate(realm: DynamicRealm) { + val outboundEntitySchema = realm.schema.create("OutboundGroupSessionInfoEntity") + .addField(OutboundGroupSessionInfoEntityFields.SERIALIZED_OUTBOUND_SESSION_DATA, String::class.java) + .addField(OutboundGroupSessionInfoEntityFields.CREATION_TIME, Long::class.java) + .setNullable(OutboundGroupSessionInfoEntityFields.CREATION_TIME, true) + + realm.schema.get("CryptoRoomEntity") + ?.addRealmObjectField(CryptoRoomEntityFields.OUTBOUND_SESSION_INFO.`$`, outboundEntitySchema) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo013.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo013.kt new file mode 100644 index 0000000000..dc22c5f133 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo013.kt @@ -0,0 +1,78 @@ +/* + * 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.crypto.store.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.util.database.RealmMigrator +import timber.log.Timber + +// Version 13L delete unreferenced TrustLevelEntity +class MigrateCryptoTo013(realm: DynamicRealm) : RealmMigrator(realm, 13) { + + override fun doMigrate(realm: DynamicRealm) { + // Use a trick to do that... Ref: https://stackoverflow.com/questions/55221366 + val trustLevelEntitySchema = realm.schema.get("TrustLevelEntity") + + /* + Creating a new temp field called isLinked which is set to true for those which are + references by other objects. Rest of them are set to false. Then removing all + those which are false and hence duplicate and unnecessary. Then removing the temp field + isLinked + */ + var mainCounter = 0 + var deviceInfoCounter = 0 + var keyInfoCounter = 0 + val deleteCounter: Int + + trustLevelEntitySchema + ?.addField("isLinked", Boolean::class.java) + ?.transform { obj -> + // Setting to false for all by default + obj.set("isLinked", false) + mainCounter++ + } + + realm.schema.get("DeviceInfoEntity")?.transform { obj -> + // Setting to true for those which are referenced in DeviceInfoEntity + deviceInfoCounter++ + obj.getObject("trustLevelEntity")?.set("isLinked", true) + } + + realm.schema.get("KeyInfoEntity")?.transform { obj -> + // Setting to true for those which are referenced in KeyInfoEntity + keyInfoCounter++ + obj.getObject("trustLevelEntity")?.set("isLinked", true) + } + + // Removing all those which are set as false + realm.where("TrustLevelEntity") + .equalTo("isLinked", false) + .findAll() + .also { deleteCounter = it.size } + .deleteAllFromRealm() + + trustLevelEntitySchema?.removeField("isLinked") + + Timber.w("TrustLevelEntity cleanup: $mainCounter entities") + Timber.w("TrustLevelEntity cleanup: $deviceInfoCounter entities referenced in DeviceInfoEntities") + Timber.w("TrustLevelEntity cleanup: $keyInfoCounter entities referenced in KeyInfoEntity") + Timber.w("TrustLevelEntity cleanup: $deleteCounter entities deleted!") + if (mainCounter != deviceInfoCounter + keyInfoCounter + deleteCounter) { + Timber.e("TrustLevelEntity cleanup: Something is not correct...") + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo014.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo014.kt new file mode 100644 index 0000000000..f0089e3427 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo014.kt @@ -0,0 +1,41 @@ +/* + * 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.crypto.store.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.crypto.store.db.model.DeviceInfoEntityFields +import org.matrix.android.sdk.internal.crypto.store.db.model.SharedSessionEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +// Version 14L Update the way we remember key sharing +class MigrateCryptoTo014(realm: DynamicRealm) : RealmMigrator(realm, 14) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("SharedSessionEntity") + ?.addField(SharedSessionEntityFields.DEVICE_IDENTITY_KEY, String::class.java) + ?.addIndex(SharedSessionEntityFields.DEVICE_IDENTITY_KEY) + ?.transform { + val sharedUserId = it.getString(SharedSessionEntityFields.USER_ID) + val sharedDeviceId = it.getString(SharedSessionEntityFields.DEVICE_ID) + val knownDevice = realm.where("DeviceInfoEntity") + .equalTo(DeviceInfoEntityFields.USER_ID, sharedUserId) + .equalTo(DeviceInfoEntityFields.DEVICE_ID, sharedDeviceId) + .findFirst() + it.setString(SharedSessionEntityFields.DEVICE_IDENTITY_KEY, knownDevice?.getString(DeviceInfoEntityFields.IDENTITY_KEY)) + } + } +} 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 1f45ac2a75..4bf352c06c 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 @@ -17,36 +17,32 @@ package org.matrix.android.sdk.internal.database import io.realm.DynamicRealm -import io.realm.FieldAttribute import io.realm.RealmMigration -import org.matrix.android.sdk.api.session.events.model.EventType -import org.matrix.android.sdk.api.session.room.model.Membership -import org.matrix.android.sdk.api.session.room.model.RoomJoinRulesContent -import org.matrix.android.sdk.api.session.room.model.VersioningState -import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent -import org.matrix.android.sdk.api.session.room.model.tag.RoomTag -import org.matrix.android.sdk.internal.crypto.model.event.EncryptionEventContent -import org.matrix.android.sdk.internal.database.model.ChunkEntityFields -import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntityFields -import org.matrix.android.sdk.internal.database.model.EditAggregatedSummaryEntityFields -import org.matrix.android.sdk.internal.database.model.EditionOfEventFields -import org.matrix.android.sdk.internal.database.model.EventEntityFields -import org.matrix.android.sdk.internal.database.model.EventInsertEntityFields -import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntityFields -import org.matrix.android.sdk.internal.database.model.PendingThreePidEntityFields -import org.matrix.android.sdk.internal.database.model.PreviewUrlCacheEntityFields -import org.matrix.android.sdk.internal.database.model.RoomAccountDataEntityFields -import org.matrix.android.sdk.internal.database.model.RoomEntityFields -import org.matrix.android.sdk.internal.database.model.RoomMemberSummaryEntityFields -import org.matrix.android.sdk.internal.database.model.RoomMembersLoadStatusType -import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields -import org.matrix.android.sdk.internal.database.model.RoomTagEntityFields -import org.matrix.android.sdk.internal.database.model.SpaceChildSummaryEntityFields -import org.matrix.android.sdk.internal.database.model.SpaceParentSummaryEntityFields -import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields -import org.matrix.android.sdk.internal.database.model.presence.UserPresenceEntityFields -import org.matrix.android.sdk.internal.di.MoshiProvider -import org.matrix.android.sdk.internal.query.process +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo001 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo002 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo003 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo004 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo005 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo006 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo007 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo008 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo009 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo010 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo011 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo012 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo013 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo014 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo015 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo016 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo017 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo018 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo019 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo020 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo021 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo022 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo023 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo024 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo025 import org.matrix.android.sdk.internal.util.Normalizer import timber.log.Timber import javax.inject.Inject @@ -54,11 +50,6 @@ import javax.inject.Inject internal class RealmSessionStoreMigration @Inject constructor( private val normalizer: Normalizer ) : RealmMigration { - - companion object { - const val SESSION_STORE_SCHEMA_VERSION = 21L - } - /** * Forces all RealmSessionStoreMigration instances to be equal * Avoids Realm throwing when multiple instances of the migration are set @@ -66,383 +57,35 @@ internal class RealmSessionStoreMigration @Inject constructor( override fun equals(other: Any?) = other is RealmSessionStoreMigration override fun hashCode() = 1000 + val schemaVersion = 24L + override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { - Timber.v("Migrating Realm Session from $oldVersion to $newVersion") + Timber.d("Migrating Realm Session from $oldVersion to $newVersion") - if (oldVersion <= 0) migrateTo1(realm) - if (oldVersion <= 1) migrateTo2(realm) - if (oldVersion <= 2) migrateTo3(realm) - if (oldVersion <= 3) migrateTo4(realm) - if (oldVersion <= 4) migrateTo5(realm) - if (oldVersion <= 5) migrateTo6(realm) - if (oldVersion <= 6) migrateTo7(realm) - if (oldVersion <= 7) migrateTo8(realm) - if (oldVersion <= 8) migrateTo9(realm) - if (oldVersion <= 9) migrateTo10(realm) - if (oldVersion <= 10) migrateTo11(realm) - if (oldVersion <= 11) migrateTo12(realm) - if (oldVersion <= 12) migrateTo13(realm) - if (oldVersion <= 13) migrateTo14(realm) - if (oldVersion <= 14) migrateTo15(realm) - if (oldVersion <= 15) migrateTo16(realm) - if (oldVersion <= 16) migrateTo17(realm) - if (oldVersion <= 17) migrateTo18(realm) - if (oldVersion <= 18) migrateTo19(realm) - if (oldVersion <= 19) migrateTo20(realm) - if (oldVersion <= 20) migrateTo21(realm) - } - - private fun migrateTo1(realm: DynamicRealm) { - Timber.d("Step 0 -> 1") - // Add hasFailedSending in RoomSummary and a small warning icon on room list - - realm.schema.get("RoomSummaryEntity") - ?.addField(RoomSummaryEntityFields.HAS_FAILED_SENDING, Boolean::class.java) - ?.transform { obj -> - obj.setBoolean(RoomSummaryEntityFields.HAS_FAILED_SENDING, false) - } - } - - private fun migrateTo2(realm: DynamicRealm) { - Timber.d("Step 1 -> 2") - realm.schema.get("HomeServerCapabilitiesEntity") - ?.addField("adminE2EByDefault", Boolean::class.java) - ?.transform { obj -> - obj.setBoolean("adminE2EByDefault", true) - } - } - - private fun migrateTo3(realm: DynamicRealm) { - Timber.d("Step 2 -> 3") - realm.schema.get("HomeServerCapabilitiesEntity") - ?.addField("preferredJitsiDomain", String::class.java) - ?.transform { obj -> - // Schedule a refresh of the capabilities - obj.setLong(HomeServerCapabilitiesEntityFields.LAST_UPDATED_TIMESTAMP, 0) - } - } - - private fun migrateTo4(realm: DynamicRealm) { - Timber.d("Step 3 -> 4") - realm.schema.create("PendingThreePidEntity") - .addField(PendingThreePidEntityFields.CLIENT_SECRET, String::class.java) - .setRequired(PendingThreePidEntityFields.CLIENT_SECRET, true) - .addField(PendingThreePidEntityFields.EMAIL, String::class.java) - .addField(PendingThreePidEntityFields.MSISDN, String::class.java) - .addField(PendingThreePidEntityFields.SEND_ATTEMPT, Int::class.java) - .addField(PendingThreePidEntityFields.SID, String::class.java) - .setRequired(PendingThreePidEntityFields.SID, true) - .addField(PendingThreePidEntityFields.SUBMIT_URL, String::class.java) - } - - private fun migrateTo5(realm: DynamicRealm) { - Timber.d("Step 4 -> 5") - realm.schema.get("HomeServerCapabilitiesEntity") - ?.removeField("adminE2EByDefault") - ?.removeField("preferredJitsiDomain") - } - - private fun migrateTo6(realm: DynamicRealm) { - Timber.d("Step 5 -> 6") - realm.schema.create("PreviewUrlCacheEntity") - .addField(PreviewUrlCacheEntityFields.URL, String::class.java) - .setRequired(PreviewUrlCacheEntityFields.URL, true) - .addPrimaryKey(PreviewUrlCacheEntityFields.URL) - .addField(PreviewUrlCacheEntityFields.URL_FROM_SERVER, String::class.java) - .addField(PreviewUrlCacheEntityFields.SITE_NAME, String::class.java) - .addField(PreviewUrlCacheEntityFields.TITLE, String::class.java) - .addField(PreviewUrlCacheEntityFields.DESCRIPTION, String::class.java) - .addField(PreviewUrlCacheEntityFields.MXC_URL, String::class.java) - .addField(PreviewUrlCacheEntityFields.LAST_UPDATED_TIMESTAMP, Long::class.java) - } - - private fun migrateTo7(realm: DynamicRealm) { - Timber.d("Step 6 -> 7") - realm.schema.get("RoomEntity") - ?.addField(RoomEntityFields.MEMBERS_LOAD_STATUS_STR, String::class.java) - ?.transform { obj -> - if (obj.getBoolean("areAllMembersLoaded")) { - obj.setString("membersLoadStatusStr", RoomMembersLoadStatusType.LOADED.name) - } else { - obj.setString("membersLoadStatusStr", RoomMembersLoadStatusType.NONE.name) - } - } - ?.removeField("areAllMembersLoaded") - } - - private fun migrateTo8(realm: DynamicRealm) { - Timber.d("Step 7 -> 8") - - val editionOfEventSchema = realm.schema.create("EditionOfEvent") - .addField(EditionOfEventFields.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(EditionOfEventFields.TIMESTAMP, Long::class.java) - .addField(EditionOfEventFields.IS_LOCAL_ECHO, Boolean::class.java) - - realm.schema.get("EditAggregatedSummaryEntity") - ?.removeField("aggregatedContent") - ?.removeField("sourceEvents") - ?.removeField("lastEditTs") - ?.removeField("sourceLocalEchoEvents") - ?.addRealmListField(EditAggregatedSummaryEntityFields.EDITIONS.`$`, editionOfEventSchema) - - // This has to be done once a parent use the model as a child - // See https://github.com/realm/realm-java/issues/7402 - editionOfEventSchema.isEmbedded = true - } - - private fun migrateTo9(realm: DynamicRealm) { - Timber.d("Step 8 -> 9") - - realm.schema.get("RoomSummaryEntity") - ?.addField(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, Long::class.java, FieldAttribute.INDEXED) - ?.setNullable(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, true) - ?.addIndex(RoomSummaryEntityFields.MEMBERSHIP_STR) - ?.addIndex(RoomSummaryEntityFields.IS_DIRECT) - ?.addIndex(RoomSummaryEntityFields.VERSIONING_STATE_STR) - - ?.addField(RoomSummaryEntityFields.IS_FAVOURITE, Boolean::class.java) - ?.addIndex(RoomSummaryEntityFields.IS_FAVOURITE) - ?.addField(RoomSummaryEntityFields.IS_LOW_PRIORITY, Boolean::class.java) - ?.addIndex(RoomSummaryEntityFields.IS_LOW_PRIORITY) - ?.addField(RoomSummaryEntityFields.IS_SERVER_NOTICE, Boolean::class.java) - ?.addIndex(RoomSummaryEntityFields.IS_SERVER_NOTICE) - - ?.transform { obj -> - val isFavorite = obj.getList(RoomSummaryEntityFields.TAGS.`$`).any { - it.getString(RoomTagEntityFields.TAG_NAME) == RoomTag.ROOM_TAG_FAVOURITE - } - obj.setBoolean(RoomSummaryEntityFields.IS_FAVOURITE, isFavorite) - - val isLowPriority = obj.getList(RoomSummaryEntityFields.TAGS.`$`).any { - it.getString(RoomTagEntityFields.TAG_NAME) == RoomTag.ROOM_TAG_LOW_PRIORITY - } - - obj.setBoolean(RoomSummaryEntityFields.IS_LOW_PRIORITY, isLowPriority) - -// XXX migrate last message origin server ts - obj.getObject(RoomSummaryEntityFields.LATEST_PREVIEWABLE_EVENT.`$`) - ?.getObject(TimelineEventEntityFields.ROOT.`$`) - ?.getLong(EventEntityFields.ORIGIN_SERVER_TS)?.let { - obj.setLong(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, it) - } - } - } - - private fun migrateTo10(realm: DynamicRealm) { - Timber.d("Step 9 -> 10") - realm.schema.create("SpaceChildSummaryEntity") - ?.addField(SpaceChildSummaryEntityFields.ORDER, String::class.java) - ?.addField(SpaceChildSummaryEntityFields.CHILD_ROOM_ID, String::class.java) - ?.addField(SpaceChildSummaryEntityFields.AUTO_JOIN, Boolean::class.java) - ?.setNullable(SpaceChildSummaryEntityFields.AUTO_JOIN, true) - ?.addRealmObjectField(SpaceChildSummaryEntityFields.CHILD_SUMMARY_ENTITY.`$`, realm.schema.get("RoomSummaryEntity")!!) - ?.addRealmListField(SpaceChildSummaryEntityFields.VIA_SERVERS.`$`, String::class.java) - - realm.schema.create("SpaceParentSummaryEntity") - ?.addField(SpaceParentSummaryEntityFields.PARENT_ROOM_ID, String::class.java) - ?.addField(SpaceParentSummaryEntityFields.CANONICAL, Boolean::class.java) - ?.setNullable(SpaceParentSummaryEntityFields.CANONICAL, true) - ?.addRealmObjectField(SpaceParentSummaryEntityFields.PARENT_SUMMARY_ENTITY.`$`, realm.schema.get("RoomSummaryEntity")!!) - ?.addRealmListField(SpaceParentSummaryEntityFields.VIA_SERVERS.`$`, String::class.java) - - val creationContentAdapter = MoshiProvider.providesMoshi().adapter(RoomCreateContent::class.java) - realm.schema.get("RoomSummaryEntity") - ?.addField(RoomSummaryEntityFields.ROOM_TYPE, String::class.java) - ?.addField(RoomSummaryEntityFields.FLATTEN_PARENT_IDS, String::class.java) - ?.addField(RoomSummaryEntityFields.GROUP_IDS, String::class.java) - ?.transform { obj -> - - val creationEvent = realm.where("CurrentStateEventEntity") - .equalTo(CurrentStateEventEntityFields.ROOM_ID, obj.getString(RoomSummaryEntityFields.ROOM_ID)) - .equalTo(CurrentStateEventEntityFields.TYPE, EventType.STATE_ROOM_CREATE) - .findFirst() - - val roomType = creationEvent?.getObject(CurrentStateEventEntityFields.ROOT.`$`) - ?.getString(EventEntityFields.CONTENT)?.let { - creationContentAdapter.fromJson(it)?.type - } - - obj.setString(RoomSummaryEntityFields.ROOM_TYPE, roomType) - } - ?.addRealmListField(RoomSummaryEntityFields.PARENTS.`$`, realm.schema.get("SpaceParentSummaryEntity")!!) - ?.addRealmListField(RoomSummaryEntityFields.CHILDREN.`$`, realm.schema.get("SpaceChildSummaryEntity")!!) - } - - private fun migrateTo11(realm: DynamicRealm) { - Timber.d("Step 10 -> 11") - realm.schema.get("EventEntity") - ?.addField(EventEntityFields.SEND_STATE_DETAILS, String::class.java) - } - - private fun migrateTo12(realm: DynamicRealm) { - Timber.d("Step 11 -> 12") - - val joinRulesContentAdapter = MoshiProvider.providesMoshi().adapter(RoomJoinRulesContent::class.java) - realm.schema.get("RoomSummaryEntity") - ?.addField(RoomSummaryEntityFields.JOIN_RULES_STR, String::class.java) - ?.transform { obj -> - val joinRulesEvent = realm.where("CurrentStateEventEntity") - .equalTo(CurrentStateEventEntityFields.ROOM_ID, obj.getString(RoomSummaryEntityFields.ROOM_ID)) - .equalTo(CurrentStateEventEntityFields.TYPE, EventType.STATE_ROOM_JOIN_RULES) - .findFirst() - - val roomJoinRules = joinRulesEvent?.getObject(CurrentStateEventEntityFields.ROOT.`$`) - ?.getString(EventEntityFields.CONTENT)?.let { - joinRulesContentAdapter.fromJson(it)?.joinRules - } - - obj.setString(RoomSummaryEntityFields.JOIN_RULES_STR, roomJoinRules?.name) - } - - realm.schema.get("SpaceChildSummaryEntity") - ?.addField(SpaceChildSummaryEntityFields.SUGGESTED, Boolean::class.java) - ?.setNullable(SpaceChildSummaryEntityFields.SUGGESTED, true) - } - - private fun migrateTo13(realm: DynamicRealm) { - Timber.d("Step 12 -> 13") - // Fix issue with the nightly build. Eventually play again the migration which has been included in migrateTo12() - realm.schema.get("SpaceChildSummaryEntity") - ?.takeIf { !it.hasField(SpaceChildSummaryEntityFields.SUGGESTED) } - ?.addField(SpaceChildSummaryEntityFields.SUGGESTED, Boolean::class.java) - ?.setNullable(SpaceChildSummaryEntityFields.SUGGESTED, true) - } - - private fun migrateTo14(realm: DynamicRealm) { - Timber.d("Step 13 -> 14") - val roomAccountDataSchema = realm.schema.create("RoomAccountDataEntity") - .addField(RoomAccountDataEntityFields.CONTENT_STR, String::class.java) - .addField(RoomAccountDataEntityFields.TYPE, String::class.java, FieldAttribute.INDEXED) - - realm.schema.get("RoomEntity") - ?.addRealmListField(RoomEntityFields.ACCOUNT_DATA.`$`, roomAccountDataSchema) - - realm.schema.get("RoomSummaryEntity") - ?.addField(RoomSummaryEntityFields.IS_HIDDEN_FROM_USER, Boolean::class.java, FieldAttribute.INDEXED) - ?.transform { - val isHiddenFromUser = it.getString(RoomSummaryEntityFields.VERSIONING_STATE_STR) == VersioningState.UPGRADED_ROOM_JOINED.name - it.setBoolean(RoomSummaryEntityFields.IS_HIDDEN_FROM_USER, isHiddenFromUser) - } - - roomAccountDataSchema.isEmbedded = true - } - - private fun migrateTo15(realm: DynamicRealm) { - Timber.d("Step 14 -> 15") - // fix issue with flattenParentIds on DM that kept growing with duplicate - // so we reset it, will be updated next sync - realm.where("RoomSummaryEntity") - .process(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.activeMemberships()) - .equalTo(RoomSummaryEntityFields.IS_DIRECT, true) - .findAll() - .onEach { - it.setString(RoomSummaryEntityFields.FLATTEN_PARENT_IDS, null) - } - } - - private fun migrateTo16(realm: DynamicRealm) { - Timber.d("Step 15 -> 16") - realm.schema.get("HomeServerCapabilitiesEntity") - ?.addField(HomeServerCapabilitiesEntityFields.ROOM_VERSIONS_JSON, String::class.java) - ?.transform { obj -> - // Schedule a refresh of the capabilities - obj.setLong(HomeServerCapabilitiesEntityFields.LAST_UPDATED_TIMESTAMP, 0) - } - } - - private fun migrateTo17(realm: DynamicRealm) { - Timber.d("Step 16 -> 17") - realm.schema.get("EventInsertEntity") - ?.addField(EventInsertEntityFields.CAN_BE_PROCESSED, Boolean::class.java) - } - - private fun migrateTo18(realm: DynamicRealm) { - Timber.d("Step 17 -> 18") - realm.schema.create("UserPresenceEntity") - ?.addField(UserPresenceEntityFields.USER_ID, String::class.java) - ?.addPrimaryKey(UserPresenceEntityFields.USER_ID) - ?.setRequired(UserPresenceEntityFields.USER_ID, true) - ?.addField(UserPresenceEntityFields.PRESENCE_STR, String::class.java) - ?.addField(UserPresenceEntityFields.LAST_ACTIVE_AGO, Long::class.java) - ?.setNullable(UserPresenceEntityFields.LAST_ACTIVE_AGO, true) - ?.addField(UserPresenceEntityFields.STATUS_MESSAGE, String::class.java) - ?.addField(UserPresenceEntityFields.IS_CURRENTLY_ACTIVE, Boolean::class.java) - ?.setNullable(UserPresenceEntityFields.IS_CURRENTLY_ACTIVE, true) - ?.addField(UserPresenceEntityFields.AVATAR_URL, String::class.java) - ?.addField(UserPresenceEntityFields.DISPLAY_NAME, String::class.java) - - val userPresenceEntity = realm.schema.get("UserPresenceEntity") ?: return - realm.schema.get("RoomSummaryEntity") - ?.addRealmObjectField(RoomSummaryEntityFields.DIRECT_USER_PRESENCE.`$`, userPresenceEntity) - - realm.schema.get("RoomMemberSummaryEntity") - ?.addRealmObjectField(RoomMemberSummaryEntityFields.USER_PRESENCE_ENTITY.`$`, userPresenceEntity) - } - - private fun migrateTo19(realm: DynamicRealm) { - Timber.d("Step 18 -> 19") - realm.schema.get("RoomSummaryEntity") - ?.addField(RoomSummaryEntityFields.NORMALIZED_DISPLAY_NAME, String::class.java) - ?.transform { - it.getString(RoomSummaryEntityFields.DISPLAY_NAME)?.let { displayName -> - val normalised = normalizer.normalize(displayName) - it.set(RoomSummaryEntityFields.NORMALIZED_DISPLAY_NAME, normalised) - } - } - } - - private fun migrateTo20(realm: DynamicRealm) { - Timber.d("Step 19 -> 20") - - realm.schema.get("ChunkEntity")?.apply { - if (hasField("numberOfTimelineEvents")) { - removeField("numberOfTimelineEvents") - } - var cleanOldChunks = false - if (!hasField(ChunkEntityFields.NEXT_CHUNK.`$`)) { - cleanOldChunks = true - addRealmObjectField(ChunkEntityFields.NEXT_CHUNK.`$`, this) - } - if (!hasField(ChunkEntityFields.PREV_CHUNK.`$`)) { - cleanOldChunks = true - addRealmObjectField(ChunkEntityFields.PREV_CHUNK.`$`, this) - } - if (cleanOldChunks) { - val chunkEntities = realm.where("ChunkEntity").equalTo(ChunkEntityFields.IS_LAST_FORWARD, false).findAll() - chunkEntities.deleteAllFromRealm() - } - } - } - - private fun migrateTo21(realm: DynamicRealm) { - Timber.d("Step 20 -> 21") - - realm.schema.get("RoomSummaryEntity") - ?.addField(RoomSummaryEntityFields.E2E_ALGORITHM, String::class.java) - ?.transform { obj -> - - val encryptionContentAdapter = MoshiProvider.providesMoshi().adapter(EncryptionEventContent::class.java) - - val encryptionEvent = realm.where("CurrentStateEventEntity") - .equalTo(CurrentStateEventEntityFields.ROOM_ID, obj.getString(RoomSummaryEntityFields.ROOM_ID)) - .equalTo(CurrentStateEventEntityFields.TYPE, EventType.STATE_ROOM_ENCRYPTION) - .findFirst() - - val encryptionEventRoot = encryptionEvent?.getObject(CurrentStateEventEntityFields.ROOT.`$`) - val algorithm = encryptionEventRoot - ?.getString(EventEntityFields.CONTENT)?.let { - encryptionContentAdapter.fromJson(it)?.algorithm - } - - obj.setString(RoomSummaryEntityFields.E2E_ALGORITHM, algorithm) - obj.setBoolean(RoomSummaryEntityFields.IS_ENCRYPTED, encryptionEvent != null) - encryptionEventRoot?.getLong(EventEntityFields.ORIGIN_SERVER_TS)?.let { - obj.setLong(RoomSummaryEntityFields.ENCRYPTION_EVENT_TS, it) - } - } + if (oldVersion < 1) MigrateSessionTo001(realm).perform() + if (oldVersion < 2) MigrateSessionTo002(realm).perform() + if (oldVersion < 3) MigrateSessionTo003(realm).perform() + if (oldVersion < 4) MigrateSessionTo004(realm).perform() + if (oldVersion < 5) MigrateSessionTo005(realm).perform() + if (oldVersion < 6) MigrateSessionTo006(realm).perform() + if (oldVersion < 7) MigrateSessionTo007(realm).perform() + if (oldVersion < 8) MigrateSessionTo008(realm).perform() + if (oldVersion < 9) MigrateSessionTo009(realm).perform() + if (oldVersion < 10) MigrateSessionTo010(realm).perform() + if (oldVersion < 11) MigrateSessionTo011(realm).perform() + if (oldVersion < 12) MigrateSessionTo012(realm).perform() + if (oldVersion < 13) MigrateSessionTo013(realm).perform() + if (oldVersion < 14) MigrateSessionTo014(realm).perform() + if (oldVersion < 15) MigrateSessionTo015(realm).perform() + if (oldVersion < 16) MigrateSessionTo016(realm).perform() + if (oldVersion < 17) MigrateSessionTo017(realm).perform() + if (oldVersion < 18) MigrateSessionTo018(realm).perform() + if (oldVersion < 19) MigrateSessionTo019(realm, normalizer).perform() + if (oldVersion < 20) MigrateSessionTo020(realm).perform() + if (oldVersion < 21) MigrateSessionTo021(realm).perform() + if (oldVersion < 22) MigrateSessionTo022(realm).perform() + if (oldVersion < 23) MigrateSessionTo023(realm).perform() + if (oldVersion < 24) MigrateSessionTo024(realm).perform() + if (oldVersion < 25) MigrateSessionTo025(realm).perform() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/SessionRealmConfigurationFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/SessionRealmConfigurationFactory.kt index 04ca26a943..08d55b5647 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/SessionRealmConfigurationFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/SessionRealmConfigurationFactory.kt @@ -71,7 +71,7 @@ internal class SessionRealmConfigurationFactory @Inject constructor( } .allowWritesOnUiThread(true) .modules(SessionRealmModule()) - .schemaVersion(RealmSessionStoreMigration.SESSION_STORE_SCHEMA_VERSION) + .schemaVersion(realmSessionStoreMigration.schemaVersion) .migration(realmSessionStoreMigration) .build() 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 c21bf74d93..289db9fa15 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 @@ -34,6 +34,7 @@ import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields import org.matrix.android.sdk.internal.database.query.find import org.matrix.android.sdk.internal.database.query.getOrCreate import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.database.query.whereRoomId import org.matrix.android.sdk.internal.extensions.assertIsManaged import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection import timber.log.Timber @@ -81,7 +82,7 @@ internal fun ChunkEntity.addStateEvent(roomId: String, stateEvent: EventEntity, internal fun ChunkEntity.addTimelineEvent(roomId: String, eventEntity: EventEntity, direction: PaginationDirection, - roomMemberContentsByUser: Map) { + roomMemberContentsByUser: Map? = null) { val eventId = eventEntity.eventId if (timelineEvents.find(eventId) != null) { return @@ -101,7 +102,7 @@ internal fun ChunkEntity.addTimelineEvent(roomId: String, ?.also { it.cleanUp(eventEntity.sender) } this.readReceipts = readReceiptsSummaryEntity this.displayIndex = displayIndex - val roomMemberContent = roomMemberContentsByUser[senderId] + val roomMemberContent = roomMemberContentsByUser?.get(senderId) this.senderAvatar = roomMemberContent?.avatarUrl this.senderName = roomMemberContent?.displayName isUniqueDisplayName = if (roomMemberContent?.displayName != null) { @@ -157,9 +158,21 @@ private fun ChunkEntity.addTimelineEventFromMerge(realm: Realm, timelineEventEnt this.senderName = timelineEventEntity.senderName this.isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName } + handleThreadSummary(realm, eventId, copied) timelineEvents.add(copied) } +/** + * Upon copy of the timeline events we should update the latestMessage TimelineEventEntity with the new one + */ +private fun handleThreadSummary(realm: Realm, oldEventId: String, newTimelineEventEntity: TimelineEventEntity) { + EventEntity + .whereRoomId(realm, newTimelineEventEntity.roomId) + .equalTo(EventEntityFields.IS_ROOT_THREAD, true) + .equalTo(EventEntityFields.THREAD_SUMMARY_LATEST_MESSAGE.EVENT_ID, oldEventId) + .findFirst()?.threadSummaryLatestMessage = newTimelineEventEntity +} + private fun handleReadReceipts(realm: Realm, roomId: String, eventEntity: EventEntity, senderId: String): ReadReceiptsSummaryEntity { val readReceiptsSummaryEntity = ReadReceiptsSummaryEntity.where(realm, eventEntity.eventId).findFirst() ?: realm.createObject(eventEntity.eventId).apply { 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 new file mode 100644 index 0000000000..f703bfaf82 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt @@ -0,0 +1,321 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.helper + +import io.realm.Realm +import io.realm.RealmQuery +import io.realm.Sort +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.threads.ThreadNotificationState +import org.matrix.android.sdk.internal.database.mapper.asDomain +import org.matrix.android.sdk.internal.database.model.ChunkEntity +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.ReadReceiptEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields +import org.matrix.android.sdk.internal.database.query.find +import org.matrix.android.sdk.internal.database.query.findIncludingEvent +import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom +import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.database.query.whereRoomId + +private typealias ThreadSummary = Pair? + +/** + * Finds the root thread event and update it with the latest message summary along with the number + * of threads included. If there is no root thread event no action is done + */ +internal fun Map.updateThreadSummaryIfNeeded( + roomId: String, + realm: Realm, currentUserId: String, + chunkEntity: ChunkEntity? = null, + shouldUpdateNotifications: Boolean = true) { + for ((rootThreadEventId, eventEntity) in this) { + eventEntity.threadSummaryInThread(eventEntity.realm, rootThreadEventId, chunkEntity)?.let { threadSummary -> + + val numberOfMessages = threadSummary.first + val latestEventInThread = threadSummary.second + + // If this is a thread message, find its root event if exists + val rootThreadEvent = if (eventEntity.isThread()) eventEntity.findRootThreadEvent() else eventEntity + + rootThreadEvent?.markEventAsRoot( + threadsCounted = numberOfMessages, + latestMessageTimelineEventEntity = latestEventInThread + ) + } + } + + if (shouldUpdateNotifications) { + updateNotificationsNew(roomId, realm, currentUserId) + } +} + +/** + * Finds the root event of the the current thread event message. + * Returns the EventEntity or null if the root event do not exist + */ +internal fun EventEntity.findRootThreadEvent(): EventEntity? = + rootThreadEventId?.let { + EventEntity + .where(realm, it) + .findFirst() + } + +/** + * Mark or update the current event a root thread event + */ +internal fun EventEntity.markEventAsRoot( + threadsCounted: Int, + latestMessageTimelineEventEntity: TimelineEventEntity?) { + isRootThread = true + numberOfThreads = threadsCounted + threadSummaryLatestMessage = latestMessageTimelineEventEntity +} + +/** + * Count the number of threads for the provided root thread eventId, and finds the latest event message + * @param rootThreadEventId The root eventId that will find the number of threads + * @return A ThreadSummary containing the counted threads and the latest event message + */ +internal fun EventEntity.threadSummaryInThread(realm: Realm, rootThreadEventId: String, chunkEntity: ChunkEntity?): ThreadSummary { + // Number of messages + val messages = TimelineEventEntity + .whereRoomId(realm, roomId = roomId) + .equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId) + .count() + .toInt() + + if (messages <= 0) return null + + // Find latest thread event, we know it exists + var chunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId) ?: chunkEntity ?: return null + var result: TimelineEventEntity? = null + + // Iterate the chunk until we find our latest event + while (result == null) { + result = findLatestSortedChunkEvent(chunk, rootThreadEventId) + chunk = ChunkEntity.find(realm, roomId, nextToken = chunk.prevToken) ?: break + } + + if (result == null && chunkEntity != null) { + // Find latest event from our current chunk + result = findLatestSortedChunkEvent(chunkEntity, rootThreadEventId) + } else if (result != null && chunkEntity != null) { + val currentChunkLatestEvent = findLatestSortedChunkEvent(chunkEntity, rootThreadEventId) + result = findMostRecentEvent(result, currentChunkLatestEvent) + } + + result ?: return null + + return ThreadSummary(messages, result) +} + +/** + * Lets compare them in case user is moving forward in the timeline and we cannot know the + * exact chunk sequence while currentChunk is not yet committed in the DB + */ +private fun findMostRecentEvent(result: TimelineEventEntity, currentChunkLatestEvent: TimelineEventEntity?): TimelineEventEntity { + currentChunkLatestEvent ?: return result + val currentChunkEventTimestamp = currentChunkLatestEvent.root?.originServerTs ?: return result + val resultTimestamp = result.root?.originServerTs ?: return result + if (currentChunkEventTimestamp > resultTimestamp) { + return currentChunkLatestEvent + } + return result +} + +/** + * Find the latest event of the current chunk + */ +private fun findLatestSortedChunkEvent(chunk: ChunkEntity, rootThreadEventId: String): TimelineEventEntity? = + chunk.timelineEvents.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)?.firstOrNull { + it.root?.rootThreadEventId == rootThreadEventId + } + +/** + * Find all TimelineEventEntity that are root threads for the specified room + * @param roomId The room that all stored root threads will be returned + */ +internal fun TimelineEventEntity.Companion.findAllThreadsForRoomId(realm: Realm, roomId: String): RealmQuery = + TimelineEventEntity + .whereRoomId(realm, roomId = roomId) + .equalTo(TimelineEventEntityFields.ROOT.IS_ROOT_THREAD, true) + .sort("${TimelineEventEntityFields.ROOT.THREAD_SUMMARY_LATEST_MESSAGE}.${TimelineEventEntityFields.ROOT.ORIGIN_SERVER_TS}", Sort.DESCENDING) + +/** + * Map each root thread TimelineEvent with the equivalent decrypted text edition/replacement + */ +internal fun List.mapEventsWithEdition(realm: Realm, roomId: String): List = + this.map { + EventAnnotationsSummaryEntity + .where(realm, roomId, eventId = it.eventId) + .findFirst() + ?.editSummary + ?.editions + ?.lastOrNull() + ?.eventId + ?.let { editedEventId -> + TimelineEventEntity.where(realm, roomId, eventId = editedEventId).findFirst()?.let { editedEvent -> + it.root.threadDetails = it.root.threadDetails?.copy(lastRootThreadEdition = editedEvent.root?.asDomain()?.getDecryptedTextSummary() + ?: "(edited)") + it + } ?: it + } ?: it + } + +/** + * Returns a list of all the marked unread threads that exists for the specified room + * @param roomId The roomId that the user is currently in + */ +internal fun TimelineEventEntity.Companion.findAllLocalThreadNotificationsForRoomId(realm: Realm, roomId: String): RealmQuery = + TimelineEventEntity + .whereRoomId(realm, roomId = roomId) + .equalTo(TimelineEventEntityFields.ROOT.IS_ROOT_THREAD, true) + .beginGroup() + .equalTo(TimelineEventEntityFields.ROOT.THREAD_NOTIFICATION_STATE_STR, ThreadNotificationState.NEW_MESSAGE.name) + .or() + .equalTo(TimelineEventEntityFields.ROOT.THREAD_NOTIFICATION_STATE_STR, ThreadNotificationState.NEW_HIGHLIGHTED_MESSAGE.name) + .endGroup() + +/** + * Returns whether or not the given user is participating in a current thread + * @param roomId the room that the thread exists + * @param rootThreadEventId the thread that the search will be done + * @param senderId the user that will try to find participation + */ +internal fun TimelineEventEntity.Companion.isUserParticipatingInThread(realm: Realm, roomId: String, rootThreadEventId: String, senderId: String): Boolean = + TimelineEventEntity + .whereRoomId(realm, roomId = roomId) + .equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId) + .equalTo(TimelineEventEntityFields.ROOT.SENDER, senderId) + .findFirst() + ?.let { true } + ?: false + +/** + * Returns whether or not the given user is mentioned in a current thread + * @param roomId the room that the thread exists + * @param rootThreadEventId the thread that the search will be done + * @param userId the user that will try to find if there is a mention + */ +internal fun TimelineEventEntity.Companion.isUserMentionedInThread(realm: Realm, roomId: String, rootThreadEventId: String, userId: String): Boolean = + TimelineEventEntity + .whereRoomId(realm, roomId = roomId) + .equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId) + .equalTo(TimelineEventEntityFields.ROOT.SENDER, userId) + .findAll() + .firstOrNull { isUserMentioned(userId, it) } + ?.let { true } + ?: false + +/** + * 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) + .findFirst() + ?.eventId + +/** + * Returns whether or not the user is mentioned in the event + */ +internal fun isUserMentioned(currentUserId: String, timelineEventEntity: TimelineEventEntity?): Boolean { + return timelineEventEntity?.root?.asDomain()?.isUserMentioned(currentUserId) == true +} + +/** + * Update badge notifications. Count the number of new thread events after the latest + * read receipt and aggregate. This function will find and notify new thread events + * that the user is either mentioned, or the user had participated in. + * Important: If the root thread event is not fetched notification will not work + * 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 + + val readReceiptChunk = ChunkEntity + .findIncludingEvent(realm, readReceipt) ?: return + + val readReceiptChunkTimelineEvents = readReceiptChunk + .timelineEvents + .where() + .equalTo(TimelineEventEntityFields.ROOM_ID, roomId) + .sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.ASCENDING) + .findAll() ?: return + + val readReceiptChunkPosition = readReceiptChunkTimelineEvents.indexOfFirst { it.eventId == readReceipt } + + if (readReceiptChunkPosition == -1) return + + if (readReceiptChunkPosition < readReceiptChunkTimelineEvents.lastIndex) { + // If the read receipt is found inside the chunk + + val threadEventsAfterReadReceipt = readReceiptChunkTimelineEvents + .slice(readReceiptChunkPosition..readReceiptChunkTimelineEvents.lastIndex) + .filter { it.root?.isThread() == true } + + // In order for the below code to work for old events, we should save the previous read receipt + // and then continue with the chunk search for that read receipt + /* + val newThreadEventsList = arrayListOf() + newThreadEventsList.addAll(threadEventsAfterReadReceipt) + + // got from latest chunk all new threads, lets move to the others + var nextChunk = ChunkEntity + .find(realm = realm, roomId = roomId, nextToken = readReceiptChunk.nextToken) + .takeIf { readReceiptChunk.nextToken != null } + while (nextChunk != null) { + newThreadEventsList.addAll(nextChunk.timelineEvents + .filter { it.root?.isThread() == true }) + nextChunk = ChunkEntity + .find(realm = realm, roomId = roomId, nextToken = nextChunk.nextToken) + .takeIf { readReceiptChunk.nextToken != null } + }*/ + + // Find if the user is mentioned in those events + val userMentionsList = threadEventsAfterReadReceipt + .filter { + isUserMentioned(currentUserId = currentUserId, it) + }.map { + it.root?.rootThreadEventId + } + + // Find the root events in the new thread events + val rootThreads = threadEventsAfterReadReceipt.distinctBy { it.root?.rootThreadEventId }.mapNotNull { it.root?.rootThreadEventId } + + // Update root thread events only if the user have participated in + rootThreads.forEach { eventId -> + val isUserParticipating = TimelineEventEntity.isUserParticipatingInThread( + realm = realm, + roomId = roomId, + rootThreadEventId = eventId, + senderId = currentUserId) + val rootThreadEventEntity = EventEntity.where(realm, eventId).findFirst() + + if (isUserParticipating) { + rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_MESSAGE + } + + if (userMentionsList.contains(eventId)) { + rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_HIGHLIGHTED_MESSAGE + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/lightweight/LightweightSettingsStorage.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/lightweight/LightweightSettingsStorage.kt new file mode 100644 index 0000000000..700b94a985 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/lightweight/LightweightSettingsStorage.kt @@ -0,0 +1,47 @@ +/* + * 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.lightweight + +import android.content.Context +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import javax.inject.Inject + +/** + * The purpose of this class is to provide an alternative and lightweight way to store settings/data + * on the sdi without using the database. This should be used just for sdk/user preferences and + * not for large data sets + */ + +class LightweightSettingsStorage @Inject constructor(context: Context) { + + private val sdkDefaultPrefs = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) + + fun setThreadMessagesEnabled(enabled: Boolean) { + sdkDefaultPrefs.edit { + putBoolean(MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED, enabled) + } + } + + fun areThreadMessagesEnabled(): Boolean { + return sdkDefaultPrefs.getBoolean(MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED, false) + } + + companion object { + const val MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED = "MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED" + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt index 613b38e340..9c420e81fd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt @@ -21,7 +21,11 @@ import org.matrix.android.sdk.api.session.crypto.MXCryptoError 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.UnsignedData +import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId import org.matrix.android.sdk.api.session.room.send.SendState +import org.matrix.android.sdk.api.session.room.sender.SenderInfo +import org.matrix.android.sdk.api.session.threads.ThreadDetails +import org.matrix.android.sdk.api.session.threads.ThreadNotificationState import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.di.MoshiProvider @@ -51,6 +55,10 @@ internal object EventMapper { } eventEntity.decryptionErrorReason = event.mCryptoErrorReason eventEntity.decryptionErrorCode = event.mCryptoError?.name + eventEntity.isRootThread = event.threadDetails?.isRootThread ?: false + eventEntity.rootThreadEventId = event.getRootThreadEventId() + eventEntity.numberOfThreads = event.threadDetails?.numberOfThreads ?: 0 + eventEntity.threadNotificationState = event.threadDetails?.threadNotificationState ?: ThreadNotificationState.NO_NEW_MESSAGE return eventEntity } @@ -93,6 +101,23 @@ internal object EventMapper { MXCryptoError.ErrorType.valueOf(errorCode) } it.mCryptoErrorReason = eventEntity.decryptionErrorReason + it.threadDetails = ThreadDetails( + isRootThread = eventEntity.isRootThread, + isThread = if (it.threadDetails?.isThread == true) true else eventEntity.isThread(), + numberOfThreads = eventEntity.numberOfThreads, + threadSummarySenderInfo = eventEntity.threadSummaryLatestMessage?.let { timelineEventEntity -> + SenderInfo( + userId = timelineEventEntity.root?.sender ?: "", + displayName = timelineEventEntity.senderName, + isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName, + avatarUrl = timelineEventEntity.senderAvatar + ) + }, + threadNotificationState = eventEntity.threadNotificationState, + threadSummaryLatestTextMessage = eventEntity.threadSummaryLatestMessage?.root?.asDomain()?.getDecryptedTextSummary(), + lastMessageTimestamp = eventEntity.threadSummaryLatestMessage?.root?.originServerTs + + ) } } } @@ -101,9 +126,15 @@ internal fun EventEntity.asDomain(castJsonNumbers: Boolean = false): Event { return EventMapper.map(this, castJsonNumbers) } -internal fun Event.toEntity(roomId: String, sendState: SendState, ageLocalTs: Long?): EventEntity { +internal fun Event.toEntity(roomId: String, sendState: SendState, ageLocalTs: Long?, contentToInject: String? = null): EventEntity { return EventMapper.map(this, roomId).apply { this.sendState = sendState this.ageLocalTs = ageLocalTs + contentToInject?.let { + this.content = it + if (this.type == EventType.STICKER) { + this.type = EventType.MESSAGE + } + } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt index 8b6d263f8c..7869506015 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt @@ -35,6 +35,9 @@ internal object HomeServerCapabilitiesMapper { fun map(entity: HomeServerCapabilitiesEntity): HomeServerCapabilities { return HomeServerCapabilities( canChangePassword = entity.canChangePassword, + canChangeDisplayName = entity.canChangeDisplayName, + canChangeAvatar = entity.canChangeAvatar, + canChange3pid = entity.canChange3pid, maxUploadFileSize = entity.maxUploadFileSize, lastVersionIdentityServerSupported = entity.lastVersionIdentityServerSupported, defaultIdentityServerUrl = entity.defaultIdentityServerUrl, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo001.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo001.kt new file mode 100644 index 0000000000..831c6280ad --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo001.kt @@ -0,0 +1,33 @@ +/* + * 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.RoomSummaryEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo001(realm: DynamicRealm) : RealmMigrator(realm, 1) { + + override fun doMigrate(realm: DynamicRealm) { + // Add hasFailedSending in RoomSummary and a small warning icon on room list + realm.schema.get("RoomSummaryEntity") + ?.addField(RoomSummaryEntityFields.HAS_FAILED_SENDING, Boolean::class.java) + ?.transform { obj -> + obj.setBoolean(RoomSummaryEntityFields.HAS_FAILED_SENDING, false) + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo002.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo002.kt new file mode 100644 index 0000000000..215e558e2a --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo002.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo002(realm: DynamicRealm) : RealmMigrator(realm, 2) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("HomeServerCapabilitiesEntity") + ?.addField("adminE2EByDefault", Boolean::class.java) + ?.transform { obj -> + obj.setBoolean("adminE2EByDefault", true) + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo003.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo003.kt new file mode 100644 index 0000000000..bc0b79d7e6 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo003.kt @@ -0,0 +1,30 @@ +/* + * 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.extensions.forceRefreshOfHomeServerCapabilities +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo003(realm: DynamicRealm) : RealmMigrator(realm, 3) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("HomeServerCapabilitiesEntity") + ?.addField("preferredJitsiDomain", String::class.java) + ?.forceRefreshOfHomeServerCapabilities() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo004.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo004.kt new file mode 100644 index 0000000000..be13ae2c2f --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo004.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.database.model.PendingThreePidEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo004(realm: DynamicRealm) : RealmMigrator(realm, 4) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.create("PendingThreePidEntity") + .addField(PendingThreePidEntityFields.CLIENT_SECRET, String::class.java) + .setRequired(PendingThreePidEntityFields.CLIENT_SECRET, true) + .addField(PendingThreePidEntityFields.EMAIL, String::class.java) + .addField(PendingThreePidEntityFields.MSISDN, String::class.java) + .addField(PendingThreePidEntityFields.SEND_ATTEMPT, Int::class.java) + .addField(PendingThreePidEntityFields.SID, String::class.java) + .setRequired(PendingThreePidEntityFields.SID, true) + .addField(PendingThreePidEntityFields.SUBMIT_URL, String::class.java) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo005.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo005.kt new file mode 100644 index 0000000000..b4826b23a4 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo005.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.util.database.RealmMigrator + +class MigrateSessionTo005(realm: DynamicRealm) : RealmMigrator(realm, 5) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("HomeServerCapabilitiesEntity") + ?.removeField("adminE2EByDefault") + ?.removeField("preferredJitsiDomain") + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo006.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo006.kt new file mode 100644 index 0000000000..3d7f26ccee --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo006.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.database.model.PreviewUrlCacheEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo006(realm: DynamicRealm) : RealmMigrator(realm, 6) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.create("PreviewUrlCacheEntity") + .addField(PreviewUrlCacheEntityFields.URL, String::class.java) + .setRequired(PreviewUrlCacheEntityFields.URL, true) + .addPrimaryKey(PreviewUrlCacheEntityFields.URL) + .addField(PreviewUrlCacheEntityFields.URL_FROM_SERVER, String::class.java) + .addField(PreviewUrlCacheEntityFields.SITE_NAME, String::class.java) + .addField(PreviewUrlCacheEntityFields.TITLE, String::class.java) + .addField(PreviewUrlCacheEntityFields.DESCRIPTION, String::class.java) + .addField(PreviewUrlCacheEntityFields.MXC_URL, String::class.java) + .addField(PreviewUrlCacheEntityFields.LAST_UPDATED_TIMESTAMP, Long::class.java) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo007.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo007.kt new file mode 100644 index 0000000000..be8c8ce9c6 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo007.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.RoomEntityFields +import org.matrix.android.sdk.internal.database.model.RoomMembersLoadStatusType +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo007(realm: DynamicRealm) : RealmMigrator(realm, 7) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("RoomEntity") + ?.addField(RoomEntityFields.MEMBERS_LOAD_STATUS_STR, String::class.java) + ?.transform { obj -> + if (obj.getBoolean("areAllMembersLoaded")) { + obj.setString("membersLoadStatusStr", RoomMembersLoadStatusType.LOADED.name) + } else { + obj.setString("membersLoadStatusStr", RoomMembersLoadStatusType.NONE.name) + } + } + ?.removeField("areAllMembersLoaded") + } +} 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 new file mode 100644 index 0000000000..d46730ef70 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo008.kt @@ -0,0 +1,47 @@ +/* + * 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.EditAggregatedSummaryEntityFields +import org.matrix.android.sdk.internal.database.model.EditionOfEventFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +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(EditionOfEventFields.EVENT_ID, String::class.java) + .setRequired(EditionOfEventFields.EVENT_ID, true) + .addField(EditionOfEventFields.SENDER_ID, String::class.java) + .setRequired(EditionOfEventFields.SENDER_ID, true) + .addField(EditionOfEventFields.TIMESTAMP, Long::class.java) + .addField(EditionOfEventFields.IS_LOCAL_ECHO, Boolean::class.java) + + realm.schema.get("EditAggregatedSummaryEntity") + ?.removeField("aggregatedContent") + ?.removeField("sourceEvents") + ?.removeField("lastEditTs") + ?.removeField("sourceLocalEchoEvents") + ?.addRealmListField(EditAggregatedSummaryEntityFields.EDITIONS.`$`, editionOfEventSchema) + + // This has to be done once a parent use the model as a child + // See https://github.com/realm/realm-java/issues/7402 + editionOfEventSchema.isEmbedded = true + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo009.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo009.kt new file mode 100644 index 0000000000..370430b9e3 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo009.kt @@ -0,0 +1,65 @@ +/* + * 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 io.realm.FieldAttribute +import org.matrix.android.sdk.api.session.room.model.tag.RoomTag +import org.matrix.android.sdk.internal.database.model.EventEntityFields +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields +import org.matrix.android.sdk.internal.database.model.RoomTagEntityFields +import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo009(realm: DynamicRealm) : RealmMigrator(realm, 9) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("RoomSummaryEntity") + ?.addField(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, Long::class.java, FieldAttribute.INDEXED) + ?.setNullable(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, true) + ?.addIndex(RoomSummaryEntityFields.MEMBERSHIP_STR) + ?.addIndex(RoomSummaryEntityFields.IS_DIRECT) + ?.addIndex(RoomSummaryEntityFields.VERSIONING_STATE_STR) + + ?.addField(RoomSummaryEntityFields.IS_FAVOURITE, Boolean::class.java) + ?.addIndex(RoomSummaryEntityFields.IS_FAVOURITE) + ?.addField(RoomSummaryEntityFields.IS_LOW_PRIORITY, Boolean::class.java) + ?.addIndex(RoomSummaryEntityFields.IS_LOW_PRIORITY) + ?.addField(RoomSummaryEntityFields.IS_SERVER_NOTICE, Boolean::class.java) + ?.addIndex(RoomSummaryEntityFields.IS_SERVER_NOTICE) + + ?.transform { obj -> + val isFavorite = obj.getList(RoomSummaryEntityFields.TAGS.`$`).any { + it.getString(RoomTagEntityFields.TAG_NAME) == RoomTag.ROOM_TAG_FAVOURITE + } + obj.setBoolean(RoomSummaryEntityFields.IS_FAVOURITE, isFavorite) + + val isLowPriority = obj.getList(RoomSummaryEntityFields.TAGS.`$`).any { + it.getString(RoomTagEntityFields.TAG_NAME) == RoomTag.ROOM_TAG_LOW_PRIORITY + } + + obj.setBoolean(RoomSummaryEntityFields.IS_LOW_PRIORITY, isLowPriority) + +// XXX migrate last message origin server ts + obj.getObject(RoomSummaryEntityFields.LATEST_PREVIEWABLE_EVENT.`$`) + ?.getObject(TimelineEventEntityFields.ROOT.`$`) + ?.getLong(EventEntityFields.ORIGIN_SERVER_TS)?.let { + obj.setLong(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, it) + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo010.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo010.kt new file mode 100644 index 0000000000..b968862d10 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo010.kt @@ -0,0 +1,70 @@ +/* + * 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.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent +import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntityFields +import org.matrix.android.sdk.internal.database.model.EventEntityFields +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields +import org.matrix.android.sdk.internal.database.model.SpaceChildSummaryEntityFields +import org.matrix.android.sdk.internal.database.model.SpaceParentSummaryEntityFields +import org.matrix.android.sdk.internal.di.MoshiProvider +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo010(realm: DynamicRealm) : RealmMigrator(realm, 10) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.create("SpaceChildSummaryEntity") + ?.addField(SpaceChildSummaryEntityFields.ORDER, String::class.java) + ?.addField(SpaceChildSummaryEntityFields.CHILD_ROOM_ID, String::class.java) + ?.addField(SpaceChildSummaryEntityFields.AUTO_JOIN, Boolean::class.java) + ?.setNullable(SpaceChildSummaryEntityFields.AUTO_JOIN, true) + ?.addRealmObjectField(SpaceChildSummaryEntityFields.CHILD_SUMMARY_ENTITY.`$`, realm.schema.get("RoomSummaryEntity")!!) + ?.addRealmListField(SpaceChildSummaryEntityFields.VIA_SERVERS.`$`, String::class.java) + + realm.schema.create("SpaceParentSummaryEntity") + ?.addField(SpaceParentSummaryEntityFields.PARENT_ROOM_ID, String::class.java) + ?.addField(SpaceParentSummaryEntityFields.CANONICAL, Boolean::class.java) + ?.setNullable(SpaceParentSummaryEntityFields.CANONICAL, true) + ?.addRealmObjectField(SpaceParentSummaryEntityFields.PARENT_SUMMARY_ENTITY.`$`, realm.schema.get("RoomSummaryEntity")!!) + ?.addRealmListField(SpaceParentSummaryEntityFields.VIA_SERVERS.`$`, String::class.java) + + val creationContentAdapter = MoshiProvider.providesMoshi().adapter(RoomCreateContent::class.java) + realm.schema.get("RoomSummaryEntity") + ?.addField(RoomSummaryEntityFields.ROOM_TYPE, String::class.java) + ?.addField(RoomSummaryEntityFields.FLATTEN_PARENT_IDS, String::class.java) + ?.addField(RoomSummaryEntityFields.GROUP_IDS, String::class.java) + ?.transform { obj -> + + val creationEvent = realm.where("CurrentStateEventEntity") + .equalTo(CurrentStateEventEntityFields.ROOM_ID, obj.getString(RoomSummaryEntityFields.ROOM_ID)) + .equalTo(CurrentStateEventEntityFields.TYPE, EventType.STATE_ROOM_CREATE) + .findFirst() + + val roomType = creationEvent?.getObject(CurrentStateEventEntityFields.ROOT.`$`) + ?.getString(EventEntityFields.CONTENT)?.let { + creationContentAdapter.fromJson(it)?.type + } + + obj.setString(RoomSummaryEntityFields.ROOM_TYPE, roomType) + } + ?.addRealmListField(RoomSummaryEntityFields.PARENTS.`$`, realm.schema.get("SpaceParentSummaryEntity")!!) + ?.addRealmListField(RoomSummaryEntityFields.CHILDREN.`$`, realm.schema.get("SpaceChildSummaryEntity")!!) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo011.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo011.kt new file mode 100644 index 0000000000..92ee26df42 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo011.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.EventEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo011(realm: DynamicRealm) : RealmMigrator(realm, 11) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("EventEntity") + ?.addField(EventEntityFields.SEND_STATE_DETAILS, String::class.java) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo012.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo012.kt new file mode 100644 index 0000000000..a914cadd80 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo012.kt @@ -0,0 +1,53 @@ +/* + * 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.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.room.model.RoomJoinRulesContent +import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntityFields +import org.matrix.android.sdk.internal.database.model.EventEntityFields +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields +import org.matrix.android.sdk.internal.database.model.SpaceChildSummaryEntityFields +import org.matrix.android.sdk.internal.di.MoshiProvider +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo012(realm: DynamicRealm) : RealmMigrator(realm, 12) { + + override fun doMigrate(realm: DynamicRealm) { + val joinRulesContentAdapter = MoshiProvider.providesMoshi().adapter(RoomJoinRulesContent::class.java) + realm.schema.get("RoomSummaryEntity") + ?.addField(RoomSummaryEntityFields.JOIN_RULES_STR, String::class.java) + ?.transform { obj -> + val joinRulesEvent = realm.where("CurrentStateEventEntity") + .equalTo(CurrentStateEventEntityFields.ROOM_ID, obj.getString(RoomSummaryEntityFields.ROOM_ID)) + .equalTo(CurrentStateEventEntityFields.TYPE, EventType.STATE_ROOM_JOIN_RULES) + .findFirst() + + val roomJoinRules = joinRulesEvent?.getObject(CurrentStateEventEntityFields.ROOT.`$`) + ?.getString(EventEntityFields.CONTENT)?.let { + joinRulesContentAdapter.fromJson(it)?.joinRules + } + + obj.setString(RoomSummaryEntityFields.JOIN_RULES_STR, roomJoinRules?.name) + } + + realm.schema.get("SpaceChildSummaryEntity") + ?.addField(SpaceChildSummaryEntityFields.SUGGESTED, Boolean::class.java) + ?.setNullable(SpaceChildSummaryEntityFields.SUGGESTED, true) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo013.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo013.kt new file mode 100644 index 0000000000..2ea0303802 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo013.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.database.model.SpaceChildSummaryEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo013(realm: DynamicRealm) : RealmMigrator(realm, 13) { + + override fun doMigrate(realm: DynamicRealm) { + // Fix issue with the nightly build. Eventually play again the migration which has been included in migrateTo12() + realm.schema.get("SpaceChildSummaryEntity") + ?.takeIf { !it.hasField(SpaceChildSummaryEntityFields.SUGGESTED) } + ?.addField(SpaceChildSummaryEntityFields.SUGGESTED, Boolean::class.java) + ?.setNullable(SpaceChildSummaryEntityFields.SUGGESTED, true) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo014.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo014.kt new file mode 100644 index 0000000000..c524b6f284 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo014.kt @@ -0,0 +1,46 @@ +/* + * 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 io.realm.FieldAttribute +import org.matrix.android.sdk.api.session.room.model.VersioningState +import org.matrix.android.sdk.internal.database.model.RoomAccountDataEntityFields +import org.matrix.android.sdk.internal.database.model.RoomEntityFields +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo014(realm: DynamicRealm) : RealmMigrator(realm, 14) { + + override fun doMigrate(realm: DynamicRealm) { + val roomAccountDataSchema = realm.schema.create("RoomAccountDataEntity") + .addField(RoomAccountDataEntityFields.CONTENT_STR, String::class.java) + .addField(RoomAccountDataEntityFields.TYPE, String::class.java, FieldAttribute.INDEXED) + + realm.schema.get("RoomEntity") + ?.addRealmListField(RoomEntityFields.ACCOUNT_DATA.`$`, roomAccountDataSchema) + + realm.schema.get("RoomSummaryEntity") + ?.addField(RoomSummaryEntityFields.IS_HIDDEN_FROM_USER, Boolean::class.java, FieldAttribute.INDEXED) + ?.transform { + val isHiddenFromUser = it.getString(RoomSummaryEntityFields.VERSIONING_STATE_STR) == VersioningState.UPGRADED_ROOM_JOINED.name + it.setBoolean(RoomSummaryEntityFields.IS_HIDDEN_FROM_USER, isHiddenFromUser) + } + + roomAccountDataSchema.isEmbedded = true + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo015.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo015.kt new file mode 100644 index 0000000000..329964a9a4 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo015.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.api.session.room.model.Membership +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields +import org.matrix.android.sdk.internal.query.process +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo015(realm: DynamicRealm) : RealmMigrator(realm, 15) { + + override fun doMigrate(realm: DynamicRealm) { + // fix issue with flattenParentIds on DM that kept growing with duplicate + // so we reset it, will be updated next sync + realm.where("RoomSummaryEntity") + .process(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.activeMemberships()) + .equalTo(RoomSummaryEntityFields.IS_DIRECT, true) + .findAll() + .onEach { + it.setString(RoomSummaryEntityFields.FLATTEN_PARENT_IDS, null) + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo016.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo016.kt new file mode 100644 index 0000000000..b2fa54a05c --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo016.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntityFields +import org.matrix.android.sdk.internal.extensions.forceRefreshOfHomeServerCapabilities +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo016(realm: DynamicRealm) : RealmMigrator(realm, 16) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("HomeServerCapabilitiesEntity") + ?.addField(HomeServerCapabilitiesEntityFields.ROOM_VERSIONS_JSON, String::class.java) + ?.forceRefreshOfHomeServerCapabilities() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo017.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo017.kt new file mode 100644 index 0000000000..95d67b9ad8 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo017.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.EventInsertEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo017(realm: DynamicRealm) : RealmMigrator(realm, 17) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("EventInsertEntity") + ?.addField(EventInsertEntityFields.CAN_BE_PROCESSED, Boolean::class.java) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo018.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo018.kt new file mode 100644 index 0000000000..b415c51d4b --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo018.kt @@ -0,0 +1,48 @@ +/* + * 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.RoomMemberSummaryEntityFields +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields +import org.matrix.android.sdk.internal.database.model.presence.UserPresenceEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo018(realm: DynamicRealm) : RealmMigrator(realm, 18) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.create("UserPresenceEntity") + ?.addField(UserPresenceEntityFields.USER_ID, String::class.java) + ?.addPrimaryKey(UserPresenceEntityFields.USER_ID) + ?.setRequired(UserPresenceEntityFields.USER_ID, true) + ?.addField(UserPresenceEntityFields.PRESENCE_STR, String::class.java) + ?.addField(UserPresenceEntityFields.LAST_ACTIVE_AGO, Long::class.java) + ?.setNullable(UserPresenceEntityFields.LAST_ACTIVE_AGO, true) + ?.addField(UserPresenceEntityFields.STATUS_MESSAGE, String::class.java) + ?.addField(UserPresenceEntityFields.IS_CURRENTLY_ACTIVE, Boolean::class.java) + ?.setNullable(UserPresenceEntityFields.IS_CURRENTLY_ACTIVE, true) + ?.addField(UserPresenceEntityFields.AVATAR_URL, String::class.java) + ?.addField(UserPresenceEntityFields.DISPLAY_NAME, String::class.java) + + val userPresenceEntity = realm.schema.get("UserPresenceEntity") ?: return + realm.schema.get("RoomSummaryEntity") + ?.addRealmObjectField(RoomSummaryEntityFields.DIRECT_USER_PRESENCE.`$`, userPresenceEntity) + + realm.schema.get("RoomMemberSummaryEntity") + ?.addRealmObjectField(RoomMemberSummaryEntityFields.USER_PRESENCE_ENTITY.`$`, userPresenceEntity) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo019.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo019.kt new file mode 100644 index 0000000000..d0b368be46 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo019.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields +import org.matrix.android.sdk.internal.util.Normalizer +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo019(realm: DynamicRealm, + private val normalizer: Normalizer) : RealmMigrator(realm, 19) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("RoomSummaryEntity") + ?.addField(RoomSummaryEntityFields.NORMALIZED_DISPLAY_NAME, String::class.java) + ?.transform { + it.getString(RoomSummaryEntityFields.DISPLAY_NAME)?.let { displayName -> + val normalised = normalizer.normalize(displayName) + it.set(RoomSummaryEntityFields.NORMALIZED_DISPLAY_NAME, normalised) + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo020.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo020.kt new file mode 100644 index 0000000000..c7f6e3ceed --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo020.kt @@ -0,0 +1,45 @@ +/* + * 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.ChunkEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo020(realm: DynamicRealm) : RealmMigrator(realm, 20) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("ChunkEntity")?.apply { + if (hasField("numberOfTimelineEvents")) { + removeField("numberOfTimelineEvents") + } + var cleanOldChunks = false + if (!hasField(ChunkEntityFields.NEXT_CHUNK.`$`)) { + cleanOldChunks = true + addRealmObjectField(ChunkEntityFields.NEXT_CHUNK.`$`, this) + } + if (!hasField(ChunkEntityFields.PREV_CHUNK.`$`)) { + cleanOldChunks = true + addRealmObjectField(ChunkEntityFields.PREV_CHUNK.`$`, this) + } + if (cleanOldChunks) { + val chunkEntities = realm.where("ChunkEntity").equalTo(ChunkEntityFields.IS_LAST_FORWARD, false).findAll() + chunkEntities.deleteAllFromRealm() + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo021.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo021.kt new file mode 100644 index 0000000000..6b6952e697 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo021.kt @@ -0,0 +1,55 @@ +/* + * 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.api.session.events.model.EventType +import org.matrix.android.sdk.internal.crypto.model.event.EncryptionEventContent +import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntityFields +import org.matrix.android.sdk.internal.database.model.EventEntityFields +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields +import org.matrix.android.sdk.internal.di.MoshiProvider +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo021(realm: DynamicRealm) : RealmMigrator(realm, 21) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("RoomSummaryEntity") + ?.addField(RoomSummaryEntityFields.E2E_ALGORITHM, String::class.java) + ?.transform { obj -> + + val encryptionContentAdapter = MoshiProvider.providesMoshi().adapter(EncryptionEventContent::class.java) + + val encryptionEvent = realm.where("CurrentStateEventEntity") + .equalTo(CurrentStateEventEntityFields.ROOM_ID, obj.getString(RoomSummaryEntityFields.ROOM_ID)) + .equalTo(CurrentStateEventEntityFields.TYPE, EventType.STATE_ROOM_ENCRYPTION) + .findFirst() + + val encryptionEventRoot = encryptionEvent?.getObject(CurrentStateEventEntityFields.ROOT.`$`) + val algorithm = encryptionEventRoot + ?.getString(EventEntityFields.CONTENT)?.let { + encryptionContentAdapter.fromJson(it)?.algorithm + } + + obj.setString(RoomSummaryEntityFields.E2E_ALGORITHM, algorithm) + obj.setBoolean(RoomSummaryEntityFields.IS_ENCRYPTED, encryptionEvent != null) + encryptionEventRoot?.getLong(EventEntityFields.ORIGIN_SERVER_TS)?.let { + obj.setLong(RoomSummaryEntityFields.ENCRYPTION_EVENT_TS, it) + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo022.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo022.kt new file mode 100644 index 0000000000..e78a9d05da --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo022.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.api.session.room.model.Membership +import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntityFields +import org.matrix.android.sdk.internal.database.model.RoomEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator +import timber.log.Timber + +class MigrateSessionTo022(realm: DynamicRealm) : RealmMigrator(realm, 22) { + + override fun doMigrate(realm: DynamicRealm) { + val listJoinedRoomIds = realm.where("RoomEntity") + .equalTo(RoomEntityFields.MEMBERSHIP_STR, Membership.JOIN.name).findAll() + .map { it.getString(RoomEntityFields.ROOM_ID) } + + val hasMissingStateEvent = realm.where("CurrentStateEventEntity") + .`in`(CurrentStateEventEntityFields.ROOM_ID, listJoinedRoomIds.toTypedArray()) + .isNull(CurrentStateEventEntityFields.ROOT.`$`).findFirst() != null + + if (hasMissingStateEvent) { + Timber.v("Has some missing state event, clear session cache") + realm.deleteAll() + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo023.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo023.kt new file mode 100644 index 0000000000..0bb8ceeaa5 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo023.kt @@ -0,0 +1,40 @@ +/* + * 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 io.realm.FieldAttribute +import org.matrix.android.sdk.api.session.threads.ThreadNotificationState +import org.matrix.android.sdk.internal.database.model.EventEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo023(realm: DynamicRealm) : RealmMigrator(realm, 23) { + + override fun doMigrate(realm: DynamicRealm) { + val eventEntity = realm.schema.get("TimelineEventEntity") ?: return + + realm.schema.get("EventEntity") + ?.addField(EventEntityFields.IS_ROOT_THREAD, Boolean::class.java, FieldAttribute.INDEXED) + ?.addField(EventEntityFields.ROOT_THREAD_EVENT_ID, String::class.java, FieldAttribute.INDEXED) + ?.addField(EventEntityFields.NUMBER_OF_THREADS, Int::class.java) + ?.addField(EventEntityFields.THREAD_NOTIFICATION_STATE_STR, String::class.java) + ?.transform { + it.setString(EventEntityFields.THREAD_NOTIFICATION_STATE_STR, ThreadNotificationState.NO_NEW_MESSAGE.name) + } + ?.addRealmObjectField(EventEntityFields.THREAD_SUMMARY_LATEST_MESSAGE.`$`, eventEntity) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo024.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo024.kt new file mode 100644 index 0000000000..ff88972566 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo024.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.database.model.PreviewUrlCacheEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo024(realm: DynamicRealm) : RealmMigrator(realm, 24) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("PreviewUrlCacheEntity") + ?.addField(PreviewUrlCacheEntityFields.IMAGE_WIDTH, Int::class.java) + ?.setNullable(PreviewUrlCacheEntityFields.IMAGE_WIDTH, true) + ?.addField(PreviewUrlCacheEntityFields.IMAGE_HEIGHT, Int::class.java) + ?.setNullable(PreviewUrlCacheEntityFields.IMAGE_HEIGHT, true) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo025.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo025.kt new file mode 100644 index 0000000000..237b016ac2 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo025.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntityFields +import org.matrix.android.sdk.internal.extensions.forceRefreshOfHomeServerCapabilities +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateSessionTo025(realm: DynamicRealm) : RealmMigrator(realm, 25) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("HomeServerCapabilitiesEntity") + ?.addField(HomeServerCapabilitiesEntityFields.CAN_CHANGE_DISPLAY_NAME, Boolean::class.java) + ?.addField(HomeServerCapabilitiesEntityFields.CAN_CHANGE_AVATAR, Boolean::class.java) + ?.addField(HomeServerCapabilitiesEntityFields.CAN_CHANGE3PID, Boolean::class.java) + ?.forceRefreshOfHomeServerCapabilities() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt index ecb602019a..c45c27ed08 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt @@ -52,6 +52,9 @@ internal fun ChunkEntity.deleteOnCascade(deleteStateEvents: Boolean, canDeleteRo if (deleteStateEvents) { stateEvents.deleteAllFromRealm() } - timelineEvents.clearWith { it.deleteOnCascade(canDeleteRoot) } + timelineEvents.clearWith { + val deleteRoot = canDeleteRoot && (it.root?.stateKey == null || deleteStateEvents) + it.deleteOnCascade(deleteRoot) + } deleteFromRealm() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt index ce2d1efc1d..445181e576 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt @@ -19,7 +19,7 @@ package org.matrix.android.sdk.internal.database.model import io.realm.RealmObject import io.realm.annotations.Index import org.matrix.android.sdk.api.session.room.send.SendState -import org.matrix.android.sdk.api.util.JsonDict +import org.matrix.android.sdk.api.session.threads.ThreadNotificationState import org.matrix.android.sdk.internal.crypto.MXEventDecryptionResult import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult import org.matrix.android.sdk.internal.di.MoshiProvider @@ -40,7 +40,12 @@ internal open class EventEntity(@Index var eventId: String = "", var unsignedData: String? = null, var redacts: String? = null, var decryptionResultJson: String? = null, - var ageLocalTs: Long? = null + var ageLocalTs: Long? = null, + // Thread related, no need to create a new Entity for performance + @Index var isRootThread: Boolean = false, + @Index var rootThreadEventId: String? = null, + var numberOfThreads: Int = 0, + var threadSummaryLatestMessage: TimelineEventEntity? = null ) : RealmObject() { private var sendStateStr: String = SendState.UNKNOWN.name @@ -53,6 +58,15 @@ internal open class EventEntity(@Index var eventId: String = "", sendStateStr = value.name } + private var threadNotificationStateStr: String = ThreadNotificationState.NO_NEW_MESSAGE.name + var threadNotificationState: ThreadNotificationState + get() { + return ThreadNotificationState.valueOf(threadNotificationStateStr) + } + set(value) { + threadNotificationStateStr = value.name + } + var decryptionErrorCode: String? = null set(value) { if (value != field) field = value @@ -65,10 +79,10 @@ internal open class EventEntity(@Index var eventId: String = "", companion object - fun setDecryptionResult(result: MXEventDecryptionResult, clearEvent: JsonDict? = null) { + fun setDecryptionResult(result: MXEventDecryptionResult) { assertIsManaged() val decryptionResult = OlmDecryptionResult( - payload = clearEvent ?: result.clearEvent, + payload = result.clearEvent, senderKey = result.senderCurve25519Key, keysClaimed = result.claimedEd25519Key?.let { mapOf("ed25519" to it) }, forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain @@ -84,4 +98,6 @@ internal open class EventEntity(@Index var eventId: String = "", .findFirst() ?.canBeProcessed = true } + + fun isThread(): Boolean = rootThreadEventId != null } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt index 980449ddfb..08ecd5995e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt @@ -21,6 +21,9 @@ import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities internal open class HomeServerCapabilitiesEntity( var canChangePassword: Boolean = true, + var canChangeDisplayName: Boolean = true, + var canChangeAvatar: Boolean = true, + var canChange3pid: Boolean = true, var roomVersionsJson: String? = null, var maxUploadFileSize: Long = HomeServerCapabilities.MAX_UPLOAD_FILE_SIZE_UNKNOWN, var lastVersionIdentityServerSupported: Boolean = false, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PreviewUrlCacheEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PreviewUrlCacheEntity.kt index b1e0b64405..f19d70a1f2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PreviewUrlCacheEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PreviewUrlCacheEntity.kt @@ -28,7 +28,8 @@ internal open class PreviewUrlCacheEntity( var title: String? = null, var description: String? = null, var mxcUrl: String? = null, - + var imageWidth: Int? = null, + var imageHeight: Int? = null, var lastUpdatedTimestamp: Long = 0L ) : RealmObject() { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventEntityQueries.kt index 240b2a0691..f7fa1037ba 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventEntityQueries.kt @@ -49,6 +49,11 @@ internal fun EventEntity.Companion.where(realm: Realm, eventId: String): RealmQu .equalTo(EventEntityFields.EVENT_ID, eventId) } +internal fun EventEntity.Companion.whereRoomId(realm: Realm, roomId: String): RealmQuery { + return realm.where() + .equalTo(EventEntityFields.ROOM_ID, roomId) +} + internal fun EventEntity.Companion.where(realm: Realm, eventIds: List): RealmQuery { return realm.where() .`in`(EventEntityFields.EVENT_ID, eventIds.toTypedArray()) @@ -85,3 +90,8 @@ internal fun RealmList.find(eventId: String): EventEntity? { internal fun RealmList.fastContains(eventId: String): Boolean { return this.find(eventId) != null } + +internal fun EventEntity.Companion.whereRootThreadEventId(realm: Realm, rootThreadEventId: String): RealmQuery { + return realm.where() + .equalTo(EventEntityFields.ROOT_THREAD_EVENT_ID, rootThreadEventId) +} 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 c9c96b9cc1..8cc99c3d2f 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 @@ -34,27 +34,29 @@ internal fun isEventRead(realmConfiguration: RealmConfiguration, if (LocalEcho.isLocalEchoId(eventId)) { return true } - // If we don't know if the event has been read, we assume it's not - var isEventRead = false - Realm.getInstance(realmConfiguration).use { realm -> - val latestEvent = TimelineEventEntity.latestEvent(realm, roomId, true) - // If latest event is from you we are sure the event is read - if (latestEvent?.root?.sender == userId) { - return true - } + return Realm.getInstance(realmConfiguration).use { realm -> val eventToCheck = TimelineEventEntity.where(realm, roomId, eventId).findFirst() - isEventRead = when { - eventToCheck == null -> false - eventToCheck.root?.sender == userId -> true - else -> { - val readReceipt = ReadReceiptEntity.where(realm, roomId, userId).findFirst() ?: return@use - val readReceiptEvent = TimelineEventEntity.where(realm, roomId, readReceipt.eventId).findFirst() ?: return@use - readReceiptEvent.isMoreRecentThan(eventToCheck) - } + when { + // The event doesn't exist locally, let's assume it hasn't been read + eventToCheck == null -> false + eventToCheck.root?.sender == userId -> true + // If new event exists and the latest event is from ourselves we can infer the event is read + latestEventIsFromSelf(realm, roomId, userId) -> true + eventToCheck.isBeforeLatestReadReceipt(realm, roomId, userId) -> true + else -> false } } - return isEventRead +} + +private fun latestEventIsFromSelf(realm: Realm, roomId: String, userId: String) = TimelineEventEntity.latestEvent(realm, roomId, true) + ?.root?.sender == userId + +private fun TimelineEventEntity.isBeforeLatestReadReceipt(realm: Realm, roomId: String, userId: String): Boolean { + return ReadReceiptEntity.where(realm, roomId, userId).findFirst()?.let { readReceipt -> + val readReceiptEvent = TimelineEventEntity.where(realm, roomId, readReceipt.eventId).findFirst() + readReceiptEvent?.isMoreRecentThan(this) + } ?: false } /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt index aa1ce41bb7..63f41ebf2c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt @@ -59,6 +59,7 @@ internal fun TimelineEventEntity.Companion.latestEvent(realm: Realm, filters: TimelineEventFilters = TimelineEventFilters()): TimelineEventEntity? { val roomEntity = RoomEntity.where(realm, roomId).findFirst() ?: return null val sendingTimelineEvents = roomEntity.sendingTimelineEvents.where().filterEvents(filters) + val liveEvents = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId)?.timelineEvents?.where()?.filterEvents(filters) val query = if (includesSending && sendingTimelineEvents.findAll().isNotEmpty()) { sendingTimelineEvents @@ -100,6 +101,7 @@ internal fun RealmQuery.filterEvents(filters: TimelineEvent if (filters.filterRedacted) { not().like(TimelineEventEntityFields.ROOT.UNSIGNED_DATA, TimelineEventFilter.Unsigned.REDACTED) } + return this } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/extensions/RealmExtensions.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/extensions/RealmExtensions.kt index e52e32e16a..28b9f64188 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/extensions/RealmExtensions.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/extensions/RealmExtensions.kt @@ -18,6 +18,8 @@ package org.matrix.android.sdk.internal.extensions import io.realm.RealmList import io.realm.RealmObject +import io.realm.RealmObjectSchema +import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntityFields internal fun RealmObject.assertIsManaged() { check(isManaged) { "${javaClass.simpleName} entity should be managed to use this function" } @@ -31,3 +33,12 @@ internal fun RealmList.clearWith(delete: (T) -> Unit) { first()?.let { delete.invoke(it) } } } + +/** + * Schedule a refresh of the HomeServers capabilities + */ +internal fun RealmObjectSchema?.forceRefreshOfHomeServerCapabilities(): RealmObjectSchema? { + return this?.transform { obj -> + obj.setLong(HomeServerCapabilitiesEntityFields.LAST_UPDATED_TIMESTAMP, 0) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/legacy/DefaultLegacySessionImporter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/legacy/DefaultLegacySessionImporter.kt index 445b6be8e8..22085e30fc 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/legacy/DefaultLegacySessionImporter.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/legacy/DefaultLegacySessionImporter.kt @@ -42,7 +42,8 @@ import org.matrix.android.sdk.internal.legacy.riot.HomeServerConnectionConfig as internal class DefaultLegacySessionImporter @Inject constructor( private val context: Context, private val sessionParamsStore: SessionParamsStore, - private val realmKeysUtils: RealmKeysUtils + private val realmKeysUtils: RealmKeysUtils, + private val realmCryptoStoreMigration: RealmCryptoStoreMigration ) : LegacySessionImporter { private val loginStorage = LoginStorage(context) @@ -170,8 +171,8 @@ internal class DefaultLegacySessionImporter @Inject constructor( .directory(File(context.filesDir, userMd5)) .name("crypto_store.realm") .modules(RealmCryptoStoreModule()) - .schemaVersion(RealmCryptoStoreMigration.CRYPTO_STORE_SCHEMA_VERSION) - .migration(RealmCryptoStoreMigration) + .schemaVersion(realmCryptoStoreMigration.schemaVersion) + .migration(realmCryptoStoreMigration) .build() Timber.d("Migration: copy DB to encrypted DB") diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/GlobalRealmMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/GlobalRealmMigration.kt index 49bcc72181..8dffac5fa0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/GlobalRealmMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/GlobalRealmMigration.kt @@ -18,24 +18,23 @@ package org.matrix.android.sdk.internal.raw import io.realm.DynamicRealm import io.realm.RealmMigration -import org.matrix.android.sdk.internal.database.model.KnownServerUrlEntityFields +import org.matrix.android.sdk.internal.raw.migration.MigrateGlobalTo001 import timber.log.Timber +import javax.inject.Inject -internal object GlobalRealmMigration : RealmMigration { +internal class GlobalRealmMigration @Inject constructor() : RealmMigration { + /** + * Forces all GlobalRealmMigration instances to be equal + * Avoids Realm throwing when multiple instances of the migration are set + */ + override fun equals(other: Any?) = other is GlobalRealmMigration + override fun hashCode() = 2000 - // Current schema version - const val SCHEMA_VERSION = 1L + val schemaVersion = 1L override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { - Timber.d("Migrating Auth Realm from $oldVersion to $newVersion") + Timber.d("Migrating Global Realm from $oldVersion to $newVersion") - if (oldVersion <= 0) migrateTo1(realm) - } - - private fun migrateTo1(realm: DynamicRealm) { - realm.schema.create("KnownServerUrlEntity") - .addField(KnownServerUrlEntityFields.URL, String::class.java) - .addPrimaryKey(KnownServerUrlEntityFields.URL) - .setRequired(KnownServerUrlEntityFields.URL, true) + if (oldVersion < 1) MigrateGlobalTo001(realm).perform() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/RawModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/RawModule.kt index 50721b809a..a830976671 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/RawModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/RawModule.kt @@ -51,14 +51,15 @@ internal abstract class RawModule { @Provides @GlobalDatabase @MatrixScope - fun providesRealmConfiguration(realmKeysUtils: RealmKeysUtils): RealmConfiguration { + fun providesRealmConfiguration(realmKeysUtils: RealmKeysUtils, + globalRealmMigration: GlobalRealmMigration): RealmConfiguration { return RealmConfiguration.Builder() .apply { realmKeysUtils.configureEncryption(this, DB_ALIAS) } .name("matrix-sdk-global.realm") - .schemaVersion(GlobalRealmMigration.SCHEMA_VERSION) - .migration(GlobalRealmMigration) + .schemaVersion(globalRealmMigration.schemaVersion) + .migration(globalRealmMigration) .allowWritesOnUiThread(true) .modules(GlobalRealmModule()) .build() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/migration/MigrateGlobalTo001.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/migration/MigrateGlobalTo001.kt new file mode 100644 index 0000000000..cff2f7b8e8 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/migration/MigrateGlobalTo001.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.raw.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.database.model.KnownServerUrlEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +class MigrateGlobalTo001(realm: DynamicRealm) : RealmMigrator(realm, 1) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.create("KnownServerUrlEntity") + .addField(KnownServerUrlEntityFields.URL, String::class.java) + .addPrimaryKey(KnownServerUrlEntityFields.URL) + .setRequired(KnownServerUrlEntityFields.URL, true) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/cleanup/CleanupSession.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/cleanup/CleanupSession.kt index c42141a0aa..44fff45917 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/cleanup/CleanupSession.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/cleanup/CleanupSession.kt @@ -94,12 +94,12 @@ internal class CleanupSession @Inject constructor( do { val sessionRealmCount = Realm.getGlobalInstanceCount(realmSessionConfiguration) val cryptoRealmCount = Realm.getGlobalInstanceCount(realmCryptoConfiguration) - Timber.d("Wait for all Realm instance to be closed ($sessionRealmCount - $cryptoRealmCount)") if (sessionRealmCount > 0 || cryptoRealmCount > 0) { - Timber.d("Waiting ${TIME_TO_WAIT_MILLIS}ms") + Timber.d("Waiting ${TIME_TO_WAIT_MILLIS}ms for all Realm instance to be closed ($sessionRealmCount - $cryptoRealmCount)") delay(TIME_TO_WAIT_MILLIS) timeToWaitMillis -= TIME_TO_WAIT_MILLIS } else { + Timber.d("Finished waiting for all Realm instance to be closed ($sessionRealmCount - $cryptoRealmCount)") timeToWaitMillis = 0 } } while (timeToWaitMillis > 0) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ThumbnailExtractor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ThumbnailExtractor.kt index 82cd682eae..55db64f309 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ThumbnailExtractor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ThumbnailExtractor.kt @@ -66,7 +66,7 @@ internal class ThumbnailExtractor @Inject constructor( thumbnail.recycle() outputStream.reset() } ?: run { - Timber.e("Cannot extract video thumbnail at %s", attachment.queryUri.toString()) + Timber.e("Cannot extract video thumbnail at ${attachment.queryUri}") } } catch (e: Exception) { Timber.e(e, "Cannot extract video thumbnail") diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt index 7047d38260..f498322967 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt @@ -48,6 +48,16 @@ data class RoomEventFilter( * a wildcard to match any sequence of characters. */ @Json(name = "types") val types: List? = null, + /** + * A list of relation types which must be exist pointing to the event being filtered. + * If this list is absent then no filtering is done on relation types. + */ + @Json(name = "relation_types") val relationTypes: List? = null, + /** + * A list of senders of relations which must exist pointing to the event being filtered. + * If this list is absent then no filtering is done on relation types. + */ + @Json(name = "relation_senders") val relationSenders: List? = null, /** * A list of room IDs to include. If this list is absent then all rooms are included. */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetCapabilitiesResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetCapabilitiesResult.kt index b36d05b6c0..830a58cd12 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetCapabilitiesResult.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetCapabilitiesResult.kt @@ -18,7 +18,6 @@ package org.matrix.android.sdk.internal.session.homeserver import com.squareup.moshi.Json import com.squareup.moshi.JsonClass -import org.matrix.android.sdk.api.extensions.orTrue import org.matrix.android.sdk.api.util.JsonDict /** @@ -37,10 +36,30 @@ internal data class GetCapabilitiesResult( internal data class Capabilities( /** * Capability to indicate if the user can change their password. + * True if the user can change their password, false otherwise. */ @Json(name = "m.change_password") - val changePassword: ChangePassword? = null, + val changePassword: BooleanCapability? = null, + /** + * Capability to indicate if the user can change their display name. + * True if the user can change their display name, false otherwise. + */ + @Json(name = "m.set_displayname") + val changeDisplayName: BooleanCapability? = null, + + /** + * Capability to indicate if the user can change their avatar. + * True if the user can change their avatar, false otherwise. + */ + @Json(name = "m.set_avatar_url") + val changeAvatar: BooleanCapability? = null, + /** + * Capability to indicate if the user can change add, remove or change 3PID associations. + * True if the user can change their 3PID associations, false otherwise. + */ + @Json(name = "m.3pid_changes") + val change3pid: BooleanCapability? = null, /** * This capability describes the default and available room versions a server supports, and at what level of stability. * Clients should make use of this capability to determine if users need to be encouraged to upgrade their rooms. @@ -50,9 +69,9 @@ internal data class Capabilities( ) @JsonClass(generateAdapter = true) -internal data class ChangePassword( +internal data class BooleanCapability( /** - * Required. True if the user can change their password, false otherwise. + * Required. */ @Json(name = "enabled") val enabled: Boolean? @@ -87,8 +106,3 @@ internal data class RoomVersions( @Json(name = "org.matrix.msc3244.room_capabilities") val roomCapabilities: JsonDict? = null ) - -// The spec says: If not present, the client should assume that password changes are possible via the API -internal fun GetCapabilitiesResult.canChangePassword(): Boolean { - return capabilities?.changePassword?.enabled.orTrue() -} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt index 612b98f863..e822cbdcdb 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt @@ -20,6 +20,7 @@ import com.zhuinden.monarchy.Monarchy import org.matrix.android.sdk.api.MatrixPatterns.getDomain import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig import org.matrix.android.sdk.api.auth.wellknown.WellknownResult +import org.matrix.android.sdk.api.extensions.orTrue import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities import org.matrix.android.sdk.internal.auth.version.Versions import org.matrix.android.sdk.internal.auth.version.isLoginAndRegistrationSupportedBySdk @@ -108,9 +109,16 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor( val homeServerCapabilitiesEntity = HomeServerCapabilitiesEntity.getOrCreate(realm) if (getCapabilitiesResult != null) { - homeServerCapabilitiesEntity.canChangePassword = getCapabilitiesResult.canChangePassword() + val capabilities = getCapabilitiesResult.capabilities - homeServerCapabilitiesEntity.roomVersionsJson = getCapabilitiesResult.capabilities?.roomVersions?.let { + // The spec says: If not present, the client should assume that + // password, display name, avatar changes and 3pid changes are possible via the API + homeServerCapabilitiesEntity.canChangePassword = capabilities?.changePassword?.enabled.orTrue() + homeServerCapabilitiesEntity.canChangeDisplayName = capabilities?.changeDisplayName?.enabled.orTrue() + homeServerCapabilitiesEntity.canChangeAvatar = capabilities?.changeAvatar?.enabled.orTrue() + homeServerCapabilitiesEntity.canChange3pid = capabilities?.change3pid?.enabled.orTrue() + + homeServerCapabilitiesEntity.roomVersionsJson = capabilities?.roomVersions?.let { MoshiProvider.providesMoshi().adapter(RoomVersions::class.java).toJson(it) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/IdentityModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/IdentityModule.kt index 65794e6b14..4e9d7dc7f7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/IdentityModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/IdentityModule.kt @@ -60,6 +60,7 @@ internal abstract class IdentityModule { @IdentityDatabase @SessionScope fun providesIdentityRealmConfiguration(realmKeysUtils: RealmKeysUtils, + realmIdentityStoreMigration: RealmIdentityStoreMigration, @SessionFilesDirectory directory: File, @UserMd5 userMd5: String): RealmConfiguration { return RealmConfiguration.Builder() @@ -68,8 +69,8 @@ internal abstract class IdentityModule { .apply { realmKeysUtils.configureEncryption(this, SessionModule.getKeyAlias(userMd5)) } - .schemaVersion(RealmIdentityStoreMigration.IDENTITY_STORE_SCHEMA_VERSION) - .migration(RealmIdentityStoreMigration) + .schemaVersion(realmIdentityStoreMigration.schemaVersion) + .migration(realmIdentityStoreMigration) .allowWritesOnUiThread(true) .modules(IdentityRealmModule()) .build() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/RealmIdentityStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/RealmIdentityStoreMigration.kt index 21c0f8eb9e..0c279d8a7e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/RealmIdentityStoreMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/RealmIdentityStoreMigration.kt @@ -18,23 +18,23 @@ package org.matrix.android.sdk.internal.session.identity.db import io.realm.DynamicRealm import io.realm.RealmMigration +import org.matrix.android.sdk.internal.session.identity.db.migration.MigrateIdentityTo001 import timber.log.Timber +import javax.inject.Inject -internal object RealmIdentityStoreMigration : RealmMigration { +internal class RealmIdentityStoreMigration @Inject constructor() : RealmMigration { + /** + * Forces all RealmIdentityStoreMigration instances to be equal + * Avoids Realm throwing when multiple instances of the migration are set + */ + override fun equals(other: Any?) = other is RealmIdentityStoreMigration + override fun hashCode() = 3000 - const val IDENTITY_STORE_SCHEMA_VERSION = 1L + val schemaVersion = 1L override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { - Timber.v("Migrating Realm Identity from $oldVersion to $newVersion") + Timber.d("Migrating Realm Identity from $oldVersion to $newVersion") - if (oldVersion <= 0) migrateTo1(realm) - } - - private fun migrateTo1(realm: DynamicRealm) { - Timber.d("Step 0 -> 1") - Timber.d("Add field userConsent (Boolean) and set the value to false") - - realm.schema.get("IdentityDataEntity") - ?.addField(IdentityDataEntityFields.USER_CONSENT, Boolean::class.java) + if (oldVersion < 1) MigrateIdentityTo001(realm).perform() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/migration/MigrateIdentityTo001.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/migration/MigrateIdentityTo001.kt new file mode 100644 index 0000000000..002601470d --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/migration/MigrateIdentityTo001.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.identity.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.session.identity.db.IdentityDataEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator +import timber.log.Timber + +class MigrateIdentityTo001(realm: DynamicRealm) : RealmMigrator(realm, 1) { + + override fun doMigrate(realm: DynamicRealm) { + Timber.d("Add field userConsent (Boolean) and set the value to false") + realm.schema.get("IdentityDataEntity") + ?.addField(IdentityDataEntityFields.USER_CONSENT, Boolean::class.java) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/media/GetPreviewUrlTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/media/GetPreviewUrlTask.kt index e707c2351c..32bcf3f7ca 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/media/GetPreviewUrlTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/media/GetPreviewUrlTask.kt @@ -48,8 +48,8 @@ internal class DefaultGetPreviewUrlTask @Inject constructor( override suspend fun execute(params: GetPreviewUrlTask.Params): PreviewUrlData { return when (params.cacheStrategy) { - CacheStrategy.NoCache -> doRequest(params.url, params.timestamp) - is CacheStrategy.TtlCache -> doRequestWithCache( + CacheStrategy.NoCache -> doRequest(params.url, params.timestamp) + is CacheStrategy.TtlCache -> doRequestWithCache( params.url, params.timestamp, params.cacheStrategy.validityDurationInMillis, @@ -77,7 +77,9 @@ internal class DefaultGetPreviewUrlTask @Inject constructor( siteName = (get("og:site_name") as? String)?.unescapeHtml(), title = (get("og:title") as? String)?.unescapeHtml(), description = (get("og:description") as? String)?.unescapeHtml(), - mxcUrl = get("og:image") as? String + mxcUrl = get("og:image") as? String, + imageHeight = (get("og:image:height") as? Double)?.toInt(), + imageWidth = (get("og:image:width") as? Double)?.toInt(), ) } @@ -114,7 +116,8 @@ internal class DefaultGetPreviewUrlTask @Inject constructor( previewUrlCacheEntity.title = data.title previewUrlCacheEntity.description = data.description previewUrlCacheEntity.mxcUrl = data.mxcUrl - + previewUrlCacheEntity.imageHeight = data.imageHeight + previewUrlCacheEntity.imageWidth = data.imageWidth previewUrlCacheEntity.lastUpdatedTimestamp = Date().time } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/media/PreviewUrlMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/media/PreviewUrlMapper.kt index dd1a9ead26..551dc29b92 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/media/PreviewUrlMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/media/PreviewUrlMapper.kt @@ -27,5 +27,7 @@ internal fun PreviewUrlCacheEntity.toDomain() = PreviewUrlData( siteName = siteName, title = title, description = description, - mxcUrl = mxcUrl + mxcUrl = mxcUrl, + imageWidth = imageWidth, + imageHeight = imageHeight ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt index da15e158e5..8b05d2ea62 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt @@ -74,6 +74,7 @@ internal class DefaultProcessEventForPushTask @Inject constructor( event to it } } + Timber.d("[PushRules] matched ${matchedEvents.size} out of ${allEvents.size}") val allRedactedEvents = params.syncResponse.join .asSequence() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt index 1c3d1971c2..2d8c3e9c78 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt @@ -35,6 +35,7 @@ import org.matrix.android.sdk.api.session.room.send.DraftService import org.matrix.android.sdk.api.session.room.send.SendService import org.matrix.android.sdk.api.session.room.state.StateService import org.matrix.android.sdk.api.session.room.tags.TagsService +import org.matrix.android.sdk.api.session.room.threads.ThreadsService import org.matrix.android.sdk.api.session.room.timeline.TimelineService import org.matrix.android.sdk.api.session.room.typing.TypingService import org.matrix.android.sdk.api.session.room.uploads.UploadsService @@ -54,6 +55,7 @@ import java.security.InvalidParameterException internal class DefaultRoom(override val roomId: String, private val roomSummaryDataSource: RoomSummaryDataSource, private val timelineService: TimelineService, + private val threadsService: ThreadsService, private val sendService: SendService, private val draftService: DraftService, private val stateService: StateService, @@ -77,6 +79,7 @@ internal class DefaultRoom(override val roomId: String, ) : Room, TimelineService by timelineService, + ThreadsService by threadsService, SendService by sendService, DraftService by draftService, StateService by stateService, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt index 7ca64aa66a..4a02c55db0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt @@ -46,6 +46,7 @@ import org.matrix.android.sdk.internal.session.room.create.CreateRoomTask import org.matrix.android.sdk.internal.session.room.membership.RoomChangeMembershipStateDataSource import org.matrix.android.sdk.internal.session.room.membership.RoomMemberHelper import org.matrix.android.sdk.internal.session.room.membership.joining.JoinRoomTask +import org.matrix.android.sdk.internal.session.room.membership.leaving.LeaveRoomTask import org.matrix.android.sdk.internal.session.room.peeking.PeekRoomTask import org.matrix.android.sdk.internal.session.room.peeking.ResolveRoomStateTask import org.matrix.android.sdk.internal.session.room.read.MarkAllRoomsReadTask @@ -66,7 +67,8 @@ internal class DefaultRoomService @Inject constructor( private val peekRoomTask: PeekRoomTask, private val roomGetter: RoomGetter, private val roomSummaryDataSource: RoomSummaryDataSource, - private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource + private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource, + private val leaveRoomTask: LeaveRoomTask, ) : RoomService { override suspend fun createRoom(createRoomParams: CreateRoomParams): String { @@ -133,6 +135,10 @@ internal class DefaultRoomService @Inject constructor( joinRoomTask.execute(JoinRoomTask.Params(roomId, reason, thirdPartySigned = thirdPartySigned)) } + override suspend fun leaveRoom(roomId: String, reason: String?) { + leaveRoomTask.execute(LeaveRoomTask.Params(roomId, reason)) + } + override suspend fun markAllAsRead(roomIds: List) { markAllRoomsReadTask.execute(MarkAllRoomsReadTask.Params(roomIds)) } 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 3cc08df0e8..acceaf6e24 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 @@ -44,6 +44,7 @@ import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.internal.SessionManager import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent 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.model.EditAggregatedSummaryEntity @@ -332,6 +333,29 @@ internal class EventRelationsAggregationProcessor @Inject constructor( ) } } + + if (!isLocalEcho) { + val replaceEvent = TimelineEventEntity.where(realm, roomId, eventId).findFirst() + handleThreadSummaryEdition(editedEvent, replaceEvent, existingSummary?.editions) + } + } + + /** + * Check if the edition is on the latest thread event, and update it accordingly + */ + private fun handleThreadSummaryEdition(editedEvent: EventEntity?, + replaceEvent: TimelineEventEntity?, + editions: List?) { + replaceEvent ?: return + editedEvent ?: return + editedEvent.findRootThreadEvent()?.apply { + val threadSummaryEventId = threadSummaryLatestMessage?.eventId + if (editedEvent.eventId == threadSummaryEventId || editions?.any { it.eventId == threadSummaryEventId } == true) { + // The edition is for the latest event or for any event replaced, this is to handle multiple + // edits of the same latest event + threadSummaryLatestMessage = replaceEvent + } + } } private fun handleResponse(realm: Realm, 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 efc5166a0c..399bfbd0e4 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 @@ -226,7 +226,8 @@ internal interface RoomAPI { suspend fun getRelations(@Path("roomId") roomId: String, @Path("eventId") eventId: String, @Path("relationType") relationType: String, - @Path("eventType") eventType: String + @Path("eventType") eventType: String, + @Query("limit") limit: Int? = null ): RelationsResponse /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt index 4ab06338a2..70c1ab4f42 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt @@ -35,6 +35,7 @@ import org.matrix.android.sdk.internal.session.room.state.DefaultStateService import org.matrix.android.sdk.internal.session.room.state.SendStateTask import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryDataSource import org.matrix.android.sdk.internal.session.room.tags.DefaultTagsService +import org.matrix.android.sdk.internal.session.room.threads.DefaultThreadsService import org.matrix.android.sdk.internal.session.room.timeline.DefaultTimelineService import org.matrix.android.sdk.internal.session.room.typing.DefaultTypingService import org.matrix.android.sdk.internal.session.room.uploads.DefaultUploadsService @@ -50,6 +51,7 @@ internal interface RoomFactory { internal class DefaultRoomFactory @Inject constructor(private val cryptoService: CryptoService, private val roomSummaryDataSource: RoomSummaryDataSource, private val timelineServiceFactory: DefaultTimelineService.Factory, + private val threadsServiceFactory: DefaultThreadsService.Factory, private val sendServiceFactory: DefaultSendService.Factory, private val draftServiceFactory: DefaultDraftService.Factory, private val stateServiceFactory: DefaultStateService.Factory, @@ -76,6 +78,7 @@ internal class DefaultRoomFactory @Inject constructor(private val cryptoService: roomId = roomId, roomSummaryDataSource = roomSummaryDataSource, timelineService = timelineServiceFactory.create(roomId), + threadsService = threadsServiceFactory.create(roomId), sendService = sendServiceFactory.create(roomId), draftService = draftServiceFactory.create(roomId), stateService = stateServiceFactory.create(roomId), diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt index 64f6bc0b30..f831a77a5d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt @@ -77,6 +77,8 @@ import org.matrix.android.sdk.internal.session.room.relation.DefaultUpdateQuickR import org.matrix.android.sdk.internal.session.room.relation.FetchEditHistoryTask import org.matrix.android.sdk.internal.session.room.relation.FindReactionEventForUndoTask import org.matrix.android.sdk.internal.session.room.relation.UpdateQuickReactionTask +import org.matrix.android.sdk.internal.session.room.relation.threads.DefaultFetchThreadTimelineTask +import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask import org.matrix.android.sdk.internal.session.room.reporting.DefaultReportContentTask import org.matrix.android.sdk.internal.session.room.reporting.ReportContentTask import org.matrix.android.sdk.internal.session.room.state.DefaultSendStateTask @@ -289,4 +291,7 @@ internal abstract class RoomModule { @Binds abstract fun bindGetRoomSummaryTask(task: DefaultGetRoomSummaryTask): GetRoomSummaryTask + + @Binds + abstract fun bindFetchThreadTimelineTask(task: DefaultFetchThreadTimelineTask): FetchThreadTimelineTask } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/accountdata/RoomAccountDataDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/accountdata/RoomAccountDataDataSource.kt index d96beed3f1..d5a110dfc2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/accountdata/RoomAccountDataDataSource.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/accountdata/RoomAccountDataDataSource.kt @@ -54,8 +54,7 @@ internal class RoomAccountDataDataSource @Inject constructor(@SessionDatabase pr */ fun getAccountDataEvents(roomId: String?, types: Set): List { return realmSessionProvider.withRealm { realm -> - val roomEntity = buildRoomQuery(realm, roomId, types).findFirst() ?: return@withRealm emptyList() - roomEntity.accountDataEvents(types) + buildRoomQuery(realm, roomId, types).findAll().flatMap { it.accountDataEvents(types) } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/DefaultMembershipService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/DefaultMembershipService.kt index 49b58aa765..005d7f26db 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/DefaultMembershipService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/DefaultMembershipService.kt @@ -37,8 +37,6 @@ import org.matrix.android.sdk.internal.query.QueryStringValueProcessor import org.matrix.android.sdk.internal.query.process import org.matrix.android.sdk.internal.session.room.membership.admin.MembershipAdminTask import org.matrix.android.sdk.internal.session.room.membership.joining.InviteTask -import org.matrix.android.sdk.internal.session.room.membership.joining.JoinRoomTask -import org.matrix.android.sdk.internal.session.room.membership.leaving.LeaveRoomTask import org.matrix.android.sdk.internal.session.room.membership.threepid.InviteThreePidTask import org.matrix.android.sdk.internal.util.fetchCopied @@ -48,8 +46,6 @@ internal class DefaultMembershipService @AssistedInject constructor( private val loadRoomMembersTask: LoadRoomMembersTask, private val inviteTask: InviteTask, private val inviteThreePidTask: InviteThreePidTask, - private val joinTask: JoinRoomTask, - private val leaveRoomTask: LeaveRoomTask, private val membershipAdminTask: MembershipAdminTask, @UserId private val userId: String, @@ -139,14 +135,4 @@ internal class DefaultMembershipService @AssistedInject constructor( val params = InviteThreePidTask.Params(roomId, threePid) return inviteThreePidTask.execute(params) } - - override suspend fun join(reason: String?, viaServers: List) { - val params = JoinRoomTask.Params(roomId, reason, viaServers) - joinTask.execute(params) - } - - override suspend fun leave(reason: String?) { - val params = LeaveRoomTask.Params(roomId, reason) - leaveRoomTask.execute(params) - } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt index 5ae4007c63..ee52fe574b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt @@ -83,7 +83,9 @@ internal class RedactionEventProcessor @Inject constructor() : EventInsertLivePr // } val modified = unsignedData.copy(redactedEvent = redactionEvent) - eventToPrune.content = ContentMapper.map(emptyMap()) + // I Commented the line below, it should not be empty while we lose all the previous info about + // the redacted event +// eventToPrune.content = ContentMapper.map(emptyMap()) eventToPrune.unsignedData = MoshiProvider.providesMoshi().adapter(UnsignedData::class.java).toJson(modified) eventToPrune.decryptionResultJson = null eventToPrune.decryptionErrorCode = null diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt index cbcc108ddd..3abf28fdd4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt @@ -21,7 +21,6 @@ import com.zhuinden.monarchy.Monarchy import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary import org.matrix.android.sdk.api.session.room.model.message.PollType @@ -38,10 +37,9 @@ import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEnt import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.di.SessionDatabase +import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor -import org.matrix.android.sdk.internal.task.TaskExecutor -import org.matrix.android.sdk.internal.task.configureWith import org.matrix.android.sdk.internal.util.fetchCopyMap import timber.log.Timber @@ -53,10 +51,10 @@ internal class DefaultRelationService @AssistedInject constructor( private val cryptoSessionInfoProvider: CryptoSessionInfoProvider, private val findReactionEventForUndoTask: FindReactionEventForUndoTask, private val fetchEditHistoryTask: FetchEditHistoryTask, + private val fetchThreadTimelineTask: FetchThreadTimelineTask, private val timelineEventMapper: TimelineEventMapper, - @SessionDatabase private val monarchy: Monarchy, - private val taskExecutor: TaskExecutor) : - RelationService { + @SessionDatabase private val monarchy: Monarchy +) : RelationService { @AssistedFactory interface Factory { @@ -78,39 +76,31 @@ internal class DefaultRelationService @AssistedInject constructor( .none { it.addedByMe && it.key == reaction }) { val event = eventFactory.createReactionEvent(roomId, targetEventId, reaction) .also { saveLocalEcho(it) } - return eventSenderProcessor.postEvent(event, false /* reaction are not encrypted*/) + eventSenderProcessor.postEvent(event, false /* reaction are not encrypted*/) } else { Timber.w("Reaction already added") NoOpCancellable } } - override fun undoReaction(targetEventId: String, reaction: String): Cancelable { + override suspend fun undoReaction(targetEventId: String, reaction: String): Cancelable { val params = FindReactionEventForUndoTask.Params( roomId, targetEventId, reaction ) - // TODO We should avoid using MatrixCallback internally - val callback = object : MatrixCallback { - override fun onSuccess(data: FindReactionEventForUndoTask.Result) { - if (data.redactEventId == null) { - Timber.w("Cannot find reaction to undo (not yet synced?)") - // TODO? - } - data.redactEventId?.let { toRedact -> - val redactEvent = eventFactory.createRedactEvent(roomId, toRedact, null) - .also { saveLocalEcho(it) } - eventSenderProcessor.postRedaction(redactEvent, null) - } - } + + val data = findReactionEventForUndoTask.executeRetry(params, Int.MAX_VALUE) + + return if (data.redactEventId == null) { + Timber.w("Cannot find reaction to undo (not yet synced?)") + // TODO? + NoOpCancellable + } else { + val redactEvent = eventFactory.createRedactEvent(roomId, data.redactEventId, null) + .also { saveLocalEcho(it) } + eventSenderProcessor.postRedaction(redactEvent, null) } - return findReactionEventForUndoTask - .configureWith(params) { - this.retryCount = Int.MAX_VALUE - this.callback = callback - } - .executeBy(taskExecutor) } override fun editPoll(targetEvent: TimelineEvent, @@ -139,8 +129,20 @@ internal class DefaultRelationService @AssistedInject constructor( return fetchEditHistoryTask.execute(FetchEditHistoryTask.Params(roomId, eventId)) } - override fun replyToMessage(eventReplied: TimelineEvent, replyText: CharSequence, autoMarkdown: Boolean): Cancelable? { - val event = eventFactory.createReplyTextEvent(roomId, eventReplied, replyText, autoMarkdown) + override fun replyToMessage( + eventReplied: TimelineEvent, + replyText: CharSequence, + autoMarkdown: Boolean, + showInThread: Boolean, + rootThreadEventId: String? + ): Cancelable? { + val event = eventFactory.createReplyTextEvent( + roomId = roomId, + eventReplied = eventReplied, + replyText = replyText, + autoMarkdown = autoMarkdown, + rootThreadEventId = rootThreadEventId, + showInThread = showInThread) ?.also { saveLocalEcho(it) } ?: return null @@ -166,6 +168,47 @@ internal class DefaultRelationService @AssistedInject constructor( } } + override fun replyInThread( + rootThreadEventId: String, + replyInThreadText: CharSequence, + msgType: String, + autoMarkdown: Boolean, + formattedText: String?, + eventReplied: TimelineEvent?): Cancelable? { + val event = if (eventReplied != null) { + // Reply within a thread + eventFactory.createReplyTextEvent( + roomId = roomId, + eventReplied = eventReplied, + replyText = replyInThreadText, + autoMarkdown = autoMarkdown, + rootThreadEventId = rootThreadEventId, + showInThread = false + ) + ?.also { + saveLocalEcho(it) + } + ?: return null + } else { + // Normal thread reply + eventFactory.createThreadTextEvent( + rootThreadEventId = rootThreadEventId, + roomId = roomId, + text = replyInThreadText, + msgType = msgType, + autoMarkdown = autoMarkdown, + formattedText = formattedText) + .also { + saveLocalEcho(it) + } + } + return eventSenderProcessor.postEvent(event, cryptoSessionInfoProvider.isRoomEncrypted(roomId)) + } + + override suspend fun fetchThreadTimeline(rootThreadEventId: String): Boolean { + return fetchThreadTimelineTask.execute(FetchThreadTimelineTask.Params(roomId, rootThreadEventId)) + } + /** * Saves the event in database as a local echo. * SendState is set to UNSENT and it's added to a the sendingTimelineEvents list of the room. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt index a40a8df443..4551f390e7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt @@ -97,7 +97,13 @@ internal class EventEditor @Inject constructor(private val eventSenderProcessor: val roomId = replyToEdit.roomId if (replyToEdit.root.sendState.hasFailed()) { // We create a new in memory event for the EventSenderProcessor but we keep the eventId of the failed event. - val editedEvent = eventFactory.createReplyTextEvent(roomId, originalTimelineEvent, newBodyText, false)?.copy( + val editedEvent = eventFactory.createReplyTextEvent( + roomId = roomId, + eventReplied = originalTimelineEvent, + replyText = newBodyText, + autoMarkdown = false, + showInThread = false + )?.copy( eventId = replyToEdit.eventId ) ?: return NoOpCancellable updateFailedEchoWithEvent(roomId, replyToEdit.eventId, editedEvent) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt new file mode 100644 index 0000000000..e0d501c515 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt @@ -0,0 +1,207 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.matrix.android.sdk.internal.session.room.relation.threads + +import com.zhuinden.monarchy.Monarchy +import io.realm.Realm +import org.matrix.android.sdk.api.session.crypto.MXCryptoError +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.room.model.RoomMemberContent +import org.matrix.android.sdk.api.session.room.send.SendState +import org.matrix.android.sdk.internal.crypto.CryptoSessionInfoProvider +import org.matrix.android.sdk.internal.crypto.DefaultCryptoService +import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult +import org.matrix.android.sdk.internal.database.helper.addTimelineEvent +import org.matrix.android.sdk.internal.database.helper.updateThreadSummaryIfNeeded +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.ReactionAggregatedSummaryEntity +import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore +import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom +import org.matrix.android.sdk.internal.database.query.getOrCreate +import org.matrix.android.sdk.internal.database.query.getOrNull +import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.di.SessionDatabase +import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.network.GlobalErrorReceiver +import org.matrix.android.sdk.internal.network.executeRequest +import org.matrix.android.sdk.internal.session.events.getFixedRoomMemberContent +import org.matrix.android.sdk.internal.session.room.RoomAPI +import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection +import org.matrix.android.sdk.internal.task.Task +import org.matrix.android.sdk.internal.util.awaitTransaction +import timber.log.Timber +import javax.inject.Inject + +internal interface FetchThreadTimelineTask : Task { + data class Params( + val roomId: String, + val rootThreadEventId: String + ) +} + +internal class DefaultFetchThreadTimelineTask @Inject constructor( + private val roomAPI: RoomAPI, + private val globalErrorReceiver: GlobalErrorReceiver, + private val cryptoSessionInfoProvider: CryptoSessionInfoProvider, + @SessionDatabase private val monarchy: Monarchy, + @UserId private val userId: String, + private val cryptoService: DefaultCryptoService +) : FetchThreadTimelineTask { + + override suspend fun execute(params: FetchThreadTimelineTask.Params): Boolean { + val isRoomEncrypted = cryptoSessionInfoProvider.isRoomEncrypted(params.roomId) + val response = executeRequest(globalErrorReceiver) { + roomAPI.getRelations( + roomId = params.roomId, + eventId = params.rootThreadEventId, + relationType = RelationType.IO_THREAD, + eventType = if (isRoomEncrypted) EventType.ENCRYPTED else EventType.MESSAGE, + limit = 2000 + ) + } + + val threadList = response.chunks + listOfNotNull(response.originalEvent) + + return storeNewEventsIfNeeded(threadList, params.roomId) + } + + /** + * Store new events if they are not already received, and returns weather or not, + * a timeline update should be made + * @param threadList is the list containing the thread replies + * @param roomId the roomId of the the thread + * @return + */ + private suspend fun storeNewEventsIfNeeded(threadList: List, roomId: String): Boolean { + var eventsSkipped = 0 + monarchy + .awaitTransaction { realm -> + val chunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId) + + val optimizedThreadSummaryMap = hashMapOf() + val roomMemberContentsByUser = HashMap() + + for (event in threadList.reversed()) { + if (event.eventId == null || event.senderId == null || event.type == null) { + eventsSkipped++ + continue + } + + if (EventEntity.where(realm, event.eventId).findFirst() != null) { + // Skip if event already exists + eventsSkipped++ + continue + } + if (event.isEncrypted()) { + // Decrypt events that will be stored + decryptIfNeeded(event, roomId) + } + + handleReaction(realm, event, roomId) + + val ageLocalTs = event.unsignedData?.age?.let { System.currentTimeMillis() - it } + val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.INCREMENTAL_SYNC) + + // Sender info + roomMemberContentsByUser.getOrPut(event.senderId) { + // If we don't have any new state on this user, get it from db + val rootStateEvent = CurrentStateEventEntity.getOrNull(realm, roomId, event.senderId, EventType.STATE_ROOM_MEMBER)?.root + rootStateEvent?.asDomain()?.getFixedRoomMemberContent() + } + + chunk?.addTimelineEvent(roomId, eventEntity, PaginationDirection.FORWARDS, roomMemberContentsByUser) + eventEntity.rootThreadEventId?.let { + // This is a thread event + optimizedThreadSummaryMap[it] = eventEntity + } ?: run { + // This is a normal event or a root thread one + optimizedThreadSummaryMap[eventEntity.eventId] = eventEntity + } + } + + optimizedThreadSummaryMap.updateThreadSummaryIfNeeded( + roomId = roomId, + realm = realm, + currentUserId = userId, + shouldUpdateNotifications = false + ) + } + Timber.i("----> size: ${threadList.size} | skipped: $eventsSkipped | threads: ${threadList.map { it.eventId }}") + + return eventsSkipped == threadList.size + } + + /** + * Invoke the event decryption mechanism for a specific event + */ + + private fun decryptIfNeeded(event: Event, roomId: String) { + try { + // Event from sync does not have roomId, so add it to the event first + val result = cryptoService.decryptEvent(event.copy(roomId = roomId), "") + event.mxDecryptionResult = OlmDecryptionResult( + payload = result.clearEvent, + senderKey = result.senderCurve25519Key, + keysClaimed = result.claimedEd25519Key?.let { k -> mapOf("ed25519" to k) }, + forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain + ) + } catch (e: MXCryptoError) { + if (e is MXCryptoError.Base) { + event.mCryptoError = e.errorType + event.mCryptoErrorReason = e.technicalMessage.takeIf { it.isNotEmpty() } ?: e.detailedErrorDescription + } + } + } + + private fun handleReaction(realm: Realm, + event: Event, + roomId: String) { + val unsignedData = event.unsignedData ?: return + val relatedEventId = event.eventId ?: return + + unsignedData.relations?.annotations?.chunk?.forEach { relationChunk -> + + if (relationChunk.type == EventType.REACTION) { + val reaction = relationChunk.key + Timber.i("----> Annotation found in ${event.eventId} ${relationChunk.key} ") + + val eventSummary = EventAnnotationsSummaryEntity.getOrCreate(realm, roomId, relatedEventId) + var sum = eventSummary.reactionsSummary.find { it.key == reaction } + + if (sum == null) { + sum = realm.createObject(ReactionAggregatedSummaryEntity::class.java) + sum.key = reaction + sum.firstTimestamp = event.originServerTs ?: 0 + Timber.v("Adding synced reaction $reaction") + sum.count = 1 + // reactionEventId not included in the /relations API +// sum.sourceEvents.add(reactionEventId) + eventSummary.reactionsSummary.add(sum) + } else { + sum.count += 1 + } + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt index 5662a72cb8..8c0ea0ec4c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt @@ -98,8 +98,14 @@ internal class DefaultSendService @AssistedInject constructor( .let { sendEvent(it) } } - override fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean): Cancelable { - return localEchoEventFactory.createQuotedTextEvent(roomId, quotedEvent, text, autoMarkdown) + override fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, rootThreadEventId: String?): Cancelable { + return localEchoEventFactory.createQuotedTextEvent( + roomId = roomId, + quotedEvent = quotedEvent, + text = text, + autoMarkdown = autoMarkdown, + rootThreadEventId = rootThreadEventId + ) .also { createLocalEcho(it) } .let { sendEvent(it) } } @@ -254,22 +260,37 @@ internal class DefaultSendService @AssistedInject constructor( override fun sendMedias(attachments: List, compressBeforeSending: Boolean, - roomIds: Set): Cancelable { + roomIds: Set, + rootThreadEventId: String? + ): Cancelable { return attachments.mapTo(CancelableBag()) { - sendMedia(it, compressBeforeSending, roomIds) + sendMedia( + attachment = it, + compressBeforeSending = compressBeforeSending, + roomIds = roomIds, + rootThreadEventId = rootThreadEventId) } } override fun sendMedia(attachment: ContentAttachmentData, compressBeforeSending: Boolean, - roomIds: Set): Cancelable { + roomIds: Set, + rootThreadEventId: String? + ): Cancelable { + // Ensure that the event will not be send in a thread if we are a different flow. + // Like sending files to multiple rooms + val rootThreadId = if (roomIds.isNotEmpty()) null else rootThreadEventId + // Create an event with the media file path // Ensure current roomId is included in the set val allRoomIds = (roomIds + roomId).toList() // Create local echo for each room val allLocalEchoes = allRoomIds.map { - localEchoEventFactory.createMediaEvent(it, attachment).also { event -> + localEchoEventFactory.createMediaEvent( + roomId = it, + attachment = attachment, + rootThreadEventId = rootThreadId).also { event -> createLocalEcho(event) } } 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 1e46602411..3c36d58710 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 @@ -28,6 +28,7 @@ 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.UnsignedData import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.message.AudioInfo import org.matrix.android.sdk.api.session.room.model.message.AudioWaveformInfo import org.matrix.android.sdk.api.session.room.model.message.FileInfo @@ -45,6 +46,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent import org.matrix.android.sdk.api.session.room.model.message.MessageLocationContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollResponseContent +import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent @@ -292,13 +294,16 @@ internal class LocalEchoEventFactory @Inject constructor( )) } - fun createMediaEvent(roomId: String, attachment: ContentAttachmentData): Event { + fun createMediaEvent(roomId: String, + attachment: ContentAttachmentData, + rootThreadEventId: String? + ): Event { return when (attachment.type) { - ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment) - ContentAttachmentData.Type.VIDEO -> createVideoEvent(roomId, attachment) - ContentAttachmentData.Type.AUDIO -> createAudioEvent(roomId, attachment, isVoiceMessage = false) - ContentAttachmentData.Type.VOICE_MESSAGE -> createAudioEvent(roomId, attachment, isVoiceMessage = true) - ContentAttachmentData.Type.FILE -> createFileEvent(roomId, attachment) + ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment, rootThreadEventId) + ContentAttachmentData.Type.VIDEO -> createVideoEvent(roomId, attachment, rootThreadEventId) + ContentAttachmentData.Type.AUDIO -> createAudioEvent(roomId, attachment, isVoiceMessage = false, rootThreadEventId = rootThreadEventId) + ContentAttachmentData.Type.VOICE_MESSAGE -> createAudioEvent(roomId, attachment, isVoiceMessage = true, rootThreadEventId = rootThreadEventId) + ContentAttachmentData.Type.FILE -> createFileEvent(roomId, attachment, rootThreadEventId) } } @@ -321,7 +326,7 @@ internal class LocalEchoEventFactory @Inject constructor( unsignedData = UnsignedData(age = null, transactionId = localId)) } - private fun createImageEvent(roomId: String, attachment: ContentAttachmentData): Event { + private fun createImageEvent(roomId: String, attachment: ContentAttachmentData, rootThreadEventId: String?): Event { var width = attachment.width var height = attachment.height @@ -345,12 +350,19 @@ internal class LocalEchoEventFactory @Inject constructor( height = height?.toInt() ?: 0, size = attachment.size ), - url = attachment.queryUri.toString() + url = attachment.queryUri.toString(), + relatesTo = rootThreadEventId?.let { + RelationDefaultContent( + type = RelationType.IO_THREAD, + eventId = it, + inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) + ) + } ) return createMessageEvent(roomId, content) } - private fun createVideoEvent(roomId: String, attachment: ContentAttachmentData): Event { + private fun createVideoEvent(roomId: String, attachment: ContentAttachmentData, rootThreadEventId: String?): Event { val mediaDataRetriever = MediaMetadataRetriever() mediaDataRetriever.setDataSource(context, attachment.queryUri) @@ -381,12 +393,23 @@ internal class LocalEchoEventFactory @Inject constructor( thumbnailUrl = attachment.queryUri.toString(), thumbnailInfo = thumbnailInfo ), - url = attachment.queryUri.toString() + url = attachment.queryUri.toString(), + relatesTo = rootThreadEventId?.let { + RelationDefaultContent( + type = RelationType.IO_THREAD, + eventId = it, + inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) + ) + } ) return createMessageEvent(roomId, content) } - private fun createAudioEvent(roomId: String, attachment: ContentAttachmentData, isVoiceMessage: Boolean): Event { + private fun createAudioEvent(roomId: String, + attachment: ContentAttachmentData, + isVoiceMessage: Boolean, + rootThreadEventId: String? + ): Event { val content = MessageAudioContent( msgType = MessageType.MSGTYPE_AUDIO, body = attachment.name ?: "audio", @@ -400,12 +423,19 @@ internal class LocalEchoEventFactory @Inject constructor( duration = attachment.duration?.toInt(), waveform = waveformSanitizer.sanitize(attachment.waveform) ), - voiceMessageIndicator = if (!isVoiceMessage) null else emptyMap() + voiceMessageIndicator = if (!isVoiceMessage) null else emptyMap(), + relatesTo = rootThreadEventId?.let { + RelationDefaultContent( + type = RelationType.IO_THREAD, + eventId = it, + inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) + ) + } ) return createMessageEvent(roomId, content) } - private fun createFileEvent(roomId: String, attachment: ContentAttachmentData): Event { + private fun createFileEvent(roomId: String, attachment: ContentAttachmentData, rootThreadEventId: String?): Event { val content = MessageFileContent( msgType = MessageType.MSGTYPE_FILE, body = attachment.name ?: "file", @@ -413,7 +443,14 @@ internal class LocalEchoEventFactory @Inject constructor( mimeType = attachment.getSafeMimeType()?.takeIf { it.isNotBlank() }, size = attachment.size ), - url = attachment.queryUri.toString() + url = attachment.queryUri.toString(), + relatesTo = rootThreadEventId?.let { + RelationDefaultContent( + type = RelationType.IO_THREAD, + eventId = it, + inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) + ) + } ) return createMessageEvent(roomId, content) } @@ -423,6 +460,7 @@ internal class LocalEchoEventFactory @Inject constructor( } fun createEvent(roomId: String, type: String, content: Content?): Event { + val newContent = enhanceStickerIfNeeded(type, content) ?: content val localId = LocalEcho.createLocalEchoId() return Event( roomId = roomId, @@ -430,19 +468,65 @@ internal class LocalEchoEventFactory @Inject constructor( senderId = userId, eventId = localId, type = type, - content = content, + content = newContent, unsignedData = UnsignedData(age = null, transactionId = localId) ) } + /** + * Enhance sticker to support threads fallback if needed + */ + private fun enhanceStickerIfNeeded(type: String, content: Content?): Content? { + var newContent: Content? = null + if (type == EventType.STICKER) { + val isThread = (content.toModel())?.relatesTo?.type == RelationType.IO_THREAD + val rootThreadEventId = (content.toModel())?.relatesTo?.eventId + if (isThread && rootThreadEventId != null) { + val newRelationalDefaultContent = (content.toModel())?.relatesTo?.copy( + inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId)) + ) + newContent = (content.toModel())?.copy( + relatesTo = newRelationalDefaultContent + ).toContent() + } + } + return newContent + } + + /** + * Creates a thread event related to the already existing root event + */ + fun createThreadTextEvent( + rootThreadEventId: String, + roomId: String, + text: CharSequence, + msgType: String, + autoMarkdown: Boolean, + formattedText: String?): Event { + val content = formattedText?.let { TextContent(text.toString(), it) } ?: createTextContent(text, autoMarkdown) + return createEvent( + roomId, + EventType.MESSAGE, + content.toThreadTextContent( + rootThreadEventId = rootThreadEventId, + latestThreadEventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId), + msgType = msgType) + .toContent()) + } + private fun dummyOriginServerTs(): Long { return System.currentTimeMillis() } + /** + * Creates a reply to a regular timeline Event or a thread Event if needed + */ fun createReplyTextEvent(roomId: String, eventReplied: TimelineEvent, replyText: CharSequence, - autoMarkdown: Boolean): Event? { + autoMarkdown: Boolean, + rootThreadEventId: String? = null, + showInThread: Boolean): Event? { // Fallbacks and event representation // TODO Add error/warning logs when any of this is null val permalink = permalinkFactory.createPermalink(eventReplied.root, false) ?: return null @@ -473,11 +557,33 @@ internal class LocalEchoEventFactory @Inject constructor( format = MessageFormat.FORMAT_MATRIX_HTML, body = replyFallback, formattedBody = replyFormatted, - relatesTo = RelationDefaultContent(null, null, ReplyToContent(eventId)) - ) + relatesTo = generateReplyRelationContent( + eventId = eventId, + rootThreadEventId = rootThreadEventId, + showAsReply = showInThread)) return createMessageEvent(roomId, content) } + /** + * Generates the appropriate relatesTo object for a reply event. + * It can either be a regular reply or a reply within a thread + * "m.relates_to": { + * "rel_type": "m.thread", + * "event_id": "$thread_root", + * "m.in_reply_to": { + * "event_id": "$event_target", + * "render_in": ["m.thread"] + * } + * } + */ + private fun generateReplyRelationContent(eventId: String, rootThreadEventId: String? = null, showAsReply: Boolean): RelationDefaultContent = + rootThreadEventId?.let { + RelationDefaultContent( + type = RelationType.IO_THREAD, + eventId = it, + inReplyTo = ReplyToContent(eventId = eventId, renderIn = if (showAsReply) arrayListOf("m.thread") else null)) + } ?: RelationDefaultContent(null, null, ReplyToContent(eventId = eventId)) + private fun buildFormattedReply(permalink: String, userLink: String, userId: String, bodyFormatted: String, newBodyFormatted: String): String { return REPLY_PATTERN.format( permalink, @@ -488,6 +594,7 @@ internal class LocalEchoEventFactory @Inject constructor( newBodyFormatted ) } + private fun buildReplyFallback(body: TextContent, originalSenderId: String?, newBodyText: String): String { return buildString { append("> <") @@ -593,11 +700,28 @@ internal class LocalEchoEventFactory @Inject constructor( quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, + rootThreadEventId: String? ): Event { val messageContent = quotedEvent.getLastMessageContent() val textMsg = messageContent?.body val quoteText = legacyRiotQuoteText(textMsg, text) - return createFormattedTextEvent(roomId, markdownParser.parse(quoteText, force = true, advanced = autoMarkdown), MessageType.MSGTYPE_TEXT) + + return if (rootThreadEventId != null) { + createMessageEvent( + roomId, + markdownParser + .parse(quoteText, force = true, advanced = autoMarkdown) + .toThreadTextContent( + rootThreadEventId = rootThreadEventId, + latestThreadEventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId), + msgType = MessageType.MSGTYPE_TEXT) + ) + } else { + createFormattedTextEvent( + roomId, + markdownParser.parse(quoteText, force = true, advanced = autoMarkdown), + MessageType.MSGTYPE_TEXT) + } } private fun legacyRiotQuoteText(quotedText: String?, myText: String): String { @@ -631,6 +755,7 @@ internal class LocalEchoEventFactory @Inject constructor( // // No whitespace because currently breaks temporary formatted text to Span const val REPLY_PATTERN = """
In reply to %s
%s
%s""" + const val QUOTE_PATTERN = """

%s

%s

""" // This is used to replace inner mx-reply tags val MX_REPLY_REGEX = ".*".toRegex() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoRepository.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoRepository.kt index 13095fbd58..1b1a66a1c4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoRepository.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoRepository.kt @@ -138,7 +138,7 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private } } - fun deleteFailedEchoAsync(roomId: String, eventId: String?) { + fun deleteFailedEchoAsync(roomId: String, eventId: String?) { monarchy.runTransactionSync { realm -> TimelineEventEntity.where(realm, roomId = roomId, eventId = eventId ?: "").findFirst()?.deleteFromRealm() EventEntity.where(realm, eventId = eventId ?: "").findFirst()?.deleteFromRealm() @@ -215,4 +215,13 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private } } } + + /** + * Returns the latest known thread event message, or the rootThreadEventId if no other event found + */ + fun getLatestThreadEvent(rootThreadEventId: String): String { + return realmSessionProvider.withRealm { realm -> + EventEntity.where(realm, eventId = rootThreadEventId).findFirst()?.threadSummaryLatestMessage?.eventId + } ?: rootThreadEventId + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt index efc0b55abf..5c629f87f0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt @@ -16,9 +16,12 @@ package org.matrix.android.sdk.internal.session.room.send +import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.room.model.message.MessageFormat import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageType +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent +import org.matrix.android.sdk.api.session.room.model.relation.ReplyToContent import org.matrix.android.sdk.api.util.ContentUtils.extractUsefulTextFromHtmlReply import org.matrix.android.sdk.api.util.ContentUtils.extractUsefulTextFromReply @@ -41,6 +44,29 @@ fun TextContent.toMessageTextContent(msgType: String = MessageType.MSGTYPE_TEXT) ) } +/** + * Transform a TextContent to a thread message content. It will also add the inReplyTo + * latestThreadEventId in order for the clients without threads enabled to render it appropriately + * If latest event not found, we pass rootThreadEventId + */ +fun TextContent.toThreadTextContent( + rootThreadEventId: String, + latestThreadEventId: String, + msgType: String = MessageType.MSGTYPE_TEXT): MessageTextContent { + return MessageTextContent( + msgType = msgType, + format = MessageFormat.FORMAT_MATRIX_HTML.takeIf { formattedText != null }, + body = text, + relatesTo = RelationDefaultContent( + type = RelationType.IO_THREAD, + eventId = rootThreadEventId, + inReplyTo = ReplyToContent( + eventId = latestThreadEventId + )), + formattedBody = formattedText + ) +} + fun TextContent.removeInReplyFallbacks(): TextContent { return copy( text = extractUsefulTextFromReply(this.text), diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt new file mode 100644 index 0000000000..5967ae8d2e --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt @@ -0,0 +1,103 @@ +/* + * 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.threads + +import androidx.lifecycle.LiveData +import com.zhuinden.monarchy.Monarchy +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.realm.Realm +import org.matrix.android.sdk.api.session.room.threads.ThreadsService +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.threads.ThreadNotificationState +import org.matrix.android.sdk.internal.database.helper.findAllLocalThreadNotificationsForRoomId +import org.matrix.android.sdk.internal.database.helper.findAllThreadsForRoomId +import org.matrix.android.sdk.internal.database.helper.isUserParticipatingInThread +import org.matrix.android.sdk.internal.database.helper.mapEventsWithEdition +import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper +import org.matrix.android.sdk.internal.database.model.EventEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +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.util.awaitTransaction + +internal class DefaultThreadsService @AssistedInject constructor( + @Assisted private val roomId: String, + @UserId private val userId: String, + @SessionDatabase private val monarchy: Monarchy, + private val timelineEventMapper: TimelineEventMapper, +) : ThreadsService { + + @AssistedFactory + interface Factory { + fun create(roomId: String): DefaultThreadsService + } + + override fun getMarkedThreadNotificationsLive(): LiveData> { + return monarchy.findAllMappedWithChanges( + { TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) }, + { timelineEventMapper.map(it) } + ) + } + + override fun getMarkedThreadNotifications(): List { + return monarchy.fetchAllMappedSync( + { TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) }, + { timelineEventMapper.map(it) } + ) + } + + override fun getAllThreadsLive(): LiveData> { + return monarchy.findAllMappedWithChanges( + { TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) }, + { timelineEventMapper.map(it) } + ) + } + + override fun getAllThreads(): List { + return monarchy.fetchAllMappedSync( + { TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) }, + { timelineEventMapper.map(it) } + ) + } + + override fun isUserParticipatingInThread(rootThreadEventId: String): Boolean { + return Realm.getInstance(monarchy.realmConfiguration).use { + TimelineEventEntity.isUserParticipatingInThread( + realm = it, + roomId = roomId, + rootThreadEventId = rootThreadEventId, + senderId = userId) + } + } + + override fun mapEventsWithEdition(threads: List): List { + return Realm.getInstance(monarchy.realmConfiguration).use { + threads.mapEventsWithEdition(it, roomId) + } + } + + override suspend fun markThreadAsRead(rootThreadEventId: String) { + monarchy.awaitTransaction { + EventEntity.where( + realm = it, + eventId = rootThreadEventId).findFirst()?.threadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE + } + } +} 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 71823cd458..3dd4225b2c 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 @@ -35,6 +35,7 @@ import org.matrix.android.sdk.api.extensions.tryOrNull 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.internal.database.lightweight.LightweightSettingsStorage import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask import org.matrix.android.sdk.internal.session.sync.handler.room.ReadReceiptHandler @@ -60,6 +61,7 @@ internal class DefaultTimeline(private val roomId: String, timelineEventMapper: TimelineEventMapper, timelineInput: TimelineInput, threadsAwarenessHandler: ThreadsAwarenessHandler, + lightweightSettingsStorage: LightweightSettingsStorage, eventDecryptor: TimelineEventDecryptor) : Timeline { companion object { @@ -79,6 +81,9 @@ internal class DefaultTimeline(private val roomId: String, private val sequencer = SemaphoreCoroutineSequencer() private val postSnapshotSignalFlow = MutableSharedFlow(0) + private var isFromThreadTimeline = false + private var rootThreadEventId: String? = null + private val strategyDependencies = LoadTimelineStrategy.Dependencies( timelineSettings = settings, realm = backgroundRealm, @@ -89,6 +94,7 @@ internal class DefaultTimeline(private val roomId: String, timelineInput = timelineInput, timelineEventMapper = timelineEventMapper, threadsAwarenessHandler = threadsAwarenessHandler, + lightweightSettingsStorage = lightweightSettingsStorage, onEventsUpdated = this::sendSignalToPostSnapshot, onLimitedTimeline = this::onLimitedTimeline, onNewTimelineEvents = this::onNewTimelineEvents @@ -118,18 +124,21 @@ internal class DefaultTimeline(private val roomId: String, listeners.clear() } - override fun start() { + override fun start(rootThreadEventId: String?) { timelineScope.launch { loadRoomMembersIfNeeded() } timelineScope.launch { sequencer.post { if (isStarted.compareAndSet(false, true)) { + isFromThreadTimeline = rootThreadEventId != null + this@DefaultTimeline.rootThreadEventId = rootThreadEventId + // / val realm = Realm.getInstance(realmConfiguration) ensureReadReceiptAreLoaded(realm) backgroundRealm.set(realm) listenToPostSnapshotSignals() - openAround(initialEventId) + openAround(initialEventId, rootThreadEventId) postSnapshot() } } @@ -150,7 +159,7 @@ internal class DefaultTimeline(private val roomId: String, override fun restartWithEventId(eventId: String?) { timelineScope.launch { - openAround(eventId) + openAround(eventId, rootThreadEventId) postSnapshot() } } @@ -219,19 +228,24 @@ internal class DefaultTimeline(private val roomId: String, return true } - private suspend fun openAround(eventId: String?) = withContext(timelineDispatcher) { + private suspend fun openAround(eventId: String?, rootThreadEventId: String?) = withContext(timelineDispatcher) { val baseLogMessage = "openAround(eventId: $eventId)" Timber.v("$baseLogMessage started") if (!isStarted.get()) { throw IllegalStateException("You should call start before using timeline") } strategy.onStop() - strategy = if (eventId == null) { - buildStrategy(LoadTimelineStrategy.Mode.Live) - } else { - buildStrategy(LoadTimelineStrategy.Mode.Permalink(eventId)) + + strategy = when { + rootThreadEventId != null -> buildStrategy(LoadTimelineStrategy.Mode.Thread(rootThreadEventId)) + eventId == null -> buildStrategy(LoadTimelineStrategy.Mode.Live) + else -> buildStrategy(LoadTimelineStrategy.Mode.Permalink(eventId)) } - initPaginationStates(eventId) + + rootThreadEventId?.let { + initPaginationStates(null) + } ?: initPaginationStates(eventId) + strategy.onStart() loadMore( count = strategyDependencies.timelineSettings.initialSize, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt index 126374b430..d7d61f0b47 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt @@ -32,11 +32,13 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineService import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.internal.database.RealmSessionProvider +import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields 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.room.membership.LoadRoomMembersTask import org.matrix.android.sdk.internal.session.sync.handler.room.ReadReceiptHandler import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler @@ -44,6 +46,7 @@ import org.matrix.android.sdk.internal.task.TaskExecutor internal class DefaultTimelineService @AssistedInject constructor( @Assisted private val roomId: String, + @UserId private val userId: String, @SessionDatabase private val monarchy: Monarchy, private val realmSessionProvider: RealmSessionProvider, private val timelineInput: TimelineInput, @@ -55,6 +58,7 @@ internal class DefaultTimelineService @AssistedInject constructor( private val timelineEventMapper: TimelineEventMapper, private val loadRoomMembersTask: LoadRoomMembersTask, private val threadsAwarenessHandler: ThreadsAwarenessHandler, + private val lightweightSettingsStorage: LightweightSettingsStorage, private val readReceiptHandler: ReadReceiptHandler, private val coroutineDispatchers: MatrixCoroutineDispatchers ) : TimelineService { @@ -79,7 +83,8 @@ internal class DefaultTimelineService @AssistedInject constructor( loadRoomMembersTask = loadRoomMembersTask, readReceiptHandler = readReceiptHandler, getEventTask = contextOfEventTask, - threadsAwarenessHandler = threadsAwarenessHandler + threadsAwarenessHandler = threadsAwarenessHandler, + lightweightSettingsStorage = lightweightSettingsStorage ) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt index 528b564e8b..f332c4a35f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt @@ -26,6 +26,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.internal.database.lightweight.LightweightSettingsStorage import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.ChunkEntityFields @@ -51,6 +52,7 @@ internal class LoadTimelineStrategy( sealed interface Mode { object Live : Mode data class Permalink(val originEventId: String) : Mode + data class Thread(val rootThreadEventId: String) : Mode fun originEventId(): String? { return if (this is Permalink) { @@ -59,6 +61,14 @@ internal class LoadTimelineStrategy( null } } + +// fun getRootThreadEventId(): String? { +// return if (this is Thread) { +// rootThreadEventId +// } else { +// null +// } +// } } data class Dependencies( @@ -71,6 +81,7 @@ internal class LoadTimelineStrategy( val timelineInput: TimelineInput, val timelineEventMapper: TimelineEventMapper, val threadsAwarenessHandler: ThreadsAwarenessHandler, + val lightweightSettingsStorage: LightweightSettingsStorage, val onEventsUpdated: (Boolean) -> Unit, val onLimitedTimeline: () -> Unit, val onNewTimelineEvents: (List) -> Unit @@ -198,12 +209,20 @@ internal class LoadTimelineStrategy( } private fun getChunkEntity(realm: Realm): RealmResults { - return if (mode is Mode.Permalink) { - ChunkEntity.findAllIncludingEvents(realm, listOf(mode.originEventId)) - } else { - ChunkEntity.where(realm, roomId) - .equalTo(ChunkEntityFields.IS_LAST_FORWARD, true) - .findAll() + return when (mode) { + is Mode.Live -> { + ChunkEntity.where(realm, roomId) + .equalTo(ChunkEntityFields.IS_LAST_FORWARD, true) + .findAll() + } + is Mode.Permalink -> { + ChunkEntity.findAllIncludingEvents(realm, listOf(mode.originEventId)) + } + is Mode.Thread -> { + ChunkEntity.where(realm, roomId) + .equalTo(ChunkEntityFields.IS_LAST_FORWARD, true) + .findAll() + } } } @@ -224,6 +243,7 @@ internal class LoadTimelineStrategy( timelineEventMapper = dependencies.timelineEventMapper, uiEchoManager = uiEchoManager, threadsAwarenessHandler = dependencies.threadsAwarenessHandler, + lightweightSettingsStorage = dependencies.lightweightSettingsStorage, initialEventId = mode.originEventId(), onBuiltEvents = dependencies.onEventsUpdated ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt index 6af03a858a..8507b63d1f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt @@ -29,6 +29,7 @@ import org.matrix.android.sdk.api.session.events.model.EventType 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.internal.database.lightweight.LightweightSettingsStorage import org.matrix.android.sdk.internal.database.mapper.EventMapper import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.database.model.ChunkEntity @@ -55,6 +56,7 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, private val timelineEventMapper: TimelineEventMapper, private val uiEchoManager: UIEchoManager? = null, private val threadsAwarenessHandler: ThreadsAwarenessHandler, + private val lightweightSettingsStorage: LightweightSettingsStorage, private val initialEventId: String?, private val onBuiltEvents: (Boolean) -> Unit) { @@ -92,7 +94,7 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, handleDatabaseChangeSet(frozenResults, changeSet) } - private var timelineEventEntities: RealmResults = chunkEntity.sortedTimelineEvents() + private var timelineEventEntities: RealmResults = chunkEntity.sortedTimelineEvents(timelineSettings.rootThreadEventId) private val builtEvents: MutableList = Collections.synchronizedList(ArrayList()) private val builtEventsIndexes: MutableMap = Collections.synchronizedMap(HashMap()) @@ -137,13 +139,18 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, } else if (direction == Timeline.Direction.BACKWARDS && prevChunk != null) { return prevChunk?.loadMore(count, direction, fetchOnServerIfNeeded) ?: LoadMoreResult.FAILURE } - val loadFromStorageCount = loadFromStorage(count, direction) - Timber.v("Has loaded $loadFromStorageCount items from storage in $direction") - val offsetCount = count - loadFromStorageCount + val loadFromStorage = loadFromStorage(count, direction).also { + logLoadedFromStorage(it, direction) + } + + val offsetCount = count - loadFromStorage.numberOfEvents + return if (direction == Timeline.Direction.FORWARDS && isLastForward.get()) { LoadMoreResult.REACHED_END } else if (direction == Timeline.Direction.BACKWARDS && isLastBackward.get()) { LoadMoreResult.REACHED_END + } else if (timelineSettings.isThreadTimeline() && loadFromStorage.threadReachedEnd) { + LoadMoreResult.REACHED_END } else if (offsetCount == 0) { LoadMoreResult.SUCCESS } else { @@ -187,6 +194,16 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, } } + /** + * Simple log that displays the number and timeline of loaded events + */ + private fun logLoadedFromStorage(loadedFromStorage: LoadedFromStorage, direction: Timeline.Direction) { + Timber.v("[" + + "${if (timelineSettings.isThreadTimeline()) "ThreadTimeLine" else "Timeline"}] Has loaded " + + "${loadedFromStorage.numberOfEvents} items from storage in $direction " + + if (timelineSettings.isThreadTimeline() && loadedFromStorage.threadReachedEnd) "[Reached End]" else "") + } + fun getBuiltEventIndex(eventId: String, searchInNext: Boolean, searchInPrev: Boolean): Int? { val builtEventIndex = builtEventsIndexes[eventId] if (builtEventIndex != null) { @@ -267,13 +284,23 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, /** * This method tries to read events from the current chunk. + * @return the number of events loaded. If we are in a thread timeline it also returns + * whether or not we reached the end/root message */ - private suspend fun loadFromStorage(count: Int, direction: Timeline.Direction): Int { - val displayIndex = getNextDisplayIndex(direction) ?: return 0 + private suspend fun loadFromStorage(count: Int, direction: Timeline.Direction): LoadedFromStorage { + val displayIndex = getNextDisplayIndex(direction) ?: return LoadedFromStorage() val baseQuery = timelineEventEntities.where() - val timelineEvents = baseQuery.offsets(direction, count, displayIndex).findAll().orEmpty() - if (timelineEvents.isEmpty()) return 0 - fetchRootThreadEventsIfNeeded(timelineEvents) + + val timelineEvents = baseQuery + .offsets(direction, count, displayIndex) + .findAll() + .orEmpty() + + if (timelineEvents.isEmpty()) return LoadedFromStorage() +// Disabled due to the new fallback +// if(!lightweightSettingsStorage.areThreadMessagesEnabled()) { +// fetchRootThreadEventsIfNeeded(timelineEvents) +// } if (direction == Timeline.Direction.FORWARDS) { builtEventsIndexes.entries.forEach { it.setValue(it.value + timelineEvents.size) } } @@ -291,9 +318,20 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, builtEvents.add(timelineEvent) } } - return timelineEvents.size + return LoadedFromStorage( + threadReachedEnd = threadReachedEnd(timelineEvents), + numberOfEvents = timelineEvents.size) } + /** + * Returns whether or not the the thread has reached end. It returns false if the current timeline + * is not a thread timeline + */ + private fun threadReachedEnd(timelineEvents: List): Boolean = + timelineSettings.rootThreadEventId?.let { rootThreadId -> + timelineEvents.firstOrNull { it.eventId == rootThreadId }?.let { true } + } ?: false + /** * This function is responsible to fetch and store the root event of a thread event * in order to be able to display the event to the user appropriately @@ -316,6 +354,10 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, timelineEvent.root.mxDecryptionResult == null) { timelineEvent.root.eventId?.also { eventDecryptor.requestDecryption(TimelineEventDecryptor.DecryptionRequest(timelineEvent.root, timelineId)) } } + if (!timelineEvent.isEncrypted() && !lightweightSettingsStorage.areThreadMessagesEnabled()) { + // Thread aware for not encrypted events + timelineEvent.root.eventId?.also { eventDecryptor.requestDecryption(TimelineEventDecryptor.DecryptionRequest(timelineEvent.root, timelineId)) } + } return timelineEvent } @@ -343,7 +385,8 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, val loadMoreResult = try { if (token == null) { if (direction == Timeline.Direction.BACKWARDS || !chunkEntity.hasBeenALastForwardChunk()) return LoadMoreResult.REACHED_END - val lastKnownEventId = chunkEntity.sortedTimelineEvents().firstOrNull()?.eventId ?: return LoadMoreResult.FAILURE + val lastKnownEventId = chunkEntity.sortedTimelineEvents(timelineSettings.rootThreadEventId).firstOrNull()?.eventId + ?: return LoadMoreResult.FAILURE val taskParams = FetchTokenAndPaginateTask.Params(roomId, lastKnownEventId, direction.toPaginationDirection(), count) fetchTokenAndPaginateTask.execute(taskParams).toLoadMoreResult() } else { @@ -352,7 +395,7 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, paginationTask.execute(taskParams).toLoadMoreResult() } } catch (failure: Throwable) { - Timber.e("Failed to fetch from server: $failure", failure) + Timber.e(failure, "Failed to fetch from server") LoadMoreResult.FAILURE } return if (loadMoreResult == LoadMoreResult.SUCCESS) { @@ -450,10 +493,16 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, timelineEventMapper = timelineEventMapper, uiEchoManager = uiEchoManager, threadsAwarenessHandler = threadsAwarenessHandler, + lightweightSettingsStorage = lightweightSettingsStorage, initialEventId = null, onBuiltEvents = this.onBuiltEvents ) } + + private data class LoadedFromStorage( + val threadReachedEnd: Boolean = false, + val numberOfEvents: Int = 0 + ) } private fun RealmQuery.offsets( @@ -474,6 +523,19 @@ private fun Timeline.Direction.toPaginationDirection(): PaginationDirection { return if (this == Timeline.Direction.BACKWARDS) PaginationDirection.BACKWARDS else PaginationDirection.FORWARDS } -private fun ChunkEntity.sortedTimelineEvents(): RealmResults { - return timelineEvents.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) +private fun ChunkEntity.sortedTimelineEvents(rootThreadEventId: String?): RealmResults { + return if (rootThreadEventId == null) { + timelineEvents + .sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) + } else { + timelineEvents + .where() + .beginGroup() + .equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId) + .or() + .equalTo(TimelineEventEntityFields.ROOT.EVENT_ID, rootThreadEventId) + .endGroup() + .sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) + .findAll() + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDecryptor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDecryptor.kt index 75d02dfd98..49a8a8b55a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDecryptor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDecryptor.kt @@ -23,6 +23,8 @@ import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.internal.crypto.NewSessionListener import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent +import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage +import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.di.SessionDatabase @@ -36,7 +38,8 @@ internal class TimelineEventDecryptor @Inject constructor( @SessionDatabase private val realmConfiguration: RealmConfiguration, private val cryptoService: CryptoService, - private val threadsAwarenessHandler: ThreadsAwarenessHandler + private val threadsAwarenessHandler: ThreadsAwarenessHandler, + private val lightweightSettingsStorage: LightweightSettingsStorage ) { private val newSessionListener = object : NewSessionListener { @@ -101,9 +104,27 @@ internal class TimelineEventDecryptor @Inject constructor( } } + private fun threadAwareNonEncryptedEvents(request: DecryptionRequest, realm: Realm) { + val event = request.event + realm.executeTransaction { + val eventId = event.eventId ?: return@executeTransaction + val eventEntity = EventEntity + .where(it, eventId = eventId) + .findFirst() + val decryptedEvent = eventEntity?.asDomain() + threadsAwarenessHandler.makeEventThreadAware(realm, event.roomId, decryptedEvent, eventEntity) + } + } private fun processDecryptRequest(request: DecryptionRequest, realm: Realm) { val event = request.event val timelineId = request.timelineId + + if (!request.event.isEncrypted()) { + // Here we have requested a decryption to an event that is not encrypted + // We will simply make this event thread aware + threadAwareNonEncryptedEvents(request, realm) + return + } try { val result = cryptoService.decryptEvent(request.event, timelineId) Timber.v("Successfully decrypted event ${event.eventId}") @@ -112,15 +133,9 @@ internal class TimelineEventDecryptor @Inject constructor( val eventEntity = EventEntity .where(it, eventId = eventId) .findFirst() - - eventEntity?.apply { - val decryptedPayload = threadsAwarenessHandler.handleIfNeededDuringDecryption( - it, - roomId = event.roomId, - event, - result) - setDecryptionResult(result, decryptedPayload) - } + eventEntity?.setDecryptionResult(result) + val decryptedEvent = eventEntity?.asDomain() + threadsAwarenessHandler.makeEventThreadAware(realm, event.roomId, decryptedEvent, eventEntity) } } catch (e: MXCryptoError) { Timber.v("Failed to decrypt event ${event.eventId} : ${e.localizedMessage}") diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt index a85f0dbdc9..6607e71bd9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt @@ -26,8 +26,11 @@ import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.internal.database.helper.addIfNecessary import org.matrix.android.sdk.internal.database.helper.addStateEvent import org.matrix.android.sdk.internal.database.helper.addTimelineEvent +import org.matrix.android.sdk.internal.database.helper.updateThreadSummaryIfNeeded +import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage 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.EventEntity import org.matrix.android.sdk.internal.database.model.EventInsertType import org.matrix.android.sdk.internal.database.model.RoomEntity import org.matrix.android.sdk.internal.database.model.TimelineEventEntity @@ -36,6 +39,7 @@ import org.matrix.android.sdk.internal.database.query.create import org.matrix.android.sdk.internal.database.query.find 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.StreamEventsManager import org.matrix.android.sdk.internal.util.awaitTransaction import timber.log.Timber @@ -45,8 +49,10 @@ import javax.inject.Inject * Insert Chunk in DB, and eventually link next and previous chunk in db. */ internal class TokenChunkEventPersistor @Inject constructor( - @SessionDatabase private val monarchy: Monarchy, - private val liveEventManager: Lazy) { + @SessionDatabase private val monarchy: Monarchy, + @UserId private val userId: String, + private val lightweightSettingsStorage: LightweightSettingsStorage, + private val liveEventManager: Lazy) { enum class Result { SHOULD_FETCH_MORE, @@ -90,6 +96,7 @@ internal class TokenChunkEventPersistor @Inject constructor( handlePagination(realm, roomId, direction, receivedChunk, currentChunk) } } + return if (receivedChunk.events.isEmpty()) { if (receivedChunk.hasMore()) { Result.SHOULD_FETCH_MORE @@ -132,6 +139,7 @@ internal class TokenChunkEventPersistor @Inject constructor( roomMemberContentsByUser[stateEvent.stateKey] = stateEvent.content.toModel() } } + val optimizedThreadSummaryMap = hashMapOf() run processTimelineEvents@{ eventList.forEach { event -> if (event.eventId == null || event.senderId == null) { @@ -176,10 +184,28 @@ internal class TokenChunkEventPersistor @Inject constructor( } liveEventManager.get().dispatchPaginatedEventReceived(event, roomId) currentChunk.addTimelineEvent(roomId, eventEntity, direction, roomMemberContentsByUser) + if (lightweightSettingsStorage.areThreadMessagesEnabled()) { + eventEntity.rootThreadEventId?.let { + // This is a thread event + optimizedThreadSummaryMap[it] = eventEntity + } ?: run { + // This is a normal event or a root thread one + optimizedThreadSummaryMap[eventEntity.eventId] = eventEntity + } + } } } if (currentChunk.isValid) { RoomEntity.where(realm, roomId).findFirst()?.addIfNecessary(currentChunk) } + + if (lightweightSettingsStorage.areThreadMessagesEnabled()) { + optimizedThreadSummaryMap.updateThreadSummaryIfNeeded( + roomId = roomId, + realm = realm, + currentUserId = userId, + chunkEntity = currentChunk + ) + } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/UIEchoManager.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/UIEchoManager.kt index 16d36c0cd9..bb92623249 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/UIEchoManager.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/UIEchoManager.kt @@ -66,11 +66,11 @@ internal class UIEchoManager(private val listener: Listener) { return existingState != sendState } - fun onLocalEchoCreated(timelineEvent: TimelineEvent): Boolean { + fun onLocalEchoCreated(timelineEvent: TimelineEvent): Boolean { when (timelineEvent.root.getClearType()) { EventType.REDACTION -> { } - EventType.REACTION -> { + EventType.REACTION -> { val content: ReactionContent? = timelineEvent.root.content?.toModel() if (RelationType.ANNOTATION == content?.relatesTo?.type) { val reaction = content.relatesTo.key @@ -104,8 +104,8 @@ internal class UIEchoManager(private val listener: Listener) { val updateReactions = existingAnnotationSummary.reactionsSummary.toMutableList() contents.forEach { uiEchoReaction -> - val existing = updateReactions.firstOrNull { it.key == uiEchoReaction.reaction } - if (existing == null) { + val indexOfExistingReaction = updateReactions.indexOfFirst { it.key == uiEchoReaction.reaction } + if (indexOfExistingReaction == -1) { // just add the new key ReactionAggregatedSummary( key = uiEchoReaction.reaction, @@ -117,6 +117,7 @@ internal class UIEchoManager(private val listener: Listener) { ).let { updateReactions.add(it) } } else { // update Existing Key + val existing = updateReactions[indexOfExistingReaction] if (!existing.localEchoEvents.contains(uiEchoReaction.localEchoId)) { updateReactions.remove(existing) // only update if echo is not yet there @@ -128,7 +129,7 @@ internal class UIEchoManager(private val listener: Listener) { sourceEvents = existing.sourceEvents, localEchoEvents = existing.localEchoEvents + uiEchoReaction.localEchoId - ).let { updateReactions.add(it) } + ).let { updateReactions.add(indexOfExistingReaction, it) } } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/search/SearchTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/search/SearchTask.kt index 8de762ee1b..3ba7d11c3d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/search/SearchTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/search/SearchTask.kt @@ -19,6 +19,10 @@ package org.matrix.android.sdk.internal.session.search import org.matrix.android.sdk.api.session.search.EventAndSender import org.matrix.android.sdk.api.session.search.SearchResult import org.matrix.android.sdk.api.util.MatrixItem +import org.matrix.android.sdk.internal.database.RealmSessionProvider +import org.matrix.android.sdk.internal.database.mapper.asDomain +import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.session.search.request.SearchRequestBody @@ -28,6 +32,7 @@ import org.matrix.android.sdk.internal.session.search.request.SearchRequestFilte import org.matrix.android.sdk.internal.session.search.request.SearchRequestOrder import org.matrix.android.sdk.internal.session.search.request.SearchRequestRoomEvents import org.matrix.android.sdk.internal.session.search.response.SearchResponse +import org.matrix.android.sdk.internal.session.search.response.SearchResponseItem import org.matrix.android.sdk.internal.task.Task import javax.inject.Inject @@ -47,7 +52,8 @@ internal interface SearchTask : Task { internal class DefaultSearchTask @Inject constructor( private val searchAPI: SearchAPI, - private val globalErrorReceiver: GlobalErrorReceiver + private val globalErrorReceiver: GlobalErrorReceiver, + private val realmSessionProvider: RealmSessionProvider ) : SearchTask { override suspend fun execute(params: SearchTask.Params): SearchResult { @@ -74,12 +80,22 @@ internal class DefaultSearchTask @Inject constructor( } private fun SearchResponse.toDomain(): SearchResult { + val localTimelineEvents = findRootThreadEventsFromDB(searchCategories.roomEvents?.results) return SearchResult( nextBatch = searchCategories.roomEvents?.nextBatch, highlights = searchCategories.roomEvents?.highlights, results = searchCategories.roomEvents?.results?.map { searchResponseItem -> + + val localThreadEventDetails = localTimelineEvents + ?.firstOrNull { it.eventId == searchResponseItem.event.eventId } + ?.root + ?.asDomain() + ?.threadDetails + EventAndSender( - searchResponseItem.event, + searchResponseItem.event.apply { + threadDetails = localThreadEventDetails + }, searchResponseItem.event.senderId?.let { senderId -> searchResponseItem.context?.profileInfo?.get(senderId) ?.let { @@ -94,4 +110,19 @@ internal class DefaultSearchTask @Inject constructor( }?.reversed() ) } + + /** + * Find local events if exists in order to enhance the result with thread summary + */ + private fun findRootThreadEventsFromDB(searchResponseItemList: List?): List? { + return realmSessionProvider.withRealm { realm -> + searchResponseItemList?.mapNotNull { + it.event.roomId ?: return@mapNotNull null + it.event.eventId ?: return@mapNotNull null + TimelineEventEntity.where(realm, it.event.roomId, it.event.eventId).findFirst() + }?.filter { + it.root?.isRootThread == true || it.root?.isThread() == true + } + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpace.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpace.kt index 8589db27b1..303eda49d8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpace.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpace.kt @@ -40,10 +40,6 @@ internal class DefaultSpace( override val spaceId = room.roomId - override suspend fun leave(reason: String?) { - return room.leave(reason) - } - override fun spaceSummary(): RoomSummary? { return spaceSummaryDataSource.getSpaceSummary(room.roomId) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt index ebd5f2578e..c18055e089 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt @@ -184,6 +184,10 @@ internal class DefaultSpaceService @Inject constructor( return joinSpaceTask.execute(JoinSpaceTask.Params(spaceIdOrAlias, reason, viaServers)) } + override suspend fun leaveSpace(spaceId: String, reason: String?) { + leaveRoomTask.execute(LeaveRoomTask.Params(spaceId, reason)) + } + override suspend fun rejectInvite(spaceId: String, reason: String?) { leaveRoomTask.execute(LeaveRoomTask.Params(spaceId, reason)) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt index f178074507..f93da9705d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt @@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.session.sync.model.RoomsSyncResponse import org.matrix.android.sdk.api.session.sync.model.SyncResponse import org.matrix.android.sdk.internal.SessionManager import org.matrix.android.sdk.internal.crypto.DefaultCryptoService +import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.di.SessionId import org.matrix.android.sdk.internal.di.WorkManagerProvider @@ -64,6 +65,7 @@ internal class SyncResponseHandler @Inject constructor( private val aggregatorHandler: SyncResponsePostTreatmentAggregatorHandler, private val cryptoService: DefaultCryptoService, private val tokenStore: SyncTokenStore, + private val lightweightSettingsStorage: LightweightSettingsStorage, private val processEventForPushTask: ProcessEventForPushTask, private val pushRuleService: PushRuleService, private val threadsAwarenessHandler: ThreadsAwarenessHandler, @@ -101,7 +103,10 @@ internal class SyncResponseHandler @Inject constructor( val aggregator = SyncResponsePostTreatmentAggregator() // Prerequisite for thread events handling in RoomSyncHandler - threadsAwarenessHandler.fetchRootThreadEventsIfNeeded(syncResponse) +// Disabled due to the new fallback +// if (!lightweightSettingsStorage.areThreadMessagesEnabled()) { +// threadsAwarenessHandler.fetchRootThreadEventsIfNeeded(syncResponse) +// } // Start one big transaction monarchy.awaitTransaction { realm -> 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 a0d1ebec4d..99e6521eb7 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 @@ -36,10 +36,13 @@ import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult import org.matrix.android.sdk.internal.database.helper.addIfNecessary import org.matrix.android.sdk.internal.database.helper.addTimelineEvent +import org.matrix.android.sdk.internal.database.helper.updateThreadSummaryIfNeeded +import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage 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.EventEntity import org.matrix.android.sdk.internal.database.model.EventInsertType import org.matrix.android.sdk.internal.database.model.RoomEntity import org.matrix.android.sdk.internal.database.model.RoomMemberSummaryEntity @@ -81,6 +84,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle private val threadsAwarenessHandler: ThreadsAwarenessHandler, private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource, @UserId private val userId: String, + private val lightweightSettingsStorage: LightweightSettingsStorage, private val timelineInput: TimelineInput, private val liveEventService: Lazy) { @@ -350,7 +354,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle aggregator: SyncResponsePostTreatmentAggregator): ChunkEntity { val lastChunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomEntity.roomId) if (isLimited && lastChunk != null) { - lastChunk.deleteOnCascade(deleteStateEvents = true, canDeleteRoot = true) + lastChunk.deleteOnCascade(deleteStateEvents = false, canDeleteRoot = true) } val chunkEntity = if (!isLimited && lastChunk != null) { lastChunk @@ -363,10 +367,12 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle val eventIds = ArrayList(eventList.size) val roomMemberContentsByUser = HashMap() + val optimizedThreadSummaryMap = hashMapOf() for (event in eventList) { if (event.eventId == null || event.senderId == null || event.type == null) { continue } + eventIds.add(event.eventId) liveEventService.get().dispatchLiveEventReceived(event, roomId, insertType == EventInsertType.INITIAL_SYNC) @@ -375,14 +381,13 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle if (event.isEncrypted() && !isInitialSync) { decryptIfNeeded(event, roomId) } - - threadsAwarenessHandler.handleIfNeeded( - realm = realm, - roomId = roomId, - event = event) + var contentToInject: String? = null + if (!isInitialSync) { + contentToInject = threadsAwarenessHandler.makeEventThreadAware(realm, roomId, event) + } val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it } - val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, insertType) + val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs, contentToInject).copyToRealmOrIgnore(realm, insertType) if (event.stateKey != null) { CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply { eventId = event.eventId @@ -402,6 +407,15 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle } chunkEntity.addTimelineEvent(roomId, eventEntity, PaginationDirection.FORWARDS, roomMemberContentsByUser) + if (lightweightSettingsStorage.areThreadMessagesEnabled()) { + eventEntity.rootThreadEventId?.let { + // This is a thread event + optimizedThreadSummaryMap[it] = eventEntity + } ?: run { + // This is a normal event or a root thread one + optimizedThreadSummaryMap[eventEntity.eventId] = eventEntity + } + } // Give info to crypto module cryptoService.onLiveEvent(roomEntity.roomId, event) @@ -426,9 +440,15 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle } } } - // Handle deletion of [stuck] local echos if needed - deleteLocalEchosIfNeeded(insertType, roomEntity, eventList) + deleteLocalEchosIfNeeded(insertType, roomEntity, eventList) + if (lightweightSettingsStorage.areThreadMessagesEnabled()) { + optimizedThreadSummaryMap.updateThreadSummaryIfNeeded( + roomId = roomId, + realm = realm, + chunkEntity = chunkEntity, + currentUserId = userId) + } // posting new events to timeline if any is registered timelineInput.onNewTimelineEvents(roomId = roomId, eventIds = eventIds) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt index 767a967522..f3a1523955 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt @@ -18,26 +18,35 @@ package org.matrix.android.sdk.internal.session.sync.handler.room import com.zhuinden.monarchy.Monarchy import io.realm.Realm -import org.matrix.android.sdk.api.session.crypto.CryptoService -import org.matrix.android.sdk.api.session.crypto.MXCryptoError +import io.realm.kotlin.where +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 +import org.matrix.android.sdk.api.session.events.model.getRelationContentForType +import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId +import org.matrix.android.sdk.api.session.events.model.isSticker import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.message.MessageFormat import org.matrix.android.sdk.api.session.room.model.message.MessageRelationContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent +import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.sync.model.SyncResponse import org.matrix.android.sdk.api.util.JsonDict -import org.matrix.android.sdk.internal.crypto.MXEventDecryptionResult import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult +import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage +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.mapper.toEntity import org.matrix.android.sdk.internal.database.model.EventEntity +import org.matrix.android.sdk.internal.database.model.EventEntityFields import org.matrix.android.sdk.internal.database.model.EventInsertType import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.di.MoshiProvider import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.session.permalinks.PermalinkFactory import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory @@ -52,11 +61,16 @@ import javax.inject.Inject */ internal class ThreadsAwarenessHandler @Inject constructor( private val permalinkFactory: PermalinkFactory, - private val cryptoService: CryptoService, @SessionDatabase private val monarchy: Monarchy, + private val lightweightSettingsStorage: LightweightSettingsStorage, private val getEventTask: GetEventTask ) { + // This caching is responsible to improve the performance when we receive a root event + // to be able to know this event is a root one without checking the DB, + // We update the list with all thread root events by checking if there is a m.thread relation on the events + private val cacheEventRootId = hashSetOf() + /** * Fetch root thread events if they are missing from the local storage * @param syncResponse the sync response @@ -84,7 +98,7 @@ internal class ThreadsAwarenessHandler @Inject constructor( if (eventList.isNullOrEmpty()) return val threadsToFetch = emptyMap().toMutableMap() - Realm.getInstance(monarchy.realmConfiguration).use { realm -> + Realm.getInstance(monarchy.realmConfiguration).use { realm -> eventList.asSequence() .filter { isThreadEvent(it) && it.roomId != null @@ -139,96 +153,186 @@ internal class ThreadsAwarenessHandler @Inject constructor( /** * Handle events mainly coming from the RoomSyncHandler + * @return The content to inject in the roomSyncHandler live events */ - fun handleIfNeeded(realm: Realm, - roomId: String, - event: Event) { - val payload = transformThreadToReplyIfNeeded( - realm = realm, - roomId = roomId, - event = event, - decryptedResult = event.mxDecryptionResult?.payload) ?: return - - event.mxDecryptionResult = event.mxDecryptionResult?.copy(payload = payload) - } - - /** - * Handle events while they are being decrypted - */ - fun handleIfNeededDuringDecryption(realm: Realm, - roomId: String?, - event: Event, - result: MXEventDecryptionResult): JsonDict? { - return transformThreadToReplyIfNeeded( - realm = realm, - roomId = roomId, - event = event, - decryptedResult = result.clearEvent) - } - - /** - * If the event is a thread event then transform/enhance it to a visual Reply Event, - * If the event is not a thread event, null value will be returned - * If there is an error (ex. the root/origin thread event is not found), null willl be returend - */ - private fun transformThreadToReplyIfNeeded(realm: Realm, roomId: String?, event: Event, decryptedResult: JsonDict?): JsonDict? { + fun makeEventThreadAware(realm: Realm, + roomId: String?, + event: Event?, + eventEntity: EventEntity? = null): String? { + event ?: return null roomId ?: return null + if (lightweightSettingsStorage.areThreadMessagesEnabled()) return null + handleRootThreadEventsIfNeeded(realm, roomId, eventEntity, event) if (!isThreadEvent(event)) return null - val rootThreadEventId = getRootThreadEventId(event) ?: return null - val payload = decryptedResult?.toMutableMap() ?: return null - val body = getValueFromPayload(payload, "body") ?: return null - val msgType = getValueFromPayload(payload, "msgtype") ?: return null - val rootThreadEvent = getEventFromDB(realm, rootThreadEventId) ?: return null - val rootThreadEventSenderId = rootThreadEvent.senderId ?: return null + val eventPayload = if (!event.isEncrypted()) { + event.content?.toMutableMap() ?: return null + } else { + event.mxDecryptionResult?.payload?.toMutableMap() ?: return null + } + val eventBody = event.getDecryptedTextSummary() ?: return null + val eventIdToInject = getPreviousEventOrRoot(event) ?: run { + return@makeEventThreadAware injectFallbackIndicator(event, eventBody, eventEntity, eventPayload) + } + val eventToInject = getEventFromDB(realm, eventIdToInject) + val eventToInjectBody = eventToInject?.getDecryptedTextSummary() + var contentForNonEncrypted: String? + if (eventToInject != null && eventToInjectBody != null) { + // If the event to inject exists and is decrypted + // Inject it to our event + val messageTextContent = injectEvent( + roomId = roomId, + eventBody = eventBody, + eventToInject = eventToInject, + eventToInjectBody = eventToInjectBody) ?: return null + // update the event + contentForNonEncrypted = updateEventEntity(event, eventEntity, eventPayload, messageTextContent) + } else { + contentForNonEncrypted = injectFallbackIndicator(event, eventBody, eventEntity, eventPayload) + } - decryptIfNeeded(rootThreadEvent, roomId) + // Now lets try to find relations for improved results, while some events may come with reverse order + eventEntity?.let { + // When eventEntity is not null means that we are not from within roomSyncHandler + handleEventsThatRelatesTo(realm, roomId, event, eventBody, false) + } + return contentForNonEncrypted + } - val rootThreadEventBody = getValueFromPayload(rootThreadEvent.mxDecryptionResult?.payload?.toMutableMap(), "body") + /** + * Handle for not thread events that we have marked them as root. + * Find relations and inject them accordingly + * @param eventEntity the current eventEntity received + * @param event the current event received + * @return The content to inject in the roomSyncHandler live events + */ + private fun handleRootThreadEventsIfNeeded(realm: Realm, roomId: String, eventEntity: EventEntity?, event: Event): String? { + if (!isThreadEvent(event) && cacheEventRootId.contains(eventEntity?.eventId)) { + eventEntity?.let { + val eventBody = event.getDecryptedTextSummary() ?: return null + return handleEventsThatRelatesTo(realm, roomId, event, eventBody, true) + } + } + return null + } - val permalink = permalinkFactory.createPermalink(roomId, rootThreadEventId, false) - val userLink = permalinkFactory.createPermalink(rootThreadEventSenderId, false) ?: "" + /** + * This function is responsible to check if there is any event that relates to our current event + * This is useful when we receive an event that relates to a missing parent, so when later we receive the parent + * we can update the child as well + * @param event the current event that we examine + * @param eventBody the current body of the event + * @param isFromCache determines whether or not we already know this is root thread event + * @return The content to inject in the roomSyncHandler live events + */ + private fun handleEventsThatRelatesTo(realm: Realm, roomId: String, event: Event, eventBody: String, isFromCache: Boolean): String? { + event.eventId ?: return null + val rootThreadEventId = if (isFromCache) event.eventId else event.getRootThreadEventId() ?: return null + eventThatRelatesTo(realm, event.eventId, rootThreadEventId)?.forEach { eventEntityFound -> + val newEventFound = eventEntityFound.asDomain() + val newEventBody = newEventFound.getDecryptedTextSummary() ?: return null + val newEventPayload = newEventFound.mxDecryptionResult?.payload?.toMutableMap() ?: return null + val messageTextContent = injectEvent( + roomId = roomId, + eventBody = newEventBody, + eventToInject = event, + eventToInjectBody = eventBody) ?: return null + + return updateEventEntity(newEventFound, eventEntityFound, newEventPayload, messageTextContent) + } + return null + } + + /** + * Actual update the eventEntity with the new payload + * @return the content to inject when this is executed by RoomSyncHandler + */ + private fun updateEventEntity(event: Event, + eventEntity: EventEntity?, + eventPayload: MutableMap, + messageTextContent: Content): String? { + eventPayload["content"] = messageTextContent + + if (event.isEncrypted()) { + if (event.isSticker()) { + eventPayload["type"] = EventType.MESSAGE + } + event.mxDecryptionResult = event.mxDecryptionResult?.copy(payload = eventPayload) + eventEntity?.decryptionResultJson = event.mxDecryptionResult?.let { + MoshiProvider.providesMoshi().adapter(OlmDecryptionResult::class.java).toJson(it) + } + } else { + if (event.type == EventType.STICKER) { + eventEntity?.type = EventType.MESSAGE + } + eventEntity?.content = ContentMapper.map(messageTextContent) + return ContentMapper.map(messageTextContent) + } + return null + } + + /** + * Injecting $eventToInject decrypted content as a reply to $event + * @param eventToInject the event that will inject + * @param eventBody the actual event body + * @return The final content with the injected event + */ + private fun injectEvent(roomId: String, + eventBody: String, + eventToInject: Event, + eventToInjectBody: String): Content? { + val eventToInjectId = eventToInject.eventId ?: return null + val eventIdToInjectSenderId = eventToInject.senderId.orEmpty() + val permalink = permalinkFactory.createPermalink(roomId, eventToInjectId, false) + val userLink = permalinkFactory.createPermalink(eventIdToInjectSenderId, false) ?: "" val replyFormatted = LocalEchoEventFactory.REPLY_PATTERN.format( permalink, userLink, - rootThreadEventSenderId, - // Remove inner mx_reply tags if any - rootThreadEventBody, - body) + eventIdToInjectSenderId, + eventToInjectBody, + eventBody) - val messageTextContent = MessageTextContent( - msgType = msgType, + return MessageTextContent( + msgType = MessageType.MSGTYPE_TEXT, format = MessageFormat.FORMAT_MATRIX_HTML, - body = body, + body = eventBody, formattedBody = replyFormatted ).toContent() - - payload["content"] = messageTextContent - - return payload } /** - * Decrypt the event + * Integrate fallback Quote reply */ + private fun injectFallbackIndicator(event: Event, + eventBody: String, + eventEntity: EventEntity?, + eventPayload: MutableMap): String? { + val replyFormatted = LocalEchoEventFactory.QUOTE_PATTERN.format( + "In reply to a thread", + eventBody) - private fun decryptIfNeeded(event: Event, roomId: String) { - try { - if (!event.isEncrypted() || event.mxDecryptionResult != null) return + val messageTextContent = MessageTextContent( + msgType = MessageType.MSGTYPE_TEXT, + format = MessageFormat.FORMAT_MATRIX_HTML, + body = eventBody, + formattedBody = replyFormatted + ).toContent() - // Event from sync does not have roomId, so add it to the event first - val result = cryptoService.decryptEvent(event.copy(roomId = roomId), "") - event.mxDecryptionResult = OlmDecryptionResult( - payload = result.clearEvent, - senderKey = result.senderCurve25519Key, - keysClaimed = result.claimedEd25519Key?.let { k -> mapOf("ed25519" to k) }, - forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain - ) - } catch (e: MXCryptoError) { - if (e is MXCryptoError.Base) { - event.mCryptoError = e.errorType - event.mCryptoErrorReason = e.technicalMessage.takeIf { it.isNotEmpty() } ?: e.detailedErrorDescription - } + return updateEventEntity(event, eventEntity, eventPayload, messageTextContent) + } + + private fun eventThatRelatesTo(realm: Realm, currentEventId: String, rootThreadEventId: String): List? { + val threadList = realm.where() + .beginGroup() + .equalTo(EventEntityFields.ROOT_THREAD_EVENT_ID, rootThreadEventId) + .or() + .equalTo(EventEntityFields.EVENT_ID, rootThreadEventId) + .endGroup() + .and() + .findAll() + cacheEventRootId.add(rootThreadEventId) + return threadList.filter { + it.asDomain().getRelationContentForType(RelationType.IO_THREAD)?.inReplyTo?.eventId == currentEventId } } @@ -246,7 +350,7 @@ internal class ThreadsAwarenessHandler @Inject constructor( * @param event */ private fun isThreadEvent(event: Event): Boolean = - event.content.toModel()?.relatesTo?.type == RelationType.THREAD + event.content.toModel()?.relatesTo?.type == RelationType.IO_THREAD /** * Returns the root thread eventId or null otherwise @@ -255,6 +359,9 @@ internal class ThreadsAwarenessHandler @Inject constructor( private fun getRootThreadEventId(event: Event): String? = event.content.toModel()?.relatesTo?.eventId + private fun getPreviousEventOrRoot(event: Event): String? = + event.content.toModel()?.relatesTo?.inReplyTo?.eventId + @Suppress("UNCHECKED_CAST") private fun getValueFromPayload(payload: JsonDict?, key: String): String? { val content = payload?.get("content") as? JsonDict diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/database/RealmMigrator.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/database/RealmMigrator.kt new file mode 100644 index 0000000000..15e82f3cc0 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/database/RealmMigrator.kt @@ -0,0 +1,52 @@ +/* + * 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.util.database + +import io.realm.DynamicRealm +import io.realm.RealmObjectSchema +import timber.log.Timber + +abstract class RealmMigrator(private val realm: DynamicRealm, + private val targetSchemaVersion: Int) { + fun perform() { + Timber.d("Migrate ${realm.configuration.realmFileName} to $targetSchemaVersion") + doMigrate(realm) + } + + abstract fun doMigrate(realm: DynamicRealm) + + protected fun RealmObjectSchema.addFieldIfNotExists(fieldName: String, fieldType: Class<*>): RealmObjectSchema { + if (!hasField(fieldName)) { + addField(fieldName, fieldType) + } + return this + } + + protected fun RealmObjectSchema.removeFieldIfExists(fieldName: String): RealmObjectSchema { + if (hasField(fieldName)) { + removeField(fieldName) + } + return this + } + + protected fun RealmObjectSchema.setRequiredIfNotAlready(fieldName: String, isRequired: Boolean): RealmObjectSchema { + if (isRequired != isRequired(fieldName)) { + setRequired(fieldName, isRequired) + } + return this + } +} diff --git a/tools/check/forbidden_strings_in_code.txt b/tools/check/forbidden_strings_in_code.txt old mode 100644 new mode 100755 diff --git a/tools/check/forbidden_strings_in_layout.txt b/tools/check/forbidden_strings_in_layout.txt old mode 100644 new mode 100755 index 545983f844..e46aa3a0bb --- a/tools/check/forbidden_strings_in_layout.txt +++ b/tools/check/forbidden_strings_in_layout.txt @@ -24,7 +24,7 @@ # Extension:xml ### Use style="@style/Widget.Vector.TextView.*" instead of textSize attribute -android:textSize===9 +android:textSize===11 ### Use `@id` and not `@+id` when referencing ids in layouts layout_(.*)="@\+id diff --git a/tools/emojis/emoji_picker_datasource_formatted.json b/tools/emojis/emoji_picker_datasource_formatted.json index 341cdc0c54..c1aa590003 100644 --- a/tools/emojis/emoji_picker_datasource_formatted.json +++ b/tools/emojis/emoji_picker_datasource_formatted.json @@ -2475,9 +2475,11 @@ "b": "1F636-200D-1F32B-FE0F", "j": [ "absentminded", - "face in clouds", "face in the fog", - "head in clouds" + "head in clouds", + "shower", + "steam", + "dream" ] }, "smirking-face": { @@ -2536,12 +2538,14 @@ "b": "1F62E-200D-1F4A8", "j": [ "exhale", - "face exhaling", "gasp", "groan", "relief", "whisper", - "whistle" + "whistle", + "relieve", + "tired", + "sigh" ] }, "lying-face": { @@ -2745,11 +2749,15 @@ "b": "1F635-200D-1F4AB", "j": [ "dizzy", - "face with spiral eyes", "hypnotized", "spiral", "trouble", - "whoa" + "whoa", + "sick", + "ill", + "confused", + "nauseous", + "nausea" ] }, "exploding-head": { @@ -3704,10 +3712,11 @@ "j": [ "burn", "heart", - "heart on fire", "love", "lust", - "sacred heart" + "sacred heart", + "passionate", + "enthusiastic" ] }, "mending-heart": { @@ -3717,10 +3726,12 @@ "healthier", "improving", "mending", - "mending heart", "recovering", "recuperating", - "well" + "well", + "broken heart", + "bandage", + "wounded" ] }, "red-heart": { @@ -4748,7 +4759,8 @@ "j": [ "beard", "man", - "man: beard" + "man: beard", + "facial hair" ] }, "woman-beard": { @@ -4757,7 +4769,8 @@ "j": [ "beard", "woman", - "woman: beard" + "woman: beard", + "facial hair" ] }, "man-red-hair": { diff --git a/vector/build.gradle b/vector/build.gradle index 1c65850a00..91a6319975 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -14,11 +14,11 @@ kapt { // Note: 2 digits max for each value ext.versionMajor = 1 -ext.versionMinor = 3 +ext.versionMinor = 4 // 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 = 18 +ext.versionPatch = 2 static def getGitTimestamp() { def cmd = 'git show -s --format=%ct' @@ -153,6 +153,9 @@ android { // This *must* only be set in trusted environments. buildConfigField "Boolean", "handleCallAssertedIdentityEvents", "false" + // Indicates whether or not threading support is enabled + buildConfigField "Boolean", "THREADING_ENABLED", "${isThreadingEnabled}" + buildConfigField "Boolean", "enableLocationSharing", "true" buildConfigField "String", "mapTilerKey", "\"fU3vlMsMn4Jb6dnEIFsx\"" @@ -373,7 +376,7 @@ dependencies { implementation 'com.facebook.stetho:stetho:1.6.0' // Phone number https://github.com/google/libphonenumber - implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.41' + implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.43' // FlowBinding implementation libs.github.flowBinding @@ -443,7 +446,7 @@ dependencies { implementation libs.github.glide kapt libs.github.glideCompiler - implementation 'com.github.yalantis:ucrop:2.2.7' + implementation 'com.github.yalantis:ucrop:2.2.8' // Badge for compatibility implementation 'me.leolin:ShortcutBadger:1.1.22@aar' diff --git a/vector/src/androidTest/java/im/vector/app/EspressoExt.kt b/vector/src/androidTest/java/im/vector/app/EspressoExt.kt index 1c3799f81d..ef76a9d1f3 100644 --- a/vector/src/androidTest/java/im/vector/app/EspressoExt.kt +++ b/vector/src/androidTest/java/im/vector/app/EspressoExt.kt @@ -195,7 +195,9 @@ fun activityIdlingResource(activityClass: Class<*>): IdlingResource { println("*** [$name] onActivityLifecycleChanged callback: $callback") callback?.onTransitionToIdle() } - else -> {} + else -> { + // do nothing, we're blocking until the activity resumes + } } } } diff --git a/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt b/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt index 1f47f3a798..bab397678e 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt @@ -20,9 +20,12 @@ import androidx.test.espresso.IdlingPolicies import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest +import im.vector.app.R import im.vector.app.espresso.tools.ScreenshotFailureRule import im.vector.app.features.MainActivity +import im.vector.app.getString import im.vector.app.ui.robot.ElementRobot +import im.vector.app.ui.robot.withDeveloperMode import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain @@ -52,45 +55,44 @@ class UiAllScreensSanityTest { fun allScreensTest() { IdlingPolicies.setMasterPolicyTimeout(120, TimeUnit.SECONDS) -// elementRobot.onboarding { -// crawl() -// } + elementRobot.onboarding { + crawl() + } // Create an account -// val userId = "UiTest_" + UUID.randomUUID().toString() -// elementRobot.signUp(userId) + val userId = "UiTest_" + UUID.randomUUID().toString() + elementRobot.signUp(userId) -// elementRobot.settings { -// general { crawl() } -// notifications { crawl() } -// preferences { crawl() } -// voiceAndVideo() -// ignoredUsers() -// // TODO Test analytics -// securityAndPrivacy { crawl() } -// labs() -// advancedSettings { crawl() } -// // TODO Rework this part (Legals, etc.) -// // helpAndAbout { crawl() } -// } + elementRobot.settings { + general { crawl() } + notifications { crawl() } + preferences { crawl() } + voiceAndVideo() + ignoredUsers() + securityAndPrivacy { crawl() } + labs() + advancedSettings { crawl() } + helpAndAbout { crawl() } + legals { crawl() } + } -// elementRobot.newDirectMessage { -// verifyQrCodeButton() -// verifyInviteFriendsButton() -// } + elementRobot.newDirectMessage { + verifyQrCodeButton() + verifyInviteFriendsButton() + } -// elementRobot.newRoom { -// createNewRoom { -// crawl() -// createRoom { -// val message = "Hello world!" -// postMessage(message) -// crawl() -// crawlMessage(message) -// openSettings { crawl() } -// } -// } -// } + elementRobot.newRoom { + createNewRoom { + crawl() + createRoom { + val message = "Hello world!" + postMessage(message) + crawl() + crawlMessage(message) + openSettings { crawl() } + } + } + } elementRobot.space { createSpace { @@ -116,31 +118,31 @@ class UiAllScreensSanityTest { } } -// elementRobot.withDeveloperMode { -// settings { -// advancedSettings { crawlDeveloperOptions() } -// } -// roomList { -// openRoom(getString(R.string.room_displayname_empty_room)) { -// val message = "Test view source" -// postMessage(message) -// openMessageMenu(message) { -// viewSource() -// } -// } -// } -// } + elementRobot.withDeveloperMode { + settings { + advancedSettings { crawlDeveloperOptions() } + } + roomList { + openRoom(getString(R.string.room_displayname_empty_room)) { + val message = "Test view source" + postMessage(message) + openMessageMenu(message) { + viewSource() + } + } + } + } -// elementRobot.roomList { -// verifyCreatedRoom() -// } + elementRobot.roomList { + verifyCreatedRoom() + } -// elementRobot.signout(expectSignOutWarning = true) + elementRobot.signout(expectSignOutWarning = true) // Login again on the same account -// elementRobot.login(userId) -// elementRobot.dismissVerificationIfPresent() -// TODO Deactivate account instead of logout? -// elementRobot.signout(expectSignOutWarning = false) + elementRobot.login(userId) + elementRobot.dismissVerificationIfPresent() + // TODO Deactivate account instead of logout? + elementRobot.signout(expectSignOutWarning = false) } } diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt index 023ee6ad99..f0ce23b7db 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt @@ -146,7 +146,7 @@ class ElementRobot { assertDisplayed(R.string.are_you_sure) clickOn(R.string.action_skip) waitUntilViewVisible(withId(R.id.bottomSheetFragmentContainer)) - }.onFailure { Timber.w("Verification popup missing", it) } + }.onFailure { Timber.w(it, "Verification popup missing") } } fun space(block: SpaceRobot.() -> Unit) { diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/MessageMenuRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/MessageMenuRobot.kt index 934c6c76a1..5973dc3473 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/MessageMenuRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/MessageMenuRobot.kt @@ -17,12 +17,16 @@ package im.vector.app.ui.robot import androidx.test.espresso.Espresso.pressBack +import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn import com.adevinta.android.barista.interaction.BaristaListInteractions.clickListItem import com.google.android.material.bottomsheet.BottomSheetBehavior import im.vector.app.R +import im.vector.app.espresso.tools.waitUntilActivityVisible +import im.vector.app.espresso.tools.waitUntilViewVisible import im.vector.app.features.home.room.detail.timeline.edithistory.ViewEditHistoryBottomSheet +import im.vector.app.features.reactions.EmojiReactionPickerActivity import im.vector.app.interactWithSheet import java.lang.Thread.sleep @@ -54,7 +58,10 @@ class MessageMenuRobot( fun addReactionFromEmojiPicker() { clickOn(R.string.message_add_reaction) // Wait for emoji to load, it's async now - sleep(2000) + waitUntilActivityVisible { + waitUntilViewVisible(withId(R.id.emojiRecyclerView)) + waitUntilViewVisible(withText("😀")) + } clickListItem(R.id.emojiRecyclerView, 4) autoClosed = true } diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt index 47bf31355c..b3bb5172e8 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt @@ -40,8 +40,11 @@ class OnboardingRobot { private fun crawlGetStarted() { clickOn(R.id.loginSplashSubmit) + assertDisplayed(R.id.useCaseHeaderTitle, R.string.ftue_auth_use_case_title) + clickOn(R.id.useCaseOptionOne) OnboardingServersRobot().crawlSignUp() pressBack() + pressBack() } private fun crawlAlreadyHaveAccount() { @@ -66,6 +69,7 @@ class OnboardingRobot { assertDisplayed(R.id.loginSplashSubmit, R.string.login_splash_create_account) if (createAccount) { clickOn(R.id.loginSplashSubmit) + clickOn(R.id.useCaseOptionOne) } else { clickOn(R.id.loginSplashAlreadyHaveAccount) } diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/RoomDetailRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/RoomDetailRobot.kt index ebf5fdf23d..6cf6ad3551 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/RoomDetailRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/RoomDetailRobot.kt @@ -20,13 +20,13 @@ import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.pressBack import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.action.ViewActions.longClick import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import com.adevinta.android.barista.interaction.BaristaClickInteractions import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn -import com.adevinta.android.barista.interaction.BaristaClickInteractions.longClickOn import com.adevinta.android.barista.interaction.BaristaEditTextInteractions.writeTo import com.adevinta.android.barista.interaction.BaristaMenuClickInteractions.clickMenu import com.adevinta.android.barista.interaction.BaristaMenuClickInteractions.openMenu @@ -60,8 +60,6 @@ class RoomDetailRobot { pressBack() clickMenu(R.id.video_call) pressBack() - clickMenu(R.id.search) - pressBack() } fun crawlMessage(message: String) { @@ -70,6 +68,7 @@ class RoomDetailRobot { openMessageMenu(message) { addQuickReaction(quickReaction) } + waitUntilViewVisible(withText(quickReaction)) println("Open reactions bottom sheet") // Open reactions longClickReaction(quickReaction) @@ -103,7 +102,7 @@ class RoomDetailRobot { private fun longClickReaction(quickReaction: String) { withRetry { - longClickOn(quickReaction) + onView(withText(quickReaction)).perform(longClick()) } } diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsHelpRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsHelpRobot.kt index 75f610d016..cf0c997d80 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsHelpRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsHelpRobot.kt @@ -16,10 +16,6 @@ package im.vector.app.ui.robot.settings -import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn -import com.adevinta.android.barista.interaction.BaristaDialogInteractions.clickDialogPositiveButton -import im.vector.app.R - class SettingsHelpRobot { fun crawl() { @@ -34,7 +30,5 @@ class SettingsHelpRobot { clickOn(R.string.settings_privacy_policy) pressBack() */ - clickOn(R.string.settings_third_party_notices) - clickDialogPositiveButton() } } diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsLegalsRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsLegalsRobot.kt new file mode 100644 index 0000000000..842471752a --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsLegalsRobot.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.ui.robot.settings + +import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn +import com.adevinta.android.barista.interaction.BaristaDialogInteractions.clickDialogPositiveButton +import im.vector.app.R + +class SettingsLegalsRobot { + + fun crawl() { + clickOn(R.string.settings_third_party_notices) + clickDialogPositiveButton() + } +} diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsRobot.kt index a9c053f6c3..561f14c6f2 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsRobot.kt @@ -64,4 +64,8 @@ class SettingsRobot { fun helpAndAbout(block: SettingsHelpRobot.() -> Unit) { clickOnAndGoBack(R.string.preference_root_help_about) { block(SettingsHelpRobot()) } } + + fun legals(block: SettingsLegalsRobot.() -> Unit) { + clickOnAndGoBack(R.string.preference_root_legals) { block(SettingsLegalsRobot()) } + } } diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsSecurityRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsSecurityRobot.kt index f2607bbc1c..168db3e0e9 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsSecurityRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsSecurityRobot.kt @@ -33,5 +33,8 @@ class SettingsSecurityRobot { clickOnPreference(R.string.encryption_export_e2e_room_keys) pressBack() */ + + clickOnPreference(R.string.settings_opt_in_of_analytics) + Espresso.pressBack() } } diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceCreateRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceCreateRobot.kt index 082adcb173..68e5fa5059 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceCreateRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceCreateRobot.kt @@ -31,7 +31,6 @@ import im.vector.app.espresso.tools.waitUntilActivityVisible import im.vector.app.espresso.tools.waitUntilDialogVisible import im.vector.app.espresso.tools.waitUntilViewVisible import im.vector.app.features.home.HomeActivity -import im.vector.app.features.home.room.detail.RoomDetailActivity import im.vector.app.features.spaces.manage.SpaceManageActivity import java.util.UUID @@ -87,16 +86,12 @@ class SpaceCreateRobot { clickOn(R.id.nextButton) waitUntilViewVisible(withId(R.id.recyclerView)) clickOn(R.id.nextButton) - waitUntilActivityVisible { - waitUntilDialogVisible(withId(R.id.inviteByMxidButton)) - } + waitUntilDialogVisible(withId(R.id.inviteByMxidButton)) // close invite dialog pressBack() - waitUntilViewVisible(withId(R.id.timelineRecyclerView)) // close room pressBack() - waitUntilViewVisible(withId(R.id.roomListContainer)) } } diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt index 74389fa723..431df396d0 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt @@ -59,8 +59,6 @@ class SpaceMenuRobot { Espresso.pressBack() // close invite view Espresso.pressBack() - - Espresso.pressBack() } fun spaceMembers() { diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index fdec5337ba..2e5412870f 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -175,6 +175,8 @@ + + diff --git a/vector/src/main/java/im/vector/app/AutoRageShaker.kt b/vector/src/main/java/im/vector/app/AutoRageShaker.kt index 0238931e4c..43283254b1 100644 --- a/vector/src/main/java/im/vector/app/AutoRageShaker.kt +++ b/vector/src/main/java/im/vector/app/AutoRageShaker.kt @@ -16,7 +16,6 @@ package im.vector.app -import android.content.Context import android.content.SharedPreferences import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.features.rageshake.BugReporter @@ -46,7 +45,6 @@ class AutoRageShaker @Inject constructor( private val sessionDataSource: ActiveSessionDataSource, private val activeSessionHolder: ActiveSessionHolder, private val bugReporter: BugReporter, - private val context: Context, private val vectorPreferences: VectorPreferences ) : Session.Listener, SharedPreferences.OnSharedPreferenceChangeListener { @@ -136,7 +134,6 @@ class AutoRageShaker @Inject constructor( private fun sendRageShake(target: E2EMessageDetected) { bugReporter.sendBugReport( - context = context, reportType = ReportType.AUTO_UISI, withDevicesLogs = true, withCrashLogs = true, @@ -218,7 +215,6 @@ class AutoRageShaker @Inject constructor( val matchingIssue = event.content?.get("recipient_rageshake")?.toString() ?: "" bugReporter.sendBugReport( - context = context, reportType = ReportType.AUTO_UISI_SENDER, withDevicesLogs = true, withCrashLogs = true, diff --git a/vector/src/main/java/im/vector/app/VectorApplication.kt b/vector/src/main/java/im/vector/app/VectorApplication.kt index d252b5d9bd..e64188765e 100644 --- a/vector/src/main/java/im/vector/app/VectorApplication.kt +++ b/vector/src/main/java/im/vector/app/VectorApplication.kt @@ -120,7 +120,7 @@ class VectorApplication : vectorAnalytics.init() invitesAcceptor.initialize() autoRageShaker.initialize() - vectorUncaughtExceptionHandler.activate(this) + vectorUncaughtExceptionHandler.activate() // Remove Log handler statically added by Jitsi Timber.forest() diff --git a/vector/src/main/java/im/vector/app/core/datastore/DataStoreProvider.kt b/vector/src/main/java/im/vector/app/core/datastore/DataStoreProvider.kt new file mode 100644 index 0000000000..5b7988b76f --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/datastore/DataStoreProvider.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.core.datastore + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import java.util.concurrent.ConcurrentHashMap +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +/** + * Provides a singleton datastore cache + * allows for lazily fetching a datastore instance by key to avoid creating multiple stores for the same file + * Based on https://androidx.tech/artifacts/datastore/datastore-preferences/1.0.0-source/androidx/datastore/preferences/PreferenceDataStoreDelegate.kt.html + * + * Makes use of a ReadOnlyProperty in order to provide a simplified api on top of a Context + * ReadOnlyProperty allows us to lazily access the backing property instead of requiring it upfront as a dependency + *
+ * val Context.dataStoreProvider by dataStoreProvider()
+ * 
+ */ +fun dataStoreProvider(): ReadOnlyProperty DataStore> { + return MappedPreferenceDataStoreSingletonDelegate() +} + +private class MappedPreferenceDataStoreSingletonDelegate : ReadOnlyProperty DataStore> { + + private val dataStoreCache = ConcurrentHashMap>() + private val provider: (Context) -> (String) -> DataStore = { context -> + { key -> + dataStoreCache.getOrPut(key) { + PreferenceDataStoreFactory.create { + context.applicationContext.preferencesDataStoreFile(key) + } + } + } + } + + override fun getValue(thisRef: Context, property: KProperty<*>) = provider.invoke(thisRef) +} diff --git a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt index 5d27909b25..e7aa83ae75 100644 --- a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt @@ -58,9 +58,10 @@ import im.vector.app.features.home.HomeDetailFragment import im.vector.app.features.home.HomeDrawerFragment import im.vector.app.features.home.LoadingFragment import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsFragment -import im.vector.app.features.home.room.detail.RoomDetailFragment +import im.vector.app.features.home.room.detail.TimelineFragment import im.vector.app.features.home.room.detail.search.SearchFragment import im.vector.app.features.home.room.list.RoomListFragment +import im.vector.app.features.home.room.threads.list.views.ThreadListFragment import im.vector.app.features.location.LocationPreviewFragment import im.vector.app.features.location.LocationSharingFragment import im.vector.app.features.login.LoginCaptchaFragment @@ -204,8 +205,8 @@ interface FragmentModule { @Binds @IntoMap - @FragmentKey(RoomDetailFragment::class) - fun bindRoomDetailFragment(fragment: RoomDetailFragment): Fragment + @FragmentKey(TimelineFragment::class) + fun bindTimelineFragment(fragment: TimelineFragment): Fragment @Binds @IntoMap @@ -937,6 +938,11 @@ interface FragmentModule { @FragmentKey(SpaceLeaveAdvancedFragment::class) fun bindSpaceLeaveAdvancedFragment(fragment: SpaceLeaveAdvancedFragment): Fragment + @Binds + @IntoMap + @FragmentKey(ThreadListFragment::class) + fun bindThreadListFragment(fragment: ThreadListFragment): Fragment + @Binds @IntoMap @FragmentKey(CreatePollFragment::class) diff --git a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt index 9ad01cd3e4..2cd7136ffc 100644 --- a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt @@ -39,12 +39,11 @@ import im.vector.app.features.discovery.DiscoverySettingsViewModel import im.vector.app.features.discovery.change.SetIdentityServerViewModel import im.vector.app.features.home.HomeActivityViewModel import im.vector.app.features.home.HomeDetailViewModel -import im.vector.app.features.home.PromoteRestrictedViewModel import im.vector.app.features.home.UnknownDeviceDetectorSharedViewModel import im.vector.app.features.home.UnreadMessagesSharedViewModel import im.vector.app.features.home.UserColorAccountDataViewModel import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsViewModel -import im.vector.app.features.home.room.detail.RoomDetailViewModel +import im.vector.app.features.home.room.detail.TimelineViewModel import im.vector.app.features.home.room.detail.composer.MessageComposerViewModel import im.vector.app.features.home.room.detail.search.SearchViewModel import im.vector.app.features.home.room.detail.timeline.action.MessageActionsViewModel @@ -61,6 +60,7 @@ import im.vector.app.features.login2.created.AccountCreatedViewModel import im.vector.app.features.matrixto.MatrixToBottomSheetViewModel import im.vector.app.features.onboarding.OnboardingViewModel import im.vector.app.features.poll.create.CreatePollViewModel +import im.vector.app.features.qrcode.QrCodeScannerViewModel import im.vector.app.features.rageshake.BugReportViewModel import im.vector.app.features.reactions.EmojiSearchResultViewModel import im.vector.app.features.room.RequireActiveMembershipViewModel @@ -220,6 +220,11 @@ interface MavericksViewModelModule { @MavericksViewModelKey(CreateDirectRoomViewModel::class) fun createDirectRoomViewModelFactory(factory: CreateDirectRoomViewModel.Factory): MavericksAssistedViewModelFactory<*, *> + @Binds + @IntoMap + @MavericksViewModelKey(QrCodeScannerViewModel::class) + fun qrCodeViewModelFactory(factory: QrCodeScannerViewModel.Factory): MavericksAssistedViewModelFactory<*, *> + @Binds @IntoMap @MavericksViewModelKey(RoomNotificationSettingsViewModel::class) @@ -235,11 +240,6 @@ interface MavericksViewModelModule { @MavericksViewModelKey(SharedSecureStorageViewModel::class) fun sharedSecureStorageViewModelFactory(factory: SharedSecureStorageViewModel.Factory): MavericksAssistedViewModelFactory<*, *> - @Binds - @IntoMap - @MavericksViewModelKey(PromoteRestrictedViewModel::class) - fun promoteRestrictedViewModelFactory(factory: PromoteRestrictedViewModel.Factory): MavericksAssistedViewModelFactory<*, *> - @Binds @IntoMap @MavericksViewModelKey(UserListViewModel::class) @@ -537,8 +537,8 @@ interface MavericksViewModelModule { @Binds @IntoMap - @MavericksViewModelKey(RoomDetailViewModel::class) - fun roomDetailViewModelFactory(factory: RoomDetailViewModel.Factory): MavericksAssistedViewModelFactory<*, *> + @MavericksViewModelKey(TimelineViewModel::class) + fun roomDetailViewModelFactory(factory: TimelineViewModel.Factory): MavericksAssistedViewModelFactory<*, *> @Binds @IntoMap diff --git a/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt b/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt index cdecd2d6c6..14ba34cc52 100644 --- a/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt +++ b/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt @@ -17,24 +17,25 @@ package im.vector.app.core.epoxy.bottomsheet import android.text.method.MovementMethod +import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass +import com.bumptech.glide.request.RequestOptions import im.vector.app.R import im.vector.app.core.epoxy.ClickListener import im.vector.app.core.epoxy.VectorEpoxyHolder import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.epoxy.onClick import im.vector.app.core.extensions.setTextOrHide +import im.vector.app.core.glide.GlideApp import im.vector.app.features.displayname.getBestName import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider import im.vector.app.features.home.room.detail.timeline.item.BindingOptions import im.vector.app.features.home.room.detail.timeline.tools.findPillsAndProcess -import im.vector.app.features.location.LocationData -import im.vector.app.features.location.MapTilerMapView import im.vector.app.features.media.ImageContentRenderer import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence import org.matrix.android.sdk.api.util.MatrixItem @@ -70,11 +71,14 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel - holder.mapView.initialize { - if (holder.view.isAttachedToWindow) { - holder.mapView.zoomToLocation(location.latitude, location.longitude, 15.0) - locationPinProvider?.create(matrixItem.id) { pinDrawable -> - holder.mapView.addPinToMap(matrixItem.id, pinDrawable) - holder.mapView.updatePinLocation(matrixItem.id, location.latitude, location.longitude) - } - } + if (locationUrl == null) { + holder.body.isVisible = true + holder.mapViewContainer.isVisible = false + } else { + holder.body.isVisible = false + holder.mapViewContainer.isVisible = true + GlideApp.with(holder.staticMapImageView) + .load(locationUrl) + .apply(RequestOptions.centerCropTransform()) + .into(holder.staticMapImageView) + + locationPinProvider?.create(locationOwnerId) { pinDrawable -> + GlideApp.with(holder.staticMapPinImageView) + .load(pinDrawable) + .into(holder.staticMapPinImageView) } } } @@ -124,6 +132,8 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel(R.id.bottom_sheet_message_preview_body_details) val timestamp by bind(R.id.bottom_sheet_message_preview_timestamp) val imagePreview by bind(R.id.bottom_sheet_message_preview_image) - val mapView by bind(R.id.bottom_sheet_message_preview_location) + val mapViewContainer by bind(R.id.mapViewContainer) + val staticMapImageView by bind(R.id.staticMapImageView) + val staticMapPinImageView by bind(R.id.staticMapPinImageView) } } diff --git a/vector/src/main/java/im/vector/app/core/extensions/Activity.kt b/vector/src/main/java/im/vector/app/core/extensions/Activity.kt index aa96a4a30c..829790f857 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/Activity.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/Activity.kt @@ -28,6 +28,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentTransaction +import im.vector.app.R fun ComponentActivity.registerStartForActivityResult(onResult: (ActivityResult) -> Unit): ActivityResultLauncher { return registerForActivityResult(ActivityResultContracts.StartActivityForResult(), onResult) @@ -66,8 +67,12 @@ fun AppCompatActivity.replaceFragment( fragmentClass: Class, params: Parcelable? = null, tag: String? = null, - allowStateLoss: Boolean = false) { + allowStateLoss: Boolean = false, + useCustomAnimation: Boolean = false) { supportFragmentManager.commitTransaction(allowStateLoss) { + if (useCustomAnimation) { + setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out) + } replace(container.id, fragmentClass, params.toMvRxBundle(), tag) } } diff --git a/vector/src/main/java/im/vector/app/core/extensions/Context.kt b/vector/src/main/java/im/vector/app/core/extensions/Context.kt index 1063d30a41..b1e24c9502 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/Context.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/Context.kt @@ -18,12 +18,18 @@ package im.vector.app.core.extensions import android.content.Context import android.graphics.drawable.Drawable +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ImageSpan import androidx.annotation.ColorInt import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.annotation.FloatRange import androidx.core.content.ContextCompat +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import dagger.hilt.EntryPoints +import im.vector.app.core.datastore.dataStoreProvider import im.vector.app.core.di.SingletonEntryPoint import kotlin.math.roundToInt @@ -31,6 +37,16 @@ fun Context.singletonEntryPoint(): SingletonEntryPoint { return EntryPoints.get(applicationContext, SingletonEntryPoint::class.java) } +fun Context.getDrawableAsSpannable(@DrawableRes drawableRes: Int, alignment: Int = ImageSpan.ALIGN_BOTTOM): Spannable { + return SpannableString(" ").apply { + val span = ContextCompat.getDrawable(this@getDrawableAsSpannable, drawableRes)?.let { + it.setBounds(0, 0, it.intrinsicWidth, it.intrinsicHeight) + ImageSpan(it, alignment) + } + setSpan(span, 0, 1, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + } +} + fun Context.getResTintedDrawable(@DrawableRes drawableRes: Int, @ColorRes tint: Int, @FloatRange(from = 0.0, to = 1.0) alpha: Float = 1f): Drawable? { return getTintedDrawable(drawableRes, ContextCompat.getColor(this, tint), alpha) } @@ -50,3 +66,5 @@ fun Context.getTintedDrawable(@DrawableRes drawableRes: Int, private fun Float.toAndroidAlpha(): Int { return (this * 255).roundToInt() } + +val Context.dataStoreProvider: (String) -> DataStore by dataStoreProvider() diff --git a/vector/src/main/java/im/vector/app/core/extensions/Session.kt b/vector/src/main/java/im/vector/app/core/extensions/Session.kt index 90b08ef92b..87ed51522f 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/Session.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/Session.kt @@ -21,6 +21,7 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.ProcessLifecycleOwner import im.vector.app.core.services.VectorSyncService +import im.vector.app.features.session.VectorSessionStore import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.keysbackup.KeysBackupState import org.matrix.android.sdk.api.session.sync.FilterService @@ -76,3 +77,5 @@ fun Session.cannotLogoutSafely(): Boolean { // That are not backed up !sharedSecretStorageService.isRecoverySetup()) } + +fun Session.vectorStore(context: Context) = VectorSessionStore(context, myUserId) diff --git a/vector/src/main/java/im/vector/app/core/extensions/TextView.kt b/vector/src/main/java/im/vector/app/core/extensions/TextView.kt index cb34b95fa1..0564f2055b 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/TextView.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/TextView.kt @@ -129,6 +129,10 @@ fun TextView.setLeftDrawable(drawable: Drawable?) { setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) } +fun TextView.clearDrawables() { + setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0) +} + /** * Set long click listener to copy the current text of the TextView to the clipboard and show a Snackbar */ diff --git a/vector/src/main/java/im/vector/app/core/platform/VectorDummyViewState.kt b/vector/src/main/java/im/vector/app/core/platform/VectorDummyViewState.kt new file mode 100644 index 0000000000..3c293b1072 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/platform/VectorDummyViewState.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.core.platform + +import com.airbnb.mvrx.MavericksState + +data class VectorDummyViewState( + val isDummy: Unit = Unit +) : MavericksState diff --git a/vector/src/main/java/im/vector/app/core/resources/LocaleProvider.kt b/vector/src/main/java/im/vector/app/core/resources/LocaleProvider.kt index 6a9d434aea..fdb5f21b61 100644 --- a/vector/src/main/java/im/vector/app/core/resources/LocaleProvider.kt +++ b/vector/src/main/java/im/vector/app/core/resources/LocaleProvider.kt @@ -17,6 +17,8 @@ package im.vector.app.core.resources import android.content.res.Resources +import android.text.TextUtils +import android.view.View import androidx.core.os.ConfigurationCompat import java.util.Locale import javax.inject.Inject @@ -29,3 +31,7 @@ class LocaleProvider @Inject constructor(private val resources: Resources) { } fun LocaleProvider.isEnglishSpeaking() = current().language.startsWith("en") + +fun LocaleProvider.getLayoutDirectionFromCurrentLocale() = TextUtils.getLayoutDirectionFromLocale(current()) + +fun LocaleProvider.isRTL() = getLayoutDirectionFromCurrentLocale() == View.LAYOUT_DIRECTION_RTL diff --git a/vector/src/main/java/im/vector/app/core/resources/UserPreferencesProvider.kt b/vector/src/main/java/im/vector/app/core/resources/UserPreferencesProvider.kt index 9ab3b9bf45..3aa1964d8d 100644 --- a/vector/src/main/java/im/vector/app/core/resources/UserPreferencesProvider.kt +++ b/vector/src/main/java/im/vector/app/core/resources/UserPreferencesProvider.kt @@ -48,4 +48,8 @@ class UserPreferencesProvider @Inject constructor(private val vectorPreferences: fun shouldShowAvatarDisplayNameChanges(): Boolean { return vectorPreferences.showAvatarDisplayNameChangeMessages() } + + fun areThreadMessagesEnabled(): Boolean { + return vectorPreferences.areThreadMessagesEnabled() + } } diff --git a/vector/src/main/java/im/vector/app/core/ui/list/GenericButtonItem.kt b/vector/src/main/java/im/vector/app/core/ui/list/GenericButtonItem.kt index fe59c82ce9..d4838289a6 100644 --- a/vector/src/main/java/im/vector/app/core/ui/list/GenericButtonItem.kt +++ b/vector/src/main/java/im/vector/app/core/ui/list/GenericButtonItem.kt @@ -19,6 +19,7 @@ import android.graphics.Typeface import android.view.Gravity import androidx.annotation.ColorInt import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import com.google.android.material.button.MaterialButton @@ -55,6 +56,9 @@ abstract class GenericButtonItem : VectorEpoxyModel() @EpoxyAttribute var bold: Boolean = false + @EpoxyAttribute + var highlight: Boolean = true + override fun bind(holder: Holder) { super.bind(holder) holder.button.text = text @@ -70,6 +74,12 @@ abstract class GenericButtonItem : VectorEpoxyModel() val textStyle = if (bold) Typeface.BOLD else Typeface.NORMAL holder.button.setTypeface(null, textStyle) + holder.button.rippleColor = if (highlight) { + ContextCompat.getColorStateList(holder.view.context, R.color.mtrl_btn_text_btn_ripple_color) + } else { + null + } + holder.button.onClick(buttonClickAction) } diff --git a/vector/src/main/java/im/vector/app/features/MainActivity.kt b/vector/src/main/java/im/vector/app/features/MainActivity.kt index b4706780b7..33b735551c 100644 --- a/vector/src/main/java/im/vector/app/features/MainActivity.kt +++ b/vector/src/main/java/im/vector/app/features/MainActivity.kt @@ -29,6 +29,7 @@ import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.error.ErrorFormatter import im.vector.app.core.extensions.startSyncing +import im.vector.app.core.extensions.vectorStore import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.utils.deleteAllFiles import im.vector.app.databinding.ActivityMainBinding @@ -40,6 +41,7 @@ import im.vector.app.features.pin.PinCodeStore import im.vector.app.features.pin.PinLocker import im.vector.app.features.pin.UnlockedActivity import im.vector.app.features.popup.PopupAlertManager +import im.vector.app.features.session.VectorSessionStore import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.signout.hard.SignedOutActivity import im.vector.app.features.themes.ActivityOtherThemes @@ -143,13 +145,15 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity startNextActivityAndFinish() return } + + val onboardingStore = session.vectorStore(this) when { args.isAccountDeactivated -> { lifecycleScope.launch { // Just do the local cleanup Timber.w("Account deactivated, start app") sessionHolder.clearActiveSession() - doLocalCleanup(clearPreferences = true) + doLocalCleanup(clearPreferences = true, onboardingStore) startNextActivityAndFinish() } } @@ -163,14 +167,14 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity } Timber.w("SIGN_OUT: success, start app") sessionHolder.clearActiveSession() - doLocalCleanup(clearPreferences = true) + doLocalCleanup(clearPreferences = true, onboardingStore) startNextActivityAndFinish() } } args.clearCache -> { lifecycleScope.launch { session.clearCache() - doLocalCleanup(clearPreferences = false) + doLocalCleanup(clearPreferences = false, onboardingStore) session.startSyncing(applicationContext) startNextActivityAndFinish() } @@ -183,7 +187,7 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity Timber.w("Ignoring invalid token global error") } - private suspend fun doLocalCleanup(clearPreferences: Boolean) { + private suspend fun doLocalCleanup(clearPreferences: Boolean, vectorSessionStore: VectorSessionStore) { // On UI Thread Glide.get(this@MainActivity).clearMemory() @@ -193,6 +197,7 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity pinLocker.unlock() pinCodeStore.deleteEncodedPin() vectorAnalytics.onSignOut() + vectorSessionStore.clear() } withContext(Dispatchers.IO) { // On BG thread diff --git a/vector/src/main/java/im/vector/app/features/VectorFeatures.kt b/vector/src/main/java/im/vector/app/features/VectorFeatures.kt index 03e9954b2c..fe8d58fb51 100644 --- a/vector/src/main/java/im/vector/app/features/VectorFeatures.kt +++ b/vector/src/main/java/im/vector/app/features/VectorFeatures.kt @@ -36,5 +36,5 @@ class DefaultVectorFeatures : VectorFeatures { override fun onboardingVariant(): VectorFeatures.OnboardingVariant = BuildConfig.ONBOARDING_VARIANT override fun isOnboardingAlreadyHaveAccountSplashEnabled() = true override fun isOnboardingSplashCarouselEnabled() = true - override fun isOnboardingUseCaseEnabled() = false + override fun isOnboardingUseCaseEnabled() = true } diff --git a/vector/src/main/java/im/vector/app/features/analytics/AnalyticsTracker.kt b/vector/src/main/java/im/vector/app/features/analytics/AnalyticsTracker.kt index e1da0f4434..2389fbd724 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/AnalyticsTracker.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/AnalyticsTracker.kt @@ -18,6 +18,7 @@ package im.vector.app.features.analytics import im.vector.app.features.analytics.itf.VectorAnalyticsEvent import im.vector.app.features.analytics.itf.VectorAnalyticsScreen +import im.vector.app.features.analytics.plan.UserProperties interface AnalyticsTracker { /** @@ -29,4 +30,9 @@ interface AnalyticsTracker { * Track a displayed screen */ fun screen(screen: VectorAnalyticsScreen) + + /** + * Update user specific properties + */ + fun updateUserProperties(userProperties: UserProperties) } diff --git a/vector/src/main/java/im/vector/app/features/analytics/accountdata/AnalyticsAccountDataViewModel.kt b/vector/src/main/java/im/vector/app/features/analytics/accountdata/AnalyticsAccountDataViewModel.kt index 5d65d7ea42..3b92e7c4de 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/accountdata/AnalyticsAccountDataViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/accountdata/AnalyticsAccountDataViewModel.kt @@ -17,7 +17,6 @@ package im.vector.app.features.analytics.accountdata import androidx.lifecycle.asFlow -import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksViewModelFactory import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -26,6 +25,7 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.platform.EmptyAction import im.vector.app.core.platform.EmptyViewEvents +import im.vector.app.core.platform.VectorDummyViewState import im.vector.app.core.platform.VectorViewModel import im.vector.app.features.analytics.VectorAnalytics import im.vector.app.features.analytics.log.analyticsTag @@ -42,24 +42,20 @@ import org.matrix.android.sdk.flow.flow import timber.log.Timber import java.util.UUID -data class DummyState( - val dummy: Boolean = false -) : MavericksState - class AnalyticsAccountDataViewModel @AssistedInject constructor( - @Assisted initialState: DummyState, + @Assisted initialState: VectorDummyViewState, private val session: Session, private val analytics: VectorAnalytics -) : VectorViewModel(initialState) { +) : VectorViewModel(initialState) { private var checkDone: Boolean = false @AssistedFactory - interface Factory : MavericksAssistedViewModelFactory { - override fun create(initialState: DummyState): AnalyticsAccountDataViewModel + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: VectorDummyViewState): AnalyticsAccountDataViewModel } - companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() { + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() { private const val ANALYTICS_EVENT_TYPE = "im.vector.analytics" } diff --git a/vector/src/main/java/im/vector/app/features/analytics/extensions/JoinedRoomExt.kt b/vector/src/main/java/im/vector/app/features/analytics/extensions/JoinedRoomExt.kt index ff23fd9a64..c13f8295f2 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/extensions/JoinedRoomExt.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/extensions/JoinedRoomExt.kt @@ -19,6 +19,7 @@ package im.vector.app.features.analytics.extensions import im.vector.app.features.analytics.plan.JoinedRoom import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.RoomType import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoom fun Int?.toAnalyticsRoomSize(): JoinedRoom.RoomSize { @@ -35,6 +36,7 @@ fun Int?.toAnalyticsRoomSize(): JoinedRoom.RoomSize { fun RoomSummary?.toAnalyticsJoinedRoom(): JoinedRoom { return JoinedRoom( isDM = this?.isDirect.orFalse(), + isSpace = this?.roomType == RoomType.SPACE, roomSize = this?.joinedMembersCount?.toAnalyticsRoomSize() ?: JoinedRoom.RoomSize.Two ) } @@ -42,6 +44,7 @@ fun RoomSummary?.toAnalyticsJoinedRoom(): JoinedRoom { fun PublicRoom.toAnalyticsJoinedRoom(): JoinedRoom { return JoinedRoom( isDM = false, + isSpace = false, roomSize = numJoinedMembers.toAnalyticsRoomSize() ) } diff --git a/vector/src/main/java/im/vector/app/features/analytics/extensions/UserPropertiesExt.kt b/vector/src/main/java/im/vector/app/features/analytics/extensions/UserPropertiesExt.kt new file mode 100644 index 0000000000..7fad43783b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/analytics/extensions/UserPropertiesExt.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.analytics.extensions + +import im.vector.app.features.analytics.plan.UserProperties +import im.vector.app.features.onboarding.FtueUseCase + +fun FtueUseCase.toTrackingValue(): UserProperties.FtueUseCaseSelection { + return when (this) { + FtueUseCase.FRIENDS_FAMILY -> UserProperties.FtueUseCaseSelection.PersonalMessaging + FtueUseCase.TEAMS -> UserProperties.FtueUseCaseSelection.WorkMessaging + FtueUseCase.COMMUNITIES -> UserProperties.FtueUseCaseSelection.CommunityMessaging + FtueUseCase.SKIP -> UserProperties.FtueUseCaseSelection.Skip + } +} diff --git a/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt b/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt index eaf2e42705..6dbf412d83 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt @@ -17,6 +17,7 @@ package im.vector.app.features.analytics.impl import android.content.Context +import com.posthog.android.Options import com.posthog.android.PostHog import com.posthog.android.Properties import im.vector.app.BuildConfig @@ -25,6 +26,7 @@ import im.vector.app.features.analytics.VectorAnalytics import im.vector.app.features.analytics.itf.VectorAnalyticsEvent import im.vector.app.features.analytics.itf.VectorAnalyticsScreen import im.vector.app.features.analytics.log.analyticsTag +import im.vector.app.features.analytics.plan.UserProperties import im.vector.app.features.analytics.store.AnalyticsStore import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.Flow @@ -34,6 +36,9 @@ import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +private val REUSE_EXISTING_ID: String? = null +private val IGNORED_OPTIONS: Options? = null + @Singleton class DefaultVectorAnalytics @Inject constructor( private val context: Context, @@ -170,11 +175,25 @@ class DefaultVectorAnalytics @Inject constructor( ?.screen(screen.getName(), screen.getProperties()?.toPostHogProperties()) } - private fun Map?.toPostHogProperties(): Properties? { + override fun updateUserProperties(userProperties: UserProperties) { + posthog?.identify(REUSE_EXISTING_ID, userProperties.getProperties()?.toPostHogUserProperties(), IGNORED_OPTIONS) + } + + private fun Map?.toPostHogProperties(): Properties? { if (this == null) return null return Properties().apply { putAll(this@toPostHogProperties) } } + + /** + * We avoid sending nulls as part of the UserProperties as this will reset the values across all devices + * The UserProperties event has nullable properties to allow for clients to opt in + */ + private fun Map.toPostHogUserProperties(): Properties { + return Properties().apply { + putAll(this@toPostHogUserProperties.filter { it.value != null }) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/analytics/itf/VectorAnalyticsEvent.kt b/vector/src/main/java/im/vector/app/features/analytics/itf/VectorAnalyticsEvent.kt index c6acb3b87a..2797734343 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/itf/VectorAnalyticsEvent.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/itf/VectorAnalyticsEvent.kt @@ -18,5 +18,5 @@ package im.vector.app.features.analytics.itf interface VectorAnalyticsEvent { fun getName(): String - fun getProperties(): Map? + fun getProperties(): Map? } diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/Identity.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/Composer.kt similarity index 61% rename from vector/src/main/java/im/vector/app/features/analytics/plan/Identity.kt rename to vector/src/main/java/im/vector/app/features/analytics/plan/Composer.kt index 1cc433aa7e..a3b847a1bd 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/plan/Identity.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/plan/Composer.kt @@ -22,42 +22,32 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsEvent // https://github.com/matrix-org/matrix-analytics-events/ /** - * The user properties to apply when identifying + * Triggered when the user sends a message via the composer. */ -data class Identity( +data class Composer( /** - * The selected messaging use case during the onboarding flow. + * Whether the user was using the composer inside of a thread. */ - val ftueUseCaseSelection: FtueUseCaseSelection? = null, + val inThread: Boolean, + /** + * Whether the user's composer interaction was editing a previously sent + * event. + */ + val isEditing: Boolean, + /** + * Whether the user's composer interaction was a reply to a previously + * sent event. + */ + val isReply: Boolean, ) : VectorAnalyticsEvent { - enum class FtueUseCaseSelection { - /** - * The third option, Communities. - */ - CommunityMessaging, - - /** - * The first option, Friends and family. - */ - PersonalMessaging, - - /** - * The footer option to skip the question. - */ - Skip, - - /** - * The second option, Teams. - */ - WorkMessaging, - } - - override fun getName() = "Identity" + override fun getName() = "Composer" override fun getProperties(): Map? { return mutableMapOf().apply { - ftueUseCaseSelection?.let { put("ftueUseCaseSelection", it.name) } + put("inThread", inThread) + put("isEditing", isEditing) + put("isReply", isReply) }.takeIf { it.isNotEmpty() } } } diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/Interaction.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/Interaction.kt new file mode 100644 index 0000000000..7bdc7740e1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/analytics/plan/Interaction.kt @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.analytics.plan + +import im.vector.app.features.analytics.itf.VectorAnalyticsEvent + +// GENERATED FILE, DO NOT EDIT. FOR MORE INFORMATION VISIT +// https://github.com/matrix-org/matrix-analytics-events/ + +/** + * Triggered when the user clicks/taps/activates a UI element. + */ +data class Interaction( + /** + * The index of the element, if its in a list of elements. + */ + val index: Int? = null, + /** + * The manner with which the user activated the UI element. + */ + val interactionType: InteractionType? = null, + /** + * The unique name of this element. + */ + val name: Name, +) : VectorAnalyticsEvent { + + enum class Name { + /** + * User tapped the already selected space from the space list. + */ + SpacePanelSelectedSpace, + + /** + * User tapped an unselected space from the space list -> space + * switching should occur. + */ + SpacePanelSwitchSpace, + + /** + * User clicked the create room button in the + context menu of the room + * list header in Element Web/Desktop. + */ + WebAddExistingToSpaceDialogCreateRoomButton, + + /** + * User clicked the create room button in the home page of Element + * Web/Desktop. + */ + WebHomeCreateRoomButton, + + /** + * User interacted with pin to sidebar checkboxes in the quick settings + * menu of Element Web/Desktop. + */ + WebQuickSettingsPinToSidebarCheckbox, + + /** + * User interacted with the theme dropdown in the quick settings menu of + * Element Web/Desktop. + */ + WebQuickSettingsThemeDropdown, + + /** + * User accessed the room invite flow using the button at the top of the + * room member list in the right panel of Element Web/Desktop. + */ + WebRightPanelMemberListInviteButton, + + /** + * User accessed room member list using the 'People' button in the right + * panel room summary card of Element Web/Desktop. + */ + WebRightPanelRoomInfoPeopleButton, + + /** + * User accessed room settings using the 'Settings' button in the right + * panel room summary card of Element Web/Desktop. + */ + WebRightPanelRoomInfoSettingsButton, + + /** + * User accessed room member list using the back button in the right + * panel user info card of Element Web/Desktop. + */ + WebRightPanelRoomUserInfoBackButton, + + /** + * User invited someone to room by clicking invite on the right panel + * user info card in Element Web/Desktop. + */ + WebRightPanelRoomUserInfoInviteButton, + + /** + * User clicked the create room button in the room directory of Element + * Web/Desktop. + */ + WebRoomDirectoryCreateRoomButton, + + /** + * User adjusted their favourites using the context menu on the header + * of a room in Element Web/Desktop. + */ + WebRoomHeaderContextMenuFavouriteToggle, + + /** + * User accessed the room invite flow using the context menu on the + * header of a room in Element Web/Desktop. + */ + WebRoomHeaderContextMenuInviteItem, + + /** + * User interacted with leave action in the context menu on the header + * of a room in Element Web/Desktop. + */ + WebRoomHeaderContextMenuLeaveItem, + + /** + * User accessed their room notification settings via the context menu + * on the header of a room in Element Web/Desktop. + */ + WebRoomHeaderContextMenuNotificationsItem, + + /** + * User accessed room member list using the context menu on the header + * of a room in Element Web/Desktop. + */ + WebRoomHeaderContextMenuPeopleItem, + + /** + * User accessed room settings using the context menu on the header of a + * room in Element Web/Desktop. + */ + WebRoomHeaderContextMenuSettingsItem, + + /** + * User clicked the create room button in the + context menu of the room + * list header in Element Web/Desktop. + */ + WebRoomListHeaderPlusMenuCreateRoomItem, + + /** + * User adjusted their favourites using the context menu on a room tile + * in the room list in Element Web/Desktop. + */ + WebRoomListRoomTileContextMenuFavouriteToggle, + + /** + * User accessed the room invite flow using the context menu on a room + * tile in the room list in Element Web/Desktop. + */ + WebRoomListRoomTileContextMenuInviteItem, + + /** + * User interacted with leave action in the context menu on a room tile + * in the room list in Element Web/Desktop. + */ + WebRoomListRoomTileContextMenuLeaveItem, + + /** + * User accessed room settings using the context menu on a room tile in + * the room list in Element Web/Desktop. + */ + WebRoomListRoomTileContextMenuSettingsItem, + + /** + * User accessed their room notification settings via the context menu + * on a room tile in the room list in Element Web/Desktop. + */ + WebRoomListRoomTileNotificationsMenu, + + /** + * User clicked the create room button in the + context menu of the + * rooms sublist in Element Web/Desktop. + */ + WebRoomListRoomsSublistPlusMenuCreateRoomItem, + + /** + * User interacted with leave action in the general tab of the room + * settings dialog in Element Web/Desktop. + */ + WebRoomSettingsLeaveButton, + + /** + * User interacted with the prompt to create a new room when adjusting + * security settings in an existing room in Element Web/Desktop. + */ + WebRoomSettingsSecurityTabCreateNewRoomButton, + + /** + * User interacted with the theme radio selector in the Appearance tab + * of Settings in Element Web/Desktop. + */ + WebSettingsAppearanceTabThemeSelector, + + /** + * User interacted with the pre-built space checkboxes in the Sidebar + * tab of Settings in Element Web/Desktop. + */ + WebSettingsSidebarTabSpacesCheckbox, + + /** + * User clicked the create room button in the + context menu of the room + * list header in Element Web/Desktop. + */ + WebSpaceContextMenuNewRoomItem, + + /** + * User clicked the create room button in the + context menu of the room + * list header in Element Web/Desktop. + */ + WebSpaceHomeCreateRoomButton, + + /** + * User clicked the theme toggle button in the user menu of Element + * Web/Desktop. + */ + WebUserMenuThemeToggleButton, + } + + enum class InteractionType { + Keyboard, + Pointer, + Touch, + } + + override fun getName() = "Interaction" + + override fun getProperties(): Map? { + return mutableMapOf().apply { + index?.let { put("index", it) } + interactionType?.let { put("interactionType", it.name) } + put("name", name.name) + }.takeIf { it.isNotEmpty() } + } +} diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/JoinedRoom.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/JoinedRoom.kt index 97ac19ec93..d2fb6832ba 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/plan/JoinedRoom.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/plan/JoinedRoom.kt @@ -29,15 +29,46 @@ data class JoinedRoom( * Whether the room is a DM. */ val isDM: Boolean, + /** + * Whether the room is a Space. + */ + val isSpace: Boolean, /** * The size of the room. */ val roomSize: RoomSize, + /** + * The trigger for a room being joined if known. + */ + val trigger: Trigger? = null, ) : VectorAnalyticsEvent { + enum class Trigger { + /** + * Room joined via a push/desktop notification. + */ + Notification, + + /** + * Room joined via the public rooms directory. + */ + RoomDirectory, + + /** + * Room joined via the space hierarchy view. + */ + SpaceHierarchy, + + /** + * Room joined via a timeline pill or link in another room. + */ + Timeline, + } + enum class RoomSize { ElevenToOneHundred, MoreThanAThousand, + One, OneHundredAndOneToAThousand, ThreeToTen, Two, @@ -48,7 +79,9 @@ data class JoinedRoom( override fun getProperties(): Map? { return mutableMapOf().apply { put("isDM", isDM) + put("isSpace", isSpace) put("roomSize", roomSize.name) + trigger?.let { put("trigger", it.name) } }.takeIf { it.isNotEmpty() } } } diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/PerformanceTimer.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/PerformanceTimer.kt index 2cfc366cd3..2770d668e5 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/plan/PerformanceTimer.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/plan/PerformanceTimer.kt @@ -46,12 +46,14 @@ data class PerformanceTimer( enum class Name { /** - * The time spent parsing the response from an initial /sync request. + * The time spent parsing the response from an initial /sync request. In + * this case, `itemCount` should contain the number of joined rooms. */ InitialSyncParsing, /** - * The time spent waiting for a response to an initial /sync request. + * The time spent waiting for a response to an initial /sync request. In + * this case, `itemCount` should contain the number of joined rooms. */ InitialSyncRequest, @@ -62,13 +64,16 @@ data class PerformanceTimer( NotificationsOpenEvent, /** - * The duration of a regular /sync request when resuming the app. + * The duration of a regular /sync request when resuming the app. In + * this case, `itemCount` should contain the number of joined rooms in + * the response. */ StartupIncrementalSync, /** * The duration of an initial /sync request during startup (if the store - * has been wiped). + * has been wiped). In this case, `itemCount` should contain the number + * of joined rooms. */ StartupInitialSync, @@ -78,13 +83,15 @@ data class PerformanceTimer( StartupLaunchScreen, /** - * The time to preload data in the MXStore on iOS. + * The time to preload data in the MXStore on iOS. In this case, + * `itemCount` should contain the number of rooms in the store. */ StartupStorePreload, /** * The time to load all data from the store (including - * StartupStorePreload time). + * StartupStorePreload time). In this case, `itemCount` should contain + * the number of rooms loaded into the session */ StartupStoreReady, } diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/Screen.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/Screen.kt index db4dcd0fac..710ae8f6f2 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/plan/Screen.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/plan/Screen.kt @@ -54,8 +54,8 @@ data class Screen( Group, /** - * The Home tab on iOS | possibly the same on Android? | The Home space - * on Web? + * The Home tab on iOS | possibly the same on Android? | Home page on + * Web */ Home, @@ -116,11 +116,41 @@ data class Screen( */ MobileSearchRooms, + /** + * The global settings screen shown in the app. + */ + MobileSettings, + + /** + * The settings screen to change the default notification options. + */ + MobileSettingsDefaultNotifications, + + /** + * The settings screen to manage notification mentions and keywords. + */ + MobileSettingsMentionsAndKeywords, + + /** + * The global security settings screen. + */ + MobileSettingsSecurity, + /** * The sidebar shown on mobile with spaces, settings etc. */ MobileSidebar, + /** + * Screen that displays the list of members of a space + */ + MobileSpaceMembers, + + /** + * The bottom sheet that list all space options + */ + MobileSpaceMenu, + /** * The screen shown to select which room directory you'd like to use. */ @@ -187,24 +217,9 @@ data class Screen( RoomUploads, /** - * The global settings screen shown in the app. + * Screen that displays the list of rooms and spaces of a space */ - Settings, - - /** - * The settings screen to change the default notification options. - */ - SettingsDefaultNotifications, - - /** - * The settings screen to manage notification mentions and keywords. - */ - SettingsMentionsAndKeywords, - - /** - * The global security settings screen. - */ - SettingsSecurity, + SpaceExploreRooms, /** * The screen shown to create a new direct room. @@ -217,25 +232,91 @@ data class Screen( User, /** - * ? + * Element Web showing flow to trust this new device with cross-signing. */ WebCompleteSecurity, /** - * ? + * Element Web showing flow to setup SSSS / cross-signing on this + * account. */ WebE2ESetup, /** - * ? + * Element Web loading spinner. */ WebLoading, /** - * ? + * Element Web device has been soft logged out by the server. */ WebSoftLogout, + /** + * Legacy: Element Web User Settings Flair Tab. + */ + WebUserSettingFlair, + + /** + * Element Web User Settings Mjolnir (labs) Tab. + */ + WebUserSettingMjolnir, + + /** + * Element Web User Settings Appearance Tab. + */ + WebUserSettingsAppearance, + + /** + * Element Web User Settings General Tab. + */ + WebUserSettingsGeneral, + + /** + * Element Web User Settings Help & About Tab. + */ + WebUserSettingsHelpAbout, + + /** + * Element Web User Settings Ignored Users Tab. + */ + WebUserSettingsIgnoredUsers, + + /** + * Element Web User Settings Keyboard Tab. + */ + WebUserSettingsKeyboard, + + /** + * Element Web User Settings Labs Tab. + */ + WebUserSettingsLabs, + + /** + * Element Web User Settings Notifications Tab. + */ + WebUserSettingsNotifications, + + /** + * Element Web User Settings Preferences Tab. + */ + WebUserSettingsPreferences, + + /** + * Element Web User Settings Security & Privacy Tab. + */ + WebUserSettingsSecurityPrivacy, + + /** + * Element Web User Settings Sidebar Tab. + */ + WebUserSettingsSidebar, + + /** + * Element Web User Settings Voice & Video Tab. + */ + WebUserSettingsVoiceVideo, + /** * The splash screen. */ diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/Click.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/SlashCommand.kt similarity index 70% rename from vector/src/main/java/im/vector/app/features/analytics/plan/Click.kt rename to vector/src/main/java/im/vector/app/features/analytics/plan/SlashCommand.kt index fbc847165d..33d3545487 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/plan/Click.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/plan/SlashCommand.kt @@ -22,29 +22,25 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsEvent // https://github.com/matrix-org/matrix-analytics-events/ /** - * Triggered when the user clicks/taps on a UI element. + * Triggered when the user runs a slash command in their composer. */ -data class Click( +data class SlashCommand( /** - * The index of the element, if its in a list of elements. + * The name of this command. */ - val index: Int? = null, - /** - * The unique name of this element. - */ - val name: Name, + val command: Command, ) : VectorAnalyticsEvent { - enum class Name { - SendMessageButton, + enum class Command { + Invite, + Part, } - override fun getName() = "Click" + override fun getName() = "SlashCommand" override fun getProperties(): Map? { return mutableMapOf().apply { - index?.let { put("index", it) } - put("name", name.name) + put("command", command.name) }.takeIf { it.isNotEmpty() } } } diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/UserProperties.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/UserProperties.kt new file mode 100644 index 0000000000..dea499edde --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/analytics/plan/UserProperties.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.analytics.plan + +// GENERATED FILE, DO NOT EDIT. FOR MORE INFORMATION VISIT +// https://github.com/matrix-org/matrix-analytics-events/ + +/** + * The user properties to apply when identifying. This is not an event + * definition. These properties must all be device independent. + */ +data class UserProperties( + /** + * Whether the user has the favourites space enabled + */ + val WebMetaSpaceFavouritesEnabled: Boolean? = null, + /** + * Whether the user has the home space set to all rooms + */ + val WebMetaSpaceHomeAllRooms: Boolean? = null, + /** + * Whether the user has the home space enabled + */ + val WebMetaSpaceHomeEnabled: Boolean? = null, + /** + * Whether the user has the other rooms space enabled + */ + val WebMetaSpaceOrphansEnabled: Boolean? = null, + /** + * Whether the user has the people space enabled + */ + val WebMetaSpacePeopleEnabled: Boolean? = null, + /** + * The selected messaging use case during the onboarding flow. + */ + val ftueUseCaseSelection: FtueUseCaseSelection? = null, + /** + * Number of joined rooms the user has favourited + */ + val numFavouriteRooms: Int? = null, + /** + * Number of spaces (and sub-spaces) the user is joined to + */ + val numSpaces: Int? = null, +) { + + enum class FtueUseCaseSelection { + /** + * The third option, Communities. + */ + CommunityMessaging, + + /** + * The first option, Friends and family. + */ + PersonalMessaging, + + /** + * The footer option to skip the question. + */ + Skip, + + /** + * The second option, Teams. + */ + WorkMessaging, + } + + fun getProperties(): Map? { + return mutableMapOf().apply { + WebMetaSpaceFavouritesEnabled?.let { put("WebMetaSpaceFavouritesEnabled", it) } + WebMetaSpaceHomeAllRooms?.let { put("WebMetaSpaceHomeAllRooms", it) } + WebMetaSpaceHomeEnabled?.let { put("WebMetaSpaceHomeEnabled", it) } + WebMetaSpaceOrphansEnabled?.let { put("WebMetaSpaceOrphansEnabled", it) } + WebMetaSpacePeopleEnabled?.let { put("WebMetaSpacePeopleEnabled", it) } + ftueUseCaseSelection?.let { put("ftueUseCaseSelection", it.name) } + numFavouriteRooms?.let { put("numFavouriteRooms", it) } + numSpaces?.let { put("numSpaces", it) } + }.takeIf { it.isNotEmpty() } + } +} diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/ViewRoom.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/ViewRoom.kt new file mode 100644 index 0000000000..50c74a64c1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/analytics/plan/ViewRoom.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.analytics.plan + +import im.vector.app.features.analytics.itf.VectorAnalyticsEvent + +// GENERATED FILE, DO NOT EDIT. FOR MORE INFORMATION VISIT +// https://github.com/matrix-org/matrix-analytics-events/ + +/** + * Triggered when the user changes rooms. + */ +data class ViewRoom( + /** + * The reason for the room change if known. + */ + val trigger: Trigger? = null, + /** + * Whether the interaction was performed via the keyboard input. + */ + val viaKeyboard: Boolean? = null, +) : VectorAnalyticsEvent { + + enum class Trigger { + /** + * Room accessed due to being just created. + */ + Created, + + /** + * Room switched due to user interacting with a message search result. + */ + MessageSearch, + + /** + * Room switched due to user selecting a user to go to a DM with. + */ + MessageUser, + + /** + * Room accessed via a push/desktop notification. + */ + Notification, + + /** + * Room accessed via the predecessor link at the top of the upgraded + * room. + */ + Predecessor, + + /** + * Room accessed via the public rooms directory. + */ + RoomDirectory, + + /** + * Room accessed via the room list. + */ + RoomList, + + /** + * Room accessed via a slash command in Element Web/Desktop like /goto. + */ + SlashCommand, + + /** + * Room accessed via the space hierarchy view. + */ + SpaceHierarchy, + + /** + * Room accessed via a timeline pill or link in another room. + */ + Timeline, + + /** + * Room accessed via a tombstone at the bottom of a predecessor room. + */ + Tombstone, + + /** + * Room switched due to user interacting with incoming verification + * request. + */ + VerificationRequest, + + /** + * Room switched due to accepting a call in a different room in Element + * Web/Desktop. + */ + WebAcceptCall, + + /** + * Room switched due to making a call via the dial pad in Element + * Web/Desktop. + */ + WebDialPad, + + /** + * Room accessed via interacting with the floating call or Jitsi PIP in + * Element Web/Desktop. + */ + WebFloatingCallWindow, + + /** + * Room accessed via the shortcut in Element Web/Desktop's forward + * modal. + */ + WebForwardShortcut, + + /** + * Room accessed via the Element Web/Desktop horizontal breadcrumbs at + * the top of the room list. + */ + WebHorizontalBreadcrumbs, + + /** + * Room accessed via an Element Web/Desktop keyboard shortcut like go to + * next room with unread messages. + */ + WebKeyboardShortcut, + + /** + * Room accessed via Element Web/Desktop's notification panel. + */ + WebNotificationPanel, + + /** + * Room accessed via the predecessor link in Settings > Advanced in + * Element Web/Desktop. + */ + WebPredecessorSettings, + + /** + * Room accessed via clicking on a notifications badge on a room list + * sublist in Element Web/Desktop. + */ + WebRoomListNotificationBadge, + + /** + * Room switched due to the user changing space in Element Web/Desktop. + */ + WebSpaceContextSwitch, + + /** + * Room accessed via clicking on the notifications badge on the + * currently selected space in Element Web/Desktop. + */ + WebSpacePanelNotificationBadge, + + /** + * Room accessed via Element Web/Desktop's Unified Search modal. + */ + WebUnifiedSearch, + + /** + * Room accessed via the Element Web/Desktop vertical breadcrumb hover + * menu. + */ + WebVerticalBreadcrumbs, + + /** + * Room switched due to widget interaction. + */ + Widget, + } + + override fun getName() = "ViewRoom" + + override fun getProperties(): Map? { + return mutableMapOf().apply { + trigger?.let { put("trigger", it.name) } + viaKeyboard?.let { put("viaKeyboard", it) } + }.takeIf { it.isNotEmpty() } + } +} diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/command/AutocompleteCommandPresenter.kt b/vector/src/main/java/im/vector/app/features/autocomplete/command/AutocompleteCommandPresenter.kt index 9888f1e35e..5e4528d381 100644 --- a/vector/src/main/java/im/vector/app/features/autocomplete/command/AutocompleteCommandPresenter.kt +++ b/vector/src/main/java/im/vector/app/features/autocomplete/command/AutocompleteCommandPresenter.kt @@ -18,17 +18,26 @@ package im.vector.app.features.autocomplete.command import android.content.Context import androidx.recyclerview.widget.RecyclerView +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import im.vector.app.features.autocomplete.AutocompleteClickListener import im.vector.app.features.autocomplete.RecyclerViewPresenter import im.vector.app.features.command.Command import im.vector.app.features.settings.VectorPreferences -import javax.inject.Inject -class AutocompleteCommandPresenter @Inject constructor(context: Context, - private val controller: AutocompleteCommandController, - private val vectorPreferences: VectorPreferences) : +class AutocompleteCommandPresenter @AssistedInject constructor( + @Assisted val isInThreadTimeline: Boolean, + context: Context, + private val controller: AutocompleteCommandController, + private val vectorPreferences: VectorPreferences) : RecyclerViewPresenter(context), AutocompleteClickListener { + @AssistedFactory + interface Factory { + fun create(isFromThreadTimeline: Boolean): AutocompleteCommandPresenter + } + init { controller.listener = this } @@ -46,6 +55,13 @@ class AutocompleteCommandPresenter @Inject constructor(context: Context, .filter { !it.isDevCommand || vectorPreferences.developerMode() } + .filter { + if (vectorPreferences.areThreadMessagesEnabled() && isInThreadTimeline) { + it.isThreadCommand + } else { + true + } + } .filter { if (query.isNullOrEmpty()) { true diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt index 22f1fc40a2..23c7b79914 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt @@ -16,6 +16,7 @@ package im.vector.app.features.call +import android.app.Activity import android.app.KeyguardManager import android.app.PictureInPictureParams import android.content.Context @@ -43,6 +44,7 @@ import com.google.android.material.card.MaterialCardView import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R +import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.extensions.setTextOrHide import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.utils.PERMISSIONS_FOR_AUDIO_IP_CALL @@ -52,13 +54,14 @@ import im.vector.app.core.utils.registerForPermissionsResult import im.vector.app.databinding.ActivityCallBinding import im.vector.app.features.call.dialpad.CallDialPadBottomSheet import im.vector.app.features.call.dialpad.DialPadFragment +import im.vector.app.features.call.transfer.CallTransferActivity import im.vector.app.features.call.utils.EglUtils import im.vector.app.features.call.webrtc.WebRtcCall import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.displayname.getBestName import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.RoomDetailActivity -import im.vector.app.features.home.room.detail.RoomDetailArgs +import im.vector.app.features.home.room.detail.arguments.TimelineArgs import io.github.hyuwah.draggableviewlib.DraggableView import io.github.hyuwah.draggableviewlib.setupDraggable import kotlinx.parcelize.Parcelize @@ -163,6 +166,7 @@ class VectorCallActivity : VectorBaseActivity(), CallContro ?.let { callViewModel.handle(VectorCallViewActions.SwitchCall(it)) } + this.intent = intent } override fun getMenuRes() = R.menu.vector_call @@ -518,13 +522,26 @@ class VectorCallActivity : VectorBaseActivity(), CallContro } is VectorCallViewEvents.ShowCallTransferScreen -> { val callId = withState(callViewModel) { it.callId } - navigator.openCallTransfer(this, callId) + navigator.openCallTransfer(this, callTransferActivityResultLauncher, callId) } + is VectorCallViewEvents.FailToTransfer -> showSnackbar(getString(R.string.call_transfer_failure)) null -> { } } } + private val callTransferActivityResultLauncher = registerStartForActivityResult { activityResult -> + when (activityResult.resultCode) { + Activity.RESULT_CANCELED -> { + callViewModel.handle(VectorCallViewActions.CallTransferSelectionCancelled) + } + Activity.RESULT_OK -> { + CallTransferActivity.getCallTransferResult(activityResult.data) + ?.let { callViewModel.handle(VectorCallViewActions.CallTransferSelectionResult(it)) } + } + } + } + private fun onErrorTimoutConnect(turn: TurnServerResponse?) { Timber.tag(loggerTag.value).d("onErrorTimoutConnect $turn") // TODO ask to use default stun, etc... @@ -563,7 +580,7 @@ class VectorCallActivity : VectorBaseActivity(), CallContro private fun returnToChat() { val roomId = withState(callViewModel) { it.roomId } - val args = RoomDetailArgs(roomId) + val args = TimelineArgs(roomId) val intent = RoomDetailActivity.newIntent(this, args).apply { flags = FLAG_ACTIVITY_CLEAR_TOP } diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt index 67aa7bede2..d1ed961814 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt @@ -18,6 +18,7 @@ package im.vector.app.features.call import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.features.call.audio.CallAudioManager +import im.vector.app.features.call.transfer.CallTransferResult sealed class VectorCallViewActions : VectorViewModelAction { object EndCall : VectorCallViewActions() @@ -36,5 +37,7 @@ sealed class VectorCallViewActions : VectorViewModelAction { object ToggleCamera : VectorCallViewActions() object ToggleHDSD : VectorCallViewActions() object InitiateCallTransfer : VectorCallViewActions() + object CallTransferSelectionCancelled : VectorCallViewActions() + data class CallTransferSelectionResult(val callTransferResult: CallTransferResult) : VectorCallViewActions() object TransferCall : VectorCallViewActions() } diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt index 5a0a2f127c..7c29d7eea3 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt @@ -29,6 +29,7 @@ sealed class VectorCallViewEvents : VectorViewEvents { ) : VectorCallViewEvents() object ShowDialPad : VectorCallViewEvents() object ShowCallTransferScreen : VectorCallViewEvents() + object FailToTransfer : VectorCallViewEvents() // data class CallAnswered(val content: CallAnswerContent) : VectorCallViewEvents() // data class CallHangup(val content: CallHangupContent) : VectorCallViewEvents() // object CallAccepted : VectorCallViewEvents() diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt index 5af2b826af..a26eec04f3 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt @@ -29,13 +29,17 @@ import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.extensions.exhaustive import im.vector.app.core.platform.VectorViewModel import im.vector.app.features.call.audio.CallAudioManager +import im.vector.app.features.call.dialpad.DialPadLookup +import im.vector.app.features.call.transfer.CallTransferResult import im.vector.app.features.call.webrtc.WebRtcCall import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.call.webrtc.getOpponentAsMatrixItem +import im.vector.app.features.createdirect.DirectRoomHelper import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.matrix.android.sdk.api.MatrixPatterns +import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.call.CallState import org.matrix.android.sdk.api.session.call.MxCall @@ -47,7 +51,9 @@ class VectorCallViewModel @AssistedInject constructor( @Assisted initialState: VectorCallViewState, val session: Session, val callManager: WebRtcCallManager, - val proximityManager: CallProximityManager + val proximityManager: CallProximityManager, + private val dialPadLookup: DialPadLookup, + private val directRoomHelper: DirectRoomHelper, ) : VectorViewModel(initialState) { private var call: WebRtcCall? = null @@ -319,10 +325,17 @@ class VectorCallViewModel @AssistedInject constructor( call?.sendDtmfDigit(action.digit) } VectorCallViewActions.InitiateCallTransfer -> { + call?.updateRemoteOnHold(true) _viewEvents.post( VectorCallViewEvents.ShowCallTransferScreen ) } + VectorCallViewActions.CallTransferSelectionCancelled -> { + call?.updateRemoteOnHold(false) + } + is VectorCallViewActions.CallTransferSelectionResult -> { + handleCallTransferSelectionResult(action.callTransferResult) + } VectorCallViewActions.TransferCall -> { handleCallTransfer() } @@ -341,6 +354,53 @@ class VectorCallViewModel @AssistedInject constructor( } } + private fun handleCallTransferSelectionResult(result: CallTransferResult) { + when (result) { + is CallTransferResult.ConnectWithUserId -> connectWithUserId(result) + is CallTransferResult.ConnectWithPhoneNumber -> connectWithPhoneNumber(result) + }.exhaustive + } + + private fun connectWithUserId(result: CallTransferResult.ConnectWithUserId) { + viewModelScope.launch { + try { + if (result.consultFirst) { + val dmRoomId = directRoomHelper.ensureDMExists(result.selectedUserId) + callManager.startOutgoingCall( + nativeRoomId = dmRoomId, + otherUserId = result.selectedUserId, + isVideoCall = call?.mxCall?.isVideoCall.orFalse(), + transferee = call + ) + } else { + call?.transferToUser(result.selectedUserId, null) + } + } catch (failure: Throwable) { + _viewEvents.post(VectorCallViewEvents.FailToTransfer) + } + } + } + + private fun connectWithPhoneNumber(action: CallTransferResult.ConnectWithPhoneNumber) { + viewModelScope.launch { + try { + val result = dialPadLookup.lookupPhoneNumber(action.phoneNumber) + if (action.consultFirst) { + callManager.startOutgoingCall( + nativeRoomId = result.roomId, + otherUserId = result.userId, + isVideoCall = call?.mxCall?.isVideoCall.orFalse(), + transferee = call + ) + } else { + call?.transferToUser(result.userId, result.roomId) + } + } catch (failure: Throwable) { + _viewEvents.post(VectorCallViewEvents.FailToTransfer) + } + } + } + @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { override fun create(initialState: VectorCallViewState): VectorCallViewModel diff --git a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferActivity.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferActivity.kt index 959e96cc4c..d8eede6a55 100644 --- a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferActivity.kt +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferActivity.kt @@ -26,6 +26,7 @@ import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.error.ErrorFormatter +import im.vector.app.core.extensions.exhaustive import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivityCallTransferBinding import kotlinx.parcelize.Parcelize @@ -55,10 +56,8 @@ class CallTransferActivity : VectorBaseActivity() { callTransferViewModel.observeViewEvents { when (it) { - is CallTransferViewEvents.Dismiss -> finish() - CallTransferViewEvents.Loading -> showWaitingView() - is CallTransferViewEvents.FailToTransfer -> showSnackbar(getString(R.string.call_transfer_failure)) - } + is CallTransferViewEvents.Complete -> handleComplete() + }.exhaustive } sectionsPagerAdapter = CallTransferPagerAdapter(this) @@ -81,24 +80,41 @@ class CallTransferActivity : VectorBaseActivity() { when (views.callTransferTabLayout.selectedTabPosition) { CallTransferPagerAdapter.USER_LIST_INDEX -> { val selectedUser = sectionsPagerAdapter.userListFragment?.getCurrentState()?.getSelectedMatrixId()?.firstOrNull() ?: return@debouncedClicks - val action = CallTransferAction.ConnectWithUserId(views.callTransferConsultCheckBox.isChecked, selectedUser) - callTransferViewModel.handle(action) + val result = CallTransferResult.ConnectWithUserId(views.callTransferConsultCheckBox.isChecked, selectedUser) + handleComplete(result) } CallTransferPagerAdapter.DIAL_PAD_INDEX -> { val phoneNumber = sectionsPagerAdapter.dialPadFragment?.getRawInput() ?: return@debouncedClicks - val action = CallTransferAction.ConnectWithPhoneNumber(views.callTransferConsultCheckBox.isChecked, phoneNumber) - callTransferViewModel.handle(action) + val result = CallTransferResult.ConnectWithPhoneNumber(views.callTransferConsultCheckBox.isChecked, phoneNumber) + handleComplete(result) } } } } + private fun handleComplete(callTransferResult: CallTransferResult? = null) { + if (callTransferResult != null) { + val intent = Intent().apply { + putExtra(EXTRA_TRANSFER_RESULT, callTransferResult) + } + setResult(RESULT_OK, intent) + } else { + setResult(RESULT_OK) + } + finish() + } + companion object { + private const val EXTRA_TRANSFER_RESULT = "EXTRA_TRANSFER_RESULT" fun newIntent(context: Context, callId: String): Intent { return Intent(context, CallTransferActivity::class.java).also { it.putExtra(Mavericks.KEY_ARG, CallTransferArgs(callId)) } } + + fun getCallTransferResult(intent: Intent?): CallTransferResult? { + return intent?.extras?.getParcelable(EXTRA_TRANSFER_RESULT) + } } } diff --git a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferAction.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferResult.kt similarity index 64% rename from vector/src/main/java/im/vector/app/features/call/transfer/CallTransferAction.kt rename to vector/src/main/java/im/vector/app/features/call/transfer/CallTransferResult.kt index bd694ad14e..0629e91d35 100644 --- a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferAction.kt +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferResult.kt @@ -16,9 +16,10 @@ package im.vector.app.features.call.transfer -import im.vector.app.core.platform.VectorViewModelAction +import android.os.Parcelable +import kotlinx.parcelize.Parcelize -sealed class CallTransferAction : VectorViewModelAction { - data class ConnectWithUserId(val consultFirst: Boolean, val selectedUserId: String) : CallTransferAction() - data class ConnectWithPhoneNumber(val consultFirst: Boolean, val phoneNumber: String) : CallTransferAction() +sealed class CallTransferResult : Parcelable { + @Parcelize data class ConnectWithUserId(val consultFirst: Boolean, val selectedUserId: String) : CallTransferResult() + @Parcelize data class ConnectWithPhoneNumber(val consultFirst: Boolean, val phoneNumber: String) : CallTransferResult() } diff --git a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewEvents.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewEvents.kt index fd4c9d672d..4202506d23 100644 --- a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewEvents.kt @@ -19,7 +19,5 @@ package im.vector.app.features.call.transfer import im.vector.app.core.platform.VectorViewEvents sealed class CallTransferViewEvents : VectorViewEvents { - object Dismiss : CallTransferViewEvents() - object Loading : CallTransferViewEvents() - object FailToTransfer : CallTransferViewEvents() + object Complete : CallTransferViewEvents() } diff --git a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewModel.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewModel.kt index ffc6ff9bc3..1765b58a02 100644 --- a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewModel.kt @@ -22,22 +22,16 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory -import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.platform.EmptyAction import im.vector.app.core.platform.VectorViewModel -import im.vector.app.features.call.dialpad.DialPadLookup import im.vector.app.features.call.webrtc.WebRtcCall import im.vector.app.features.call.webrtc.WebRtcCallManager -import im.vector.app.features.createdirect.DirectRoomHelper -import kotlinx.coroutines.launch -import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.call.CallState import org.matrix.android.sdk.api.session.call.MxCall class CallTransferViewModel @AssistedInject constructor(@Assisted initialState: CallTransferViewState, - private val dialPadLookup: DialPadLookup, - private val directRoomHelper: DirectRoomHelper, private val callManager: WebRtcCallManager) : - VectorViewModel(initialState) { + VectorViewModel(initialState) { @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { @@ -50,14 +44,14 @@ class CallTransferViewModel @AssistedInject constructor(@Assisted initialState: private val callListener = object : WebRtcCall.Listener { override fun onStateUpdate(call: MxCall) { if (call.state is CallState.Ended) { - _viewEvents.post(CallTransferViewEvents.Dismiss) + _viewEvents.post(CallTransferViewEvents.Complete) } } } init { if (call == null) { - _viewEvents.post(CallTransferViewEvents.Dismiss) + _viewEvents.post(CallTransferViewEvents.Complete) } else { call.addListener(callListener) } @@ -68,53 +62,5 @@ class CallTransferViewModel @AssistedInject constructor(@Assisted initialState: call?.removeListener(callListener) } - override fun handle(action: CallTransferAction) { - when (action) { - is CallTransferAction.ConnectWithUserId -> connectWithUserId(action) - is CallTransferAction.ConnectWithPhoneNumber -> connectWithPhoneNumber(action) - }.exhaustive - } - - private fun connectWithUserId(action: CallTransferAction.ConnectWithUserId) { - viewModelScope.launch { - try { - if (action.consultFirst) { - val dmRoomId = directRoomHelper.ensureDMExists(action.selectedUserId) - callManager.startOutgoingCall( - nativeRoomId = dmRoomId, - otherUserId = action.selectedUserId, - isVideoCall = call?.mxCall?.isVideoCall.orFalse(), - transferee = call - ) - } else { - call?.transferToUser(action.selectedUserId, null) - } - _viewEvents.post(CallTransferViewEvents.Dismiss) - } catch (failure: Throwable) { - _viewEvents.post(CallTransferViewEvents.FailToTransfer) - } - } - } - - private fun connectWithPhoneNumber(action: CallTransferAction.ConnectWithPhoneNumber) { - viewModelScope.launch { - try { - _viewEvents.post(CallTransferViewEvents.Loading) - val result = dialPadLookup.lookupPhoneNumber(action.phoneNumber) - if (action.consultFirst) { - callManager.startOutgoingCall( - nativeRoomId = result.roomId, - otherUserId = result.userId, - isVideoCall = call?.mxCall?.isVideoCall.orFalse(), - transferee = call - ) - } else { - call?.transferToUser(result.userId, result.roomId) - } - _viewEvents.post(CallTransferViewEvents.Dismiss) - } catch (failure: Throwable) { - _viewEvents.post(CallTransferViewEvents.FailToTransfer) - } - } - } + override fun handle(action: EmptyAction) { } } diff --git a/vector/src/main/java/im/vector/app/features/command/Command.kt b/vector/src/main/java/im/vector/app/features/command/Command.kt index 01f8cf234b..421c83c9fe 100644 --- a/vector/src/main/java/im/vector/app/features/command/Command.kt +++ b/vector/src/main/java/im/vector/app/features/command/Command.kt @@ -28,41 +28,42 @@ enum class Command(val command: String, val aliases: Array?, val parameters: String, @StringRes val description: Int, - val isDevCommand: Boolean) { - EMOTE("/me", null, "", R.string.command_description_emote, false), - BAN_USER("/ban", null, " [reason]", R.string.command_description_ban_user, false), - UNBAN_USER("/unban", null, " [reason]", R.string.command_description_unban_user, false), - IGNORE_USER("/ignore", null, " [reason]", R.string.command_description_ignore_user, false), - UNIGNORE_USER("/unignore", null, "", R.string.command_description_unignore_user, false), - SET_USER_POWER_LEVEL("/op", null, " []", R.string.command_description_op_user, false), - RESET_USER_POWER_LEVEL("/deop", null, "", R.string.command_description_deop_user, false), - ROOM_NAME("/roomname", null, "", R.string.command_description_room_name, false), - INVITE("/invite", null, " [reason]", R.string.command_description_invite_user, false), - JOIN_ROOM("/join", arrayOf("/j", "/goto"), " [reason]", R.string.command_description_join_room, false), - PART("/part", null, "[]", R.string.command_description_part_room, false), - TOPIC("/topic", null, "", R.string.command_description_topic, false), - REMOVE_USER("/remove", arrayOf("/kick"), " [reason]", R.string.command_description_remove_user, false), - CHANGE_DISPLAY_NAME("/nick", null, "", R.string.command_description_nick, false), - CHANGE_DISPLAY_NAME_FOR_ROOM("/myroomnick", arrayOf("/roomnick"), "", R.string.command_description_nick_for_room, false), - ROOM_AVATAR("/roomavatar", null, "", R.string.command_description_room_avatar, true /* Since user has to know the mxc url */), - CHANGE_AVATAR_FOR_ROOM("/myroomavatar", null, "", R.string.command_description_avatar_for_room, true /* Since user has to know the mxc url */), - MARKDOWN("/markdown", null, "", R.string.command_description_markdown, false), - RAINBOW("/rainbow", null, "", R.string.command_description_rainbow, false), - RAINBOW_EMOTE("/rainbowme", null, "", R.string.command_description_rainbow_emote, false), - CLEAR_SCALAR_TOKEN("/clear_scalar_token", null, "", R.string.command_description_clear_scalar_token, false), - SPOILER("/spoiler", null, "", R.string.command_description_spoiler, false), - SHRUG("/shrug", null, "", R.string.command_description_shrug, false), - LENNY("/lenny", null, "", R.string.command_description_lenny, false), - PLAIN("/plain", null, "", R.string.command_description_plain, false), - WHOIS("/whois", null, "", R.string.command_description_whois, false), - DISCARD_SESSION("/discardsession", null, "", R.string.command_description_discard_session, false), - CONFETTI("/confetti", null, "", R.string.command_confetti, false), - SNOWFALL("/snowfall", null, "", R.string.command_snow, false), - CREATE_SPACE("/createspace", null, " *", R.string.command_description_create_space, true), - ADD_TO_SPACE("/addToSpace", null, "spaceId", R.string.command_description_add_to_space, true), - JOIN_SPACE("/joinSpace", null, "spaceId", R.string.command_description_join_space, true), - LEAVE_ROOM("/leave", null, "", R.string.command_description_leave_room, true), - UPGRADE_ROOM("/upgraderoom", null, "newVersion", R.string.command_description_upgrade_room, true); + val isDevCommand: Boolean, + val isThreadCommand: Boolean) { + EMOTE("/me", null, "", R.string.command_description_emote, false, true), + BAN_USER("/ban", null, " [reason]", R.string.command_description_ban_user, false, false), + UNBAN_USER("/unban", null, " [reason]", R.string.command_description_unban_user, false, false), + IGNORE_USER("/ignore", null, " [reason]", R.string.command_description_ignore_user, false, true), + UNIGNORE_USER("/unignore", null, "", R.string.command_description_unignore_user, false, true), + SET_USER_POWER_LEVEL("/op", null, " []", R.string.command_description_op_user, false, false), + RESET_USER_POWER_LEVEL("/deop", null, "", R.string.command_description_deop_user, false, false), + ROOM_NAME("/roomname", null, "", R.string.command_description_room_name, false, false), + INVITE("/invite", null, " [reason]", R.string.command_description_invite_user, false, false), + JOIN_ROOM("/join", arrayOf("/j", "/goto"), " [reason]", R.string.command_description_join_room, false, false), + PART("/part", null, "[]", R.string.command_description_part_room, false, false), + TOPIC("/topic", null, "", R.string.command_description_topic, false, false), + REMOVE_USER("/remove", arrayOf("/kick"), " [reason]", R.string.command_description_remove_user, false, false), + CHANGE_DISPLAY_NAME("/nick", null, "", R.string.command_description_nick, false, false), + CHANGE_DISPLAY_NAME_FOR_ROOM("/myroomnick", arrayOf("/roomnick"), "", R.string.command_description_nick_for_room, false, false), + ROOM_AVATAR("/roomavatar", null, "", R.string.command_description_room_avatar, true /* User has to know the mxc url */, false), + CHANGE_AVATAR_FOR_ROOM("/myroomavatar", null, "", R.string.command_description_avatar_for_room, true /* User has to know the mxc url */, false), + MARKDOWN("/markdown", null, "", R.string.command_description_markdown, false, false), + RAINBOW("/rainbow", null, "", R.string.command_description_rainbow, false, true), + RAINBOW_EMOTE("/rainbowme", null, "", R.string.command_description_rainbow_emote, false, true), + CLEAR_SCALAR_TOKEN("/clear_scalar_token", null, "", R.string.command_description_clear_scalar_token, false, false), + SPOILER("/spoiler", null, "", R.string.command_description_spoiler, false, true), + SHRUG("/shrug", null, "", R.string.command_description_shrug, false, true), + LENNY("/lenny", null, "", R.string.command_description_lenny, false, true), + PLAIN("/plain", null, "", R.string.command_description_plain, false, true), + WHOIS("/whois", null, "", R.string.command_description_whois, false, true), + DISCARD_SESSION("/discardsession", null, "", R.string.command_description_discard_session, false, false), + CONFETTI("/confetti", null, "", R.string.command_confetti, false, false), + SNOWFALL("/snowfall", null, "", R.string.command_snow, false, false), + CREATE_SPACE("/createspace", null, " *", R.string.command_description_create_space, true, false), + ADD_TO_SPACE("/addToSpace", null, "spaceId", R.string.command_description_add_to_space, true, false), + JOIN_SPACE("/joinSpace", null, "spaceId", R.string.command_description_join_space, true, false), + LEAVE_ROOM("/leave", null, "", R.string.command_description_leave_room, true, false), + UPGRADE_ROOM("/upgraderoom", null, "newVersion", R.string.command_description_upgrade_room, true, false); val allAliases = arrayOf(command, *aliases.orEmpty()) diff --git a/vector/src/main/java/im/vector/app/features/command/CommandParser.kt b/vector/src/main/java/im/vector/app/features/command/CommandParser.kt index 9d854fdbee..b8bef506b1 100644 --- a/vector/src/main/java/im/vector/app/features/command/CommandParser.kt +++ b/vector/src/main/java/im/vector/app/features/command/CommandParser.kt @@ -33,7 +33,7 @@ class CommandParser @Inject constructor() { * @param textMessage the text message * @return a parsed slash command (ok or error) */ - fun parseSlashCommand(textMessage: CharSequence): ParsedCommand { + fun parseSlashCommand(textMessage: CharSequence, isInThreadTimeline: Boolean): ParsedCommand { // check if it has the Slash marker return if (!textMessage.startsWith("/")) { ParsedCommand.ErrorNotACommand @@ -63,6 +63,10 @@ class CommandParser @Inject constructor() { val slashCommand = messageParts.first() val message = textMessage.substring(slashCommand.length).trim() + getNotSupportedByThreads(isInThreadTimeline, slashCommand)?.let { + return ParsedCommand.ErrorCommandNotSupportedInThreads(it) + } + when { Command.PLAIN.matches(slashCommand) -> { if (message.isNotEmpty()) { @@ -400,6 +404,28 @@ class CommandParser @Inject constructor() { } } + private val notSupportedThreadsCommands: List by lazy { + Command.values().filter { + !it.isThreadCommand + } + } + + /** + * Checks whether or not the current command is not supported by threads + * @param slashCommand the slash command that will be checked + * @param isInThreadTimeline if its true we are in a thread timeline + * @return The command that is not supported + */ + private fun getNotSupportedByThreads(isInThreadTimeline: Boolean, slashCommand: String): Command? { + return if (isInThreadTimeline) { + notSupportedThreadsCommands.firstOrNull { + it.command == slashCommand + } + } else { + null + } + } + private fun trimParts(message: CharSequence, messageParts: List): String? { val partsSize = messageParts.sumOf { it.length } val gapsNumber = messageParts.size - 1 diff --git a/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt b/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt index 5f2e7f56a5..771f721d3c 100644 --- a/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt +++ b/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt @@ -28,6 +28,8 @@ sealed interface ParsedCommand { object ErrorEmptySlashCommand : ParsedCommand + class ErrorCommandNotSupportedInThreads(val command: Command) : ParsedCommand + // Unknown/Unsupported slash command data class ErrorUnknownSlashCommand(val slashCommand: String) : ParsedCommand diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt index da3425d326..83c7f0a13b 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt @@ -23,4 +23,8 @@ sealed class CreateDirectRoomAction : VectorViewModelAction { data class CreateRoomAndInviteSelectedUsers( val selections: Set ) : CreateDirectRoomAction() + + data class QrScannedAction( + val result: String + ) : CreateDirectRoomAction() } diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt index 0df9426852..2d93bab6a3 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt @@ -22,6 +22,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.view.View +import android.widget.Toast import androidx.lifecycle.lifecycleScope import com.airbnb.mvrx.Async import com.airbnb.mvrx.Fail @@ -44,6 +45,10 @@ import im.vector.app.core.utils.onPermissionDeniedSnackbar import im.vector.app.core.utils.registerForPermissionsResult import im.vector.app.features.analytics.plan.Screen import im.vector.app.features.contactsbook.ContactsBookFragment +import im.vector.app.features.qrcode.QrCodeScannerEvents +import im.vector.app.features.qrcode.QrCodeScannerFragment +import im.vector.app.features.qrcode.QrCodeScannerViewModel +import im.vector.app.features.qrcode.QrScannerArgs import im.vector.app.features.userdirectory.UserListFragment import im.vector.app.features.userdirectory.UserListFragmentArgs import im.vector.app.features.userdirectory.UserListSharedAction @@ -59,6 +64,8 @@ import javax.inject.Inject class CreateDirectRoomActivity : SimpleFragmentActivity() { private val viewModel: CreateDirectRoomViewModel by viewModel() + private val qrViewModel: QrCodeScannerViewModel by viewModel() + private lateinit var sharedActionViewModel: UserListSharedActionViewModel @Inject lateinit var errorFormatter: ErrorFormatter @@ -93,11 +100,38 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() { viewModel.onEach(CreateDirectRoomViewState::createAndInviteState) { renderCreateAndInviteState(it) } + + viewModel.observeViewEvents { + when (it) { + CreateDirectRoomViewEvents.InvalidCode -> { + Toast.makeText(this, R.string.invalid_qr_code_uri, Toast.LENGTH_SHORT).show() + finish() + } + CreateDirectRoomViewEvents.DmSelf -> { + Toast.makeText(this, R.string.cannot_dm_self, Toast.LENGTH_SHORT).show() + finish() + } + }.exhaustive + } + + qrViewModel.observeViewEvents { + when (it) { + is QrCodeScannerEvents.CodeParsed -> { + viewModel.handle(CreateDirectRoomAction.QrScannedAction(it.result)) + } + is QrCodeScannerEvents.ParseFailed -> { + Toast.makeText(this, R.string.qr_code_not_scanned, Toast.LENGTH_SHORT).show() + finish() + } + else -> Unit + }.exhaustive + } } private fun openAddByQrCode() { if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, this, permissionCameraLauncher)) { - addFragment(views.container, CreateDirectRoomByQrCodeFragment::class.java) + val args = QrScannerArgs(showExtraButtons = false, R.string.add_by_qr_code) + addFragment(views.container, QrCodeScannerFragment::class.java, args) } } @@ -118,7 +152,7 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() { private val permissionCameraLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> if (allGranted) { - addFragment(views.container, CreateDirectRoomByQrCodeFragment::class.java) + addFragment(views.container, QrCodeScannerFragment::class.java) } else if (deniedPermanently) { onPermissionDeniedSnackbar(R.string.permissions_denied_qr_code) } diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt deleted file mode 100644 index 766a6f5156..0000000000 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright 2020 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.createdirect - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import com.airbnb.mvrx.activityViewModel -import com.google.zxing.Result -import com.google.zxing.ResultMetadataType -import im.vector.app.R -import im.vector.app.core.extensions.hideKeyboard -import im.vector.app.core.platform.VectorBaseFragment -import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO -import im.vector.app.core.utils.checkPermissions -import im.vector.app.core.utils.onPermissionDeniedDialog -import im.vector.app.core.utils.registerForPermissionsResult -import im.vector.app.databinding.FragmentQrCodeScannerBinding -import im.vector.app.features.userdirectory.PendingSelection -import me.dm7.barcodescanner.zxing.ZXingScannerView -import org.matrix.android.sdk.api.session.permalinks.PermalinkData -import org.matrix.android.sdk.api.session.permalinks.PermalinkParser -import org.matrix.android.sdk.api.session.user.model.User -import javax.inject.Inject - -class CreateDirectRoomByQrCodeFragment @Inject constructor() : VectorBaseFragment(), ZXingScannerView.ResultHandler { - - private val viewModel: CreateDirectRoomViewModel by activityViewModel() - - override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentQrCodeScannerBinding { - return FragmentQrCodeScannerBinding.inflate(inflater, container, false) - } - - private val openCameraActivityResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> - if (allGranted) { - startCamera() - } else if (deniedPermanently) { - activity?.onPermissionDeniedDialog(R.string.denied_permission_camera) - } - } - - private fun startCamera() { - // Start camera on resume - views.scannerView.startCamera() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - setupToolbar(views.qrScannerToolbar) - .setTitle(R.string.add_by_qr_code) - .allowBack(useCross = true) - } - - override fun onResume() { - super.onResume() - view?.hideKeyboard() - // Register ourselves as a handler for scan results. - views.scannerView.setResultHandler(this) - // Start camera on resume - if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), openCameraActivityResultLauncher)) { - startCamera() - } - } - - override fun onPause() { - super.onPause() - // Unregister ourselves as a handler for scan results. - views.scannerView.setResultHandler(null) - // Stop camera on pause - views.scannerView.stopCamera() - } - - // Copied from https://github.com/markusfisch/BinaryEye/blob/ - // 9d57889b810dcaa1a91d7278fc45c262afba1284/app/src/main/kotlin/de/markusfisch/android/binaryeye/activity/CameraActivity.kt#L434 - private fun getRawBytes(result: Result): ByteArray? { - val metadata = result.resultMetadata ?: return null - val segments = metadata[ResultMetadataType.BYTE_SEGMENTS] ?: return null - var bytes = ByteArray(0) - @Suppress("UNCHECKED_CAST") - for (seg in segments as Iterable) { - bytes += seg - } - // byte segments can never be shorter than the text. - // Zxing cuts off content prefixes like "WIFI:" - return if (bytes.size >= result.text.length) bytes else null - } - - private fun addByQrCode(value: String) { - val mxid = (PermalinkParser.parse(value) as? PermalinkData.UserLink)?.userId - - if (mxid === null) { - Toast.makeText(requireContext(), R.string.invalid_qr_code_uri, Toast.LENGTH_SHORT).show() - requireActivity().finish() - } else { - // The following assumes MXIDs are case insensitive - if (mxid.equals(other = viewModel.session.myUserId, ignoreCase = true)) { - Toast.makeText(requireContext(), R.string.cannot_dm_self, Toast.LENGTH_SHORT).show() - requireActivity().finish() - } else { - // Try to get user from known users and fall back to creating a User object from MXID - val qrInvitee = if (viewModel.session.getUser(mxid) != null) viewModel.session.getUser(mxid)!! else User(mxid, null, null) - - viewModel.handle( - CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(setOf(PendingSelection.UserPendingSelection(qrInvitee))) - ) - } - } - } - - override fun handleResult(result: Result?) { - if (result === null) { - Toast.makeText(requireContext(), R.string.qr_code_not_scanned, Toast.LENGTH_SHORT).show() - requireActivity().finish() - } else { - val rawBytes = getRawBytes(result) - val rawBytesStr = rawBytes?.toString(Charsets.ISO_8859_1) - val value = rawBytesStr ?: result.text - addByQrCode(value) - } - } -} diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewEvents.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewEvents.kt index 0c9804e9a4..060cb0c327 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewEvents.kt @@ -18,4 +18,7 @@ package im.vector.app.features.createdirect import im.vector.app.core.platform.VectorViewEvents -sealed class CreateDirectRoomViewEvents : VectorViewEvents +sealed class CreateDirectRoomViewEvents : VectorViewEvents { + object InvalidCode : CreateDirectRoomViewEvents() + object DmSelf : CreateDirectRoomViewEvents() +} diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt index 41360eab93..9dd3ef6a9b 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt @@ -34,13 +34,16 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.matrix.android.sdk.api.raw.RawService import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.permalinks.PermalinkData +import org.matrix.android.sdk.api.session.permalinks.PermalinkParser import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams +import org.matrix.android.sdk.api.session.user.model.User class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted initialState: CreateDirectRoomViewState, private val rawService: RawService, val session: Session) : - VectorViewModel(initialState) { + VectorViewModel(initialState) { @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { @@ -51,15 +54,33 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted override fun handle(action: CreateDirectRoomAction) { when (action) { - is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> onSubmitInvitees(action) + is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> onSubmitInvitees(action.selections) + is CreateDirectRoomAction.QrScannedAction -> onCodeParsed(action) }.exhaustive } + private fun onCodeParsed(action: CreateDirectRoomAction.QrScannedAction) { + val mxid = (PermalinkParser.parse(action.result) as? PermalinkData.UserLink)?.userId + + if (mxid === null) { + _viewEvents.post(CreateDirectRoomViewEvents.InvalidCode) + } else { + // The following assumes MXIDs are case insensitive + if (mxid.equals(other = session.myUserId, ignoreCase = true)) { + _viewEvents.post(CreateDirectRoomViewEvents.DmSelf) + } else { + // Try to get user from known users and fall back to creating a User object from MXID + val qrInvitee = if (session.getUser(mxid) != null) session.getUser(mxid)!! else User(mxid, null, null) + onSubmitInvitees(setOf(PendingSelection.UserPendingSelection(qrInvitee))) + } + } + } + /** * If users already have a DM room then navigate to it instead of creating a new room. */ - private fun onSubmitInvitees(action: CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers) { - val existingRoomId = action.selections.singleOrNull()?.getMxId()?.let { userId -> + private fun onSubmitInvitees(selections: Set) { + val existingRoomId = selections.singleOrNull()?.getMxId()?.let { userId -> session.getExistingDirectRoomWithUser(userId) } if (existingRoomId != null) { @@ -69,7 +90,7 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted } } else { // Create the DM - createRoomAndInviteSelectedUsers(action.selections) + createRoomAndInviteSelectedUsers(selections) } } diff --git a/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt b/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt index 14ffda58a9..3a3f1054f1 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt @@ -21,7 +21,7 @@ import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.features.displayname.getBestName import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.RoomDetailActivity -import im.vector.app.features.home.room.detail.RoomDetailArgs +import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.popup.PopupAlertManager import im.vector.app.features.popup.VerificationVectorAlert import org.matrix.android.sdk.api.session.Session @@ -142,7 +142,7 @@ class IncomingVerificationRequestHandler @Inject constructor( R.drawable.ic_shield_black, shouldBeDisplayedIn = { activity -> if (activity is RoomDetailActivity) { - activity.intent?.extras?.getParcelable(RoomDetailActivity.EXTRA_ROOM_DETAIL_ARGS)?.let { + activity.intent?.extras?.getParcelable(RoomDetailActivity.EXTRA_ROOM_DETAIL_ARGS)?.let { it.roomId != pr.roomId } ?: true } else true diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt index d9719cb28f..6b6be63480 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt @@ -66,7 +66,6 @@ import im.vector.app.features.rageshake.ReportType import im.vector.app.features.rageshake.VectorUncaughtExceptionHandler import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorSettingsActivity -import im.vector.app.features.spaces.RestrictedPromoBottomSheet import im.vector.app.features.spaces.SpaceCreationActivity import im.vector.app.features.spaces.SpacePreviewActivity import im.vector.app.features.spaces.SpaceSettingsMenuBottomSheet @@ -111,7 +110,6 @@ class HomeActivity : private val userColorAccountDataViewModel: UserColorAccountDataViewModel by viewModel() private val serverBackupStatusViewModel: ServerBackupStatusViewModel by viewModel() - private val promoteRestrictedViewModel: PromoteRestrictedViewModel by viewModel() @Inject lateinit var activeSessionHolder: ActiveSessionHolder @Inject lateinit var vectorUncaughtExceptionHandler: VectorUncaughtExceptionHandler @@ -267,21 +265,6 @@ class HomeActivity : shortcutsHandler.observeRoomsAndBuildShortcuts(lifecycleScope) - if (!vectorPreferences.didPromoteNewRestrictedFeature()) { - promoteRestrictedViewModel.onEach { - if (it.activeSpaceSummary != null && !it.activeSpaceSummary.isPublic && - it.activeSpaceSummary.otherMemberIds.isNotEmpty()) { - // It's a private space with some members show this once - if (it.canUserManageSpace && !popupAlertManager.hasAlertsToShow()) { - if (!vectorPreferences.didPromoteNewRestrictedFeature()) { - vectorPreferences.setDidPromoteNewRestrictedFeature() - RestrictedPromoBottomSheet().show(supportFragmentManager, "RestrictedPromoBottomSheet") - } - } - } - } - } - if (isFirstCreation()) { handleIntent(intent) } @@ -473,14 +456,14 @@ class HomeActivity : override fun onResume() { super.onResume() - if (vectorUncaughtExceptionHandler.didAppCrash(this)) { - vectorUncaughtExceptionHandler.clearAppCrashStatus(this) + if (vectorUncaughtExceptionHandler.didAppCrash()) { + vectorUncaughtExceptionHandler.clearAppCrashStatus() MaterialAlertDialogBuilder(this) .setMessage(R.string.send_bug_report_app_crashed) .setCancelable(false) .setPositiveButton(R.string.yes) { _, _ -> bugReporter.openBugReportScreen(this) } - .setNegativeButton(R.string.no) { _, _ -> bugReporter.deleteCrashFile(this) } + .setNegativeButton(R.string.no) { _, _ -> bugReporter.deleteCrashFile() } .show() } else { showDisclaimerDialog(this) @@ -550,7 +533,7 @@ class HomeActivity : return true } - override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?): Boolean { + override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?, rootThreadEventId: String?): Boolean { if (roomId == null) return false MatrixToBottomSheet.withLink(deepLink.toString()) .show(supportFragmentManager, "HA#MatrixToBottomSheet") diff --git a/vector/src/main/java/im/vector/app/features/home/PromoteRestrictedViewModel.kt b/vector/src/main/java/im/vector/app/features/home/PromoteRestrictedViewModel.kt deleted file mode 100644 index 5c66e7c52d..0000000000 --- a/vector/src/main/java/im/vector/app/features/home/PromoteRestrictedViewModel.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright (c) 2021 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.app.features.home - -import com.airbnb.mvrx.MavericksState -import com.airbnb.mvrx.MavericksViewModelFactory -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import im.vector.app.AppStateHandler -import im.vector.app.RoomGroupingMethod -import im.vector.app.core.di.ActiveSessionHolder -import im.vector.app.core.di.MavericksAssistedViewModelFactory -import im.vector.app.core.di.hiltMavericksViewModelFactory -import im.vector.app.core.platform.EmptyAction -import im.vector.app.core.platform.EmptyViewEvents -import im.vector.app.core.platform.VectorViewModel -import kotlinx.coroutines.flow.distinctUntilChanged -import org.matrix.android.sdk.api.query.QueryStringValue -import org.matrix.android.sdk.api.session.events.model.EventType -import org.matrix.android.sdk.api.session.events.model.toModel -import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent -import org.matrix.android.sdk.api.session.room.model.RoomSummary -import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper - -data class ActiveSpaceViewState( - val isInSpaceMode: Boolean = false, - val activeSpaceSummary: RoomSummary? = null, - val canUserManageSpace: Boolean = false -) : MavericksState - -class PromoteRestrictedViewModel @AssistedInject constructor( - @Assisted initialState: ActiveSpaceViewState, - private val activeSessionHolder: ActiveSessionHolder, - appStateHandler: AppStateHandler -) : VectorViewModel(initialState) { - - init { - appStateHandler.selectedRoomGroupingFlow.distinctUntilChanged().execute { state -> - val groupingMethod = state.invoke()?.orNull() - val isSpaceMode = groupingMethod is RoomGroupingMethod.BySpace - val currentSpace = (groupingMethod as? RoomGroupingMethod.BySpace)?.spaceSummary - val canManage = currentSpace?.roomId?.let { roomId -> - activeSessionHolder.getSafeActiveSession() - ?.getRoom(roomId) - ?.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS, QueryStringValue.NoCondition) - ?.content?.toModel()?.let { - PowerLevelsHelper(it).isUserAllowedToSend(activeSessionHolder.getActiveSession().myUserId, true, EventType.STATE_SPACE_CHILD) - } ?: false - } ?: false - - copy( - isInSpaceMode = isSpaceMode, - activeSpaceSummary = currentSpace, - canUserManageSpace = canManage - ) - } - } - - @AssistedFactory - interface Factory : MavericksAssistedViewModelFactory { - override fun create(initialState: ActiveSpaceViewState): PromoteRestrictedViewModel - } - - companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() - - override fun handle(action: EmptyAction) {} -} diff --git a/vector/src/main/java/im/vector/app/features/home/UserColorAccountDataViewModel.kt b/vector/src/main/java/im/vector/app/features/home/UserColorAccountDataViewModel.kt index 3d4f219a7c..37e15af8b3 100644 --- a/vector/src/main/java/im/vector/app/features/home/UserColorAccountDataViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/UserColorAccountDataViewModel.kt @@ -16,7 +16,6 @@ package im.vector.app.features.home -import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksViewModelFactory import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -25,6 +24,7 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.platform.EmptyAction import im.vector.app.core.platform.EmptyViewEvents +import im.vector.app.core.platform.VectorDummyViewState import im.vector.app.core.platform.VectorViewModel import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider import kotlinx.coroutines.flow.launchIn @@ -37,22 +37,18 @@ import org.matrix.android.sdk.flow.flow import org.matrix.android.sdk.flow.unwrap import timber.log.Timber -data class DummyState( - val dummy: Boolean = false -) : MavericksState - class UserColorAccountDataViewModel @AssistedInject constructor( - @Assisted initialState: DummyState, + @Assisted initialState: VectorDummyViewState, private val session: Session, private val matrixItemColorProvider: MatrixItemColorProvider -) : VectorViewModel(initialState) { +) : VectorViewModel(initialState) { @AssistedFactory - interface Factory : MavericksAssistedViewModelFactory { - override fun create(initialState: DummyState): UserColorAccountDataViewModel + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: VectorDummyViewState): UserColorAccountDataViewModel } - companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() init { observeAccountData() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt index 7dbd8cc3b5..9f85d4015b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt @@ -49,9 +49,10 @@ import org.matrix.android.sdk.api.util.toRoomAliasMatrixItem class AutoCompleter @AssistedInject constructor( @Assisted val roomId: String, + @Assisted val isInThreadTimeline: Boolean, private val avatarRenderer: AvatarRenderer, private val commandAutocompletePolicy: CommandAutocompletePolicy, - private val autocompleteCommandPresenter: AutocompleteCommandPresenter, + AutocompleteCommandPresenterFactory: AutocompleteCommandPresenter.Factory, private val autocompleteMemberPresenterFactory: AutocompleteMemberPresenter.Factory, private val autocompleteRoomPresenter: AutocompleteRoomPresenter, private val autocompleteGroupPresenter: AutocompleteGroupPresenter, @@ -62,7 +63,11 @@ class AutoCompleter @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(roomId: String): AutoCompleter + fun create(roomId: String, isInThreadTimeline: Boolean): AutoCompleter + } + + private val autocompleteCommandPresenter: AutocompleteCommandPresenter by lazy { + AutocompleteCommandPresenterFactory.create(isInThreadTimeline) } private var editText: EditText? = null diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/JoinReplacementRoomBottomSheet.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/JoinReplacementRoomBottomSheet.kt index ba559677c9..99843084ec 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/JoinReplacementRoomBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/JoinReplacementRoomBottomSheet.kt @@ -44,7 +44,7 @@ class JoinReplacementRoomBottomSheet : @Inject lateinit var errorFormatter: ErrorFormatter - private val viewModel: RoomDetailViewModel by parentFragmentViewModel() + private val viewModel: TimelineViewModel by parentFragmentViewModel() override val showExpanded: Boolean get() = true diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt index 58e36d2303..14c8e598f8 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt @@ -20,7 +20,6 @@ import android.net.Uri import android.view.View import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.features.call.conference.ConferenceEvent -import im.vector.app.features.location.LocationData import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent @@ -90,6 +89,7 @@ sealed class RoomDetailAction : VectorViewModelAction { data class EnsureNativeWidgetAllowed(val widget: Widget, val userJustAccepted: Boolean, val grantedEvents: RoomDetailViewEvents) : RoomDetailAction() + data class UpdateJoinJitsiCallStatus(val conferenceEvent: ConferenceEvent) : RoomDetailAction() data class OpenOrCreateDm(val userId: String) : RoomDetailAction() @@ -112,7 +112,4 @@ sealed class RoomDetailAction : VectorViewModelAction { // Poll data class EndPoll(val eventId: String) : RoomDetailAction() - - // Location - data class ShowLocation(val locationData: LocationData, val userId: String) : RoomDetailAction() } 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 9a7b8e64f7..ae24052aa2 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 @@ -38,6 +38,7 @@ import im.vector.app.databinding.ActivityRoomDetailBinding import im.vector.app.features.analytics.plan.Screen import im.vector.app.features.analytics.screen.ScreenEvent import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsFragment +import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker import im.vector.app.features.matrixto.MatrixToBottomSheet import im.vector.app.features.navigation.Navigator @@ -97,17 +98,17 @@ class RoomDetailActivity : super.onCreate(savedInstanceState) supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, false) waitingView = views.waitingView.waitingView - val roomDetailArgs: RoomDetailArgs? = if (intent?.action == ACTION_ROOM_DETAILS_FROM_SHORTCUT) { - RoomDetailArgs(roomId = intent?.extras?.getString(EXTRA_ROOM_ID)!!) + val timelineArgs: TimelineArgs? = if (intent?.action == ACTION_ROOM_DETAILS_FROM_SHORTCUT) { + TimelineArgs(roomId = intent?.extras?.getString(EXTRA_ROOM_ID)!!) } else { intent?.extras?.getParcelable(EXTRA_ROOM_DETAIL_ARGS) } - if (roomDetailArgs == null) return - intent.putExtra(Mavericks.KEY_ARG, roomDetailArgs) - currentRoomId = roomDetailArgs.roomId + if (timelineArgs == null) return + intent.putExtra(Mavericks.KEY_ARG, timelineArgs) + currentRoomId = timelineArgs.roomId if (isFirstCreation()) { - replaceFragment(views.roomDetailContainer, RoomDetailFragment::class.java, roomDetailArgs) + replaceFragment(views.roomDetailContainer, TimelineFragment::class.java, timelineArgs) replaceFragment(views.roomDetailDrawerContainer, BreadcrumbsFragment::class.java) } @@ -145,7 +146,7 @@ class RoomDetailActivity : if (currentRoomId != switchToRoom.roomId) { currentRoomId = switchToRoom.roomId requireActiveMembershipViewModel.handle(RequireActiveMembershipAction.ChangeRoom(switchToRoom.roomId)) - replaceFragment(views.roomDetailContainer, RoomDetailFragment::class.java, RoomDetailArgs(switchToRoom.roomId)) + replaceFragment(views.roomDetailContainer, TimelineFragment::class.java, TimelineArgs(switchToRoom.roomId)) } } @@ -196,9 +197,9 @@ class RoomDetailActivity : const val EXTRA_ROOM_ID = "EXTRA_ROOM_ID" const val ACTION_ROOM_DETAILS_FROM_SHORTCUT = "ROOM_DETAILS_FROM_SHORTCUT" - fun newIntent(context: Context, roomDetailArgs: RoomDetailArgs): Intent { + fun newIntent(context: Context, timelineArgs: TimelineArgs): Intent { return Intent(context, RoomDetailActivity::class.java).apply { - putExtra(EXTRA_ROOM_DETAIL_ARGS, roomDetailArgs) + putExtra(EXTRA_ROOM_DETAIL_ARGS, timelineArgs) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt index b0921e01f9..86240a5ffe 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt @@ -20,7 +20,6 @@ import android.net.Uri import android.view.View import im.vector.app.core.platform.VectorViewEvents import im.vector.app.features.call.webrtc.WebRtcCall -import im.vector.app.features.location.LocationData import org.matrix.android.sdk.api.session.widgets.model.Widget import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.internal.crypto.model.event.WithHeldCode @@ -83,6 +82,4 @@ sealed class RoomDetailViewEvents : VectorViewEvents { data class StartChatEffect(val type: ChatEffect) : RoomDetailViewEvents() object StopChatEffects : RoomDetailViewEvents() object RoomReplacementStarted : RoomDetailViewEvents() - - data class ShowLocation(val locationData: LocationData, val userId: String) : RoomDetailViewEvents() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt index d963030013..22d5fc2a77 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt @@ -19,6 +19,7 @@ package im.vector.app.features.home.room.detail import com.airbnb.mvrx.Async import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.Uninitialized +import im.vector.app.features.home.room.detail.arguments.TimelineArgs import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.initsync.SyncStatusService @@ -26,6 +27,7 @@ import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.sync.SyncState +import org.matrix.android.sdk.api.session.threads.ThreadNotificationBadgeState import org.matrix.android.sdk.api.session.widgets.model.Widget import org.matrix.android.sdk.api.session.widgets.model.WidgetType @@ -67,22 +69,29 @@ data class RoomDetailViewState( val isAllowedToSetupEncryption: Boolean = true, val hasFailedSending: Boolean = false, val jitsiState: JitsiState = JitsiState(), - val switchToParentSpace: Boolean = false + val switchToParentSpace: Boolean = false, + val rootThreadEventId: String? = null, + val threadNotificationBadgeState: ThreadNotificationBadgeState = ThreadNotificationBadgeState() ) : MavericksState { - constructor(args: RoomDetailArgs) : this( + constructor(args: TimelineArgs) : this( roomId = args.roomId, eventId = args.eventId, // Also highlight the target event, if any highlightedEventId = args.eventId, - switchToParentSpace = args.switchToParentSpace + switchToParentSpace = args.switchToParentSpace, + rootThreadEventId = args.threadTimelineArgs?.rootThreadEventId ) fun isWebRTCCallOptionAvailable() = (asyncRoomSummary.invoke()?.joinedMembersCount ?: 0) <= 2 + fun isSearchAvailable() = asyncRoomSummary()?.isEncrypted == false + // This checks directly on the active room widgets. // It can differs for a short period of time on the JitsiState as its computed async. fun hasActiveJitsiWidget() = activeRoomWidgets()?.any { it.type == WidgetType.Jitsi && it.isActive }.orFalse() fun isDm() = asyncRoomSummary()?.isDirect == true + + fun isThreadTimeline() = rootThreadEventId != null } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt index 6b5ed3ba66..193dc42f33 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt @@ -32,7 +32,7 @@ class StartCallActionsHandler( private val fragment: Fragment, private val callManager: WebRtcCallManager, private val vectorPreferences: VectorPreferences, - private val roomDetailViewModel: RoomDetailViewModel, + private val timelineViewModel: TimelineViewModel, private val startCallActivityResultLauncher: ActivityResultLauncher>, private val showDialogWithMessage: (String) -> Unit, private val onTapToReturnToCall: () -> Unit) { @@ -45,7 +45,7 @@ class StartCallActionsHandler( handleCallRequest(false) } - private fun handleCallRequest(isVideoCall: Boolean) = withState(roomDetailViewModel) { state -> + private fun handleCallRequest(isVideoCall: Boolean) = withState(timelineViewModel) { state -> val roomSummary = state.asyncRoomSummary.invoke() ?: return@withState when (roomSummary.joinedMembersCount) { 1 -> { @@ -95,7 +95,7 @@ class StartCallActionsHandler( .setMessage(R.string.audio_video_meeting_description) .setPositiveButton(fragment.getString(R.string.create)) { _, _ -> // create the widget, then navigate to it.. - roomDetailViewModel.handle(RoomDetailAction.AddJitsiWidget(isVideoCall)) + timelineViewModel.handle(RoomDetailAction.AddJitsiWidget(isVideoCall)) } .setNegativeButton(fragment.getString(R.string.action_cancel), null) .show() @@ -121,22 +121,22 @@ class StartCallActionsHandler( private fun safeStartCall2(isVideoCall: Boolean) { val startCallAction = RoomDetailAction.StartCall(isVideoCall) - roomDetailViewModel.pendingAction = startCallAction + timelineViewModel.pendingAction = startCallAction if (isVideoCall) { if (checkPermissions(PERMISSIONS_FOR_VIDEO_IP_CALL, fragment.requireActivity(), startCallActivityResultLauncher, R.string.permissions_rationale_msg_camera_and_audio)) { - roomDetailViewModel.pendingAction = null - roomDetailViewModel.handle(startCallAction) + timelineViewModel.pendingAction = null + timelineViewModel.handle(startCallAction) } } else { if (checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL, fragment.requireActivity(), startCallActivityResultLauncher, R.string.permissions_rationale_msg_record_audio)) { - roomDetailViewModel.pendingAction = null - roomDetailViewModel.handle(startCallAction) + timelineViewModel.pendingAction = null + timelineViewModel.handle(startCallAction) } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt similarity index 80% rename from vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt rename to vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index 9926ecad24..92319fabdc 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -25,7 +25,6 @@ import android.graphics.Typeface import android.net.Uri import android.os.Build import android.os.Bundle -import android.os.Parcelable import android.text.Spannable import android.text.format.DateUtils import android.view.HapticFeedbackConstants @@ -37,12 +36,14 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo +import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView import android.widget.Toast import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat import androidx.core.net.toUri import androidx.core.text.buildSpannedString import androidx.core.text.toSpannable @@ -87,6 +88,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.ColorProvider +import im.vector.app.core.resources.UserPreferencesProvider import im.vector.app.core.time.Clock import im.vector.app.core.ui.views.CurrentCallsView import im.vector.app.core.ui.views.CurrentCallsViewPresenter @@ -116,8 +118,8 @@ import im.vector.app.core.utils.shareText import im.vector.app.core.utils.startInstallFromSourceIntent import im.vector.app.core.utils.toast import im.vector.app.databinding.DialogReportContentBinding -import im.vector.app.databinding.FragmentRoomDetailBinding -import im.vector.app.features.analytics.plan.Click +import im.vector.app.databinding.FragmentTimelineBinding +import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Screen import im.vector.app.features.attachments.AttachmentTypeSelectorView import im.vector.app.features.attachments.AttachmentsHelper @@ -136,6 +138,7 @@ import im.vector.app.features.command.Command import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreActivity import im.vector.app.features.crypto.verification.VerificationBottomSheet import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.home.room.detail.composer.CanSendStatus import im.vector.app.features.home.room.detail.composer.MessageComposerAction import im.vector.app.features.home.room.detail.composer.MessageComposerView @@ -167,12 +170,13 @@ import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever import im.vector.app.features.home.room.detail.upgrade.MigrateRoomBottomSheet import im.vector.app.features.home.room.detail.views.RoomDetailLazyLoadedViews import im.vector.app.features.home.room.detail.widget.RoomWidgetsBottomSheet +import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs 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.invite.VectorInviteView -import im.vector.app.features.location.LocationData import im.vector.app.features.location.LocationSharingMode +import im.vector.app.features.location.toLocationData import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.media.VideoContentRenderer import im.vector.app.features.notifications.NotificationDrawerManager @@ -200,7 +204,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.parcelize.Parcelize import nl.dionsegijn.konfetti.models.Shape import nl.dionsegijn.konfetti.models.Size import org.billcarsonfr.jsonviewer.JSonViewerDialog @@ -240,16 +243,7 @@ import java.net.URL import java.util.UUID import javax.inject.Inject -@Parcelize -data class RoomDetailArgs( - val roomId: String, - val eventId: String? = null, - val sharedData: SharedData? = null, - val openShareSpaceForId: String? = null, - val switchToParentSpace: Boolean = false -) : Parcelable - -class RoomDetailFragment @Inject constructor( +class TimelineFragment @Inject constructor( private val session: Session, private val avatarRenderer: AvatarRenderer, private val timelineEventController: TimelineEventController, @@ -260,6 +254,7 @@ class RoomDetailFragment @Inject constructor( private val vectorPreferences: VectorPreferences, private val colorProvider: ColorProvider, private val dimensionConverter: DimensionConverter, + private val userPreferencesProvider: UserPreferencesProvider, private val notificationUtils: NotificationUtils, private val matrixItemColorProvider: MatrixItemColorProvider, private val imageContentRenderer: ImageContentRenderer, @@ -269,7 +264,7 @@ class RoomDetailFragment @Inject constructor( private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker, private val clock: Clock ) : - VectorBaseFragment(), + VectorBaseFragment(), TimelineEventController.Callback, VectorInviteView.Callback, AttachmentTypeSelectorView.Callback, @@ -297,31 +292,34 @@ class RoomDetailFragment @Inject constructor( private val galleryOrCameraDialogHelper = GalleryOrCameraDialogHelper(this, colorProvider) - private val roomDetailArgs: RoomDetailArgs by args() + private val timelineArgs: TimelineArgs by args() private val glideRequests by lazy { GlideApp.with(this) } private val pillsPostProcessor by lazy { - pillsPostProcessorFactory.create(roomDetailArgs.roomId) + pillsPostProcessorFactory.create(timelineArgs.roomId) } private val autoCompleter: AutoCompleter by lazy { - autoCompleterFactory.create(roomDetailArgs.roomId) + autoCompleterFactory.create(timelineArgs.roomId, isThreadTimeLine()) } - private val roomDetailViewModel: RoomDetailViewModel by fragmentViewModel() + + private val timelineViewModel: TimelineViewModel by fragmentViewModel() private val messageComposerViewModel: MessageComposerViewModel by fragmentViewModel() private val debouncer = Debouncer(createUIHandler()) private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback private lateinit var scrollOnHighlightedEventCallback: ScrollOnHighlightedEventCallback - override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentRoomDetailBinding { - return FragmentRoomDetailBinding.inflate(inflater, container, false) + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentTimelineBinding { + return FragmentTimelineBinding.inflate(inflater, container, false) } override fun getMenuRes() = R.menu.menu_timeline private lateinit var sharedActionViewModel: MessageSharedActionViewModel + private lateinit var sharedActivityActionViewModel: RoomDetailSharedActionViewModel + private lateinit var knownCallsViewModel: SharedKnownCallsViewModel private lateinit var layoutManager: LinearLayoutManager @@ -347,7 +345,7 @@ class RoomDetailFragment @Inject constructor( analyticsScreenName = Screen.ScreenName.Room setFragmentResultListener(MigrateRoomBottomSheet.REQUEST_KEY) { _, bundle -> bundle.getString(MigrateRoomBottomSheet.BUNDLE_KEY_REPLACEMENT_ROOM)?.let { replacementRoomId -> - roomDetailViewModel.handle(RoomDetailAction.RoomUpgradeSuccess(replacementRoomId)) + timelineViewModel.handle(RoomDetailAction.RoomUpgradeSuccess(replacementRoomId)) } } } @@ -356,13 +354,14 @@ class RoomDetailFragment @Inject constructor( lifecycle.addObserver(ConferenceEventObserver(vectorBaseActivity, this::onBroadcastJitsiEvent)) super.onViewCreated(view, savedInstanceState) sharedActionViewModel = activityViewModelProvider.get(MessageSharedActionViewModel::class.java) + sharedActivityActionViewModel = activityViewModelProvider.get(RoomDetailSharedActionViewModel::class.java) knownCallsViewModel = activityViewModelProvider.get(SharedKnownCallsViewModel::class.java) attachmentsHelper = AttachmentsHelper(requireContext(), this).register() callActionsHandler = StartCallActionsHandler( - roomId = roomDetailArgs.roomId, + roomId = timelineArgs.roomId, fragment = this, vectorPreferences = vectorPreferences, - roomDetailViewModel = roomDetailViewModel, + timelineViewModel = timelineViewModel, callManager = callManager, startCallActivityResultLauncher = startCallActivityResultLauncher, showDialogWithMessage = ::showDialogWithMessage, @@ -382,8 +381,8 @@ class RoomDetailFragment @Inject constructor( setupRemoveJitsiWidgetView() setupVoiceMessageView() - views.roomToolbarContentView.debouncedClicks { - navigator.openRoomProfile(requireActivity(), roomDetailArgs.roomId) + views.includeRoomToolbar.roomToolbarContentView.debouncedClicks { + navigator.openRoomProfile(requireActivity(), timelineArgs.roomId) } sharedActionViewModel @@ -400,7 +399,7 @@ class RoomDetailFragment @Inject constructor( invalidateOptionsMenu() } - roomDetailViewModel.onEach(RoomDetailViewState::canShowJumpToReadMarker, RoomDetailViewState::unreadState) { _, _ -> + timelineViewModel.onEach(RoomDetailViewState::canShowJumpToReadMarker, RoomDetailViewState::unreadState) { _, _ -> updateJumpToReadMarkerViewVisibility() } @@ -417,7 +416,7 @@ class RoomDetailFragment @Inject constructor( } } - roomDetailViewModel.onEach( + timelineViewModel.onEach( RoomDetailViewState::syncState, RoomDetailViewState::incrementalSyncStatus, RoomDetailViewState::pushCounter @@ -447,7 +446,7 @@ class RoomDetailFragment @Inject constructor( }.exhaustive } - roomDetailViewModel.observeViewEvents { + timelineViewModel.observeViewEvents { when (it) { is RoomDetailViewEvents.Failure -> showErrorInSnackbar(it.throwable) is RoomDetailViewEvents.OnNewTimelineEvents -> scrollOnNewMessageCallback.addNewTimelineEventIds(it.eventIds) @@ -470,7 +469,7 @@ class RoomDetailFragment @Inject constructor( RoomDetailViewEvents.HideWaitingView -> vectorBaseActivity.hideWaitingView() is RoomDetailViewEvents.RequestNativeWidgetPermission -> requestNativeWidgetPermission(it) is RoomDetailViewEvents.OpenRoom -> handleOpenRoom(it) - RoomDetailViewEvents.OpenInvitePeople -> navigator.openInviteUsersToRoom(requireContext(), roomDetailArgs.roomId) + RoomDetailViewEvents.OpenInvitePeople -> navigator.openInviteUsersToRoom(requireContext(), timelineArgs.roomId) RoomDetailViewEvents.OpenSetRoomAvatarDialog -> galleryOrCameraDialogHelper.show() RoomDetailViewEvents.OpenRoomSettings -> handleOpenRoomSettings(RoomProfileActivity.EXTRA_DIRECT_ACCESS_ROOM_SETTINGS) RoomDetailViewEvents.OpenRoomProfile -> handleOpenRoomSettings() @@ -481,7 +480,6 @@ class RoomDetailFragment @Inject constructor( RoomDetailViewEvents.StopChatEffects -> handleStopChatEffects() is RoomDetailViewEvents.DisplayAndAcceptCall -> acceptIncomingCall(it) RoomDetailViewEvents.RoomReplacementStarted -> handleRoomReplacement() - is RoomDetailViewEvents.ShowLocation -> handleShowLocationPreview(it) }.exhaustive } @@ -514,12 +512,12 @@ class RoomDetailFragment @Inject constructor( private fun setupRemoveJitsiWidgetView() { views.removeJitsiWidgetView.onCompleteSliding = { - withState(roomDetailViewModel) { + withState(timelineViewModel) { val jitsiWidgetId = it.jitsiState.widgetId ?: return@withState if (it.jitsiState.hasJoined) { leaveJitsiConference() } - roomDetailViewModel.handle(RoomDetailAction.RemoveWidget(jitsiWidgetId)) + timelineViewModel.handle(RoomDetailAction.RemoveWidget(jitsiWidgetId)) } } } @@ -529,7 +527,7 @@ class RoomDetailFragment @Inject constructor( } private fun onBroadcastJitsiEvent(conferenceEvent: ConferenceEvent) { - roomDetailViewModel.handle(RoomDetailAction.UpdateJoinJitsiCallStatus(conferenceEvent)) + timelineViewModel.handle(RoomDetailAction.UpdateJoinJitsiCallStatus(conferenceEvent)) } private fun onCannotRecord() { @@ -555,7 +553,7 @@ class RoomDetailFragment @Inject constructor( private fun handleShowRoomUpgradeDialog(roomDetailViewEvents: MessageComposerViewEvents.ShowRoomUpgradeDialog) { val tag = MigrateRoomBottomSheet::javaClass.name - MigrateRoomBottomSheet.newInstance(roomDetailArgs.roomId, roomDetailViewEvents.newVersion) + MigrateRoomBottomSheet.newInstance(timelineArgs.roomId, roomDetailViewEvents.newVersion) .show(parentFragmentManager, tag) } @@ -590,7 +588,7 @@ class RoomDetailFragment @Inject constructor( override fun onImageReady(uri: Uri?) { uri ?: return - roomDetailViewModel.handle( + timelineViewModel.handle( RoomDetailAction.SetAvatarAction( newAvatarUri = uri, newAvatarFileName = getFilenameFromUri(requireContext(), uri) ?: UUID.randomUUID().toString() @@ -601,7 +599,7 @@ class RoomDetailFragment @Inject constructor( private fun handleOpenRoomSettings(directAccess: Int? = null) { navigator.openRoomProfile( requireContext(), - roomDetailArgs.roomId, + timelineArgs.roomId, directAccess ) } @@ -613,14 +611,15 @@ class RoomDetailFragment @Inject constructor( } } - private fun handleShowLocationPreview(viewEvent: RoomDetailViewEvents.ShowLocation) { + private fun handleShowLocationPreview(locationContent: MessageLocationContent, senderId: String) { + val isSelfLocation = locationContent.isSelfLocation() navigator .openLocationSharing( context = requireContext(), - roomId = roomDetailArgs.roomId, + roomId = timelineArgs.roomId, mode = LocationSharingMode.PREVIEW, - initialLocationData = viewEvent.locationData, - locationOwnerId = viewEvent.userId + initialLocationData = locationContent.toLocationData(), + locationOwnerId = if (isSelfLocation) senderId else null ) } @@ -634,13 +633,13 @@ class RoomDetailFragment @Inject constructor( WidgetArgs( baseUrl = it.domain, kind = WidgetKind.ROOM, - roomId = roomDetailArgs.roomId, + roomId = timelineArgs.roomId, widgetId = it.widget.widgetId ) ).apply { directListener = { granted -> if (granted) { - roomDetailViewModel.handle(RoomDetailAction.EnsureNativeWidgetAllowed( + timelineViewModel.handle(RoomDetailAction.EnsureNativeWidgetAllowed( widget = it.widget, userJustAccepted = true, grantedEvents = it.grantedEvents @@ -660,7 +659,7 @@ class RoomDetailFragment @Inject constructor( navigator.openIntegrationManager( context = requireContext(), activityResultLauncher = integrationManagerActivityResultLauncher, - roomId = roomDetailArgs.roomId, + roomId = timelineArgs.roomId, integId = null, screen = screen ) @@ -720,13 +719,13 @@ class RoomDetailFragment @Inject constructor( .setMessage(getString(R.string.event_status_delete_all_failed_dialog_message)) .setNegativeButton(R.string.no, null) .setPositiveButton(R.string.yes) { _, _ -> - roomDetailViewModel.handle(RoomDetailAction.RemoveAllFailedMessages) + timelineViewModel.handle(RoomDetailAction.RemoveAllFailedMessages) } .show() } override fun onRetryClicked() { - roomDetailViewModel.handle(RoomDetailAction.ResendAll) + timelineViewModel.handle(RoomDetailAction.ResendAll) } } } @@ -748,7 +747,7 @@ class RoomDetailFragment @Inject constructor( } override fun onVoiceRecordingCancelled() { - messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true)) + messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true, rootThreadEventId = getRootThreadEventId())) vibrate(requireContext()) updateRecordingUiState(RecordingUiState.Idle) } @@ -764,37 +763,42 @@ class RoomDetailFragment @Inject constructor( } override fun onSendVoiceMessage() { - messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = false)) + messageComposerViewModel.handle( + MessageComposerAction.EndRecordingVoiceMessage(isCancelled = false, rootThreadEventId = getRootThreadEventId())) updateRecordingUiState(RecordingUiState.Idle) } override fun onDeleteVoiceMessage() { - messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true)) + messageComposerViewModel.handle( + MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true, rootThreadEventId = getRootThreadEventId())) updateRecordingUiState(RecordingUiState.Idle) } override fun onRecordingLimitReached() { - messageComposerViewModel.handle(MessageComposerAction.PauseRecordingVoiceMessage) + messageComposerViewModel.handle( + MessageComposerAction.PauseRecordingVoiceMessage) updateRecordingUiState(RecordingUiState.Draft) } override fun onRecordingWaveformClicked() { - messageComposerViewModel.handle(MessageComposerAction.PauseRecordingVoiceMessage) + messageComposerViewModel.handle( + MessageComposerAction.PauseRecordingVoiceMessage) updateRecordingUiState(RecordingUiState.Draft) } private fun updateRecordingUiState(state: RecordingUiState) { - messageComposerViewModel.handle(MessageComposerAction.OnVoiceRecordingUiStateChanged(state)) + messageComposerViewModel.handle( + MessageComposerAction.OnVoiceRecordingUiStateChanged(state)) } } } private fun joinJitsiRoom(jitsiWidget: Widget, enableVideo: Boolean) { - navigator.openRoomWidget(requireContext(), roomDetailArgs.roomId, jitsiWidget, mapOf(JitsiCallViewModel.ENABLE_VIDEO_OPTION to enableVideo)) + navigator.openRoomWidget(requireContext(), timelineArgs.roomId, jitsiWidget, mapOf(JitsiCallViewModel.ENABLE_VIDEO_OPTION to enableVideo)) } private fun openStickerPicker(event: RoomDetailViewEvents.OpenStickerPicker) { - navigator.openStickerPicker(requireContext(), stickerActivityResultLauncher, roomDetailArgs.roomId, event.widget) + navigator.openStickerPicker(requireContext(), stickerActivityResultLauncher, timelineArgs.roomId, event.widget) } private fun startOpenFileIntent(action: RoomDetailViewEvents.OpenFile) { @@ -818,7 +822,7 @@ class RoomDetailFragment @Inject constructor( val safeContext = context ?: return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (!safeContext.packageManager.canRequestPackageInstalls()) { - roomDetailViewModel.pendingEvent = action + timelineViewModel.pendingEvent = action startInstallFromSourceIntent(safeContext, installApkActivityResultLauncher) } else { openFile(action) @@ -830,7 +834,7 @@ class RoomDetailFragment @Inject constructor( private val installApkActivityResultLauncher = registerStartForActivityResult { activityResult -> if (activityResult.resultCode == Activity.RESULT_OK) { - roomDetailViewModel.pendingEvent?.let { + timelineViewModel.pendingEvent?.let { if (it is RoomDetailViewEvents.OpenFile) { openFile(it) } @@ -838,7 +842,7 @@ class RoomDetailFragment @Inject constructor( } else { // User cancelled } - roomDetailViewModel.pendingEvent = null + timelineViewModel.pendingEvent = null } private fun displayPromptForIntegrationManager() { @@ -864,7 +868,7 @@ class RoomDetailFragment @Inject constructor( } private fun handleShareData() { - when (val sharedData = roomDetailArgs.sharedData) { + when (val sharedData = timelineArgs.sharedData) { is SharedData.Text -> { messageComposerViewModel.handle(MessageComposerAction.EnterRegularMode(sharedData.text, fromSharing = true)) } @@ -877,7 +881,7 @@ class RoomDetailFragment @Inject constructor( } private fun handleSpaceShare() { - roomDetailArgs.openShareSpaceForId?.let { spaceId -> + timelineArgs.openShareSpaceForId?.let { spaceId -> ShareSpaceBottomSheet.show(childFragmentManager, spaceId, true) view?.post { handleChatEffect(ChatEffect.CONFETTI) @@ -898,18 +902,18 @@ class RoomDetailFragment @Inject constructor( } override fun onDestroy() { - roomDetailViewModel.handle(RoomDetailAction.ExitTrackingUnreadMessagesState) + timelineViewModel.handle(RoomDetailAction.ExitTrackingUnreadMessagesState) super.onDestroy() } private fun setupJumpToBottomView() { views.jumpToBottomView.visibility = View.INVISIBLE views.jumpToBottomView.debouncedClicks { - roomDetailViewModel.handle(RoomDetailAction.ExitTrackingUnreadMessagesState) + timelineViewModel.handle(RoomDetailAction.ExitTrackingUnreadMessagesState) views.jumpToBottomView.visibility = View.INVISIBLE - if (!roomDetailViewModel.timeline.isLive) { + if (!timelineViewModel.timeline.isLive) { scrollOnNewMessageCallback.forceScrollOnNextUpdate() - roomDetailViewModel.timeline.restartWithEventId(null) + timelineViewModel.timeline.restartWithEventId(null) } else { layoutManager.scrollToPosition(0) } @@ -928,7 +932,7 @@ class RoomDetailFragment @Inject constructor( onJumpToReadMarkerClicked() } views.jumpToReadMarkerView.setOnCloseIconClickListener { - roomDetailViewModel.handle(RoomDetailAction.MarkAllAsRead) + timelineViewModel.handle(RoomDetailAction.MarkAllAsRead) } } @@ -971,11 +975,11 @@ class RoomDetailFragment @Inject constructor( private fun setupNotificationView() { views.notificationAreaView.delegate = object : NotificationAreaView.Delegate { override fun onTombstoneEventClicked() { - roomDetailViewModel.handle(RoomDetailAction.JoinAndOpenReplacementRoom) + timelineViewModel.handle(RoomDetailAction.JoinAndOpenReplacementRoom) } override fun onMisconfiguredEncryptionClicked() { - roomDetailViewModel.handle(RoomDetailAction.OnClickMisconfiguredEncryption) + timelineViewModel.handle(RoomDetailAction.OnClickMisconfiguredEncryption) } } } @@ -990,15 +994,23 @@ class RoomDetailFragment @Inject constructor( } val joinConfItem = menu.findItem(R.id.join_conference) (joinConfItem.actionView as? JoinConferenceView)?.onJoinClicked = { - roomDetailViewModel.handle(RoomDetailAction.JoinJitsiCall) + timelineViewModel.handle(RoomDetailAction.JoinJitsiCall) + } + + // Custom thread notification menu item + menu.findItem(R.id.menu_timeline_thread_list)?.let { menuItem -> + menuItem.actionView.setOnClickListener { + onOptionsItemSelected(menuItem) + } } } override fun onPrepareOptionsMenu(menu: Menu) { menu.forEach { - it.isVisible = roomDetailViewModel.isMenuItemVisible(it.itemId) + it.isVisible = timelineViewModel.isMenuItemVisible(it.itemId) } - withState(roomDetailViewModel) { state -> + + withState(timelineViewModel) { state -> // Set the visual state of the call buttons (voice/video) to enabled/disabled according to user permissions val hasCallInRoom = callManager.getCallsByRoomId(state.roomId).isNotEmpty() || state.jitsiState.hasJoined val callButtonsEnabled = !hasCallInRoom && when (state.asyncRoomSummary.invoke()?.joinedMembersCount) { @@ -1029,49 +1041,113 @@ class RoomDetailFragment @Inject constructor( actionView.findViewById(R.id.cart_badge).setTextOrHide("$widgetsCount") matrixAppsMenuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) } + + // Handle custom threads badge notification + updateMenuThreadNotificationBadge(menu, state) } } override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { - R.id.invite -> { - navigator.openInviteUsersToRoom(requireActivity(), roomDetailArgs.roomId) + R.id.invite -> { + navigator.openInviteUsersToRoom(requireActivity(), timelineArgs.roomId) true } - R.id.timeline_setting -> { - navigator.openRoomProfile(requireActivity(), roomDetailArgs.roomId) + R.id.timeline_setting -> { + navigator.openRoomProfile(requireActivity(), timelineArgs.roomId) true } - R.id.open_matrix_apps -> { - roomDetailViewModel.handle(RoomDetailAction.ManageIntegrations) + R.id.open_matrix_apps -> { + timelineViewModel.handle(RoomDetailAction.ManageIntegrations) true } - R.id.voice_call -> { + R.id.voice_call -> { callActionsHandler.onVoiceCallClicked() true } - R.id.video_call -> { + R.id.video_call -> { callActionsHandler.onVideoCallClicked() true } - R.id.search -> { + R.id.menu_timeline_thread_list -> { + navigateToThreadList() + true + } + R.id.search -> { handleSearchAction() true } - R.id.dev_tools -> { - navigator.openDevTools(requireContext(), roomDetailArgs.roomId) + R.id.dev_tools -> { + navigator.openDevTools(requireContext(), timelineArgs.roomId) true } - else -> super.onOptionsItemSelected(item) + R.id.menu_thread_timeline_copy_link -> { + getRootThreadEventId()?.let { + val permalink = session.permalinkService().createPermalink(timelineArgs.roomId, it) + copyToClipboard(requireContext(), permalink, false) + showSnackWithMessage(getString(R.string.copied_to_clipboard)) + } + true + } + R.id.menu_thread_timeline_view_in_room -> { + handleViewInRoomAction() + true + } + R.id.menu_thread_timeline_share -> { + getRootThreadEventId()?.let { + val permalink = session.permalinkService().createPermalink(timelineArgs.roomId, it) + shareText(requireContext(), permalink) + } + true + } + else -> super.onOptionsItemSelected(item) + } + } + + /** + * Update menu thread notification badge appropriately + */ + private fun updateMenuThreadNotificationBadge(menu: Menu, state: RoomDetailViewState) { + val menuThreadList = menu.findItem(R.id.menu_timeline_thread_list).actionView + val badgeFrameLayout = menuThreadList.findViewById(R.id.threadNotificationBadgeFrameLayout) + val badgeTextView = menuThreadList.findViewById(R.id.threadNotificationBadgeTextView) + + val unreadThreadMessages = state.threadNotificationBadgeState.numberOfLocalUnreadThreads + val userIsMentioned = state.threadNotificationBadgeState.isUserMentioned + + if (unreadThreadMessages > 0) { + badgeFrameLayout.isVisible = true + badgeTextView.text = unreadThreadMessages.toString() + val badgeDrawable = DrawableCompat.wrap(badgeFrameLayout.background) + val color = ContextCompat.getColor(requireContext(), if (userIsMentioned) R.color.palette_vermilion else R.color.palette_gray_200) + DrawableCompat.setTint(badgeDrawable, color) + badgeFrameLayout.background = badgeDrawable + } else { + badgeFrameLayout.isVisible = false + } + } + + /** + * View and highlight the original root thread message in the main timeline + */ + private fun handleViewInRoomAction() { + getRootThreadEventId()?.let { + val newRoom = timelineArgs.copy(threadTimelineArgs = null, eventId = it) + context?.let { con -> + val int = RoomDetailActivity.newIntent(con, newRoom) + int.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK + con.startActivity(int) + } } } private fun handleSearchAction() { - if (session.getRoom(roomDetailArgs.roomId)?.isEncrypted() == false) { - navigator.openSearch(requireContext(), roomDetailArgs.roomId) - } else { - showDialogWithMessage(getString(R.string.search_is_not_supported_in_e2e_room)) - } + navigator.openSearch( + context = requireContext(), + roomId = timelineArgs.roomId, + roomDisplayName = timelineViewModel.getRoomSummary()?.displayName, + roomAvatarUrl = timelineViewModel.getRoomSummary()?.avatarUrl + ) } private fun displayDisabledIntegrationDialog() { @@ -1151,7 +1227,7 @@ class RoomDetailFragment @Inject constructor( override fun onResume() { super.onResume() - notificationDrawerManager.setCurrentRoom(roomDetailArgs.roomId) + notificationDrawerManager.setCurrentRoom(timelineArgs.roomId) roomDetailPendingActionStore.data?.let { handlePendingAction(it) } roomDetailPendingActionStore.data = null @@ -1162,11 +1238,11 @@ class RoomDetailFragment @Inject constructor( private fun handlePendingAction(roomDetailPendingAction: RoomDetailPendingAction) { when (roomDetailPendingAction) { is RoomDetailPendingAction.JumpToReadReceipt -> - roomDetailViewModel.handle(RoomDetailAction.JumpToReadReceipt(roomDetailPendingAction.userId)) + timelineViewModel.handle(RoomDetailAction.JumpToReadReceipt(roomDetailPendingAction.userId)) is RoomDetailPendingAction.MentionUser -> insertUserDisplayNameInTextEditor(roomDetailPendingAction.userId) is RoomDetailPendingAction.OpenOrCreateDm -> - roomDetailViewModel.handle(RoomDetailAction.OpenOrCreateDm(roomDetailPendingAction.userId)) + timelineViewModel.handle(RoomDetailAction.OpenOrCreateDm(roomDetailPendingAction.userId)) is RoomDetailPendingAction.OpenRoom -> handleOpenRoom(RoomDetailViewEvents.OpenRoom(roomDetailPendingAction.roomId, roomDetailPendingAction.closeCurrentRoom)) }.exhaustive @@ -1219,7 +1295,7 @@ class RoomDetailFragment @Inject constructor( if (activityResult.resultCode == Activity.RESULT_OK) { val sendData = AttachmentsPreviewActivity.getOutput(data) val keepOriginalSize = AttachmentsPreviewActivity.getKeepOriginalSize(data) - roomDetailViewModel.handle(RoomDetailAction.SendMedia(sendData, !keepOriginalSize)) + timelineViewModel.handle(RoomDetailAction.SendMedia(sendData, !keepOriginalSize)) } } @@ -1228,7 +1304,7 @@ class RoomDetailFragment @Inject constructor( val eventId = EmojiReactionPickerActivity.getOutputEventId(activityResult.data) val reaction = EmojiReactionPickerActivity.getOutputReaction(activityResult.data) if (eventId != null && reaction != null) { - roomDetailViewModel.handle(RoomDetailAction.SendReaction(eventId, reaction)) + timelineViewModel.handle(RoomDetailAction.SendReaction(eventId, reaction)) } } } @@ -1238,16 +1314,16 @@ class RoomDetailFragment @Inject constructor( if (activityResult.resultCode == Activity.RESULT_OK) { WidgetActivity.getOutput(data).toModel() ?.let { content -> - roomDetailViewModel.handle(RoomDetailAction.SendSticker(content)) + timelineViewModel.handle(RoomDetailAction.SendSticker(content)) } } } private val startCallActivityResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> if (allGranted) { - (roomDetailViewModel.pendingAction as? RoomDetailAction.StartCall)?.let { - roomDetailViewModel.pendingAction = null - roomDetailViewModel.handle(it) + (timelineViewModel.pendingAction as? RoomDetailAction.StartCall)?.let { + timelineViewModel.pendingAction = null + timelineViewModel.handle(it) } } else { if (deniedPermanently) { @@ -1261,7 +1337,7 @@ class RoomDetailFragment @Inject constructor( private fun setupRecyclerView() { timelineEventController.callback = this - timelineEventController.timeline = roomDetailViewModel.timeline + timelineEventController.timeline = timelineViewModel.timeline views.timelineRecyclerView.trackItemsVisibilityChange() layoutManager = object : LinearLayoutManager(context, RecyclerView.VERTICAL, true) { @@ -1329,8 +1405,9 @@ class RoomDetailFragment @Inject constructor( } private fun updateJumpToReadMarkerViewVisibility() { + if (isThreadTimeLine()) return viewLifecycleOwner.lifecycleScope.launchWhenResumed { - val state = roomDetailViewModel.awaitState() + val state = timelineViewModel.awaitState() val showJumpToUnreadBanner = when (state.unreadState) { UnreadState.Unknown, UnreadState.HasNoUnread -> false @@ -1388,8 +1465,12 @@ class RoomDetailFragment @Inject constructor( views.composerLayout.callback = object : MessageComposerView.Callback { override fun onAddAttachment() { if (!::attachmentTypeSelector.isInitialized) { - attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@RoomDetailFragment) - attachmentTypeSelector.setAttachmentVisibility(AttachmentTypeSelectorView.Type.LOCATION, vectorPreferences.isLocationSharingEnabled()) + attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@TimelineFragment) + attachmentTypeSelector.setAttachmentVisibility( + AttachmentTypeSelectorView.Type.LOCATION, + vectorPreferences.isLocationSharingEnabled()) + attachmentTypeSelector.setAttachmentVisibility( + AttachmentTypeSelectorView.Type.POLL, !isThreadTimeLine()) } attachmentTypeSelector.show(views.composerLayout.views.attachmentButton) } @@ -1418,7 +1499,9 @@ class RoomDetailFragment @Inject constructor( return } if (text.isNotBlank()) { - analyticsTracker.capture(Click(name = Click.Name.SendMessageButton)) + withState(messageComposerViewModel) { state -> + analyticsTracker.capture(Composer(isThreadTimeLine(), isEditing = state.sendMode is SendMode.Edit, isReply = state.sendMode is SendMode.Reply)) + } // We collapse ASAP, if not there will be a slight annoying delay views.composerLayout.collapse(true) lockSendButton = true @@ -1428,6 +1511,7 @@ class RoomDetailFragment @Inject constructor( } private fun observerUserTyping() { + if (isThreadTimeLine()) return views.composerLayout.views.composerEditText.textChanges() .skipInitialValue() .debounce(300) @@ -1440,7 +1524,7 @@ class RoomDetailFragment @Inject constructor( views.composerLayout.views.composerEditText.focusChanges() .onEach { - roomDetailViewModel.handle(RoomDetailAction.ComposerFocusChange(it)) + timelineViewModel.handle(RoomDetailAction.ComposerFocusChange(it)) } .launchIn(viewLifecycleOwner.lifecycleScope) } @@ -1454,7 +1538,7 @@ class RoomDetailFragment @Inject constructor( return isHandled } - override fun invalidate() = withState(roomDetailViewModel, messageComposerViewModel) { mainState, messageComposerState -> + override fun invalidate() = withState(timelineViewModel, messageComposerViewModel) { mainState, messageComposerState -> invalidateOptionsMenu() val summary = mainState.asyncRoomSummary() renderToolbar(summary, mainState.formattedTypingUsers) @@ -1497,7 +1581,7 @@ class RoomDetailFragment @Inject constructor( } else if (summary?.membership == Membership.INVITE && inviter != null) { views.hideComposerViews() lazyLoadedViews.inviteView(true)?.apply { - callback = this@RoomDetailFragment + callback = this@TimelineFragment isVisible = true render(inviter, VectorInviteView.Mode.LARGE, mainState.changeMembershipState) setOnClickListener(null) @@ -1508,29 +1592,43 @@ class RoomDetailFragment @Inject constructor( } } - private fun FragmentRoomDetailBinding.hideComposerViews() { + private fun FragmentTimelineBinding.hideComposerViews() { composerLayout.isVisible = false voiceMessageRecorderView.isVisible = false } private fun renderToolbar(roomSummary: RoomSummary?, typingMessage: String?) { - if (roomSummary == null) { - views.roomToolbarContentView.isClickable = false + if (!isThreadTimeLine()) { + views.includeRoomToolbar.roomToolbarContentView.isVisible = true + views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = false + if (roomSummary == null) { + views.includeRoomToolbar.roomToolbarContentView.isClickable = false + } else { + views.includeRoomToolbar.roomToolbarContentView.isClickable = roomSummary.membership == Membership.JOIN + views.includeRoomToolbar.roomToolbarTitleView.text = roomSummary.displayName + avatarRenderer.render(roomSummary.toMatrixItem(), views.includeRoomToolbar.roomToolbarAvatarImageView) + renderSubTitle(typingMessage, roomSummary.topic) + views.includeRoomToolbar.roomToolbarDecorationImageView.render(roomSummary.roomEncryptionTrustLevel) + views.includeRoomToolbar.roomToolbarPresenceImageView.render(roomSummary.isDirect, roomSummary.directUserPresence) + views.includeRoomToolbar.roomToolbarPublicImageView.isVisible = roomSummary.isPublic && !roomSummary.isDirect + } } else { - views.roomToolbarContentView.isClickable = roomSummary.membership == Membership.JOIN - views.roomToolbarTitleView.text = roomSummary.displayName - avatarRenderer.render(roomSummary.toMatrixItem(), views.roomToolbarAvatarImageView) - renderSubTitle(typingMessage, roomSummary.topic) - views.roomToolbarDecorationImageView.render(roomSummary.roomEncryptionTrustLevel) - views.roomToolbarPresenceImageView.render(roomSummary.isDirect, roomSummary.directUserPresence) - views.roomToolbarPublicImageView.isVisible = roomSummary.isPublic && !roomSummary.isDirect + views.includeRoomToolbar.roomToolbarContentView.isVisible = false + views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = true + timelineArgs.threadTimelineArgs?.let { + val matrixItem = MatrixItem.RoomItem(it.roomId, it.displayName, it.avatarUrl) + avatarRenderer.render(matrixItem, views.includeThreadToolbar.roomToolbarThreadImageView) + views.includeThreadToolbar.roomToolbarThreadShieldImageView.render(it.roomEncryptionTrustLevel) + views.includeThreadToolbar.roomToolbarThreadSubtitleTextView.text = it.displayName + } + views.includeThreadToolbar.roomToolbarThreadTitleTextView.text = resources.getText(R.string.thread_timeline_title) } } private fun renderSubTitle(typingMessage: String?, topic: String) { // TODO Temporary place to put typing data val subtitle = typingMessage?.takeIf { it.isNotBlank() } ?: topic - views.roomToolbarSubtitleView.apply { + views.includeRoomToolbar.roomToolbarSubtitleView.apply { setTextOrHide(subtitle) if (typingMessage.isNullOrBlank()) { setTextColor(colorProvider.getColorFromAttribute(R.attr.vctr_content_secondary)) @@ -1544,27 +1642,30 @@ class RoomDetailFragment @Inject constructor( private fun renderSendMessageResult(sendMessageResult: MessageComposerViewEvents.SendMessageResult) { when (sendMessageResult) { - is MessageComposerViewEvents.SlashCommandLoading -> { + is MessageComposerViewEvents.SlashCommandLoading -> { showLoading(null) } - is MessageComposerViewEvents.SlashCommandError -> { + is MessageComposerViewEvents.SlashCommandError -> { displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command)) } - is MessageComposerViewEvents.SlashCommandUnknown -> { + is MessageComposerViewEvents.SlashCommandUnknown -> { displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command)) } - is MessageComposerViewEvents.SlashCommandResultOk -> { + is MessageComposerViewEvents.SlashCommandResultOk -> { dismissLoadingDialog() views.composerLayout.setTextIfDifferent("") sendMessageResult.messageRes?.let { showSnackWithMessage(getString(it)) } } - is MessageComposerViewEvents.SlashCommandResultError -> { + is MessageComposerViewEvents.SlashCommandResultError -> { dismissLoadingDialog() displayCommandError(errorFormatter.toHumanReadable(sendMessageResult.throwable)) } - is MessageComposerViewEvents.SlashCommandNotImplemented -> { + is MessageComposerViewEvents.SlashCommandNotImplemented -> { displayCommandError(getString(R.string.not_implemented)) } + is MessageComposerViewEvents.SlashCommandNotSupportedInThreads -> { + displayCommandError(getString(R.string.command_not_supported_in_threads, sendMessageResult.command.command)) + } } // .exhaustive lockSendButton = false @@ -1602,7 +1703,7 @@ class RoomDetailFragment @Inject constructor( .setView(layout) .setPositiveButton(R.string.report_content_custom_submit) { _, _ -> val reason = views.dialogReportContentInput.text.toString() - roomDetailViewModel.handle(RoomDetailAction.ReportContent(action.eventId, action.senderId, reason)) + timelineViewModel.handle(RoomDetailAction.ReportContent(action.eventId, action.senderId, reason)) } .setNegativeButton(R.string.action_cancel, null) .show() @@ -1618,7 +1719,7 @@ class RoomDetailFragment @Inject constructor( reasonHintRes = R.string.delete_event_dialog_reason_hint, titleRes = action.dialogTitleRes ) { reason -> - roomDetailViewModel.handle(RoomDetailAction.RedactAction(action.eventId, reason)) + timelineViewModel.handle(RoomDetailAction.RedactAction(action.eventId, reason)) } } @@ -1640,7 +1741,7 @@ class RoomDetailFragment @Inject constructor( .setMessage(R.string.content_reported_as_spam_content) .setPositiveButton(R.string.ok, null) .setNegativeButton(R.string.block_user) { _, _ -> - roomDetailViewModel.handle(RoomDetailAction.IgnoreUser(data.senderId)) + timelineViewModel.handle(RoomDetailAction.IgnoreUser(data.senderId)) } .show() } @@ -1650,7 +1751,7 @@ class RoomDetailFragment @Inject constructor( .setMessage(R.string.content_reported_as_inappropriate_content) .setPositiveButton(R.string.ok, null) .setNegativeButton(R.string.block_user) { _, _ -> - roomDetailViewModel.handle(RoomDetailAction.IgnoreUser(data.senderId)) + timelineViewModel.handle(RoomDetailAction.IgnoreUser(data.senderId)) } .show() } @@ -1660,7 +1761,7 @@ class RoomDetailFragment @Inject constructor( .setMessage(R.string.content_reported_content) .setPositiveButton(R.string.ok, null) .setNegativeButton(R.string.block_user) { _, _ -> - roomDetailViewModel.handle(RoomDetailAction.IgnoreUser(data.senderId)) + timelineViewModel.handle(RoomDetailAction.IgnoreUser(data.senderId)) } .show() } @@ -1669,14 +1770,14 @@ class RoomDetailFragment @Inject constructor( is RoomDetailAction.RequestVerification -> { Timber.v("## SAS RequestVerification action") VerificationBottomSheet.withArgs( - roomDetailArgs.roomId, + timelineArgs.roomId, data.userId ).show(parentFragmentManager, "REQ") } is RoomDetailAction.AcceptVerificationRequest -> { Timber.v("## SAS AcceptVerificationRequest action") VerificationBottomSheet.withArgs( - roomDetailArgs.roomId, + timelineArgs.roomId, data.otherUserId, data.transactionId ).show(parentFragmentManager, "REQ") @@ -1687,7 +1788,7 @@ class RoomDetailFragment @Inject constructor( setArguments(VerificationBottomSheet.VerificationArgs( otherUserId = otherUserId, verificationId = data.transactionId, - roomId = roomDetailArgs.roomId + roomId = timelineArgs.roomId )) }.show(parentFragmentManager, "REQ") } @@ -1695,25 +1796,39 @@ class RoomDetailFragment @Inject constructor( } // TimelineEventController.Callback ************************************************************ - override fun onUrlClicked(url: String, title: String): Boolean { viewLifecycleOwner.lifecycleScope.launch { val isManaged = permalinkHandler .launch(requireActivity(), url, object : NavigationInterceptor { - override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?): Boolean { + override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?, rootThreadEventId: String?): Boolean { // Same room? - if (roomId == roomDetailArgs.roomId) { - // Navigation to same room - if (eventId == null) { + if (roomId != timelineArgs.roomId) return false + // Navigation to same room + if (!isThreadTimeLine()) { + if (rootThreadEventId != null && userPreferencesProvider.areThreadMessagesEnabled()) { + // Thread link, so PermalinkHandler will handle the navigation + return false + } + return if (eventId == null) { showSnackWithMessage(getString(R.string.navigate_to_room_when_already_in_the_room)) + true } else { // Highlight and scroll to this event - roomDetailViewModel.handle(RoomDetailAction.NavigateToEvent(eventId, true)) + timelineViewModel.handle(RoomDetailAction.NavigateToEvent(eventId, true)) + true + } + } else { + return if (rootThreadEventId == getRootThreadEventId() && eventId == null) { + showSnackWithMessage(getString(R.string.navigate_to_thread_when_already_in_the_thread)) + true + } else if (rootThreadEventId == getRootThreadEventId() && eventId != null) { + // we are in the same thread + timelineViewModel.handle(RoomDetailAction.NavigateToEvent(eventId, true)) + true + } else { + false } - return true } - // Not handled - return false } override fun navToMemberProfile(userId: String, deepLink: Uri): Boolean { @@ -1755,11 +1870,11 @@ class RoomDetailFragment @Inject constructor( } override fun onEventVisible(event: TimelineEvent) { - roomDetailViewModel.handle(RoomDetailAction.TimelineEventTurnsVisible(event)) + timelineViewModel.handle(RoomDetailAction.TimelineEventTurnsVisible(event)) } override fun onEventInvisible(event: TimelineEvent) { - roomDetailViewModel.handle(RoomDetailAction.TimelineEventTurnsInvisible(event)) + timelineViewModel.handle(RoomDetailAction.TimelineEventTurnsInvisible(event)) } override fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View) { @@ -1769,7 +1884,7 @@ class RoomDetailFragment @Inject constructor( override fun onImageMessageClicked(messageImageContent: MessageImageInfoContent, mediaData: ImageContentRenderer.Data, view: View) { navigator.openMediaViewer( activity = requireActivity(), - roomId = roomDetailArgs.roomId, + roomId = timelineArgs.roomId, mediaData = mediaData, view = view ) { pairs -> @@ -1781,7 +1896,7 @@ class RoomDetailFragment @Inject constructor( override fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View) { navigator.openMediaViewer( activity = requireActivity(), - roomId = roomDetailArgs.roomId, + roomId = timelineArgs.roomId, mediaData = mediaData, view = view ) { pairs -> @@ -1790,54 +1905,51 @@ class RoomDetailFragment @Inject constructor( } } -// override fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent) { -// val isEncrypted = messageFileContent.encryptedFileInfo != null -// val action = RoomDetailAction.DownloadOrOpen(eventId, messageFileContent, isEncrypted) -// // We need WRITE_EXTERNAL permission -// // if (!isEncrypted || checkPermissions(PERMISSIONS_FOR_WRITING_FILES, this, PERMISSION_REQUEST_CODE_DOWNLOAD_FILE)) { -// showSnackWithMessage(getString(R.string.downloading_file, messageFileContent.getFileName())) -// roomDetailViewModel.handle(action) -// // } else { -// // roomDetailViewModel.pendingAction = action -// // } -// } - private fun cleanUpAfterPermissionNotGranted() { // Reset all pending data - roomDetailViewModel.pendingAction = null + timelineViewModel.pendingAction = null attachmentsHelper.pendingType = null } -// override fun onAudioMessageClicked(messageAudioContent: MessageAudioContent) { -// vectorBaseActivity.notImplemented("open audio file") -// } - override fun onLoadMore(direction: Timeline.Direction) { - roomDetailViewModel.handle(RoomDetailAction.LoadMoreTimelineEvents(direction)) + timelineViewModel.handle(RoomDetailAction.LoadMoreTimelineEvents(direction)) } - override fun onEventCellClicked(informationData: MessageInformationData, messageContent: Any?, view: View) { + override fun onAddMoreReaction(event: TimelineEvent) { + openEmojiReactionPicker(event.eventId) + } + + override fun onEventCellClicked(informationData: MessageInformationData, messageContent: Any?, view: View, isRootThreadEvent: Boolean) { when (messageContent) { is MessageVerificationRequestContent -> { - roomDetailViewModel.handle(RoomDetailAction.ResumeVerification(informationData.eventId, null)) + timelineViewModel.handle(RoomDetailAction.ResumeVerification(informationData.eventId, null)) } is MessageWithAttachmentContent -> { val action = RoomDetailAction.DownloadOrOpen(informationData.eventId, informationData.senderId, messageContent) - roomDetailViewModel.handle(action) + timelineViewModel.handle(action) } is EncryptedEventContent -> { - roomDetailViewModel.handle(RoomDetailAction.TapOnFailedToDecrypt(informationData.eventId)) + timelineViewModel.handle(RoomDetailAction.TapOnFailedToDecrypt(informationData.eventId)) + } + is MessageLocationContent -> { + handleShowLocationPreview(messageContent, informationData.senderId) + } + else -> { + val handled = onThreadSummaryClicked(informationData.eventId, isRootThreadEvent) + if (!handled) { + Timber.d("No click action defined for this message content") + } } } } override fun onEventLongClicked(informationData: MessageInformationData, messageContent: Any?, view: View): Boolean { view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - val roomId = roomDetailArgs.roomId + val roomId = timelineArgs.roomId this.view?.hideKeyboard() MessageActionsBottomSheet - .newInstance(roomId, informationData) + .newInstance(roomId, informationData, isThreadTimeLine()) .show(requireActivity().supportFragmentManager, "MESSAGE_CONTEXTUAL_ACTIONS") return true @@ -1845,26 +1957,35 @@ class RoomDetailFragment @Inject constructor( private fun handleCancelSend(action: EventSharedAction.Cancel) { if (action.force) { - roomDetailViewModel.handle(RoomDetailAction.CancelSend(action.eventId, true)) + timelineViewModel.handle(RoomDetailAction.CancelSend(action.eventId, true)) } else { MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.dialog_title_confirmation) .setMessage(getString(R.string.event_status_cancel_sending_dialog_message)) .setNegativeButton(R.string.no, null) .setPositiveButton(R.string.yes) { _, _ -> - roomDetailViewModel.handle(RoomDetailAction.CancelSend(action.eventId, false)) + timelineViewModel.handle(RoomDetailAction.CancelSend(action.eventId, false)) } .show() } } + override fun onThreadSummaryClicked(eventId: String, isRootThreadEvent: Boolean): Boolean { + return if (vectorPreferences.areThreadMessagesEnabled() && isRootThreadEvent && !isThreadTimeLine()) { + navigateToThreadTimeline(eventId) + true + } else { + false + } + } + override fun onAvatarClicked(informationData: MessageInformationData) { // roomDetailViewModel.handle(RoomDetailAction.RequestVerification(informationData.userId)) openRoomMemberProfile(informationData.senderId) } private fun openRoomMemberProfile(userId: String) { - navigator.openRoomMemberProfile(userId = userId, roomId = roomDetailArgs.roomId, context = requireActivity()) + navigator.openRoomMemberProfile(userId = userId, roomId = timelineArgs.roomId, context = requireActivity()) } override fun onMemberNameClicked(informationData: MessageInformationData) { @@ -1874,36 +1995,36 @@ class RoomDetailFragment @Inject constructor( override fun onClickOnReactionPill(informationData: MessageInformationData, reaction: String, on: Boolean) { if (on) { // we should test the current real state of reaction on this event - roomDetailViewModel.handle(RoomDetailAction.SendReaction(informationData.eventId, reaction)) + timelineViewModel.handle(RoomDetailAction.SendReaction(informationData.eventId, reaction)) } else { // I need to redact a reaction - roomDetailViewModel.handle(RoomDetailAction.UndoReaction(informationData.eventId, reaction)) + timelineViewModel.handle(RoomDetailAction.UndoReaction(informationData.eventId, reaction)) } } override fun onLongClickOnReactionPill(informationData: MessageInformationData, reaction: String) { - ViewReactionsBottomSheet.newInstance(roomDetailArgs.roomId, informationData) + ViewReactionsBottomSheet.newInstance(timelineArgs.roomId, informationData) .show(requireActivity().supportFragmentManager, "DISPLAY_REACTIONS") } override fun onEditedDecorationClicked(informationData: MessageInformationData) { - ViewEditHistoryBottomSheet.newInstance(roomDetailArgs.roomId, informationData) + ViewEditHistoryBottomSheet.newInstance(timelineArgs.roomId, informationData) .show(requireActivity().supportFragmentManager, "DISPLAY_EDITS") } override fun onTimelineItemAction(itemAction: RoomDetailAction) { - roomDetailViewModel.handle(itemAction) + timelineViewModel.handle(itemAction) } override fun getPreviewUrlRetriever(): PreviewUrlRetriever { - return roomDetailViewModel.previewUrlRetriever + return timelineViewModel.previewUrlRetriever } override fun onRoomCreateLinkClicked(url: String) { viewLifecycleOwner.lifecycleScope.launchWhenResumed { permalinkHandler .launch(requireContext(), url, object : NavigationInterceptor { - override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?): Boolean { + override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?, rootThreadEventId: String?): Boolean { requireActivity().finish() return false } @@ -1917,7 +2038,7 @@ class RoomDetailFragment @Inject constructor( } override fun onReadMarkerVisible() { - roomDetailViewModel.handle(RoomDetailAction.EnterTrackingUnreadMessagesState) + timelineViewModel.handle(RoomDetailAction.EnterTrackingUnreadMessagesState) } override fun onPreviewUrlClicked(url: String) { @@ -1925,7 +2046,7 @@ class RoomDetailFragment @Inject constructor( } override fun onPreviewUrlCloseClicked(eventId: String, url: String) { - roomDetailViewModel.handle(RoomDetailAction.DoNotShowPreviewUrlFor(eventId, url)) + timelineViewModel.handle(RoomDetailAction.DoNotShowPreviewUrlFor(eventId, url)) } override fun onPreviewUrlImageClicked(sharedView: View?, mxcUrl: String?, title: String?) { @@ -1940,7 +2061,7 @@ class RoomDetailFragment @Inject constructor( when (action.messageContent) { is MessageTextContent -> shareText(requireContext(), action.messageContent.body) is MessageLocationContent -> { - LocationData.create(action.messageContent.getUri())?.let { + action.messageContent.toLocationData()?.let { openLocation(requireActivity(), it.latitude, it.longitude) } } @@ -2002,10 +2123,10 @@ class RoomDetailFragment @Inject constructor( openRoomMemberProfile(action.userId) } is EventSharedAction.AddReaction -> { - emojiActivityResultLauncher.launch(EmojiReactionPickerActivity.intent(requireContext(), action.eventId)) + openEmojiReactionPicker(action.eventId) } is EventSharedAction.ViewReactions -> { - ViewReactionsBottomSheet.newInstance(roomDetailArgs.roomId, action.messageInformationData) + ViewReactionsBottomSheet.newInstance(timelineArgs.roomId, action.messageInformationData) .show(requireActivity().supportFragmentManager, "DISPLAY_REACTIONS") } is EventSharedAction.Copy -> { @@ -2041,11 +2162,11 @@ class RoomDetailFragment @Inject constructor( } is EventSharedAction.QuickReact -> { // eventId,ClickedOn,Add - roomDetailViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add)) + timelineViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add)) } is EventSharedAction.Edit -> { if (action.eventType == EventType.POLL_START) { - navigator.openCreatePoll(requireContext(), roomDetailArgs.roomId, action.eventId, PollMode.EDIT) + navigator.openCreatePoll(requireContext(), timelineArgs.roomId, action.eventId, PollMode.EDIT) } else if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) { messageComposerViewModel.handle(MessageComposerAction.EnterEditMode(action.eventId, views.composerLayout.text.toString())) } else { @@ -2062,26 +2183,40 @@ class RoomDetailFragment @Inject constructor( requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) } } + is EventSharedAction.ReplyInThread -> { + if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) { + navigateToThreadTimeline(action.eventId) + } else { + requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) + } + } + is EventSharedAction.ViewInRoom -> { + if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) { + handleViewInRoomAction() + } else { + requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) + } + } is EventSharedAction.CopyPermalink -> { - val permalink = session.permalinkService().createPermalink(roomDetailArgs.roomId, action.eventId) + val permalink = session.permalinkService().createPermalink(timelineArgs.roomId, action.eventId) copyToClipboard(requireContext(), permalink, false) showSnackWithMessage(getString(R.string.copied_to_clipboard)) } is EventSharedAction.Resend -> { - roomDetailViewModel.handle(RoomDetailAction.ResendMessage(action.eventId)) + timelineViewModel.handle(RoomDetailAction.ResendMessage(action.eventId)) } is EventSharedAction.Remove -> { - roomDetailViewModel.handle(RoomDetailAction.RemoveFailedEcho(action.eventId)) + timelineViewModel.handle(RoomDetailAction.RemoveFailedEcho(action.eventId)) } is EventSharedAction.Cancel -> { handleCancelSend(action) } is EventSharedAction.ReportContentSpam -> { - roomDetailViewModel.handle(RoomDetailAction.ReportContent( + timelineViewModel.handle(RoomDetailAction.ReportContent( action.eventId, action.senderId, "This message is spam", spam = true)) } is EventSharedAction.ReportContentInappropriate -> { - roomDetailViewModel.handle(RoomDetailAction.ReportContent( + timelineViewModel.handle(RoomDetailAction.ReportContent( action.eventId, action.senderId, "This message is inappropriate", inappropriate = true)) } is EventSharedAction.ReportContentCustom -> { @@ -2097,7 +2232,7 @@ class RoomDetailFragment @Inject constructor( onUrlLongClicked(action.url) } is EventSharedAction.ReRequestKey -> { - roomDetailViewModel.handle(RoomDetailAction.ReRequestKeys(action.eventId)) + timelineViewModel.handle(RoomDetailAction.ReRequestKeys(action.eventId)) } is EventSharedAction.UseKeyBackup -> { context?.let { @@ -2110,13 +2245,17 @@ class RoomDetailFragment @Inject constructor( } } + private fun openEmojiReactionPicker(eventId: String) { + emojiActivityResultLauncher.launch(EmojiReactionPickerActivity.intent(requireContext(), eventId)) + } + private fun askConfirmationToEndPoll(eventId: String) { MaterialAlertDialogBuilder(requireContext(), R.style.ThemeOverlay_Vector_MaterialAlertDialog) .setTitle(R.string.end_poll_confirmation_title) .setMessage(R.string.end_poll_confirmation_description) .setNegativeButton(R.string.action_cancel, null) .setPositiveButton(R.string.end_poll_confirmation_approve_button) { _, _ -> - roomDetailViewModel.handle(RoomDetailAction.EndPoll(eventId)) + timelineViewModel.handle(RoomDetailAction.EndPoll(eventId)) } .show() } @@ -2127,7 +2266,7 @@ class RoomDetailFragment @Inject constructor( .setMessage(R.string.room_participants_action_ignore_prompt_msg) .setNegativeButton(R.string.action_cancel, null) .setPositiveButton(R.string.room_participants_action_ignore) { _, _ -> - roomDetailViewModel.handle(RoomDetailAction.IgnoreUser(senderId)) + timelineViewModel.handle(RoomDetailAction.IgnoreUser(senderId)) } .show() } @@ -2147,7 +2286,7 @@ class RoomDetailFragment @Inject constructor( views.composerLayout.views.composerEditText.setText(Command.EMOTE.command + " ") views.composerLayout.views.composerEditText.setSelection(Command.EMOTE.command.length + 1) } else { - val roomMember = roomDetailViewModel.getMember(userId) + val roomMember = timelineViewModel.getMember(userId) // TODO move logic outside of fragment (roomMember?.displayName ?: userId) .let { sanitizeDisplayName(it) } @@ -2200,29 +2339,60 @@ class RoomDetailFragment @Inject constructor( .show() } -// VectorInviteView.Callback + /** + * Navigate to Threads timeline for the specified rootThreadEventId + * using the ThreadsActivity + */ + private fun navigateToThreadTimeline(rootThreadEventId: String) { + context?.let { + val roomThreadDetailArgs = ThreadTimelineArgs( + roomId = timelineArgs.roomId, + displayName = timelineViewModel.getRoomSummary()?.displayName, + avatarUrl = timelineViewModel.getRoomSummary()?.avatarUrl, + roomEncryptionTrustLevel = timelineViewModel.getRoomSummary()?.roomEncryptionTrustLevel, + rootThreadEventId = rootThreadEventId) + navigator.openThread(it, roomThreadDetailArgs) + } + } + + /** + * Navigate to Threads list for the current room + * using the ThreadsActivity + */ + + private fun navigateToThreadList() { + context?.let { + val roomThreadDetailArgs = ThreadTimelineArgs( + roomId = timelineArgs.roomId, + displayName = timelineViewModel.getRoomSummary()?.displayName, + roomEncryptionTrustLevel = timelineViewModel.getRoomSummary()?.roomEncryptionTrustLevel, + avatarUrl = timelineViewModel.getRoomSummary()?.avatarUrl) + navigator.openThreadList(it, roomThreadDetailArgs) + } + } + + // VectorInviteView.Callback override fun onAcceptInvite() { - notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) } - roomDetailViewModel.handle(RoomDetailAction.AcceptInvite) + notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(timelineArgs.roomId) } + timelineViewModel.handle(RoomDetailAction.AcceptInvite) } override fun onRejectInvite() { - notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) } - roomDetailViewModel.handle(RoomDetailAction.RejectInvite) + notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(timelineArgs.roomId) } + timelineViewModel.handle(RoomDetailAction.RejectInvite) } - private fun onJumpToReadMarkerClicked() = withState(roomDetailViewModel) { + private fun onJumpToReadMarkerClicked() = withState(timelineViewModel) { if (it.unreadState is UnreadState.HasUnread) { - roomDetailViewModel.handle(RoomDetailAction.NavigateToEvent(it.unreadState.firstUnreadEventId, false)) + timelineViewModel.handle(RoomDetailAction.NavigateToEvent(it.unreadState.firstUnreadEventId, false)) } if (it.unreadState is UnreadState.ReadMarkerNotLoaded) { - roomDetailViewModel.handle(RoomDetailAction.NavigateToEvent(it.unreadState.readMarkerId, false)) + timelineViewModel.handle(RoomDetailAction.NavigateToEvent(it.unreadState.readMarkerId, false)) } } -// AttachmentTypeSelectorView.Callback - + // AttachmentTypeSelectorView.Callback private val typeSelectedActivityResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> if (allGranted) { val pendingType = attachmentsHelper.pendingType @@ -2257,13 +2427,13 @@ class RoomDetailFragment @Inject constructor( AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher) AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher) AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher) - AttachmentTypeSelectorView.Type.STICKER -> roomDetailViewModel.handle(RoomDetailAction.SelectStickerAttachment) - AttachmentTypeSelectorView.Type.POLL -> navigator.openCreatePoll(requireContext(), roomDetailArgs.roomId, null, PollMode.CREATE) + AttachmentTypeSelectorView.Type.STICKER -> timelineViewModel.handle(RoomDetailAction.SelectStickerAttachment) + AttachmentTypeSelectorView.Type.POLL -> navigator.openCreatePoll(requireContext(), timelineArgs.roomId, null, PollMode.CREATE) AttachmentTypeSelectorView.Type.LOCATION -> { navigator .openLocationSharing( context = requireContext(), - roomId = roomDetailArgs.roomId, + roomId = timelineArgs.roomId, mode = LocationSharingMode.STATIC_SHARING, initialLocationData = null, locationOwnerId = session.myUserId @@ -2272,13 +2442,12 @@ class RoomDetailFragment @Inject constructor( }.exhaustive } -// AttachmentsHelper.Callback - + // AttachmentsHelper.Callback override fun onContentAttachmentsReady(attachments: List) { val grouped = attachments.toGroupedContentAttachmentData() if (grouped.notPreviewables.isNotEmpty()) { // Send the not previewable attachments right now (?) - roomDetailViewModel.handle(RoomDetailAction.SendMedia(grouped.notPreviewables, false)) + timelineViewModel.handle(RoomDetailAction.SendMedia(grouped.notPreviewables, false)) } if (grouped.previewables.isNotEmpty()) { val intent = AttachmentsPreviewActivity.newIntent(requireContext(), AttachmentsPreviewArgs(grouped.previewables)) @@ -2316,4 +2485,14 @@ class RoomDetailFragment @Inject constructor( } } } + + /** + * Returns true if the current room is a Thread room, false otherwise + */ + private fun isThreadTimeLine(): Boolean = timelineArgs.threadTimelineArgs?.rootThreadEventId != null + + /** + * Returns the root thread event if we are in a thread room, otherwise returns null + */ + fun getRootThreadEventId(): String? = timelineArgs.threadTimelineArgs?.rootThreadEventId } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt similarity index 91% rename from vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt rename to vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt index 6e14b0fc76..a404f9136b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt @@ -53,7 +53,6 @@ import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandle import im.vector.app.features.home.room.detail.timeline.factory.TimelineFactory import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever import im.vector.app.features.home.room.typing.TypingHelper -import im.vector.app.features.location.LocationData import im.vector.app.features.powerlevel.PowerLevelsFlowFactory import im.vector.app.features.session.coroutineScope import im.vector.app.features.settings.VectorDataStore @@ -79,6 +78,7 @@ import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.events.model.EventType 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.isAttachmentMessage import org.matrix.android.sdk.api.session.events.model.isTextMessage import org.matrix.android.sdk.api.session.events.model.toContent @@ -91,11 +91,14 @@ import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.message.getFileUrl +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent import org.matrix.android.sdk.api.session.room.model.tombstone.RoomTombstoneContent 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 org.matrix.android.sdk.api.session.threads.ThreadNotificationBadgeState +import org.matrix.android.sdk.api.session.threads.ThreadNotificationState import org.matrix.android.sdk.api.session.widgets.model.WidgetType import org.matrix.android.sdk.api.util.toOptional import org.matrix.android.sdk.flow.flow @@ -104,7 +107,7 @@ import org.matrix.android.sdk.internal.crypto.model.event.WithHeldCode import timber.log.Timber import java.util.concurrent.atomic.AtomicBoolean -class RoomDetailViewModel @AssistedInject constructor( +class TimelineViewModel @AssistedInject constructor( @Assisted private val initialState: RoomDetailViewState, private val vectorPreferences: VectorPreferences, private val vectorDataStore: VectorDataStore, @@ -130,7 +133,7 @@ class RoomDetailViewModel @AssistedInject constructor( private val invisibleEventsSource = BehaviorDataSource() private val visibleEventsSource = BehaviorDataSource() private var timelineEvents = MutableSharedFlow>(0) - val timeline = timelineFactory.createTimeline(viewModelScope, room, eventId) + val timeline = timelineFactory.createTimeline(viewModelScope, room, eventId, initialState.rootThreadEventId) // Same lifecycle than the ViewModel (survive to screen rotation) val previewUrlRetriever = PreviewUrlRetriever(session, viewModelScope) @@ -147,16 +150,16 @@ class RoomDetailViewModel @AssistedInject constructor( private var prepareToEncrypt: Async = Uninitialized @AssistedFactory - interface Factory : MavericksAssistedViewModelFactory { - override fun create(initialState: RoomDetailViewState): RoomDetailViewModel + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: RoomDetailViewState): TimelineViewModel } - companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() { + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() { const val PAGINATION_COUNT = 50 } init { - timeline.start() + timeline.start(initialState.rootThreadEventId) timeline.addListener(this) observeRoomSummary() observeMembershipChanges() @@ -204,6 +207,17 @@ class RoomDetailViewModel @AssistedInject constructor( } } } + + // Threads + initThreads() + } + + /** + * Threads specific initialization + */ + private fun initThreads() { + markThreadTimelineAsReadLocal() + observeLocalThreadNotifications() } private fun observeDataStore() { @@ -317,8 +331,40 @@ class RoomDetailViewModel @AssistedInject constructor( .launchIn(viewModelScope) } + /** + * Mark the thread as read, while the user navigated within the thread + * This is a local implementation has nothing to do with APIs + */ + private fun markThreadTimelineAsReadLocal() { + initialState.rootThreadEventId?.let { + session.coroutineScope.launch { + room.markThreadAsRead(it) + } + } + } + + /** + * Observe local unread threads + */ + private fun observeLocalThreadNotifications() { + room.flow() + .liveLocalUnreadThreadList() + .execute { + val threadList = it.invoke() + val isUserMentioned = threadList?.firstOrNull { threadRootEvent -> + threadRootEvent.root.threadDetails?.threadNotificationState == ThreadNotificationState.NEW_HIGHLIGHTED_MESSAGE + }?.let { true } ?: false + val numberOfLocalUnreadThreads = threadList?.size ?: 0 + copy(threadNotificationBadgeState = ThreadNotificationBadgeState( + numberOfLocalUnreadThreads = numberOfLocalUnreadThreads, + isUserMentioned = isUserMentioned)) + } + } + fun getOtherUserIds() = room.roomSummary()?.otherMemberIds + fun getRoomSummary() = room.roomSummary() + override fun handle(action: RoomDetailAction) { when (action) { is RoomDetailAction.ComposerFocusChange -> handleComposerFocusChange(action) @@ -385,14 +431,9 @@ class RoomDetailViewModel @AssistedInject constructor( _viewEvents.post(RoomDetailViewEvents.OpenRoom(action.replacementRoomId, closeCurrentRoom = true)) } is RoomDetailAction.EndPoll -> handleEndPoll(action.eventId) - is RoomDetailAction.ShowLocation -> handleShowLocation(action.locationData, action.userId) }.exhaustive } - private fun handleShowLocation(locationData: LocationData, userId: String) { - _viewEvents.post(RoomDetailViewEvents.ShowLocation(locationData, userId)) - } - private fun handleJitsiCallJoinStatus(action: RoomDetailAction.UpdateJoinJitsiCallStatus) = withState { state -> if (state.jitsiState.confId == null) { // If jitsi widget is removed while on the call @@ -469,7 +510,11 @@ class RoomDetailViewModel @AssistedInject constructor( } private fun handleSendSticker(action: RoomDetailAction.SendSticker) { - room.sendEvent(EventType.STICKER, action.stickerContent.toContent()) + val content = initialState.rootThreadEventId?.let { + action.stickerContent.copy(relatesTo = RelationDefaultContent(RelationType.IO_THREAD, it)) + } ?: action.stickerContent + + room.sendEvent(EventType.STICKER, content.toContent()) } private fun handleStartCall(action: RoomDetailAction.StartCall) { @@ -656,20 +701,30 @@ class RoomDetailViewModel @AssistedInject constructor( private fun isIntegrationEnabled() = session.integrationManagerService().isIntegrationEnabled() fun isMenuItemVisible(@IdRes itemId: Int): Boolean = com.airbnb.mvrx.withState(this) { state -> + if (state.asyncRoomSummary()?.membership != Membership.JOIN) { return@withState false } - when (itemId) { - R.id.timeline_setting -> true - R.id.invite -> state.canInvite - R.id.open_matrix_apps -> true - R.id.voice_call -> state.isWebRTCCallOptionAvailable() - R.id.video_call -> state.isWebRTCCallOptionAvailable() || state.jitsiState.confId == null || state.jitsiState.hasJoined - // Show Join conference button only if there is an active conf id not joined. Otherwise fallback to default video disabled. ^ - R.id.join_conference -> !state.isWebRTCCallOptionAvailable() && state.jitsiState.confId != null && !state.jitsiState.hasJoined - R.id.search -> true - R.id.dev_tools -> vectorPreferences.developerMode() - else -> false + + if (initialState.isThreadTimeline()) { + when (itemId) { + R.id.menu_thread_timeline_more -> true + else -> false + } + } else { + when (itemId) { + R.id.timeline_setting -> true + R.id.invite -> state.canInvite + R.id.open_matrix_apps -> true + R.id.voice_call -> state.isWebRTCCallOptionAvailable() + R.id.video_call -> state.isWebRTCCallOptionAvailable() || state.jitsiState.confId == null || state.jitsiState.hasJoined + // Show Join conference button only if there is an active conf id not joined. Otherwise fallback to default video disabled. ^ + R.id.join_conference -> !state.isWebRTCCallOptionAvailable() && state.jitsiState.confId != null && !state.jitsiState.hasJoined + R.id.search -> state.isSearchAvailable() + R.id.menu_timeline_thread_list -> vectorPreferences.areThreadMessagesEnabled() + R.id.dev_tools -> vectorPreferences.developerMode() + else -> false + } } } @@ -685,19 +740,32 @@ class RoomDetailViewModel @AssistedInject constructor( } private fun handleUndoReact(action: RoomDetailAction.UndoReaction) { - room.undoReaction(action.targetEventId, action.reaction) + viewModelScope.launch { + tryOrNull { + room.undoReaction(action.targetEventId, action.reaction) + } + } } private fun handleUpdateQuickReaction(action: RoomDetailAction.UpdateQuickReactAction) { if (action.add) { room.sendReaction(action.targetEventId, action.selectedReaction) } else { - room.undoReaction(action.targetEventId, action.selectedReaction) + viewModelScope.launch { + tryOrNull { + room.undoReaction(action.targetEventId, action.selectedReaction) + } + } } } private fun handleSendMedia(action: RoomDetailAction.SendMedia) { - room.sendMedias(action.attachments, action.compressBeforeSending, emptySet()) + room.sendMedias( + action.attachments, + action.compressBeforeSending, + emptySet(), + initialState.rootThreadEventId + ) } private fun handleEventVisible(action: RoomDetailAction.TimelineEventTurnsVisible) { @@ -733,14 +801,14 @@ class RoomDetailViewModel @AssistedInject constructor( private fun handleRejectInvite() { viewModelScope.launch { - tryOrNull { room.leave(null) } + tryOrNull { session.leaveRoom(room.roomId) } } } private fun handleAcceptInvite() { viewModelScope.launch { tryOrNull { - room.join() + session.joinRoom(room.roomId) analyticsTracker.capture(room.roomSummary().toAnalyticsJoinedRoom()) } } @@ -1134,6 +1202,9 @@ class RoomDetailViewModel @AssistedInject constructor( chatEffectManager.delegate = null chatEffectManager.dispose() callManager.removeProtocolsCheckerListener(this) + // we should also mark it as read here, for the scenario that the user + // is already in the thread timeline + markThreadTimelineAsReadLocal() super.onCleared() } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/arguments/TimelineArgs.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/arguments/TimelineArgs.kt new file mode 100644 index 0000000000..f22fe1b7df --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/arguments/TimelineArgs.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.arguments + +import android.os.Parcelable +import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs +import im.vector.app.features.share.SharedData +import kotlinx.parcelize.Parcelize + +@Parcelize +data class TimelineArgs( + val roomId: String, + val eventId: String? = null, + val sharedData: SharedData? = null, + val openShareSpaceForId: String? = null, + val threadTimelineArgs: ThreadTimelineArgs? = null, + val switchToParentSpace: Boolean = false +) : Parcelable 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 690f127cbd..10cef39942 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 @@ -35,7 +35,7 @@ sealed class MessageComposerAction : VectorViewModelAction { data class InitializeVoiceRecorder(val attachmentData: ContentAttachmentData) : MessageComposerAction() data class OnVoiceRecordingUiStateChanged(val uiState: VoiceMessageRecorderView.RecordingUiState) : MessageComposerAction() object StartRecordingVoiceMessage : MessageComposerAction() - data class EndRecordingVoiceMessage(val isCancelled: Boolean) : MessageComposerAction() + data class EndRecordingVoiceMessage(val isCancelled: Boolean, val rootThreadEventId: String?) : MessageComposerAction() object PauseRecordingVoiceMessage : MessageComposerAction() data class PlayOrPauseVoicePlayback(val eventId: String, val messageAudioContent: MessageAudioContent) : MessageComposerAction() object PlayOrPauseRecordingPlayback : MessageComposerAction() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewEvents.kt index 07c58c9196..c1af838795 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewEvents.kt @@ -32,6 +32,8 @@ sealed class MessageComposerViewEvents : VectorViewEvents { data class JoinRoomCommandSuccess(val roomId: String) : SendMessageResult() class SlashCommandError(val command: Command) : SendMessageResult() class SlashCommandUnknown(val command: String) : SendMessageResult() + class SlashCommandNotSupportedInThreads(val command: Command) : SendMessageResult() + data class SlashCommandHandled(@StringRes val messageRes: Int? = null) : SendMessageResult() object SlashCommandLoading : SendMessageResult() data class SlashCommandResultOk(@StringRes val messageRes: Int? = null) : SendMessageResult() class SlashCommandResultError(val throwable: Throwable) : SendMessageResult() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt index a2d9b50edd..6adf248af9 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt @@ -46,6 +46,8 @@ import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.content.ContentAttachmentData 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.isThread 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.PowerLevelsContent @@ -53,6 +55,7 @@ import org.matrix.android.sdk.api.session.room.model.RoomAvatarContent import org.matrix.android.sdk.api.session.room.model.RoomEncryptionAlgorithm import org.matrix.android.sdk.api.session.room.model.RoomMemberContent import org.matrix.android.sdk.api.session.room.model.message.MessageType +import org.matrix.android.sdk.api.session.room.model.relation.shouldRenderInThread import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper import org.matrix.android.sdk.api.session.room.send.UserDraft import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent @@ -98,7 +101,7 @@ class MessageComposerViewModel @AssistedInject constructor( is MessageComposerAction.OnTextChanged -> handleOnTextChanged(action) is MessageComposerAction.OnVoiceRecordingUiStateChanged -> handleOnVoiceRecordingUiStateChanged(action) is MessageComposerAction.StartRecordingVoiceMessage -> handleStartRecordingVoiceMessage() - is MessageComposerAction.EndRecordingVoiceMessage -> handleEndRecordingVoiceMessage(action.isCancelled) + is MessageComposerAction.EndRecordingVoiceMessage -> handleEndRecordingVoiceMessage(action.isCancelled, action.rootThreadEventId) is MessageComposerAction.PlayOrPauseVoicePlayback -> handlePlayOrPauseVoicePlayback(action) MessageComposerAction.PauseRecordingVoiceMessage -> handlePauseRecordingVoiceMessage() MessageComposerAction.PlayOrPauseRecordingPlayback -> handlePlayOrPauseRecordingPlayback() @@ -187,135 +190,185 @@ class MessageComposerViewModel @AssistedInject constructor( withState { state -> when (state.sendMode) { is SendMode.Regular -> { - when (val slashCommandResult = commandParser.parseSlashCommand(action.text)) { - is ParsedCommand.ErrorNotACommand -> { + when (val slashCommandResult = commandParser.parseSlashCommand( + textMessage = action.text, + isInThreadTimeline = state.isInThreadTimeline())) { + is ParsedCommand.ErrorNotACommand -> { // Send the text message to the room - room.sendTextMessage(action.text, autoMarkdown = action.autoMarkdown) + if (state.rootThreadEventId != null) { + room.replyInThread( + rootThreadEventId = state.rootThreadEventId, + replyInThreadText = action.text, + autoMarkdown = action.autoMarkdown) + } else { + room.sendTextMessage(action.text, autoMarkdown = action.autoMarkdown) + } + _viewEvents.post(MessageComposerViewEvents.MessageSent) popDraft() } - is ParsedCommand.ErrorSyntax -> { + is ParsedCommand.ErrorSyntax -> { _viewEvents.post(MessageComposerViewEvents.SlashCommandError(slashCommandResult.command)) } - is ParsedCommand.ErrorEmptySlashCommand -> { + is ParsedCommand.ErrorEmptySlashCommand -> { _viewEvents.post(MessageComposerViewEvents.SlashCommandUnknown("/")) } - is ParsedCommand.ErrorUnknownSlashCommand -> { + is ParsedCommand.ErrorUnknownSlashCommand -> { _viewEvents.post(MessageComposerViewEvents.SlashCommandUnknown(slashCommandResult.slashCommand)) } - is ParsedCommand.SendPlainText -> { + is ParsedCommand.ErrorCommandNotSupportedInThreads -> { + _viewEvents.post(MessageComposerViewEvents.SlashCommandNotSupportedInThreads(slashCommandResult.command)) + } + is ParsedCommand.SendPlainText -> { // Send the text message to the room, without markdown - room.sendTextMessage(slashCommandResult.message, autoMarkdown = false) + if (state.rootThreadEventId != null) { + room.replyInThread( + rootThreadEventId = state.rootThreadEventId, + replyInThreadText = slashCommandResult.message, + autoMarkdown = false) + } else { + room.sendTextMessage(slashCommandResult.message, autoMarkdown = false) + } _viewEvents.post(MessageComposerViewEvents.MessageSent) popDraft() } - is ParsedCommand.ChangeRoomName -> { + is ParsedCommand.ChangeRoomName -> { handleChangeRoomNameSlashCommand(slashCommandResult) } - is ParsedCommand.Invite -> { + is ParsedCommand.Invite -> { handleInviteSlashCommand(slashCommandResult) } - is ParsedCommand.Invite3Pid -> { + is ParsedCommand.Invite3Pid -> { handleInvite3pidSlashCommand(slashCommandResult) } - is ParsedCommand.SetUserPowerLevel -> { + is ParsedCommand.SetUserPowerLevel -> { handleSetUserPowerLevel(slashCommandResult) } - is ParsedCommand.ClearScalarToken -> { + is ParsedCommand.ClearScalarToken -> { // TODO _viewEvents.post(MessageComposerViewEvents.SlashCommandNotImplemented) } - is ParsedCommand.SetMarkdown -> { + is ParsedCommand.SetMarkdown -> { vectorPreferences.setMarkdownEnabled(slashCommandResult.enable) _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk( if (slashCommandResult.enable) R.string.markdown_has_been_enabled else R.string.markdown_has_been_disabled)) popDraft() } - is ParsedCommand.BanUser -> { + is ParsedCommand.BanUser -> { handleBanSlashCommand(slashCommandResult) } - is ParsedCommand.UnbanUser -> { + is ParsedCommand.UnbanUser -> { handleUnbanSlashCommand(slashCommandResult) } - is ParsedCommand.IgnoreUser -> { + is ParsedCommand.IgnoreUser -> { handleIgnoreSlashCommand(slashCommandResult) } - is ParsedCommand.UnignoreUser -> { + is ParsedCommand.UnignoreUser -> { handleUnignoreSlashCommand(slashCommandResult) } - is ParsedCommand.RemoveUser -> { + is ParsedCommand.RemoveUser -> { handleRemoveSlashCommand(slashCommandResult) } - is ParsedCommand.JoinRoom -> { + is ParsedCommand.JoinRoom -> { handleJoinToAnotherRoomSlashCommand(slashCommandResult) popDraft() } - is ParsedCommand.PartRoom -> { + is ParsedCommand.PartRoom -> { handlePartSlashCommand(slashCommandResult) } - is ParsedCommand.SendEmote -> { - room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE, autoMarkdown = action.autoMarkdown) - _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) - popDraft() - } - is ParsedCommand.SendRainbow -> { - slashCommandResult.message.toString().let { - room.sendFormattedTextMessage(it, rainbowGenerator.generate(it)) + is ParsedCommand.SendEmote -> { + if (state.rootThreadEventId != null) { + room.replyInThread( + rootThreadEventId = state.rootThreadEventId, + replyInThreadText = slashCommandResult.message, + msgType = MessageType.MSGTYPE_EMOTE, + autoMarkdown = action.autoMarkdown) + } else { + room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE, autoMarkdown = action.autoMarkdown) } _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) popDraft() } - is ParsedCommand.SendRainbowEmote -> { - slashCommandResult.message.toString().let { - room.sendFormattedTextMessage(it, rainbowGenerator.generate(it), MessageType.MSGTYPE_EMOTE) + is ParsedCommand.SendRainbow -> { + val message = slashCommandResult.message.toString() + if (state.rootThreadEventId != null) { + room.replyInThread( + rootThreadEventId = state.rootThreadEventId, + replyInThreadText = slashCommandResult.message, + formattedText = rainbowGenerator.generate(message)) + } else { + room.sendFormattedTextMessage(message, rainbowGenerator.generate(message)) } _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) popDraft() } - is ParsedCommand.SendSpoiler -> { - room.sendFormattedTextMessage( - "[${stringProvider.getString(R.string.spoiler)}](${slashCommandResult.message})", - "${slashCommandResult.message}" - ) + is ParsedCommand.SendRainbowEmote -> { + val message = slashCommandResult.message.toString() + if (state.rootThreadEventId != null) { + room.replyInThread( + rootThreadEventId = state.rootThreadEventId, + replyInThreadText = slashCommandResult.message, + msgType = MessageType.MSGTYPE_EMOTE, + formattedText = rainbowGenerator.generate(message)) + } else { + room.sendFormattedTextMessage(message, rainbowGenerator.generate(message), MessageType.MSGTYPE_EMOTE) + } + _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) popDraft() } - is ParsedCommand.SendShrug -> { - sendPrefixedMessage("¯\\_(ツ)_/¯", slashCommandResult.message) + is ParsedCommand.SendSpoiler -> { + val text = "[${stringProvider.getString(R.string.spoiler)}](${slashCommandResult.message})" + val formattedText = "${slashCommandResult.message}" + if (state.rootThreadEventId != null) { + room.replyInThread( + rootThreadEventId = state.rootThreadEventId, + replyInThreadText = text, + formattedText = formattedText) + } else { + room.sendFormattedTextMessage( + text, + formattedText) + } _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) popDraft() } - is ParsedCommand.SendLenny -> { - sendPrefixedMessage("( ͡° ͜ʖ ͡°)", slashCommandResult.message) + is ParsedCommand.SendShrug -> { + sendPrefixedMessage("¯\\_(ツ)_/¯", slashCommandResult.message, state.rootThreadEventId) _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) popDraft() } - is ParsedCommand.SendChatEffect -> { + is ParsedCommand.SendLenny -> { + sendPrefixedMessage("( ͡° ͜ʖ ͡°)", slashCommandResult.message, state.rootThreadEventId) + _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) + popDraft() + } + is ParsedCommand.SendChatEffect -> { sendChatEffect(slashCommandResult) _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) popDraft() } - is ParsedCommand.ChangeTopic -> { + is ParsedCommand.ChangeTopic -> { handleChangeTopicSlashCommand(slashCommandResult) } - is ParsedCommand.ChangeDisplayName -> { + is ParsedCommand.ChangeDisplayName -> { handleChangeDisplayNameSlashCommand(slashCommandResult) } - is ParsedCommand.ChangeDisplayNameForRoom -> { + is ParsedCommand.ChangeDisplayNameForRoom -> { handleChangeDisplayNameForRoomSlashCommand(slashCommandResult) } - is ParsedCommand.ChangeRoomAvatar -> { + is ParsedCommand.ChangeRoomAvatar -> { handleChangeRoomAvatarSlashCommand(slashCommandResult) } - is ParsedCommand.ChangeAvatarForRoom -> { + is ParsedCommand.ChangeAvatarForRoom -> { handleChangeAvatarForRoomSlashCommand(slashCommandResult) } - is ParsedCommand.ShowUser -> { + is ParsedCommand.ShowUser -> { _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) handleWhoisSlashCommand(slashCommandResult) popDraft() } - is ParsedCommand.DiscardSession -> { + is ParsedCommand.DiscardSession -> { if (room.isEncrypted()) { session.cryptoService().discardOutboundSession(room.roomId) _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) @@ -328,7 +381,7 @@ class MessageComposerViewModel @AssistedInject constructor( ) } } - is ParsedCommand.CreateSpace -> { + is ParsedCommand.CreateSpace -> { _viewEvents.post(MessageComposerViewEvents.SlashCommandLoading) viewModelScope.launch(Dispatchers.IO) { try { @@ -352,7 +405,7 @@ class MessageComposerViewModel @AssistedInject constructor( } Unit } - is ParsedCommand.AddToSpace -> { + is ParsedCommand.AddToSpace -> { _viewEvents.post(MessageComposerViewEvents.SlashCommandLoading) viewModelScope.launch(Dispatchers.IO) { try { @@ -371,7 +424,7 @@ class MessageComposerViewModel @AssistedInject constructor( } Unit } - is ParsedCommand.JoinSpace -> { + is ParsedCommand.JoinSpace -> { _viewEvents.post(MessageComposerViewEvents.SlashCommandLoading) viewModelScope.launch(Dispatchers.IO) { try { @@ -384,10 +437,10 @@ class MessageComposerViewModel @AssistedInject constructor( } Unit } - is ParsedCommand.LeaveRoom -> { + is ParsedCommand.LeaveRoom -> { viewModelScope.launch(Dispatchers.IO) { try { - session.getRoom(slashCommandResult.roomId)?.leave(null) + session.leaveRoom(slashCommandResult.roomId) popDraft() _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) } catch (failure: Throwable) { @@ -396,7 +449,7 @@ class MessageComposerViewModel @AssistedInject constructor( } Unit } - is ParsedCommand.UpgradeRoom -> { + is ParsedCommand.UpgradeRoom -> { _viewEvents.post( MessageComposerViewEvents.ShowRoomUpgradeDialog( slashCommandResult.newVersion, @@ -410,7 +463,20 @@ class MessageComposerViewModel @AssistedInject constructor( } is SendMode.Edit -> { // is original event a reply? - val inReplyTo = state.sendMode.timelineEvent.getRelationContent()?.inReplyTo?.eventId + val relationContent = state.sendMode.timelineEvent.getRelationContent() + val inReplyTo = if (state.rootThreadEventId != null) { + if (relationContent?.inReplyTo?.shouldRenderInThread() == true) { + // Reply within a thread event + relationContent.inReplyTo?.eventId + } else { + // Normal thread event + null + } + } else { + // Normal event + relationContent?.inReplyTo?.eventId + } + if (inReplyTo != null) { // TODO check if same content? room.getTimeLineEvent(inReplyTo)?.let { @@ -432,16 +498,34 @@ class MessageComposerViewModel @AssistedInject constructor( popDraft() } is SendMode.Quote -> { - room.sendQuotedTextMessage(state.sendMode.timelineEvent, action.text.toString(), action.autoMarkdown) + room.sendQuotedTextMessage( + quotedEvent = state.sendMode.timelineEvent, + text = action.text.toString(), + autoMarkdown = action.autoMarkdown, + rootThreadEventId = state.rootThreadEventId) _viewEvents.post(MessageComposerViewEvents.MessageSent) popDraft() } is SendMode.Reply -> { - state.sendMode.timelineEvent.let { - room.replyToMessage(it, action.text.toString(), action.autoMarkdown) - _viewEvents.post(MessageComposerViewEvents.MessageSent) - popDraft() - } + val timelineEvent = state.sendMode.timelineEvent + val showInThread = state.sendMode.timelineEvent.root.isThread() && state.rootThreadEventId == null + val rootThreadEventId = if (showInThread) timelineEvent.root.getRootThreadEventId() else null + state.rootThreadEventId?.let { + room.replyInThread( + rootThreadEventId = it, + replyInThreadText = action.text.toString(), + autoMarkdown = action.autoMarkdown, + eventReplied = timelineEvent) + } ?: room.replyToMessage( + eventReplied = timelineEvent, + replyText = action.text.toString(), + autoMarkdown = action.autoMarkdown, + showInThread = showInThread, + rootThreadEventId = rootThreadEventId + ) + + _viewEvents.post(MessageComposerViewEvents.MessageSent) + popDraft() } is SendMode.Voice -> { // do nothing @@ -599,7 +683,9 @@ class MessageComposerViewModel @AssistedInject constructor( ?.roomId ?.let { session.getRoom(it) } } - ?.leave(reason = null) + ?.let { + session.leaveRoom(it.roomId) + } } } @@ -677,7 +763,7 @@ class MessageComposerViewModel @AssistedInject constructor( _viewEvents.post(MessageComposerViewEvents.OpenRoomMemberProfile(whois.userId)) } - private fun sendPrefixedMessage(prefix: String, message: CharSequence) { + private fun sendPrefixedMessage(prefix: String, message: CharSequence, rootThreadEventId: String?) { val sequence = buildString { append(prefix) if (message.isNotEmpty()) { @@ -685,7 +771,9 @@ class MessageComposerViewModel @AssistedInject constructor( append(message) } } - room.sendTextMessage(sequence) + rootThreadEventId?.let { + room.replyInThread(it, sequence) + } ?: room.sendTextMessage(sequence) } /** @@ -722,14 +810,18 @@ class MessageComposerViewModel @AssistedInject constructor( } } - private fun handleEndRecordingVoiceMessage(isCancelled: Boolean) { + private fun handleEndRecordingVoiceMessage(isCancelled: Boolean, rootThreadEventId: String? = null) { voiceMessageHelper.stopPlayback() if (isCancelled) { voiceMessageHelper.deleteRecording() } else { voiceMessageHelper.stopRecording(convertForSending = true)?.let { audioType -> if (audioType.duration > 1000) { - room.sendMedia(audioType.toContentAttachmentData(isVoiceMessage = true), false, emptySet()) + room.sendMedia( + attachment = audioType.toContentAttachmentData(isVoiceMessage = true), + compressBeforeSending = false, + roomIds = emptySet(), + rootThreadEventId = rootThreadEventId) } else { voiceMessageHelper.deleteRecording() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt index 915e1b3338..f90f3975c6 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt @@ -17,7 +17,7 @@ package im.vector.app.features.home.room.detail.composer import com.airbnb.mvrx.MavericksState -import im.vector.app.features.home.room.detail.RoomDetailArgs +import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent @@ -61,12 +61,13 @@ data class MessageComposerViewState( val roomId: String, val canSendMessage: CanSendStatus = CanSendStatus.Allowed, val isSendButtonVisible: Boolean = false, + val rootThreadEventId: String? = null, val sendMode: SendMode = SendMode.Regular("", false), val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle ) : MavericksState { val isVoiceRecording = when (voiceRecordingUiState) { - VoiceMessageRecorderView.RecordingUiState.Idle -> false + VoiceMessageRecorderView.RecordingUiState.Idle -> false is VoiceMessageRecorderView.RecordingUiState.Locked, VoiceMessageRecorderView.RecordingUiState.Draft, is VoiceMessageRecorderView.RecordingUiState.Recording -> true @@ -77,6 +78,9 @@ data class MessageComposerViewState( val isComposerVisible = canSendMessage.boolean() && !isVoiceRecording val isVoiceMessageRecorderVisible = canSendMessage.boolean() && !isSendButtonVisible - @Suppress("UNUSED") // needed by mavericks - constructor(args: RoomDetailArgs) : this(roomId = args.roomId) + constructor(args: TimelineArgs) : this( + roomId = args.roomId, + rootThreadEventId = args.threadTimelineArgs?.rootThreadEventId) + + fun isInThreadTimeline(): Boolean = rootThreadEventId != null } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchFragment.kt index 4a285da5f2..62c142238e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchFragment.kt @@ -37,13 +37,17 @@ import im.vector.app.core.extensions.trackItemsVisibilityChange import im.vector.app.core.platform.StateView import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.databinding.FragmentSearchBinding +import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import kotlinx.parcelize.Parcelize import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId import javax.inject.Inject @Parcelize data class SearchArgs( - val roomId: String + val roomId: String, + val roomDisplayName: String?, + val roomAvatarUrl: String? ) : Parcelable class SearchFragment @Inject constructor( @@ -111,10 +115,25 @@ class SearchFragment @Inject constructor( searchViewModel.handle(SearchAction.Retry) } - override fun onItemClicked(event: Event) { - event.roomId?.let { - navigator.openRoom(requireContext(), it, event.eventId) - } + override fun onItemClicked(event: Event) = + navigateToEvent(event) + + /** + * Navigate and highlight the event. If this is a thread event, + * user will be redirected to the appropriate thread room + * @param event the event to navigate and highlight + */ + private fun navigateToEvent(event: Event) { + val roomId = event.roomId ?: return + event.getRootThreadEventId()?.let { + val threadTimelineArgs = ThreadTimelineArgs( + roomId = roomId, + displayName = fragmentArgs.roomDisplayName, + avatarUrl = fragmentArgs.roomAvatarUrl, + roomEncryptionTrustLevel = null, + rootThreadEventId = it) + navigator.openThread(requireContext(), threadTimelineArgs, event.eventId) + } ?: navigator.openRoom(requireContext(), roomId, event.eventId) } override fun loadMore() { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt index ccf83011a8..2cdc1a0d90 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt @@ -29,6 +29,7 @@ import im.vector.app.core.date.VectorDateFormatter import im.vector.app.core.epoxy.loadingItem import im.vector.app.core.epoxy.noResultItem import im.vector.app.core.resources.StringProvider +import im.vector.app.core.resources.UserPreferencesProvider import im.vector.app.core.ui.list.GenericHeaderItem_ import im.vector.app.features.home.AvatarRenderer import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence @@ -43,7 +44,8 @@ class SearchResultController @Inject constructor( private val session: Session, private val avatarRenderer: AvatarRenderer, private val stringProvider: StringProvider, - private val dateFormatter: VectorDateFormatter + private val dateFormatter: VectorDateFormatter, + private val userPreferencesProvider: UserPreferencesProvider ) : TypedEpoxyController() { var listener: Listener? = null @@ -122,6 +124,8 @@ class SearchResultController @Inject constructor( .spannable(spannable.toEpoxyCharSequence()) .sender(eventAndSender.sender ?: eventAndSender.event.senderId?.let { session.getRoomMember(it, data.roomId) }?.toMatrixItem()) + .threadDetails(event.threadDetails) + .areThreadMessagesEnabled(userPreferencesProvider.areThreadMessagesEnabled()) .listener { listener?.onItemClicked(eventAndSender.event) } .let { result.add(it) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt index 95dea2b8d2..2ec786fab2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt @@ -18,6 +18,8 @@ package im.vector.app.features.home.room.detail.search import android.widget.ImageView import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R @@ -29,6 +31,7 @@ import im.vector.app.core.extensions.setTextOrHide import im.vector.app.features.displayname.getBestName import im.vector.app.features.home.AvatarRenderer import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence +import org.matrix.android.sdk.api.session.threads.ThreadDetails import org.matrix.android.sdk.api.util.MatrixItem @EpoxyModelClass(layout = R.layout.item_search_result) @@ -38,6 +41,9 @@ abstract class SearchResultItem : VectorEpoxyModel() { @EpoxyAttribute var formattedDate: String? = null @EpoxyAttribute lateinit var spannable: EpoxyCharSequence @EpoxyAttribute var sender: MatrixItem? = null + @EpoxyAttribute var threadDetails: ThreadDetails? = null + @EpoxyAttribute var areThreadMessagesEnabled: Boolean = false + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var listener: ClickListener? = null override fun bind(holder: Holder) { @@ -48,6 +54,36 @@ abstract class SearchResultItem : VectorEpoxyModel() { holder.memberNameView.setTextOrHide(sender?.getBestName()) holder.timeView.text = formattedDate holder.contentView.text = spannable.charSequence + + if (areThreadMessagesEnabled) { + threadDetails?.let { + if (it.isRootThread) { + showThreadSummary(holder) + holder.threadSummaryCounterTextView.text = it.numberOfThreads.toString() + holder.threadSummaryInfoTextView.text = it.threadSummaryLatestTextMessage.orEmpty() + + val userId = it.threadSummarySenderInfo?.userId ?: return@let + val displayName = it.threadSummarySenderInfo?.displayName + val avatarUrl = it.threadSummarySenderInfo?.avatarUrl + avatarRenderer.render(MatrixItem.UserItem(userId, displayName, avatarUrl), holder.threadSummaryAvatarImageView) + } else { + showFromThread(holder) + } + } ?: run { + holder.threadSummaryConstraintLayout.isVisible = false + holder.fromThreadConstraintLayout.isVisible = false + } + } + } + + private fun showThreadSummary(holder: Holder, show: Boolean = true) { + holder.threadSummaryConstraintLayout.isVisible = show + holder.fromThreadConstraintLayout.isVisible = !show + } + + private fun showFromThread(holder: Holder, show: Boolean = true) { + holder.threadSummaryConstraintLayout.isVisible = !show + holder.fromThreadConstraintLayout.isVisible = show } class Holder : VectorEpoxyHolder() { @@ -55,5 +91,10 @@ abstract class SearchResultItem : VectorEpoxyModel() { val memberNameView by bind(R.id.messageMemberNameView) val timeView by bind(R.id.messageTimeView) val contentView by bind(R.id.messageContentView) + val threadSummaryConstraintLayout by bind(R.id.searchThreadSummaryConstraintLayout) + val threadSummaryCounterTextView by bind(R.id.messageThreadSummaryCounterTextView) + val threadSummaryAvatarImageView by bind(R.id.messageThreadSummaryAvatarImageView) + val threadSummaryInfoTextView by bind(R.id.messageThreadSummaryInfoTextView) + val fromThreadConstraintLayout by bind(R.id.searchFromThreadConstraintLayout) } } 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 4a9a03789f..e3f162dfd4 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 @@ -41,6 +41,7 @@ import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFact import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFactoryParams import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder +import im.vector.app.features.home.room.detail.timeline.helper.ReactionsSummaryFactory import im.vector.app.features.home.room.detail.timeline.helper.TimelineControllerInterceptorHelper import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventDiffUtilCallback import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityHelper @@ -86,7 +87,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec @TimelineEventControllerHandler private val backgroundHandler: Handler, private val timelineEventVisibilityHelper: TimelineEventVisibilityHelper, - private val readReceiptsItemFactory: ReadReceiptsItemFactory + private val readReceiptsItemFactory: ReadReceiptsItemFactory, + private val reactionListFactory: ReactionsSummaryFactory ) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener, EpoxyController.Interceptor { /** @@ -96,21 +98,26 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec val unreadState: UnreadState = UnreadState.Unknown, val highlightedEventId: String? = null, val jitsiState: JitsiState = JitsiState(), - val roomSummary: RoomSummary? = null + val roomSummary: RoomSummary? = null, + val rootThreadEventId: String? = null ) { constructor(state: RoomDetailViewState) : this( unreadState = state.unreadState, highlightedEventId = state.highlightedEventId, jitsiState = state.jitsiState, - roomSummary = state.asyncRoomSummary() + roomSummary = state.asyncRoomSummary(), + rootThreadEventId = state.rootThreadEventId ) + + fun isFromThreadTimeline(): Boolean = rootThreadEventId != null } interface Callback : BaseCallback, ReactionPillCallback, AvatarCallback, + ThreadCallback, UrlClickCallback, ReadReceiptsCallback, PreviewUrlCallback { @@ -133,6 +140,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec fun getPreviewUrlRetriever(): PreviewUrlRetriever fun onVoiceControlButtonClicked(eventId: String, messageAudioContent: MessageAudioContent) + + fun onAddMoreReaction(event: TimelineEvent) } interface ReactionPillCallback { @@ -141,7 +150,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } interface BaseCallback { - fun onEventCellClicked(informationData: MessageInformationData, messageContent: Any?, view: View) + fun onEventCellClicked(informationData: MessageInformationData, messageContent: Any?, view: View, isRootThreadEvent: Boolean) fun onEventLongClicked(informationData: MessageInformationData, messageContent: Any?, view: View): Boolean } @@ -150,6 +159,10 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec fun onMemberNameClicked(informationData: MessageInformationData) } + interface ThreadCallback { + fun onThreadSummaryClicked(eventId: String, isRootThreadEvent: Boolean): Boolean + } + interface ReadReceiptsCallback { fun onReadReceiptsClicked(readReceipts: List) fun onReadMarkerVisible() @@ -198,7 +211,12 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec // In some cases onChanged will be called before onRemoved and onInserted so position will be bigger than currentSnapshot.size. val prevList = currentSnapshot.subList(0, min(position, currentSnapshot.size)) val prevDisplayableEventIndex = prevList.indexOfLast { - timelineEventVisibilityHelper.shouldShowEvent(it, partialState.highlightedEventId) + timelineEventVisibilityHelper.shouldShowEvent( + timelineEvent = it, + highlightedEventId = partialState.highlightedEventId, + isFromThreadTimeline = partialState.isFromThreadTimeline(), + rootThreadEventId = partialState.rootThreadEventId + ) } if (prevDisplayableEventIndex != -1 && currentSnapshot.getOrNull(prevDisplayableEventIndex)?.senderInfo?.userId == invalidatedSenderId) { modelCache[prevDisplayableEventIndex] = null @@ -269,6 +287,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec super.onAttachedToRecyclerView(recyclerView) timeline?.addListener(this) timelineMediaSizeProvider.recyclerView = recyclerView + reactionListFactory.onRequestBuild = { requestModelBuild() } } override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { @@ -276,6 +295,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec contentUploadStateTrackerBinder.clear() contentDownloadStateTrackerBinder.clear() timeline?.removeListener(this) + reactionListFactory.onRequestBuild = null super.onDetachedFromRecyclerView(recyclerView) } @@ -313,6 +333,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } private fun submitSnapshot(newSnapshot: List) { + // Update is triggered on any DB change backgroundHandler.post { inSubmitList = true val diffCallback = TimelineEventDiffUtilCallback(currentSnapshot, newSnapshot) @@ -367,16 +388,28 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec (0 until modelCache.size).forEach { position -> val event = currentSnapshot[position] val nextEvent = currentSnapshot.nextOrNull(position) - val prevEvent = currentSnapshot.prevOrNull(position) - val nextDisplayableEvent = currentSnapshot.subList(position + 1, currentSnapshot.size).firstOrNull { - timelineEventVisibilityHelper.shouldShowEvent(it, partialState.highlightedEventId) - } // Should be build if not cached or if model should be refreshed - if (modelCache[position] == null || modelCache[position]?.isCacheable(partialState) == false) { + if (modelCache[position] == null || modelCache[position]?.isCacheable(partialState) == false || reactionListFactory.needsRebuild(event)) { + val prevEvent = currentSnapshot.prevOrNull(position) + val prevDisplayableEvent = currentSnapshot.subList(0, position).lastOrNull { + timelineEventVisibilityHelper.shouldShowEvent( + timelineEvent = it, + highlightedEventId = partialState.highlightedEventId, + isFromThreadTimeline = partialState.isFromThreadTimeline(), + rootThreadEventId = partialState.rootThreadEventId) + } + val nextDisplayableEvent = currentSnapshot.subList(position + 1, currentSnapshot.size).firstOrNull { + timelineEventVisibilityHelper.shouldShowEvent( + timelineEvent = it, + highlightedEventId = partialState.highlightedEventId, + isFromThreadTimeline = partialState.isFromThreadTimeline(), + rootThreadEventId = partialState.rootThreadEventId) + } val timelineEventsGroup = timelineEventsGroups.getOrNull(event) val params = TimelineItemFactoryParams( event = event, prevEvent = prevEvent, + prevDisplayableEvent = prevDisplayableEvent, nextEvent = nextEvent, nextDisplayableEvent = nextDisplayableEvent, partialState = partialState, @@ -436,7 +469,12 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } val readReceipts = receiptsByEvents[event.eventId].orEmpty() return copy( - readReceiptsItem = readReceiptsItemFactory.create(event.eventId, readReceipts, callback), + readReceiptsItem = readReceiptsItemFactory.create( + event.eventId, + readReceipts, + callback, + partialState.isFromThreadTimeline() + ), formattedDayModel = formattedDayModel, mergedHeaderModel = mergedHeaderModel ) @@ -453,7 +491,12 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec return null } // If the event is not shown, we go to the next one - if (!timelineEventVisibilityHelper.shouldShowEvent(event, partialState.highlightedEventId)) { + if (!timelineEventVisibilityHelper.shouldShowEvent( + timelineEvent = event, + highlightedEventId = partialState.highlightedEventId, + isFromThreadTimeline = partialState.isFromThreadTimeline(), + rootThreadEventId = partialState.rootThreadEventId + )) { continue } // If the event is sent by us, we update the holder with the eventId and stop the search @@ -475,7 +518,11 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec val currentReadReceipts = ArrayList(event.readReceipts).filter { it.user.userId != session.myUserId } - if (timelineEventVisibilityHelper.shouldShowEvent(event, partialState.highlightedEventId)) { + if (timelineEventVisibilityHelper.shouldShowEvent( + timelineEvent = event, + highlightedEventId = partialState.highlightedEventId, + isFromThreadTimeline = partialState.isFromThreadTimeline(), + rootThreadEventId = partialState.rootThreadEventId)) { lastShownEventId = event.eventId } if (lastShownEventId == null) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt index d7a57e6577..048a4754f5 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt @@ -48,6 +48,12 @@ sealed class EventSharedAction(@StringRes val titleRes: Int, data class Reply(val eventId: String) : EventSharedAction(R.string.reply, R.drawable.ic_reply) + data class ReplyInThread(val eventId: String) : + EventSharedAction(R.string.reply_in_thread, R.drawable.ic_reply_in_thread) + + object ViewInRoom : + EventSharedAction(R.string.view_in_room, R.drawable.ic_thread_view_in_room_menu_item) + data class Share(val eventId: String, val messageContent: MessageContent) : EventSharedAction(R.string.action_share, R.drawable.ic_share) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionState.kt index c1c145040e..0cf7e60eae 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionState.kt @@ -49,10 +49,15 @@ data class MessageActionState( // For actions val actions: List = emptyList(), val expendedReportContentMenu: Boolean = false, - val actionPermissions: ActionPermissions = ActionPermissions() + val actionPermissions: ActionPermissions = ActionPermissions(), + val isFromThreadTimeline: Boolean = false ) : MavericksState { - constructor(args: TimelineEventFragmentArgs) : this(roomId = args.roomId, eventId = args.eventId, informationData = args.informationData) + constructor(args: TimelineEventFragmentArgs) : this( + roomId = args.roomId, + eventId = args.eventId, + informationData = args.informationData, + isFromThreadTimeline = args.isFromThreadTimeline) fun senderName(): String = informationData.memberName?.toString() ?: "" diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsBottomSheet.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsBottomSheet.kt index 5e0db19d9e..24c5679438 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsBottomSheet.kt @@ -93,13 +93,14 @@ class MessageActionsBottomSheet : } companion object { - fun newInstance(roomId: String, informationData: MessageInformationData): MessageActionsBottomSheet { + fun newInstance(roomId: String, informationData: MessageInformationData, isFromThreadTimeline: Boolean): MessageActionsBottomSheet { return MessageActionsBottomSheet().apply { setArguments( TimelineEventFragmentArgs( informationData.eventId, roomId, - informationData + informationData, + isFromThreadTimeline ) ) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt index 1ff9679479..27937047a5 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt @@ -39,10 +39,13 @@ import im.vector.app.features.home.room.detail.timeline.item.E2EDecoration import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovementMethod import im.vector.app.features.home.room.detail.timeline.tools.linkify import im.vector.app.features.html.SpanUtils -import im.vector.app.features.location.LocationData +import im.vector.app.features.location.INITIAL_MAP_ZOOM_IN_TIMELINE +import im.vector.app.features.location.UrlMapProvider +import im.vector.app.features.location.toLocationData import im.vector.app.features.media.ImageContentRenderer import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.extensions.orTrue import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.message.MessageLocationContent @@ -62,6 +65,7 @@ class MessageActionsEpoxyController @Inject constructor( private val spanUtils: SpanUtils, private val eventDetailsFormatter: EventDetailsFormatter, private val dateFormatter: VectorDateFormatter, + private val urlMapProvider: UrlMapProvider, private val locationPinProvider: LocationPinProvider ) : TypedEpoxyController() { @@ -74,9 +78,13 @@ class MessageActionsEpoxyController @Inject constructor( val formattedDate = dateFormatter.format(date, DateFormatKind.MESSAGE_DETAIL) val body = state.messageBody.linkify(host.listener) val bindingOptions = spanUtils.getBindingOptions(body) - val locationData = state.timelineEvent()?.root?.getClearContent()?.toModel(catchError = true)?.let { - LocationData.create(it.getUri()) - } + + val locationContent = state.timelineEvent()?.root?.getClearContent() + ?.toModel(catchError = true) + val locationUrl = locationContent?.toLocationData() + ?.let { urlMapProvider.buildStaticMapUrl(it, INITIAL_MAP_ZOOM_IN_TIMELINE, 1200, 800) } + val locationOwnerId = if (locationContent?.isSelfLocation().orTrue()) state.informationData.matrixItem.id else null + bottomSheetMessagePreviewItem { id("preview") avatarRenderer(host.avatarRenderer) @@ -89,8 +97,9 @@ class MessageActionsEpoxyController @Inject constructor( body(body.toEpoxyCharSequence()) bodyDetails(host.eventDetailsFormatter.format(state.timelineEvent()?.root)?.toEpoxyCharSequence()) time(formattedDate) - locationData(locationData) + locationUrl(locationUrl) locationPinProvider(host.locationPinProvider) + locationOwnerId(locationOwnerId) } // Send state 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 ea54d91a78..745cb0c731 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 @@ -46,6 +46,7 @@ import org.matrix.android.sdk.api.session.crypto.keysbackup.KeysBackupState import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage import org.matrix.android.sdk.api.session.events.model.isTextMessage +import org.matrix.android.sdk.api.session.events.model.isThread import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageFormat @@ -59,6 +60,8 @@ import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.hasBeenEdited +import org.matrix.android.sdk.api.session.room.timeline.isPoll +import org.matrix.android.sdk.api.session.room.timeline.isSticker import org.matrix.android.sdk.flow.flow import org.matrix.android.sdk.flow.unwrap @@ -324,6 +327,14 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted add(EventSharedAction.Reply(eventId)) } + if (canReplyInThread(timelineEvent, messageContent, actionPermissions)) { + add(EventSharedAction.ReplyInThread(eventId)) + } + + if (canViewInRoom(timelineEvent, messageContent, actionPermissions)) { + add(EventSharedAction.ViewInRoom) + } + if (canEndPoll(timelineEvent, actionPermissions)) { add(EventSharedAction.EndPoll(timelineEvent.eventId)) } @@ -430,6 +441,59 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted } } + /** + * Determine whether or not the Reply In Thread bottom sheet action will be visible + * to the user + */ + private fun canReplyInThread(event: TimelineEvent, + messageContent: MessageContent?, + actionPermissions: ActionPermissions): Boolean { + if (!vectorPreferences.areThreadMessagesEnabled()) return false + if (initialState.isFromThreadTimeline) return false + if (event.root.isThread()) return false + if (event.root.getClearType() != EventType.MESSAGE && + !event.isSticker() && !event.isPoll()) return false + if (!actionPermissions.canSendMessage) return false + return when (messageContent?.msgType) { + MessageType.MSGTYPE_TEXT, + MessageType.MSGTYPE_NOTICE, + MessageType.MSGTYPE_EMOTE, + MessageType.MSGTYPE_IMAGE, + MessageType.MSGTYPE_VIDEO, + MessageType.MSGTYPE_AUDIO, + MessageType.MSGTYPE_FILE, + MessageType.MSGTYPE_POLL_START, + MessageType.MSGTYPE_STICKER_LOCAL -> true + else -> false + } + } + + /** + * Determine whether or not the view in room action will be available for the current event + */ + private fun canViewInRoom(event: TimelineEvent, + messageContent: MessageContent?, + actionPermissions: ActionPermissions): Boolean { + if (!vectorPreferences.areThreadMessagesEnabled()) return false + if (!initialState.isFromThreadTimeline) return false + if (event.root.getClearType() != EventType.MESSAGE && + !event.isSticker() && !event.isPoll()) return false + if (!actionPermissions.canSendMessage) return false + + return when (messageContent?.msgType) { + MessageType.MSGTYPE_TEXT, + MessageType.MSGTYPE_NOTICE, + MessageType.MSGTYPE_EMOTE, + MessageType.MSGTYPE_IMAGE, + MessageType.MSGTYPE_VIDEO, + MessageType.MSGTYPE_AUDIO, + MessageType.MSGTYPE_FILE, + MessageType.MSGTYPE_POLL_START, + MessageType.MSGTYPE_STICKER_LOCAL -> event.root.threadDetails?.isRootThread ?: false + else -> false + } + } + private fun canQuote(event: TimelineEvent, messageContent: MessageContent?, actionPermissions: ActionPermissions): Boolean { // Only event of type EventType.MESSAGE are supported for the moment if (event.root.getClearType() != EventType.MESSAGE) return false diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/TimelineEventFragmentArgs.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/TimelineEventFragmentArgs.kt index 1bb1a876bd..2bd3c54d52 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/TimelineEventFragmentArgs.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/TimelineEventFragmentArgs.kt @@ -24,5 +24,6 @@ import kotlinx.parcelize.Parcelize data class TimelineEventFragmentArgs( val eventId: String, val roomId: String, - val informationData: MessageInformationData + val informationData: MessageInformationData, + val isFromThreadTimeline: Boolean = false ) : Parcelable diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt index 97f2618fe6..0161f0b55d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt @@ -101,7 +101,11 @@ class CallItemFactory @Inject constructor( createCallTileTimelineItem( roomSummary = roomSummary, callId = callEventGrouper.callId, - callStatus = if (callEventGrouper.callWasMissed()) CallTileTimelineItem.CallStatus.MISSED else CallTileTimelineItem.CallStatus.ENDED, + callStatus = if (callEventGrouper.callWasAnswered()) { + CallTileTimelineItem.CallStatus.ENDED + } else { + CallTileTimelineItem.CallStatus.MISSED + }, callKind = callKind, callback = params.callback, highlight = params.isHighlighted, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt index 4f8a36e234..bc2497392c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt @@ -106,8 +106,14 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat } val informationData = messageInformationDataFactory.create(params) - val attributes = attributesFactory.create(event.root.content.toModel(), informationData, params.callback) + val threadDetails = if (params.isFromThreadTimeline()) null else event.root.threadDetails + val attributes = attributesFactory.create( + messageContent = event.root.content.toModel(), + informationData = informationData, + callback = params.callback, + threadDetails = threadDetails) return MessageTextItem_() + .layout(informationData.messageLayout.layoutRes) .leftGuideline(avatarSizeProvider.leftGuideline) .highlighted(params.isHighlighted) .attributes(attributes) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt index e378969b4a..99a026a0cf 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt @@ -83,7 +83,13 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde eventIdToHighlight: String?, requestModelBuild: () -> Unit, callback: TimelineEventController.Callback?): MergedMembershipEventsItem_? { - val mergedEvents = timelineEventVisibilityHelper.prevSameTypeEvents(items, currentPosition, 2, eventIdToHighlight) + val mergedEvents = timelineEventVisibilityHelper.prevSameTypeEvents( + items, + currentPosition, + 2, + eventIdToHighlight, + partialState.rootThreadEventId, + partialState.isFromThreadTimeline()) return if (mergedEvents.isEmpty()) { null } else { 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 eab7621d14..0c836748c8 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 @@ -33,7 +33,6 @@ import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.StringProvider import im.vector.app.core.utils.DimensionConverter import im.vector.app.core.utils.containsOnlyEmojis -import im.vector.app.features.home.room.detail.RoomDetailAction import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder @@ -44,8 +43,6 @@ import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttrib import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem -import im.vector.app.features.home.room.detail.timeline.item.MessageBlockCodeItem -import im.vector.app.features.home.room.detail.timeline.item.MessageBlockCodeItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageFileItem import im.vector.app.features.home.room.detail.timeline.item.MessageFileItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageImageVideoItem @@ -66,22 +63,23 @@ import im.vector.app.features.home.room.detail.timeline.item.VerificationRequest import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem_ import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovementMethod import im.vector.app.features.home.room.detail.timeline.tools.linkify -import im.vector.app.features.html.CodeVisitor import im.vector.app.features.html.EventHtmlRenderer import im.vector.app.features.html.PillsPostProcessor import im.vector.app.features.html.SpanUtils import im.vector.app.features.html.VectorHtmlCompressor -import im.vector.app.features.location.LocationData +import im.vector.app.features.location.INITIAL_MAP_ZOOM_IN_TIMELINE +import im.vector.app.features.location.UrlMapProvider +import im.vector.app.features.location.toLocationData import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.media.VideoContentRenderer import im.vector.app.features.settings.VectorPreferences import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence import me.gujun.android.span.span -import org.commonmark.node.Document import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.events.model.isThread import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent @@ -104,6 +102,7 @@ import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.util.MimeTypes import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent +import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage import javax.inject.Inject class MessageItemFactory @Inject constructor( @@ -123,11 +122,14 @@ class MessageItemFactory @Inject constructor( private val noticeItemFactory: NoticeItemFactory, private val avatarSizeProvider: AvatarSizeProvider, private val pillsPostProcessorFactory: PillsPostProcessor.Factory, + private val lightweightSettingsStorage: LightweightSettingsStorage, private val spanUtils: SpanUtils, private val session: Session, private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker, private val locationPinProvider: LocationPinProvider, - private val vectorPreferences: VectorPreferences) { + private val vectorPreferences: VectorPreferences, + private val urlMapProvider: UrlMapProvider, +) { // TODO inject this properly? private var roomId: String = "" @@ -143,9 +145,11 @@ class MessageItemFactory @Inject constructor( event.root.eventId ?: return null roomId = event.roomId val informationData = messageInformationDataFactory.create(params) + val threadDetails = if (params.isFromThreadTimeline()) null else event.root.threadDetails + if (event.root.isRedacted()) { // message is redacted - val attributes = messageItemAttributesFactory.create(null, informationData, callback) + val attributes = messageItemAttributesFactory.create(null, informationData, callback, threadDetails) return buildRedactedItem(attributes, highlight) } @@ -160,11 +164,18 @@ class MessageItemFactory @Inject constructor( // This is an edit event, we should display it when debugging as a notice event return noticeItemFactory.create(params) } - val attributes = messageItemAttributesFactory.create(messageContent, informationData, callback) + + if (lightweightSettingsStorage.areThreadMessagesEnabled() && !params.isFromThreadTimeline() && event.root.isThread()) { + // This is a thread event and we will [debug] display it when we are in the main timeline + return noticeItemFactory.create(params) + } + + // always hide summary when we are on thread timeline + val attributes = messageItemAttributesFactory.create(messageContent, informationData, callback, threadDetails) // val all = event.root.toContent() // val ev = all.toModel() - return when (messageContent) { + val messageItem = when (messageContent) { is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageTextContent -> buildItemForTextContent(messageContent, informationData, highlight, callback, attributes) is MessageImageInfoContent -> buildImageMessageItem(messageContent, informationData, highlight, callback, attributes) @@ -182,39 +193,40 @@ class MessageItemFactory @Inject constructor( is MessagePollContent -> buildPollItem(messageContent, informationData, highlight, callback, attributes) is MessageLocationContent -> { if (vectorPreferences.labsRenderLocationsInTimeline()) { - buildLocationItem(messageContent, informationData, highlight, callback, attributes) + buildLocationItem(messageContent, informationData, highlight, attributes) } else { buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes) } } else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes) } + return messageItem?.apply { + layout(informationData.messageLayout.layoutRes) + } } private fun buildLocationItem(locationContent: MessageLocationContent, informationData: MessageInformationData, highlight: Boolean, - callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes): MessageLocationItem? { - val geoUri = locationContent.getUri() - val locationData = LocationData.create(geoUri) + val width = timelineMediaSizeProvider.getMaxSize().first + val height = dimensionConverter.dpToPx(200) - val mapCallback: MessageLocationItem.Callback = object : MessageLocationItem.Callback { - override fun onMapClicked() { - locationData?.let { - callback?.onTimelineItemAction(RoomDetailAction.ShowLocation(it, informationData.senderId)) - } - } + val locationUrl = locationContent.toLocationData()?.let { + urlMapProvider.buildStaticMapUrl(it, INITIAL_MAP_ZOOM_IN_TIMELINE, width, height) } + val userId = if (locationContent.isSelfLocation()) informationData.senderId else null + return MessageLocationItem_() .attributes(attributes) - .locationData(locationData) - .userId(informationData.senderId) + .locationUrl(locationUrl) + .mapWidth(width) + .mapHeight(height) + .userId(userId) .locationPinProvider(locationPinProvider) .highlighted(highlight) .leftGuideline(avatarSizeProvider.leftGuideline) - .callback(mapCallback) } private fun buildPollItem(pollContent: MessagePollContent, @@ -513,46 +525,22 @@ class MessageItemFactory @Inject constructor( highlight: Boolean, callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes): VectorEpoxyModel<*>? { - val isFormatted = messageContent.matrixFormattedBody.isNullOrBlank().not() - return if (isFormatted) { - // First detect if the message contains some code block(s) or inline code - val localFormattedBody = htmlRenderer.get().parse(messageContent.body) as Document - val codeVisitor = CodeVisitor() - codeVisitor.visit(localFormattedBody) - when (codeVisitor.codeKind) { - CodeVisitor.Kind.BLOCK -> { - val codeFormattedBlock = htmlRenderer.get().render(localFormattedBody) - if (codeFormattedBlock == null) { - buildFormattedTextItem(messageContent, informationData, highlight, callback, attributes) - } else { - buildCodeBlockItem(codeFormattedBlock, informationData, highlight, callback, attributes) - } - } - CodeVisitor.Kind.INLINE -> { - val codeFormatted = htmlRenderer.get().render(localFormattedBody) - if (codeFormatted == null) { - buildFormattedTextItem(messageContent, informationData, highlight, callback, attributes) - } else { - buildMessageTextItem(codeFormatted, false, informationData, highlight, callback, attributes) - } - } - CodeVisitor.Kind.NONE -> { - buildFormattedTextItem(messageContent, informationData, highlight, callback, attributes) - } - } + val matrixFormattedBody = messageContent.matrixFormattedBody + return if (matrixFormattedBody != null) { + buildFormattedTextItem(matrixFormattedBody, informationData, highlight, callback, attributes) } else { buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes) } } - private fun buildFormattedTextItem(messageContent: MessageTextContent, + private fun buildFormattedTextItem(matrixFormattedBody: String, informationData: MessageInformationData, highlight: Boolean, callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes): MessageTextItem? { - val compressed = htmlCompressor.compress(messageContent.formattedBody!!) - val formattedBody = htmlRenderer.get().render(compressed, pillsPostProcessor) - return buildMessageTextItem(formattedBody, true, informationData, highlight, callback, attributes) + val compressed = htmlCompressor.compress(matrixFormattedBody) + val renderedFormattedBody = htmlRenderer.get().render(compressed, pillsPostProcessor) as Spanned + return buildMessageTextItem(renderedFormattedBody, true, informationData, highlight, callback, attributes) } private fun buildMessageTextItem(body: CharSequence, @@ -585,24 +573,6 @@ class MessageItemFactory @Inject constructor( .movementMethod(createLinkMovementMethod(callback)) } - private fun buildCodeBlockItem(formattedBody: CharSequence, - informationData: MessageInformationData, - highlight: Boolean, - callback: TimelineEventController.Callback?, - attributes: AbsMessageItem.Attributes): MessageBlockCodeItem? { - return MessageBlockCodeItem_() - .apply { - if (informationData.hasBeenEdited) { - val spannable = annotateWithEdited("", callback, informationData) - editedSpan(spannable.toEpoxyCharSequence()) - } - } - .leftGuideline(avatarSizeProvider.leftGuideline) - .attributes(attributes) - .highlighted(highlight) - .message(formattedBody.toEpoxyCharSequence()) - } - private fun annotateWithEdited(linkifiedBody: CharSequence, callback: TimelineEventController.Callback?, informationData: MessageInformationData): Spannable { @@ -708,6 +678,7 @@ class MessageItemFactory @Inject constructor( private fun buildRedactedItem(attributes: AbsMessageItem.Attributes, highlight: Boolean): RedactedMessageItem? { return RedactedMessageItem_() + .layout(attributes.informationData.messageLayout.layoutRes) .leftGuideline(avatarSizeProvider.leftGuideline) .attributes(attributes) .highlighted(highlight) 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 8a74a6d207..d477a3d40e 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 @@ -26,7 +26,11 @@ import javax.inject.Inject class ReadReceiptsItemFactory @Inject constructor(private val avatarRenderer: AvatarRenderer) { - fun create(eventId: String, readReceipts: List, callback: TimelineEventController.Callback?): ReadReceiptsItem? { + fun create( + eventId: String, + readReceipts: List, + callback: TimelineEventController.Callback?, + isFromThreadTimeLine: Boolean): ReadReceiptsItem? { if (readReceipts.isEmpty()) { return null } @@ -41,6 +45,7 @@ class ReadReceiptsItemFactory @Inject constructor(private val avatarRenderer: Av .eventId(eventId) .readReceipts(readReceiptsData) .avatarRenderer(avatarRenderer) + .shouldHideReadReceipts(isFromThreadTimeLine) .clickListener { callback?.onReadReceiptsClicked(readReceiptsData) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineFactory.kt index b57e39b3cf..3ec1366131 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineFactory.kt @@ -35,8 +35,14 @@ private val secondaryTimelineAllowedTypes = listOf( class TimelineFactory @Inject constructor(private val session: Session, private val timelineSettingsFactory: TimelineSettingsFactory) { - fun createTimeline(coroutineScope: CoroutineScope, mainRoom: Room, eventId: String?): Timeline { - val settings = timelineSettingsFactory.create() + fun createTimeline( + coroutineScope: CoroutineScope, + mainRoom: Room, + eventId: String?, + rootThreadEventId: String? + ): Timeline { + val settings = timelineSettingsFactory.create(rootThreadEventId) + if (!session.vectorCallService.protocolChecker.supportVirtualRooms) { return mainRoom.createTimeline(eventId, settings) } 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 dfe1cc1d9b..b41e1d8f25 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 @@ -44,8 +44,17 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*> { val event = params.event val computedModel = try { - if (!timelineEventVisibilityHelper.shouldShowEvent(event, params.highlightedEventId)) { - return buildEmptyItem(event, params.prevEvent, params.highlightedEventId) + if (!timelineEventVisibilityHelper.shouldShowEvent( + timelineEvent = event, + highlightedEventId = params.highlightedEventId, + isFromThreadTimeline = params.isFromThreadTimeline(), + rootThreadEventId = params.rootThreadEventId)) { + return buildEmptyItem( + event, + params.prevEvent, + params.highlightedEventId, + params.rootThreadEventId, + params.isFromThreadTimeline()) } // Manage state event differently, to check validity @@ -134,11 +143,24 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me Timber.e(throwable, "failed to create message item") defaultItemFactory.create(params, throwable) } - return computedModel ?: buildEmptyItem(event, params.prevEvent, params.highlightedEventId) + return computedModel ?: buildEmptyItem( + event, + params.prevEvent, + params.highlightedEventId, + params.rootThreadEventId, + params.isFromThreadTimeline()) } - private fun buildEmptyItem(timelineEvent: TimelineEvent, prevEvent: TimelineEvent?, highlightedEventId: String?): TimelineEmptyItem { - val isNotBlank = prevEvent == null || timelineEventVisibilityHelper.shouldShowEvent(prevEvent, highlightedEventId) + private fun buildEmptyItem(timelineEvent: TimelineEvent, + prevEvent: TimelineEvent?, + highlightedEventId: String?, + rootThreadEventId: String?, + isFromThreadTimeline: Boolean): TimelineEmptyItem { + val isNotBlank = prevEvent == null || timelineEventVisibilityHelper.shouldShowEvent( + timelineEvent = prevEvent, + highlightedEventId = highlightedEventId, + isFromThreadTimeline = isFromThreadTimeline, + rootThreadEventId = rootThreadEventId) return TimelineEmptyItem_() .id(timelineEvent.localId) .eventId(timelineEvent.eventId) 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 cdfedb2925..46ae01a794 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 @@ -23,6 +23,7 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent data class TimelineItemFactoryParams( val event: TimelineEvent, val prevEvent: TimelineEvent? = null, + val prevDisplayableEvent: TimelineEvent? = null, val nextEvent: TimelineEvent? = null, val nextDisplayableEvent: TimelineEvent? = null, val partialState: TimelineEventController.PartialState = TimelineEventController.PartialState(), @@ -34,5 +35,10 @@ data class TimelineItemFactoryParams( val highlightedEventId: String? get() = partialState.highlightedEventId + val rootThreadEventId: String? + get() = partialState.rootThreadEventId + val isHighlighted = highlightedEventId == event.eventId + + fun isFromThreadTimeline(): Boolean = rootThreadEventId != null } 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 ae541217bf..c7be395693 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 @@ -25,6 +25,7 @@ import org.matrix.android.sdk.api.extensions.appendNl import org.matrix.android.sdk.api.extensions.orFalse 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.isThread import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.GuestAccess import org.matrix.android.sdk.api.session.room.model.Membership @@ -104,6 +105,7 @@ class NoticeEventFormatter @Inject constructor( EventType.STATE_SPACE_CHILD, EventType.STATE_SPACE_PARENT, EventType.REDACTION, + EventType.STICKER, EventType.POLL_RESPONSE, EventType.POLL_END -> formatDebug(timelineEvent.root) else -> { @@ -194,7 +196,8 @@ class NoticeEventFormatter @Inject constructor( } private fun formatDebug(event: Event): CharSequence { - return "Debug: event type \"${event.getClearType()}\"" + val threadPrefix = if (event.isThread()) "thread" else "" + return "Debug: $threadPrefix event type \"${event.getClearType()}\"" } private fun formatRoomCreateEvent(event: Event, isDm: Boolean): CharSequence? { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AvatarSizeProvider.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AvatarSizeProvider.kt index 5fc5deb407..a34c216fad 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AvatarSizeProvider.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AvatarSizeProvider.kt @@ -17,14 +17,22 @@ package im.vector.app.features.home.room.detail.timeline.helper import im.vector.app.core.utils.DimensionConverter +import im.vector.app.features.home.room.detail.timeline.style.TimelineLayoutSettings +import im.vector.app.features.home.room.detail.timeline.style.TimelineLayoutSettingsProvider import javax.inject.Inject -class AvatarSizeProvider @Inject constructor(private val dimensionConverter: DimensionConverter) { +class AvatarSizeProvider @Inject constructor(private val dimensionConverter: DimensionConverter, + private val layoutSettingsProvider: TimelineLayoutSettingsProvider) { - private val avatarStyle = AvatarStyle.SMALL + private val avatarStyle by lazy { + when (layoutSettingsProvider.getLayoutSettings()) { + TimelineLayoutSettings.MODERN -> AvatarStyle.SMALL + TimelineLayoutSettings.BUBBLE -> AvatarStyle.BUBBLE + } + } val leftGuideline: Int by lazy { - dimensionConverter.dpToPx(avatarStyle.avatarSizeDP + 8) + dimensionConverter.dpToPx(avatarStyle.avatarSizeDP + avatarStyle.marginDP) } val avatarSize: Int by lazy { @@ -33,11 +41,12 @@ class AvatarSizeProvider @Inject constructor(private val dimensionConverter: Dim companion object { - enum class AvatarStyle(val avatarSizeDP: Int) { - BIG(50), - MEDIUM(40), - SMALL(30), - NONE(0) + enum class AvatarStyle(val avatarSizeDP: Int, val marginDP: Int) { + BIG(50, 8), + MEDIUM(40, 8), + SMALL(30, 8), + BUBBLE(28, 4), + NONE(0, 8) } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/ContentDownloadStateTrackerBinder.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/ContentDownloadStateTrackerBinder.kt index caf0131144..8f5f80c834 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/ContentDownloadStateTrackerBinder.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/ContentDownloadStateTrackerBinder.kt @@ -22,16 +22,12 @@ import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat import dagger.hilt.android.scopes.ActivityScoped import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder -import im.vector.app.core.error.ErrorFormatter -import im.vector.app.features.home.room.detail.timeline.MessageColorProvider import im.vector.app.features.home.room.detail.timeline.item.MessageFileItem import org.matrix.android.sdk.api.session.file.ContentDownloadStateTracker import javax.inject.Inject @ActivityScoped -class ContentDownloadStateTrackerBinder @Inject constructor(private val activeSessionHolder: ActiveSessionHolder, - private val messageColorProvider: MessageColorProvider, - private val errorFormatter: ErrorFormatter) { +class ContentDownloadStateTrackerBinder @Inject constructor(private val activeSessionHolder: ActiveSessionHolder) { private val updateListeners = mutableMapOf() @@ -39,7 +35,7 @@ class ContentDownloadStateTrackerBinder @Inject constructor(private val activeSe holder: MessageFileItem.Holder) { activeSessionHolder.getSafeActiveSession()?.also { session -> val downloadStateTracker = session.contentDownloadProgressTracker() - val updateListener = ContentDownloadUpdater(holder, messageColorProvider, errorFormatter) + val updateListener = ContentDownloadUpdater(holder) updateListeners[mxcUrl] = updateListener downloadStateTracker.track(mxcUrl, updateListener) } @@ -62,9 +58,7 @@ class ContentDownloadStateTrackerBinder @Inject constructor(private val activeSe } } -private class ContentDownloadUpdater(private val holder: MessageFileItem.Holder, - private val messageColorProvider: MessageColorProvider, - private val errorFormatter: ErrorFormatter) : ContentDownloadStateTracker.UpdateListener { +private class ContentDownloadUpdater(private val holder: MessageFileItem.Holder) : ContentDownloadStateTracker.UpdateListener { override fun onDownloadStateUpdate(state: ContentDownloadStateTracker.State) { when (state) { @@ -124,7 +118,7 @@ private class ContentDownloadUpdater(private val holder: MessageFileItem.Holder, private fun handleSuccess() { stop() holder.fileDownloadProgress.isIndeterminate = false - holder.fileDownloadProgress.progress = 100 + holder.fileDownloadProgress.progress = 0 holder.fileImageView.setImageResource(R.drawable.ic_paperclip) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/LocationPinProvider.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/LocationPinProvider.kt index fe3a7d9007..0cf30c8c01 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/LocationPinProvider.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/LocationPinProvider.kt @@ -28,6 +28,7 @@ import im.vector.app.core.glide.GlideApp import im.vector.app.core.utils.DimensionConverter import im.vector.app.features.home.AvatarRenderer import org.matrix.android.sdk.api.util.toMatrixItem +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -44,7 +45,17 @@ class LocationPinProvider @Inject constructor( GlideApp.with(context) } - fun create(userId: String, callback: (Drawable) -> Unit) { + /** + * Creates a pin drawable. If userId is null then a generic pin drawable will be created. + * @param userId userId that will be used to retrieve user avatar + * @param callback Pin drawable will be sent through the callback + */ + fun create(userId: String?, callback: (Drawable) -> Unit) { + if (userId == null) { + callback(ContextCompat.getDrawable(context, R.drawable.ic_location_pin)!!) + return + } + if (cache.contains(userId)) { callback(cache[userId]!!) return @@ -54,22 +65,36 @@ class LocationPinProvider @Inject constructor( val size = dimensionConverter.dpToPx(44) avatarRenderer.render(glideRequests, it, object : CustomTarget(size, size) { override fun onResourceReady(resource: Drawable, transition: Transition?) { - val bgUserPin = ContextCompat.getDrawable(context, R.drawable.bg_map_user_pin)!! - val layerDrawable = LayerDrawable(arrayOf(bgUserPin, resource)) - val horizontalInset = dimensionConverter.dpToPx(4) - val topInset = dimensionConverter.dpToPx(4) - val bottomInset = dimensionConverter.dpToPx(8) - layerDrawable.setLayerInset(1, horizontalInset, topInset, horizontalInset, bottomInset) - - cache[userId] = layerDrawable - - callback(layerDrawable) + Timber.d("## Location: onResourceReady") + val pinDrawable = createPinDrawable(resource) + cache[userId] = pinDrawable + callback(pinDrawable) } override fun onLoadCleared(placeholder: Drawable?) { // Is it possible? Put placeholder instead? + // FIXME The doc says it has to be implemented and should free resources + Timber.d("## Location: onLoadCleared") + } + + override fun onLoadFailed(errorDrawable: Drawable?) { + Timber.w("## Location: onLoadFailed") + errorDrawable ?: return + val pinDrawable = createPinDrawable(errorDrawable) + cache[userId] = pinDrawable + callback(pinDrawable) } }) } } + + private fun createPinDrawable(drawable: Drawable): Drawable { + val bgUserPin = ContextCompat.getDrawable(context, R.drawable.bg_map_user_pin)!! + val layerDrawable = LayerDrawable(arrayOf(bgUserPin, drawable)) + val horizontalInset = dimensionConverter.dpToPx(4) + val topInset = dimensionConverter.dpToPx(4) + val bottomInset = dimensionConverter.dpToPx(8) + layerDrawable.setLayerInset(1, horizontalInset, topInset, horizontalInset, bottomInset) + return layerDrawable + } } 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 b30286163e..59b39d17ef 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 @@ -24,10 +24,9 @@ import im.vector.app.features.home.room.detail.timeline.item.E2EDecoration import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData import im.vector.app.features.home.room.detail.timeline.item.PollResponseData import im.vector.app.features.home.room.detail.timeline.item.PollVoteSummaryData -import im.vector.app.features.home.room.detail.timeline.item.ReactionInfoData import im.vector.app.features.home.room.detail.timeline.item.ReferencesInfoData import im.vector.app.features.home.room.detail.timeline.item.SendStateDecoration -import im.vector.app.features.settings.VectorPreferences +import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayoutFactory import org.matrix.android.sdk.api.crypto.VerificationState import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.Session @@ -41,7 +40,6 @@ import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.hasBeenEdited -import org.matrix.android.sdk.api.session.room.timeline.isEdition import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent import javax.inject.Inject @@ -51,35 +49,29 @@ import javax.inject.Inject */ class MessageInformationDataFactory @Inject constructor(private val session: Session, private val dateFormatter: VectorDateFormatter, - private val visibilityHelper: TimelineEventVisibilityHelper, - private val vectorPreferences: VectorPreferences) { + private val messageLayoutFactory: TimelineMessageLayoutFactory, + private val reactionsSummaryFactory: ReactionsSummaryFactory) { fun create(params: TimelineItemFactoryParams): MessageInformationData { val event = params.event val nextDisplayableEvent = params.nextDisplayableEvent + val prevDisplayableEvent = params.prevDisplayableEvent val eventId = event.eventId + val isSentByMe = event.root.senderId == session.myUserId + val roomSummary = params.partialState.roomSummary val date = event.root.localDateTime() val nextDate = nextDisplayableEvent?.root?.localDateTime() val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate() - val isNextMessageReceivedMoreThanOneHourAgo = nextDate?.isBefore(date.minusMinutes(60)) - ?: false - val showInformation = - addDaySeparator || - event.senderInfo.avatarUrl != nextDisplayableEvent?.senderInfo?.avatarUrl || - event.senderInfo.disambiguatedDisplayName != nextDisplayableEvent?.senderInfo?.disambiguatedDisplayName || - nextDisplayableEvent.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER, EventType.ENCRYPTED) || - isNextMessageReceivedMoreThanOneHourAgo || - isTileTypeMessage(nextDisplayableEvent) || - nextDisplayableEvent.isEdition() + val isFirstFromThisSender = nextDisplayableEvent?.root?.senderId != event.root.senderId || addDaySeparator + val isLastFromThisSender = prevDisplayableEvent?.root?.senderId != event.root.senderId || + prevDisplayableEvent?.root?.localDateTime()?.toLocalDate() != date.toLocalDate() val time = dateFormatter.format(event.root.originServerTs, DateFormatKind.MESSAGE_SIMPLE) - val roomSummary = params.partialState.roomSummary val e2eDecoration = getE2EDecoration(roomSummary, event) // SendState Decoration - val isSentByMe = event.root.senderId == session.myUserId val sendStateDecoration = if (isSentByMe) { getSendStateDecoration( event = event, @@ -90,6 +82,8 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses SendStateDecoration.NONE } + val messageLayout = messageLayoutFactory.create(params) + return MessageInformationData( eventId = eventId, senderId = event.root.senderId ?: "", @@ -98,13 +92,8 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses ageLocalTS = event.root.ageLocalTs, avatarUrl = event.senderInfo.avatarUrl, memberName = event.senderInfo.disambiguatedDisplayName, - showInformation = showInformation, - forceShowTimestamp = vectorPreferences.alwaysShowTimeStamps(), - orderedReactionList = event.annotations?.reactionsSummary - // ?.filter { isSingleEmoji(it.key) } - ?.map { - ReactionInfoData(it.key, it.count, it.addedByMe, it.localEchoEvents.isEmpty()) - }, + messageLayout = messageLayout, + reactionsSummary = reactionsSummaryFactory.create(event, params.callback), pollResponseAggregatedSummary = event.annotations?.pollResponseSummary?.let { PollResponseData( myVote = it.aggregatedContent?.myVote, @@ -127,6 +116,8 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses ReferencesInfoData(verificationState) }, sentByMe = isSentByMe, + isFirstFromThisSender = isFirstFromThisSender, + isLastFromThisSender = isLastFromThisSender, e2eDecoration = e2eDecoration, sendStateDecoration = sendStateDecoration ) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt index 679613d262..845b765101 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt @@ -16,22 +16,29 @@ package im.vector.app.features.home.room.detail.timeline.helper import im.vector.app.EmojiCompatFontProvider +import im.vector.app.R +import im.vector.app.core.resources.StringProvider +import im.vector.app.core.resources.UserPreferencesProvider import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.timeline.MessageColorProvider import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData +import org.matrix.android.sdk.api.session.threads.ThreadDetails import javax.inject.Inject class MessageItemAttributesFactory @Inject constructor( private val avatarRenderer: AvatarRenderer, private val messageColorProvider: MessageColorProvider, private val avatarSizeProvider: AvatarSizeProvider, + private val stringProvider: StringProvider, + private val preferencesProvider: UserPreferencesProvider, private val emojiCompatFontProvider: EmojiCompatFontProvider) { fun create(messageContent: Any?, informationData: MessageInformationData, - callback: TimelineEventController.Callback?): AbsMessageItem.Attributes { + callback: TimelineEventController.Callback?, + threadDetails: ThreadDetails? = null): AbsMessageItem.Attributes { return AbsMessageItem.Attributes( avatarSize = avatarSizeProvider.avatarSize, informationData = informationData, @@ -41,15 +48,19 @@ class MessageItemAttributesFactory @Inject constructor( callback?.onEventLongClicked(informationData, messageContent, view) ?: false }, itemClickListener = { view -> - callback?.onEventCellClicked(informationData, messageContent, view) + callback?.onEventCellClicked(informationData, messageContent, view, threadDetails?.isRootThread ?: false) }, memberClickListener = { callback?.onMemberNameClicked(informationData) }, reactionPillCallback = callback, avatarCallback = callback, + threadCallback = callback, readReceiptsCallback = callback, - emojiTypeFace = emojiCompatFontProvider.typeface + emojiTypeFace = emojiCompatFontProvider.typeface, + decryptionErrorMessage = stringProvider.getString(R.string.encrypted_message), + threadDetails = threadDetails, + areThreadMessagesEnabled = preferencesProvider.areThreadMessagesEnabled() ) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/ReactionsSummaryFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/ReactionsSummaryFactory.kt new file mode 100644 index 0000000000..fcc98ff729 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/ReactionsSummaryFactory.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.helper + +import dagger.hilt.android.scopes.ActivityScoped +import im.vector.app.features.home.room.detail.timeline.TimelineEventController +import im.vector.app.features.home.room.detail.timeline.item.ReactionInfoData +import im.vector.app.features.home.room.detail.timeline.item.ReactionsSummaryData +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import javax.inject.Inject + +@ActivityScoped +class ReactionsSummaryFactory @Inject constructor() { + + var onRequestBuild: (() -> Unit)? = null + private val showAllReactionsByEvent = HashSet() + private val eventsRequestingBuild = HashSet() + + fun needsRebuild(event: TimelineEvent): Boolean { + return eventsRequestingBuild.remove(event.eventId) + } + + fun create(event: TimelineEvent, callback: TimelineEventController.Callback?): ReactionsSummaryData { + val eventId = event.eventId + val showAllStates = showAllReactionsByEvent.contains(eventId) + val reactions = event.annotations?.reactionsSummary + ?.map { + ReactionInfoData(it.key, it.count, it.addedByMe, it.localEchoEvents.isEmpty()) + } + return ReactionsSummaryData( + reactions = reactions, + showAll = showAllStates, + onShowMoreClicked = { + showAllReactionsByEvent.add(eventId) + onRequestBuild(eventId) + }, + onShowLessClicked = { + showAllReactionsByEvent.remove(eventId) + onRequestBuild(eventId) + }, + onAddMoreClicked = { + callback?.onAddMoreReaction(event) + } + ) + } + + private fun onRequestBuild(eventId: String) { + eventsRequestingBuild.add(eventId) + onRequestBuild?.invoke() + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineControllerInterceptorHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineControllerInterceptorHelper.kt index 7165921b35..8a0e1e18fd 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineControllerInterceptorHelper.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineControllerInterceptorHelper.kt @@ -115,7 +115,10 @@ class TimelineControllerInterceptorHelper(private val positionOfReadMarker: KMut private fun MutableList>.addForwardPrefetchIfNeeded(timeline: Timeline?, callback: TimelineEventController.Callback?) { val shouldAddForwardPrefetch = timeline?.hasMoreToLoad(Timeline.Direction.FORWARDS) ?: false if (shouldAddForwardPrefetch) { - val indexOfPrefetchForward = DEFAULT_PREFETCH_THRESHOLD.coerceAtMost(size - 1) + val indexOfPrefetchForward = DEFAULT_PREFETCH_THRESHOLD + .coerceAtMost(size - 1) + .coerceAtLeast(0) + val loadingItem = LoadingItem_() .id("prefetch_forward_loading${System.currentTimeMillis()}") .showLoader(false) 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 580d7d18cf..f317eb4f9a 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 @@ -21,6 +21,8 @@ import im.vector.app.core.resources.UserPreferencesProvider 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.getRootThreadEventId +import org.matrix.android.sdk.api.session.events.model.isThread 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 @@ -37,7 +39,13 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen * * @return a list of timeline events which have sequentially the same type following the next direction. */ - fun nextSameTypeEvents(timelineEvents: List, index: Int, minSize: Int, eventIdToHighlight: String?): List { + private fun nextSameTypeEvents( + timelineEvents: List, + index: Int, + minSize: Int, + eventIdToHighlight: String?, + rootThreadEventId: String?, + isFromThreadTimeline: Boolean): List { if (index >= timelineEvents.size - 1) { return emptyList() } @@ -59,11 +67,18 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen } else { nextSameDayEvents.subList(0, indexOfFirstDifferentEventType) } - val filteredSameTypeEvents = sameTypeEvents.filter { shouldShowEvent(it, eventIdToHighlight) } + val filteredSameTypeEvents = sameTypeEvents.filter { + shouldShowEvent( + timelineEvent = it, + highlightedEventId = eventIdToHighlight, + isFromThreadTimeline = isFromThreadTimeline, + rootThreadEventId = rootThreadEventId + ) + } if (filteredSameTypeEvents.size < minSize) { return emptyList() } - return filteredSameTypeEvents + return filteredSameTypeEvents } /** @@ -74,23 +89,35 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen * * @return a list of timeline events which have sequentially the same type following the prev direction. */ - fun prevSameTypeEvents(timelineEvents: List, index: Int, minSize: Int, eventIdToHighlight: String?): List { + fun prevSameTypeEvents( + timelineEvents: List, + index: Int, + minSize: Int, + eventIdToHighlight: String?, + rootThreadEventId: String?, + isFromThreadTimeline: Boolean): List { val prevSub = timelineEvents.subList(0, index + 1) return prevSub .reversed() .let { - nextSameTypeEvents(it, 0, minSize, eventIdToHighlight) + nextSameTypeEvents(it, 0, minSize, eventIdToHighlight, rootThreadEventId, isFromThreadTimeline) } } /** * @param timelineEvent the event to check for visibility * @param highlightedEventId can be checked to force visibility to true + * @param rootThreadEventId if this param is null it means we are in the original timeline * @return true if the event should be shown in the timeline. */ - fun shouldShowEvent(timelineEvent: TimelineEvent, highlightedEventId: String?): Boolean { + fun shouldShowEvent( + timelineEvent: TimelineEvent, + highlightedEventId: String?, + isFromThreadTimeline: Boolean, + rootThreadEventId: String? + ): Boolean { // If show hidden events is true we should always display something - if (userPreferencesProvider.shouldShowHiddenEvents()) { + if (userPreferencesProvider.shouldShowHiddenEvents() && !isFromThreadTimeline) { return true } // We always show highlighted event @@ -100,18 +127,35 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen if (!timelineEvent.isDisplayable()) { return false } + // Check for special case where we should hide the event, like redacted, relation, memberships... according to user preferences. - return !timelineEvent.shouldBeHidden() + return !timelineEvent.shouldBeHidden(rootThreadEventId, isFromThreadTimeline) } private fun TimelineEvent.isDisplayable(): Boolean { return TimelineDisplayableEvents.DISPLAYABLE_TYPES.contains(root.getClearType()) } - private fun TimelineEvent.shouldBeHidden(): Boolean { - if (root.isRedacted() && !userPreferencesProvider.shouldShowRedactedMessages()) { + private fun TimelineEvent.shouldBeHidden(rootThreadEventId: String?, isFromThreadTimeline: Boolean): Boolean { + if (root.isRedacted() && !userPreferencesProvider.shouldShowRedactedMessages() && root.threadDetails?.isRootThread == false) { return true } + + // We should not display deleted thread messages within the normal timeline + if (root.isRedacted() && + userPreferencesProvider.areThreadMessagesEnabled() && + !isFromThreadTimeline && + (root.isThread() || root.threadDetails?.isThread == true)) { + return true + } + if (root.isRedacted() && + !userPreferencesProvider.shouldShowRedactedMessages() && + userPreferencesProvider.areThreadMessagesEnabled() && + isFromThreadTimeline && + root.isThread()) { + return true + } + if (root.getRelationContent()?.type == RelationType.REPLACE) { return true } @@ -120,6 +164,18 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen if ((diff.isJoin || diff.isPart) && !userPreferencesProvider.shouldShowJoinLeaves()) return true if ((diff.isAvatarChange || diff.isDisplaynameChange) && !userPreferencesProvider.shouldShowAvatarDisplayNameChanges()) return true } + + if (userPreferencesProvider.areThreadMessagesEnabled() && !isFromThreadTimeline && root.isThread()) { + return true + } + + // Allow only the the threads within the rootThreadEventId along with the root event + if (userPreferencesProvider.areThreadMessagesEnabled() && isFromThreadTimeline) { + return if (root.getRootThreadEventId() == rootThreadEventId) { + false + } else root.eventId != rootThreadEventId + } + return false } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt index 3910204293..4ff8a9fa43 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt @@ -108,11 +108,8 @@ class CallSignalingEventsGroup(private val group: TimelineEventsGroup) { } } - /** - * Returns true if there are only events from one side. - */ - fun callWasMissed(): Boolean { - return group.events.distinctBy { it.senderInfo.userId }.size == 1 + fun callWasAnswered(): Boolean { + return getAnswer() != null } private fun getAnswer(): TimelineEvent? { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineMediaSizeProvider.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineMediaSizeProvider.kt index 9ec61e6054..53c2f6c0d4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineMediaSizeProvider.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineMediaSizeProvider.kt @@ -16,13 +16,17 @@ package im.vector.app.features.home.room.detail.timeline.helper +import android.content.res.Resources import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.scopes.ActivityScoped +import im.vector.app.R +import im.vector.app.features.settings.VectorPreferences import javax.inject.Inject import kotlin.math.roundToInt @ActivityScoped -class TimelineMediaSizeProvider @Inject constructor() { +class TimelineMediaSizeProvider @Inject constructor(private val resources: Resources, + private val vectorPreferences: VectorPreferences) { var recyclerView: RecyclerView? = null private var cachedSize: Pair? = null @@ -41,9 +45,14 @@ class TimelineMediaSizeProvider @Inject constructor() { maxImageWidth = (width * 0.7f).roundToInt() maxImageHeight = (height * 0.5f).roundToInt() } else { - maxImageWidth = (width * 0.5f).roundToInt() + maxImageWidth = (width * 0.7f).roundToInt() maxImageHeight = (height * 0.7f).roundToInt() } - return Pair(maxImageWidth, maxImageHeight) + return if (vectorPreferences.useMessageBubblesLayout()) { + val bubbleMaxImageWidth = maxImageWidth.coerceAtMost(resources.getDimensionPixelSize(R.dimen.chat_bubble_fixed_size)) + Pair(bubbleMaxImageWidth, maxImageHeight) + } else { + Pair(maxImageWidth, maxImageHeight) + } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineSettingsFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineSettingsFactory.kt index 3aee65bf19..8b7dcc9c72 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineSettingsFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineSettingsFactory.kt @@ -22,9 +22,11 @@ import javax.inject.Inject class TimelineSettingsFactory @Inject constructor(private val userPreferencesProvider: UserPreferencesProvider) { - fun create(): TimelineSettings { + fun create(rootThreadEventId: String?): TimelineSettings { return TimelineSettings( initialSize = 30, - buildReadReceipts = userPreferencesProvider.shouldShowReadReceipts()) + buildReadReceipts = userPreferencesProvider.shouldShowReadReceipts(), + rootThreadEventId = rootThreadEventId + ) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsBaseMessageItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsBaseMessageItem.kt index 080b766258..23f2aceff5 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsBaseMessageItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsBaseMessageItem.kt @@ -16,23 +16,34 @@ package im.vector.app.features.home.room.detail.timeline.item +import android.annotation.SuppressLint +import android.graphics.Typeface import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView import androidx.annotation.IdRes +import androidx.appcompat.view.ContextThemeWrapper +import androidx.core.content.ContextCompat.getDrawable import androidx.core.view.isVisible +import androidx.core.widget.TextViewCompat import im.vector.app.R import im.vector.app.core.epoxy.ClickListener import im.vector.app.core.epoxy.onClick +import im.vector.app.core.extensions.getDrawableAsSpannable import im.vector.app.core.ui.views.ShieldImageView +import im.vector.app.core.utils.DimensionConverter import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.timeline.MessageColorProvider import im.vector.app.features.home.room.detail.timeline.TimelineEventController +import im.vector.app.features.home.room.detail.timeline.view.TimelineMessageLayoutRenderer import im.vector.app.features.reactions.widget.ReactionButton +import im.vector.app.features.themes.ThemeUtils import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel import org.matrix.android.sdk.api.session.room.send.SendState +private const val MAX_REACTIONS_TO_SHOW = 8 + /** * Base timeline item with reactions and read receipts. * Manages associated click listeners and send status. @@ -64,27 +75,10 @@ abstract class AbsBaseMessageItem : BaseEventItem return listOf(baseAttributes.informationData.eventId) } + @SuppressLint("SetTextI18n") override fun bind(holder: H) { super.bind(holder) - val reactions = baseAttributes.informationData.orderedReactionList - if (!shouldShowReactionAtBottom() || reactions.isNullOrEmpty()) { - holder.reactionsContainer.isVisible = false - } else { - holder.reactionsContainer.isVisible = true - holder.reactionsContainer.removeAllViews() - reactions.take(8).forEach { reaction -> - val reactionButton = ReactionButton(holder.view.context) - reactionButton.reactedListener = reactionClickListener - reactionButton.setTag(R.id.reactionsContainer, reaction.key) - reactionButton.reactionString = reaction.key - reactionButton.reactionCount = reaction.count - reactionButton.setChecked(reaction.addedByMe) - reactionButton.isEnabled = reaction.synced - holder.reactionsContainer.addView(reactionButton) - } - holder.reactionsContainer.setOnLongClickListener(baseAttributes.itemLongClickListener) - } - + renderReactions(holder, baseAttributes.informationData.reactionsSummary) when (baseAttributes.informationData.e2eDecoration) { E2EDecoration.NONE -> { holder.e2EDecorationView.render(null) @@ -98,6 +92,59 @@ abstract class AbsBaseMessageItem : BaseEventItem holder.view.onClick(baseAttributes.itemClickListener) holder.view.setOnLongClickListener(baseAttributes.itemLongClickListener) + (holder.view as? TimelineMessageLayoutRenderer)?.renderMessageLayout(baseAttributes.informationData.messageLayout) + } + + private fun renderReactions(holder: H, reactionsSummary: ReactionsSummaryData) { + val reactions = reactionsSummary.reactions + if (!shouldShowReactionAtBottom() || reactions.isNullOrEmpty()) { + holder.reactionsContainer.isVisible = false + } else { + holder.reactionsContainer.isVisible = true + holder.reactionsContainer.removeAllViews() + val reactionsToShow = if (reactionsSummary.showAll) { + reactions + } else { + reactions.take(MAX_REACTIONS_TO_SHOW) + } + reactionsToShow.forEach { reaction -> + val reactionButton = ReactionButton(holder.view.context) + reactionButton.reactedListener = reactionClickListener + reactionButton.setTag(R.id.reactionsContainer, reaction.key) + reactionButton.reactionString = reaction.key + reactionButton.reactionCount = reaction.count + reactionButton.setChecked(reaction.addedByMe) + reactionButton.isEnabled = reaction.synced + holder.reactionsContainer.addView(reactionButton) + } + if (reactions.count() > MAX_REACTIONS_TO_SHOW) { + val showReactionsTextView = createReactionTextView(holder) + if (reactionsSummary.showAll) { + showReactionsTextView.setText(R.string.message_reaction_show_less) + showReactionsTextView.onClick { reactionsSummary.onShowLessClicked() } + } else { + val moreCount = reactions.count() - MAX_REACTIONS_TO_SHOW + showReactionsTextView.text = holder.view.resources.getString(R.string.message_reaction_show_more, moreCount) + showReactionsTextView.onClick { reactionsSummary.onShowMoreClicked() } + } + holder.reactionsContainer.addView(showReactionsTextView) + } + val addMoreReactionsTextView = createReactionTextView(holder) + + addMoreReactionsTextView.text = holder.view.context.getDrawableAsSpannable(R.drawable.ic_add_reaction_small) + addMoreReactionsTextView.onClick { reactionsSummary.onAddMoreClicked() } + holder.reactionsContainer.addView(addMoreReactionsTextView) + holder.reactionsContainer.setOnLongClickListener(baseAttributes.itemLongClickListener) + } + } + + private fun createReactionTextView(holder: H): TextView { + return TextView(ContextThemeWrapper(holder.view.context, R.style.TimelineReactionView)).apply { + background = getDrawable(context, R.drawable.reaction_rounded_rect_shape_off) + TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Vector_Micro) + setTypeface(typeface, Typeface.BOLD) + setTextColor(ThemeUtils.getColor(context, R.attr.vctr_content_secondary)) + } } override fun unbind(holder: H) { @@ -113,6 +160,9 @@ abstract class AbsBaseMessageItem : BaseEventItem } abstract class Holder(@IdRes stubId: Int) : BaseEventItem.BaseHolder(stubId) { + val dimensionConverter by lazy { + DimensionConverter(view.resources) + } val reactionsContainer by bind(R.id.reactionsContainer) val e2EDecorationView by bind(R.id.messageE2EDecoration) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt index b53495fdaf..9e8f86c26e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -19,11 +19,14 @@ package im.vector.app.features.home.room.detail.timeline.item import android.graphics.Typeface import android.view.View import android.widget.ImageView +import android.widget.LinearLayout import android.widget.ProgressBar +import android.widget.RelativeLayout import android.widget.TextView import androidx.annotation.IdRes -import androidx.core.view.isInvisible +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import com.airbnb.epoxy.EpoxyAttribute import im.vector.app.R import im.vector.app.core.epoxy.ClickListener @@ -32,6 +35,8 @@ import im.vector.app.core.ui.views.SendStateImageView import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.timeline.MessageColorProvider import im.vector.app.features.home.room.detail.timeline.TimelineEventController +import org.matrix.android.sdk.api.session.threads.ThreadDetails +import org.matrix.android.sdk.api.util.MatrixItem /** * Base timeline item that adds an optional information bar with the sender avatar, name, time, send state @@ -61,43 +66,77 @@ abstract class AbsMessageItem : AbsBaseMessageItem } } + private val _threadClickListener = object : ClickListener { + override fun invoke(p1: View) { + attributes.threadCallback?.onThreadSummaryClicked(attributes.informationData.eventId, attributes.threadDetails?.isRootThread ?: false) + } + } + override fun bind(holder: H) { super.bind(holder) - if (attributes.informationData.showInformation) { + if (attributes.informationData.messageLayout.showAvatar) { holder.avatarImageView.layoutParams = holder.avatarImageView.layoutParams?.apply { height = attributes.avatarSize width = attributes.avatarSize } - holder.avatarImageView.visibility = View.VISIBLE - holder.avatarImageView.onClick(_avatarClickListener) - holder.memberNameView.visibility = View.VISIBLE - holder.memberNameView.onClick(_memberNameClickListener) - holder.timeView.visibility = View.VISIBLE - holder.timeView.text = attributes.informationData.time - holder.memberNameView.text = attributes.informationData.memberName - holder.memberNameView.setTextColor(attributes.getMemberNameColor()) attributes.avatarRenderer.render(attributes.informationData.matrixItem, holder.avatarImageView) holder.avatarImageView.setOnLongClickListener(attributes.itemLongClickListener) - holder.memberNameView.setOnLongClickListener(attributes.itemLongClickListener) + holder.avatarImageView.isVisible = true + holder.avatarImageView.onClick(_avatarClickListener) } else { holder.avatarImageView.setOnClickListener(null) - holder.memberNameView.setOnClickListener(null) - holder.avatarImageView.visibility = View.GONE - if (attributes.informationData.forceShowTimestamp) { - holder.memberNameView.isInvisible = true - holder.timeView.isVisible = true - holder.timeView.text = attributes.informationData.time - } else { - holder.memberNameView.isVisible = false - holder.timeView.isVisible = false - } holder.avatarImageView.setOnLongClickListener(null) - holder.memberNameView.setOnLongClickListener(null) + holder.avatarImageView.isVisible = false + } + if (attributes.informationData.messageLayout.showDisplayName) { + holder.memberNameView.isVisible = true + holder.memberNameView.text = attributes.informationData.memberName + holder.memberNameView.setTextColor(attributes.getMemberNameColor()) + holder.memberNameView.onClick(_memberNameClickListener) + holder.memberNameView.setOnLongClickListener(attributes.itemLongClickListener) + } else { + holder.memberNameView.setOnClickListener(null) + holder.memberNameView.setOnLongClickListener(null) + holder.memberNameView.isVisible = false + } + if (attributes.informationData.messageLayout.showTimestamp) { + holder.timeView.isVisible = true + holder.timeView.text = attributes.informationData.time + } else { + holder.timeView.isVisible = false } - // Render send state indicator holder.sendStateImageView.render(attributes.informationData.sendStateDecoration) holder.eventSendingIndicator.isVisible = attributes.informationData.sendStateDecoration == SendStateDecoration.SENDING_MEDIA + + // Threads + if (attributes.areThreadMessagesEnabled) { + holder.threadSummaryConstraintLayout.onClick(_threadClickListener) + attributes.threadDetails?.let { threadDetails -> + holder.threadSummaryConstraintLayout.isVisible = threadDetails.isRootThread + holder.threadSummaryCounterTextView.text = threadDetails.numberOfThreads.toString() + holder.threadSummaryInfoTextView.text = threadDetails.threadSummaryLatestTextMessage ?: attributes.decryptionErrorMessage + + val userId = threadDetails.threadSummarySenderInfo?.userId ?: return@let + val displayName = threadDetails.threadSummarySenderInfo?.displayName + val avatarUrl = threadDetails.threadSummarySenderInfo?.avatarUrl + attributes.avatarRenderer.render(MatrixItem.UserItem(userId, displayName, avatarUrl), holder.threadSummaryAvatarImageView) + updateHighlightedMessageHeight(holder, true) + } ?: run { + holder.threadSummaryConstraintLayout.isVisible = false + updateHighlightedMessageHeight(holder, false) + } + } + } + + private fun updateHighlightedMessageHeight(holder: Holder, isExpanded: Boolean) { + holder.checkableBackground.updateLayoutParams { + if (isExpanded) { + addRule(RelativeLayout.ALIGN_BOTTOM, holder.threadSummaryConstraintLayout.id) + } else { + addRule(RelativeLayout.ALIGN_BOTTOM, holder.informationBottom.id) + } + } } override fun unbind(holder: H) { @@ -106,17 +145,25 @@ abstract class AbsMessageItem : AbsBaseMessageItem holder.avatarImageView.setOnLongClickListener(null) holder.memberNameView.setOnClickListener(null) holder.memberNameView.setOnLongClickListener(null) + attributes.avatarRenderer.clear(holder.threadSummaryAvatarImageView) + holder.threadSummaryConstraintLayout.setOnClickListener(null) super.unbind(holder) } private fun Attributes.getMemberNameColor() = messageColorProvider.getMemberNameTextColor(informationData.matrixItem) abstract class Holder(@IdRes stubId: Int) : AbsBaseMessageItem.Holder(stubId) { + val avatarImageView by bind(R.id.messageAvatarImageView) val memberNameView by bind(R.id.messageMemberNameView) val timeView by bind(R.id.messageTimeView) val sendStateImageView by bind(R.id.messageSendStateImageView) val eventSendingIndicator by bind(R.id.eventSendingIndicator) + val informationBottom by bind(R.id.informationBottom) + val threadSummaryConstraintLayout by bind(R.id.messageThreadSummaryConstraintLayout) + val threadSummaryCounterTextView by bind(R.id.messageThreadSummaryCounterTextView) + val threadSummaryAvatarImageView by bind(R.id.messageThreadSummaryAvatarImageView) + val threadSummaryInfoTextView by bind(R.id.messageThreadSummaryInfoTextView) } /** @@ -132,8 +179,12 @@ abstract class AbsMessageItem : AbsBaseMessageItem val memberClickListener: ClickListener? = null, override val reactionPillCallback: TimelineEventController.ReactionPillCallback? = null, val avatarCallback: TimelineEventController.AvatarCallback? = null, + val threadCallback: TimelineEventController.ThreadCallback? = null, override val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null, - val emojiTypeFace: Typeface? = null + val emojiTypeFace: Typeface? = null, + val decryptionErrorMessage: String? = null, + val threadDetails: ThreadDetails? = null, + val areThreadMessagesEnabled: Boolean = false ) : AbsBaseMessageItem.Attributes { // Have to override as it's used to diff epoxy items @@ -145,6 +196,7 @@ abstract class AbsMessageItem : AbsBaseMessageItem if (avatarSize != other.avatarSize) return false if (informationData != other.informationData) return false + if (threadDetails != other.threadDetails) return false return true } @@ -152,6 +204,8 @@ abstract class AbsMessageItem : AbsBaseMessageItem override fun hashCode(): Int { var result = avatarSize result = 31 * result + informationData.hashCode() + result = 31 * result + threadDetails.hashCode() + return result } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BaseEventItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BaseEventItem.kt index 5dfbf5d8f6..8ea761830a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BaseEventItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BaseEventItem.kt @@ -26,7 +26,6 @@ import im.vector.app.R import im.vector.app.core.epoxy.VectorEpoxyHolder import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.platform.CheckableView -import im.vector.app.core.utils.DimensionConverter /** * Children must override getViewType() @@ -40,8 +39,18 @@ abstract class BaseEventItem : VectorEpoxyModel @EpoxyAttribute open var leftGuideline: Int = 0 - @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) - lateinit var dimensionConverter: DimensionConverter + final override fun getViewType(): Int { + // This makes sure we have a unique integer for the combination of layout and ViewStubId. + val pairingResult = pairingFunction(layout.toLong(), getViewStubId().toLong()) + return (pairingResult - Int.MAX_VALUE).toInt() + } + + abstract fun getViewStubId(): Int + + // Szudzik function + private fun pairingFunction(a: Long, b: Long): Long { + return if (a >= b) a * a + a + b else a + b * b + } @CallSuper override fun bind(holder: H) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/CallTileTimelineItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/CallTileTimelineItem.kt index 5abc9d714c..6db0b0c380 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/CallTileTimelineItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/CallTileTimelineItem.kt @@ -50,7 +50,7 @@ abstract class CallTileTimelineItem : AbsBaseMessageItem() { return listOf(attributes.informationData.eventId) } - override fun getViewType() = STUB_ID + override fun getViewStubId() = STUB_ID class Holder : BaseHolder(STUB_ID) { val avatarImageView by bind(R.id.itemDefaultAvatarView) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedMembershipEventsItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedMembershipEventsItem.kt index a52ddf8336..e19dc33fff 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedMembershipEventsItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedMembershipEventsItem.kt @@ -29,7 +29,7 @@ import im.vector.app.features.home.AvatarRenderer @EpoxyModelClass(layout = R.layout.item_timeline_event_base_noinfo) abstract class MergedMembershipEventsItem : BasedMergedItem() { - override fun getViewType() = STUB_ID + override fun getViewStubId() = STUB_ID @EpoxyAttribute override lateinit var attributes: Attributes diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedRoomCreationItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedRoomCreationItem.kt index 1e8e96426f..9f631f7a0e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedRoomCreationItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedRoomCreationItem.kt @@ -51,7 +51,7 @@ abstract class MergedRoomCreationItem : BasedMergedItem() { - - @EpoxyAttribute - var message: EpoxyCharSequence? = null - - @EpoxyAttribute - var editedSpan: EpoxyCharSequence? = null - - override fun bind(holder: Holder) { - super.bind(holder) - holder.messageView.text = message?.charSequence - renderSendState(holder.messageView, holder.messageView) - holder.messageView.onClick(attributes.itemClickListener) - holder.messageView.setOnLongClickListener(attributes.itemLongClickListener) - holder.editedView.movementMethod = BetterLinkMovementMethod.getInstance() - holder.editedView.setTextOrHide(editedSpan?.charSequence) - } - - override fun getViewType() = STUB_ID - - class Holder : AbsMessageItem.Holder(STUB_ID) { - val messageView by bind(R.id.codeBlockTextView) - val editedView by bind(R.id.codeBlockEditedView) - } - - companion object { - private const val STUB_ID = R.id.messageContentCodeBlockStub - } -} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt index b15f909b79..8b6899daee 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt @@ -16,6 +16,8 @@ package im.vector.app.features.home.room.detail.timeline.item +import android.content.res.ColorStateList +import android.graphics.Color import android.graphics.Paint import android.view.ViewGroup import android.widget.ImageView @@ -29,6 +31,8 @@ import im.vector.app.R import im.vector.app.core.epoxy.onClick import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder +import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout +import im.vector.app.features.themes.ThemeUtils @EpoxyModelClass(layout = R.layout.item_timeline_event_base) abstract class MessageFileItem : AbsMessageItem() { @@ -73,15 +77,19 @@ abstract class MessageFileItem : AbsMessageItem() { } else { if (izDownloaded) { holder.fileImageView.setImageResource(iconRes) - holder.fileDownloadProgress.progress = 100 + holder.fileDownloadProgress.progress = 0 } else { contentDownloadStateTrackerBinder.bind(mxcUrl, holder) holder.fileImageView.setImageResource(R.drawable.ic_download) - holder.fileDownloadProgress.progress = 0 } } // holder.view.setOnClickListener(clickListener) - + val backgroundTint = if (attributes.informationData.messageLayout is TimelineMessageLayout.Bubble) { + Color.TRANSPARENT + } else { + ThemeUtils.getColor(holder.view.context, R.attr.vctr_content_quinary) + } + holder.mainLayout.backgroundTintList = ColorStateList.valueOf(backgroundTint) holder.filenameView.onClick(attributes.itemClickListener) holder.filenameView.setOnLongClickListener(attributes.itemLongClickListener) holder.fileImageWrapper.onClick(attributes.itemClickListener) @@ -95,9 +103,10 @@ abstract class MessageFileItem : AbsMessageItem() { contentDownloadStateTrackerBinder.unbind(mxcUrl) } - override fun getViewType() = STUB_ID + override fun getViewStubId() = STUB_ID class Holder : AbsMessageItem.Holder(STUB_ID) { + val mainLayout by bind(R.id.messageFileMainLayout) val progressLayout by bind(R.id.messageFileUploadProgressLayout) val fileLayout by bind(R.id.messageFileLayout) val fileImageView by bind(R.id.messageFileIconView) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageImageVideoItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageImageVideoItem.kt index 3ae91db97c..8485c40ef9 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageImageVideoItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageImageVideoItem.kt @@ -23,12 +23,16 @@ import androidx.core.view.ViewCompat import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass +import com.bumptech.glide.load.resource.bitmap.RoundedCorners import im.vector.app.R import im.vector.app.core.epoxy.ClickListener import im.vector.app.core.epoxy.onClick import im.vector.app.core.files.LocalFilesHelper import im.vector.app.core.glide.GlideApp +import im.vector.app.core.utils.DimensionConverter import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder +import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout +import im.vector.app.features.home.room.detail.timeline.style.granularRoundedCorners import im.vector.app.features.media.ImageContentRenderer @EpoxyModelClass(layout = R.layout.item_timeline_event_base) @@ -54,7 +58,14 @@ abstract class MessageImageVideoItem : AbsMessageItem(R.id.messageMediaUploadProgressLayout) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt index 8258f797f1..2b68742e7e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt @@ -17,6 +17,7 @@ package im.vector.app.features.home.room.detail.timeline.item import android.os.Parcelable +import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout import kotlinx.parcelize.Parcelize import org.matrix.android.sdk.api.crypto.VerificationState import org.matrix.android.sdk.api.session.room.send.SendState @@ -31,17 +32,17 @@ data class MessageInformationData( val ageLocalTS: Long?, val avatarUrl: String?, val memberName: CharSequence? = null, - val showInformation: Boolean = true, - val forceShowTimestamp: Boolean = false, - /*List of reactions (emoji,count,isSelected)*/ - val orderedReactionList: List? = null, + val messageLayout: TimelineMessageLayout, + val reactionsSummary: ReactionsSummaryData, val pollResponseAggregatedSummary: PollResponseData? = null, val hasBeenEdited: Boolean = false, val hasPendingEdits: Boolean = false, val referencesInfoData: ReferencesInfoData? = null, val sentByMe: Boolean, val e2eDecoration: E2EDecoration = E2EDecoration.NONE, - val sendStateDecoration: SendStateDecoration = SendStateDecoration.NONE + val sendStateDecoration: SendStateDecoration = SendStateDecoration.NONE, + val isFirstFromThisSender: Boolean = false, + val isLastFromThisSender: Boolean = false ) : Parcelable { val matrixItem: MatrixItem @@ -53,6 +54,16 @@ data class ReferencesInfoData( val verificationStatus: VerificationState ) : Parcelable +@Parcelize +data class ReactionsSummaryData( + /*List of reactions (emoji,count,isSelected)*/ + val reactions: List? = null, + val showAll: Boolean = false, + val onShowMoreClicked: () -> Unit, + val onShowLessClicked: () -> Unit, + val onAddMoreClicked: () -> Unit +) : Parcelable + @Parcelize data class ReactionInfoData( val key: String, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLocationItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLocationItem.kt index 3f030866a5..1e2808afd8 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLocationItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLocationItem.kt @@ -16,68 +16,99 @@ package im.vector.app.features.home.room.detail.timeline.item -import android.widget.FrameLayout -import androidx.constraintlayout.widget.ConstraintLayout +import android.graphics.drawable.Drawable +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.RequestOptions +import com.bumptech.glide.request.target.Target import im.vector.app.R -import im.vector.app.core.epoxy.onClick +import im.vector.app.core.glide.GlideApp +import im.vector.app.core.utils.DimensionConverter import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider -import im.vector.app.features.location.LocationData -import im.vector.app.features.location.MapTilerMapView +import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout +import im.vector.app.features.home.room.detail.timeline.style.granularRoundedCorners @EpoxyModelClass(layout = R.layout.item_timeline_event_base) abstract class MessageLocationItem : AbsMessageItem() { - interface Callback { - fun onMapClicked() - } - @EpoxyAttribute - var callback: Callback? = null - - @EpoxyAttribute - var locationData: LocationData? = null + var locationUrl: String? = null @EpoxyAttribute var userId: String? = null @EpoxyAttribute + var mapWidth: Int = 0 + + @EpoxyAttribute + var mapHeight: Int = 0 + + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var locationPinProvider: LocationPinProvider? = null override fun bind(holder: Holder) { super.bind(holder) - renderSendState(holder.mapViewContainer, null) - - val location = locationData ?: return - val locationOwnerId = userId ?: return - - holder.clickableMapArea.onClick { - callback?.onMapClicked() + renderSendState(holder.view, null) + val location = locationUrl ?: return + val messageLayout = attributes.informationData.messageLayout + val dimensionConverter = DimensionConverter(holder.view.resources) + val imageCornerTransformation = if (messageLayout is TimelineMessageLayout.Bubble) { + messageLayout.cornersRadius.granularRoundedCorners() + } else { + RoundedCorners(dimensionConverter.dpToPx(8)) } - - holder.mapView.apply { - initialize { - zoomToLocation(location.latitude, location.longitude, INITIAL_ZOOM) - - locationPinProvider?.create(locationOwnerId) { pinDrawable -> - addPinToMap(locationOwnerId, pinDrawable) - updatePinLocation(locationOwnerId, location.latitude, location.longitude) - } - } + holder.staticMapImageView.updateLayoutParams { + width = mapWidth + height = mapHeight } + GlideApp.with(holder.staticMapImageView) + .load(location) + .apply(RequestOptions.centerCropTransform()) + .listener(object : RequestListener { + override fun onLoadFailed(e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean): Boolean { + holder.staticMapPinImageView.setImageResource(R.drawable.ic_location_pin_failed) + holder.staticMapErrorTextView.isVisible = true + return false + } + + override fun onResourceReady(resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean): Boolean { + locationPinProvider?.create(userId) { pinDrawable -> + GlideApp.with(holder.staticMapPinImageView) + .load(pinDrawable) + .into(holder.staticMapPinImageView) + } + holder.staticMapErrorTextView.isVisible = false + return false + } + }) + .transform(imageCornerTransformation) + .into(holder.staticMapImageView) } - override fun getViewType() = STUB_ID + override fun getViewStubId() = STUB_ID class Holder : AbsMessageItem.Holder(STUB_ID) { - val mapViewContainer by bind(R.id.mapViewContainer) - val mapView by bind(R.id.mapView) - val clickableMapArea by bind(R.id.clickableMapArea) + val staticMapImageView by bind(R.id.staticMapImageView) + val staticMapPinImageView by bind(R.id.staticMapPinImageView) + val staticMapErrorTextView by bind(R.id.staticMapErrorTextView) } companion object { private const val STUB_ID = R.id.messageContentLocationStub - private const val INITIAL_ZOOM = 15.0 } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageTextItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageTextItem.kt index 1794f04c2a..bc9e4a7ff1 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageTextItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageTextItem.kt @@ -80,6 +80,7 @@ abstract class MessageTextItem : AbsMessageItem() { safePreviewUrlRetriever.addListener(attributes.informationData.eventId, previewUrlViewUpdater) } holder.previewUrlView.delegate = previewUrlCallback + holder.previewUrlView.renderMessageLayout(attributes.informationData.messageLayout) if (useBigFont) { holder.messageView.textSize = 44F @@ -109,6 +110,7 @@ abstract class MessageTextItem : AbsMessageItem() { val textFuture = PrecomputedTextCompat.getTextFuture(message, TextViewCompat.getTextMetricsParams(this), null) setTextFuture(textFuture) } else { + setTextFuture(null) text = message } } @@ -120,7 +122,7 @@ abstract class MessageTextItem : AbsMessageItem() { previewUrlRetriever?.removeListener(attributes.informationData.eventId, previewUrlViewUpdater) } - override fun getViewType() = STUB_ID + override fun getViewStubId() = STUB_ID class Holder : AbsMessageItem.Holder(STUB_ID) { val messageView by bind(R.id.messageTextView) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt index f006c2aa35..e9f728d976 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt @@ -16,7 +16,10 @@ package im.vector.app.features.home.room.detail.timeline.item +import android.content.res.ColorStateList +import android.graphics.Color import android.text.format.DateUtils +import android.view.View import android.view.ViewGroup import android.widget.ImageButton import android.widget.TextView @@ -29,6 +32,8 @@ import im.vector.app.core.epoxy.ClickListener import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker +import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout +import im.vector.app.features.themes.ThemeUtils @EpoxyModelClass(layout = R.layout.item_timeline_event_base) abstract class MessageVoiceItem : AbsMessageItem() { @@ -80,6 +85,12 @@ abstract class MessageVoiceItem : AbsMessageItem() { } } + val backgroundTint = if (attributes.informationData.messageLayout is TimelineMessageLayout.Bubble) { + Color.TRANSPARENT + } else { + ThemeUtils.getColor(holder.view.context, R.attr.vctr_content_quinary) + } + holder.voicePlaybackLayout.backgroundTintList = ColorStateList.valueOf(backgroundTint) holder.voicePlaybackControlButton.setOnClickListener { playbackControlButtonClickListener?.invoke(it) } voiceMessagePlaybackTracker.track(attributes.informationData.eventId, object : VoiceMessagePlaybackTracker.Listener { @@ -120,9 +131,10 @@ abstract class MessageVoiceItem : AbsMessageItem() { voiceMessagePlaybackTracker.unTrack(attributes.informationData.eventId) } - override fun getViewType() = STUB_ID + override fun getViewStubId() = STUB_ID class Holder : AbsMessageItem.Holder(STUB_ID) { + val voicePlaybackLayout by bind(R.id.voicePlaybackLayout) val voiceLayout by bind(R.id.voiceLayout) val voicePlaybackControlButton by bind(R.id.voicePlaybackControlButton) val voicePlaybackTime by bind(R.id.voicePlaybackTime) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/NoticeItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/NoticeItem.kt index 689d7e6768..3c3510a073 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/NoticeItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/NoticeItem.kt @@ -64,7 +64,7 @@ abstract class NoticeItem : BaseEventItem() { return listOf(attributes.informationData.eventId) } - override fun getViewType() = STUB_ID + override fun getViewStubId() = STUB_ID class Holder : BaseHolder(STUB_ID) { val avatarImageView by bind(R.id.itemNoticeAvatarView) @@ -78,7 +78,8 @@ abstract class NoticeItem : BaseEventItem() { val noticeText: EpoxyCharSequence, val itemLongClickListener: View.OnLongClickListener? = null, val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null, - val avatarClickListener: ClickListener? = null + val avatarClickListener: ClickListener? = null, + val threadSummaryClickListener: ClickListener? = null ) companion object { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/PollItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/PollItem.kt index b660ee9a59..2327a0f2e2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/PollItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/PollItem.kt @@ -50,6 +50,8 @@ abstract class PollItem : AbsMessageItem() { @EpoxyAttribute lateinit var optionViewStates: List + override fun getViewStubId() = STUB_ID + override fun bind(holder: Holder) { super.bind(holder) val relatedEventId = eventId ?: return diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/ReadReceiptsItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/ReadReceiptsItem.kt index 650c804cfa..4f29253264 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/ReadReceiptsItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/ReadReceiptsItem.kt @@ -16,6 +16,7 @@ package im.vector.app.features.home.room.detail.timeline.item +import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelWithHolder @@ -31,6 +32,7 @@ abstract class ReadReceiptsItem : EpoxyModelWithHolder( @EpoxyAttribute lateinit var eventId: String @EpoxyAttribute lateinit var readReceipts: List + @EpoxyAttribute var shouldHideReadReceipts: Boolean = false @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) lateinit var avatarRenderer: AvatarRenderer @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) lateinit var clickListener: ClickListener @@ -42,6 +44,7 @@ abstract class ReadReceiptsItem : EpoxyModelWithHolder( super.bind(holder) holder.readReceiptsView.onClick(clickListener) holder.readReceiptsView.render(readReceipts, avatarRenderer) + holder.readReceiptsView.isVisible = !shouldHideReadReceipts } override fun unbind(holder: Holder) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/RedactedMessageItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/RedactedMessageItem.kt index 282550daec..204bab2254 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/RedactedMessageItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/RedactedMessageItem.kt @@ -22,7 +22,7 @@ import im.vector.app.R @EpoxyModelClass(layout = R.layout.item_timeline_event_base) abstract class RedactedMessageItem : AbsMessageItem() { - override fun getViewType() = STUB_ID + override fun getViewStubId() = STUB_ID override fun shouldShowReactionAtBottom() = false diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/StatusTileTimelineItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/StatusTileTimelineItem.kt index a3d9d3995c..fdde087b44 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/StatusTileTimelineItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/StatusTileTimelineItem.kt @@ -40,7 +40,7 @@ abstract class StatusTileTimelineItem : AbsBaseMessageItem { + buildModernLayout(showInformation) + } + TimelineLayoutSettings.BUBBLE -> { + val shouldBuildBubbleLayout = event.shouldBuildBubbleLayout() + if (shouldBuildBubbleLayout) { + val isFirstFromThisSender = nextDisplayableEvent == null || !nextDisplayableEvent.shouldBuildBubbleLayout() || + nextDisplayableEvent.root.senderId != event.root.senderId || addDaySeparator + + val isLastFromThisSender = prevDisplayableEvent == null || !prevDisplayableEvent.shouldBuildBubbleLayout() || + prevDisplayableEvent.root.senderId != event.root.senderId || + prevDisplayableEvent.root.localDateTime().toLocalDate() != date.toLocalDate() + + val cornersRadius = buildCornersRadius( + isIncoming = !isSentByMe, + isFirstFromThisSender = isFirstFromThisSender, + isLastFromThisSender = isLastFromThisSender + ) + + val messageContent = event.getLastMessageContent() + TimelineMessageLayout.Bubble( + showAvatar = showInformation && !isSentByMe, + showDisplayName = showInformation && !isSentByMe, + isIncoming = !isSentByMe, + cornersRadius = cornersRadius, + isPseudoBubble = messageContent.isPseudoBubble(), + timestampAsOverlay = messageContent.timestampAsOverlay() + ) + } else { + buildModernLayout(showInformation) + } + } + } + return messageLayout + } + + private fun MessageContent?.isPseudoBubble(): Boolean { + if (this == null) return false + if (msgType == MessageType.MSGTYPE_LOCATION) return vectorPreferences.labsRenderLocationsInTimeline() + return this.msgType in MSG_TYPES_WITH_PSEUDO_BUBBLE_LAYOUT + } + + private fun MessageContent?.timestampAsOverlay(): Boolean { + if (this == null) return false + if (msgType == MessageType.MSGTYPE_LOCATION) return vectorPreferences.labsRenderLocationsInTimeline() + return this.msgType in MSG_TYPES_WITH_TIMESTAMP_AS_OVERLAY + } + + private fun TimelineEvent.shouldBuildBubbleLayout(): Boolean { + val type = root.getClearType() + if (type in EVENT_TYPES_WITH_BUBBLE_LAYOUT) { + val messageContent = getLastMessageContent() + return messageContent?.msgType !in MSG_TYPES_WITHOUT_BUBBLE_LAYOUT + } + return false + } + + private fun buildModernLayout(showInformation: Boolean): TimelineMessageLayout.Default { + return TimelineMessageLayout.Default( + showAvatar = showInformation, + showDisplayName = showInformation, + showTimestamp = showInformation || vectorPreferences.alwaysShowTimeStamps() + ) + } + + private fun buildCornersRadius(isIncoming: Boolean, + isFirstFromThisSender: Boolean, + isLastFromThisSender: Boolean): TimelineMessageLayout.Bubble.CornersRadius { + return if ((isIncoming && !isRTL) || (!isIncoming && isRTL)) { + TimelineMessageLayout.Bubble.CornersRadius( + topStartRadius = if (isFirstFromThisSender) cornerRadius else 0f, + topEndRadius = cornerRadius, + bottomStartRadius = if (isLastFromThisSender) cornerRadius else 0f, + bottomEndRadius = cornerRadius + ) + } else { + TimelineMessageLayout.Bubble.CornersRadius( + topStartRadius = cornerRadius, + topEndRadius = if (isFirstFromThisSender) cornerRadius else 0f, + bottomStartRadius = cornerRadius, + bottomEndRadius = if (isLastFromThisSender) cornerRadius else 0f + ) + } + } + + /** + * Tiles type message never show the sender information (like verification request), so we should repeat it for next message + * even if same sender + */ + private fun isTileTypeMessage(event: TimelineEvent?): Boolean { + return when (event?.root?.getClearType()) { + EventType.KEY_VERIFICATION_DONE, + EventType.KEY_VERIFICATION_CANCEL -> true + EventType.MESSAGE -> { + event.getLastMessageContent() is MessageVerificationRequestContent + } + else -> false + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/url/PreviewUrlView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/url/PreviewUrlView.kt index 631f00819c..bb306c2016 100755 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/url/PreviewUrlView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/url/PreviewUrlView.kt @@ -17,17 +17,21 @@ package im.vector.app.features.home.room.detail.timeline.url import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color import android.util.AttributeSet import android.view.View import androidx.core.view.isVisible import com.google.android.material.card.MaterialCardView import im.vector.app.R import im.vector.app.core.extensions.setTextOrHide +import im.vector.app.core.utils.DimensionConverter import im.vector.app.databinding.ViewUrlPreviewBinding import im.vector.app.features.home.room.detail.timeline.TimelineEventController +import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout +import im.vector.app.features.home.room.detail.timeline.view.TimelineMessageLayoutRenderer import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.themes.ThemeUtils -import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.media.PreviewUrlData /** @@ -37,7 +41,7 @@ class PreviewUrlView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : MaterialCardView(context, attrs, defStyleAttr), View.OnClickListener { +) : MaterialCardView(context, attrs, defStyleAttr), View.OnClickListener, TimelineMessageLayoutRenderer { private lateinit var views: ViewUrlPreviewBinding @@ -47,7 +51,6 @@ class PreviewUrlView @JvmOverloads constructor( setupView() radius = resources.getDimensionPixelSize(R.dimen.preview_url_view_corner_radius).toFloat() cardElevation = 0f - setCardBackgroundColor(ThemeUtils.getColor(context, R.attr.vctr_system)) } private var state: PreviewUrlUiState = PreviewUrlUiState.Unknown @@ -76,6 +79,22 @@ class PreviewUrlView @JvmOverloads constructor( } } + override fun renderMessageLayout(messageLayout: TimelineMessageLayout) { + when (messageLayout) { + is TimelineMessageLayout.Default -> { + val backgroundColor = ThemeUtils.getColor(context, R.attr.vctr_system) + setCardBackgroundColor(backgroundColor) + val guidelineBegin = DimensionConverter(resources).dpToPx(8) + views.urlPreviewStartGuideline.setGuidelineBegin(guidelineBegin) + } + is TimelineMessageLayout.Bubble -> { + setCardBackgroundColor(Color.TRANSPARENT) + rippleColor = ColorStateList.valueOf(Color.TRANSPARENT) + views.urlPreviewStartGuideline.setGuidelineBegin(0) + } + } + } + override fun onClick(v: View?) { when (val finalState = state) { is PreviewUrlUiState.Data -> delegate?.onPreviewUrlClicked(finalState.url) @@ -127,7 +146,7 @@ class PreviewUrlView @JvmOverloads constructor( isVisible = true views.urlPreviewTitle.setTextOrHide(previewUrlData.title) - views.urlPreviewImage.isVisible = previewUrlData.mxcUrl?.let { imageContentRenderer.render(it, views.urlPreviewImage) }.orFalse() + views.urlPreviewImage.isVisible = imageContentRenderer.render(previewUrlData, views.urlPreviewImage) views.urlPreviewDescription.setTextOrHide(previewUrlData.description) views.urlPreviewDescription.maxLines = when { previewUrlData.mxcUrl != null -> 2 diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/MessageBubbleView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/MessageBubbleView.kt new file mode 100644 index 0000000000..422dfb0dbd --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/MessageBubbleView.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.view + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.RippleDrawable +import android.util.AttributeSet +import android.view.View +import android.view.ViewOutlineProvider +import android.widget.RelativeLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.content.ContextCompat +import androidx.core.content.withStyledAttributes +import androidx.core.graphics.drawable.DrawableCompat +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.resources.LocaleProvider +import im.vector.app.core.resources.getLayoutDirectionFromCurrentLocale +import im.vector.app.core.utils.DimensionConverter +import im.vector.app.databinding.ViewMessageBubbleBinding +import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout +import im.vector.app.features.home.room.detail.timeline.style.shapeAppearanceModel +import im.vector.app.features.themes.ThemeUtils +import timber.log.Timber + +class MessageBubbleView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, + defStyleAttr: Int = 0) : + RelativeLayout(context, attrs, defStyleAttr), TimelineMessageLayoutRenderer { + + private var isIncoming: Boolean = false + + private val horizontalStubPadding = DimensionConverter(resources).dpToPx(12) + private val verticalStubPadding = DimensionConverter(resources).dpToPx(4) + + private lateinit var views: ViewMessageBubbleBinding + private lateinit var bubbleDrawable: MaterialShapeDrawable + private lateinit var rippleMaskDrawable: MaterialShapeDrawable + + init { + inflate(context, R.layout.view_message_bubble, this) + context.withStyledAttributes(attrs, R.styleable.MessageBubble) { + isIncoming = getBoolean(R.styleable.MessageBubble_incoming_style, false) + } + } + + override fun onFinishInflate() { + super.onFinishInflate() + views = ViewMessageBubbleBinding.bind(this) + val currentLayoutDirection = LocaleProvider(resources).getLayoutDirectionFromCurrentLocale() + val layoutDirectionToSet = if (isIncoming) { + currentLayoutDirection + } else { + if (currentLayoutDirection == View.LAYOUT_DIRECTION_LTR) { + View.LAYOUT_DIRECTION_RTL + } else { + View.LAYOUT_DIRECTION_LTR + } + } + views.informationBottom.layoutDirection = layoutDirectionToSet + views.messageThreadSummaryContainer.layoutDirection = layoutDirectionToSet + views.bubbleWrapper.layoutDirection = layoutDirectionToSet + views.bubbleView.layoutDirection = currentLayoutDirection + + bubbleDrawable = MaterialShapeDrawable() + rippleMaskDrawable = MaterialShapeDrawable() + DrawableCompat.setTint(rippleMaskDrawable, Color.WHITE) + views.bubbleView.apply { + outlineProvider = ViewOutlineProvider.BACKGROUND + clipToOutline = true + background = RippleDrawable( + ContextCompat.getColorStateList(context, R.color.mtrl_btn_ripple_color) ?: ColorStateList.valueOf(Color.TRANSPARENT), + bubbleDrawable, + rippleMaskDrawable) + } + } + + override fun renderMessageLayout(messageLayout: TimelineMessageLayout) { + if (messageLayout !is TimelineMessageLayout.Bubble) { + Timber.v("Can't render messageLayout $messageLayout") + return + } + updateDrawables(messageLayout) + ConstraintSet().apply { + clone(views.bubbleView) + clear(R.id.viewStubContainer, ConstraintSet.END) + if (messageLayout.timestampAsOverlay) { + val timeColor = ContextCompat.getColor(context, R.color.palette_white) + views.messageTimeView.setTextColor(timeColor) + connect(R.id.viewStubContainer, ConstraintSet.END, R.id.parent, ConstraintSet.END, 0) + } else { + val timeColor = ThemeUtils.getColor(context, R.attr.vctr_content_tertiary) + views.messageTimeView.setTextColor(timeColor) + connect(R.id.viewStubContainer, ConstraintSet.END, R.id.messageTimeView, ConstraintSet.START, 0) + } + applyTo(views.bubbleView) + } + if (messageLayout.timestampAsOverlay) { + views.messageOverlayView.isVisible = true + (views.messageOverlayView.background as? GradientDrawable)?.cornerRadii = messageLayout.cornersRadius.toFloatArray() + } else { + views.messageOverlayView.isVisible = false + } + if (messageLayout.isPseudoBubble && messageLayout.timestampAsOverlay) { + views.viewStubContainer.root.setPadding(0, 0, 0, 0) + } else { + views.viewStubContainer.root.setPadding(horizontalStubPadding, verticalStubPadding, horizontalStubPadding, verticalStubPadding) + } + if (isIncoming) { + views.messageEndGuideline.updateLayoutParams { + marginEnd = resources.getDimensionPixelSize(R.dimen.chat_bubble_margin_end) + } + views.messageStartGuideline.updateLayoutParams { + marginStart = resources.getDimensionPixelSize(R.dimen.chat_bubble_margin_start) + } + } else { + views.messageEndGuideline.updateLayoutParams { + marginEnd = resources.getDimensionPixelSize(R.dimen.chat_bubble_margin_start) + } + views.messageStartGuideline.updateLayoutParams { + marginStart = resources.getDimensionPixelSize(R.dimen.chat_bubble_margin_end) + } + } + } + + private fun TimelineMessageLayout.Bubble.CornersRadius.toFloatArray(): FloatArray { + return floatArrayOf(topStartRadius, topStartRadius, topEndRadius, topEndRadius, bottomEndRadius, bottomEndRadius, bottomStartRadius, bottomStartRadius) + } + + private fun updateDrawables(messageLayout: TimelineMessageLayout.Bubble) { + val shapeAppearanceModel = messageLayout.cornersRadius.shapeAppearanceModel() + bubbleDrawable.apply { + this.shapeAppearanceModel = shapeAppearanceModel + this.fillColor = if (messageLayout.isPseudoBubble) { + ColorStateList.valueOf(Color.TRANSPARENT) + } else { + val backgroundColorAttr = if (isIncoming) R.attr.vctr_message_bubble_inbound else R.attr.vctr_message_bubble_outbound + val backgroundColor = ThemeUtils.getColor(context, backgroundColorAttr) + ColorStateList.valueOf(backgroundColor) + } + } + rippleMaskDrawable.shapeAppearanceModel = shapeAppearanceModel + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/TimelineMessageLayoutRenderer.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/TimelineMessageLayoutRenderer.kt new file mode 100644 index 0000000000..0c42662801 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/TimelineMessageLayoutRenderer.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.view + +import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout + +interface TimelineMessageLayoutRenderer { + fun renderMessageLayout(messageLayout: TimelineMessageLayout) +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/views/RoomDetailLazyLoadedViews.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/views/RoomDetailLazyLoadedViews.kt index fafb49ad5c..a7eb6ee78f 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/views/RoomDetailLazyLoadedViews.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/views/RoomDetailLazyLoadedViews.kt @@ -19,7 +19,7 @@ package im.vector.app.features.home.room.detail.views import android.view.View import android.view.ViewStub import im.vector.app.core.ui.views.FailedMessagesWarningView -import im.vector.app.databinding.FragmentRoomDetailBinding +import im.vector.app.databinding.FragmentTimelineBinding import im.vector.app.features.invite.VectorInviteView import kotlin.reflect.KMutableProperty0 @@ -29,12 +29,12 @@ import kotlin.reflect.KMutableProperty0 */ class RoomDetailLazyLoadedViews { - private var roomDetailBinding: FragmentRoomDetailBinding? = null + private var roomDetailBinding: FragmentTimelineBinding? = null private var failedMessagesWarningView: FailedMessagesWarningView? = null private var inviteView: VectorInviteView? = null - fun bind(roomDetailBinding: FragmentRoomDetailBinding) { + fun bind(roomDetailBinding: FragmentTimelineBinding) { this.roomDetailBinding = roomDetailBinding } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/widget/RoomWidgetsBottomSheet.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/widget/RoomWidgetsBottomSheet.kt index aa6966254f..65f3d16ad4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/widget/RoomWidgetsBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/widget/RoomWidgetsBottomSheet.kt @@ -30,8 +30,8 @@ import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment import im.vector.app.core.resources.ColorProvider import im.vector.app.databinding.BottomSheetGenericListWithTitleBinding import im.vector.app.features.home.room.detail.RoomDetailAction -import im.vector.app.features.home.room.detail.RoomDetailViewModel import im.vector.app.features.home.room.detail.RoomDetailViewState +import im.vector.app.features.home.room.detail.TimelineViewModel import im.vector.app.features.navigation.Navigator import org.matrix.android.sdk.api.session.widgets.model.Widget import javax.inject.Inject @@ -48,7 +48,7 @@ class RoomWidgetsBottomSheet : @Inject lateinit var colorProvider: ColorProvider @Inject lateinit var navigator: Navigator - private val roomDetailViewModel: RoomDetailViewModel by parentFragmentViewModel() + private val timelineViewModel: TimelineViewModel by parentFragmentViewModel() override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): BottomSheetGenericListWithTitleBinding { return BottomSheetGenericListWithTitleBinding.inflate(inflater, container, false) @@ -61,7 +61,7 @@ class RoomWidgetsBottomSheet : views.bottomSheetTitle.textSize = 20f views.bottomSheetTitle.setTextColor(colorProvider.getColorFromAttribute(R.attr.vctr_content_primary)) epoxyController.listener = this - roomDetailViewModel.onAsync(RoomDetailViewState::activeRoomWidgets) { + timelineViewModel.onAsync(RoomDetailViewState::activeRoomWidgets) { epoxyController.setData(it) } } @@ -72,13 +72,13 @@ class RoomWidgetsBottomSheet : super.onDestroyView() } - override fun didSelectWidget(widget: Widget) = withState(roomDetailViewModel) { + override fun didSelectWidget(widget: Widget) = withState(timelineViewModel) { navigator.openRoomWidget(requireContext(), it.roomId, widget) dismiss() } override fun didSelectManageWidgets() { - roomDetailViewModel.handle(RoomDetailAction.OpenIntegrationManager) + timelineViewModel.handle(RoomDetailAction.OpenIntegrationManager) dismiss() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListDisplayModeFilter.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListDisplayModeFilter.kt deleted file mode 100644 index 94a79f5fbd..0000000000 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListDisplayModeFilter.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2019 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.app.features.home.room.list - -import androidx.core.util.Predicate -import im.vector.app.features.home.RoomListDisplayMode -import org.matrix.android.sdk.api.session.room.model.Membership -import org.matrix.android.sdk.api.session.room.model.RoomSummary - -class RoomListDisplayModeFilter(private val displayMode: RoomListDisplayMode) : Predicate { - - override fun test(roomSummary: RoomSummary): Boolean { - if (roomSummary.membership.isLeft()) { - return false - } - return when (displayMode) { - RoomListDisplayMode.NOTIFICATIONS -> - roomSummary.notificationCount > 0 || roomSummary.membership == Membership.INVITE || roomSummary.userDrafts.isNotEmpty() - RoomListDisplayMode.PEOPLE -> roomSummary.isDirect && roomSummary.membership.isActive() - RoomListDisplayMode.ROOMS -> !roomSummary.isDirect && roomSummary.membership.isActive() - RoomListDisplayMode.FILTERED -> roomSummary.membership == Membership.JOIN - } - } -} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt index 42c800ab9d..a5977501d2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt @@ -222,10 +222,9 @@ class RoomListViewModel @AssistedInject constructor( ) } - val room = session.getRoom(roomId) ?: return@withState viewModelScope.launch { try { - room.join() + session.joinRoom(roomId) analyticsTracker.capture(action.roomSummary.toAnalyticsJoinedRoom()) // We do not update the joiningRoomsIds here, because, the room is not joined yet regarding the sync data. // Instead, we wait for the room to be joined @@ -245,10 +244,9 @@ class RoomListViewModel @AssistedInject constructor( return@withState } - val room = session.getRoom(roomId) ?: return@withState viewModelScope.launch { try { - room.leave(null) + session.leaveRoom(roomId) // We do not update the rejectingRoomsIds here, because, the room is not rejected yet regarding the sync data. // Instead, we wait for the room to be rejected // Known bug: if the user is invited again (after rejecting the first invitation), the loading will be displayed instead of the buttons. @@ -333,9 +331,8 @@ class RoomListViewModel @AssistedInject constructor( private fun handleLeaveRoom(action: RoomListAction.LeaveRoom) { _viewEvents.post(RoomListViewEvents.Loading(null)) - val room = session.getRoom(action.roomId) ?: return viewModelScope.launch { - val value = runCatching { room.leave(null) } + val value = runCatching { session.leaveRoom(action.roomId) } .fold({ RoomListViewEvents.Done }, { RoomListViewEvents.Failure(it) }) _viewEvents.post(value) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt new file mode 100644 index 0000000000..ca18060c51 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.threads + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.FragmentTransaction +import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.R +import im.vector.app.core.extensions.addFragmentToBackstack +import im.vector.app.core.extensions.replaceFragment +import im.vector.app.core.platform.VectorBaseActivity +import im.vector.app.databinding.ActivityThreadsBinding +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.TimelineFragment +import im.vector.app.features.home.room.detail.arguments.TimelineArgs +import im.vector.app.features.home.room.threads.arguments.ThreadListArgs +import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs +import im.vector.app.features.home.room.threads.list.views.ThreadListFragment +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import javax.inject.Inject + +@AndroidEntryPoint +class ThreadsActivity : VectorBaseActivity() { + + @Inject + lateinit var avatarRenderer: AvatarRenderer + +// private val roomThreadDetailFragment: RoomThreadDetailFragment? +// get() { +// return supportFragmentManager.findFragmentByTag(FRAGMENT_TAG) as? RoomThreadDetailFragment +// } + + override fun getBinding() = ActivityThreadsBinding.inflate(layoutInflater) + + override fun getCoordinatorLayout() = views.coordinatorLayout + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initFragment() + } + + private fun initFragment() { + if (isFirstCreation()) { + when (val fragment = fragmentToNavigate()) { + is DisplayFragment.ThreadList -> { + initThreadListFragment(fragment.threadListArgs) + } + is DisplayFragment.ThreadTimeLine -> { + initThreadTimelineFragment(fragment.threadTimelineArgs) + } + is DisplayFragment.ErrorFragment -> { + finish() + } + } + } + } + + private fun initThreadListFragment(threadListArgs: ThreadListArgs) { + replaceFragment( + views.threadsActivityFragmentContainer, + ThreadListFragment::class.java, + threadListArgs) + } + + private fun initThreadTimelineFragment(threadTimelineArgs: ThreadTimelineArgs) = + replaceFragment( + views.threadsActivityFragmentContainer, + TimelineFragment::class.java, + TimelineArgs( + roomId = threadTimelineArgs.roomId, + eventId = getEventIdToNavigate(), + threadTimelineArgs = threadTimelineArgs + )) + + /** + * This function is used to navigate to the selected thread timeline. + * One usage of that is from the Threads Activity + */ + fun navigateToThreadTimeline( + timelineEvent: TimelineEvent) { + val roomThreadDetailArgs = ThreadTimelineArgs( + roomId = timelineEvent.roomId, + displayName = timelineEvent.senderInfo.displayName, + avatarUrl = timelineEvent.senderInfo.avatarUrl, + roomEncryptionTrustLevel = null, + rootThreadEventId = timelineEvent.eventId) + val commonOption: (FragmentTransaction) -> Unit = { + it.setCustomAnimations( + R.anim.animation_slide_in_right, + R.anim.animation_slide_out_left, + R.anim.animation_slide_in_left, + R.anim.animation_slide_out_right) + } + addFragmentToBackstack( + container = views.threadsActivityFragmentContainer, + fragmentClass = TimelineFragment::class.java, + params = TimelineArgs( + roomId = timelineEvent.roomId, + threadTimelineArgs = roomThreadDetailArgs + ), + option = commonOption + ) + } + + /** + * Determine in witch fragment we should navigate + */ + private fun fragmentToNavigate(): DisplayFragment { + getThreadTimelineArgs()?.let { + return DisplayFragment.ThreadTimeLine(it) + } + getThreadListArgs()?.let { + return DisplayFragment.ThreadList(it) + } + return DisplayFragment.ErrorFragment + } + + private fun getThreadTimelineArgs(): ThreadTimelineArgs? = intent?.extras?.getParcelable(THREAD_TIMELINE_ARGS) + private fun getThreadListArgs(): ThreadListArgs? = intent?.extras?.getParcelable(THREAD_LIST_ARGS) + private fun getEventIdToNavigate(): String? = intent?.extras?.getString(THREAD_EVENT_ID_TO_NAVIGATE) + + companion object { + // private val FRAGMENT_TAG = RoomThreadDetailFragment::class.simpleName + const val THREAD_TIMELINE_ARGS = "THREAD_TIMELINE_ARGS" + const val THREAD_EVENT_ID_TO_NAVIGATE = "THREAD_EVENT_ID_TO_NAVIGATE" + const val THREAD_LIST_ARGS = "THREAD_LIST_ARGS" + + fun newIntent( + context: Context, + threadTimelineArgs: ThreadTimelineArgs?, + threadListArgs: ThreadListArgs?, + eventIdToNavigate: String? = null + ): Intent { + return Intent(context, ThreadsActivity::class.java).apply { + putExtra(THREAD_TIMELINE_ARGS, threadTimelineArgs) + putExtra(THREAD_EVENT_ID_TO_NAVIGATE, eventIdToNavigate) + putExtra(THREAD_LIST_ARGS, threadListArgs) + } + } + } + + sealed class DisplayFragment { + data class ThreadList(val threadListArgs: ThreadListArgs) : DisplayFragment() + data class ThreadTimeLine(val threadTimelineArgs: ThreadTimelineArgs) : DisplayFragment() + object ErrorFragment : DisplayFragment() + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/arguments/ThreadListArgs.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/arguments/ThreadListArgs.kt new file mode 100644 index 0000000000..aa3746ea41 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/arguments/ThreadListArgs.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.threads.arguments + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel + +@Parcelize +data class ThreadListArgs( + val roomId: String, + val displayName: String?, + val avatarUrl: String?, + val roomEncryptionTrustLevel: RoomEncryptionTrustLevel? +) : Parcelable diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/arguments/ThreadTimelineArgs.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/arguments/ThreadTimelineArgs.kt new file mode 100644 index 0000000000..aadad3d97c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/arguments/ThreadTimelineArgs.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.threads.arguments + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel + +@Parcelize +data class ThreadTimelineArgs( + val roomId: String, + val displayName: String?, + val avatarUrl: String?, + val roomEncryptionTrustLevel: RoomEncryptionTrustLevel?, + val rootThreadEventId: String? = null +) : Parcelable diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/model/ThreadListItem.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/model/ThreadListItem.kt new file mode 100644 index 0000000000..2364e86166 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/model/ThreadListItem.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.threads.list.model + +import android.widget.ImageView +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.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.clearDrawables +import im.vector.app.core.extensions.setLeftDrawable +import im.vector.app.core.utils.DimensionConverter +import im.vector.app.features.displayname.getBestName +import im.vector.app.features.home.AvatarRenderer +import org.matrix.android.sdk.api.session.threads.ThreadNotificationState +import org.matrix.android.sdk.api.util.MatrixItem + +@EpoxyModelClass(layout = R.layout.item_thread) +abstract class ThreadListItem : VectorEpoxyModel() { + + @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer + @EpoxyAttribute lateinit var matrixItem: MatrixItem + @EpoxyAttribute lateinit var title: String + @EpoxyAttribute lateinit var date: String + @EpoxyAttribute lateinit var rootMessage: String + @EpoxyAttribute lateinit var lastMessage: String + @EpoxyAttribute var threadNotificationState: ThreadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE + @EpoxyAttribute lateinit var lastMessageCounter: String + @EpoxyAttribute var rootMessageDeleted: Boolean = false + @EpoxyAttribute var lastMessageMatrixItem: MatrixItem? = null + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var itemClickListener: ClickListener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.rootView.onClick(itemClickListener) + avatarRenderer.render(matrixItem, holder.avatarImageView) + holder.avatarImageView.contentDescription = matrixItem.getBestName() + holder.titleTextView.text = title + holder.dateTextView.text = date + if (rootMessageDeleted) { + holder.rootMessageTextView.text = holder.view.context.getString(R.string.event_redacted) + holder.rootMessageTextView.setLeftDrawable(R.drawable.ic_trash_16, R.attr.vctr_content_tertiary) + holder.rootMessageTextView.compoundDrawablePadding = DimensionConverter(holder.view.context.resources).dpToPx(10) + } else { + holder.rootMessageTextView.text = rootMessage + holder.rootMessageTextView.clearDrawables() + } + // Last message summary + lastMessageMatrixItem?.let { + avatarRenderer.render(it, holder.lastMessageAvatarImageView) + } + holder.lastMessageAvatarImageView.contentDescription = lastMessageMatrixItem?.getBestName() + holder.lastMessageTextView.text = lastMessage + holder.lastMessageCounterTextView.text = lastMessageCounter + renderNotificationState(holder) + } + + private fun renderNotificationState(holder: Holder) { + when (threadNotificationState) { + ThreadNotificationState.NEW_MESSAGE -> { + holder.unreadImageView.isVisible = true + holder.unreadImageView.setColorFilter(ContextCompat.getColor(holder.view.context, R.color.palette_gray_200)) + } + ThreadNotificationState.NEW_HIGHLIGHTED_MESSAGE -> { + holder.unreadImageView.isVisible = true + holder.unreadImageView.setColorFilter(ContextCompat.getColor(holder.view.context, R.color.palette_vermilion)) + } + else -> { + holder.unreadImageView.isVisible = false + } + } + } + + class Holder : VectorEpoxyHolder() { + val avatarImageView by bind(R.id.threadSummaryAvatarImageView) + val titleTextView by bind(R.id.threadSummaryTitleTextView) + val dateTextView by bind(R.id.threadSummaryDateTextView) + val rootMessageTextView by bind(R.id.threadSummaryRootMessageTextView) + val lastMessageAvatarImageView by bind(R.id.messageThreadSummaryAvatarImageView) + val lastMessageCounterTextView by bind(R.id.messageThreadSummaryCounterTextView) + val lastMessageTextView by bind(R.id.messageThreadSummaryInfoTextView) + val unreadImageView by bind(R.id.threadSummaryUnreadImageView) + val rootView by bind(R.id.threadSummaryRootConstraintLayout) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt new file mode 100644 index 0000000000..8bc6bd73e9 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.threads.list.viewmodel + +import com.airbnb.epoxy.EpoxyController +import im.vector.app.R +import im.vector.app.core.date.DateFormatKind +import im.vector.app.core.date.VectorDateFormatter +import im.vector.app.core.resources.StringProvider +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.threads.list.model.threadListItem +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.threads.ThreadNotificationState +import org.matrix.android.sdk.api.util.toMatrixItem +import javax.inject.Inject + +class ThreadListController @Inject constructor( + private val avatarRenderer: AvatarRenderer, + private val stringProvider: StringProvider, + private val dateFormatter: VectorDateFormatter +) : EpoxyController() { + + var listener: Listener? = null + + private var viewState: ThreadListViewState? = null + + fun update(viewState: ThreadListViewState) { + this.viewState = viewState + requestModelBuild() + } + + override fun buildModels() { + val safeViewState = viewState ?: return + val host = this + + safeViewState.rootThreadEventList.invoke() + ?.filter { + if (safeViewState.shouldFilterThreads) { + it.isParticipating + } else { + true + } + }?.map { + it.timelineEvent + } + ?.forEach { timelineEvent -> + val date = dateFormatter.format(timelineEvent.root.threadDetails?.lastMessageTimestamp, DateFormatKind.ROOM_LIST) + val decryptionErrorMessage = stringProvider.getString(R.string.encrypted_message) + val lastRootThreadEdition = timelineEvent.root.threadDetails?.lastRootThreadEdition + threadListItem { + id(timelineEvent.eventId) + avatarRenderer(host.avatarRenderer) + matrixItem(timelineEvent.senderInfo.toMatrixItem()) + title(timelineEvent.senderInfo.displayName) + date(date) + rootMessageDeleted(timelineEvent.root.isRedacted()) + threadNotificationState(timelineEvent.root.threadDetails?.threadNotificationState ?: ThreadNotificationState.NO_NEW_MESSAGE) + rootMessage(lastRootThreadEdition ?: timelineEvent.root.getDecryptedTextSummary() ?: decryptionErrorMessage) + lastMessage(timelineEvent.root.threadDetails?.threadSummaryLatestTextMessage ?: decryptionErrorMessage) + lastMessageCounter(timelineEvent.root.threadDetails?.numberOfThreads.toString()) + lastMessageMatrixItem(timelineEvent.root.threadDetails?.threadSummarySenderInfo?.toMatrixItem()) + itemClickListener { + host.listener?.onThreadClicked(timelineEvent) + } + } + } + } + + interface Listener { + fun onThreadClicked(timelineEvent: TimelineEvent) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt new file mode 100644 index 0000000000..d82b5d6ccf --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.threads.list.viewmodel + +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.MavericksViewModelFactory +import com.airbnb.mvrx.ViewModelContext +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.core.platform.EmptyAction +import im.vector.app.core.platform.EmptyViewEvents +import im.vector.app.core.platform.VectorViewModel +import im.vector.app.features.home.room.threads.list.views.ThreadListFragment +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.threads.ThreadTimelineEvent +import org.matrix.android.sdk.flow.flow + +class ThreadListViewModel @AssistedInject constructor(@Assisted val initialState: ThreadListViewState, + private val session: Session) : + VectorViewModel(initialState) { + + private val room = session.getRoom(initialState.roomId) + + @AssistedFactory + interface Factory { + fun create(initialState: ThreadListViewState): ThreadListViewModel + } + + companion object : MavericksViewModelFactory { + + @JvmStatic + override fun create(viewModelContext: ViewModelContext, state: ThreadListViewState): ThreadListViewModel? { + val fragment: ThreadListFragment = (viewModelContext as FragmentViewModelContext).fragment() + return fragment.threadListViewModelFactory.create(state) + } + } + + init { + observeThreadsList() + } + + override fun handle(action: EmptyAction) {} + + private fun observeThreadsList() { + room?.flow() + ?.liveThreadList() + ?.map { room.mapEventsWithEdition(it) } + ?.map { + it.map { threadRootEvent -> + val isParticipating = room.isUserParticipatingInThread(threadRootEvent.eventId) + ThreadTimelineEvent(threadRootEvent, isParticipating) + } + } + ?.flowOn(room.coroutineDispatchers.io) + ?.execute { asyncThreads -> + copy(rootThreadEventList = asyncThreads) + } + } + + fun applyFiltering(shouldFilterThreads: Boolean) { + setState { + copy(shouldFilterThreads = shouldFilterThreads) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt new file mode 100644 index 0000000000..2a70a5be1e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.threads.list.viewmodel + +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.MavericksState +import com.airbnb.mvrx.Uninitialized +import im.vector.app.features.home.room.threads.arguments.ThreadListArgs +import org.matrix.android.sdk.api.session.threads.ThreadTimelineEvent + +data class ThreadListViewState( + val rootThreadEventList: Async> = Uninitialized, + val shouldFilterThreads: Boolean = false, + val roomId: String +) : MavericksState { + + constructor(args: ThreadListArgs) : this(roomId = args.roomId) +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListBottomSheet.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListBottomSheet.kt new file mode 100644 index 0000000000..7ad4804e5b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListBottomSheet.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.threads.list.views + +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.AttrRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import com.airbnb.mvrx.parentFragmentViewModel +import im.vector.app.R +import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment +import im.vector.app.databinding.BottomSheetThreadListBinding +import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewModel +import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewState +import im.vector.app.features.themes.ThemeUtils + +class ThreadListBottomSheet : VectorBaseBottomSheetDialogFragment() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): BottomSheetThreadListBinding { + return BottomSheetThreadListBinding.inflate(inflater, container, false) + } + + private val threadListViewModel: ThreadListViewModel by parentFragmentViewModel() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + threadListViewModel.onEach { + renderState(it) + } + views.threadListModalAllThreads.views.bottomSheetActionClickableZone.debouncedClicks { + threadListViewModel.applyFiltering(false) + dismiss() + } + views.threadListModalMyThreads.views.bottomSheetActionClickableZone.debouncedClicks { + threadListViewModel.applyFiltering(true) + dismiss() + } + } + + private fun renderState(state: ThreadListViewState) { + val radioOffDrawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_radio_off) + val radioOnDrawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_radio_on) + + if (state.shouldFilterThreads) { + setRightIconDrawableAllThreads(radioOffDrawable, R.attr.vctr_content_primary) + setRightIconDrawableMyThreads(radioOnDrawable, R.attr.colorPrimary) + } else { + setRightIconDrawableAllThreads(radioOnDrawable, R.attr.colorPrimary) + setRightIconDrawableMyThreads(radioOffDrawable, R.attr.vctr_content_primary) + } + } + + private fun setRightIconDrawableAllThreads(drawable: Drawable?, @AttrRes tint: Int) { + views.threadListModalAllThreads.rightIcon = drawable + views.threadListModalAllThreads.rightIcon?.setTintFromAttribute(tint) + } + + private fun setRightIconDrawableMyThreads(drawable: Drawable?, @AttrRes tint: Int) { + views.threadListModalMyThreads.rightIcon = drawable + views.threadListModalMyThreads.rightIcon?.setTintFromAttribute(tint) + } + + private fun Drawable.setTintFromAttribute(@AttrRes tint: Int) { + DrawableCompat.setTint(this, ThemeUtils.getColor(requireContext(), tint)) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt new file mode 100644 index 0000000000..180e6226d0 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.threads.list.views + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import com.airbnb.mvrx.args +import com.airbnb.mvrx.fragmentViewModel +import com.airbnb.mvrx.withState +import im.vector.app.R +import im.vector.app.core.extensions.cleanup +import im.vector.app.core.extensions.configureWith +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.databinding.FragmentThreadListBinding +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.timeline.animation.TimelineItemAnimator +import im.vector.app.features.home.room.threads.ThreadsActivity +import im.vector.app.features.home.room.threads.arguments.ThreadListArgs +import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListController +import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewModel +import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewState +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.util.MatrixItem +import javax.inject.Inject + +class ThreadListFragment @Inject constructor( + private val avatarRenderer: AvatarRenderer, + private val threadListController: ThreadListController, + val threadListViewModelFactory: ThreadListViewModel.Factory +) : VectorBaseFragment(), + ThreadListController.Listener { + + private val threadListViewModel: ThreadListViewModel by fragmentViewModel() + + private val threadListArgs: ThreadListArgs by args() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentThreadListBinding { + return FragmentThreadListBinding.inflate(inflater, container, false) + } + + override fun getMenuRes() = R.menu.menu_thread_list + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.menu_thread_list_filter -> { + ThreadListBottomSheet().show(childFragmentManager, "Filtering") + true + } + else -> super.onOptionsItemSelected(item) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initToolbar() + initTextConstants() + views.threadListRecyclerView.configureWith(threadListController, TimelineItemAnimator(), hasFixedSize = false) + threadListController.listener = this + } + + override fun onDestroyView() { + views.threadListRecyclerView.cleanup() + threadListController.listener = null + super.onDestroyView() + } + + private fun initToolbar() { + setupToolbar(views.threadListToolbar).allowBack() + renderToolbar() + } + + private fun initTextConstants() { + views.threadListEmptyNoticeTextView.text = String.format( + resources.getString(R.string.thread_list_empty_notice), + resources.getString(R.string.reply_in_thread)) + } + + override fun invalidate() = withState(threadListViewModel) { state -> + renderEmptyStateIfNeeded(state) + threadListController.update(state) + } + + private fun renderToolbar() { + views.includeThreadListToolbar.roomToolbarThreadConstraintLayout.isVisible = true + val matrixItem = MatrixItem.RoomItem(threadListArgs.roomId, threadListArgs.displayName, threadListArgs.avatarUrl) + avatarRenderer.render(matrixItem, views.includeThreadListToolbar.roomToolbarThreadImageView) + views.includeThreadListToolbar.roomToolbarThreadShieldImageView.render(threadListArgs.roomEncryptionTrustLevel) + views.includeThreadListToolbar.roomToolbarThreadTitleTextView.text = resources.getText(R.string.thread_list_title) + views.includeThreadListToolbar.roomToolbarThreadSubtitleTextView.text = threadListArgs.displayName + } + + override fun onThreadClicked(timelineEvent: TimelineEvent) { + (activity as? ThreadsActivity)?.navigateToThreadTimeline(timelineEvent) + } + + private fun renderEmptyStateIfNeeded(state: ThreadListViewState) { + val show = state.rootThreadEventList.invoke().isNullOrEmpty() + views.threadListEmptyConstraintLayout.isVisible = show + } +} diff --git a/vector/src/main/java/im/vector/app/features/html/CodeVisitor.kt b/vector/src/main/java/im/vector/app/features/html/CodeVisitor.kt deleted file mode 100644 index f1612c3717..0000000000 --- a/vector/src/main/java/im/vector/app/features/html/CodeVisitor.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2019 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.app.features.html - -import org.commonmark.node.AbstractVisitor -import org.commonmark.node.Code -import org.commonmark.node.FencedCodeBlock -import org.commonmark.node.IndentedCodeBlock - -/** - * This class is in charge of visiting nodes and tells if we have some code nodes (inline or block). - */ -class CodeVisitor : AbstractVisitor() { - - var codeKind: Kind = Kind.NONE - private set - - override fun visit(fencedCodeBlock: FencedCodeBlock?) { - if (codeKind == Kind.NONE) { - codeKind = Kind.BLOCK - } - } - - override fun visit(indentedCodeBlock: IndentedCodeBlock?) { - if (codeKind == Kind.NONE) { - codeKind = Kind.BLOCK - } - } - - override fun visit(code: Code?) { - if (codeKind == Kind.NONE) { - codeKind = Kind.INLINE - } - } - - enum class Kind { - NONE, - INLINE, - BLOCK - } -} 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 36acad8854..7d78be3584 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 @@ -17,9 +17,11 @@ package im.vector.app.features.html import android.content.Context +import android.content.res.Resources import android.text.Spannable import androidx.core.text.toSpannable import im.vector.app.core.resources.ColorProvider +import im.vector.app.core.utils.DimensionConverter import im.vector.app.features.settings.VectorPreferences import io.noties.markwon.AbstractMarkwonPlugin import io.noties.markwon.Markwon @@ -53,11 +55,11 @@ class EventHtmlRenderer @Inject constructor( .usePlugin(object : AbstractMarkwonPlugin() { // Markwon expects maths to be in a specific format: https://noties.io/Markwon/docs/v4/ext-latex override fun processMarkdown(markdown: String): String { return markdown - .replace(Regex(""".*?""")) { - matchResult -> "$$" + matchResult.groupValues[1] + "$$" + .replace(Regex(""".*?""")) { matchResult -> + "$$" + matchResult.groupValues[1] + "$$" } - .replace(Regex(""".*?""")) { - matchResult -> "\n$$\n" + matchResult.groupValues[1] + "\n$$\n" + .replace(Regex(""".*?""")) { matchResult -> + "\n$$\n" + matchResult.groupValues[1] + "\n$$\n" } } }) @@ -112,12 +114,15 @@ class EventHtmlRenderer @Inject constructor( } } -class MatrixHtmlPluginConfigure @Inject constructor(private val colorProvider: ColorProvider) : HtmlPlugin.HtmlConfigure { +class MatrixHtmlPluginConfigure @Inject constructor(private val colorProvider: ColorProvider, private val resources: Resources) : HtmlPlugin.HtmlConfigure { override fun configureHtml(plugin: HtmlPlugin) { plugin .addHandler(FontTagHandler()) + .addHandler(ParagraphHandler(DimensionConverter(resources))) .addHandler(MxReplyTagHandler()) + .addHandler(CodePreTagHandler()) + .addHandler(CodeTagHandler()) .addHandler(SpanHandler(colorProvider)) } } diff --git a/vector/src/main/java/im/vector/app/features/html/HtmlCodeHandlers.kt b/vector/src/main/java/im/vector/app/features/html/HtmlCodeHandlers.kt new file mode 100644 index 0000000000..1010625370 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/html/HtmlCodeHandlers.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.html + +import io.noties.markwon.MarkwonVisitor +import io.noties.markwon.SpannableBuilder +import io.noties.markwon.html.HtmlTag +import io.noties.markwon.html.MarkwonHtmlRenderer +import io.noties.markwon.html.TagHandler + +class CodeTagHandler : TagHandler() { + + override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) { + SpannableBuilder.setSpans( + visitor.builder(), + HtmlCodeSpan(visitor.configuration().theme(), false), + tag.start(), + tag.end() + ) + } + + override fun supportedTags(): List { + return listOf("code") + } +} + +/** + * Pre tag are already handled by HtmlPlugin to keep the formatting. + * We are only using it to check for
*
tags. + */ +class CodePreTagHandler : TagHandler() { + override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) { + val htmlCodeSpan = visitor.builder() + .getSpans(tag.start(), tag.end()) + .firstOrNull { + it.what is HtmlCodeSpan + } + if (htmlCodeSpan != null) { + (htmlCodeSpan.what as HtmlCodeSpan).isBlock = true + } + } + + override fun supportedTags(): List { + return listOf("pre") + } +} diff --git a/vector/src/main/java/im/vector/app/features/html/HtmlCodeSpan.kt b/vector/src/main/java/im/vector/app/features/html/HtmlCodeSpan.kt new file mode 100644 index 0000000000..7f01321aab --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/html/HtmlCodeSpan.kt @@ -0,0 +1,86 @@ +/* + * 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.html + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.text.Layout +import android.text.TextPaint +import android.text.style.LeadingMarginSpan +import android.text.style.MetricAffectingSpan +import io.noties.markwon.core.MarkwonTheme + +class HtmlCodeSpan(private val theme: MarkwonTheme, var isBlock: Boolean) : MetricAffectingSpan(), LeadingMarginSpan { + + private val rect = Rect() + private val paint = Paint() + + override fun updateDrawState(p: TextPaint) { + applyTextStyle(p) + if (!isBlock) { + p.bgColor = theme.getCodeBackgroundColor(p) + } + } + + override fun updateMeasureState(p: TextPaint) { + applyTextStyle(p) + } + + private fun applyTextStyle(p: TextPaint) { + if (isBlock) { + theme.applyCodeBlockTextStyle(p) + } else { + theme.applyCodeTextStyle(p) + } + } + + override fun getLeadingMargin(first: Boolean): Int { + return theme.codeBlockMargin + } + + override fun drawLeadingMargin( + c: Canvas, + p: Paint?, + x: Int, + dir: Int, + top: Int, + baseline: Int, + bottom: Int, + text: CharSequence?, + start: Int, + end: Int, + first: Boolean, + layout: Layout? + ) { + if (!isBlock) return + + paint.style = Paint.Style.FILL + paint.color = theme.getCodeBlockBackgroundColor(p!!) + val left: Int + val right: Int + if (dir > 0) { + left = x + right = c.width + } else { + left = x - c.width + right = x + } + rect[left, top, right] = bottom + c.drawRect(rect, paint) + } +} diff --git a/vector/src/main/java/im/vector/app/features/html/MxReplyTagHandler.kt b/vector/src/main/java/im/vector/app/features/html/MxReplyTagHandler.kt index 391c5f9477..118369e3c8 100644 --- a/vector/src/main/java/im/vector/app/features/html/MxReplyTagHandler.kt +++ b/vector/src/main/java/im/vector/app/features/html/MxReplyTagHandler.kt @@ -17,28 +17,17 @@ package im.vector.app.features.html import io.noties.markwon.MarkwonVisitor -import io.noties.markwon.SpannableBuilder import io.noties.markwon.html.HtmlTag import io.noties.markwon.html.MarkwonHtmlRenderer import io.noties.markwon.html.TagHandler -import org.commonmark.node.BlockQuote class MxReplyTagHandler : TagHandler() { override fun supportedTags() = listOf("mx-reply") override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) { - val configuration = visitor.configuration() - val factory = configuration.spansFactory().get(BlockQuote::class.java) - if (factory != null) { - SpannableBuilder.setSpans( - visitor.builder(), - factory.getSpans(configuration, visitor.renderProps()), - tag.start(), - tag.end() - ) - val replyText = visitor.builder().removeFromEnd(tag.end()) - visitor.builder().append("\n\n").append(replyText) - } + visitChildren(visitor, renderer, tag.asBlock) + val replyText = visitor.builder().removeFromEnd(tag.end()) + visitor.builder().append("\n\n").append(replyText) } } diff --git a/vector/src/main/java/im/vector/app/features/html/ParagraphHandler.kt b/vector/src/main/java/im/vector/app/features/html/ParagraphHandler.kt new file mode 100644 index 0000000000..3dd1b4f091 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/html/ParagraphHandler.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.html + +import im.vector.app.core.utils.DimensionConverter +import io.noties.markwon.MarkwonVisitor +import io.noties.markwon.SpannableBuilder +import io.noties.markwon.html.HtmlTag +import io.noties.markwon.html.MarkwonHtmlRenderer +import io.noties.markwon.html.TagHandler +import me.gujun.android.span.style.VerticalPaddingSpan + +class ParagraphHandler(private val dimensionConverter: DimensionConverter) : TagHandler() { + + override fun supportedTags() = listOf("p") + + override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) { + if (tag.isBlock) { + visitChildren(visitor, renderer, tag.asBlock) + } + SpannableBuilder.setSpans( + visitor.builder(), + VerticalPaddingSpan(dimensionConverter.dpToPx(4), dimensionConverter.dpToPx(4)), + tag.start(), + tag.end() + ) + } +} diff --git a/vector/src/main/java/im/vector/app/features/html/PillImageSpan.kt b/vector/src/main/java/im/vector/app/features/html/PillImageSpan.kt index c1040a8cc0..ff2e2a9cdb 100644 --- a/vector/src/main/java/im/vector/app/features/html/PillImageSpan.kt +++ b/vector/src/main/java/im/vector/app/features/html/PillImageSpan.kt @@ -65,10 +65,15 @@ class PillImageSpan(private val glideRequests: GlideRequests, fm: Paint.FontMetricsInt?): Int { val rect = pillDrawable.bounds if (fm != null) { - fm.ascent = -rect.bottom - fm.descent = 0 - fm.top = fm.ascent - fm.bottom = 0 + val fmPaint = paint.fontMetricsInt + val fontHeight = fmPaint.bottom - fmPaint.top + val drHeight = rect.bottom - rect.top + val top = drHeight / 2 - fontHeight / 4 + val bottom = drHeight / 2 + fontHeight / 4 + fm.ascent = -bottom + fm.top = -bottom + fm.bottom = top + fm.descent = top } return rect.right } @@ -82,7 +87,9 @@ class PillImageSpan(private val glideRequests: GlideRequests, bottom: Int, paint: Paint) { canvas.save() - val transY = bottom - pillDrawable.bounds.bottom + val fm = paint.fontMetricsInt + val transY: Int = y + (fm.descent + fm.ascent - pillDrawable.bounds.bottom) / 2 + canvas.save() canvas.translate(x, transY.toFloat()) pillDrawable.draw(canvas) canvas.restore() diff --git a/vector/src/main/java/im/vector/app/features/invite/InvitesAcceptor.kt b/vector/src/main/java/im/vector/app/features/invite/InvitesAcceptor.kt index 73876bf6e9..a482998f77 100644 --- a/vector/src/main/java/im/vector/app/features/invite/InvitesAcceptor.kt +++ b/vector/src/main/java/im/vector/app/features/invite/InvitesAcceptor.kt @@ -34,7 +34,6 @@ import kotlinx.coroutines.sync.withPermit import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.session.room.Room import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams @@ -109,7 +108,7 @@ class InvitesAcceptor @Inject constructor( private suspend fun Session.joinRoomSafely(roomId: String) { if (shouldRejectRoomIds.contains(roomId)) { - getRoom(roomId)?.rejectInviteSafely() + rejectInviteSafely(roomId) return } val roomMembershipChanged = getChangeMemberships(roomId) @@ -126,16 +125,16 @@ class InvitesAcceptor @Inject constructor( // if the inviting user is on the same HS, there can only be one cause: they left, so we try to reject the invite. if (inviterId?.endsWith(sessionParams.credentials.homeServer.orEmpty()).orFalse()) { shouldRejectRoomIds.add(roomId) - room.rejectInviteSafely() + rejectInviteSafely(roomId) } } } } } - private suspend fun Room.rejectInviteSafely() { + private suspend fun Session.rejectInviteSafely(roomId: String) { try { - leave(null) + leaveRoom(roomId) shouldRejectRoomIds.remove(roomId) } catch (failure: Throwable) { Timber.v("Fail rejecting invite for room: $roomId") diff --git a/vector/src/main/java/im/vector/app/features/location/Config.kt b/vector/src/main/java/im/vector/app/features/location/Config.kt index 630df16a37..6f947290e2 100644 --- a/vector/src/main/java/im/vector/app/features/location/Config.kt +++ b/vector/src/main/java/im/vector/app/features/location/Config.kt @@ -16,6 +16,11 @@ package im.vector.app.features.location -const val INITIAL_MAP_ZOOM = 15.0 -const val MIN_TIME_MILLIS_TO_UPDATE_LOCATION = 1 * 60 * 1000L // every 1 minute -const val MIN_DISTANCE_METERS_TO_UPDATE_LOCATION = 10f +const val MAP_BASE_URL = "https://api.maptiler.com/maps/streets/style.json" +const val STATIC_MAP_BASE_URL = "https://api.maptiler.com/maps/basic/static/" +const val DEFAULT_PIN_ID = "DEFAULT_PIN_ID" + +const val INITIAL_MAP_ZOOM_IN_PREVIEW = 15.0 +const val INITIAL_MAP_ZOOM_IN_TIMELINE = 17.0 +const val MIN_TIME_TO_UPDATE_LOCATION_MILLIS = 5 * 1_000L // every 5 seconds +const val MIN_DISTANCE_TO_UPDATE_LOCATION_METERS = 10f diff --git a/vector/src/main/java/im/vector/app/features/location/LocationData.kt b/vector/src/main/java/im/vector/app/features/location/LocationData.kt index c3ff09ebcd..a69d8d20e3 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationData.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationData.kt @@ -17,41 +17,43 @@ package im.vector.app.features.location import android.os.Parcelable +import androidx.annotation.VisibleForTesting import kotlinx.parcelize.Parcelize +import org.matrix.android.sdk.api.session.room.model.message.MessageLocationContent @Parcelize data class LocationData( val latitude: Double, val longitude: Double, val uncertainty: Double? -) : Parcelable { +) : Parcelable - companion object { - - /** - * Creates location data from geo uri - * @param geoUri geo:latitude,longitude;uncertainty - * @return location data or null if geo uri is not valid - */ - fun create(geoUri: String): LocationData? { - val geoParts = geoUri - .split(":") - .takeIf { it.firstOrNull() == "geo" } - ?.getOrNull(1) - ?.split(",") - - val latitude = geoParts?.firstOrNull() - val geoTailParts = geoParts?.getOrNull(1)?.split(";") - val longitude = geoTailParts?.firstOrNull() - val uncertainty = geoTailParts?.getOrNull(1)?.replace("u=", "") - - return if (latitude != null && longitude != null) { - LocationData( - latitude = latitude.toDouble(), - longitude = longitude.toDouble(), - uncertainty = uncertainty?.toDouble() - ) - } else null - } - } +/** + * Creates location data from a LocationContent + * "geo:40.05,29.24;30" -> LocationData(40.05, 29.24, 30) + * @return location data or null if geo uri is not valid + */ +fun MessageLocationContent.toLocationData(): LocationData? { + return parseGeo(getBestGeoUri()) +} + +@VisibleForTesting +fun parseGeo(geo: String): LocationData? { + val geoParts = geo + .split(":") + .takeIf { it.firstOrNull() == "geo" } + ?.getOrNull(1) + ?.split(";") ?: return null + + val gpsParts = geoParts.getOrNull(0)?.split(",") ?: return null + val lat = gpsParts.getOrNull(0)?.toDoubleOrNull() ?: return null + val lng = gpsParts.getOrNull(1)?.toDoubleOrNull() ?: return null + + val uncertainty = geoParts.getOrNull(1)?.replace("u=", "")?.toDoubleOrNull() + + return LocationData( + latitude = lat, + longitude = lng, + uncertainty = uncertainty + ) } diff --git a/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt b/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt index 6209bf5a4f..db837f4823 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt @@ -21,20 +21,30 @@ import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope import com.airbnb.mvrx.args +import com.mapbox.mapboxsdk.maps.MapView import im.vector.app.R import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.utils.openLocation import im.vector.app.databinding.FragmentLocationPreviewBinding import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider +import java.lang.ref.WeakReference import javax.inject.Inject +/** + * TODO Move locationPinProvider to a ViewModel + */ class LocationPreviewFragment @Inject constructor( + private val urlMapProvider: UrlMapProvider, private val locationPinProvider: LocationPinProvider ) : VectorBaseFragment() { private val args: LocationSharingArgs by args() + // Keep a ref to handle properly the onDestroy callback + private var mapView: WeakReference? = null + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLocationPreviewBinding { return FragmentLocationPreviewBinding.inflate(layoutInflater, container, false) } @@ -42,23 +52,51 @@ class LocationPreviewFragment @Inject constructor( override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - views.mapView.initialize { - if (isAdded) { - onMapReady() - } + mapView = WeakReference(views.mapView) + views.mapView.onCreate(savedInstanceState) + + lifecycleScope.launchWhenCreated { + views.mapView.initialize(urlMapProvider.getMapUrl()) + loadPinDrawable() } } + override fun onResume() { + super.onResume() + views.mapView.onResume() + } + override fun onPause() { views.mapView.onPause() super.onPause() } + override fun onLowMemory() { + views.mapView.onLowMemory() + super.onLowMemory() + } + + override fun onStart() { + super.onStart() + views.mapView.onStart() + } + override fun onStop() { views.mapView.onStop() super.onStop() } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + views.mapView.onSaveInstanceState(outState) + } + + override fun onDestroy() { + mapView?.get()?.onDestroy() + mapView?.clear() + super.onDestroy() + } + override fun getMenuRes() = R.menu.menu_location_preview override fun onOptionsItemSelected(item: MenuItem): Boolean { @@ -76,18 +114,20 @@ class LocationPreviewFragment @Inject constructor( openLocation(requireActivity(), location.latitude, location.longitude) } - private fun onMapReady() { - if (!isAdded) return - + private fun loadPinDrawable() { val location = args.initialLocationData ?: return val userId = args.locationOwnerId locationPinProvider.create(userId) { pinDrawable -> - views.mapView.apply { - zoomToLocation(location.latitude, location.longitude, INITIAL_MAP_ZOOM) - deleteAllPins() - addPinToMap(userId, pinDrawable) - updatePinLocation(userId, location.latitude, location.longitude) + lifecycleScope.launchWhenResumed { + views.mapView.render( + MapState( + zoomOnlyOnce = true, + pinLocationData = location, + pinId = args.locationOwnerId ?: DEFAULT_PIN_ID, + pinDrawable = pinDrawable + ) + ) } } } diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt index 71101d0612..01319ef6c7 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt @@ -19,7 +19,5 @@ package im.vector.app.features.location import im.vector.app.core.platform.VectorViewModelAction sealed class LocationSharingAction : VectorViewModelAction { - data class OnLocationUpdate(val locationData: LocationData) : LocationSharingAction() object OnShareLocation : LocationSharingAction() - object OnLocationProviderIsNotAvailable : LocationSharingAction() } diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingActivity.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingActivity.kt index 67b36b8442..10c271727b 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingActivity.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingActivity.kt @@ -30,7 +30,7 @@ data class LocationSharingArgs( val roomId: String, val mode: LocationSharingMode, val initialLocationData: LocationData?, - val locationOwnerId: String + val locationOwnerId: String? ) : Parcelable @AndroidEntryPoint diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt index 900f465f04..a7e93a3f6c 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt @@ -20,29 +20,30 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.isGone +import androidx.lifecycle.lifecycleScope import com.airbnb.mvrx.fragmentViewModel +import com.airbnb.mvrx.withState import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.mapbox.mapboxsdk.maps.MapView import im.vector.app.R import im.vector.app.core.extensions.exhaustive import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.databinding.FragmentLocationSharingBinding -import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider -import org.matrix.android.sdk.api.session.Session +import java.lang.ref.WeakReference import javax.inject.Inject +/** + * We should consider using SupportMapFragment for a out of the box lifecycle handling + */ class LocationSharingFragment @Inject constructor( - private val locationTracker: LocationTracker, - private val session: Session, - private val locationPinProvider: LocationPinProvider -) : VectorBaseFragment(), LocationTracker.Callback { - - init { - locationTracker.callback = this - } + private val urlMapProvider: UrlMapProvider +) : VectorBaseFragment() { private val viewModel: LocationSharingViewModel by fragmentViewModel() - private var lastZoomValue: Double = -1.0 + // Keep a ref to handle properly the onDestroy callback + private var mapView: WeakReference? = null override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLocationSharingBinding { return FragmentLocationSharingBinding.inflate(inflater, container, false) @@ -51,10 +52,11 @@ class LocationSharingFragment @Inject constructor( override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - views.mapView.initialize { - if (isAdded) { - onMapReady() - } + mapView = WeakReference(views.mapView) + views.mapView.onCreate(savedInstanceState) + + lifecycleScope.launchWhenCreated { + views.mapView.initialize(urlMapProvider.getMapUrl()) } views.shareLocationContainer.debouncedClicks { @@ -63,54 +65,48 @@ class LocationSharingFragment @Inject constructor( viewModel.observeViewEvents { when (it) { - LocationSharingViewEvents.LocationNotAvailableError -> handleLocationNotAvailableError() - LocationSharingViewEvents.Close -> activity?.finish() + LocationSharingViewEvents.LocationNotAvailableError -> handleLocationNotAvailableError() + LocationSharingViewEvents.Close -> activity?.finish() }.exhaustive } } + override fun onResume() { + super.onResume() + views.mapView.onResume() + } + override fun onPause() { views.mapView.onPause() super.onPause() } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + views.mapView.onSaveInstanceState(outState) + } + + override fun onStart() { + super.onStart() + views.mapView.onStart() + } + override fun onStop() { views.mapView.onStop() super.onStop() } + override fun onLowMemory() { + super.onLowMemory() + views.mapView.onLowMemory() + } + override fun onDestroy() { - locationTracker.stop() + mapView?.get()?.onDestroy() + mapView?.clear() super.onDestroy() } - private fun onMapReady() { - if (!isAdded) return - - locationPinProvider.create(session.myUserId) { - views.mapView.addPinToMap( - pinId = USER_PIN_NAME, - image = it, - ) - // All set, start location tracker - locationTracker.start() - } - } - - override fun onLocationUpdate(locationData: LocationData) { - lastZoomValue = if (lastZoomValue == -1.0) INITIAL_MAP_ZOOM else views.mapView.getCurrentZoom() ?: INITIAL_MAP_ZOOM - - views.mapView.zoomToLocation(locationData.latitude, locationData.longitude, lastZoomValue) - views.mapView.deleteAllPins() - views.mapView.updatePinLocation(USER_PIN_NAME, locationData.latitude, locationData.longitude) - - viewModel.handle(LocationSharingAction.OnLocationUpdate(locationData)) - } - - override fun onLocationProviderIsNotAvailable() { - viewModel.handle(LocationSharingAction.OnLocationProviderIsNotAvailable) - } - private fun handleLocationNotAvailableError() { MaterialAlertDialogBuilder(requireActivity()) .setTitle(R.string.location_not_available_dialog_title) @@ -118,10 +114,12 @@ class LocationSharingFragment @Inject constructor( .setPositiveButton(R.string.ok) { _, _ -> activity?.finish() } + .setCancelable(false) .show() } - companion object { - const val USER_PIN_NAME = "USER_PIN_NAME" + override fun invalidate() = withState(viewModel) { state -> + views.mapView.render(state.toMapState()) + views.shareLocationGpsLoading.isGone = state.lastKnownLocation != null } } 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 b3c97310e1..f4e1fd0281 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 @@ -24,12 +24,15 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.extensions.exhaustive import im.vector.app.core.platform.VectorViewModel +import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider import org.matrix.android.sdk.api.session.Session class LocationSharingViewModel @AssistedInject constructor( @Assisted private val initialState: LocationSharingViewState, - session: Session -) : VectorViewModel(initialState) { + private val locationTracker: LocationTracker, + private val locationPinProvider: LocationPinProvider, + private val session: Session +) : VectorViewModel(initialState), LocationTracker.Callback { private val room = session.getRoom(initialState.roomId)!! @@ -38,14 +41,31 @@ class LocationSharingViewModel @AssistedInject constructor( override fun create(initialState: LocationSharingViewState): LocationSharingViewModel } - companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() { + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() + + init { + locationTracker.start(this) + createPin() + } + + private fun createPin() { + locationPinProvider.create(session.myUserId) { + setState { + copy( + pinDrawable = it + ) + } + } + } + + override fun onCleared() { + super.onCleared() + locationTracker.stop() } override fun handle(action: LocationSharingAction) { when (action) { - is LocationSharingAction.OnLocationUpdate -> handleLocationUpdate(action.locationData) - LocationSharingAction.OnShareLocation -> handleShareLocation() - LocationSharingAction.OnLocationProviderIsNotAvailable -> handleLocationProviderIsNotAvailable() + LocationSharingAction.OnShareLocation -> handleShareLocation() }.exhaustive } @@ -62,13 +82,13 @@ class LocationSharingViewModel @AssistedInject constructor( } } - private fun handleLocationUpdate(locationData: LocationData) { + override fun onLocationUpdate(locationData: LocationData) { setState { copy(lastKnownLocation = locationData) } } - private fun handleLocationProviderIsNotAvailable() { + override fun onLocationProviderIsNotAvailable() { _viewEvents.post(LocationSharingViewEvents.LocationNotAvailableError) } } diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt index 2869929b12..a9a24094eb 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt @@ -16,6 +16,7 @@ package im.vector.app.features.location +import android.graphics.drawable.Drawable import androidx.annotation.StringRes import com.airbnb.mvrx.MavericksState import im.vector.app.R @@ -28,7 +29,8 @@ enum class LocationSharingMode(@StringRes val titleRes: Int) { data class LocationSharingViewState( val roomId: String, val mode: LocationSharingMode, - val lastKnownLocation: LocationData? = null + val lastKnownLocation: LocationData? = null, + val pinDrawable: Drawable? = null ) : MavericksState { constructor(locationSharingArgs: LocationSharingArgs) : this( @@ -36,3 +38,10 @@ data class LocationSharingViewState( mode = locationSharingArgs.mode ) } + +fun LocationSharingViewState.toMapState() = MapState( + zoomOnlyOnce = true, + pinLocationData = lastKnownLocation, + pinId = DEFAULT_PIN_ID, + pinDrawable = pinDrawable +) diff --git a/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt b/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt index 0c0315cf34..162fbc5959 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt @@ -19,70 +19,108 @@ package im.vector.app.features.location import android.Manifest import android.content.Context import android.location.Location -import android.location.LocationListener import android.location.LocationManager import androidx.annotation.RequiresPermission import androidx.core.content.getSystemService +import androidx.core.location.LocationListenerCompat +import im.vector.app.BuildConfig import timber.log.Timber import javax.inject.Inject class LocationTracker @Inject constructor( - private val context: Context -) : LocationListener { + context: Context +) : LocationListenerCompat { + + private val locationManager = context.getSystemService() interface Callback { fun onLocationUpdate(locationData: LocationData) fun onLocationProviderIsNotAvailable() } - private var locationManager: LocationManager? = null - var callback: Callback? = null + private var callback: Callback? = null + + private var hasGpsProviderLiveLocation = false @RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) - fun start() { - val locationManager = context.getSystemService() + fun start(callback: Callback?) { + Timber.d("## LocationTracker. start()") + hasGpsProviderLiveLocation = false + this.callback = callback - locationManager?.let { - val isGpsEnabled = it.isProviderEnabled(LocationManager.GPS_PROVIDER) - val isNetworkEnabled = it.isProviderEnabled(LocationManager.NETWORK_PROVIDER) - - val provider = when { - isGpsEnabled -> LocationManager.GPS_PROVIDER - isNetworkEnabled -> LocationManager.NETWORK_PROVIDER - else -> { - callback?.onLocationProviderIsNotAvailable() - Timber.v("## LocationTracker. There is no location provider available") - return - } - } - - // Send last known location without waiting location updates - it.getLastKnownLocation(provider)?.let { lastKnownLocation -> - callback?.onLocationUpdate(lastKnownLocation.toLocationData()) - } - - it.requestLocationUpdates( - provider, - MIN_TIME_MILLIS_TO_UPDATE_LOCATION, - MIN_DISTANCE_METERS_TO_UPDATE_LOCATION, - this - ) - } ?: run { + if (locationManager == null) { callback?.onLocationProviderIsNotAvailable() Timber.v("## LocationTracker. LocationManager is not available") + return } + + locationManager.allProviders + .takeIf { it.isNotEmpty() } + // Take GPS first + ?.sortedByDescending { if (it == LocationManager.GPS_PROVIDER) 1 else 0 } + ?.forEach { provider -> + Timber.d("## LocationTracker. track location using $provider") + + // Send last known location without waiting location updates + locationManager.getLastKnownLocation(provider)?.let { lastKnownLocation -> + if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) { + Timber.d("## LocationTracker. lastKnownLocation: $lastKnownLocation") + } else { + Timber.d("## LocationTracker. lastKnownLocation: ${lastKnownLocation.provider}") + } + notifyLocation(lastKnownLocation, isLive = false) + } + + locationManager.requestLocationUpdates( + provider, + MIN_TIME_TO_UPDATE_LOCATION_MILLIS, + MIN_DISTANCE_TO_UPDATE_LOCATION_METERS, + this + ) + } + ?: run { + callback?.onLocationProviderIsNotAvailable() + Timber.v("## LocationTracker. There is no location provider available") + } } @RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) fun stop() { + Timber.d("## LocationTracker. stop()") locationManager?.removeUpdates(this) callback = null } override fun onLocationChanged(location: Location) { + if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) { + Timber.d("## LocationTracker. onLocationChanged: $location") + } else { + Timber.d("## LocationTracker. onLocationChanged: ${location.provider}") + } + notifyLocation(location, isLive = true) + } + + private fun notifyLocation(location: Location, isLive: Boolean) { + when (location.provider) { + LocationManager.GPS_PROVIDER -> { + hasGpsProviderLiveLocation = isLive + } + else -> { + if (hasGpsProviderLiveLocation) { + // Ignore this update + Timber.d("## LocationTracker. ignoring location from ${location.provider}, we have gps live location") + return + } + } + } callback?.onLocationUpdate(location.toLocationData()) } + override fun onProviderDisabled(provider: String) { + Timber.d("## LocationTracker. onProviderDisabled: $provider") + callback?.onLocationProviderIsNotAvailable() + } + private fun Location.toLocationData(): LocationData { return LocationData(latitude, longitude, accuracy.toDouble()) } diff --git a/vector/src/main/java/im/vector/app/features/location/VectorMapView.kt b/vector/src/main/java/im/vector/app/features/location/MapState.kt similarity index 61% rename from vector/src/main/java/im/vector/app/features/location/VectorMapView.kt rename to vector/src/main/java/im/vector/app/features/location/MapState.kt index 23b59bf99a..d001457e4f 100644 --- a/vector/src/main/java/im/vector/app/features/location/VectorMapView.kt +++ b/vector/src/main/java/im/vector/app/features/location/MapState.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 New Vector Ltd + * 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. @@ -18,15 +18,9 @@ package im.vector.app.features.location import android.graphics.drawable.Drawable -interface VectorMapView { - fun initialize(onMapReady: () -> Unit) - - fun addPinToMap(pinId: String, image: Drawable) - fun updatePinLocation(pinId: String, latitude: Double, longitude: Double) - fun deleteAllPins() - - fun zoomToLocation(latitude: Double, longitude: Double, zoom: Double) - fun getCurrentZoom(): Double? - - fun onClick(callback: () -> Unit) -} +data class MapState( + val zoomOnlyOnce: Boolean, + val pinLocationData: LocationData? = null, + val pinId: String, + val pinDrawable: Drawable? = null +) diff --git a/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt b/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt index c64af1ebaa..dd80f701f6 100644 --- a/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt +++ b/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt @@ -17,7 +17,6 @@ package im.vector.app.features.location import android.content.Context -import android.graphics.drawable.Drawable import android.util.AttributeSet import com.mapbox.mapboxsdk.camera.CameraPosition import com.mapbox.mapboxsdk.geometry.LatLng @@ -27,65 +26,76 @@ import com.mapbox.mapboxsdk.maps.Style import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions import com.mapbox.mapboxsdk.style.layers.Property -import im.vector.app.BuildConfig +import timber.log.Timber class MapTilerMapView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : MapView(context, attrs, defStyleAttr), VectorMapView { +) : MapView(context, attrs, defStyleAttr) { - private var map: MapboxMap? = null - private var symbolManager: SymbolManager? = null - private var style: Style? = null + private var pendingState: MapState? = null - override fun initialize(onMapReady: () -> Unit) { + data class MapRefs( + val map: MapboxMap, + val symbolManager: SymbolManager, + val style: Style + ) + + private var mapRefs: MapRefs? = null + private var initZoomDone = false + + /** + * For location fragments + */ + fun initialize(url: String) { + Timber.d("## Location: initialize") getMapAsync { map -> - map.setStyle(styleUrl) { style -> - this.symbolManager = SymbolManager(this, map, style) - this.map = map - this.style = style - onMapReady() + map.setStyle(url) { style -> + mapRefs = MapRefs( + map, + SymbolManager(this, map, style), + style + ) + pendingState?.let { render(it) } + pendingState = null } } } - override fun addPinToMap(pinId: String, image: Drawable) { - style?.addImage(pinId, image) - } + fun render(state: MapState) { + val safeMapRefs = mapRefs ?: return Unit.also { + pendingState = state + } - override fun updatePinLocation(pinId: String, latitude: Double, longitude: Double) { - symbolManager?.create( - SymbolOptions() - .withLatLng(LatLng(latitude, longitude)) - .withIconImage(pinId) - .withIconAnchor(Property.ICON_ANCHOR_BOTTOM) - ) - } + state.pinDrawable?.let { pinDrawable -> + if (!safeMapRefs.style.isFullyLoaded || + safeMapRefs.style.getImage(state.pinId) == null) { + safeMapRefs.style.addImage(state.pinId, pinDrawable) + } + } - override fun deleteAllPins() { - symbolManager?.deleteAll() - } + state.pinLocationData?.let { locationData -> + if (!initZoomDone || !state.zoomOnlyOnce) { + zoomToLocation(locationData.latitude, locationData.longitude) + initZoomDone = true + } - override fun zoomToLocation(latitude: Double, longitude: Double, zoom: Double) { - map?.cameraPosition = CameraPosition.Builder() - .target(LatLng(latitude, longitude)) - .zoom(zoom) - .build() - } - - override fun getCurrentZoom(): Double? { - return map?.cameraPosition?.zoom - } - - override fun onClick(callback: () -> Unit) { - map?.addOnMapClickListener { - callback() - true + safeMapRefs.symbolManager.deleteAll() + safeMapRefs.symbolManager.create( + SymbolOptions() + .withLatLng(LatLng(locationData.latitude, locationData.longitude)) + .withIconImage(state.pinId) + .withIconAnchor(Property.ICON_ANCHOR_BOTTOM) + ) } } - companion object { - private const val styleUrl = "https://api.maptiler.com/maps/streets/style.json?key=${BuildConfig.mapTilerKey}" + private fun zoomToLocation(latitude: Double, longitude: Double) { + Timber.d("## Location: zoomToLocation") + mapRefs?.map?.cameraPosition = CameraPosition.Builder() + .target(LatLng(latitude, longitude)) + .zoom(INITIAL_MAP_ZOOM_IN_PREVIEW) + .build() } } diff --git a/vector/src/main/java/im/vector/app/features/location/UrlMapProvider.kt b/vector/src/main/java/im/vector/app/features/location/UrlMapProvider.kt new file mode 100644 index 0000000000..adb5c27a02 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/UrlMapProvider.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.location + +import im.vector.app.BuildConfig +import im.vector.app.core.resources.LocaleProvider +import im.vector.app.core.resources.isRTL +import im.vector.app.features.raw.wellknown.getElementWellknown +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.raw.RawService +import org.matrix.android.sdk.api.session.Session +import javax.inject.Inject + +class UrlMapProvider @Inject constructor( + private val localeProvider: LocaleProvider, + private val session: Session, + private val rawService: RawService +) { + private val keyParam = "?key=${BuildConfig.mapTilerKey}" + + private val fallbackMapUrl = buildString { + append(MAP_BASE_URL) + append(keyParam) + } + + suspend fun getMapUrl(): String { + val upstreamMapUrl = tryOrNull { rawService.getElementWellknown(session.sessionParams) } + ?.mapTileServerConfig + ?.mapStyleUrl + return upstreamMapUrl ?: fallbackMapUrl + } + + fun buildStaticMapUrl(locationData: LocationData, + zoom: Double, + width: Int, + height: Int): String { + return buildString { + append(STATIC_MAP_BASE_URL) + append(locationData.longitude) + append(",") + append(locationData.latitude) + append(",") + append(zoom) + append("/") + append(width) + append("x") + append(height) + append(".png") + append(keyParam) + if (!localeProvider.isRTL()) { + // On LTR languages we want the legal mentions to be displayed on the bottom left of the image + append("&attribution=bottomleft") + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/matrixto/SpaceCardRenderer.kt b/vector/src/main/java/im/vector/app/features/matrixto/SpaceCardRenderer.kt index c56481d3f2..2f71089a39 100644 --- a/vector/src/main/java/im/vector/app/features/matrixto/SpaceCardRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/matrixto/SpaceCardRenderer.kt @@ -41,7 +41,8 @@ class SpaceCardRenderer @Inject constructor( fun render(spaceSummary: RoomSummary?, peopleYouKnow: List, matrixLinkCallback: TimelineEventController.UrlClickCallback?, - inCard: FragmentMatrixToRoomSpaceCardBinding) { + inCard: FragmentMatrixToRoomSpaceCardBinding, + showDescription: Boolean) { if (spaceSummary == null) { inCard.matrixToCardContentVisibility.isVisible = false inCard.matrixToCardButtonLoading.isVisible = true @@ -70,6 +71,8 @@ class SpaceCardRenderer @Inject constructor( inCard.matrixToMemberPills.isVisible = false } + inCard.matrixToCardDescText.isVisible = showDescription + renderPeopleYouKnow(inCard, peopleYouKnow.map { it.toMatrixItem() }) } inCard.matrixToCardDescText.movementMethod = createLinkMovementMethod(object : TimelineEventController.UrlClickCallback { diff --git a/vector/src/main/java/im/vector/app/features/media/ImageContentRenderer.kt b/vector/src/main/java/im/vector/app/features/media/ImageContentRenderer.kt index 8d6d1f467b..65c99362b9 100644 --- a/vector/src/main/java/im/vector/app/features/media/ImageContentRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/media/ImageContentRenderer.kt @@ -16,6 +16,7 @@ package im.vector.app.features.media +import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.net.Uri import android.os.Parcelable @@ -23,6 +24,7 @@ import android.view.View import android.widget.ImageView import androidx.core.view.updateLayoutParams import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.Transformation import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.load.resource.bitmap.RoundedCorners @@ -42,6 +44,7 @@ import im.vector.app.core.utils.DimensionConverter import kotlinx.parcelize.Parcelize import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.content.ContentUrlResolver +import org.matrix.android.sdk.api.session.media.PreviewUrlData import org.matrix.android.sdk.internal.crypto.attachments.ElementToDecrypt import timber.log.Timber import java.io.File @@ -59,6 +62,9 @@ interface AttachmentData : Parcelable { val allowNonMxcUrls: Boolean } +private const val URL_PREVIEW_IMAGE_MIN_FULL_WIDTH_PX = 600 +private const val URL_PREVIEW_IMAGE_MIN_FULL_HEIGHT_PX = 315 + class ImageContentRenderer @Inject constructor(private val localFilesHelper: LocalFilesHelper, private val activeSessionHolder: ActiveSessionHolder, private val dimensionConverter: DimensionConverter) { @@ -87,12 +93,20 @@ class ImageContentRenderer @Inject constructor(private val localFilesHelper: Loc /** * For url preview */ - fun render(mxcUrl: String, imageView: ImageView): Boolean { + fun render(previewUrlData: PreviewUrlData, imageView: ImageView): Boolean { val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver() - val imageUrl = contentUrlResolver.resolveFullSize(mxcUrl) ?: return false - + val imageUrl = contentUrlResolver.resolveFullSize(previewUrlData.mxcUrl) ?: return false + val maxHeight = dimensionConverter.resources.getDimensionPixelSize(R.dimen.preview_url_view_image_max_height) + val height = previewUrlData.imageHeight ?: URL_PREVIEW_IMAGE_MIN_FULL_HEIGHT_PX + val width = previewUrlData.imageWidth ?: URL_PREVIEW_IMAGE_MIN_FULL_WIDTH_PX + if (height < URL_PREVIEW_IMAGE_MIN_FULL_HEIGHT_PX || width < URL_PREVIEW_IMAGE_MIN_FULL_WIDTH_PX) { + imageView.scaleType = ImageView.ScaleType.CENTER_INSIDE + } else { + imageView.scaleType = ImageView.ScaleType.CENTER_CROP + } GlideApp.with(imageView) .load(imageUrl) + .override(width, height.coerceAtMost(maxHeight)) .into(imageView) return true } @@ -109,7 +123,7 @@ class ImageContentRenderer @Inject constructor(private val localFilesHelper: Loc .into(imageView) } - fun render(data: Data, mode: Mode, imageView: ImageView) { + fun render(data: Data, mode: Mode, imageView: ImageView, cornerTransformation: Transformation = RoundedCorners(dimensionConverter.dpToPx(8))) { val size = processSize(data, mode) imageView.updateLayoutParams { width = size.width @@ -120,7 +134,7 @@ class ImageContentRenderer @Inject constructor(private val localFilesHelper: Loc createGlideRequest(data, mode, imageView, size) .dontAnimate() - .transform(RoundedCorners(dimensionConverter.dpToPx(8))) + .transform(cornerTransformation) // .thumbnail(0.3f) .into(imageView) } diff --git a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt index f66ced3299..b521710c1e 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt @@ -53,10 +53,13 @@ import im.vector.app.features.crypto.verification.VerificationBottomSheet import im.vector.app.features.debug.DebugMenuActivity import im.vector.app.features.devtools.RoomDevToolActivity import im.vector.app.features.home.room.detail.RoomDetailActivity -import im.vector.app.features.home.room.detail.RoomDetailArgs +import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.home.room.detail.search.SearchActivity import im.vector.app.features.home.room.detail.search.SearchArgs import im.vector.app.features.home.room.filtered.FilteredRoomsActivity +import im.vector.app.features.home.room.threads.ThreadsActivity +import im.vector.app.features.home.room.threads.arguments.ThreadListArgs +import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.invite.InviteUsersToRoomActivity import im.vector.app.features.location.LocationData import im.vector.app.features.location.LocationSharingActivity @@ -140,12 +143,17 @@ class DefaultNavigator @Inject constructor( context.startActivity(intent) } - override fun openRoom(context: Context, roomId: String, eventId: String?, buildTask: Boolean) { + override fun openRoom( + context: Context, + roomId: String, + eventId: String?, + buildTask: Boolean + ) { if (sessionHolder.getSafeActiveSession()?.getRoom(roomId) == null) { fatalError("Trying to open an unknown room $roomId", vectorPreferences.failFast()) return } - val args = RoomDetailArgs(roomId, eventId) + val args = TimelineArgs(roomId, eventId) val intent = RoomDetailActivity.newIntent(context, args) startActivity(context, intent, buildTask) } @@ -168,7 +176,7 @@ class DefaultNavigator @Inject constructor( startActivity(context, SpaceManageActivity.newIntent(context, spaceId, ManageType.AddRooms), false) } is Navigator.PostSwitchSpaceAction.OpenDefaultRoom -> { - val args = RoomDetailArgs( + val args = TimelineArgs( postSwitchSpaceAction.roomId, eventId = null, openShareSpaceForId = spaceId.takeIf { postSwitchSpaceAction.showShareSheet } @@ -266,7 +274,7 @@ class DefaultNavigator @Inject constructor( } override fun openRoomForSharingAndFinish(activity: Activity, roomId: String, sharedData: SharedData) { - val args = RoomDetailArgs(roomId, null, sharedData) + val args = TimelineArgs(roomId, null, sharedData) val intent = RoomDetailActivity.newIntent(activity, args) activity.startActivity(intent) activity.finish() @@ -515,8 +523,11 @@ class DefaultNavigator @Inject constructor( } } - override fun openSearch(context: Context, roomId: String) { - val intent = SearchActivity.newIntent(context, SearchArgs(roomId)) + override fun openSearch(context: Context, + roomId: String, + roomDisplayName: String?, + roomAvatarUrl: String?) { + val intent = SearchActivity.newIntent(context, SearchArgs(roomId, roomDisplayName, roomAvatarUrl)) context.startActivity(intent) } @@ -524,9 +535,13 @@ class DefaultNavigator @Inject constructor( context.startActivity(RoomDevToolActivity.intent(context, roomId)) } - override fun openCallTransfer(context: Context, callId: String) { + override fun openCallTransfer( + context: Context, + activityResultLauncher: ActivityResultLauncher, + callId: String + ) { val intent = CallTransferActivity.newIntent(context, callId) - context.startActivity(intent) + activityResultLauncher.launch(intent) } override fun openCreatePoll(context: Context, roomId: String, editedEventId: String?, mode: PollMode) { @@ -541,7 +556,7 @@ class DefaultNavigator @Inject constructor( roomId: String, mode: LocationSharingMode, initialLocationData: LocationData?, - locationOwnerId: String) { + locationOwnerId: String?) { val intent = LocationSharingActivity.getIntent( context, LocationSharingArgs(roomId = roomId, mode = mode, initialLocationData = initialLocationData, locationOwnerId = locationOwnerId) @@ -558,4 +573,25 @@ class DefaultNavigator @Inject constructor( context.startActivity(intent) } } + + override fun openThread(context: Context, threadTimelineArgs: ThreadTimelineArgs, eventIdToNavigate: String?) { + context.startActivity(ThreadsActivity.newIntent( + context = context, + threadTimelineArgs = threadTimelineArgs, + threadListArgs = null, + eventIdToNavigate = eventIdToNavigate + )) + } + + override fun openThreadList(context: Context, threadTimelineArgs: ThreadTimelineArgs) { + context.startActivity(ThreadsActivity.newIntent( + context = context, + threadTimelineArgs = null, + threadListArgs = ThreadListArgs( + roomId = threadTimelineArgs.roomId, + displayName = threadTimelineArgs.displayName, + avatarUrl = threadTimelineArgs.avatarUrl, + roomEncryptionTrustLevel = threadTimelineArgs.roomEncryptionTrustLevel + ))) + } } diff --git a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt index 775272bd33..b5e94241ce 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt @@ -25,6 +25,7 @@ import androidx.activity.result.ActivityResultLauncher import androidx.core.util.Pair import im.vector.app.features.crypto.recover.SetupMode import im.vector.app.features.displayname.getBestName +import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.location.LocationData import im.vector.app.features.location.LocationSharingMode import im.vector.app.features.login.LoginConfig @@ -145,11 +146,15 @@ interface Navigator { inMemory: List = emptyList(), options: ((MutableList>) -> Unit)?) - fun openSearch(context: Context, roomId: String) + fun openSearch(context: Context, roomId: String, roomDisplayName: String?, roomAvatarUrl: String?) fun openDevTools(context: Context, roomId: String) - fun openCallTransfer(context: Context, callId: String) + fun openCallTransfer( + context: Context, + activityResultLauncher: ActivityResultLauncher, + callId: String + ) fun openCreatePoll(context: Context, roomId: String, editedEventId: String?, mode: PollMode) @@ -157,5 +162,9 @@ interface Navigator { roomId: String, mode: LocationSharingMode, initialLocationData: LocationData?, - locationOwnerId: String) + locationOwnerId: String?) + + fun openThread(context: Context, threadTimelineArgs: ThreadTimelineArgs, eventIdToNavigate: String? = null) + + fun openThreadList(context: Context, threadTimelineArgs: ThreadTimelineArgs) } 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 3d10d74fe3..351f085b7e 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 @@ -20,6 +20,7 @@ import im.vector.app.features.invite.AutoAcceptInvites import im.vector.app.features.notifications.ProcessedEvent.Type.KEEP import im.vector.app.features.notifications.ProcessedEvent.Type.REMOVE import org.matrix.android.sdk.api.session.events.model.EventType +import timber.log.Timber import javax.inject.Inject private typealias ProcessedEvents = List> @@ -33,9 +34,13 @@ class NotifiableEventProcessor @Inject constructor( val processedEvents = queuedEvents.map { val type = when (it) { is InviteNotifiableEvent -> if (autoAcceptInvites.hideInvites) REMOVE else KEEP - is NotifiableMessageEvent -> if (shouldIgnoreMessageEventInRoom(currentRoomId, it.roomId) || outdatedDetector.isMessageOutdated(it)) { - REMOVE - } else KEEP + is NotifiableMessageEvent -> when { + shouldIgnoreMessageEventInRoom(currentRoomId, it.roomId) -> REMOVE + .also { Timber.d("notification message removed due to currently viewing the same room") } + outdatedDetector.isMessageOutdated(it) -> REMOVE + .also { Timber.d("notification message removed due to being read") } + else -> KEEP + } is SimpleNotifiableEvent -> when (it.type) { EventType.REDACTION -> REMOVE else -> KEEP 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 ac2ec06474..01c1117ab2 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 @@ -83,7 +83,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { if (room != null) { session.coroutineScope.launch { tryOrNull { - room.join() + session.joinRoom(room.roomId) analyticsTracker.capture(room.roomSummary().toAnalyticsJoinedRoom()) } } @@ -93,11 +93,8 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { private fun handleRejectRoom(roomId: String) { activeSessionHolder.getSafeActiveSession()?.let { session -> - val room = session.getRoom(roomId) - if (room != null) { - session.coroutineScope.launch { - tryOrNull { room.leave() } - } + session.coroutineScope.launch { + tryOrNull { session.leaveRoom(roomId) } } } } diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt index 27568dae95..d39926f620 100755 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt @@ -58,7 +58,7 @@ import im.vector.app.features.call.webrtc.WebRtcCall import im.vector.app.features.displayname.getBestName import im.vector.app.features.home.HomeActivity import im.vector.app.features.home.room.detail.RoomDetailActivity -import im.vector.app.features.home.room.detail.RoomDetailArgs +import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.troubleshoot.TestNotificationReceiver import im.vector.app.features.themes.ThemeUtils @@ -514,7 +514,7 @@ class NotificationUtils @Inject constructor(private val context: Context, val contentPendingIntent = TaskStackBuilder.create(context) .addNextIntentWithParentStack(HomeActivity.newIntent(context)) - .addNextIntent(RoomDetailActivity.newIntent(context, RoomDetailArgs(callInformation.nativeRoomId))) + .addNextIntent(RoomDetailActivity.newIntent(context, TimelineArgs(callInformation.nativeRoomId))) .getPendingIntent(System.currentTimeMillis().toInt(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE) builder.setContentIntent(contentPendingIntent) @@ -773,7 +773,7 @@ class NotificationUtils @Inject constructor(private val context: Context, } private fun buildOpenRoomIntent(roomId: String): PendingIntent? { - val roomIntentTap = RoomDetailActivity.newIntent(context, RoomDetailArgs(roomId = roomId, switchToParentSpace = true)) + val roomIntentTap = RoomDetailActivity.newIntent(context, TimelineArgs(roomId = roomId, switchToParentSpace = true)) roomIntentTap.action = TAP_TO_VIEW_ACTION // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that roomIntentTap.data = createIgnoredUri("openRoom?$roomId") diff --git a/vector/src/main/java/im/vector/app/features/notifications/PushRuleTriggerListener.kt b/vector/src/main/java/im/vector/app/features/notifications/PushRuleTriggerListener.kt index ff817520db..cd08820fc1 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/PushRuleTriggerListener.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/PushRuleTriggerListener.kt @@ -55,12 +55,12 @@ class PushRuleTriggerListener @Inject constructor( private suspend fun createNotifiableEvents(pushEvents: PushEvents, session: Session): List { return pushEvents.matchedEvents.mapNotNull { (event, pushRule) -> - Timber.v("Push rule match for event ${event.eventId}") + Timber.d("Push rule match for event ${event.eventId}") val action = pushRule.getActions().toNotificationAction() if (action.shouldNotify) { resolver.resolveEvent(event, session, isNoisy = !action.soundName.isNullOrBlank()) } else { - Timber.v("Matched push rule is set to not notify") + Timber.d("Matched push rule is set to not notify") null } } diff --git a/vector/src/main/java/im/vector/app/features/onboarding/FtueUseCase.kt b/vector/src/main/java/im/vector/app/features/onboarding/FtueUseCase.kt index e720b7307c..952612b43f 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/FtueUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/FtueUseCase.kt @@ -16,9 +16,13 @@ package im.vector.app.features.onboarding -enum class FtueUseCase { - FRIENDS_FAMILY, - TEAMS, - COMMUNITIES, - SKIP +enum class FtueUseCase(val persistableValue: String) { + FRIENDS_FAMILY("friends_family"), + TEAMS("teams"), + COMMUNITIES("communities"), + SKIP("skip"); + + companion object { + fun from(persistedValue: String) = values().first { it.persistableValue == persistedValue } + } } diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt index 43f37f4601..a125c57ac9 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt @@ -32,10 +32,14 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.extensions.configureAndStart import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.extensions.vectorStore import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.resources.StringProvider import im.vector.app.core.utils.ensureTrailingSlash import im.vector.app.features.VectorFeatures +import im.vector.app.features.analytics.AnalyticsTracker +import im.vector.app.features.analytics.extensions.toTrackingValue +import im.vector.app.features.analytics.plan.UserProperties import im.vector.app.features.login.HomeServerConnectionConfigFactory import im.vector.app.features.login.LoginConfig import im.vector.app.features.login.LoginMode @@ -73,7 +77,8 @@ class OnboardingViewModel @AssistedInject constructor( private val reAuthHelper: ReAuthHelper, private val stringProvider: StringProvider, private val homeServerHistoryService: HomeServerHistoryService, - private val vectorFeatures: VectorFeatures + private val vectorFeatures: VectorFeatures, + private val analyticsTracker: AnalyticsTracker ) : VectorViewModel(initialState) { @AssistedFactory @@ -125,7 +130,7 @@ class OnboardingViewModel @AssistedInject constructor( when (action) { is OnboardingAction.OnGetStarted -> handleSplashAction(action.resetLoginConfig, action.onboardingFlow) is OnboardingAction.OnIAlreadyHaveAnAccount -> handleSplashAction(action.resetLoginConfig, action.onboardingFlow) - is OnboardingAction.UpdateUseCase -> handleUpdateUseCase() + is OnboardingAction.UpdateUseCase -> handleUpdateUseCase(action) OnboardingAction.ResetUseCase -> resetUseCase() is OnboardingAction.UpdateServerType -> handleUpdateServerType(action) is OnboardingAction.UpdateSignMode -> handleUpdateSignMode(action) @@ -458,13 +463,13 @@ class OnboardingViewModel @AssistedInject constructor( } } - private fun handleUpdateUseCase() { - // TODO act on the use case selection + private fun handleUpdateUseCase(action: OnboardingAction.UpdateUseCase) { + setState { copy(useCase = action.useCase) } _viewEvents.post(OnboardingViewEvents.OpenServerSelection) } private fun resetUseCase() { - // TODO remove stored use case + setState { copy(useCase = null) } } private fun handleUpdateServerType(action: OnboardingAction.UpdateServerType) { @@ -745,6 +750,10 @@ class OnboardingViewModel @AssistedInject constructor( } private suspend fun onSessionCreated(session: Session) { + awaitState().useCase?.let { useCase -> + session.vectorStore(applicationContext).setUseCase(useCase) + analyticsTracker.updateUserProperties(UserProperties(ftueUseCaseSelection = useCase.toTrackingValue())) + } activeSessionHolder.setActiveSession(session) authenticationService.reset() diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt index fd25f3901e..d05a8294f6 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt @@ -40,6 +40,8 @@ data class OnboardingViewState( @PersistState val serverType: ServerType = ServerType.Unknown, @PersistState + val useCase: FtueUseCase? = null, + @PersistState val signMode: SignMode = SignMode.Unknown, @PersistState val resetPasswordEmail: String? = null, diff --git a/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandler.kt b/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandler.kt index 40cc0b3e13..b67e779a33 100644 --- a/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandler.kt +++ b/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandler.kt @@ -22,21 +22,26 @@ import androidx.core.net.toUri import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.extensions.isIgnored +import im.vector.app.core.resources.UserPreferencesProvider import im.vector.app.core.utils.toast +import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.navigation.Navigator import im.vector.app.features.roomdirectory.roompreview.RoomPreviewData import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId import org.matrix.android.sdk.api.session.permalinks.PermalinkData import org.matrix.android.sdk.api.session.permalinks.PermalinkParser import org.matrix.android.sdk.api.session.permalinks.PermalinkService import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.RoomType import javax.inject.Inject class PermalinkHandler @Inject constructor(private val activeSessionHolder: ActiveSessionHolder, + private val userPreferencesProvider: UserPreferencesProvider, private val navigator: Navigator) { suspend fun launch( @@ -80,13 +85,20 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti return when (permalinkData) { is PermalinkData.RoomLink -> { val roomId = permalinkData.getRoomId() + val session = activeSessionHolder.getSafeActiveSession() + + val rootThreadEventId = permalinkData.eventId?.let { eventId -> + val room = roomId?.let { session?.getRoom(it) } + room?.getTimeLineEvent(eventId)?.root?.getRootThreadEventId() + } openRoom( navigationInterceptor, context = context, roomId = roomId, permalinkData = permalinkData, rawLink = rawLink, - buildTask = buildTask + buildTask = buildTask, + rootThreadEventId = rootThreadEventId ) true } @@ -150,7 +162,8 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti roomId: String?, permalinkData: PermalinkData.RoomLink, rawLink: Uri, - buildTask: Boolean + buildTask: Boolean, + rootThreadEventId: String? = null ) { val session = activeSessionHolder.getSafeActiveSession() ?: return if (roomId == null) { @@ -167,7 +180,7 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti membership?.isActive().orFalse() -> { if (!isSpace && membership == Membership.JOIN) { // If it's a room you're in, let's just open it, you can tap back if needed - navigationInterceptor.openJoinedRoomScreen(buildTask, roomId, eventId, rawLink, context) + navigationInterceptor.openJoinedRoomScreen(buildTask, roomId, eventId, rawLink, context, rootThreadEventId, roomSummary) } else { // maybe open space preview navigator.openSpacePreview(context, roomId)? if already joined? navigator.openMatrixToBottomSheet(context, rawLink.toString()) @@ -180,9 +193,26 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti } } - private fun NavigationInterceptor?.openJoinedRoomScreen(buildTask: Boolean, roomId: String, eventId: String?, rawLink: Uri, context: Context) { - if (this?.navToRoom(roomId, eventId, rawLink) != true) { - navigator.openRoom(context, roomId, eventId, buildTask) + private fun NavigationInterceptor?.openJoinedRoomScreen(buildTask: Boolean, + roomId: String, + eventId: String?, + rawLink: Uri, + context: Context, + rootThreadEventId: String?, + roomSummary: RoomSummary + ) { + if (this?.navToRoom(roomId, eventId, rawLink, rootThreadEventId) != true) { + if (rootThreadEventId != null && userPreferencesProvider.areThreadMessagesEnabled()) { + val threadTimelineArgs = ThreadTimelineArgs( + roomId = roomId, + displayName = roomSummary.displayName, + avatarUrl = roomSummary.avatarUrl, + roomEncryptionTrustLevel = roomSummary.roomEncryptionTrustLevel, + rootThreadEventId = rootThreadEventId) + navigator.openThread(context, threadTimelineArgs, eventId) + } else { + navigator.openRoom(context, roomId, eventId, buildTask) + } } } @@ -198,7 +228,7 @@ interface NavigationInterceptor { /** * Return true if the navigation has been intercepted */ - fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri? = null): Boolean { + fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri? = null, rootThreadEventId: String? = null): Boolean { return false } diff --git a/vector/src/main/java/im/vector/app/features/poll/create/CreatePollController.kt b/vector/src/main/java/im/vector/app/features/poll/create/CreatePollController.kt index d938b98eed..6ccf7fc6aa 100644 --- a/vector/src/main/java/im/vector/app/features/poll/create/CreatePollController.kt +++ b/vector/src/main/java/im/vector/app/features/poll/create/CreatePollController.kt @@ -121,6 +121,7 @@ class CreatePollController @Inject constructor( textColor(host.colorProvider.getColor(R.color.palette_element_green)) gravity(Gravity.START) bold(true) + highlight(false) buttonClickAction { host.callback?.onAddOption() } diff --git a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerAction.kt b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerAction.kt new file mode 100644 index 0000000000..910f0246d3 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerAction.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.qrcode + +import im.vector.app.core.platform.VectorViewModelAction + +sealed class QrCodeScannerAction : VectorViewModelAction { + data class CodeDecoded( + val result: String, + val isQrCode: Boolean + ) : QrCodeScannerAction() + + object ScanFailed : QrCodeScannerAction() + + object SwitchMode : QrCodeScannerAction() +} diff --git a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerActivity.kt b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerActivity.kt index d347bc0250..dda7b2e2eb 100644 --- a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerActivity.kt +++ b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerActivity.kt @@ -19,57 +19,55 @@ package im.vector.app.features.qrcode import android.app.Activity import android.content.Intent import android.os.Bundle +import android.widget.Toast import androidx.activity.result.ActivityResultLauncher -import com.google.zxing.BarcodeFormat -import com.google.zxing.Result -import com.google.zxing.ResultMetadataType +import com.airbnb.mvrx.viewModel import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.R +import im.vector.app.core.extensions.exhaustive import im.vector.app.core.extensions.replaceFragment import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivitySimpleBinding @AndroidEntryPoint -class QrCodeScannerActivity : VectorBaseActivity() { +class QrCodeScannerActivity() : VectorBaseActivity() { override fun getBinding() = ActivitySimpleBinding.inflate(layoutInflater) override fun getCoordinatorLayout() = views.coordinatorLayout + private val qrViewModel: QrCodeScannerViewModel by viewModel() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + qrViewModel.observeViewEvents { + when (it) { + is QrCodeScannerEvents.CodeParsed -> { + setResultAndFinish(it.result, it.isQrCode) + } + is QrCodeScannerEvents.ParseFailed -> { + Toast.makeText(this, R.string.qr_code_not_scanned, Toast.LENGTH_SHORT).show() + finish() + } + else -> Unit + }.exhaustive + } + if (isFirstCreation()) { - replaceFragment(views.simpleFragmentContainer, QrCodeScannerFragment::class.java) + val args = QrScannerArgs(showExtraButtons = false, R.string.verification_scan_their_code) + replaceFragment(views.simpleFragmentContainer, QrCodeScannerFragment::class.java, args) } } - fun setResultAndFinish(result: Result?) { - if (result != null) { - val rawBytes = getRawBytes(result) - val rawBytesStr = rawBytes?.toString(Charsets.ISO_8859_1) - - setResult(RESULT_OK, Intent().apply { - putExtra(EXTRA_OUT_TEXT, rawBytesStr ?: result.text) - putExtra(EXTRA_OUT_IS_QR_CODE, result.barcodeFormat == BarcodeFormat.QR_CODE) - }) - } + private fun setResultAndFinish(result: String, isQrCode: Boolean) { + setResult(RESULT_OK, Intent().apply { + putExtra(EXTRA_OUT_TEXT, result) + putExtra(EXTRA_OUT_IS_QR_CODE, isQrCode) + }) finish() } - // Copied from https://github.com/markusfisch/BinaryEye/blob/ - // 9d57889b810dcaa1a91d7278fc45c262afba1284/app/src/main/kotlin/de/markusfisch/android/binaryeye/activity/CameraActivity.kt#L434 - private fun getRawBytes(result: Result): ByteArray? { - val metadata = result.resultMetadata ?: return null - val segments = metadata[ResultMetadataType.BYTE_SEGMENTS] ?: return null - var bytes = ByteArray(0) - @Suppress("UNCHECKED_CAST") - for (seg in segments as Iterable) { - bytes += seg - } - // byte segments can never be shorter than the text. - // Zxing cuts off content prefixes like "WIFI:" - return if (bytes.size >= result.text.length) bytes else null - } - companion object { private const val EXTRA_OUT_TEXT = "EXTRA_OUT_TEXT" private const val EXTRA_OUT_IS_QR_CODE = "EXTRA_OUT_IS_QR_CODE" diff --git a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerEvents.kt b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerEvents.kt new file mode 100644 index 0000000000..69a500238e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerEvents.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.qrcode + +import im.vector.app.core.platform.VectorViewEvents + +sealed class QrCodeScannerEvents : VectorViewEvents { + data class CodeParsed(val result: String, val isQrCode: Boolean) : QrCodeScannerEvents() + object ParseFailed : QrCodeScannerEvents() + object SwitchMode : QrCodeScannerEvents() +} diff --git a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerFragment.kt b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerFragment.kt index a7231a0c5b..c514a1c8aa 100644 --- a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerFragment.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 New Vector Ltd + * 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. @@ -16,50 +16,157 @@ package im.vector.app.features.qrcode +import android.app.Activity import android.os.Bundle +import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import com.airbnb.mvrx.activityViewModel +import com.airbnb.mvrx.args +import com.google.zxing.BarcodeFormat import com.google.zxing.Result +import com.google.zxing.ResultMetadataType import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO +import im.vector.app.core.utils.checkPermissions +import im.vector.app.core.utils.onPermissionDeniedDialog +import im.vector.app.core.utils.registerForPermissionsResult import im.vector.app.databinding.FragmentQrCodeScannerBinding +import im.vector.app.features.usercode.QRCodeBitmapDecodeHelper +import im.vector.lib.multipicker.MultiPicker +import im.vector.lib.multipicker.utils.ImageUtils +import kotlinx.parcelize.Parcelize import me.dm7.barcodescanner.zxing.ZXingScannerView +import org.matrix.android.sdk.api.extensions.tryOrNull import javax.inject.Inject -class QrCodeScannerFragment @Inject constructor() : - VectorBaseFragment(), - ZXingScannerView.ResultHandler { +@Parcelize +data class QrScannerArgs( + val showExtraButtons: Boolean, + @StringRes val titleRes: Int +) : Parcelable + +open class QrCodeScannerFragment @Inject constructor() : VectorBaseFragment(), ZXingScannerView.ResultHandler { + + private val qrViewModel: QrCodeScannerViewModel by activityViewModel() + private val scannerArgs: QrScannerArgs? by args() override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentQrCodeScannerBinding { return FragmentQrCodeScannerBinding.inflate(inflater, container, false) } + private val openCameraActivityResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> + if (allGranted) { + startCamera() + } else if (deniedPermanently) { + activity?.onPermissionDeniedDialog(R.string.denied_permission_camera) + } + } + + private val pickImageActivityResultLauncher = registerStartForActivityResult { activityResult -> + if (activityResult.resultCode == Activity.RESULT_OK) { + MultiPicker + .get(MultiPicker.IMAGE) + .getSelectedFiles(requireActivity(), activityResult.data) + .firstOrNull() + ?.contentUri + ?.let { uri -> + // try to see if it is a valid matrix code + val bitmap = ImageUtils.getBitmap(requireContext(), uri) + ?: return@let Unit.also { + Toast.makeText(requireContext(), getString(R.string.qr_code_not_scanned), Toast.LENGTH_SHORT).show() + } + handleResult(tryOrNull { QRCodeBitmapDecodeHelper.decodeQRFromBitmap(bitmap) }) + } + } + } + + private var autoFocus = true + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + val title = scannerArgs?.titleRes?.let { getString(it) } + setupToolbar(views.qrScannerToolbar) - .setTitle(R.string.verification_scan_their_code) + .setTitle(title) .allowBack(useCross = true) + + scannerArgs?.showExtraButtons?.let { showButtons -> + views.userCodeMyCodeButton.isVisible = showButtons + views.userCodeOpenGalleryButton.isVisible = showButtons + + if (showButtons) { + views.userCodeOpenGalleryButton.debouncedClicks { + MultiPicker.get(MultiPicker.IMAGE).single().startWith(pickImageActivityResultLauncher) + } + views.userCodeMyCodeButton.debouncedClicks { + qrViewModel.handle(QrCodeScannerAction.SwitchMode) + } + } + } + } + + private fun startCamera() { + with(views.qrScannerView) { + startCamera() + setAutoFocus(autoFocus) + debouncedClicks { + autoFocus = !autoFocus + setAutoFocus(autoFocus) + } + } } override fun onResume() { super.onResume() + view?.hideKeyboard() + // Register ourselves as a handler for scan results. - views.scannerView.setResultHandler(this) - // Start camera on resume - views.scannerView.startCamera() + views.qrScannerView.setResultHandler(this) + + if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), openCameraActivityResultLauncher)) { + startCamera() + } } override fun onPause() { super.onPause() - // Stop camera on pause - views.scannerView.stopCamera() + views.qrScannerView.setResultHandler(null) + views.qrScannerView.stopCamera() + } + + // Copied from https://github.com/markusfisch/BinaryEye/blob/ + // 9d57889b810dcaa1a91d7278fc45c262afba1284/app/src/main/kotlin/de/markusfisch/android/binaryeye/activity/CameraActivity.kt#L434 + private fun getRawBytes(result: Result): ByteArray? { + val metadata = result.resultMetadata ?: return null + val segments = metadata[ResultMetadataType.BYTE_SEGMENTS] ?: return null + var bytes = ByteArray(0) + @Suppress("UNCHECKED_CAST") + for (seg in segments as Iterable) { + bytes += seg + } + // byte segments can never be shorter than the text. + // Zxing cuts off content prefixes like "WIFI:" + return if (bytes.size >= result.text.length) bytes else null } override fun handleResult(rawResult: Result?) { - // Do something with the result here - // This is not intended to be used outside of QrCodeScannerActivity for the moment - (requireActivity() as? QrCodeScannerActivity)?.setResultAndFinish(rawResult) + if (rawResult == null) { + qrViewModel.handle(QrCodeScannerAction.ScanFailed) + } else { + val rawBytes = getRawBytes(rawResult) + val rawBytesStr = rawBytes?.toString(Charsets.ISO_8859_1) + val result = rawBytesStr ?: rawResult.text + val isQrCode = rawResult.barcodeFormat == BarcodeFormat.QR_CODE + qrViewModel.handle(QrCodeScannerAction.CodeDecoded(result, isQrCode)) + } } } diff --git a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerViewModel.kt b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerViewModel.kt new file mode 100644 index 0000000000..ef47ea1a6e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerViewModel.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.qrcode + +import com.airbnb.mvrx.MavericksViewModelFactory +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.core.di.MavericksAssistedViewModelFactory +import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.core.platform.VectorDummyViewState +import im.vector.app.core.platform.VectorViewModel +import org.matrix.android.sdk.api.session.Session + +class QrCodeScannerViewModel @AssistedInject constructor( + @Assisted initialState: VectorDummyViewState, + val session: Session +) : VectorViewModel(initialState) { + + @AssistedFactory + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: VectorDummyViewState): QrCodeScannerViewModel + } + + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() + + override fun handle(action: QrCodeScannerAction) { + _viewEvents.post( + when (action) { + is QrCodeScannerAction.CodeDecoded -> QrCodeScannerEvents.CodeParsed(action.result, action.isQrCode) + is QrCodeScannerAction.SwitchMode -> QrCodeScannerEvents.SwitchMode + is QrCodeScannerAction.ScanFailed -> QrCodeScannerEvents.ParseFailed + } + ) + } +} diff --git a/vector/src/main/java/im/vector/app/features/rageshake/BugReportActivity.kt b/vector/src/main/java/im/vector/app/features/rageshake/BugReportActivity.kt index 0aec24f4ac..2d4bc704a4 100755 --- a/vector/src/main/java/im/vector/app/features/rageshake/BugReportActivity.kt +++ b/vector/src/main/java/im/vector/app/features/rageshake/BugReportActivity.kt @@ -151,7 +151,7 @@ class BugReportActivity : VectorBaseActivity() { views.bugReportProgressView.isVisible = true views.bugReportProgressView.progress = 0 - bugReporter.sendBugReport(this, + bugReporter.sendBugReport( reportType, views.bugReportButtonIncludeLogs.isChecked, views.bugReportButtonIncludeCrashLogs.isChecked, @@ -249,7 +249,7 @@ class BugReportActivity : VectorBaseActivity() { override fun onBackPressed() { // Ensure there is no crash status remaining, which will be sent later on by mistake - bugReporter.deleteCrashFile(this) + bugReporter.deleteCrashFile() super.onBackPressed() } 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 b62a182fd8..2c554716d2 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 @@ -68,6 +68,7 @@ import javax.inject.Singleton */ @Singleton class BugReporter @Inject constructor( + private val context: Context, private val activeSessionHolder: ActiveSessionHolder, private val versionProvider: VersionProvider, private val vectorPreferences: VectorPreferences, @@ -153,7 +154,6 @@ class BugReporter @Inject constructor( /** * Send a bug report. * - * @param context the application context * @param reportType The report type (bug, suggestion, feedback) * @param withDevicesLogs true to include the device log * @param withCrashLogs true to include the crash logs @@ -163,8 +163,7 @@ class BugReporter @Inject constructor( * @param listener the listener */ @SuppressLint("StaticFieldLeak") - fun sendBugReport(context: Context, - reportType: ReportType, + fun sendBugReport(reportType: ReportType, withDevicesLogs: Boolean, withCrashLogs: Boolean, withKeyRequestHistory: Boolean, @@ -182,7 +181,7 @@ class BugReporter @Inject constructor( var reportURL: String? = null withContext(Dispatchers.IO) { var bugDescription = theBugDescription - val crashCallStack = getCrashDescription(context) + val crashCallStack = getCrashDescription() if (null != crashCallStack) { bugDescription += "\n\n\n\n--------------------------------- crash call stack ---------------------------------\n" @@ -203,7 +202,7 @@ class BugReporter @Inject constructor( } if (!mIsCancelled && (withCrashLogs || withDevicesLogs)) { - val gzippedLogcat = saveLogCat(context, false) + val gzippedLogcat = saveLogCat(false) if (null != gzippedLogcat) { if (gzippedFiles.size == 0) { @@ -213,7 +212,7 @@ class BugReporter @Inject constructor( } } - val crashDescription = getCrashFile(context) + val crashDescription = getCrashFile() if (crashDescription.exists()) { val compressedCrashDescription = compressFile(crashDescription) @@ -265,7 +264,7 @@ class BugReporter @Inject constructor( // build the multi part request val builder = BugReporterMultipartBody.Builder() .addFormDataPart("text", text) - .addFormDataPart("app", rageShakeAppNameForReport(context, reportType)) + .addFormDataPart("app", rageShakeAppNameForReport(reportType)) .addFormDataPart("user_agent", Matrix.getInstance(context).getUserAgent()) .addFormDataPart("user_id", userId) .addFormDataPart("can_contact", canContact.toString()) @@ -352,9 +351,9 @@ class BugReporter @Inject constructor( } } - if (getCrashFile(context).exists()) { + if (getCrashFile().exists()) { builder.addFormDataPart("label", "crash") - deleteCrashFile(context) + deleteCrashFile() } val requestBody = builder.build() @@ -487,20 +486,16 @@ class BugReporter @Inject constructor( activity.startActivity(BugReportActivity.intent(activity, reportType)) } - private fun rageShakeAppNameForReport(context: Context, reportType: ReportType): String { + private fun rageShakeAppNameForReport(reportType: ReportType): String { // As per https://github.com/matrix-org/rageshake // app: Identifier for the application (eg 'riot-web'). // Should correspond to a mapping configured in the configuration file for github issue reporting to work. // (see R.string.bug_report_url for configured RS server) - return when (reportType) { + return context.getString(when (reportType) { ReportType.AUTO_UISI_SENDER, - ReportType.AUTO_UISI -> { - context.getString(R.string.bug_report_auto_uisi_app_name) - } - else -> { - context.getString(R.string.bug_report_app_name) - } - } + ReportType.AUTO_UISI -> R.string.bug_report_auto_uisi_app_name + else -> R.string.bug_report_app_name + }) } // ============================================================================================================== // crash report management @@ -509,20 +504,17 @@ class BugReporter @Inject constructor( /** * Provides the crash file * - * @param context the context * @return the crash file */ - private fun getCrashFile(context: Context): File { + private fun getCrashFile(): File { return File(context.cacheDir.absolutePath, CRASH_FILENAME) } /** * Remove the crash file - * - * @param context */ - fun deleteCrashFile(context: Context) { - val crashFile = getCrashFile(context) + fun deleteCrashFile() { + val crashFile = getCrashFile() if (crashFile.exists()) { crashFile.delete() @@ -535,11 +527,10 @@ class BugReporter @Inject constructor( /** * Save the crash report * - * @param context the context * @param crashDescription teh crash description */ - fun saveCrashReport(context: Context, crashDescription: String) { - val crashFile = getCrashFile(context) + fun saveCrashReport(crashDescription: String) { + val crashFile = getCrashFile() if (crashFile.exists()) { crashFile.delete() @@ -557,11 +548,10 @@ class BugReporter @Inject constructor( /** * Read the crash description file and return its content. * - * @param context teh context * @return the crash description */ - private fun getCrashDescription(context: Context): String? { - val crashFile = getCrashFile(context) + private fun getCrashDescription(): String? { + val crashFile = getCrashFile() if (crashFile.exists()) { try { @@ -650,11 +640,10 @@ class BugReporter @Inject constructor( /** * Save the logcat * - * @param context the context * @param isErrorLogcat true to save the error logcat * @return the file if the operation succeeds */ - private fun saveLogCat(context: Context, isErrorLogcat: Boolean): File? { + private fun saveLogCat(isErrorLogcat: Boolean): File? { val logCatErrFile = File(context.cacheDir.absolutePath, if (isErrorLogcat) LOG_CAT_ERROR_FILENAME else LOG_CAT_FILENAME) if (logCatErrFile.exists()) { 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 6954b9c87b..bd2f0b67bd 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 @@ -30,9 +30,12 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class VectorUncaughtExceptionHandler @Inject constructor(private val bugReporter: BugReporter, - private val versionProvider: VersionProvider, - private val versionCodeProvider: VersionCodeProvider) : Thread.UncaughtExceptionHandler { +class VectorUncaughtExceptionHandler @Inject constructor( + context: Context, + private val bugReporter: BugReporter, + private val versionProvider: VersionProvider, + private val versionCodeProvider: VersionCodeProvider +) : Thread.UncaughtExceptionHandler { // key to save the crash status companion object { @@ -41,13 +44,12 @@ class VectorUncaughtExceptionHandler @Inject constructor(private val bugReporter private var previousHandler: Thread.UncaughtExceptionHandler? = null - private lateinit var context: Context + private val preferences = DefaultSharedPreferences.getInstance(context) /** * Activate this handler */ - fun activate(context: Context) { - this.context = context + fun activate() { previousHandler = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler(this) } @@ -61,7 +63,7 @@ class VectorUncaughtExceptionHandler @Inject constructor(private val bugReporter */ override fun uncaughtException(thread: Thread, throwable: Throwable) { Timber.v("Uncaught exception: $throwable") - DefaultSharedPreferences.getInstance(context).edit { + preferences.edit(commit = true) { putBoolean(PREFS_CRASH_KEY, true) } val b = StringBuilder() @@ -103,7 +105,7 @@ class VectorUncaughtExceptionHandler @Inject constructor(private val bugReporter val bugDescription = b.toString() Timber.e("FATAL EXCEPTION $bugDescription") - bugReporter.saveCrashReport(context, bugDescription) + bugReporter.saveCrashReport(bugDescription) // Show the classical system popup previousHandler?.uncaughtException(thread, throwable) @@ -114,16 +116,15 @@ class VectorUncaughtExceptionHandler @Inject constructor(private val bugReporter * * @return true if the application crashed */ - fun didAppCrash(context: Context): Boolean { - return DefaultSharedPreferences.getInstance(context) - .getBoolean(PREFS_CRASH_KEY, false) + fun didAppCrash(): Boolean { + return preferences.getBoolean(PREFS_CRASH_KEY, false) } /** * Clear the crash status */ - fun clearAppCrashStatus(context: Context) { - DefaultSharedPreferences.getInstance(context).edit { + fun clearAppCrashStatus() { + preferences.edit { remove(PREFS_CRASH_KEY) } } diff --git a/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnown.kt b/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnown.kt index dc8090bc7c..0ae2a16b71 100644 --- a/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnown.kt +++ b/vector/src/main/java/im/vector/app/features/raw/wellknown/ElementWellKnown.kt @@ -35,7 +35,10 @@ data class ElementWellKnown( val elementE2E: E2EWellKnownConfig? = null, @Json(name = "im.vector.riot.e2ee") - val riotE2E: E2EWellKnownConfig? = null + val riotE2E: E2EWellKnownConfig? = null, + + @Json(name = "org.matrix.msc3488.tile_server") + val mapTileServerConfig: MapTileServerConfig? = null ) @JsonClass(generateAdapter = true) @@ -53,3 +56,9 @@ data class WellKnownPreferredConfig( @Json(name = "preferredDomain") val preferredDomain: String? = null ) + +@JsonClass(generateAdapter = true) +data class MapTileServerConfig( + @Json(name = "map_style_url") + val mapStyleUrl: String? = null +) diff --git a/vector/src/main/java/im/vector/app/features/reactions/widget/ReactionButton.kt b/vector/src/main/java/im/vector/app/features/reactions/widget/ReactionButton.kt index 67095b974a..7340953f32 100644 --- a/vector/src/main/java/im/vector/app/features/reactions/widget/ReactionButton.kt +++ b/vector/src/main/java/im/vector/app/features/reactions/widget/ReactionButton.kt @@ -18,7 +18,6 @@ package im.vector.app.features.reactions.widget import android.content.Context import android.graphics.drawable.Drawable import android.util.AttributeSet -import android.view.Gravity import android.view.View import android.widget.LinearLayout import androidx.core.content.ContextCompat @@ -26,7 +25,6 @@ import androidx.core.content.withStyledAttributes import dagger.hilt.android.AndroidEntryPoint import im.vector.app.EmojiSpanify import im.vector.app.R -import im.vector.app.core.utils.DimensionConverter import im.vector.app.core.utils.TextUtils import im.vector.app.databinding.ReactionButtonBinding import javax.inject.Inject @@ -38,8 +36,9 @@ import javax.inject.Inject @AndroidEntryPoint class ReactionButton @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = 0) : - LinearLayout(context, attrs, defStyleAttr), View.OnClickListener, View.OnLongClickListener { + defStyleAttr: Int = 0, + defStyleRes: Int = R.style.TimelineReactionView) : + LinearLayout(context, attrs, defStyleAttr, defStyleRes), View.OnClickListener, View.OnLongClickListener { @Inject lateinit var emojiSpanify: EmojiSpanify @@ -68,8 +67,7 @@ class ReactionButton @JvmOverloads constructor(context: Context, init { inflate(context, R.layout.reaction_button, this) orientation = HORIZONTAL - minimumHeight = DimensionConverter(context.resources).dpToPx(30) - gravity = Gravity.CENTER + layoutDirection = View.LAYOUT_DIRECTION_LOCALE views = ReactionButtonBinding.bind(this) views.reactionCount.text = TextUtils.formatCountToShortDecimal(reactionCount) context.withStyledAttributes(attrs, R.styleable.ReactionButton, defStyleAttr) { diff --git a/vector/src/main/java/im/vector/app/features/room/RequireActiveMembershipViewState.kt b/vector/src/main/java/im/vector/app/features/room/RequireActiveMembershipViewState.kt index 7a5363100f..7e4af1b7d5 100644 --- a/vector/src/main/java/im/vector/app/features/room/RequireActiveMembershipViewState.kt +++ b/vector/src/main/java/im/vector/app/features/room/RequireActiveMembershipViewState.kt @@ -17,7 +17,7 @@ package im.vector.app.features.room import com.airbnb.mvrx.MavericksState -import im.vector.app.features.home.room.detail.RoomDetailArgs +import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.roommemberprofile.RoomMemberProfileArgs import im.vector.app.features.roomprofile.RoomProfileArgs @@ -25,7 +25,7 @@ data class RequireActiveMembershipViewState( val roomId: String? = null ) : MavericksState { - constructor(args: RoomDetailArgs) : this(roomId = args.roomId) + constructor(args: TimelineArgs) : this(roomId = args.roomId) constructor(args: RoomProfileArgs) : this(roomId = args.roomId) diff --git a/vector/src/main/java/im/vector/app/features/roomdirectory/PublicRoomsFragment.kt b/vector/src/main/java/im/vector/app/features/roomdirectory/PublicRoomsFragment.kt index b6b8aa9653..14b50c2745 100644 --- a/vector/src/main/java/im/vector/app/features/roomdirectory/PublicRoomsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomdirectory/PublicRoomsFragment.kt @@ -127,7 +127,7 @@ class PublicRoomsFragment @Inject constructor( val permalink = session.permalinkService().createPermalink(roomIdOrAlias) val isHandled = permalinkHandler .launch(requireContext(), permalink, object : NavigationInterceptor { - override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?): Boolean { + override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?, rootThreadEventId: String?): Boolean { requireActivity().finish() return false } diff --git a/vector/src/main/java/im/vector/app/features/roomdirectory/roompreview/RoomPreviewViewModel.kt b/vector/src/main/java/im/vector/app/features/roomdirectory/roompreview/RoomPreviewViewModel.kt index b1fa0e974a..42bec8c8b3 100644 --- a/vector/src/main/java/im/vector/app/features/roomdirectory/roompreview/RoomPreviewViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/roomdirectory/roompreview/RoomPreviewViewModel.kt @@ -251,6 +251,7 @@ class RoomPreviewViewModel @AssistedInject constructor( analyticsTracker.capture(JoinedRoom( // Always false in this case (?) isDM = false, + isSpace = false, roomSize = state.numJoinMembers.toAnalyticsRoomSize() )) // We do not update the joiningRoomsIds here, because, the room is not joined yet regarding the sync data. diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileViewModel.kt b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileViewModel.kt index 363cb1ea31..b7c7d24888 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileViewModel.kt @@ -186,7 +186,7 @@ class RoomProfileViewModel @AssistedInject constructor( _viewEvents.post(RoomProfileViewEvents.Loading(stringProvider.getString(R.string.room_profile_leaving_room))) viewModelScope.launch { try { - room.leave(null) + session.leaveRoom(room.roomId) // Do nothing, we will be closing the room automatically when it will get back from sync } catch (failure: Throwable) { _viewEvents.post(RoomProfileViewEvents.Failure(failure)) diff --git a/vector/src/main/java/im/vector/app/features/session/VectorSessionStore.kt b/vector/src/main/java/im/vector/app/features/session/VectorSessionStore.kt new file mode 100644 index 0000000000..a2f3196979 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/session/VectorSessionStore.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.session + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import im.vector.app.core.extensions.dataStoreProvider +import im.vector.app.features.onboarding.FtueUseCase +import kotlinx.coroutines.flow.first +import org.matrix.android.sdk.internal.util.md5 + +/** + * User session scoped storage for: + * - messaging use case (Enum/String) + */ +class VectorSessionStore constructor( + context: Context, + myUserId: String +) { + + private val useCaseKey = stringPreferencesKey("use_case") + private val dataStore by lazy { context.dataStoreProvider("vector_session_store_${myUserId.md5()}") } + + suspend fun readUseCase() = dataStore.data.first().let { preferences -> + preferences[useCaseKey]?.let { FtueUseCase.from(it) } + } + + suspend fun setUseCase(useCase: FtueUseCase) { + dataStore.edit { settings -> + settings[useCaseKey] = useCase.persistableValue + } + } + + suspend fun resetUseCase() { + dataStore.edit { settings -> + settings.remove(useCaseKey) + } + } + + suspend fun clear() { + dataStore.edit { settings -> settings.clear() } + } +} 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 eb620f8e5c..6a4b5484eb 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 @@ -83,6 +83,7 @@ class VectorPreferences @Inject constructor(private val context: Context) { // interface const val SETTINGS_INTERFACE_LANGUAGE_PREFERENCE_KEY = "SETTINGS_INTERFACE_LANGUAGE_PREFERENCE_KEY" const val SETTINGS_INTERFACE_TEXT_SIZE_KEY = "SETTINGS_INTERFACE_TEXT_SIZE_KEY" + const val SETTINGS_INTERFACE_BUBBLE_KEY = "SETTINGS_INTERFACE_BUBBLE_KEY" const val SETTINGS_SHOW_URL_PREVIEW_KEY = "SETTINGS_SHOW_URL_PREVIEW_KEY" private const val SETTINGS_SEND_TYPING_NOTIF_KEY = "SETTINGS_SEND_TYPING_NOTIF_KEY" private const val SETTINGS_ENABLE_MARKDOWN_KEY = "SETTINGS_ENABLE_MARKDOWN_KEY" @@ -185,7 +186,6 @@ class VectorPreferences @Inject constructor(private val context: Context) { private const val SETTINGS_DISPLAY_ALL_EVENTS_KEY = "SETTINGS_DISPLAY_ALL_EVENTS_KEY" private const val DID_ASK_TO_ENABLE_SESSION_PUSH = "DID_ASK_TO_ENABLE_SESSION_PUSH" - private const val DID_PROMOTE_NEW_RESTRICTED_JOIN_RULE = "DID_PROMOTE_NEW_RESTRICTED_JOIN_RULE" // Location Sharing const val SETTINGS_PREF_ENABLE_LOCATION_SHARING = "SETTINGS_PREF_ENABLE_LOCATION_SHARING" @@ -200,6 +200,7 @@ class VectorPreferences @Inject constructor(private val context: Context) { private const val TAKE_PHOTO_VIDEO_MODE = "TAKE_PHOTO_VIDEO_MODE" private const val SETTINGS_LABS_RENDER_LOCATIONS_IN_TIMELINE = "SETTINGS_LABS_RENDER_LOCATIONS_IN_TIMELINE" + const val SETTINGS_LABS_ENABLE_THREAD_MESSAGES = "SETTINGS_LABS_ENABLE_THREAD_MESSAGES" // Possible values for TAKE_PHOTO_VIDEO_MODE const val TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK = 0 @@ -355,16 +356,6 @@ class VectorPreferences @Inject constructor(private val context: Context) { } } - fun didPromoteNewRestrictedFeature(): Boolean { - return defaultPrefs.getBoolean(DID_PROMOTE_NEW_RESTRICTED_JOIN_RULE, false) - } - - fun setDidPromoteNewRestrictedFeature() { - defaultPrefs.edit { - putBoolean(DID_PROMOTE_NEW_RESTRICTED_JOIN_RULE, true) - } - } - /** * Tells if we have already asked the user to disable battery optimisations on android >= M devices. * @@ -859,6 +850,15 @@ class VectorPreferences @Inject constructor(private val context: Context) { return defaultPrefs.getBoolean(SETTINGS_SHOW_EMOJI_KEYBOARD, true) } + /** + * Tells if the timeline messages should be shown in a bubble or not. + * + * @return true to show timeline message in bubble. + */ + fun useMessageBubblesLayout(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_INTERFACE_BUBBLE_KEY, false) + } + /** * Tells if the rage shake is used. * @@ -1002,4 +1002,8 @@ class VectorPreferences @Inject constructor(private val context: Context) { fun labsRenderLocationsInTimeline(): Boolean { return defaultPrefs.getBoolean(SETTINGS_LABS_RENDER_LOCATIONS_IN_TIMELINE, true) } + + fun areThreadMessagesEnabled(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_LABS_ENABLE_THREAD_MESSAGES, false) + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsLabsFragment.kt b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsLabsFragment.kt index a83b4c33f4..118e820f84 100644 --- a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsLabsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsLabsFragment.kt @@ -16,12 +16,18 @@ package im.vector.app.features.settings +import androidx.preference.Preference import im.vector.app.R import im.vector.app.core.preference.VectorSwitchPreference +import im.vector.app.features.MainActivity +import im.vector.app.features.MainActivityArgs +import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage import javax.inject.Inject class VectorSettingsLabsFragment @Inject constructor( - private val vectorPreferences: VectorPreferences + private val vectorPreferences: VectorPreferences, + private val lightweightSettingsStorage: LightweightSettingsStorage + ) : VectorSettingsBaseFragment() { override var titleRes = R.string.room_settings_labs_pref_title @@ -32,5 +38,15 @@ class VectorSettingsLabsFragment @Inject constructor( // ensure correct default pref.isChecked = vectorPreferences.labsAutoReportUISI() } + + // clear cache + findPreference(VectorPreferences.SETTINGS_LABS_ENABLE_THREAD_MESSAGES)?.let { + it.onPreferenceClickListener = Preference.OnPreferenceClickListener { + lightweightSettingsStorage.setThreadMessagesEnabled(vectorPreferences.areThreadMessagesEnabled()) + displayLoadingView() + MainActivity.restartApp(requireActivity(), MainActivityArgs(clearCache = true)) + false + } + } } } diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsRootFragment.kt b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsRootFragment.kt index fb5d83239b..cd76efac58 100644 --- a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsRootFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsRootFragment.kt @@ -29,7 +29,7 @@ class VectorSettingsRootFragment @Inject constructor() : VectorSettingsBaseFragm override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.Settings + analyticsScreenName = Screen.ScreenName.MobileSettings } override fun bindPref() { diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsSecurityPrivacyFragment.kt b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsSecurityPrivacyFragment.kt index 31fce00f3c..e4e287e83a 100644 --- a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsSecurityPrivacyFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsSecurityPrivacyFragment.kt @@ -94,7 +94,7 @@ class VectorSettingsSecurityPrivacyFragment @Inject constructor( override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.SettingsSecurity + analyticsScreenName = Screen.ScreenName.MobileSettingsSecurity } // cryptography diff --git a/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsDefaultNotificationPreferenceFragment.kt b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsDefaultNotificationPreferenceFragment.kt index b6f2098209..27007744c8 100644 --- a/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsDefaultNotificationPreferenceFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsDefaultNotificationPreferenceFragment.kt @@ -38,7 +38,7 @@ class VectorSettingsDefaultNotificationPreferenceFragment : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.SettingsDefaultNotifications + analyticsScreenName = Screen.ScreenName.MobileSettingsDefaultNotifications } override fun bindPref() { diff --git a/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsKeywordAndMentionsNotificationPreferenceFragment.kt b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsKeywordAndMentionsNotificationPreferenceFragment.kt index b7cf7f6bbe..dd3b899fcc 100644 --- a/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsKeywordAndMentionsNotificationPreferenceFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsKeywordAndMentionsNotificationPreferenceFragment.kt @@ -45,7 +45,7 @@ class VectorSettingsKeywordAndMentionsNotificationPreferenceFragment : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.SettingsMentionsAndKeywords + analyticsScreenName = Screen.ScreenName.MobileSettingsMentionsAndKeywords } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/vector/src/main/java/im/vector/app/features/spaces/RestrictedPromoBottomSheet.kt b/vector/src/main/java/im/vector/app/features/spaces/RestrictedPromoBottomSheet.kt deleted file mode 100644 index dbea6807ce..0000000000 --- a/vector/src/main/java/im/vector/app/features/spaces/RestrictedPromoBottomSheet.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright (c) 2021 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.app.features.spaces - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isVisible -import im.vector.app.R -import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment -import im.vector.app.databinding.BottomSheetSpaceAdvertiseRestrictedBinding - -class RestrictedPromoBottomSheet : VectorBaseBottomSheetDialogFragment() { - - override fun getBinding(inflater: LayoutInflater, container: ViewGroup?) = - BottomSheetSpaceAdvertiseRestrictedBinding.inflate(inflater, container, false) - - override val showExpanded = true - - var learnMoreMode: Boolean = false - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - render() - views.skipButton.debouncedClicks { - dismiss() - } - - views.learnMore.debouncedClicks { - if (learnMoreMode) { - dismiss() - } else { - learnMoreMode = true - render() - } - } - } - - private fun render() { - if (learnMoreMode) { - views.title.text = getString(R.string.new_let_people_in_spaces_find_and_join) - views.topDescription.text = getString(R.string.to_help_space_members_find_and_join) - views.imageHint.isVisible = true - views.bottomDescription.isVisible = true - views.bottomDescription.text = getString(R.string.this_makes_it_easy_for_rooms_to_stay_private_to_a_space) - views.skipButton.isVisible = false - views.learnMore.text = getString(R.string.ok) - } else { - views.title.text = getString(R.string.help_space_members) - views.topDescription.text = getString(R.string.help_people_in_spaces_find_and_join) - views.imageHint.isVisible = false - views.bottomDescription.isVisible = false - views.skipButton.isVisible = true - views.learnMore.text = getString(R.string.learn_more) - } - } -} diff --git a/vector/src/main/java/im/vector/app/features/spaces/SpaceListViewModel.kt b/vector/src/main/java/im/vector/app/features/spaces/SpaceListViewModel.kt index 02771abc95..20af5b5827 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/SpaceListViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/SpaceListViewModel.kt @@ -256,7 +256,7 @@ class SpaceListViewModel @AssistedInject constructor(@Assisted initialState: Spa private fun handleLeaveSpace(action: SpaceListAction.LeaveSpace) { viewModelScope.launch { tryOrNull("Failed to leave space ${action.spaceSummary.roomId}") { - session.spaceService().getSpace(action.spaceSummary.roomId)?.leave(null) + session.spaceService().leaveSpace(action.spaceSummary.roomId) } } } diff --git a/vector/src/main/java/im/vector/app/features/spaces/SpaceMenuViewModel.kt b/vector/src/main/java/im/vector/app/features/spaces/SpaceMenuViewModel.kt index 2e9af2eacb..9b95b5328f 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/SpaceMenuViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/SpaceMenuViewModel.kt @@ -131,7 +131,7 @@ class SpaceMenuViewModel @AssistedInject constructor( session.coroutineScope.launch { try { if (state.leaveMode == SpaceMenuState.LeaveMode.LEAVE_NONE) { - session.getRoom(initialState.spaceId)?.leave(null) + session.spaceService().leaveSpace(initialState.spaceId) } else if (state.leaveMode == SpaceMenuState.LeaveMode.LEAVE_ALL) { // need to find all child rooms that i have joined @@ -143,13 +143,13 @@ class SpaceMenuViewModel @AssistedInject constructor( } ).forEach { try { - session.getRoom(it.roomId)?.leave(null) + session.spaceService().leaveSpace(it.roomId) } catch (failure: Throwable) { // silently ignore? Timber.e(failure, "Fail to leave sub rooms/spaces") } } - session.getRoom(initialState.spaceId)?.leave(null) + session.spaceService().leaveSpace(initialState.spaceId) } // We observe the membership and to dismiss when we have remote echo of leaving diff --git a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryFragment.kt b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryFragment.kt index bbf6ac79ca..955fedd7dc 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryFragment.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryFragment.kt @@ -170,7 +170,7 @@ class SpaceDirectoryFragment @Inject constructor( ?: getString(R.string.space_explore_activity_title) } - spaceCardRenderer.render(state.currentRootSummary, emptyList(), this, views.spaceCard) + spaceCardRenderer.render(state.currentRootSummary, emptyList(), this, views.spaceCard, showDescription = false) views.addOrCreateChatRoomButton.isVisible = state.canAddRooms } diff --git a/vector/src/main/java/im/vector/app/features/spaces/invite/SpaceInviteBottomSheet.kt b/vector/src/main/java/im/vector/app/features/spaces/invite/SpaceInviteBottomSheet.kt index 815175c977..91cb6194b1 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/invite/SpaceInviteBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/invite/SpaceInviteBottomSheet.kt @@ -118,7 +118,7 @@ class SpaceInviteBottomSheet : VectorBaseBottomSheetDialogFragment { val intent = InviteUsersToRoomActivity.getIntent(requireContext(), event.spaceId) startActivity(intent) + dismissAllowingStateLoss() } is ShareSpaceViewEvents.ShowInviteByLink -> { startSharePlainTextIntent( @@ -94,6 +95,7 @@ class ShareSpaceBottomSheet : VectorBaseBottomSheetDialogFragment(), - ZXingScannerView.ResultHandler { - - override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentQrCodeScannerWithButtonBinding { - return FragmentQrCodeScannerWithButtonBinding.inflate(inflater, container, false) - } - - val sharedViewModel: UserCodeSharedViewModel by activityViewModel() - - var autoFocus = true - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - setupToolbar(views.qrScannerToolbar) - .allowBack(useCross = true) - - views.userCodeMyCodeButton.debouncedClicks { - sharedViewModel.handle(UserCodeActions.SwitchMode(UserCodeState.Mode.SHOW)) - } - - views.userCodeOpenGalleryButton.debouncedClicks { - MultiPicker.get(MultiPicker.IMAGE).single().startWith(pickImageActivityResultLauncher) - } - } - - private val openCameraActivityResultLauncher = registerForPermissionsResult { allGranted, _ -> - if (allGranted) { - startCamera() - } else { - // For now just go back - sharedViewModel.handle(UserCodeActions.SwitchMode(UserCodeState.Mode.SHOW)) - } - } - - private val pickImageActivityResultLauncher = registerStartForActivityResult { activityResult -> - if (activityResult.resultCode == Activity.RESULT_OK) { - MultiPicker - .get(MultiPicker.IMAGE) - .getSelectedFiles(requireActivity(), activityResult.data) - .firstOrNull() - ?.contentUri - ?.let { uri -> - // try to see if it is a valid matrix code - val bitmap = ImageUtils.getBitmap(requireContext(), uri) - ?: return@let Unit.also { - Toast.makeText(requireContext(), getString(R.string.qr_code_not_scanned), Toast.LENGTH_SHORT).show() - } - handleResult(tryOrNull { QRCodeBitmapDecodeHelper.decodeQRFromBitmap(bitmap) }) - } - } - } - - private fun startCamera() { - views.userCodeScannerView.startCamera() - views.userCodeScannerView.setAutoFocus(autoFocus) - views.userCodeScannerView.debouncedClicks { - this.autoFocus = !autoFocus - views.userCodeScannerView.setAutoFocus(autoFocus) - } - } - - override fun onStart() { - super.onStart() - if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), openCameraActivityResultLauncher)) { - startCamera() - } - } - - override fun onResume() { - super.onResume() - // Register ourselves as a handler for scan results. - views.userCodeScannerView.setResultHandler(this) - if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { - startCamera() - } - } - - override fun onPause() { - super.onPause() - views.userCodeScannerView.setResultHandler(null) - // Stop camera on pause - views.userCodeScannerView.stopCamera() - } - - override fun handleResult(result: Result?) { - if (result === null) { - Toast.makeText(requireContext(), R.string.qr_code_not_scanned, Toast.LENGTH_SHORT).show() - requireActivity().finish() - } else { - val rawBytes = getRawBytes(result) - val rawBytesStr = rawBytes?.toString(Charsets.ISO_8859_1) - val value = rawBytesStr ?: result.text - sharedViewModel.handle(UserCodeActions.DecodedQRCode(value)) - } - } - - // Copied from https://github.com/markusfisch/BinaryEye/blob/ - // 9d57889b810dcaa1a91d7278fc45c262afba1284/app/src/main/kotlin/de/markusfisch/android/binaryeye/activity/CameraActivity.kt#L434 - private fun getRawBytes(result: Result): ByteArray? { - val metadata = result.resultMetadata ?: return null - val segments = metadata[ResultMetadataType.BYTE_SEGMENTS] ?: return null - var bytes = ByteArray(0) - @Suppress("UNCHECKED_CAST") - for (seg in segments as Iterable) { - bytes += seg - } - // byte segments can never be shorter than the text. - // Zxing cuts off content prefixes like "WIFI:" - return if (bytes.size >= result.text.length) bytes else null - } -} diff --git a/vector/src/main/java/im/vector/app/features/usercode/UserCodeActivity.kt b/vector/src/main/java/im/vector/app/features/usercode/UserCodeActivity.kt index 7011f8c280..356893aee2 100644 --- a/vector/src/main/java/im/vector/app/features/usercode/UserCodeActivity.kt +++ b/vector/src/main/java/im/vector/app/features/usercode/UserCodeActivity.kt @@ -30,12 +30,16 @@ import com.airbnb.mvrx.viewModel import com.airbnb.mvrx.withState import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R -import im.vector.app.core.extensions.commitTransaction import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.extensions.replaceFragment import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.utils.onPermissionDeniedSnackbar import im.vector.app.databinding.ActivitySimpleBinding import im.vector.app.features.matrixto.MatrixToBottomSheet +import im.vector.app.features.qrcode.QrCodeScannerEvents +import im.vector.app.features.qrcode.QrCodeScannerFragment +import im.vector.app.features.qrcode.QrCodeScannerViewModel +import im.vector.app.features.qrcode.QrScannerArgs import kotlinx.parcelize.Parcelize import kotlin.reflect.KClass @@ -44,6 +48,7 @@ class UserCodeActivity : VectorBaseActivity(), MatrixToBottomSheet.InteractionListener { val sharedViewModel: UserCodeSharedViewModel by viewModel() + private val qrViewModel: QrCodeScannerViewModel by viewModel() @Parcelize data class Args( @@ -81,10 +86,13 @@ class UserCodeActivity : VectorBaseActivity(), sharedViewModel.onEach(UserCodeState::mode) { mode -> when (mode) { - UserCodeState.Mode.SHOW -> showFragment(ShowUserCodeFragment::class, Bundle.EMPTY) - UserCodeState.Mode.SCAN -> showFragment(ScanUserCodeFragment::class, Bundle.EMPTY) + UserCodeState.Mode.SHOW -> showFragment(ShowUserCodeFragment::class) + UserCodeState.Mode.SCAN -> { + val args = QrScannerArgs(showExtraButtons = true, R.string.user_code_scan) + showFragment(QrCodeScannerFragment::class, args) + } is UserCodeState.Mode.RESULT -> { - showFragment(ShowUserCodeFragment::class, Bundle.EMPTY) + showFragment(ShowUserCodeFragment::class) MatrixToBottomSheet.withLink(mode.rawLink).show(supportFragmentManager, "MatrixToBottomSheet") } } @@ -106,6 +114,21 @@ class UserCodeActivity : VectorBaseActivity(), } } } + + qrViewModel.observeViewEvents { + when (it) { + is QrCodeScannerEvents.CodeParsed -> { + sharedViewModel.handle(UserCodeActions.DecodedQRCode(it.result)) + } + QrCodeScannerEvents.SwitchMode -> { + sharedViewModel.handle(UserCodeActions.SwitchMode(UserCodeState.Mode.SHOW)) + } + is QrCodeScannerEvents.ParseFailed -> { + Toast.makeText(this, R.string.qr_code_not_scanned, Toast.LENGTH_SHORT).show() + finish() + } + }.exhaustive + } } override fun onDestroy() { @@ -113,16 +136,9 @@ class UserCodeActivity : VectorBaseActivity(), super.onDestroy() } - private fun showFragment(fragmentClass: KClass, bundle: Bundle) { + private fun showFragment(fragmentClass: KClass, params: Parcelable? = null) { if (supportFragmentManager.findFragmentByTag(fragmentClass.simpleName) == null) { - supportFragmentManager.commitTransaction { - setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out) - replace(views.simpleFragmentContainer.id, - fragmentClass.java, - bundle, - fragmentClass.simpleName - ) - } + replaceFragment(views.simpleFragmentContainer, fragmentClass.java, params, fragmentClass.simpleName, useCustomAnimation = true) } } diff --git a/vector/src/main/java/im/vector/app/features/widgets/permissions/RoomWidgetPermissionBottomSheet.kt b/vector/src/main/java/im/vector/app/features/widgets/permissions/RoomWidgetPermissionBottomSheet.kt index 58cfebba94..91371b1f73 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/permissions/RoomWidgetPermissionBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/permissions/RoomWidgetPermissionBottomSheet.kt @@ -53,7 +53,6 @@ class RoomWidgetPermissionBottomSheet : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - setupViews() } diff --git a/vector/src/main/res/drawable-nodpi/room_settings.png b/vector/src/main/res/drawable-nodpi/room_settings.png deleted file mode 100644 index 2e3fb404fa..0000000000 Binary files a/vector/src/main/res/drawable-nodpi/room_settings.png and /dev/null differ diff --git a/vector/src/main/res/drawable/bg_avatar_border.xml b/vector/src/main/res/drawable/bg_avatar_border.xml new file mode 100644 index 0000000000..e22731c1a3 --- /dev/null +++ b/vector/src/main/res/drawable/bg_avatar_border.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/drawable/ic_add_reaction_small.xml b/vector/src/main/res/drawable/ic_add_reaction_small.xml new file mode 100644 index 0000000000..ddd4367ce0 --- /dev/null +++ b/vector/src/main/res/drawable/ic_add_reaction_small.xml @@ -0,0 +1,4 @@ + + + diff --git a/vector/src/main/res/drawable/ic_filter.xml b/vector/src/main/res/drawable/ic_filter.xml index 740585b17e..35fd8db97d 100644 --- a/vector/src/main/res/drawable/ic_filter.xml +++ b/vector/src/main/res/drawable/ic_filter.xml @@ -4,17 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> - + android:pathData="M10.9996,18H12.9996C13.5496,18 13.9996,17.55 13.9996,17C13.9996,16.45 13.5496,16 12.9996,16H10.9996C10.4496,16 9.9996,16.45 9.9996,17C9.9996,17.55 10.4496,18 10.9996,18ZM2.9996,7C2.9996,7.55 3.4496,8 3.9996,8H19.9996C20.5496,8 20.9996,7.55 20.9996,7C20.9996,6.45 20.5496,6 19.9996,6H3.9996C3.4496,6 2.9996,6.45 2.9996,7ZM6.9996,13H16.9996C17.5496,13 17.9996,12.55 17.9996,12C17.9996,11.45 17.5496,11 16.9996,11H6.9996C6.4496,11 5.9996,11.45 5.9996,12C5.9996,12.55 6.4496,13 6.9996,13Z" + android:fillColor="#737D8C"/> diff --git a/vector/src/main/res/drawable/ic_location_pin.xml b/vector/src/main/res/drawable/ic_location_pin.xml new file mode 100644 index 0000000000..8227ea4e05 --- /dev/null +++ b/vector/src/main/res/drawable/ic_location_pin.xml @@ -0,0 +1,13 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_location_pin_failed.xml b/vector/src/main/res/drawable/ic_location_pin_failed.xml new file mode 100644 index 0000000000..250d048836 --- /dev/null +++ b/vector/src/main/res/drawable/ic_location_pin_failed.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_reply_in_thread.xml b/vector/src/main/res/drawable/ic_reply_in_thread.xml new file mode 100644 index 0000000000..3b9b595bd3 --- /dev/null +++ b/vector/src/main/res/drawable/ic_reply_in_thread.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/vector/src/main/res/drawable/ic_thread_link_menu_item.xml b/vector/src/main/res/drawable/ic_thread_link_menu_item.xml new file mode 100644 index 0000000000..779c9d832c --- /dev/null +++ b/vector/src/main/res/drawable/ic_thread_link_menu_item.xml @@ -0,0 +1,12 @@ + + + diff --git a/vector/src/main/res/drawable/ic_thread_menu_item.xml b/vector/src/main/res/drawable/ic_thread_menu_item.xml new file mode 100644 index 0000000000..2d77251c53 --- /dev/null +++ b/vector/src/main/res/drawable/ic_thread_menu_item.xml @@ -0,0 +1,10 @@ + + + diff --git a/vector/src/main/res/drawable/ic_thread_share_menu_item.xml b/vector/src/main/res/drawable/ic_thread_share_menu_item.xml new file mode 100644 index 0000000000..cb863c39bf --- /dev/null +++ b/vector/src/main/res/drawable/ic_thread_share_menu_item.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/vector/src/main/res/drawable/ic_thread_summary.xml b/vector/src/main/res/drawable/ic_thread_summary.xml new file mode 100644 index 0000000000..5e27ad0a0a --- /dev/null +++ b/vector/src/main/res/drawable/ic_thread_summary.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/vector/src/main/res/drawable/ic_thread_view_in_room_menu_item.xml b/vector/src/main/res/drawable/ic_thread_view_in_room_menu_item.xml new file mode 100644 index 0000000000..f408f99713 --- /dev/null +++ b/vector/src/main/res/drawable/ic_thread_view_in_room_menu_item.xml @@ -0,0 +1,30 @@ + + + + + + diff --git a/vector/src/main/res/drawable/notification_badge.xml b/vector/src/main/res/drawable/notification_badge.xml new file mode 100644 index 0000000000..11f4b1d274 --- /dev/null +++ b/vector/src/main/res/drawable/notification_badge.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/drawable/overlay_bubble_media.xml b/vector/src/main/res/drawable/overlay_bubble_media.xml new file mode 100644 index 0000000000..ce34a39037 --- /dev/null +++ b/vector/src/main/res/drawable/overlay_bubble_media.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/vector/src/main/res/drawable/reaction_divider.xml b/vector/src/main/res/drawable/reaction_divider.xml index d68b6a9094..1d7ee57084 100644 --- a/vector/src/main/res/drawable/reaction_divider.xml +++ b/vector/src/main/res/drawable/reaction_divider.xml @@ -2,7 +2,7 @@ + android:width="4dp" + android:height="4dp" /> \ No newline at end of file diff --git a/vector/src/main/res/layout/activity_threads.xml b/vector/src/main/res/layout/activity_threads.xml new file mode 100644 index 0000000000..c34be9687d --- /dev/null +++ b/vector/src/main/res/layout/activity_threads.xml @@ -0,0 +1,24 @@ + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/bottom_sheet_space_advertise_restricted.xml b/vector/src/main/res/layout/bottom_sheet_space_advertise_restricted.xml deleted file mode 100644 index 7cc243ee75..0000000000 --- a/vector/src/main/res/layout/bottom_sheet_space_advertise_restricted.xml +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - - - - - - - - - - -