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,12 @@ 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 && exit 1 ) + - name: Upload Test Report Log uses: actions/upload-artifact@v2 - if: failure() + if: always() 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/.idea/dictionaries/bmarty.xml b/.idea/dictionaries/bmarty.xml index f99842f067..ed572b573f 100644 --- a/.idea/dictionaries/bmarty.xml +++ b/.idea/dictionaries/bmarty.xml @@ -28,6 +28,7 @@ previewable previewables pstn + rageshake riotx signin signout diff --git a/.idea/icon.png b/.idea/icon.png new file mode 100644 index 0000000000..6f7872211b Binary files /dev/null and b/.idea/icon.png differ diff --git a/CHANGES.md b/CHANGES.md index ec022bc770..255792f800 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,82 @@ +Changes in Element v1.4.2 (2022-02-22) +====================================== + +Features ✨ +---------- + - Open the room when user accepts an invite from the room list ([#3771](https://github.com/vector-im/element-android/issues/3771)) + - Add completion for @room to notify everyone in a room ([#5123](https://github.com/vector-im/element-android/issues/5123)) + - Improve UI of reactions in timeline, including quick add reaction. ([#5204](https://github.com/vector-im/element-android/issues/5204)) + - Support creating disclosed polls ([#5290](https://github.com/vector-im/element-android/issues/5290)) + +Bugfixes 🐛 +---------- + - Remove redundant highlight on add poll option button ([#5178](https://github.com/vector-im/element-android/issues/5178)) + - Reliably display crash report prompt ([#5195](https://github.com/vector-im/element-android/issues/5195)) + - Fix for rooms with virtual rooms not showing call status events in the timeline. ([#5198](https://github.com/vector-im/element-android/issues/5198)) + - Fix for call transfer with consult failing to make outgoing consultation call. ([#5201](https://github.com/vector-im/element-android/issues/5201)) + - Fix crash during account registration when redirecting to Web View ([#5218](https://github.com/vector-im/element-android/issues/5218)) + - Analytics: Fixes missing use case identity values from within the onboarding flow ([#5234](https://github.com/vector-im/element-android/issues/5234)) + - Fixing crash when adding room by QR code after accepting the camera permission for the first time ([#5295](https://github.com/vector-im/element-android/issues/5295)) + +SDK API changes ⚠️ +------------------ + - `join` and `leave` methods moved from MembershipService to RoomService and SpaceService to split logic for rooms and spaces ([#5183](https://github.com/vector-im/element-android/issues/5183)) + - Deprecates Matrix.initialize and Matrix.getInstance in favour of the client providing its own singleton instance via Matrix.createInstance ([#5185](https://github.com/vector-im/element-android/issues/5185)) + - Adds support for MSC3283, additional homeserver capabilities ([#5207](https://github.com/vector-im/element-android/issues/5207)) + +Other changes +------------- + - Right align the notifications badge in the rooms list (and DMs) so that it's always in a consistent place on the screen. ([#4640](https://github.com/vector-im/element-android/issues/4640)) + - Collapse successive ACLs events in room timeline ([#2782](https://github.com/vector-im/element-android/issues/2782)) + - Home screen: Replacing search icon by filter icon in the top right menu ([#4643](https://github.com/vector-im/element-android/issues/4643)) + - Make Space creation screens more consistent ([#5104](https://github.com/vector-im/element-android/issues/5104)) + - Defensive coding to ensure encryption when room was once e2e ([#5136](https://github.com/vector-im/element-android/issues/5136)) + - Reduce verbosity of debug logging, ([#5209](https://github.com/vector-im/element-android/issues/5209)) + - Standardise emulator versions of GHA integration tests. ([#5210](https://github.com/vector-im/element-android/issues/5210)) + - Replacing color "vctr_unread_room_badge" by "vctr_content_secondary" ([#5225](https://github.com/vector-im/element-android/issues/5225)) + - Change preferred jitsi domain from `jitsi.riot.im` to `meet.element.io` ([#5254](https://github.com/vector-im/element-android/issues/5254)) + - Analytics screen events are now tracked on screen enter instead of screen leave ([#5256](https://github.com/vector-im/element-android/issues/5256)) + - Improves bitmap memory usage by caching the shortcut images ([#5276](https://github.com/vector-im/element-android/issues/5276)) + - Changes unread marker in room list from green to grey ([#5294](https://github.com/vector-im/element-android/issues/5294)) + - Improve some internal realm usages. ([#5297](https://github.com/vector-im/element-android/issues/5297)) + +Translations 🗣 +-------------- + - Improved Japanese translations (special thanks to Suguru Hirahara!) + + +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) ======================================= 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/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/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/cs-CZ/changelogs/40103170.txt b/fastlane/metadata/android/cs-CZ/changelogs/40103170.txt new file mode 100644 index 0000000000..73ec686cb1 --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/changelogs/40103170.txt @@ -0,0 +1,2 @@ +Hlavní změny v této verzi: odeslání svojí polohy do libovolné místnosti. Úpravy anket. +Úplný seznam změn: https://github.com/vector-im/element-android/releases/tag/v1.3.17 diff --git a/fastlane/metadata/android/cs-CZ/changelogs/40103180.txt b/fastlane/metadata/android/cs-CZ/changelogs/40103180.txt new file mode 100644 index 0000000000..502b318b3e --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/changelogs/40103180.txt @@ -0,0 +1,2 @@ +Hlavní změny v této verzi: odeslání svojí polohy do libovolné místnosti. Úpravy anket. +Úplný seznam změn: https://github.com/vector-im/element-android/releases/tag/v1.3.18 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/de-DE/changelogs/40103170.txt b/fastlane/metadata/android/de-DE/changelogs/40103170.txt new file mode 100644 index 0000000000..4a93cfca52 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/40103170.txt @@ -0,0 +1,2 @@ +Wichtigste Änderungen in dieser Version: Versende deinen Standort an jeden Raum deiner Wahl. Bearbeite Umfragen. +Alle Änderungen: https://github.com/vector-im/element-android/releases/tag/v1.3.17 diff --git a/fastlane/metadata/android/de-DE/changelogs/40103180.txt b/fastlane/metadata/android/de-DE/changelogs/40103180.txt new file mode 100644 index 0000000000..28c954f326 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/40103180.txt @@ -0,0 +1,2 @@ +Wichtigste Änderungen in dieser Version: Versende deinen Standort an jeden Raum deiner Wahl. Bearbeite Umfragen. +Alle Änderungen: https://github.com/vector-im/element-android/releases/tag/v1.3.18 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..4492b78882 --- /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/en-US/changelogs/40104020.txt b/fastlane/metadata/android/en-US/changelogs/40104020.txt new file mode 100644 index 0000000000..82d3197db3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40104020.txt @@ -0,0 +1,2 @@ +Main changes in this version: add support to @room and undisclosed polls among many other little changes. +Full changelog: https://github.com/vector-im/element-android/releases/tag/v1.4.2 \ No newline at end of file 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/et/changelogs/40103170.txt b/fastlane/metadata/android/et/changelogs/40103170.txt new file mode 100644 index 0000000000..0dda39444a --- /dev/null +++ b/fastlane/metadata/android/et/changelogs/40103170.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.17 diff --git a/fastlane/metadata/android/et/changelogs/40103180.txt b/fastlane/metadata/android/et/changelogs/40103180.txt new file mode 100644 index 0000000000..55f251f454 --- /dev/null +++ b/fastlane/metadata/android/et/changelogs/40103180.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.18 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/fa/changelogs/40103170.txt b/fastlane/metadata/android/fa/changelogs/40103170.txt new file mode 100644 index 0000000000..55769b8a12 --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/40103170.txt @@ -0,0 +1,2 @@ +تغییرات عمده در این نگارش: فرستادن مکانتان به هر اتاقی. ویرایش نظرسنجی. +گزارش دگرگونی کامل: https://github.com/vector-im/element-android/releases/tag/v1.3.17 diff --git a/fastlane/metadata/android/fa/changelogs/40103180.txt b/fastlane/metadata/android/fa/changelogs/40103180.txt new file mode 100644 index 0000000000..529da3f5a4 --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/40103180.txt @@ -0,0 +1,2 @@ +تغییرات عمده در این نگارش: فرستادن مکانتان به هر اتاقی. ویرایش نظرسنجی. +گزارش دگرگونی کامل: https://github.com/vector-im/element-android/releases/tag/v1.3.18 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/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/hu-HU/changelogs/40103170.txt b/fastlane/metadata/android/hu-HU/changelogs/40103170.txt new file mode 100644 index 0000000000..7882ee3eed --- /dev/null +++ b/fastlane/metadata/android/hu-HU/changelogs/40103170.txt @@ -0,0 +1,2 @@ +Fő változás ebben a verzióban: földrajzi helyzet elküldése bármelyik szobába. Szavazás szerkesztése. +Teljes változásnapló: https://github.com/vector-im/element-android/releases/tag/v1.3.17 diff --git a/fastlane/metadata/android/hu-HU/changelogs/40103180.txt b/fastlane/metadata/android/hu-HU/changelogs/40103180.txt new file mode 100644 index 0000000000..0630b8ba11 --- /dev/null +++ b/fastlane/metadata/android/hu-HU/changelogs/40103180.txt @@ -0,0 +1,2 @@ +Fő változás ebben a verzióban: földrajzi helyzet elküldése bármelyik szobába. Szavazás szerkesztése. +Teljes változásnapló: https://github.com/vector-im/element-android/releases/tag/v1.3.18 diff --git a/fastlane/metadata/android/id/changelogs/40100100.txt b/fastlane/metadata/android/id/changelogs/40100100.txt index 96a8f506b3..d4294758d8 100644 --- a/fastlane/metadata/android/id/changelogs/40100100.txt +++ b/fastlane/metadata/android/id/changelogs/40100100.txt @@ -1,2 +1,2 @@ Versi baru ini terutama berisi perbaikan bug dan peningkatan. Mengirim pesan sekarang jauh lebih cepat. -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.0.10 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.0.10 diff --git a/fastlane/metadata/android/id/changelogs/40100110.txt b/fastlane/metadata/android/id/changelogs/40100110.txt index 9f86005d8b..07e969581c 100644 --- a/fastlane/metadata/android/id/changelogs/40100110.txt +++ b/fastlane/metadata/android/id/changelogs/40100110.txt @@ -1,2 +1,2 @@ Versi baru ini terutama berisi antarmuka pengguna dan peningkatan pengalaman pengguna. Sekarang Anda dapat mengundang teman, dan membuat sebuah DM sangat cepat dengan memindai kode QR. -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.0.11 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.0.11 diff --git a/fastlane/metadata/android/id/changelogs/40100120.txt b/fastlane/metadata/android/id/changelogs/40100120.txt index 3067b6367d..18adabfdcd 100644 --- a/fastlane/metadata/android/id/changelogs/40100120.txt +++ b/fastlane/metadata/android/id/changelogs/40100120.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: Pratinjau URL, keyboard Emoji baru, kemampuan pengaturan ruangan baru, dan salju untuk Natal! -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.0.12 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.0.12 diff --git a/fastlane/metadata/android/id/changelogs/40100130.txt b/fastlane/metadata/android/id/changelogs/40100130.txt index df52988b6c..f94db4d7cd 100644 --- a/fastlane/metadata/android/id/changelogs/40100130.txt +++ b/fastlane/metadata/android/id/changelogs/40100130.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: Pratinjau URL, keyboard Emoji baru, kemampuan pengaturan ruangan baru, dan salju untuk Natal! -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.0.13 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.0.13 diff --git a/fastlane/metadata/android/id/changelogs/40100140.txt b/fastlane/metadata/android/id/changelogs/40100140.txt index 5243adc1a8..e1ef504cd9 100644 --- a/fastlane/metadata/android/id/changelogs/40100140.txt +++ b/fastlane/metadata/android/id/changelogs/40100140.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: Edit izin ruangan, tema cahaya/gelap otomatis, dan banyak perbaikan bug. -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.0.14 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.0.14 diff --git a/fastlane/metadata/android/id/changelogs/40100150.txt b/fastlane/metadata/android/id/changelogs/40100150.txt index 54c307b9b6..eaf8e1a715 100644 --- a/fastlane/metadata/android/id/changelogs/40100150.txt +++ b/fastlane/metadata/android/id/changelogs/40100150.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: Dukungan login sosial. -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.0.15 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.0.15 diff --git a/fastlane/metadata/android/id/changelogs/40100160.txt b/fastlane/metadata/android/id/changelogs/40100160.txt index 3e357db352..0a6d42f8f6 100644 --- a/fastlane/metadata/android/id/changelogs/40100160.txt +++ b/fastlane/metadata/android/id/changelogs/40100160.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: Dukungan login sosial. -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.0.15 dan https://github.com/vector-im/element-android/releases/tag/v1.0.16 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.0.15 dan https://github.com/vector-im/element-android/releases/tag/v1.0.16 diff --git a/fastlane/metadata/android/id/changelogs/40100170.txt b/fastlane/metadata/android/id/changelogs/40100170.txt index 77f638a7fd..a2bd48f2c3 100644 --- a/fastlane/metadata/android/id/changelogs/40100170.txt +++ b/fastlane/metadata/android/id/changelogs/40100170.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: perbaikan bug! -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.0.17 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.0.17 diff --git a/fastlane/metadata/android/id/changelogs/40101000.txt b/fastlane/metadata/android/id/changelogs/40101000.txt index acfe661354..737f9b63ac 100644 --- a/fastlane/metadata/android/id/changelogs/40101000.txt +++ b/fastlane/metadata/android/id/changelogs/40101000.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: perbaikan VoIP (panggilan audio dan video dalam DM) dan perbaikan bug! -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.0 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.0 diff --git a/fastlane/metadata/android/id/changelogs/40101010.txt b/fastlane/metadata/android/id/changelogs/40101010.txt index a9903a90bd..be22d21c32 100644 --- a/fastlane/metadata/android/id/changelogs/40101010.txt +++ b/fastlane/metadata/android/id/changelogs/40101010.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: peningkatan kinerja dan perbaikan bug! -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.1 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.1 diff --git a/fastlane/metadata/android/id/changelogs/40101020.txt b/fastlane/metadata/android/id/changelogs/40101020.txt index d654bda4fe..394f48c171 100644 --- a/fastlane/metadata/android/id/changelogs/40101020.txt +++ b/fastlane/metadata/android/id/changelogs/40101020.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: peningkatan kinerja dan perbaikan bug! -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.2 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.2 diff --git a/fastlane/metadata/android/id/changelogs/40101030.txt b/fastlane/metadata/android/id/changelogs/40101030.txt index 283c201b2f..aa1e725b46 100644 --- a/fastlane/metadata/android/id/changelogs/40101030.txt +++ b/fastlane/metadata/android/id/changelogs/40101030.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: peningkatan kinerja dan perbaikan bug! -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.3 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.3 diff --git a/fastlane/metadata/android/id/changelogs/40101040.txt b/fastlane/metadata/android/id/changelogs/40101040.txt index fdb94db53d..97eddf643d 100644 --- a/fastlane/metadata/android/id/changelogs/40101040.txt +++ b/fastlane/metadata/android/id/changelogs/40101040.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: peningkatan kinerja dan perbaikan bug! -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.4 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.4 diff --git a/fastlane/metadata/android/id/changelogs/40101050.txt b/fastlane/metadata/android/id/changelogs/40101050.txt index 856530c703..fe745e06d1 100644 --- a/fastlane/metadata/android/id/changelogs/40101050.txt +++ b/fastlane/metadata/android/id/changelogs/40101050.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: perbaikan hot-fix untuk 1.1.4 -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.5 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.5 diff --git a/fastlane/metadata/android/id/changelogs/40101060.txt b/fastlane/metadata/android/id/changelogs/40101060.txt index 1810ecc3aa..2e01d640f7 100644 --- a/fastlane/metadata/android/id/changelogs/40101060.txt +++ b/fastlane/metadata/android/id/changelogs/40101060.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: perbaikan hot-fix untuk 1.1.5 -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.6 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.6 diff --git a/fastlane/metadata/android/id/changelogs/40101070.txt b/fastlane/metadata/android/id/changelogs/40101070.txt index 0087d51703..7449ef81c2 100644 --- a/fastlane/metadata/android/id/changelogs/40101070.txt +++ b/fastlane/metadata/android/id/changelogs/40101070.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: dukungan beta untuk Spaces. Kompres video sebelum mengirim. -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.7 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.7 diff --git a/fastlane/metadata/android/id/changelogs/40101080.txt b/fastlane/metadata/android/id/changelogs/40101080.txt index cb98796449..737298a081 100644 --- a/fastlane/metadata/android/id/changelogs/40101080.txt +++ b/fastlane/metadata/android/id/changelogs/40101080.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: perbaikan untuk Spaces. -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.8 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.8 diff --git a/fastlane/metadata/android/id/changelogs/40101090.txt b/fastlane/metadata/android/id/changelogs/40101090.txt index f6f535fe64..040cbac3b0 100644 --- a/fastlane/metadata/android/id/changelogs/40101090.txt +++ b/fastlane/metadata/android/id/changelogs/40101090.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: menambahkan dukungan untuk jaringan gitter.im. -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.9 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.9 diff --git a/fastlane/metadata/android/id/changelogs/40101100.txt b/fastlane/metadata/android/id/changelogs/40101100.txt index 121d84ca50..2c13314610 100644 --- a/fastlane/metadata/android/id/changelogs/40101100.txt +++ b/fastlane/metadata/android/id/changelogs/40101100.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: pembaruan tema dan gaya dan fitur-fitur baru untuk Spaces. -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.10 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.10 diff --git a/fastlane/metadata/android/id/changelogs/40101110.txt b/fastlane/metadata/android/id/changelogs/40101110.txt index 63c97253c4..7930471cb9 100644 --- a/fastlane/metadata/android/id/changelogs/40101110.txt +++ b/fastlane/metadata/android/id/changelogs/40101110.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: pembaruan tema dan gaya dan fitur baru untuk spaces (perbaikan bug untuk 1.1.10) -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.11 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.11 diff --git a/fastlane/metadata/android/id/changelogs/40101120.txt b/fastlane/metadata/android/id/changelogs/40101120.txt index b8f23c530b..6fd0e25502 100644 --- a/fastlane/metadata/android/id/changelogs/40101120.txt +++ b/fastlane/metadata/android/id/changelogs/40101120.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: pembaruan tema dan gaya dan perbaiki crash setelah panggilan video -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.12 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.12 diff --git a/fastlane/metadata/android/id/changelogs/40101130.txt b/fastlane/metadata/android/id/changelogs/40101130.txt index 51c532725b..0fca5b3563 100644 --- a/fastlane/metadata/android/id/changelogs/40101130.txt +++ b/fastlane/metadata/android/id/changelogs/40101130.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: terutama pembaruan stabilitas dan perbaikan bug. -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.13 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.13 diff --git a/fastlane/metadata/android/id/changelogs/40101140.txt b/fastlane/metadata/android/id/changelogs/40101140.txt index af1e203dde..60b041b8e7 100644 --- a/fastlane/metadata/android/id/changelogs/40101140.txt +++ b/fastlane/metadata/android/id/changelogs/40101140.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: memperbaiki masalah tentang pesan terenkripsi. -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.14 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.14 diff --git a/fastlane/metadata/android/id/changelogs/40101150.txt b/fastlane/metadata/android/id/changelogs/40101150.txt index f3aec557d0..2411b62b5f 100644 --- a/fastlane/metadata/android/id/changelogs/40101150.txt +++ b/fastlane/metadata/android/id/changelogs/40101150.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: implementasi pesan suara dalam pengaturan labs. -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.15 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.15 diff --git a/fastlane/metadata/android/id/changelogs/40101160.txt b/fastlane/metadata/android/id/changelogs/40101160.txt index 19209bacf2..0a829a9262 100644 --- a/fastlane/metadata/android/id/changelogs/40101160.txt +++ b/fastlane/metadata/android/id/changelogs/40101160.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: Memperbaiki kesalahan saat mengirim pesan terenkripsi jika seseorang yang ada di ruangan keluar. -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.16 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.1.16 diff --git a/fastlane/metadata/android/id/changelogs/40102000.txt b/fastlane/metadata/android/id/changelogs/40102000.txt index f7d93e2e4f..745c46dc18 100644 --- a/fastlane/metadata/android/id/changelogs/40102000.txt +++ b/fastlane/metadata/android/id/changelogs/40102000.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: Pesan Suara diaktifkan secara default -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.2.0 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.2.0 diff --git a/fastlane/metadata/android/id/changelogs/40102010.txt b/fastlane/metadata/android/id/changelogs/40102010.txt index e77f0327b0..e68a571226 100644 --- a/fastlane/metadata/android/id/changelogs/40102010.txt +++ b/fastlane/metadata/android/id/changelogs/40102010.txt @@ -1,2 +1,2 @@ Perubahan utama di versi ini: Banyak perbaikan di VoIP dan Space (masih beta). -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.2.1 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.2.1 diff --git a/fastlane/metadata/android/id/changelogs/40103000.txt b/fastlane/metadata/android/id/changelogs/40103000.txt index bf7b5d8d5d..7192c3ba30 100644 --- a/fastlane/metadata/android/id/changelogs/40103000.txt +++ b/fastlane/metadata/android/id/changelogs/40103000.txt @@ -1,2 +1,2 @@ Perubahan utama di versi ini: Organisir ruangan Anda menggunakan sebuah Space! -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.0 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.0 diff --git a/fastlane/metadata/android/id/changelogs/40103020.txt b/fastlane/metadata/android/id/changelogs/40103020.txt index 4f46881d68..2eb358b980 100644 --- a/fastlane/metadata/android/id/changelogs/40103020.txt +++ b/fastlane/metadata/android/id/changelogs/40103020.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: Penambahan dukungan untuk Android Auto. Banyak perbaikan bug! -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.2 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.2 diff --git a/fastlane/metadata/android/id/changelogs/40103030.txt b/fastlane/metadata/android/id/changelogs/40103030.txt index 630593a107..9324b44a3a 100644 --- a/fastlane/metadata/android/id/changelogs/40103030.txt +++ b/fastlane/metadata/android/id/changelogs/40103030.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: Membuat kebijakan server identitas terlihat di pengaturan. Menghilangkan dukungan Android Auto untuk sementara. -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.3 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.3 diff --git a/fastlane/metadata/android/id/changelogs/40103040.txt b/fastlane/metadata/android/id/changelogs/40103040.txt index 0641f72ffd..00987013ac 100644 --- a/fastlane/metadata/android/id/changelogs/40103040.txt +++ b/fastlane/metadata/android/id/changelogs/40103040.txt @@ -1,2 +1,2 @@ Perubahan utama di versi ini: Tambahkan dukungan presensi, untuk ruangan Pesan Langsung (diingat bahwa presensi dinonaktifkan di matrix.org). Tambahkan lagi dukungan Android Auto. -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.4 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.4 diff --git a/fastlane/metadata/android/id/changelogs/40103050.txt b/fastlane/metadata/android/id/changelogs/40103050.txt index ec7c9423bf..dbca8cc0db 100644 --- a/fastlane/metadata/android/id/changelogs/40103050.txt +++ b/fastlane/metadata/android/id/changelogs/40103050.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: Tambahkan dukungan presensi, untuk ruangan Pesan Langsung (diingat bahwa presensi dinonaktifkan di matrix.org). Tambahkan lagi dukungan Android Auto. -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.5 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.5 diff --git a/fastlane/metadata/android/id/changelogs/40103060.txt b/fastlane/metadata/android/id/changelogs/40103060.txt index 4265699d2f..18f81838ff 100644 --- a/fastlane/metadata/android/id/changelogs/40103060.txt +++ b/fastlane/metadata/android/id/changelogs/40103060.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: Tambahkan dukungan presensi, untuk ruangan Pesan Langsung (catatan: presensi dinonaktifkan di matrix.org). Tambahkan lagi dukungan Android Auto. -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.6 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.6 diff --git a/fastlane/metadata/android/id/changelogs/40103100.txt b/fastlane/metadata/android/id/changelogs/40103100.txt index 39d127cd93..0254c176ee 100644 --- a/fastlane/metadata/android/id/changelogs/40103100.txt +++ b/fastlane/metadata/android/id/changelogs/40103100.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: Dukungan untuk fitur poll (dalam Uji Coba), dan desain tampilan URL baru. -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.10 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.10 diff --git a/fastlane/metadata/android/id/changelogs/40103110.txt b/fastlane/metadata/android/id/changelogs/40103110.txt index 725e58d957..3b325f7f03 100644 --- a/fastlane/metadata/android/id/changelogs/40103110.txt +++ b/fastlane/metadata/android/id/changelogs/40103110.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: Perbaikan bug! -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.11 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.11 diff --git a/fastlane/metadata/android/id/changelogs/40103120.txt b/fastlane/metadata/android/id/changelogs/40103120.txt index 9a5dc8026c..c765575ad7 100644 --- a/fastlane/metadata/android/id/changelogs/40103120.txt +++ b/fastlane/metadata/android/id/changelogs/40103120.txt @@ -1,2 +1,2 @@ Perubahan utama dalam versi ini: Perbaikan bug! -Changelog lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.12 +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.12 diff --git a/fastlane/metadata/android/id/changelogs/40103130.txt b/fastlane/metadata/android/id/changelogs/40103130.txt index 26a784d62a..2b81855beb 100644 --- a/fastlane/metadata/android/id/changelogs/40103130.txt +++ b/fastlane/metadata/android/id/changelogs/40103130.txt @@ -1,2 +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: +Catatan perubahan 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 index dfefff307f..cabf0750e0 100644 --- a/fastlane/metadata/android/id/changelogs/40103140.txt +++ b/fastlane/metadata/android/id/changelogs/40103140.txt @@ -1,2 +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 +Catatan perubahan 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 index c46e661d47..27fc93215b 100644 --- a/fastlane/metadata/android/id/changelogs/40103150.txt +++ b/fastlane/metadata/android/id/changelogs/40103150.txt @@ -1,2 +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 +Catatan perubahan 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..418853bb2c --- /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. Pengeditan pemungutan suara. +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.16 diff --git a/fastlane/metadata/android/id/changelogs/40103170.txt b/fastlane/metadata/android/id/changelogs/40103170.txt new file mode 100644 index 0000000000..eebdcf5858 --- /dev/null +++ b/fastlane/metadata/android/id/changelogs/40103170.txt @@ -0,0 +1,2 @@ +Perubahan utama dalam versi ini: kirim lokasi Anda ke ruangan apa saja. Pengeditan pemungutan suara. +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.17 diff --git a/fastlane/metadata/android/id/changelogs/40103180.txt b/fastlane/metadata/android/id/changelogs/40103180.txt new file mode 100644 index 0000000000..6ec4c2c1bc --- /dev/null +++ b/fastlane/metadata/android/id/changelogs/40103180.txt @@ -0,0 +1,2 @@ +Perubahan utama dalam versi ini: kirim lokasi Anda ke ruangan apa saja. Pengeditan pemungutan suara. +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases/tag/v1.3.18 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/it-IT/changelogs/40103170.txt b/fastlane/metadata/android/it-IT/changelogs/40103170.txt new file mode 100644 index 0000000000..f490bc3f0d --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/40103170.txt @@ -0,0 +1,2 @@ +Modifiche principali in questa versione: invia la tua posizione in qualsiasi stanza. Modifica dei sondaggi. +Cronologia completa: https://github.com/vector-im/element-android/releases/tag/v1.3.17 diff --git a/fastlane/metadata/android/it-IT/changelogs/40103180.txt b/fastlane/metadata/android/it-IT/changelogs/40103180.txt new file mode 100644 index 0000000000..9872ddcb9b --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/40103180.txt @@ -0,0 +1,2 @@ +Modifiche principali in questa versione: invia la tua posizione in qualsiasi stanza. Modifica dei sondaggi. +Cronologia completa: https://github.com/vector-im/element-android/releases/tag/v1.3.18 diff --git a/fastlane/metadata/android/iw-IL/changelogs/40103180.txt b/fastlane/metadata/android/iw-IL/changelogs/40103180.txt new file mode 100644 index 0000000000..9649c6839c --- /dev/null +++ b/fastlane/metadata/android/iw-IL/changelogs/40103180.txt @@ -0,0 +1,2 @@ +שינויים עיקריים בגרסה זו: שלח את המיקום שלך לכל חדר. ערוך סקר. +יומן שינויים מלא: https://github.com/vector-im/element-android/releases/tag/v1.3.18 diff --git a/fastlane/metadata/android/iw-IL/full_description.txt b/fastlane/metadata/android/iw-IL/full_description.txt index fe3bc16661..1545634b33 100644 --- a/fastlane/metadata/android/iw-IL/full_description.txt +++ b/fastlane/metadata/android/iw-IL/full_description.txt @@ -28,3 +28,6 @@ תקשורת מלאה : הודעות, שיחות קול ווידאו, שיתוף קבצים, שיתוף מסך וחבורה שלמה של אינטגרציות, בוטים ווידג'טים. לבנות חדרים, קהילות, לשמור על קשר ולעשות דברים. בכל מקום שאתה נמצא : הישאר בקשר בכל מקום שאתה נמצא עם היסטוריית הודעות מסונכרנת לחלוטין בכל המכשירים שלך באינטרנט בכתובת https://app.element.io. + +קוד פתוח: +Element Android הוא פרויקט קוד פתוח, המתארח על ידי GitHub. נא לדווח על באגים ו/או לתרום לפיתוחה בכתובת https://github.com/vector-im/element-android diff --git a/fastlane/metadata/android/ja-JP/changelogs/40100100.txt b/fastlane/metadata/android/ja-JP/changelogs/40100100.txt index 8359a12964..48af96d216 100644 --- a/fastlane/metadata/android/ja-JP/changelogs/40100100.txt +++ b/fastlane/metadata/android/ja-JP/changelogs/40100100.txt @@ -1,2 +1,2 @@ 今回の新バージョンでは、主にバグの修正と改善が行われています。メッセージの送信がより速くなりました。 -すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.0.10 +全ての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.0.10 diff --git a/fastlane/metadata/android/ja-JP/changelogs/40100110.txt b/fastlane/metadata/android/ja-JP/changelogs/40100110.txt index c93db421af..b8b9798fcd 100644 --- a/fastlane/metadata/android/ja-JP/changelogs/40100110.txt +++ b/fastlane/metadata/android/ja-JP/changelogs/40100110.txt @@ -1,2 +1,2 @@ 今回の新バージョンでは、主にUI(ユーザーインターフェース)とUX(ユーザーエクスペリエンス)の向上が図られています。友達を招待したり、QRコードを読み取って素早くDMを作成できるようになりました。 -すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.0.11 +全ての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.0.11 diff --git a/fastlane/metadata/android/ja-JP/changelogs/40100120.txt b/fastlane/metadata/android/ja-JP/changelogs/40100120.txt index aace2ef79f..01c33c5d52 100644 --- a/fastlane/metadata/android/ja-JP/changelogs/40100120.txt +++ b/fastlane/metadata/android/ja-JP/changelogs/40100120.txt @@ -1,2 +1,2 @@ このバージョンの主な変更点: URLプレビュー、新しい絵文字、新しいルーム設定機能、それにクリスマスには雪が! -すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.0.12 +全ての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.0.12 diff --git a/fastlane/metadata/android/ja-JP/changelogs/40100130.txt b/fastlane/metadata/android/ja-JP/changelogs/40100130.txt index 97633621c5..941a052239 100644 --- a/fastlane/metadata/android/ja-JP/changelogs/40100130.txt +++ b/fastlane/metadata/android/ja-JP/changelogs/40100130.txt @@ -1,2 +1,2 @@ このバージョンの主な変更点: URLプレビュー、新しい絵文字、新しいルーム設定機能、それにクリスマスには雪が! -すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.0.13 +全ての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.0.13 diff --git a/fastlane/metadata/android/ja-JP/changelogs/40100140.txt b/fastlane/metadata/android/ja-JP/changelogs/40100140.txt index c340663127..6dc536cdcf 100644 --- a/fastlane/metadata/android/ja-JP/changelogs/40100140.txt +++ b/fastlane/metadata/android/ja-JP/changelogs/40100140.txt @@ -1,2 +1,2 @@ このバージョンの主な変更点: 部屋の許可、自動のテーマ切替、そして多くのバグを修正しました。 -すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.0.14 +全ての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.0.14 diff --git a/fastlane/metadata/android/ja-JP/changelogs/40100150.txt b/fastlane/metadata/android/ja-JP/changelogs/40100150.txt index 42f28c7bea..caded1b8ed 100644 --- a/fastlane/metadata/android/ja-JP/changelogs/40100150.txt +++ b/fastlane/metadata/android/ja-JP/changelogs/40100150.txt @@ -1,2 +1,2 @@ このバージョンの主な変更点: ソーシャルログインに対応しました。 -すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.0.15 +全ての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.0.15 diff --git a/fastlane/metadata/android/ja-JP/changelogs/40100160.txt b/fastlane/metadata/android/ja-JP/changelogs/40100160.txt index 8b5196998a..1b1a2092b0 100644 --- a/fastlane/metadata/android/ja-JP/changelogs/40100160.txt +++ b/fastlane/metadata/android/ja-JP/changelogs/40100160.txt @@ -1,2 +1,2 @@ このバージョンの主な変更点: パフォーマンスの向上とバグの修正! -すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.0.15 and https://github.com/vector-im/element-android/releases/tag/v1.0.16 +全ての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.0.15 and https://github.com/vector-im/element-android/releases/tag/v1.0.16 diff --git a/fastlane/metadata/android/ja-JP/changelogs/40100170.txt b/fastlane/metadata/android/ja-JP/changelogs/40100170.txt index 586b01cb2b..a0cc7b107d 100644 --- a/fastlane/metadata/android/ja-JP/changelogs/40100170.txt +++ b/fastlane/metadata/android/ja-JP/changelogs/40100170.txt @@ -1,2 +1,2 @@ このバージョンの主な変更点: バグの修正! -すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.0.17 +全ての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.0.17 diff --git a/fastlane/metadata/android/ja-JP/changelogs/40101000.txt b/fastlane/metadata/android/ja-JP/changelogs/40101000.txt index 25bbd7ab87..d0900f38c2 100644 --- a/fastlane/metadata/android/ja-JP/changelogs/40101000.txt +++ b/fastlane/metadata/android/ja-JP/changelogs/40101000.txt @@ -1,2 +1,2 @@ このバージョンの主な変更点: パフォーマンスの向上とバグの修正! -すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.1.0 +全ての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.1.0 diff --git a/fastlane/metadata/android/ja-JP/changelogs/40101010.txt b/fastlane/metadata/android/ja-JP/changelogs/40101010.txt index 35ba933069..cb204e5696 100644 --- a/fastlane/metadata/android/ja-JP/changelogs/40101010.txt +++ b/fastlane/metadata/android/ja-JP/changelogs/40101010.txt @@ -1,2 +1,2 @@ このバージョンの主な変更点: パフォーマンスの向上とバグの修正! -すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.1.1 +全ての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.1.1 diff --git a/fastlane/metadata/android/ja-JP/changelogs/40101020.txt b/fastlane/metadata/android/ja-JP/changelogs/40101020.txt index 88e3c79ca8..bb6ab66525 100644 --- a/fastlane/metadata/android/ja-JP/changelogs/40101020.txt +++ b/fastlane/metadata/android/ja-JP/changelogs/40101020.txt @@ -1,2 +1,2 @@ このバージョンの主な変更点: パフォーマンスの向上とバグの修正! -すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.1.2 +全ての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.1.2 diff --git a/fastlane/metadata/android/ja-JP/changelogs/40101030.txt b/fastlane/metadata/android/ja-JP/changelogs/40101030.txt index 87d191b226..e7ecc05a0f 100644 --- a/fastlane/metadata/android/ja-JP/changelogs/40101030.txt +++ b/fastlane/metadata/android/ja-JP/changelogs/40101030.txt @@ -1,2 +1,2 @@ このバージョンの主な変更点: パフォーマンスの向上とバグの修正! -すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.1.3 +全ての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.1.3 diff --git a/fastlane/metadata/android/ja-JP/changelogs/40101160.txt b/fastlane/metadata/android/ja-JP/changelogs/40101160.txt index a498487f46..985ea10510 100644 --- a/fastlane/metadata/android/ja-JP/changelogs/40101160.txt +++ b/fastlane/metadata/android/ja-JP/changelogs/40101160.txt @@ -1,2 +1,2 @@ このバージョンの主な変更点:ルームにて誰かがログアウトした際に発生するエラーを修正しました。 -すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.1.16 +全ての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.1.16 diff --git a/fastlane/metadata/android/ja-JP/changelogs/40103160.txt b/fastlane/metadata/android/ja-JP/changelogs/40103160.txt new file mode 100644 index 0000000000..5475828623 --- /dev/null +++ b/fastlane/metadata/android/ja-JP/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/ja-JP/changelogs/40103170.txt b/fastlane/metadata/android/ja-JP/changelogs/40103170.txt new file mode 100644 index 0000000000..61b91b54c0 --- /dev/null +++ b/fastlane/metadata/android/ja-JP/changelogs/40103170.txt @@ -0,0 +1,2 @@ +このバージョンの主な変更点:ルームへのロケーションの送信。投票機能の変更。 +更新履歴:https://github.com/vector-im/element-android/releases/tag/v1.3.17 diff --git a/fastlane/metadata/android/ja-JP/changelogs/40103180.txt b/fastlane/metadata/android/ja-JP/changelogs/40103180.txt new file mode 100644 index 0000000000..ae4f1a0a0f --- /dev/null +++ b/fastlane/metadata/android/ja-JP/changelogs/40103180.txt @@ -0,0 +1,2 @@ +このバージョンの主な変更点:ルームへのロケーションの送信。投票機能の変更。 +更新履歴:https://github.com/vector-im/element-android/releases/tag/v1.3.18 diff --git a/fastlane/metadata/android/ja-JP/full_description.txt b/fastlane/metadata/android/ja-JP/full_description.txt index 4e7b01cce3..6014938cce 100644 --- a/fastlane/metadata/android/ja-JP/full_description.txt +++ b/fastlane/metadata/android/ja-JP/full_description.txt @@ -1,39 +1,42 @@ -Elementはセキュアなメッセンジャーであると同時に、リモートワークでのグループチャットにも最適です。エンドツーエンドの暗号化を使用して、強力なビデオ会議、ファイル共有、音声通話を提供します。 +Elementは、安全なメッセンジャー、リモートワーク中のグループチャットに適したチームコラボレーションアプリです。エンドツーエンドの暗号化を使用して、強力なビデオ会議、ファイル共有、音声通話を提供します。 Elementの特徴 - 高度なオンラインコミュニケーションツール -- 完全に暗号化されたメッセージ +- 完全に暗号化されたメッセージにより、リモートワーカーでも、より安全な企業コミュニケーションが可能 - Matrixオープンソースフレームワークをベースにした分散型のチャット - プロジェクトを管理しながら、暗号化されたデータで安全にファイル共有 - Voice over IPによるビデオチャットと画面共有 -- お気に入りのオンラインコラボレーションツール、プロジェクト管理ツール、VoIPサービス、その他のチームメッセージングアプリと統合可能 +- お気に入りのオンラインコラボレーションツール、プロジェクト管理ツール、VoIPサービス、その他のチームメッセージングアプリと簡単に統合可能 -Elementは他のメッセージングアプリやコラボレーションアプリとは異なります。安全なメッセージングと分散型(非中央集権)コミュニケーションのためのオープンネットワークであるMatrixで動作します。また、ユーザーが自分のデータやメッセージを最大限にコントロールできるように、セルフホスティングも可能です。 +Elementは他のメッセージングアプリやコラボレーションアプリとは全く異なります。安全なメッセージングと分散型(非中央集権)コミュニケーションのためのオープンネットワークであるMatrixで動作します。ユーザーが自分のデータやメッセージを最大限にコントロールできるように、セルフホスティングも可能です。 -プライバシーと暗号化されたやりとり -Elementは、望ましくない広告、データマイニング、ウォールドガーデンからユーザーを保護します。また、エンド・ツー・エンドの暗号化と相互署名されたデバイスの検証により、すべてのデータ、1対1のビデオおよび音声通信を保護します。 +プライバシーと暗号化されたコミュニケーション +Elementは、望ましくない広告、データマイニング、ウォールドガーデンからユーザーを保護します。また、エンド・ツー・エンドの暗号化と相互署名された端末の検証により、全てのデータ、1対1のビデオおよび音声通信を保護します。 -Elementは、Slackなどのアプリと統合することで、Matrixネットワーク上の誰とでも安全にコミュニケーションをとることができると同時に、プライバシーをコントロールすることができます。 +Elementは、Slackなどのアプリと統合することで、Matrixネットワーク上の誰とでも安全にコミュニケーションを取ることができると同時に、プライバシーをコントロールすることができます。 Elementはセルフホスティングが可能 -機密データや会話の管理を強化するために、Elementはセルフホスティングが可能で、オープンソースの分散型コミュニケーションの標準であるマトリックスベースのホストを選択することもできます。Elementは、プライバシー、セキュリティコンプライアンス、および統合の柔軟性を提供します。 +機密データや会話の管理を強化するために、Elementはセルフホスティングが可能です。または、オープンソースの分散型コミュニケーションの標準であるMatrixベースのホストを選択することもできます。Elementは、プライバシー、セキュリティーコンプライアンス、および統合の柔軟性を提供します。 -データを所有する -データやメッセージをどこに保管するかは、お客様が決めることができます。データマイニングやサードパーティからのアクセスされません。 +自分のデータを所有する +データやメッセージをどこに保管するかは、ユーザー自身が決めることができます。データマイニングやサードパーティからのアクセスのリスクはありません。 -Elementではどのサーバーを使うか決めることができます。さまざまな方法で選択できます。 +Elementでは、どのサーバーを使うかを、ご自身で決めることができます。 1. 開発者がホストする matrix.org のパブリックサーバーで無料アカウントを取得するか、ボランティアがホストしているパブリックサーバーから選択する。 -2. 自分でサーバを実行することにより、アカウントをセルフホストする。 -3. Element Matrix Servicesのホスティングプラットフォームに加入しカスタムサーバー上でアカウントを作る。 +2. あなた自身がサーバーを運営し、アカウントを管理する。 +3. Element Matrix Servicesのホスティングプラットフォームに加入し、カスタムサーバー上でアカウントを作る。 オープンなメッセージングとコラボレーション -Matrixネットワーク上の誰とでも、相手がElementを使っているか、他のMatrixアプリを使っていてもコミュニケーションすることができます。 +Matrixネットワーク上の誰とでも、相手がElementや他のMatrixアプリを使っているか、さらには他のメッセージングアプリを使っているかに関わらず、チャットをすることができます。 -すごく安全 -本物のエンド・ツー・エンドの暗号化(会話に参加している人だけがメッセージを復号化できる)と、相互署名されたデバイスの検証を行います。 +非常に安全 +本物のエンド・ツー・エンドの暗号化(会話に参加している人だけがメッセージを復号化できる)と、相互署名された端末の検証を行います。 包括的なコミュニケーションと統合 -メッセージング、音声およびビデオ通話、ファイル共有、画面共有、その他多くの統合、ボット、ウィジェットを提供します。部屋やコミュニティを作り、連絡を取り合い、物事を成し遂げることができます。 +メッセージング、音声およびビデオ通話、ファイル共有、画面共有、その他多くのインテグレーション、ボット、ウィジェットを提供します。ルームやコミュニティーを立ち上げて連絡を取り合い、物事をスムーズに成し遂げることができます。 -中断からの再開は -すべてのデバイスとウェブで完全に同期されたメッセージにより、どこにいても連絡を取り合うことができます。https://app.element.io +中断からの再開 +メッセージの履歴は全ての端末とウェブ(https://app.element.io)で完全に同期されるので、どこからでも連絡を取り合うことができます。 + +オープンソース +Element AndroidはGitHubで開発されているオープンソースのプロジェクトです。 バグの報告や開発への貢献は https://github.com/vector-im/element-android にて受け付けています。 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/pt-BR/changelogs/40103170.txt b/fastlane/metadata/android/pt-BR/changelogs/40103170.txt new file mode 100644 index 0000000000..1266131db7 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/40103170.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.17 diff --git a/fastlane/metadata/android/pt-BR/changelogs/40103180.txt b/fastlane/metadata/android/pt-BR/changelogs/40103180.txt new file mode 100644 index 0000000000..56e23a2be4 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/40103180.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.18 diff --git a/fastlane/metadata/android/ru-RU/changelogs/40103050.txt b/fastlane/metadata/android/ru-RU/changelogs/40103050.txt new file mode 100644 index 0000000000..6f864e59dc --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40103050.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Добавлена поддержка присутствия для комнаты личных сообщений (примечание: присутствие отключено на matrix.org). Добавьте снова поддержку Android Auto. +Полный журнал изменений: https://github.com/vector-im/element-android/releases/tag/v1.3.5 diff --git a/fastlane/metadata/android/ru-RU/changelogs/40103060.txt b/fastlane/metadata/android/ru-RU/changelogs/40103060.txt new file mode 100644 index 0000000000..975b92ef9b --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40103060.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Добавлена поддержка присутствия для комнаты личных сообщений (примечание: присутствие отключено на matrix.org). Добавьте снова поддержку Android Auto. +Полный журнал изменений: https://github.com/vector-im/element-android/releases/tag/v1.3.6 diff --git a/fastlane/metadata/android/ru-RU/changelogs/40103070.txt b/fastlane/metadata/android/ru-RU/changelogs/40103070.txt new file mode 100644 index 0000000000..8ba9ea6417 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40103070.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Исправлены ошибки, в основном касающиеся уведомлений. +Полный список изменений: https://github.com/vector-im/element-android/releases/tag/v1.3.7-RC2 diff --git a/fastlane/metadata/android/ru-RU/changelogs/40103080.txt b/fastlane/metadata/android/ru-RU/changelogs/40103080.txt new file mode 100644 index 0000000000..bf47216dfb --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40103080.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Исправлены ошибки! +Полный журнал изменений: https://github.com/vector-im/element-android/releases/tag/v1.3.8 diff --git a/fastlane/metadata/android/ru-RU/changelogs/40103090.txt b/fastlane/metadata/android/ru-RU/changelogs/40103090.txt new file mode 100644 index 0000000000..95a482a48c --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40103090.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Добавлена поддержка черновика голосового сообщения. Много исправлений! +Полный журнал изменений: https://github.com/vector-im/element-android/releases/tag/v1.3.9 diff --git a/fastlane/metadata/android/ru-RU/changelogs/40103100.txt b/fastlane/metadata/android/ru-RU/changelogs/40103100.txt new file mode 100644 index 0000000000..73aab13115 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40103100.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Исправлены ошибки! +Полный журнал изменений: https://github.com/vector-im/element-android/releases/tag/v1.3.11 diff --git a/fastlane/metadata/android/ru-RU/changelogs/40103110.txt b/fastlane/metadata/android/ru-RU/changelogs/40103110.txt new file mode 100644 index 0000000000..836fd5131e --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40103110.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Исправлены ошибки! +Полный список изменений: https://github.com/vector-im/element-android/releases/tag/v1.3.12 diff --git a/fastlane/metadata/android/ru-RU/changelogs/40103120.txt b/fastlane/metadata/android/ru-RU/changelogs/40103120.txt new file mode 100644 index 0000000000..fb1bf5cb37 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40103120.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Первое изменение экранов регистрации, включая подписку на Analytics. Добавлена поддержка событий с математикой в лабораториях. +Полный журнал изменений: https://github.com/vector-im/element-android/releases/tag/v1.3.13 diff --git a/fastlane/metadata/android/ru-RU/changelogs/40103130.txt b/fastlane/metadata/android/ru-RU/changelogs/40103130.txt new file mode 100644 index 0000000000..fb1bf5cb37 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40103130.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Первое изменение экранов регистрации, включая подписку на Analytics. Добавлена поддержка событий с математикой в лабораториях. +Полный журнал изменений: https://github.com/vector-im/element-android/releases/tag/v1.3.13 diff --git a/fastlane/metadata/android/ru-RU/changelogs/40103140.txt b/fastlane/metadata/android/ru-RU/changelogs/40103140.txt new file mode 100644 index 0000000000..f1144e7022 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40103140.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Первое изменение экранов регистрации, включая подписку на Analytics. Добавлена поддержка событий с математикой в лабораториях. +Полный журнал изменений: https://github.com/vector-im/element-android/releases/tag/v1.3.14 diff --git a/fastlane/metadata/android/ru-RU/changelogs/40103150.txt b/fastlane/metadata/android/ru-RU/changelogs/40103150.txt new file mode 100644 index 0000000000..87becb7910 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40103150.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Первое изменение экранов регистрации, включая подписку на Analytics. Добавлена поддержка событий с математикой в лабораториях. +Полный журнал изменений: https://github.com/vector-im/element-android/releases/tag/v1.3.15 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/ru-RU/changelogs/40103170.txt b/fastlane/metadata/android/ru-RU/changelogs/40103170.txt new file mode 100644 index 0000000000..08286401e2 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40103170.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: отправка вашего местоположения в любую комнату. Изменить опрос. +Полный журнал изменений: https://github.com/vector-im/element-android/releases/tag/v1.3.17 diff --git a/fastlane/metadata/android/ru-RU/changelogs/40103180.txt b/fastlane/metadata/android/ru-RU/changelogs/40103180.txt new file mode 100644 index 0000000000..08286401e2 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40103180.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: отправка вашего местоположения в любую комнату. Изменить опрос. +Полный журнал изменений: https://github.com/vector-im/element-android/releases/tag/v1.3.17 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/sk/changelogs/40103170.txt b/fastlane/metadata/android/sk/changelogs/40103170.txt new file mode 100644 index 0000000000..58e5f1fa61 --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/40103170.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.17 diff --git a/fastlane/metadata/android/sk/changelogs/40103180.txt b/fastlane/metadata/android/sk/changelogs/40103180.txt new file mode 100644 index 0000000000..b5649471cb --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/40103180.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.18 diff --git a/fastlane/metadata/android/sk/full_description.txt b/fastlane/metadata/android/sk/full_description.txt index 78661e961e..d653933c48 100644 --- a/fastlane/metadata/android/sk/full_description.txt +++ b/fastlane/metadata/android/sk/full_description.txt @@ -1,4 +1,4 @@ -Element je zabezpečený messenger a zároveň aplikácia na tímovú spoluprácu, ktorá je ideálna na skupinové konverzácie pri práci na diaľku. Táto komunikačná aplikácia využíva end-to-end šifrovanie na poskytovanie výkonných videokonferencií, zdieľania súborov a hlasových hovorov. +Element je bezpečný messenger a zároveň aplikácia na tímovú spoluprácu, ktorá je ideálna na skupinové konverzácie pri práci na diaľku. Táto komunikačná aplikácia využíva end-to-end šifrovanie na poskytovanie výkonných videokonferencií, zdieľania súborov a hlasových hovorov. Funkcie aplikácie Element zahŕňajú: - Pokročilé nástroje na online komunikáciu @@ -11,14 +11,15 @@ Element je zabezpečený messenger a zároveň aplikácia na tímovú spoluprác Element sa úplne líši od ostatných aplikácií na zasielanie správ a spoluprácu. Funguje na Matrixe, otvorenej sieti na bezpečné posielanie správ a decentralizovanú komunikáciu. Umožňuje vlastný hosting, aby používatelia získali maximálne vlastníctvo a kontrolu nad svojimi údajmi a správami. Súkromie a šifrovanie správ -Element vás chráni pred nežiaducimi reklamami, ťažbou údajov a tzv. walled gardens. Zabezpečuje tiež všetky vaše údaje, video a hlasovú komunikáciu jeden na jedného prostredníctvom end-to-end šifrovania a overovania zariadení krížovým podpisovaním +Element vás chráni pred nežiaducimi reklamami, ťažbou údajov a tzv. walled gardens. Zabezpečuje tiež všetky vaše údaje, video a hlasovú komunikáciu jeden na jedného prostredníctvom end-to-end šifrovania a overovania zariadení krížovým podpisovaním. + Element vám poskytuje kontrolu nad vaším súkromím a zároveň vám umožňuje bezpečne komunikovať s kýmkoľvek v sieti Matrix alebo s inými nástrojmi na podnikovú spoluprácu vďaka integrácii s aplikáciami, ako je napríklad Slack. -Element môže byť na vašom vlastnom serveri. +Element môže byť na vašom vlastnom serveri Aby ste mali väčšiu kontrolu nad svojimi citlivými údajmi a konverzáciami, Element môže byť na vašom vlastnom serveri alebo si môžete vybrať ľubovoľný hosting založený na systéme Matrix - štandarde pre decentralizovanú komunikáciu s otvoreným zdrojovým kódom. Element vám poskytuje súkromie, súlad s bezpečnostnými predpismi a flexibilitu integrácie. Vlastnite svoje údaje -Vy rozhodujete o tom, kde budú vaše údaje a správy uložené. Bez rizika ťažby údajov alebo prístupu tretích strán. +Vy rozhodujete o tom, kde budú vaše údaje a správy uložené. Bez rizika získavania údajov alebo prístupu tretích strán. Element vám dáva kontrolu rôznymi spôsobmi: 1. Získajte bezplatné konto na verejnom serveri matrix.org, ktorý hostia vývojári Matrixu, alebo si vyberte z tisícov verejných serverov, ktoré hostia dobrovoľníci. @@ -32,10 +33,10 @@ Môžete komunikovať s kýmkoľvek v sieti Matrix, či už používa aplikáciu Skutočné end-to-end šifrovanie (správy môžu dešifrovať len účastníci konverzácie) a krížové overovanie zariadení. Kompletná komunikácia a integrácia -Správy, hlasové a video hovory, zdieľanie súborov, zdieľanie obrazovky a celý rad integrácií, botov a widgetov. Vytvárajte miestnosti, komunity, zostaňte v kontakte a vybavujte veci. +Správy, hlasové a video hovory, zdieľanie súborov, zdieľanie obrazovky a celý rad integrácií, botov a widgetov. Vytvárajte miestnosti, komunity, zostaňte v kontakte a vybavte veci. Nadviažte tam, kde ste skončili Buďte v kontakte, nech ste kdekoľvek, vďaka plne synchronizovanej histórii správ vo všetkých zariadeniach a na webe na adrese https://app.element.io. Otvorený zdroj -Element Android je projekt s otvoreným zdrojovým kódom, ktorého hostiteľom je GitHub. Nahlasujte chyby a/alebo prispievajte k jeho vývoju na adrese https://github.com/vector-im/element-android. +Element Android je projekt s otvoreným zdrojovým kódom, ktorého hostiteľom je GitHub. Nahláste prosím chyby a/alebo prispejte k jeho vývoju na https://github.com/vector-im/element-android diff --git a/fastlane/metadata/android/sq/changelogs/40103130.txt b/fastlane/metadata/android/sq/changelogs/40103130.txt new file mode 100644 index 0000000000..abae5b8206 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40103130.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Ndryshimi i parë te skena e mirëseardhjeve, përfshi zgjedhje për pjesëmarrje në Analizime. Mbulim për Akte dhe Formula Matematikore të shtuara te laboratorë. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases/tag/v1.3.13 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/sq/changelogs/40103170.txt b/fastlane/metadata/android/sq/changelogs/40103170.txt new file mode 100644 index 0000000000..405743bcb6 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40103170.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: dërgojeni vendndodhjen tuaj në çfarëdo dhome. Përpunoni pyetësorë. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases/tag/v1.3.17 diff --git a/fastlane/metadata/android/sq/changelogs/40103180.txt b/fastlane/metadata/android/sq/changelogs/40103180.txt new file mode 100644 index 0000000000..5ec41f7db3 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40103180.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: dërgojeni vendndodhjen tuaj në çfarëdo dhome. Përpunoni pyetësorë. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases/tag/v1.3.18 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/sv-SE/changelogs/40103170.txt b/fastlane/metadata/android/sv-SE/changelogs/40103170.txt new file mode 100644 index 0000000000..ae2be8bd17 --- /dev/null +++ b/fastlane/metadata/android/sv-SE/changelogs/40103170.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.17 diff --git a/fastlane/metadata/android/sv-SE/changelogs/40103180.txt b/fastlane/metadata/android/sv-SE/changelogs/40103180.txt new file mode 100644 index 0000000000..fe5b212d9c --- /dev/null +++ b/fastlane/metadata/android/sv-SE/changelogs/40103180.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.18 diff --git a/fastlane/metadata/android/tr-TR/changelogs/40103000.txt b/fastlane/metadata/android/tr-TR/changelogs/40103000.txt new file mode 100644 index 0000000000..bb66b40193 --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/40103000.txt @@ -0,0 +1,2 @@ +Bu sürümdeki ana değişiklikler: Spaces kullanarak odalarınızı düzenleyin! +Tam değişiklik günlüğü: https://github.com/vector-im/element-android/releases/tag/v1.3.0 diff --git a/fastlane/metadata/android/tr-TR/changelogs/40103010.txt b/fastlane/metadata/android/tr-TR/changelogs/40103010.txt new file mode 100644 index 0000000000..3fea80054e --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/40103010.txt @@ -0,0 +1,2 @@ +Bu sürümdeki ana değişiklikler: Spaces kullanarak odalarınızı düzenleyin! v1.3.1, v1.3.0'da meydana gelen bir kilitlenme düzeltildi. +Tam değişiklik günlüğü: https://github.com/vector-im/element-android/releases/tag/v1.3.1 diff --git a/fastlane/metadata/android/tr-TR/changelogs/40103020.txt b/fastlane/metadata/android/tr-TR/changelogs/40103020.txt new file mode 100644 index 0000000000..ef8271c6c2 --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/40103020.txt @@ -0,0 +1,2 @@ +Bu sürümdeki ana değişiklikler: Android Auto desteği eklendi. Birçok hata düzeltmesi! +Tam değişiklik günlüğü: https://github.com/vector-im/element-android/releases/tag/v1.3.2 diff --git a/fastlane/metadata/android/tr-TR/changelogs/40103030.txt b/fastlane/metadata/android/tr-TR/changelogs/40103030.txt new file mode 100644 index 0000000000..b771e53f96 --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/40103030.txt @@ -0,0 +1,2 @@ +Bu sürümdeki ana değişiklikler: Ayarlarda kimlik sunucusu politika(lar)ını görünür yapın. Android Auto desteğini geçici olarak kaldırın. +Tam değişiklik günlüğü: https://github.com/vector-im/element-android/releases/tag/v1.3.3 diff --git a/fastlane/metadata/android/tr-TR/changelogs/40103040.txt b/fastlane/metadata/android/tr-TR/changelogs/40103040.txt new file mode 100644 index 0000000000..1b2f20e349 --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/40103040.txt @@ -0,0 +1,2 @@ +Bu sürümdeki ana değişiklikler: Doğrudan Mesaj odası için Durum desteği ekleyin (not: durum devre dışı açık matrix.org ). Android Auto desteğini tekrar ekleyin. +Tam değişiklik listesi: https://github.com/vector-im/element-android/releases/tag/v1.3.4 diff --git a/fastlane/metadata/android/tr-TR/changelogs/40103050.txt b/fastlane/metadata/android/tr-TR/changelogs/40103050.txt new file mode 100644 index 0000000000..69ac8dcb1f --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/40103050.txt @@ -0,0 +1,2 @@ +Bu sürümdeki ana değişiklikler: Doğrudan Mesaj odası için Durum desteği ekleyin (not: durum devre dışı açık matrix.org ). Android Auto desteğini tekrar ekleyin. +Tam değişiklik listesi: https://github.com/vector-im/element-android/releases/tag/v1.3.5 diff --git a/fastlane/metadata/android/tr-TR/changelogs/40103060.txt b/fastlane/metadata/android/tr-TR/changelogs/40103060.txt new file mode 100644 index 0000000000..396cebd495 --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/40103060.txt @@ -0,0 +1,2 @@ +Bu sürümdeki ana değişiklikler: Doğrudan Mesaj odası için Durum desteği ekleyin (not: durum devre dışı açık matrix.org ). Android Auto desteğini tekrar ekleyin. +Tam değişiklik listesi: https://github.com/vector-im/element-android/releases/tag/v1.3.6 diff --git a/fastlane/metadata/android/tr-TR/changelogs/40103070.txt b/fastlane/metadata/android/tr-TR/changelogs/40103070.txt new file mode 100644 index 0000000000..62b3fa8824 --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/40103070.txt @@ -0,0 +1,2 @@ +Bu sürümdeki ana değişiklikler: Esas olarak bildirimlerle ilgili hata düzeltmeleri. +Tam değişiklik listesi: https://github.com/vector-im/element-android/releases/tag/v1.3.7-RC2 diff --git a/fastlane/metadata/android/tr-TR/changelogs/40103080.txt b/fastlane/metadata/android/tr-TR/changelogs/40103080.txt new file mode 100644 index 0000000000..6d802d09f7 --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/40103080.txt @@ -0,0 +1,2 @@ +Bu sürümdeki ana değişiklikler: Hata düzeltmeleri! +Tam değişiklik listesi: https://github.com/vector-im/element-android/releases/tag/v1.3.8 diff --git a/fastlane/metadata/android/tr-TR/changelogs/40103090.txt b/fastlane/metadata/android/tr-TR/changelogs/40103090.txt new file mode 100644 index 0000000000..4acef8f885 --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/40103090.txt @@ -0,0 +1,2 @@ +Bu sürümdeki ana değişiklikler: Sesli mesaj taslağı için destek ekleyin. Birçok hata düzeltmeleri! +Tam değişiklik listesi: https://github.com/vector-im/element-android/releases/tag/v1.3.9 diff --git a/fastlane/metadata/android/tr-TR/changelogs/40103100.txt b/fastlane/metadata/android/tr-TR/changelogs/40103100.txt new file mode 100644 index 0000000000..1ccdf8210e --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/40103100.txt @@ -0,0 +1,2 @@ +Bu sürümdeki ana değişiklikler: Anketler için destek ekleyin (laboratuarlarda). Yeni URL önizleme tasarımı. +Tam değişiklik listesi: https://github.com/vector-im/element-android/releases/tag/v1.3.10 diff --git a/fastlane/metadata/android/tr-TR/changelogs/40103110.txt b/fastlane/metadata/android/tr-TR/changelogs/40103110.txt new file mode 100644 index 0000000000..c59f57a897 --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/40103110.txt @@ -0,0 +1,2 @@ +Bu sürümdeki ana değişiklikler: Hata düzeltmeleri! +Tam değişiklik listesi: https://github.com/vector-im/element-android/releases/tag/v1.3.11 diff --git a/fastlane/metadata/android/tr-TR/changelogs/40103120.txt b/fastlane/metadata/android/tr-TR/changelogs/40103120.txt new file mode 100644 index 0000000000..9ba7fa4556 --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/40103120.txt @@ -0,0 +1,2 @@ +Bu sürümdeki ana değişiklikler: Hata düzeltmeleri! +Tam değişiklik listesi: https://github.com/vector-im/element-android/releases/tag/v1.3.12 diff --git a/fastlane/metadata/android/tr-TR/changelogs/40103130.txt b/fastlane/metadata/android/tr-TR/changelogs/40103130.txt new file mode 100644 index 0000000000..1f356f8360 --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/40103130.txt @@ -0,0 +1,2 @@ +Bu sürümdeki ana değişiklikler: Analytics'in etkinleştirilmesi de dahil olmak üzere, ilk önce yerleşik ekranlardaki değişiklikler. Laboratuvarlara Matematik eklenmiş Etkinlikler için destek. +Tam değişiklik listesi: https://github.com/vector-im/element-android/releases/tag/v1.3.13 diff --git a/fastlane/metadata/android/tr-TR/changelogs/40103140.txt b/fastlane/metadata/android/tr-TR/changelogs/40103140.txt new file mode 100644 index 0000000000..d8e2d5f614 --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/40103140.txt @@ -0,0 +1,2 @@ +Bu sürümdeki ana değişiklikler: Analytics'in etkinleştirilmesi de dahil olmak üzere, ilk önce yerleşik ekranlardaki değişiklikler. Laboratuvarlara Matematik eklenmiş Etkinlikler için destek. +Tam değişiklik listesi: https://github.com/vector-im/element-android/releases/tag/v1.3.14 diff --git a/fastlane/metadata/android/tr-TR/changelogs/40103150.txt b/fastlane/metadata/android/tr-TR/changelogs/40103150.txt new file mode 100644 index 0000000000..7e98b545e8 --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/40103150.txt @@ -0,0 +1,2 @@ +Bu sürümdeki ana değişiklikler: Analytics'in etkinleştirilmesi de dahil olmak üzere, ilk önce yerleşik ekranlardaki değişiklikler. Laboratuvarlara Matematik eklenmiş Etkinlikler için destek. +Tam değişiklik listesi: https://github.com/vector-im/element-android/releases/tag/v1.3.15 diff --git a/fastlane/metadata/android/tr-TR/changelogs/40103160.txt b/fastlane/metadata/android/tr-TR/changelogs/40103160.txt new file mode 100644 index 0000000000..594b19dbfc --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/40103160.txt @@ -0,0 +1,2 @@ +Bu sürümdeki ana değişiklikler: konumunuzu herhangi bir odaya gönderin. Anketi düzenleyin. +Tam değişiklik günlüğü: https://github.com/vector-im/element-android/releases/tag/v1.3.16 diff --git a/fastlane/metadata/android/tr-TR/changelogs/40103170.txt b/fastlane/metadata/android/tr-TR/changelogs/40103170.txt new file mode 100644 index 0000000000..4bb0b066b6 --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/40103170.txt @@ -0,0 +1,2 @@ +Bu sürümdeki ana değişiklikler: konumunuzu herhangi bir odaya gönderin. Anketi düzenleyin. +Tam değişiklik günlüğü: https://github.com/vector-im/element-android/releases/tag/v1.3.17 diff --git a/fastlane/metadata/android/tr-TR/changelogs/40103180.txt b/fastlane/metadata/android/tr-TR/changelogs/40103180.txt new file mode 100644 index 0000000000..ce1573e113 --- /dev/null +++ b/fastlane/metadata/android/tr-TR/changelogs/40103180.txt @@ -0,0 +1,2 @@ +Bu sürümdeki ana değişiklikler: konumunuzu herhangi bir odaya gönderin. Anketi düzenleyin. +Tam değişiklik günlüğü: https://github.com/vector-im/element-android/releases/tag/v1.3.18 diff --git a/fastlane/metadata/android/tr-TR/full_description.txt b/fastlane/metadata/android/tr-TR/full_description.txt index 3895621620..8ce63e40a1 100644 --- a/fastlane/metadata/android/tr-TR/full_description.txt +++ b/fastlane/metadata/android/tr-TR/full_description.txt @@ -1,30 +1,42 @@ -Element, şu özelliklere sahip yeni bir tür mesajlaşma ve işbirliği uygulamasıdır: +Element, hem güvenli bir haberci hem de uzaktan çalışırken grup sohbetleri için ideal olan bir üretkenlik ekibi işbirliği uygulamasıdır. Bu sohbet uygulaması, güçlü video konferans, dosya paylaşımı ve sesli aramalar sağlamak için uçtan uca şifreleme kullanır. -1. Gizliliğinizi korumak için kontrolü size verir -2. Matrix ağındaki herkesle ve hatta Slack gibi uygulamalarla entegre olarak iletişim kurmanızı sağlar -3. Sizi reklamlardan, veri madenciliğinden ve walled gardenlardan korur -4. Başkalarını doğrulamak için çapraz imzalama ile uçtan uca şifreleme yoluyla güvenliğinizi sağlar + Elementin özellikleri şunları içerir: + - Gelişmiş çevrimiçi iletişim araçları + - Uzak çalışanlar için bile daha güvenli kurumsal iletişime izin vermek için tamamen şifrelenmiş mesajlar + - Matrix açık kaynak çerçevesine dayalı merkezi olmayan sohbet + - Projeleri yönetirken şifreli verilerle güvenli dosya paylaşımı + - IP üzerinden Ses ve ekran paylaşımı ile görüntülü sohbetler + - En sevdiğiniz çevrimiçi işbirliği araçları, proje yönetimi araçları, VoIP hizmetleri ve diğer ekip mesajlaşma uygulamalarıyla kolay entegrasyon -Element, dağıtık (decentralized) ve açık kaynak olduğu için diğer mesajlaşma ve işbirliği uygulamalarından tamamen farklıdır. + Element, diğer mesajlaşma ve işbirliği uygulamalarından tamamen farklıdır. Güvenli mesajlaşma ve merkezi olmayan iletişim için açık bir ağ olan Matrix üzerinde çalışır. Kendi kendine barındırmanın, kullanıcılara verilerinin ve mesajlarının maksimum sahipliğini ve kontrolünü vermesini sağlar. -Element kendi sunucunuzu kurmanıza yada bir sunucu seçmenizi izin verir, böylece verilerinizin ve sohbetlerinizin gizliliğine ve kontrolüne sahip olursunuz. Size açık bir ağa erişim sağlar; yani yalnızca diğer Element kullanıcılarıyla konuşmak zorunda kalmazsınız. Ve çok güvenlidir. + Gizlilik ve şifreli mesajlaşma + Element sizi istenmeyen reklamlardan, veri madenciliğinden ve duvarlarla çevrili bahçelerden korur. Ayrıca uçtan uca şifreleme ve çapraz imzalı cihaz doğrulama yoluyla tüm verilerinizi, bire bir video ve sesli iletişiminizi korur. -Element, açık, merkezi olmayan iletişim standardı olan Matrix üzerinde çalıştığı için tüm bunları yapabilir. + Element, Slack gibi uygulamalarla bütünleşerek Matrix ağındaki herhangi biriyle veya diğer iş işbirliği araçlarıyla güvenli bir şekilde iletişim kurmanıza izin verirken gizliliğiniz üzerinde kontrol sağlar. -Element, konuşmalarınızın sunucusunu seçmenize izin vererek kontrolü size verir. Element uygulamasından, farklı şekillerde sunucu seçebilirsiniz: + Öğe kendi kendine barındırılabilir + Hassas verileriniz ve konuşmalarınız üzerinde daha fazla kontrole izin vermek için Element, kendi kendine barındırılabilir veya açık kaynak, merkezi olmayan iletişim standardı olan Matrix tabanlı herhangi bir ana bilgisayarı seçebilirsiniz. Element size gizlilik, güvenlik uyumluluğu ve entegrasyon esnekliği sağlar. -1. Matrix geliştiricilerinin sahip olduğu matrix.org genel sunucusunda ücretsiz bir hesap edinin veya gönüllüler tarafından barındırılan binlerce genel sunucu arasından seçim yapın -2. Kendi donanımınız üzerinde bir sunucu çalıştırarak kendi hesabınızı barındırın -3. Element Matrix Hizmetleri sunucu platformuna abone olarak özel bir sunucuda hesap oluşturun + Verilerinizin sahibi olun + Verilerinizi ve mesajlarınızı nerede tutacağınıza siz karar verirsiniz. Veri madenciliği veya üçüncü şahıslardan erişim riski olmadan. -Neden Element'i Seçmelisiniz + Element, kontrolü farklı şekillerde size verir: + 1. Matrix geliştiricileri tarafından barındırılan matrix.org genel sunucusunda ücretsiz bir hesap edinin veya gönüllüler tarafından barındırılan binlerce genel sunucu arasından seçim yapın + 2. Kendi BT altyapınızda bir sunucu çalıştırarak hesabınızı kendiniz barındırın + 3. Element Matrix Services barındırma platformuna abone olarak özel bir sunucuda bir hesap için kaydolun -KENDİ VERİLERİNİZE SAHİP OLUN : Verilerinizi ve mesajlarınızı nerede saklayacağınıza siz karar verirsiniz. Verilerinize madencilik yapan veya üçüncü şahıslara erişim sağlayan bir BÜYÜKŞİRKETE verilerinizi vermiyorsunuz, onlara sahipsiniz ve kontrol ediyorsunuz. + Açık mesajlaşma ve ortak çalışma + Element, başka bir Matrix uygulaması veya farklı bir mesajlaşma uygulaması kullanıyor olsalar bile, Matrix ağındaki herkesle sohbet edebilirsiniz. -AÇIK MESAJLAŞMA VE İŞBİRLİĞİ: Element veya başka bir Matrix uygulamalarını kullanmaları fark etmeksizin, hatta Slack, IRC yada XMPP gibi farklı mesajlaşma uygulamaları kullanıyor olsalar bile, Matrix sunucusundaki herhangi biriyle konuşabilirsiniz + Süper güvenli + Gerçek uçtan uca şifreleme (yalnızca görüşmedekiler mesajların şifresini çözebilir) ve çapraz imzalı cihaz doğrulaması. -SÜPER GÜVENLİ: Gerçek uçtan uca şifreleme (yalnızca görüştüğünüz kişiler mesajların şifresini çözebilir) ve konuşma katılımcılarının cihazlarını doğrulamak için çapraz imzalama. + Tam iletişim ve entegrasyon + Mesajlaşma, sesli ve görüntülü aramalar, dosya paylaşımı, ekran paylaşımı ve bir sürü entegrasyon, bot ve widget. Odalar, topluluklar oluşturun, iletişimde kalın ve işlerinizi halledin. -TAM İLETİŞİM: Mesajlaşma, sesli ve görüntülü aramalar, dosya paylaşımı, ekran paylaşımı ve bir sürü entegrasyon, bot ve widgetlar. Odalar, topluluklar oluşturun, iletişimde kalın ve işlerinizi halledin. + Kaldığınız yerden devam edin + https://app.element.io adresinde tüm cihazlarınızda ve web'de tamamen senkronize edilmiş mesaj geçmişiyle nerede olursanız olun bağlantıda kalın -OLDUĞUNUZ HER YERDE: Nerede olursanız olun, tüm cihazlarınızda ve internette https://app.element.io adresinden tam senkronize mesaj geçmişiyle iletişimde kalın + Açık kaynak + Element Android, GitHub tarafından barındırılan açık kaynaklı bir projedir. Lütfen https://github.com/vector-im/element-android adresinde hataları bildirin ve/veya gelişimine katkıda bulunun. diff --git a/fastlane/metadata/android/tr-TR/short_description.txt b/fastlane/metadata/android/tr-TR/short_description.txt index f730519d33..7965a7c857 100644 --- a/fastlane/metadata/android/tr-TR/short_description.txt +++ b/fastlane/metadata/android/tr-TR/short_description.txt @@ -1 +1 @@ -Güvenli ve merkezsiz sohbet ve VoIP. Verilerinizi üçüncü taraflardan sakının. +Grup mesajlaşma - şifreli mesajlaşma, grup sohbeti ve görüntülü aramalar diff --git a/fastlane/metadata/android/tr-TR/title.txt b/fastlane/metadata/android/tr-TR/title.txt index 28088f3da4..bb365ccdd0 100644 --- a/fastlane/metadata/android/tr-TR/title.txt +++ b/fastlane/metadata/android/tr-TR/title.txt @@ -1 +1 @@ -Element (eskiden Riot.im) +Element - Güvenli Mesajlaşma 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/uk/changelogs/40103170.txt b/fastlane/metadata/android/uk/changelogs/40103170.txt new file mode 100644 index 0000000000..708af771ac --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/40103170.txt @@ -0,0 +1,2 @@ +Основні зміни цієї версії: надсилання свого розташування у будь-яку кімнату. Редагування опитувань. +Повний перелік змін: https://github.com/vector-im/element-android/releases/tag/v1.3.17 diff --git a/fastlane/metadata/android/uk/changelogs/40103180.txt b/fastlane/metadata/android/uk/changelogs/40103180.txt new file mode 100644 index 0000000000..6ffbe45571 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/40103180.txt @@ -0,0 +1,2 @@ +Основні зміни цієї версії: надсилання свого розташування у будь-яку кімнату. Редагування опитувань. +Повний перелік змін: https://github.com/vector-im/element-android/releases/tag/v1.3.18 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/fastlane/metadata/android/zh-TW/changelogs/40103170.txt b/fastlane/metadata/android/zh-TW/changelogs/40103170.txt new file mode 100644 index 0000000000..a8e353c2eb --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/40103170.txt @@ -0,0 +1,2 @@ +此版本中的主要變動:將您的位置傳送給任何聊天室。編輯投票。 +完整的變更紀錄:https://github.com/vector-im/element-android/releases/tag/v1.3.17 diff --git a/fastlane/metadata/android/zh-TW/changelogs/40103180.txt b/fastlane/metadata/android/zh-TW/changelogs/40103180.txt new file mode 100644 index 0000000000..3eabe8b7cc --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/40103180.txt @@ -0,0 +1,2 @@ +此版本中的主要變動:將您的位置傳送給任何聊天室。編輯投票。 +完整的變更紀錄:https://github.com/vector-im/element-android/releases/tag/v1.3.18 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 84% 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-ldrtl/bools.xml b/library/ui-styles/src/main/res/values-v23/dimens.xml similarity index 54% rename from library/ui-styles/src/main/res/values-ldrtl/bools.xml rename to library/ui-styles/src/main/res/values-v23/dimens.xml index 27b280985f..18b8a81a7e 100644 --- a/library/ui-styles/src/main/res/values-ldrtl/bools.xml +++ b/library/ui-styles/src/main/res/values-v23/dimens.xml @@ -1,6 +1,4 @@ - - true - + 28dp \ No newline at end of file diff --git a/library/ui-styles/src/main/res/values/bools.xml b/library/ui-styles/src/main/res/values/bools.xml index 9966999f28..93d5f925af 100644 --- a/library/ui-styles/src/main/res/values/bools.xml +++ b/library/ui-styles/src/main/res/values/bools.xml @@ -4,6 +4,4 @@ false - false - \ 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..770b001893 100644 --- a/library/ui-styles/src/main/res/values/colors.xml +++ b/library/ui-styles/src/main/res/values/colors.xml @@ -57,11 +57,6 @@ - - #FF61708B - #FF61708B - #FF61708B - @android:color/white #FF181B21 @@ -137,4 +132,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/text_appearances.xml b/library/ui-styles/src/main/res/values/text_appearances.xml index 4ad3fd493e..8e30dd00d6 100644 --- a/library/ui-styles/src/main/res/values/text_appearances.xml +++ b/library/ui-styles/src/main/res/values/text_appearances.xml @@ -59,6 +59,10 @@ sans-serif-medium + + - \ No newline at end of file + diff --git a/library/ui-styles/src/main/res/values/theme_black.xml b/library/ui-styles/src/main/res/values/theme_black.xml index c472a4fae5..44d4206d43 100644 --- a/library/ui-styles/src/main/res/values/theme_black.xml +++ b/library/ui-styles/src/main/res/values/theme_black.xml @@ -7,7 +7,6 @@ - @color/vctr_unread_room_badge_black @color/vctr_fab_label_bg_black @color/vctr_fab_label_stroke_black @color/vctr_fab_label_color_black 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..100a07f41d 100644 --- a/library/ui-styles/src/main/res/values/theme_dark.xml +++ b/library/ui-styles/src/main/res/values/theme_dark.xml @@ -16,7 +16,6 @@ @color/element_system_dark - @color/vctr_unread_room_badge_dark @color/vctr_fab_label_bg_dark @color/vctr_fab_label_stroke_dark @color/vctr_fab_label_color_dark @@ -31,6 +30,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 +106,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..39e78ee5b1 100644 --- a/library/ui-styles/src/main/res/values/theme_light.xml +++ b/library/ui-styles/src/main/res/values/theme_light.xml @@ -16,7 +16,6 @@ @color/element_system_light - @color/vctr_unread_room_badge_light @color/vctr_fab_label_bg_light @color/vctr_fab_label_stroke_light @color/vctr_fab_label_color_light @@ -31,6 +30,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 +106,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 a4102f7441..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.42' + 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/Matrix.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/Matrix.kt index 901ba75d16..5fedff53f0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/Matrix.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/Matrix.kt @@ -99,12 +99,31 @@ class Matrix private constructor(context: Context, matrixConfiguration: MatrixCo private lateinit var instance: Matrix private val isInit = AtomicBoolean(false) + /** + * Creates a new instance of Matrix, it's recommended to manage this instance as a singleton. + * To make use of the built in singleton use Matrix.initialize() and/or Matrix.getInstance(context) instead + **/ + fun createInstance(context: Context, matrixConfiguration: MatrixConfiguration): Matrix { + return Matrix(context.applicationContext, matrixConfiguration) + } + + /** + * Initializes a singleton instance of Matrix for the given MatrixConfiguration + * This instance will be returned by Matrix.getInstance(context) + */ + @Deprecated("Use Matrix.createInstance and manage the instance manually") fun initialize(context: Context, matrixConfiguration: MatrixConfiguration) { if (isInit.compareAndSet(false, true)) { instance = Matrix(context.applicationContext, matrixConfiguration) } } + /** + * Either provides an already initialized singleton Matrix instance or queries the application context for a MatrixConfiguration.Provider + * to lazily create and store the instance. + */ + @Suppress("deprecation") // suppressing warning as this method is unused but is still provided for SDK clients + @Deprecated("Use Matrix.createInstance and manage the instance manually") fun getInstance(context: Context): Matrix { if (isInit.compareAndSet(false, true)) { val appContext = context.applicationContext @@ -113,7 +132,8 @@ class Matrix private constructor(context: Context, matrixConfiguration: MatrixCo instance = Matrix(appContext, matrixConfiguration) } else { throw IllegalStateException("Matrix is not initialized properly." + - " You should call Matrix.initialize or let your application implements MatrixConfiguration.Provider.") + " If you want to manage your own Matrix instance use Matrix.createInstance" + + " otherwise you should call Matrix.initialize or let your application implement MatrixConfiguration.Provider.") } } return instance diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt index 306ed45500..c87f21d7ac 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt @@ -66,6 +66,7 @@ data class MatrixConfiguration( /** * Can be implemented by your Application class. */ + @Deprecated("Use Matrix.createInstance and manage the instance manually instead of Matrix.getInstance") interface Provider { fun providesMatrixConfiguration(): MatrixConfiguration } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/pushrules/PushRuleService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/pushrules/PushRuleService.kt index 88268f0f86..76885d8545 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/pushrules/PushRuleService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/pushrules/PushRuleService.kt @@ -50,6 +50,9 @@ interface PushRuleService { // fun fulfilledBingRule(event: Event, rules: List): PushRule? + fun resolveSenderNotificationPermissionCondition(event: Event, + condition: SenderNotificationPermissionCondition): Boolean + interface PushRuleListener { fun onEvents(pushEvents: PushEvents) } 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/ReadReceipt.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/ReadReceipt.kt index 67cb9600c8..5639730219 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/ReadReceipt.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/ReadReceipt.kt @@ -16,9 +16,7 @@ package org.matrix.android.sdk.api.session.room.model -import org.matrix.android.sdk.api.session.user.model.User - data class ReadReceipt( - val user: User, + val roomMember: RoomMemberSummary, val originServerTs: Long ) 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 c090487c58..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 @@ -64,4 +64,12 @@ data class MessageLocationContent( ) : MessageContent { 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/api/util/MatrixItem.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt index 3396c4a6c9..302f7387fa 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt @@ -35,7 +35,19 @@ sealed class MatrixItem( data class UserItem(override val id: String, override val displayName: String? = null, override val avatarUrl: String? = null) : - MatrixItem(id, displayName?.removeSuffix(ircPattern), avatarUrl) { + MatrixItem(id, displayName?.removeSuffix(IRC_PATTERN), avatarUrl) { + init { + if (BuildConfig.DEBUG) checkId() + } + + override fun updateAvatar(newAvatar: String?) = copy(avatarUrl = newAvatar) + } + + data class EveryoneInRoomItem(override val id: String, + override val displayName: String = NOTIFY_EVERYONE, + override val avatarUrl: String? = null, + val roomDisplayName: String? = null) : + MatrixItem(id, displayName, avatarUrl) { init { if (BuildConfig.DEBUG) checkId() } @@ -46,7 +58,7 @@ sealed class MatrixItem( data class EventItem(override val id: String, override val displayName: String? = null, override val avatarUrl: String? = null) : - MatrixItem(id, displayName, avatarUrl) { + MatrixItem(id, displayName, avatarUrl) { init { if (BuildConfig.DEBUG) checkId() } @@ -57,7 +69,7 @@ sealed class MatrixItem( data class RoomItem(override val id: String, override val displayName: String? = null, override val avatarUrl: String? = null) : - MatrixItem(id, displayName, avatarUrl) { + MatrixItem(id, displayName, avatarUrl) { init { if (BuildConfig.DEBUG) checkId() } @@ -68,7 +80,7 @@ sealed class MatrixItem( data class SpaceItem(override val id: String, override val displayName: String? = null, override val avatarUrl: String? = null) : - MatrixItem(id, displayName, avatarUrl) { + MatrixItem(id, displayName, avatarUrl) { init { if (BuildConfig.DEBUG) checkId() } @@ -79,7 +91,7 @@ sealed class MatrixItem( data class RoomAliasItem(override val id: String, override val displayName: String? = null, override val avatarUrl: String? = null) : - MatrixItem(id, displayName, avatarUrl) { + MatrixItem(id, displayName, avatarUrl) { init { if (BuildConfig.DEBUG) checkId() } @@ -90,7 +102,7 @@ sealed class MatrixItem( data class GroupItem(override val id: String, override val displayName: String? = null, override val avatarUrl: String? = null) : - MatrixItem(id, displayName, avatarUrl) { + MatrixItem(id, displayName, avatarUrl) { init { if (BuildConfig.DEBUG) checkId() } @@ -109,16 +121,22 @@ sealed class MatrixItem( /** * Return the prefix as defined in the matrix spec (and not extracted from the id) */ - fun getIdPrefix() = when (this) { - is UserItem -> '@' - is EventItem -> '$' + private fun getIdPrefix() = when (this) { + is UserItem -> '@' + is EventItem -> '$' is SpaceItem, - is RoomItem -> '!' - is RoomAliasItem -> '#' - is GroupItem -> '+' + is RoomItem, + is EveryoneInRoomItem -> '!' + is RoomAliasItem -> '#' + is GroupItem -> '+' } fun firstLetterOfDisplayName(): String { + val displayName = when (this) { + // use the room display name for the notify everyone item + is EveryoneInRoomItem -> roomDisplayName + else -> displayName + } return (displayName?.takeIf { it.isNotBlank() } ?: id) .let { dn -> var startIndex = 0 @@ -151,7 +169,8 @@ sealed class MatrixItem( } companion object { - private const val ircPattern = " (IRC)" + private const val IRC_PATTERN = " (IRC)" + const val NOTIFY_EVERYONE = "@room" } } @@ -171,6 +190,8 @@ fun RoomSummary.toMatrixItem() = if (roomType == RoomType.SPACE) { fun RoomSummary.toRoomAliasMatrixItem() = MatrixItem.RoomAliasItem(canonicalAlias ?: roomId, displayName, avatarUrl) +fun RoomSummary.toEveryoneInRoomMatrixItem() = MatrixItem.EveryoneInRoomItem(id = roomId, avatarUrl = avatarUrl, roomDisplayName = displayName) + // If no name is available, use room alias as Riot-Web does fun PublicRoom.toMatrixItem() = MatrixItem.RoomItem(roomId, name ?: getPrimaryAlias() ?: "", avatarUrl) 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/CryptoSessionInfoProvider.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/CryptoSessionInfoProvider.kt index 82eced4371..2a58d731e5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/CryptoSessionInfoProvider.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/CryptoSessionInfoProvider.kt @@ -35,6 +35,8 @@ internal class CryptoSessionInfoProvider @Inject constructor( ) { fun isRoomEncrypted(roomId: String): Boolean { + // We look at the presence at any m.room.encryption state event no matter if it's + // the latest one or if it is well formed val encryptionEvent = monarchy.fetchCopied { realm -> EventEntity.whereType(realm, roomId = roomId, type = EventType.STATE_ROOM_ENCRYPTION) .isEmpty(EventEntityFields.STATE_KEY) 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/IMXCryptoStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt index 82fb565377..96ea5c03fa 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt @@ -240,6 +240,14 @@ internal interface IMXCryptoStore { */ fun getRoomAlgorithm(roomId: String): String? + /** + * This is a bit different than isRoomEncrypted + * A room is encrypted when there is a m.room.encryption state event in the room (malformed/invalid or not) + * But the crypto layer has additional guaranty to ensure that encryption would never been reverted + * It's defensive coding out of precaution (if ever state is reset) + */ + fun roomWasOnceEncrypted(roomId: String): Boolean + fun shouldEncryptForInvitedMembers(roomId: String): Boolean fun setShouldEncryptForInvitedMembers(roomId: String, shouldEncryptForInvitedMembers: Boolean) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt index 33578ba06a..a07827c033 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt @@ -631,7 +631,15 @@ internal class RealmCryptoStore @Inject constructor( override fun storeRoomAlgorithm(roomId: String, algorithm: String?) { doRealmTransaction(realmConfiguration) { - CryptoRoomEntity.getOrCreate(it, roomId).algorithm = algorithm + CryptoRoomEntity.getOrCreate(it, roomId).let { entity -> + entity.algorithm = algorithm + // store anyway the new algorithm, but mark the room + // as having been encrypted once whatever, this can never + // go back to false + if (algorithm == MXCRYPTO_ALGORITHM_MEGOLM) { + entity.wasEncryptedOnce = true + } + } } } @@ -641,6 +649,12 @@ internal class RealmCryptoStore @Inject constructor( } } + override fun roomWasOnceEncrypted(roomId: String): Boolean { + return doWithRealm(realmConfiguration) { + CryptoRoomEntity.getById(it, roomId)?.wasEncryptedOnce ?: false + } + } + override fun shouldEncryptForInvitedMembers(roomId: String): Boolean { return doWithRealm(realmConfiguration) { CryptoRoomEntity.getById(it, roomId)?.shouldEncryptForInvitedMembers 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..cac6499486 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,56 @@ 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 org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo015 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 = 15L 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() + if (oldVersion < 15) MigrateCryptoTo015(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/crypto/store/db/migration/MigrateCryptoTo015.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo015.kt new file mode 100644 index 0000000000..465c18555a --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo015.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.MXCRYPTO_ALGORITHM_MEGOLM +import org.matrix.android.sdk.internal.crypto.store.db.model.CryptoRoomEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +// Version 15L adds wasEncryptedOnce field to CryptoRoomEntity +class MigrateCryptoTo015(realm: DynamicRealm) : RealmMigrator(realm, 15) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("CryptoRoomEntity") + ?.addField(CryptoRoomEntityFields.WAS_ENCRYPTED_ONCE, Boolean::class.java) + ?.setNullable(CryptoRoomEntityFields.WAS_ENCRYPTED_ONCE, true) + ?.transform { + val currentAlgorithm = it.getString(CryptoRoomEntityFields.ALGORITHM) + it.set(CryptoRoomEntityFields.WAS_ENCRYPTED_ONCE, currentAlgorithm == MXCRYPTO_ALGORITHM_MEGOLM) + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/CryptoRoomEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/CryptoRoomEntity.kt index 711b698464..6167314b5a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/CryptoRoomEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/CryptoRoomEntity.kt @@ -27,7 +27,10 @@ internal open class CryptoRoomEntity( // Store the current outbound session for this room, // to avoid re-create and re-share at each startup (if rotation not needed..) // This is specific to megolm but not sure how to model it better - var outboundSessionInfo: OutboundGroupSessionInfoEntity? = null + var outboundSessionInfo: OutboundGroupSessionInfoEntity? = null, + // a security to ensure that a room will never revert to not encrypted + // even if a new state event with empty encryption, or state is reset somehow + var wasEncryptedOnce: Boolean? = false ) : RealmObject() { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/AsyncTransaction.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/AsyncTransaction.kt index d5a96f5ba1..ebc9bcce5a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/AsyncTransaction.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/AsyncTransaction.kt @@ -19,11 +19,9 @@ import com.zhuinden.monarchy.Monarchy import io.realm.Realm import io.realm.RealmConfiguration import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import timber.log.Timber @@ -37,30 +35,26 @@ internal fun CoroutineScope.asyncTransaction(realmConfiguration: RealmConfig } } -private val realmSemaphore = Semaphore(1) - suspend fun awaitTransaction(config: RealmConfiguration, transaction: suspend (realm: Realm) -> T): T { - return realmSemaphore.withPermit { - withContext(Dispatchers.IO) { - Realm.getInstance(config).use { bgRealm -> - bgRealm.beginTransaction() - val result: T - try { - val start = System.currentTimeMillis() - result = transaction(bgRealm) - if (isActive) { - bgRealm.commitTransaction() - val end = System.currentTimeMillis() - val time = end - start - Timber.v("Execute transaction in $time millis") - } - } finally { - if (bgRealm.isInTransaction) { - bgRealm.cancelTransaction() - } + return withContext(Realm.WRITE_EXECUTOR.asCoroutineDispatcher()) { + Realm.getInstance(config).use { bgRealm -> + bgRealm.beginTransaction() + val result: T + try { + val start = System.currentTimeMillis() + result = transaction(bgRealm) + if (isActive) { + bgRealm.commitTransaction() + val end = System.currentTimeMillis() + val time = end - start + Timber.v("Execute transaction in $time millis") + } + } finally { + if (bgRealm.isInTransaction) { + bgRealm.cancelTransaction() } - result } + result } } } 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 01576c3d61..12e60da114 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 = 22L - } - /** * Forces all RealmSessionStoreMigration instances to be equal * Avoids Realm throwing when multiple instances of the migration are set @@ -66,400 +57,35 @@ internal class RealmSessionStoreMigration @Inject constructor( override fun equals(other: Any?) = other is RealmSessionStoreMigration override fun hashCode() = 1000 + val schemaVersion = 25L + 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) - if (oldVersion <= 21) migrateTo22(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) - } - } - } - - private fun migrateTo22(realm: DynamicRealm) { - Timber.d("Step 21 -> 22") - 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() - } + 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/mapper/ReadReceiptsSummaryMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ReadReceiptsSummaryMapper.kt index 5413dd3d71..f3770e4afe 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ReadReceiptsSummaryMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ReadReceiptsSummaryMapper.kt @@ -19,23 +19,26 @@ package org.matrix.android.sdk.internal.database.mapper import org.matrix.android.sdk.api.session.room.model.ReadReceipt import org.matrix.android.sdk.internal.database.RealmSessionProvider import org.matrix.android.sdk.internal.database.model.ReadReceiptsSummaryEntity -import org.matrix.android.sdk.internal.database.model.UserEntity +import org.matrix.android.sdk.internal.database.model.RoomMemberSummaryEntity import org.matrix.android.sdk.internal.database.query.where import javax.inject.Inject -internal class ReadReceiptsSummaryMapper @Inject constructor(private val realmSessionProvider: RealmSessionProvider) { +internal class ReadReceiptsSummaryMapper @Inject constructor( + private val realmSessionProvider: RealmSessionProvider +) { fun map(readReceiptsSummaryEntity: ReadReceiptsSummaryEntity?): List { if (readReceiptsSummaryEntity == null) { return emptyList() } + val readReceipts = readReceiptsSummaryEntity.readReceipts + return realmSessionProvider.withRealm { realm -> - val readReceipts = readReceiptsSummaryEntity.readReceipts readReceipts .mapNotNull { - val user = UserEntity.where(realm, it.userId).findFirst() + val roomMember = RoomMemberSummaryEntity.where(realm, roomId = it.roomId, userId = it.userId).findFirst() ?: return@mapNotNull null - ReadReceipt(user.asDomain(), it.originServerTs.toLong()) + ReadReceipt(roomMember.asDomain(), it.originServerTs.toLong()) } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/TimelineEventMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/TimelineEventMapper.kt index f3bea68c26..55c7f2a8ee 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/TimelineEventMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/TimelineEventMapper.kt @@ -48,7 +48,7 @@ internal class TimelineEventMapper @Inject constructor(private val readReceiptsS ), readReceipts = readReceipts ?.distinctBy { - it.user + it.roomMember }?.sortedByDescending { it.originServerTs }.orEmpty() 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/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/DefaultPushRuleService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/DefaultPushRuleService.kt index 3e821b8956..cdc7350f8b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/DefaultPushRuleService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/DefaultPushRuleService.kt @@ -19,11 +19,13 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations import com.zhuinden.monarchy.Monarchy import org.matrix.android.sdk.api.pushrules.Action +import org.matrix.android.sdk.api.pushrules.ConditionResolver import org.matrix.android.sdk.api.pushrules.PushEvents import org.matrix.android.sdk.api.pushrules.PushRuleService import org.matrix.android.sdk.api.pushrules.RuleKind import org.matrix.android.sdk.api.pushrules.RuleScope import org.matrix.android.sdk.api.pushrules.RuleSetKey +import org.matrix.android.sdk.api.pushrules.SenderNotificationPermissionCondition import org.matrix.android.sdk.api.pushrules.getActions import org.matrix.android.sdk.api.pushrules.rest.PushRule import org.matrix.android.sdk.api.pushrules.rest.RuleSet @@ -53,6 +55,7 @@ internal class DefaultPushRuleService @Inject constructor( private val removePushRuleTask: RemovePushRuleTask, private val pushRuleFinder: PushRuleFinder, private val taskExecutor: TaskExecutor, + private val conditionResolver: ConditionResolver, @SessionDatabase private val monarchy: Monarchy ) : PushRuleService { @@ -143,6 +146,10 @@ internal class DefaultPushRuleService @Inject constructor( return pushRuleFinder.fulfilledBingRule(event, rules)?.getActions().orEmpty() } + override fun resolveSenderNotificationPermissionCondition(event: Event, condition: SenderNotificationPermissionCondition): Boolean { + return conditionResolver.resolveSenderNotificationPermissionCondition(event, condition) + } + override fun getKeywords(): LiveData> { // Keywords are all content rules that don't start with '.' val liveData = monarchy.findAllMappedWithChanges( 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/create/CreateRoomTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomTask.kt index ac6e0562b0..9bd15a0267 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomTask.kt @@ -17,7 +17,6 @@ package org.matrix.android.sdk.internal.session.room.create import com.zhuinden.monarchy.Monarchy -import io.realm.Realm import io.realm.RealmConfiguration import kotlinx.coroutines.TimeoutCancellationException import org.matrix.android.sdk.api.failure.Failure @@ -28,6 +27,7 @@ import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams import org.matrix.android.sdk.api.session.room.model.create.CreateRoomPreset import org.matrix.android.sdk.internal.database.awaitNotEmptyResult +import org.matrix.android.sdk.internal.database.awaitTransaction import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields import org.matrix.android.sdk.internal.database.query.where @@ -105,7 +105,7 @@ internal class DefaultCreateRoomTask @Inject constructor( throw CreateRoomFailure.CreatedWithTimeout(roomId) } - Realm.getInstance(realmConfiguration).executeTransactionAsync { + awaitTransaction(realmConfiguration) { RoomSummaryEntity.where(it, roomId).findFirst()?.lastActivityTime = System.currentTimeMillis() } 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/membership/joining/JoinRoomTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/joining/JoinRoomTask.kt index 82fea237db..22a46b6cfc 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/joining/JoinRoomTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/joining/JoinRoomTask.kt @@ -16,7 +16,6 @@ package org.matrix.android.sdk.internal.session.room.membership.joining -import io.realm.Realm import io.realm.RealmConfiguration import kotlinx.coroutines.TimeoutCancellationException import org.matrix.android.sdk.api.session.events.model.toContent @@ -24,6 +23,7 @@ import org.matrix.android.sdk.api.session.room.failure.JoinRoomFailure 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.internal.database.awaitNotEmptyResult +import org.matrix.android.sdk.internal.database.awaitTransaction import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields import org.matrix.android.sdk.internal.database.query.where @@ -89,11 +89,9 @@ internal class DefaultJoinRoomTask @Inject constructor( } catch (exception: TimeoutCancellationException) { throw JoinRoomFailure.JoinedWithTimeout } - - Realm.getInstance(realmConfiguration).executeTransactionAsync { + awaitTransaction(realmConfiguration) { RoomSummaryEntity.where(it, roomId).findFirst()?.lastActivityTime = System.currentTimeMillis() } - setReadMarkers(roomId) } 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..848e14ff57 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 @@ -31,17 +30,15 @@ import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.api.util.NoOpCancellable import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.toOptional -import org.matrix.android.sdk.internal.crypto.CryptoSessionInfoProvider import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity 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 @@ -50,13 +47,12 @@ internal class DefaultRelationService @AssistedInject constructor( private val eventEditor: EventEditor, private val eventSenderProcessor: EventSenderProcessor, private val eventFactory: LocalEchoEventFactory, - 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 +74,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,12 +127,24 @@ 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 - return eventSenderProcessor.postEvent(event, cryptoSessionInfoProvider.isRoomEncrypted(roomId)) + return eventSenderProcessor.postEvent(event) } override fun getEventAnnotationsSummary(eventId: String): EventAnnotationsSummary? { @@ -166,6 +166,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) + } + + 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..b54cd71e50 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 @@ -23,7 +23,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.util.Cancelable import org.matrix.android.sdk.api.util.NoOpCancellable -import org.matrix.android.sdk.internal.crypto.CryptoSessionInfoProvider import org.matrix.android.sdk.internal.database.mapper.toEntity import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository @@ -33,7 +32,6 @@ import javax.inject.Inject internal class EventEditor @Inject constructor(private val eventSenderProcessor: EventSenderProcessor, private val eventFactory: LocalEchoEventFactory, - private val cryptoSessionInfoProvider: CryptoSessionInfoProvider, private val localEchoRepository: LocalEchoRepository) { fun editTextMessage(targetEvent: TimelineEvent, @@ -51,7 +49,7 @@ internal class EventEditor @Inject constructor(private val eventSenderProcessor: } else if (targetEvent.root.sendState.isSent()) { val event = eventFactory .createReplaceTextEvent(roomId, targetEvent.eventId, newBodyText, newBodyAutoMarkdown, msgType, compatibilityBodyText) - return sendReplaceEvent(roomId, event) + return sendReplaceEvent(event) } else { // Should we throw? Timber.w("Can't edit a sending event") @@ -72,7 +70,7 @@ internal class EventEditor @Inject constructor(private val eventSenderProcessor: } else if (targetEvent.root.sendState.isSent()) { val event = eventFactory .createPollReplaceEvent(roomId, pollType, targetEvent.eventId, question, options) - return sendReplaceEvent(roomId, event) + return sendReplaceEvent(event) } else { Timber.w("Can't edit a sending event") return NoOpCancellable @@ -82,12 +80,12 @@ internal class EventEditor @Inject constructor(private val eventSenderProcessor: private fun sendFailedEvent(targetEvent: TimelineEvent, editedEvent: Event): Cancelable { val roomId = targetEvent.roomId updateFailedEchoWithEvent(roomId, targetEvent.eventId, editedEvent) - return eventSenderProcessor.postEvent(editedEvent, cryptoSessionInfoProvider.isRoomEncrypted(roomId)) + return eventSenderProcessor.postEvent(editedEvent) } - private fun sendReplaceEvent(roomId: String, editedEvent: Event): Cancelable { + private fun sendReplaceEvent(editedEvent: Event): Cancelable { localEchoRepository.createLocalEcho(editedEvent) - return eventSenderProcessor.postEvent(editedEvent, cryptoSessionInfoProvider.isRoomEncrypted(roomId)) + return eventSenderProcessor.postEvent(editedEvent) } fun editReply(replyToEdit: TimelineEvent, @@ -97,11 +95,17 @@ 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) - return eventSenderProcessor.postEvent(editedEvent, cryptoSessionInfoProvider.isRoomEncrypted(roomId)) + return eventSenderProcessor.postEvent(editedEvent) } else if (replyToEdit.root.sendState.isSent()) { val event = eventFactory.createReplaceTextOfReply( roomId, @@ -113,7 +117,7 @@ internal class EventEditor @Inject constructor(private val eventSenderProcessor: compatibilityBodyText ) .also { localEchoRepository.createLocalEcho(it) } - return eventSenderProcessor.postEvent(event, cryptoSessionInfoProvider.isRoomEncrypted(roomId)) + return eventSenderProcessor.postEvent(event) } else { // Should we throw? Timber.w("Can't edit a sending event") 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..28c17f38b6 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 @@ -46,7 +46,7 @@ import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.api.util.CancelableBag import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.api.util.NoOpCancellable -import org.matrix.android.sdk.internal.crypto.CryptoSessionInfoProvider +import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore import org.matrix.android.sdk.internal.di.SessionId import org.matrix.android.sdk.internal.di.WorkManagerProvider import org.matrix.android.sdk.internal.session.content.UploadContentWorker @@ -66,7 +66,7 @@ internal class DefaultSendService @AssistedInject constructor( private val workManagerProvider: WorkManagerProvider, @SessionId private val sessionId: String, private val localEchoEventFactory: LocalEchoEventFactory, - private val cryptoSessionInfoProvider: CryptoSessionInfoProvider, + private val cryptoStore: IMXCryptoStore, private val taskExecutor: TaskExecutor, private val localEchoRepository: LocalEchoRepository, private val eventSenderProcessor: EventSenderProcessor, @@ -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) } } @@ -282,7 +303,7 @@ internal class DefaultSendService @AssistedInject constructor( private fun internalSendMedia(allLocalEchoes: List, attachment: ContentAttachmentData, compressBeforeSending: Boolean): Cancelable { val cancelableBag = CancelableBag() - allLocalEchoes.groupBy { cryptoSessionInfoProvider.isRoomEncrypted(it.roomId!!) } + allLocalEchoes.groupBy { cryptoStore.roomWasOnceEncrypted(it.roomId!!) } .apply { keys.forEach { isRoomEncrypted -> // Should never be empty @@ -313,7 +334,7 @@ internal class DefaultSendService @AssistedInject constructor( } private fun sendEvent(event: Event): Cancelable { - return eventSenderProcessor.postEvent(event, cryptoSessionInfoProvider.isRoomEncrypted(event.roomId!!)) + return eventSenderProcessor.postEvent(event) } private fun createLocalEcho(event: 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/send/pills/TextPillsUtils.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt index 33cb0db243..ccbfbfcded 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.session.room.send.pills import android.text.SpannableString import org.matrix.android.sdk.api.session.room.send.MatrixItemSpan +import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.internal.session.displayname.DisplayNameResolver import java.util.Collections import javax.inject.Inject @@ -51,6 +52,8 @@ internal class TextPillsUtils @Inject constructor( val pills = spannableString ?.getSpans(0, text.length, MatrixItemSpan::class.java) ?.map { MentionLinkSpec(it, spannableString.getSpanStart(it), spannableString.getSpanEnd(it)) } + // we use the raw text for @room notification instead of a link + ?.filterNot { it.span.matrixItem is MatrixItem.EveryoneInRoomItem } ?.toMutableList() ?.takeIf { it.isNotEmpty() } ?: return null diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessorCoroutine.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessorCoroutine.kt index eb69161614..5b4efa5df6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessorCoroutine.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessorCoroutine.kt @@ -26,9 +26,9 @@ import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.getRetryDelay import org.matrix.android.sdk.api.failure.isLimitExceededError import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.session.crypto.CryptoService import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.util.Cancelable +import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore import org.matrix.android.sdk.internal.session.SessionScope import org.matrix.android.sdk.internal.task.CoroutineSequencer import org.matrix.android.sdk.internal.task.SemaphoreCoroutineSequencer @@ -54,7 +54,7 @@ private const val MAX_RETRY_COUNT = 3 */ @SessionScope internal class EventSenderProcessorCoroutine @Inject constructor( - private val cryptoService: CryptoService, + private val cryptoStore: IMXCryptoStore, private val sessionParams: SessionParams, private val queuedTaskFactory: QueuedTaskFactory, private val taskExecutor: TaskExecutor, @@ -92,7 +92,8 @@ internal class EventSenderProcessorCoroutine @Inject constructor( } override fun postEvent(event: Event): Cancelable { - return postEvent(event, event.roomId?.let { cryptoService.isRoomEncrypted(it) } ?: false) + val shouldEncrypt = event.roomId?.let { cryptoStore.roomWasOnceEncrypted(it) } ?: false + return postEvent(event, shouldEncrypt) } override fun postEvent(event: Event, encrypt: Boolean): Cancelable { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt index a7887d77f8..1c1d59fb3d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt @@ -119,9 +119,8 @@ internal class RoomSummaryUpdater @Inject constructor( roomSummaryEntity.roomType = roomType Timber.v("## Space: Updating summary room [$roomId] roomType: [$roomType]") - // Don't use current state for this one as we are only interested in having MXCRYPTO_ALGORITHM_MEGOLM event in the room val encryptionEvent = CurrentStateEventEntity.getOrNull(realm, roomId, type = EventType.STATE_ROOM_ENCRYPTION, stateKey = "")?.root - Timber.v("## CRYPTO: currentEncryptionEvent is $encryptionEvent") + Timber.d("## CRYPTO: currentEncryptionEvent is $encryptionEvent") val latestPreviewableEvent = RoomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt 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..c0dc31fcf8 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) { @@ -88,11 +90,10 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, private val timelineEventsChangeListener = OrderedRealmCollectionChangeListener { results: RealmResults, changeSet: OrderedCollectionChangeSet -> Timber.v("on timeline events chunk update") - val frozenResults = results.freeze() - handleDatabaseChangeSet(frozenResults, changeSet) + handleDatabaseChangeSet(results, 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 +138,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 +193,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 +283,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 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 +317,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 +353,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 +384,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 +394,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) { @@ -385,10 +427,10 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, * This method is responsible for managing insertions and updates of events on this chunk. * */ - private fun handleDatabaseChangeSet(frozenResults: RealmResults, changeSet: OrderedCollectionChangeSet) { + private fun handleDatabaseChangeSet(results: RealmResults, changeSet: OrderedCollectionChangeSet) { val insertions = changeSet.insertionRanges for (range in insertions) { - val newItems = frozenResults + val newItems = results .subList(range.startIndex, range.startIndex + range.length) .map { it.buildAndDecryptIfNeeded() } builtEventsIndexes.entries.filter { it.value >= range.startIndex }.forEach { it.setValue(it.value + range.length) } @@ -404,7 +446,7 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, val modifications = changeSet.changeRanges for (range in modifications) { for (modificationIndex in (range.startIndex until range.startIndex + range.length)) { - val updatedEntity = frozenResults[modificationIndex] ?: continue + val updatedEntity = results[modificationIndex] ?: continue try { builtEvents[modificationIndex] = updatedEntity.buildAndDecryptIfNeeded() } catch (failure: Throwable) { @@ -418,17 +460,16 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, } private fun getNextDisplayIndex(direction: Timeline.Direction): Int? { - val frozenTimelineEvents = timelineEventEntities.freeze() - if (frozenTimelineEvents.isEmpty()) { + if (timelineEventEntities.isEmpty()) { return null } return if (builtEvents.isEmpty()) { if (initialEventId != null) { - frozenTimelineEvents.where().equalTo(TimelineEventEntityFields.EVENT_ID, initialEventId).findFirst()?.displayIndex + timelineEventEntities.where().equalTo(TimelineEventEntityFields.EVENT_ID, initialEventId).findFirst()?.displayIndex } else if (direction == Timeline.Direction.BACKWARDS) { - frozenTimelineEvents.first(null)?.displayIndex + timelineEventEntities.first(null)?.displayIndex } else { - frozenTimelineEvents.last(null)?.displayIndex + timelineEventEntities.last(null)?.displayIndex } } else if (direction == Timeline.Direction.FORWARDS) { builtEvents.first().displayIndex + 1 @@ -450,10 +491,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 +521,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 170e8b734b..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) { @@ -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/session/sync/job/SyncService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncService.kt index 0ecf91f6fa..97ae9b3a68 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncService.kt @@ -192,12 +192,14 @@ abstract class SyncService : Service() { } } + abstract fun provideMatrix(): Matrix + private fun initialize(intent: Intent?): Boolean { if (intent == null) { Timber.d("## Sync: initialize intent is null") return false } - val matrix = Matrix.getInstance(applicationContext) + val matrix = provideMatrix() val safeSessionId = intent.getStringExtra(EXTRA_SESSION_ID) ?: return false syncTimeoutSeconds = intent.getIntExtra(EXTRA_TIMEOUT_SECONDS, getDefaultSyncTimeoutSeconds()) syncDelaySeconds = intent.getIntExtra(EXTRA_DELAY_SECONDS, getDefaultSyncDelaySeconds()) 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 8792f526d4..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.42' + 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/SecurityBootstrapTest.kt b/vector/src/androidTest/java/im/vector/app/SecurityBootstrapTest.kt index fb7b9dcb41..69fe63fb7b 100644 --- a/vector/src/androidTest/java/im/vector/app/SecurityBootstrapTest.kt +++ b/vector/src/androidTest/java/im/vector/app/SecurityBootstrapTest.kt @@ -38,7 +38,7 @@ import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest -import androidx.test.platform.app.InstrumentationRegistry +import im.vector.app.core.utils.getMatrixInstance import im.vector.app.features.MainActivity import im.vector.app.features.crypto.recover.SetupMode import im.vector.app.features.home.HomeActivity @@ -47,7 +47,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.matrix.android.sdk.api.Matrix import org.matrix.android.sdk.api.session.Session @RunWith(AndroidJUnit4::class) @@ -61,8 +60,7 @@ class SecurityBootstrapTest : VerificationTestBase() { @Before fun createSessionWithCrossSigning() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val matrix = Matrix.getInstance(context) + val matrix = getMatrixInstance() val userName = "foobar_${System.currentTimeMillis()}" existingSession = createAccountAndSync(matrix, userName, password, true) stubAllExternalIntents() diff --git a/vector/src/androidTest/java/im/vector/app/VerifySessionInteractiveTest.kt b/vector/src/androidTest/java/im/vector/app/VerifySessionInteractiveTest.kt index 982a421425..c82b543a08 100644 --- a/vector/src/androidTest/java/im/vector/app/VerifySessionInteractiveTest.kt +++ b/vector/src/androidTest/java/im/vector/app/VerifySessionInteractiveTest.kt @@ -33,7 +33,7 @@ import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest -import androidx.test.platform.app.InstrumentationRegistry +import im.vector.app.core.utils.getMatrixInstance import im.vector.app.features.MainActivity import im.vector.app.features.home.HomeActivity import org.hamcrest.CoreMatchers.not @@ -41,7 +41,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.matrix.android.sdk.api.Matrix import org.matrix.android.sdk.api.auth.UIABaseAuth import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor import org.matrix.android.sdk.api.auth.UserPasswordAuth @@ -66,8 +65,7 @@ class VerifySessionInteractiveTest : VerificationTestBase() { @Before fun createSessionWithCrossSigning() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val matrix = Matrix.getInstance(context) + val matrix = getMatrixInstance() val userName = "foobar_${System.currentTimeMillis()}" existingSession = createAccountAndSync(matrix, userName, password, true) doSync { diff --git a/vector/src/androidTest/java/im/vector/app/VerifySessionPassphraseTest.kt b/vector/src/androidTest/java/im/vector/app/VerifySessionPassphraseTest.kt index c51ff29669..80d8315a0e 100644 --- a/vector/src/androidTest/java/im/vector/app/VerifySessionPassphraseTest.kt +++ b/vector/src/androidTest/java/im/vector/app/VerifySessionPassphraseTest.kt @@ -34,6 +34,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry import im.vector.app.core.resources.StringProvider +import im.vector.app.core.utils.getMatrixInstance import im.vector.app.features.MainActivity import im.vector.app.features.crypto.quads.SharedSecureStorageActivity import im.vector.app.features.crypto.recover.BootstrapCrossSigningTask @@ -45,7 +46,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.matrix.android.sdk.api.Matrix import org.matrix.android.sdk.api.auth.UIABaseAuth import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor import org.matrix.android.sdk.api.auth.UserPasswordAuth @@ -67,7 +67,7 @@ class VerifySessionPassphraseTest : VerificationTestBase() { @Before fun createSessionWithCrossSigningAnd4S() { val context = InstrumentationRegistry.getInstrumentation().targetContext - val matrix = Matrix.getInstance(context) + val matrix = getMatrixInstance() val userName = "foobar_${System.currentTimeMillis()}" existingSession = createAccountAndSync(matrix, userName, password, true) doSync { @@ -90,7 +90,7 @@ class VerifySessionPassphraseTest : VerificationTestBase() { runBlocking { task.execute(Params( - userInteractiveAuthInterceptor = object : UserInteractiveAuthInterceptor { + userInteractiveAuthInterceptor = object : UserInteractiveAuthInterceptor { override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation) { promise.resume( UserPasswordAuth( diff --git a/vector/src/androidTest/java/im/vector/app/core/utils/TestMatrixHelper.kt b/vector/src/androidTest/java/im/vector/app/core/utils/TestMatrixHelper.kt new file mode 100644 index 0000000000..322f5fa23d --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/core/utils/TestMatrixHelper.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.core.utils + +import androidx.test.platform.app.InstrumentationRegistry +import im.vector.app.features.room.VectorRoomDisplayNameFallbackProvider +import org.matrix.android.sdk.api.Matrix +import org.matrix.android.sdk.api.MatrixConfiguration + +fun getMatrixInstance(): Matrix { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val configuration = MatrixConfiguration( + roomDisplayNameFallbackProvider = VectorRoomDisplayNameFallbackProvider(context) + ) + return Matrix.createInstance(context, configuration) +} diff --git a/vector/src/androidTest/java/im/vector/app/espresso/tools/ScreenshotFailureRule.kt b/vector/src/androidTest/java/im/vector/app/espresso/tools/ScreenshotFailureRule.kt index 2e329ebb6b..2939dcf4e0 100644 --- a/vector/src/androidTest/java/im/vector/app/espresso/tools/ScreenshotFailureRule.kt +++ b/vector/src/androidTest/java/im/vector/app/espresso/tools/ScreenshotFailureRule.kt @@ -40,7 +40,7 @@ private val deviceLanguage = Locale.getDefault().language class ScreenshotFailureRule : TestWatcher() { override fun failed(e: Throwable?, description: Description) { - val screenShotName = "$deviceLanguage-${description.methodName}-${SimpleDateFormat("EEE-MMMM-dd-HH:mm:ss").format(Date())}" + val screenShotName = "$deviceLanguage-${description.methodName}-${SimpleDateFormat("EEE-MMMM-dd-HHmmss").format(Date())}" val bitmap = getInstrumentation().uiAutomation.takeScreenshot() storeFailureScreenshot(bitmap, screenShotName) } 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 d625cf0390..417d28d625 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt @@ -16,10 +16,12 @@ package im.vector.app.ui +import android.Manifest 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 androidx.test.rule.GrantPermissionRule import im.vector.app.R import im.vector.app.espresso.tools.ScreenshotFailureRule import im.vector.app.features.MainActivity @@ -43,6 +45,7 @@ class UiAllScreensSanityTest { @get:Rule val testRule = RuleChain .outerRule(ActivityScenarioRule(MainActivity::class.java)) + .around(GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE)) .around(ScreenshotFailureRule()) private val elementRobot = ElementRobot() @@ -94,6 +97,30 @@ class UiAllScreensSanityTest { } } + elementRobot.space { + createSpace { + crawl() + } + val spaceName = UUID.randomUUID().toString() + createSpace { + createPublicSpace(spaceName) + } + + spaceMenu(spaceName) { + spaceMembers() + spaceSettings { + crawl() + } + exploreRooms() + + invitePeople().also { openMenu(spaceName) } + addRoom().also { openMenu(spaceName) } + addSpace().also { openMenu(spaceName) } + + leaveSpace() + } + } + elementRobot.withDeveloperMode { settings { advancedSettings { crawlDeveloperOptions() } 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 d7e99c63dd..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 @@ -35,6 +35,7 @@ import im.vector.app.features.home.HomeActivity import im.vector.app.features.onboarding.OnboardingActivity import im.vector.app.initialSyncIdlingResource import im.vector.app.ui.robot.settings.SettingsRobot +import im.vector.app.ui.robot.space.SpaceRobot import im.vector.app.withIdlingResource import timber.log.Timber @@ -145,7 +146,11 @@ 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) { + block(SpaceRobot()) } } 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 da57b105d7..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 @@ -60,8 +60,6 @@ class RoomDetailRobot { pressBack() clickMenu(R.id.video_call) pressBack() - clickMenu(R.id.search) - pressBack() } fun crawlMessage(message: String) { 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 new file mode 100644 index 0000000000..68e5fa5059 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceCreateRobot.kt @@ -0,0 +1,97 @@ +/* + * 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.ui.robot.space + +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.click +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.clickOn +import im.vector.app.R +import im.vector.app.espresso.tools.waitUntilActivityVisible +import im.vector.app.espresso.tools.waitUntilDialogVisible +import im.vector.app.espresso.tools.waitUntilViewVisible +import im.vector.app.features.home.HomeActivity +import im.vector.app.features.spaces.manage.SpaceManageActivity +import java.util.UUID + +class SpaceCreateRobot { + + fun crawl() { + // public + clickOn(R.id.publicButton) + waitUntilViewVisible(withId(R.id.recyclerView)) + onView(ViewMatchers.withHint(R.string.create_room_name_hint)).perform(ViewActions.replaceText(UUID.randomUUID().toString())) + clickOn(R.id.nextButton) + waitUntilViewVisible(withId(R.id.recyclerView)) + pressBack() + pressBack() + + // private + clickOn(R.id.privateButton) + waitUntilViewVisible(withId(R.id.recyclerView)) + clickOn(R.id.nextButton) + + waitUntilViewVisible(withId(R.id.teammatesButton)) + // me and teammates + clickOn(R.id.teammatesButton) + waitUntilViewVisible(withId(R.id.recyclerView)) + clickOn(R.id.nextButton) + pressBack() + pressBack() + + // just me + waitUntilViewVisible(withId(R.id.justMeButton)) + clickOn(R.id.justMeButton) + waitUntilActivityVisible { + waitUntilViewVisible(withId(R.id.roomList)) + } + + onView(withId(R.id.roomList)) + .perform( + RecyclerViewActions.actionOnItem( + ViewMatchers.hasDescendant(withText(R.string.room_displayname_empty_room)), + click() + ).atPosition(0) + ) + clickOn(R.id.spaceAddRoomSaveItem) + waitUntilActivityVisible { + waitUntilViewVisible(withId(R.id.roomListContainer)) + } + } + + fun createPublicSpace(spaceName: String) { + clickOn(R.id.publicButton) + waitUntilViewVisible(withId(R.id.recyclerView)) + onView(ViewMatchers.withHint(R.string.create_room_name_hint)).perform(ViewActions.replaceText(spaceName)) + clickOn(R.id.nextButton) + waitUntilViewVisible(withId(R.id.recyclerView)) + clickOn(R.id.nextButton) + 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 new file mode 100644 index 0000000000..431df396d0 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt @@ -0,0 +1,114 @@ +/* + * 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.ui.robot.space + +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers +import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn +import com.adevinta.android.barista.internal.viewaction.ClickChildAction +import im.vector.app.R +import im.vector.app.espresso.tools.waitUntilActivityVisible +import im.vector.app.espresso.tools.waitUntilDialogVisible +import im.vector.app.espresso.tools.waitUntilViewVisible +import im.vector.app.features.invite.InviteUsersToRoomActivity +import im.vector.app.features.roomprofile.RoomProfileActivity +import im.vector.app.features.spaces.SpaceExploreActivity +import im.vector.app.features.spaces.leave.SpaceLeaveAdvancedActivity +import im.vector.app.features.spaces.manage.SpaceManageActivity +import org.hamcrest.Matchers + +class SpaceMenuRobot { + + fun openMenu(spaceName: String) { + waitUntilViewVisible(ViewMatchers.withId(R.id.groupListView)) + onView(ViewMatchers.withId(R.id.groupListView)) + .perform( + RecyclerViewActions.actionOnItem( + ViewMatchers.hasDescendant(Matchers.allOf(ViewMatchers.withId(R.id.groupNameView), ViewMatchers.withText(spaceName))), + ClickChildAction.clickChildWithId(R.id.groupTmpLeave) + ).atPosition(0) + ) + waitUntilDialogVisible(ViewMatchers.withId(R.id.spaceNameView)) + } + + fun invitePeople() = apply { + clickOn(R.id.invitePeople) + waitUntilDialogVisible(ViewMatchers.withId(R.id.inviteByMxidButton)) + clickOn(R.id.inviteByMxidButton) + waitUntilActivityVisible { + waitUntilViewVisible(ViewMatchers.withId(R.id.userListRecyclerView)) + } + // close keyboard + Espresso.pressBack() + // close invite view + Espresso.pressBack() + } + + fun spaceMembers() { + clickOn(R.id.showMemberList) + waitUntilActivityVisible { + waitUntilViewVisible(ViewMatchers.withId(R.id.roomSettingsRecyclerView)) + } + Espresso.pressBack() + } + + fun spaceSettings(block: SpaceSettingsRobot.() -> Unit) { + clickOn(R.id.spaceSettings) + waitUntilActivityVisible { + waitUntilViewVisible(ViewMatchers.withId(R.id.roomSettingsRecyclerView)) + } + block(SpaceSettingsRobot()) + } + + fun exploreRooms() { + clickOn(R.id.exploreRooms) + waitUntilActivityVisible { + waitUntilViewVisible(ViewMatchers.withId(R.id.spaceDirectoryList)) + } + Espresso.pressBack() + } + + fun addRoom() = apply { + clickOn(R.id.addRooms) + waitUntilActivityVisible { + waitUntilViewVisible(ViewMatchers.withId(R.id.roomList)) + } + Espresso.pressBack() + } + + fun addSpace() = apply { + clickOn(R.id.addSpaces) + waitUntilActivityVisible { + waitUntilViewVisible(ViewMatchers.withId(R.id.roomList)) + } + Espresso.pressBack() + } + + fun leaveSpace() { + clickOn(R.id.leaveSpace) + waitUntilDialogVisible(ViewMatchers.withId(R.id.leaveButton)) + clickOn(R.id.leave_selected) + waitUntilActivityVisible { + waitUntilViewVisible(ViewMatchers.withId(R.id.roomList)) + } + clickOn(R.id.spaceLeaveButton) + waitUntilViewVisible(ViewMatchers.withId(R.id.groupListView)) + } +} diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceRobot.kt new file mode 100644 index 0000000000..ffb3c24051 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceRobot.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.ui.robot.space + +import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn +import com.adevinta.android.barista.interaction.BaristaDrawerInteractions.openDrawer +import im.vector.app.R + +class SpaceRobot { + + fun createSpace(block: SpaceCreateRobot.() -> Unit) { + openDrawer() + clickOn(R.string.add_space) + block(SpaceCreateRobot()) + } + + fun spaceMenu(spaceName: String, block: SpaceMenuRobot.() -> Unit) { + openDrawer() + with(SpaceMenuRobot()) { + openMenu(spaceName) + block() + } + } +} diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceSettingsRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceSettingsRobot.kt new file mode 100644 index 0000000000..dcd003da98 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceSettingsRobot.kt @@ -0,0 +1,68 @@ +/* + * 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.ui.robot.space + +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers +import im.vector.app.R +import im.vector.app.espresso.tools.waitUntilActivityVisible +import im.vector.app.espresso.tools.waitUntilViewVisible +import im.vector.app.features.roomprofile.settings.joinrule.RoomJoinRuleActivity + +class SpaceSettingsRobot { + fun crawl() { + Espresso.onView(ViewMatchers.withId(R.id.roomSettingsRecyclerView)) + .perform( + RecyclerViewActions.actionOnItem( + ViewMatchers.hasDescendant(ViewMatchers.withText(R.string.room_settings_space_access_title)), + ViewActions.click() + ) + ) + + waitUntilActivityVisible { + waitUntilViewVisible(ViewMatchers.withId(R.id.genericRecyclerView)) + } + + Espresso.pressBack() + + Espresso.onView(ViewMatchers.withId(R.id.roomSettingsRecyclerView)) + .perform( + RecyclerViewActions.actionOnItem( + ViewMatchers.hasDescendant(ViewMatchers.withText(R.string.space_settings_manage_rooms)), + ViewActions.click() + ) + ) + + waitUntilViewVisible(ViewMatchers.withId(R.id.roomList)) + Espresso.pressBack() + + Espresso.onView(ViewMatchers.withId(R.id.roomSettingsRecyclerView)) + .perform( + RecyclerViewActions.actionOnItem( + ViewMatchers.hasDescendant(ViewMatchers.withText(R.string.space_settings_permissions_title)), + ViewActions.click() + ) + ) + + waitUntilViewVisible(ViewMatchers.withId(R.id.roomSettingsRecyclerView)) + Espresso.pressBack() + Espresso.pressBack() + } +} 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..a3f4ffcfcd 100644 --- a/vector/src/main/java/im/vector/app/VectorApplication.kt +++ b/vector/src/main/java/im/vector/app/VectorApplication.kt @@ -55,7 +55,6 @@ import im.vector.app.features.pin.PinLocker import im.vector.app.features.popup.PopupAlertManager import im.vector.app.features.rageshake.VectorFileLogger import im.vector.app.features.rageshake.VectorUncaughtExceptionHandler -import im.vector.app.features.room.VectorRoomDisplayNameFallbackProvider import im.vector.app.features.settings.VectorLocale import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.themes.ThemeUtils @@ -63,7 +62,6 @@ import im.vector.app.features.version.VersionProvider import im.vector.app.push.fcm.FcmHelper import org.jitsi.meet.sdk.log.JitsiMeetDefaultLogHandler import org.matrix.android.sdk.api.Matrix -import org.matrix.android.sdk.api.MatrixConfiguration import org.matrix.android.sdk.api.auth.AuthenticationService import org.matrix.android.sdk.api.legacy.LegacySessionImporter import timber.log.Timber @@ -77,7 +75,6 @@ import androidx.work.Configuration as WorkConfiguration @HiltAndroidApp class VectorApplication : Application(), - MatrixConfiguration.Provider, WorkConfiguration.Provider { lateinit var appContext: Context @@ -100,6 +97,7 @@ class VectorApplication : @Inject lateinit var autoRageShaker: AutoRageShaker @Inject lateinit var vectorFileLogger: VectorFileLogger @Inject lateinit var vectorAnalytics: VectorAnalytics + @Inject lateinit var matrix: Matrix // font thread handler private var fontThreadHandler: Handler? = null @@ -120,7 +118,7 @@ class VectorApplication : vectorAnalytics.init() invitesAcceptor.initialize() autoRageShaker.initialize() - vectorUncaughtExceptionHandler.activate(this) + vectorUncaughtExceptionHandler.activate() // Remove Log handler statically added by Jitsi Timber.forest() @@ -220,16 +218,9 @@ class VectorApplication : } } - override fun providesMatrixConfiguration(): MatrixConfiguration { - return MatrixConfiguration( - applicationFlavor = BuildConfig.FLAVOR_DESCRIPTION, - roomDisplayNameFallbackProvider = VectorRoomDisplayNameFallbackProvider(this) - ) - } - override fun getWorkManagerConfiguration(): WorkConfiguration { return WorkConfiguration.Builder() - .setWorkerFactory(Matrix.getInstance(this.appContext).workerFactory()) + .setWorkerFactory(matrix.workerFactory()) .setExecutor(Executors.newCachedThreadPool()) .build() } 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/di/NamedGlobalScope.kt b/vector/src/main/java/im/vector/app/core/di/NamedGlobalScope.kt new file mode 100644 index 0000000000..cc1ac829a1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/di/NamedGlobalScope.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.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class NamedGlobalScope diff --git a/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt b/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt index 0e19cd4388..a5575ef536 100644 --- a/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt @@ -26,13 +26,16 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import im.vector.app.BuildConfig import im.vector.app.EmojiCompatWrapper import im.vector.app.EmojiSpanify +import im.vector.app.config.analyticsConfig import im.vector.app.core.dispatchers.CoroutineDispatchers import im.vector.app.core.error.DefaultErrorFormatter import im.vector.app.core.error.ErrorFormatter import im.vector.app.core.time.Clock import im.vector.app.core.time.DefaultClock +import im.vector.app.features.analytics.AnalyticsConfig import im.vector.app.features.analytics.AnalyticsTracker import im.vector.app.features.analytics.VectorAnalytics import im.vector.app.features.analytics.impl.DefaultVectorAnalytics @@ -42,12 +45,15 @@ import im.vector.app.features.navigation.DefaultNavigator import im.vector.app.features.navigation.Navigator import im.vector.app.features.pin.PinCodeStore import im.vector.app.features.pin.SharedPrefPinCodeStore +import im.vector.app.features.room.VectorRoomDisplayNameFallbackProvider import im.vector.app.features.ui.SharedPreferencesUiStateRepository import im.vector.app.features.ui.UiStateRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.SupervisorJob import org.matrix.android.sdk.api.Matrix +import org.matrix.android.sdk.api.MatrixConfiguration import org.matrix.android.sdk.api.auth.AuthenticationService import org.matrix.android.sdk.api.auth.HomeServerHistoryService import org.matrix.android.sdk.api.legacy.LegacySessionImporter @@ -107,8 +113,17 @@ object VectorStaticModule { } @Provides - fun providesMatrix(context: Context): Matrix { - return Matrix.getInstance(context) + fun providesMatrixConfiguration(vectorRoomDisplayNameFallbackProvider: VectorRoomDisplayNameFallbackProvider): MatrixConfiguration { + return MatrixConfiguration( + applicationFlavor = BuildConfig.FLAVOR_DESCRIPTION, + roomDisplayNameFallbackProvider = vectorRoomDisplayNameFallbackProvider + ) + } + + @Provides + @Singleton + fun providesMatrix(context: Context, configuration: MatrixConfiguration): Matrix { + return Matrix.createInstance(context, configuration) } @Provides @@ -147,4 +162,16 @@ object VectorStaticModule { fun providesCoroutineDispatchers(): CoroutineDispatchers { return CoroutineDispatchers(io = Dispatchers.IO, computation = Dispatchers.Default) } + + @Suppress("EXPERIMENTAL_API_USAGE") + @Provides + @NamedGlobalScope + fun providesGlobalScope(): CoroutineScope { + return GlobalScope + } + + @Provides + fun providesAnalyticsConfig(): AnalyticsConfig { + return analyticsConfig + } } 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 5295cbaec3..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 @@ -76,6 +76,9 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel + locationPinProvider?.create(locationOwnerId) { pinDrawable -> GlideApp.with(holder.staticMapPinImageView) .load(pinDrawable) .into(holder.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/VectorBaseActivity.kt b/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt index 8164df9c55..2c161feb37 100644 --- a/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt +++ b/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt @@ -67,8 +67,7 @@ import im.vector.app.core.utils.toast import im.vector.app.features.MainActivity import im.vector.app.features.MainActivityArgs import im.vector.app.features.analytics.AnalyticsTracker -import im.vector.app.features.analytics.plan.Screen -import im.vector.app.features.analytics.screen.ScreenEvent +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.configuration.VectorConfiguration import im.vector.app.features.consent.ConsentNotGivenHelper import im.vector.app.features.navigation.Navigator @@ -97,8 +96,7 @@ abstract class VectorBaseActivity : AppCompatActivity(), Maver * Analytics * ========================================================================================== */ - protected var analyticsScreenName: Screen.ScreenName? = null - private var screenEvent: ScreenEvent? = null + protected var analyticsScreenName: MobileScreen.ScreenName? = null protected lateinit var analyticsTracker: AnalyticsTracker @@ -337,7 +335,9 @@ abstract class VectorBaseActivity : AppCompatActivity(), Maver override fun onResume() { super.onResume() Timber.i("onResume Activity ${javaClass.simpleName}") - screenEvent = analyticsScreenName?.let { ScreenEvent(it) } + analyticsScreenName?.let { + analyticsTracker.screen(MobileScreen(screenName = it)) + } configurationViewModel.onActivityResumed() if (this !is BugReportActivity && vectorPreferences.useRageshake()) { @@ -376,7 +376,6 @@ abstract class VectorBaseActivity : AppCompatActivity(), Maver override fun onPause() { super.onPause() - screenEvent?.send(analyticsTracker, analyticsScreenName) Timber.i("onPause Activity ${javaClass.simpleName}") rageShake.stop() diff --git a/vector/src/main/java/im/vector/app/core/platform/VectorBaseBottomSheetDialogFragment.kt b/vector/src/main/java/im/vector/app/core/platform/VectorBaseBottomSheetDialogFragment.kt index 7e6a429274..ddc281fdd1 100644 --- a/vector/src/main/java/im/vector/app/core/platform/VectorBaseBottomSheetDialogFragment.kt +++ b/vector/src/main/java/im/vector/app/core/platform/VectorBaseBottomSheetDialogFragment.kt @@ -38,8 +38,7 @@ import im.vector.app.core.extensions.singletonEntryPoint import im.vector.app.core.extensions.toMvRxBundle import im.vector.app.core.utils.DimensionConverter import im.vector.app.features.analytics.AnalyticsTracker -import im.vector.app.features.analytics.plan.Screen -import im.vector.app.features.analytics.screen.ScreenEvent +import im.vector.app.features.analytics.plan.MobileScreen import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import reactivecircus.flowbinding.android.view.clicks @@ -53,8 +52,7 @@ abstract class VectorBaseBottomSheetDialogFragment : BottomShe * Analytics * ========================================================================================== */ - protected var analyticsScreenName: Screen.ScreenName? = null - private var screenEvent: ScreenEvent? = null + protected var analyticsScreenName: MobileScreen.ScreenName? = null protected lateinit var analyticsTracker: AnalyticsTracker @@ -139,12 +137,9 @@ abstract class VectorBaseBottomSheetDialogFragment : BottomShe override fun onResume() { super.onResume() Timber.i("onResume BottomSheet ${javaClass.simpleName}") - screenEvent = analyticsScreenName?.let { ScreenEvent(it) } - } - - override fun onPause() { - super.onPause() - screenEvent?.send(analyticsTracker) + analyticsScreenName?.let { + analyticsTracker.screen(MobileScreen(screenName = it)) + } } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { diff --git a/vector/src/main/java/im/vector/app/core/platform/VectorBaseFragment.kt b/vector/src/main/java/im/vector/app/core/platform/VectorBaseFragment.kt index 8a1b9051cc..70b265ff9f 100644 --- a/vector/src/main/java/im/vector/app/core/platform/VectorBaseFragment.kt +++ b/vector/src/main/java/im/vector/app/core/platform/VectorBaseFragment.kt @@ -44,8 +44,7 @@ import im.vector.app.core.extensions.singletonEntryPoint import im.vector.app.core.extensions.toMvRxBundle import im.vector.app.core.utils.ToolbarConfig import im.vector.app.features.analytics.AnalyticsTracker -import im.vector.app.features.analytics.plan.Screen -import im.vector.app.features.analytics.screen.ScreenEvent +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.navigation.Navigator import im.vector.lib.ui.styles.dialogs.MaterialProgressDialog import kotlinx.coroutines.flow.launchIn @@ -58,8 +57,7 @@ abstract class VectorBaseFragment : Fragment(), MavericksView * Analytics * ========================================================================================== */ - protected var analyticsScreenName: Screen.ScreenName? = null - private var screenEvent: ScreenEvent? = null + protected var analyticsScreenName: MobileScreen.ScreenName? = null protected lateinit var analyticsTracker: AnalyticsTracker @@ -145,14 +143,15 @@ abstract class VectorBaseFragment : Fragment(), MavericksView override fun onResume() { super.onResume() Timber.i("onResume Fragment ${javaClass.simpleName}") - screenEvent = analyticsScreenName?.let { ScreenEvent(it) } + analyticsScreenName?.let { + analyticsTracker.screen(MobileScreen(screenName = it)) + } } @CallSuper override fun onPause() { super.onPause() Timber.i("onPause Fragment ${javaClass.simpleName}") - screenEvent?.send(analyticsTracker) } @CallSuper 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/services/VectorSyncService.kt b/vector/src/main/java/im/vector/app/core/services/VectorSyncService.kt index a6f6d8d82f..8621c28d57 100644 --- a/vector/src/main/java/im/vector/app/core/services/VectorSyncService.kt +++ b/vector/src/main/java/im/vector/app/core/services/VectorSyncService.kt @@ -35,6 +35,7 @@ import im.vector.app.R import im.vector.app.core.platform.PendingIntentCompat import im.vector.app.features.notifications.NotificationUtils import im.vector.app.features.settings.BackgroundSyncMode +import org.matrix.android.sdk.api.Matrix import org.matrix.android.sdk.internal.session.sync.job.SyncService import timber.log.Timber import javax.inject.Inject @@ -75,6 +76,9 @@ class VectorSyncService : SyncService() { } @Inject lateinit var notificationUtils: NotificationUtils + @Inject lateinit var matrix: Matrix + + override fun provideMatrix() = matrix override fun getDefaultSyncDelaySeconds() = BackgroundSyncMode.DEFAULT_SYNC_DELAY_SECONDS 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..7b653ef44b 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 @@ -16,17 +16,18 @@ 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 -import im.vector.app.config.analyticsConfig +import im.vector.app.core.di.NamedGlobalScope +import im.vector.app.features.analytics.AnalyticsConfig 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.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -34,17 +35,35 @@ 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, - private val analyticsStore: AnalyticsStore + postHogFactory: PostHogFactory, + analyticsConfig: AnalyticsConfig, + private val analyticsStore: AnalyticsStore, + private val lateInitUserPropertiesFactory: LateInitUserPropertiesFactory, + @NamedGlobalScope private val globalScope: CoroutineScope ) : VectorAnalytics { - private var posthog: PostHog? = null + + private val posthog: PostHog? = when { + analyticsConfig.isEnabled -> postHogFactory.createPosthog() + else -> { + Timber.tag(analyticsTag.value).w("Analytics is disabled") + null + } + } // Cache for the store values private var userConsent: Boolean? = null private var analyticsId: String? = null + override fun init() { + observeUserConsent() + observeAnalyticsId() + } + override fun getUserConsent(): Flow { return analyticsStore.userConsentFlow } @@ -77,13 +96,6 @@ class DefaultVectorAnalytics @Inject constructor( setAnalyticsId("") } - override fun init() { - observeUserConsent() - observeAnalyticsId() - createAnalyticsClient() - } - - @Suppress("EXPERIMENTAL_API_USAGE") private fun observeAnalyticsId() { getAnalyticsId() .onEach { id -> @@ -91,21 +103,20 @@ class DefaultVectorAnalytics @Inject constructor( analyticsId = id identifyPostHog() } - .launchIn(GlobalScope) + .launchIn(globalScope) } - private fun identifyPostHog() { + private suspend fun identifyPostHog() { val id = analyticsId ?: return if (id.isEmpty()) { Timber.tag(analyticsTag.value).d("reset") posthog?.reset() } else { Timber.tag(analyticsTag.value).d("identify") - posthog?.identify(id) + posthog?.identify(id, lateInitUserPropertiesFactory.createUserProperties()?.getProperties()?.toPostHogUserProperties(), IGNORED_OPTIONS) } } - @Suppress("EXPERIMENTAL_API_USAGE") private fun observeUserConsent() { getUserConsent() .onEach { consent -> @@ -113,49 +124,13 @@ class DefaultVectorAnalytics @Inject constructor( userConsent = consent optOutPostHog() } - .launchIn(GlobalScope) + .launchIn(globalScope) } private fun optOutPostHog() { userConsent?.let { posthog?.optOut(!it) } } - private fun createAnalyticsClient() { - Timber.tag(analyticsTag.value).d("createAnalyticsClient()") - - if (analyticsConfig.isEnabled.not()) { - Timber.tag(analyticsTag.value).w("Analytics is disabled") - return - } - - posthog = PostHog.Builder(context, analyticsConfig.postHogApiKey, analyticsConfig.postHogHost) - // Record certain application events automatically! (off/false by default) - // .captureApplicationLifecycleEvents() - // Record screen views automatically! (off/false by default) - // .recordScreenViews() - // Capture deep links as part of the screen call. (off by default) - // .captureDeepLinks() - // Maximum number of events to keep in queue before flushing (default 20) - // .flushQueueSize(20) - // Max delay before flushing the queue (30 seconds) - // .flushInterval(30, TimeUnit.SECONDS) - // Enable or disable collection of ANDROID_ID (true) - .collectDeviceId(false) - .logLevel(getLogLevel()) - .build() - - optOutPostHog() - identifyPostHog() - } - - private fun getLogLevel(): PostHog.LogLevel { - return if (BuildConfig.DEBUG) { - PostHog.LogLevel.DEBUG - } else { - PostHog.LogLevel.INFO - } - } - override fun capture(event: VectorAnalyticsEvent) { Timber.tag(analyticsTag.value).d("capture($event)") posthog @@ -170,11 +145,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/impl/LateInitUserPropertiesFactory.kt b/vector/src/main/java/im/vector/app/features/analytics/impl/LateInitUserPropertiesFactory.kt new file mode 100644 index 0000000000..d961ceaadc --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/analytics/impl/LateInitUserPropertiesFactory.kt @@ -0,0 +1,36 @@ +/* + * 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.impl + +import android.content.Context +import im.vector.app.ActiveSessionDataSource +import im.vector.app.core.extensions.vectorStore +import im.vector.app.features.analytics.extensions.toTrackingValue +import im.vector.app.features.analytics.plan.UserProperties +import javax.inject.Inject + +class LateInitUserPropertiesFactory @Inject constructor( + private val activeSessionDataSource: ActiveSessionDataSource, + private val context: Context, +) { + suspend fun createUserProperties(): UserProperties? { + val useCase = activeSessionDataSource.currentValue?.orNull()?.vectorStore(context)?.readUseCase() + return useCase?.let { + UserProperties(ftueUseCaseSelection = it.toTrackingValue()) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/analytics/impl/PostHogFactory.kt b/vector/src/main/java/im/vector/app/features/analytics/impl/PostHogFactory.kt new file mode 100644 index 0000000000..029732f76c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/analytics/impl/PostHogFactory.kt @@ -0,0 +1,52 @@ +/* + * 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.impl + +import android.content.Context +import com.posthog.android.PostHog +import im.vector.app.BuildConfig +import im.vector.app.config.analyticsConfig +import javax.inject.Inject + +class PostHogFactory @Inject constructor(private val context: Context) { + + fun createPosthog(): PostHog { + return PostHog.Builder(context, analyticsConfig.postHogApiKey, analyticsConfig.postHogHost) + // Record certain application events automatically! (off/false by default) + // .captureApplicationLifecycleEvents() + // Record screen views automatically! (off/false by default) + // .recordScreenViews() + // Capture deep links as part of the screen call. (off by default) + // .captureDeepLinks() + // Maximum number of events to keep in queue before flushing (default 20) + // .flushQueueSize(20) + // Max delay before flushing the queue (30 seconds) + // .flushInterval(30, TimeUnit.SECONDS) + // Enable or disable collection of ANDROID_ID (true) + .collectDeviceId(false) + .logLevel(getLogLevel()) + .build() + } + + private fun getLogLevel(): PostHog.LogLevel { + return if (BuildConfig.DEBUG) { + PostHog.LogLevel.DEBUG + } else { + PostHog.LogLevel.INFO + } + } +} 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/Screen.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/MobileScreen.kt similarity index 89% rename from vector/src/main/java/im/vector/app/features/analytics/plan/Screen.kt rename to vector/src/main/java/im/vector/app/features/analytics/plan/MobileScreen.kt index db4dcd0fac..758a0540bf 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/MobileScreen.kt @@ -22,9 +22,9 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsScreen // https://github.com/matrix-org/matrix-analytics-events/ /** - * Triggered when the user changed screen + * Triggered when the user changed screen on Element Android/iOS */ -data class Screen( +data class MobileScreen( /** * How long the screen was displayed for in milliseconds. */ @@ -33,6 +33,11 @@ data class Screen( ) : VectorAnalyticsScreen { enum class ScreenName { + /** + * The screen that displays the user's breadcrumbs. + */ + Breadcrumbs, + /** * The screen shown to create a new (non-direct) room. */ @@ -43,6 +48,16 @@ data class Screen( */ DeactivateAccount, + /** + * The tab on mobile that displays the dialpad. + */ + Dialpad, + + /** + * The Favourites tab on mobile that lists your favourite people/rooms. + */ + Favourites, + /** * The form for the forgot password use case */ @@ -54,11 +69,15 @@ 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, + /** + * The screen shown to share a link to download the app. + */ + InviteFriends, + /** * The screen that displays the login flow (when the user already has an * account). @@ -66,70 +85,14 @@ data class Screen( Login, /** - * The screen that displays the user's breadcrumbs. + * Legacy: The screen that shows all groups/communities you have joined. */ - MobileBreadcrumbs, - - /** - * The tab on mobile that displays the dialpad. - */ - MobileDialpad, - - /** - * The Favourites tab on mobile that lists your favourite people/rooms. - */ - MobileFavourites, - - /** - * The screen shown to share a link to download the app. - */ - MobileInviteFriends, + MyGroups, /** * The People tab on mobile that lists all the DM rooms you have joined. */ - MobilePeople, - - /** - * The Rooms tab on mobile that lists all the (non-direct) rooms you've - * joined. - */ - MobileRooms, - - /** - * The Files tab shown in the global search screen on Mobile. - */ - MobileSearchFiles, - - /** - * The Messages tab shown in the global search screen on Mobile. - */ - MobileSearchMessages, - - /** - * The People tab shown in the global search screen on Mobile. - */ - MobileSearchPeople, - - /** - * The Rooms tab shown in the global search screen on Mobile. - */ - MobileSearchRooms, - - /** - * The sidebar shown on mobile with spaces, settings etc. - */ - MobileSidebar, - - /** - * The screen shown to select which room directory you'd like to use. - */ - MobileSwitchDirectory, - - /** - * Legacy: The screen that shows all groups/communities you have joined. - */ - MyGroups, + People, /** * The screen that displays the registration flow (when the user wants @@ -186,6 +149,32 @@ data class Screen( */ RoomUploads, + /** + * The Rooms tab on mobile that lists all the (non-direct) rooms you've + * joined. + */ + Rooms, + + /** + * The Files tab shown in the global search screen on Mobile. + */ + SearchFiles, + + /** + * The Messages tab shown in the global search screen on Mobile. + */ + SearchMessages, + + /** + * The People tab shown in the global search screen on Mobile. + */ + SearchPeople, + + /** + * The Rooms tab shown in the global search screen on Mobile. + */ + SearchRooms, + /** * The global settings screen shown in the app. */ @@ -206,36 +195,41 @@ data class Screen( */ SettingsSecurity, + /** + * The sidebar shown on mobile with spaces, settings etc. + */ + Sidebar, + + /** + * Screen that displays the list of rooms and spaces of a space + */ + SpaceExploreRooms, + + /** + * Screen that displays the list of members of a space + */ + SpaceMembers, + + /** + * The bottom sheet that list all space options + */ + SpaceMenu, + /** * The screen shown to create a new direct room. */ StartChat, + /** + * The screen shown to select which room directory you'd like to use. + */ + SwitchDirectory, + /** * A screen that shows information about a room member. */ User, - /** - * ? - */ - WebCompleteSecurity, - - /** - * ? - */ - WebE2ESetup, - - /** - * ? - */ - WebLoading, - - /** - * ? - */ - WebSoftLogout, - /** * The splash screen. */ 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 59dd997f92..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 @@ -83,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/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/analytics/screen/ScreenEvent.kt b/vector/src/main/java/im/vector/app/features/analytics/screen/ScreenEvent.kt deleted file mode 100644 index 8e0513f25a..0000000000 --- a/vector/src/main/java/im/vector/app/features/analytics/screen/ScreenEvent.kt +++ /dev/null @@ -1,50 +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.analytics.screen - -import android.os.SystemClock -import im.vector.app.features.analytics.AnalyticsTracker -import im.vector.app.features.analytics.plan.Screen -import timber.log.Timber - -/** - * Track a screen display. Unique usage. - */ -class ScreenEvent(val screenName: Screen.ScreenName) { - private val startTime = SystemClock.elapsedRealtime() - - // Protection to avoid multiple sending - private var isSent = false - - /** - * @param screenNameOverride can be used to override the screen name passed in constructor parameter - */ - fun send(analyticsTracker: AnalyticsTracker, - screenNameOverride: Screen.ScreenName? = null) { - if (isSent) { - Timber.w("Event $screenName Already sent!") - return - } - isSent = true - analyticsTracker.screen( - Screen( - screenName = screenNameOverride ?: screenName, - durationMs = (SystemClock.elapsedRealtime() - startTime).toInt() - ) - ) - } -} diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/AutocompleteHeaderItem.kt b/vector/src/main/java/im/vector/app/features/autocomplete/AutocompleteHeaderItem.kt new file mode 100644 index 0000000000..f287104415 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/autocomplete/AutocompleteHeaderItem.kt @@ -0,0 +1,39 @@ +/* + * 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.autocomplete + +import android.widget.TextView +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel + +@EpoxyModelClass(layout = R.layout.item_autocomplete_header_item) +abstract class AutocompleteHeaderItem : VectorEpoxyModel() { + + @EpoxyAttribute var title: String? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.titleView.text = title + } + + class Holder : VectorEpoxyHolder() { + val titleView by bind(R.id.headerItemAutocompleteTitle) + } +} 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/autocomplete/member/AutocompleteMemberController.kt b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberController.kt index 9b4bd78504..2034cee90a 100644 --- a/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberController.kt +++ b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberController.kt @@ -16,31 +16,81 @@ package im.vector.app.features.autocomplete.member +import android.content.Context import com.airbnb.epoxy.TypedEpoxyController +import im.vector.app.R import im.vector.app.features.autocomplete.AutocompleteClickListener +import im.vector.app.features.autocomplete.autocompleteHeaderItem import im.vector.app.features.autocomplete.autocompleteMatrixItem import im.vector.app.features.home.AvatarRenderer -import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary +import org.matrix.android.sdk.api.util.toEveryoneInRoomMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject -class AutocompleteMemberController @Inject constructor() : TypedEpoxyController>() { +class AutocompleteMemberController @Inject constructor(private val context: Context) : + TypedEpoxyController>() { - var listener: AutocompleteClickListener? = null + /* ========================================================================================== + * Fields + * ========================================================================================== */ + + var listener: AutocompleteClickListener? = null + + /* ========================================================================================== + * Dependencies + * ========================================================================================== */ @Inject lateinit var avatarRenderer: AvatarRenderer - override fun buildModels(data: List?) { + /* ========================================================================================== + * Specialization + * ========================================================================================== */ + + override fun buildModels(data: List?) { if (data.isNullOrEmpty()) { return } + data.forEach { item -> + when (item) { + is AutocompleteMemberItem.Header -> buildHeaderItem(item) + is AutocompleteMemberItem.RoomMember -> buildRoomMemberItem(item) + is AutocompleteMemberItem.Everyone -> buildEveryoneItem(item) + } + } + } + + /* ========================================================================================== + * Helper methods + * ========================================================================================== */ + + private fun buildHeaderItem(header: AutocompleteMemberItem.Header) { + autocompleteHeaderItem { + id(header.id) + title(header.title) + } + } + + private fun buildRoomMemberItem(roomMember: AutocompleteMemberItem.RoomMember) { val host = this - data.forEach { user -> - autocompleteMatrixItem { + autocompleteMatrixItem { + roomMember.roomMemberSummary.let { user -> id(user.userId) matrixItem(user.toMatrixItem()) avatarRenderer(host.avatarRenderer) - clickListener { host.listener?.onItemClick(user) } + clickListener { host.listener?.onItemClick(roomMember) } + } + } + } + + private fun buildEveryoneItem(everyone: AutocompleteMemberItem.Everyone) { + val host = this + autocompleteMatrixItem { + everyone.roomSummary.let { room -> + id(room.roomId) + matrixItem(room.toEveryoneInRoomMatrixItem()) + subName(host.context.getString(R.string.room_message_notify_everyone)) + avatarRenderer(host.avatarRenderer) + clickListener { host.listener?.onItemClick(everyone) } } } } diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberItem.kt b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberItem.kt new file mode 100644 index 0000000000..77c5069938 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberItem.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.autocomplete.member + +import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary +import org.matrix.android.sdk.api.session.room.model.RoomSummary + +sealed class AutocompleteMemberItem { + data class Header(val id: String, val title: String) : AutocompleteMemberItem() + data class RoomMember(val roomMemberSummary: RoomMemberSummary) : AutocompleteMemberItem() + data class Everyone(val roomSummary: RoomSummary) : AutocompleteMemberItem() +} diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt index 4976cb39b9..ce3b9c6a7e 100644 --- a/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt +++ b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt @@ -21,26 +21,44 @@ import androidx.recyclerview.widget.RecyclerView import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import im.vector.app.R import im.vector.app.features.autocomplete.AutocompleteClickListener import im.vector.app.features.autocomplete.RecyclerViewPresenter +import org.matrix.android.sdk.api.pushrules.SenderNotificationPermissionCondition import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.room.members.RoomMemberQueryParams import org.matrix.android.sdk.api.session.room.members.roomMemberQueryParams import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary +import org.matrix.android.sdk.api.util.MatrixItem class AutocompleteMemberPresenter @AssistedInject constructor(context: Context, @Assisted val roomId: String, - session: Session, + private val session: Session, private val controller: AutocompleteMemberController -) : RecyclerViewPresenter(context), AutocompleteClickListener { +) : RecyclerViewPresenter(context), AutocompleteClickListener { + + /* ========================================================================================== + * Fields + * ========================================================================================== */ private val room by lazy { session.getRoom(roomId)!! } + /* ========================================================================================== + * Init + * ========================================================================================== */ + init { controller.listener = this } + /* ========================================================================================== + * Public api + * ========================================================================================== */ + fun clear() { controller.listener = null } @@ -50,29 +68,100 @@ class AutocompleteMemberPresenter @AssistedInject constructor(context: Context, fun create(roomId: String): AutocompleteMemberPresenter } + /* ========================================================================================== + * Specialization + * ========================================================================================== */ + override fun instantiateAdapter(): RecyclerView.Adapter<*> { return controller.adapter } - override fun onItemClick(t: RoomMemberSummary) { + override fun onItemClick(t: AutocompleteMemberItem) { dispatchClick(t) } override fun onQuery(query: CharSequence?) { - val queryParams = roomMemberQueryParams { - displayName = if (query.isNullOrBlank()) { - QueryStringValue.IsNotEmpty - } else { - QueryStringValue.Contains(query.toString(), QueryStringValue.Case.INSENSITIVE) + val queryParams = createQueryParams(query) + val membersHeader = createMembersHeader() + val members = createMemberItems(queryParams) + val everyone = createEveryoneItem(query) + // add headers only when user can notify everyone + val canAddHeaders = canNotifyEveryone() + + val items = mutableListOf().apply { + if (members.isNotEmpty()) { + if (canAddHeaders) { + add(membersHeader) + } + addAll(members) + } + everyone?.let { + val everyoneHeader = createEveryoneHeader() + add(everyoneHeader) + add(it) } - memberships = listOf(Membership.JOIN) - excludeSelf = true } - val members = room.getRoomMembers(queryParams) - .asSequence() - .sortedBy { it.displayName } - .disambiguate() - controller.setData(members.toList()) + + controller.setData(items) + } + + /* ========================================================================================== + * Helper methods + * ========================================================================================== */ + + private fun createQueryParams(query: CharSequence?) = roomMemberQueryParams { + displayName = if (query.isNullOrBlank()) { + QueryStringValue.IsNotEmpty + } else { + QueryStringValue.Contains(query.toString(), QueryStringValue.Case.INSENSITIVE) + } + memberships = listOf(Membership.JOIN) + excludeSelf = true + } + + private fun createMembersHeader() = + AutocompleteMemberItem.Header( + ID_HEADER_MEMBERS, + context.getString(R.string.room_message_autocomplete_users) + ) + + private fun createMemberItems(queryParams: RoomMemberQueryParams) = + room.getRoomMembers(queryParams) + .asSequence() + .sortedBy { it.displayName } + .disambiguate() + .map { AutocompleteMemberItem.RoomMember(it) } + .toList() + + private fun createEveryoneHeader() = + AutocompleteMemberItem.Header( + ID_HEADER_EVERYONE, + context.getString(R.string.room_message_autocomplete_notification) + ) + + private fun createEveryoneItem(query: CharSequence?) = + room.roomSummary() + ?.takeIf { canNotifyEveryone() } + ?.takeIf { query.isNullOrBlank() || MatrixItem.NOTIFY_EVERYONE.startsWith("@$query") } + ?.let { + AutocompleteMemberItem.Everyone(it) + } + + private fun canNotifyEveryone() = session.resolveSenderNotificationPermissionCondition( + Event( + senderId = session.myUserId, + roomId = roomId + ), + SenderNotificationPermissionCondition(PowerLevelsContent.NOTIFICATIONS_ROOM_KEY) + ) + + /* ========================================================================================== + * Const + * ========================================================================================== */ + + companion object { + private const val ID_HEADER_MEMBERS = "ID_HEADER_MEMBERS" + private const val ID_HEADER_EVERYONE = "ID_HEADER_EVERYONE" } } 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 642d259723..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 @@ -54,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 @@ -165,6 +166,7 @@ class VectorCallActivity : VectorBaseActivity(), CallContro ?.let { callViewModel.handle(VectorCallViewActions.SwitchCall(it)) } + this.intent = intent } override fun getMenuRes() = R.menu.vector_call @@ -522,14 +524,21 @@ class VectorCallActivity : VectorBaseActivity(), CallContro val callId = withState(callViewModel) { it.callId } navigator.openCallTransfer(this, callTransferActivityResultLauncher, callId) } + is VectorCallViewEvents.FailToTransfer -> showSnackbar(getString(R.string.call_transfer_failure)) null -> { } } } private val callTransferActivityResultLauncher = registerStartForActivityResult { activityResult -> - if (activityResult.resultCode == Activity.RESULT_CANCELED) { - callViewModel.handle(VectorCallViewActions.CallTransferSelectionCancelled) + when (activityResult.resultCode) { + Activity.RESULT_CANCELED -> { + callViewModel.handle(VectorCallViewActions.CallTransferSelectionCancelled) + } + Activity.RESULT_OK -> { + CallTransferActivity.getCallTransferResult(activityResult.data) + ?.let { callViewModel.handle(VectorCallViewActions.CallTransferSelectionResult(it)) } + } } } @@ -571,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 fb39660282..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() @@ -37,5 +38,6 @@ sealed class VectorCallViewActions : VectorViewModelAction { 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 4aca0ea499..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 @@ -327,6 +333,9 @@ class VectorCallViewModel @AssistedInject constructor( VectorCallViewActions.CallTransferSelectionCancelled -> { call?.updateRemoteOnHold(false) } + is VectorCallViewActions.CallTransferSelectionResult -> { + handleCallTransferSelectionResult(action.callTransferResult) + } VectorCallViewActions.TransferCall -> { handleCallTransfer() } @@ -345,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/dialpad/DialPadFragment.kt b/vector/src/main/java/im/vector/app/features/call/dialpad/DialPadFragment.kt index 5fc866a4dd..606ba1d1e9 100644 --- a/vector/src/main/java/im/vector/app/features/call/dialpad/DialPadFragment.kt +++ b/vector/src/main/java/im/vector/app/features/call/dialpad/DialPadFragment.kt @@ -40,8 +40,7 @@ import com.android.dialer.dialpadview.DigitsEditText import im.vector.app.R import im.vector.app.core.extensions.singletonEntryPoint import im.vector.app.features.analytics.AnalyticsTracker -import im.vector.app.features.analytics.plan.Screen -import im.vector.app.features.analytics.screen.ScreenEvent +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.themes.ThemeUtils class DialPadFragment : Fragment(), TextWatcher { @@ -66,15 +65,9 @@ class DialPadFragment : Fragment(), TextWatcher { analyticsTracker = singletonEntryPoint.analyticsTracker() } - private var screenEvent: ScreenEvent? = null override fun onResume() { super.onResume() - screenEvent = ScreenEvent(Screen.ScreenName.MobileDialpad) - } - - override fun onPause() { - super.onPause() - screenEvent?.send(analyticsTracker) + analyticsTracker.screen(MobileScreen(screenName = MobileScreen.ScreenName.Dialpad)) } override fun onCreateView( 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 0e63316bbe..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 @@ -16,7 +16,6 @@ package im.vector.app.features.call.transfer -import android.app.Activity import android.content.Context import android.content.Intent import android.os.Bundle @@ -27,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 @@ -56,10 +56,8 @@ class CallTransferActivity : VectorBaseActivity() { callTransferViewModel.observeViewEvents { when (it) { - is CallTransferViewEvents.Complete -> handleComplete() - CallTransferViewEvents.Loading -> showWaitingView() - is CallTransferViewEvents.FailToTransfer -> showSnackbar(getString(R.string.call_transfer_failure)) - } + is CallTransferViewEvents.Complete -> handleComplete() + }.exhaustive } sectionsPagerAdapter = CallTransferPagerAdapter(this) @@ -82,29 +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() { - setResult(Activity.RESULT_OK) + 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 a8451e4fb5..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 @@ -20,6 +20,4 @@ import im.vector.app.core.platform.VectorViewEvents sealed class CallTransferViewEvents : VectorViewEvents { object Complete : CallTransferViewEvents() - object Loading : CallTransferViewEvents() - object FailToTransfer : 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 de6a5de539..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 { @@ -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.Complete) - } 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.Complete) - } 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..9df4f52d0f 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 @@ -42,8 +43,12 @@ import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO import im.vector.app.core.utils.checkPermissions 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.analytics.plan.MobileScreen 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,12 +64,14 @@ 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 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.StartChat + analyticsScreenName = MobileScreen.ScreenName.StartChat views.toolbar.visibility = View.GONE sharedActionViewModel = viewModelProvider.get(UserListSharedActionViewModel::class.java) @@ -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,8 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() { private val permissionCameraLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> if (allGranted) { - addFragment(views.container, CreateDirectRoomByQrCodeFragment::class.java) + val args = QrScannerArgs(showExtraButtons = false, R.string.add_by_qr_code) + addFragment(views.container, QrCodeScannerFragment::class.java, args) } 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/AdaptiveIconTransformation.kt b/vector/src/main/java/im/vector/app/features/home/AdaptiveIconTransformation.kt new file mode 100644 index 0000000000..6eb41a1d85 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/AdaptiveIconTransformation.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home + +import android.graphics.Bitmap +import android.graphics.Canvas +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool +import com.bumptech.glide.load.resource.bitmap.BitmapTransformation +import com.bumptech.glide.util.Util +import java.nio.ByteBuffer +import java.security.MessageDigest + +private const val ADAPTIVE_TRANSFORMATION_ID = "adaptive-icon-transform" +private val ID_BYTES = ADAPTIVE_TRANSFORMATION_ID.toByteArray() + +class AdaptiveIconTransformation(private val adaptiveIconSize: Int, private val adaptiveIconOuterSides: Float) : BitmapTransformation() { + + override fun updateDiskCacheKey(messageDigest: MessageDigest) { + messageDigest.update(ID_BYTES) + messageDigest.update(ByteBuffer.allocate(8).putInt(adaptiveIconSize).putFloat(adaptiveIconOuterSides).array()) + } + + override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap { + val insetBmp = Bitmap.createBitmap(adaptiveIconSize, adaptiveIconSize, Bitmap.Config.ARGB_8888) + val canvas = Canvas(insetBmp) + canvas.drawBitmap(toTransform, adaptiveIconOuterSides, adaptiveIconOuterSides, null) + canvas.setBitmap(null) + return insetBmp + } + + override fun equals(other: Any?): Boolean { + return if (other is AdaptiveIconTransformation) { + other.adaptiveIconSize == adaptiveIconSize && other.adaptiveIconOuterSides == adaptiveIconOuterSides + } else { + false + } + } + + override fun hashCode() = Util.hashCode(ADAPTIVE_TRANSFORMATION_ID.hashCode(), Util.hashCode(adaptiveIconSize, Util.hashCode(adaptiveIconOuterSides))) +} diff --git a/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt b/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt index 2ee3233637..3678808b2d 100644 --- a/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt @@ -26,12 +26,14 @@ import androidx.core.graphics.drawable.toBitmap import com.amulyakhare.textdrawable.TextDrawable import com.bumptech.glide.load.MultiTransformation import com.bumptech.glide.load.Transformation +import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.CircleCrop import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.target.DrawableImageViewTarget import com.bumptech.glide.request.target.Target +import com.bumptech.glide.signature.ObjectKey import im.vector.app.core.contacts.MappedContact import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.glide.AvatarPlaceholder @@ -157,25 +159,45 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active fun shortcutDrawable(glideRequests: GlideRequests, matrixItem: MatrixItem, iconSize: Int): Bitmap { return glideRequests .asBitmap() - .let { - val resolvedUrl = resolvedUrl(matrixItem.avatarUrl) - if (resolvedUrl != null) { - it.load(resolvedUrl) - } else { - val avatarColor = matrixItemColorProvider.getColor(matrixItem) - it.load(TextDrawable.builder() - .beginConfig() - .bold() - .endConfig() - .buildRect(matrixItem.firstLetterOfDisplayName(), avatarColor) - .toBitmap(width = iconSize, height = iconSize)) - } - } + .avatarOrText(matrixItem, iconSize) .apply(RequestOptions.centerCropTransform()) .submit(iconSize, iconSize) .get() } + @AnyThread + @Throws + fun adaptiveShortcutDrawable(glideRequests: GlideRequests, + matrixItem: MatrixItem, iconSize: Int, + adaptiveIconSize: Int, + adaptiveIconOuterSides: Float): Bitmap { + return glideRequests + .asBitmap() + .avatarOrText(matrixItem, iconSize) + .transform(CenterCrop(), AdaptiveIconTransformation(adaptiveIconSize, adaptiveIconOuterSides)) + .signature(ObjectKey("adaptive-icon")) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .submit(iconSize, iconSize) + .get() + } + + private fun GlideRequest.avatarOrText(matrixItem: MatrixItem, iconSize: Int): GlideRequest { + return this.let { + val resolvedUrl = resolvedUrl(matrixItem.avatarUrl) + if (resolvedUrl != null) { + it.load(resolvedUrl) + } else { + val avatarColor = matrixItemColorProvider.getColor(matrixItem) + it.load(TextDrawable.builder() + .beginConfig() + .bold() + .endConfig() + .buildRect(matrixItem.firstLetterOfDisplayName(), avatarColor) + .toBitmap(width = iconSize, height = iconSize)) + } + } + } + @UiThread fun renderBlur(matrixItem: MatrixItem, imageView: ImageView, 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..47f1a9208b 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 @@ -48,8 +48,7 @@ import im.vector.app.databinding.ActivityHomeBinding import im.vector.app.features.MainActivity import im.vector.app.features.MainActivityArgs import im.vector.app.features.analytics.accountdata.AnalyticsAccountDataViewModel -import im.vector.app.features.analytics.plan.Screen -import im.vector.app.features.analytics.screen.ScreenEvent +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.disclaimer.showDisclaimerDialog import im.vector.app.features.matrixto.MatrixToBottomSheet import im.vector.app.features.navigation.Navigator @@ -66,7 +65,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 +109,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 @@ -165,14 +162,8 @@ class HomeActivity : } private val drawerListener = object : DrawerLayout.SimpleDrawerListener() { - private var drawerScreenEvent: ScreenEvent? = null override fun onDrawerOpened(drawerView: View) { - drawerScreenEvent = ScreenEvent(Screen.ScreenName.MobileSidebar) - } - - override fun onDrawerClosed(drawerView: View) { - drawerScreenEvent?.send(analyticsTracker) - drawerScreenEvent = null + analyticsTracker.screen(MobileScreen(screenName = MobileScreen.ScreenName.Sidebar)) } override fun onDrawerStateChanged(newState: Int) { @@ -186,7 +177,7 @@ class HomeActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.Home + analyticsScreenName = MobileScreen.ScreenName.Home supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, false) FcmHelper.ensureFcmTokenIsRetrieved(this, pushManager, vectorPreferences.areNotificationEnabledForDevice()) sharedActionViewModel = viewModelProvider.get(HomeSharedActionViewModel::class.java) @@ -267,21 +258,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 +449,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 +526,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/HomeDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt index a07409d063..ea03b833ac 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt @@ -457,7 +457,7 @@ class HomeDetailFragment @Inject constructor( backgroundColor = if (highlight) { ThemeUtils.getColor(requireContext(), R.attr.colorError) } else { - ThemeUtils.getColor(requireContext(), R.attr.vctr_unread_room_badge) + ThemeUtils.getColor(requireContext(), R.attr.vctr_content_secondary) } } diff --git a/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt b/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt index 9af06ef801..1aee0257f4 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt @@ -30,7 +30,7 @@ import im.vector.app.core.extensions.replaceChildFragment import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.utils.startSharePlainTextIntent import im.vector.app.databinding.FragmentHomeDrawerBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorSettingsActivity import im.vector.app.features.spaces.SpaceListFragment @@ -98,7 +98,7 @@ class HomeDrawerFragment @Inject constructor( views.homeDrawerInviteFriendButton.debouncedClicks { session.permalinkService().createPermalink(sharedActionViewModel.session.myUserId)?.let { permalink -> - analyticsTracker.screen(Screen(screenName = Screen.ScreenName.MobileInviteFriends)) + analyticsTracker.screen(MobileScreen(screenName = MobileScreen.ScreenName.InviteFriends)) val text = getString(R.string.invite_friends_text, permalink) startSharePlainTextIntent( 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/ShortcutCreator.kt b/vector/src/main/java/im/vector/app/features/home/ShortcutCreator.kt index ee7edc021d..082d318cc7 100644 --- a/vector/src/main/java/im/vector/app/features/home/ShortcutCreator.kt +++ b/vector/src/main/java/im/vector/app/features/home/ShortcutCreator.kt @@ -19,7 +19,6 @@ package im.vector.app.features.home import android.content.Context import android.content.pm.ShortcutInfo import android.graphics.Bitmap -import android.graphics.Canvas import android.os.Build import androidx.annotation.WorkerThread import androidx.core.content.pm.ShortcutInfoCompat @@ -61,7 +60,12 @@ class ShortcutCreator @Inject constructor( fun create(roomSummary: RoomSummary, rank: Int = 1): ShortcutInfoCompat { val intent = RoomDetailActivity.shortcutIntent(context, roomSummary.roomId) val bitmap = try { - avatarRenderer.shortcutDrawable(GlideApp.with(context), roomSummary.toMatrixItem(), iconSize) + val glideRequests = GlideApp.with(context) + val matrixItem = roomSummary.toMatrixItem() + when (useAdaptiveIcon) { + true -> avatarRenderer.adaptiveShortcutDrawable(glideRequests, matrixItem, iconSize, adaptiveIconSize, adaptiveIconOuterSides.toFloat()) + false -> avatarRenderer.shortcutDrawable(glideRequests, matrixItem, iconSize) + } } catch (failure: Throwable) { null } @@ -83,11 +87,7 @@ class ShortcutCreator @Inject constructor( private fun Bitmap.toProfileImageIcon(): IconCompat { return if (useAdaptiveIcon) { - val insetBmp = Bitmap.createBitmap(adaptiveIconSize, adaptiveIconSize, Bitmap.Config.ARGB_8888) - val canvas = Canvas(insetBmp) - canvas.drawBitmap(this, adaptiveIconOuterSides.toFloat(), adaptiveIconOuterSides.toFloat(), null) - - IconCompat.createWithAdaptiveBitmap(insetBmp) + IconCompat.createWithAdaptiveBitmap(this) } else { IconCompat.createWithBitmap(this) } 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..be5f9c0bb4 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 @@ -33,6 +33,7 @@ import im.vector.app.features.autocomplete.command.AutocompleteCommandPresenter import im.vector.app.features.autocomplete.command.CommandAutocompletePolicy import im.vector.app.features.autocomplete.emoji.AutocompleteEmojiPresenter import im.vector.app.features.autocomplete.group.AutocompleteGroupPresenter +import im.vector.app.features.autocomplete.member.AutocompleteMemberItem import im.vector.app.features.autocomplete.member.AutocompleteMemberPresenter import im.vector.app.features.autocomplete.room.AutocompleteRoomPresenter import im.vector.app.features.command.Command @@ -41,17 +42,18 @@ import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.html.PillImageSpan import im.vector.app.features.themes.ThemeUtils import org.matrix.android.sdk.api.session.group.model.GroupSummary -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.util.MatrixItem +import org.matrix.android.sdk.api.util.toEveryoneInRoomMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem 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 +64,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 @@ -101,7 +107,7 @@ class AutoCompleter @AssistedInject constructor( Autocomplete.on(editText) .with(commandAutocompletePolicy) .with(autocompleteCommandPresenter) - .with(ELEVATION) + .with(ELEVATION_DP) .with(backgroundDrawable) .with(object : AutocompleteCallback { override fun onPopupItemClicked(editable: Editable, item: Command): Boolean { @@ -120,15 +126,24 @@ class AutoCompleter @AssistedInject constructor( private fun setupMembers(backgroundDrawable: ColorDrawable, editText: EditText) { autocompleteMemberPresenter = autocompleteMemberPresenterFactory.create(roomId) - Autocomplete.on(editText) - .with(CharPolicy('@', true)) + Autocomplete.on(editText) + .with(CharPolicy(TRIGGER_AUTO_COMPLETE_MEMBERS, true)) .with(autocompleteMemberPresenter) - .with(ELEVATION) + .with(ELEVATION_DP) .with(backgroundDrawable) - .with(object : AutocompleteCallback { - override fun onPopupItemClicked(editable: Editable, item: RoomMemberSummary): Boolean { - insertMatrixItem(editText, editable, "@", item.toMatrixItem()) - return true + .with(object : AutocompleteCallback { + override fun onPopupItemClicked(editable: Editable, item: AutocompleteMemberItem): Boolean { + return when (item) { + is AutocompleteMemberItem.Header -> false // do nothing header is not clickable + is AutocompleteMemberItem.RoomMember -> { + insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_MEMBERS, item.roomMemberSummary.toMatrixItem()) + true + } + is AutocompleteMemberItem.Everyone -> { + insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_MEMBERS, item.roomSummary.toEveryoneInRoomMatrixItem()) + true + } + } } override fun onPopupVisibilityChanged(shown: Boolean) { @@ -139,13 +154,13 @@ class AutoCompleter @AssistedInject constructor( private fun setupRooms(backgroundDrawable: ColorDrawable, editText: EditText) { Autocomplete.on(editText) - .with(CharPolicy('#', true)) + .with(CharPolicy(TRIGGER_AUTO_COMPLETE_ROOMS, true)) .with(autocompleteRoomPresenter) - .with(ELEVATION) + .with(ELEVATION_DP) .with(backgroundDrawable) .with(object : AutocompleteCallback { override fun onPopupItemClicked(editable: Editable, item: RoomSummary): Boolean { - insertMatrixItem(editText, editable, "#", item.toRoomAliasMatrixItem()) + insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_ROOMS, item.toRoomAliasMatrixItem()) return true } @@ -157,13 +172,13 @@ class AutoCompleter @AssistedInject constructor( private fun setupGroups(backgroundDrawable: ColorDrawable, editText: EditText) { Autocomplete.on(editText) - .with(CharPolicy('+', true)) + .with(CharPolicy(TRIGGER_AUTO_COMPLETE_GROUPS, true)) .with(autocompleteGroupPresenter) - .with(ELEVATION) + .with(ELEVATION_DP) .with(backgroundDrawable) .with(object : AutocompleteCallback { override fun onPopupItemClicked(editable: Editable, item: GroupSummary): Boolean { - insertMatrixItem(editText, editable, "+", item.toMatrixItem()) + insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_GROUPS, item.toMatrixItem()) return true } @@ -175,9 +190,9 @@ class AutoCompleter @AssistedInject constructor( private fun setupEmojis(backgroundDrawable: Drawable, editText: EditText) { Autocomplete.on(editText) - .with(CharPolicy(':', false)) + .with(CharPolicy(TRIGGER_AUTO_COMPLETE_EMOJIS, false)) .with(autocompleteEmojiPresenter) - .with(ELEVATION) + .with(ELEVATION_DP) .with(backgroundDrawable) .with(object : AutocompleteCallback { override fun onPopupItemClicked(editable: Editable, item: String): Boolean { @@ -205,7 +220,7 @@ class AutoCompleter @AssistedInject constructor( .build() } - private fun insertMatrixItem(editText: EditText, editable: Editable, firstChar: String, matrixItem: MatrixItem) { + private fun insertMatrixItem(editText: EditText, editable: Editable, firstChar: Char, matrixItem: MatrixItem) { // Detect last firstChar and remove it var startIndex = editable.lastIndexOf(firstChar) if (startIndex == -1) { @@ -223,7 +238,7 @@ class AutoCompleter @AssistedInject constructor( // Adding trailing space " " or ": " if the user started mention someone val displayNameSuffix = - if (firstChar == "@" && startIndex == 0) { + if (matrixItem is MatrixItem.UserItem) { ": " } else { " " @@ -244,6 +259,10 @@ class AutoCompleter @AssistedInject constructor( } companion object { - private const val ELEVATION = 6f + private const val ELEVATION_DP = 6f + private const val TRIGGER_AUTO_COMPLETE_MEMBERS = '@' + private const val TRIGGER_AUTO_COMPLETE_ROOMS = '#' + private const val TRIGGER_AUTO_COMPLETE_GROUPS = '+' + private const val TRIGGER_AUTO_COMPLETE_EMOJIS = ':' } } 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/RoomDetailActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt index 9a7b8e64f7..aa4ee825dc 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 @@ -35,9 +35,9 @@ import im.vector.app.core.extensions.keepScreenOn import im.vector.app.core.extensions.replaceFragment import im.vector.app.core.platform.VectorBaseActivity 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.analytics.plan.MobileScreen 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 +97,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 +145,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)) } } @@ -157,14 +157,8 @@ class RoomDetailActivity : } private val drawerListener = object : DrawerLayout.SimpleDrawerListener() { - private var drawerScreenEvent: ScreenEvent? = null override fun onDrawerOpened(drawerView: View) { - drawerScreenEvent = ScreenEvent(Screen.ScreenName.MobileBreadcrumbs) - } - - override fun onDrawerClosed(drawerView: View) { - drawerScreenEvent?.send(analyticsTracker) - drawerScreenEvent = null + analyticsTracker.screen(MobileScreen(screenName = MobileScreen.ScreenName.Breadcrumbs)) } override fun onDrawerStateChanged(newState: Int) { @@ -196,9 +190,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 86240a5ffe..d08a27324c 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 @@ -29,7 +29,7 @@ import java.io.File * Transient events for RoomDetail */ sealed class RoomDetailViewEvents : VectorViewEvents { - data class Failure(val throwable: Throwable) : RoomDetailViewEvents() + data class Failure(val throwable: Throwable, val showInDialog: Boolean = false) : RoomDetailViewEvents() data class OnNewTimelineEvents(val eventIds: List) : RoomDetailViewEvents() data class ActionSuccess(val action: RoomDetailAction) : 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..71a299e11b 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 @@ -47,6 +49,7 @@ data class JitsiState( data class RoomDetailViewState( val roomId: String, val eventId: String?, + val isInviteAlreadyAccepted: Boolean, val myRoomMember: Async = Uninitialized, val asyncInviter: Async = Uninitialized, val asyncRoomSummary: Async = Uninitialized, @@ -67,22 +70,30 @@ 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, + isInviteAlreadyAccepted = args.isInviteAlreadyAccepted, // 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 81% 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 b58a1d627e..b6cbd538f3 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,9 +118,9 @@ 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.features.analytics.plan.Screen +import im.vector.app.databinding.FragmentTimelineBinding +import im.vector.app.features.analytics.plan.Composer +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.attachments.AttachmentTypeSelectorView import im.vector.app.features.attachments.AttachmentsHelper import im.vector.app.features.attachments.ContactAttachment @@ -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,6 +170,7 @@ 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 @@ -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 @@ -344,10 +342,10 @@ class RoomDetailFragment @Inject constructor( override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.Room + analyticsScreenName = MobileScreen.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,9 +446,9 @@ class RoomDetailFragment @Inject constructor( }.exhaustive } - roomDetailViewModel.observeViewEvents { + timelineViewModel.observeViewEvents { when (it) { - is RoomDetailViewEvents.Failure -> showErrorInSnackbar(it.throwable) + is RoomDetailViewEvents.Failure -> displayErrorMessage(it) is RoomDetailViewEvents.OnNewTimelineEvents -> scrollOnNewMessageCallback.addNewTimelineEventIds(it.eventIds) is RoomDetailViewEvents.ActionSuccess -> displayRoomDetailActionSuccess(it) is RoomDetailViewEvents.ActionFailure -> displayRoomDetailActionFailure(it) @@ -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() @@ -513,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)) } } } @@ -528,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() { @@ -554,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) } @@ -589,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() @@ -600,7 +599,7 @@ class RoomDetailFragment @Inject constructor( private fun handleOpenRoomSettings(directAccess: Int? = null) { navigator.openRoomProfile( requireContext(), - roomDetailArgs.roomId, + timelineArgs.roomId, directAccess ) } @@ -613,16 +612,21 @@ class RoomDetailFragment @Inject constructor( } 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 = locationContent.toLocationData(), - locationOwnerId = senderId + locationOwnerId = if (isSelfLocation) senderId else null ) } + private fun displayErrorMessage(error: RoomDetailViewEvents.Failure) { + if (error.showInDialog) displayErrorDialog(error.throwable) else showErrorInSnackbar(error.throwable) + } + private fun requestNativeWidgetPermission(it: RoomDetailViewEvents.RequestNativeWidgetPermission) { val tag = RoomWidgetPermissionBottomSheet::class.java.name val dFrag = childFragmentManager.findFragmentByTag(tag) as? RoomWidgetPermissionBottomSheet @@ -633,13 +637,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 @@ -659,7 +663,7 @@ class RoomDetailFragment @Inject constructor( navigator.openIntegrationManager( context = requireContext(), activityResultLauncher = integrationManagerActivityResultLauncher, - roomId = roomDetailArgs.roomId, + roomId = timelineArgs.roomId, integId = null, screen = screen ) @@ -719,13 +723,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) } } } @@ -747,7 +751,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) } @@ -763,37 +767,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) { @@ -817,7 +826,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) @@ -829,7 +838,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) } @@ -837,7 +846,7 @@ class RoomDetailFragment @Inject constructor( } else { // User cancelled } - roomDetailViewModel.pendingEvent = null + timelineViewModel.pendingEvent = null } private fun displayPromptForIntegrationManager() { @@ -863,7 +872,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)) } @@ -876,7 +885,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) @@ -897,18 +906,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) } @@ -927,7 +936,7 @@ class RoomDetailFragment @Inject constructor( onJumpToReadMarkerClicked() } views.jumpToReadMarkerView.setOnCloseIconClickListener { - roomDetailViewModel.handle(RoomDetailAction.MarkAllAsRead) + timelineViewModel.handle(RoomDetailAction.MarkAllAsRead) } } @@ -970,11 +979,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) } } } @@ -989,15 +998,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) { @@ -1028,49 +1045,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() { @@ -1150,7 +1231,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 @@ -1161,11 +1242,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 @@ -1218,7 +1299,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)) } } @@ -1227,7 +1308,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)) } } } @@ -1237,16 +1318,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) { @@ -1260,7 +1341,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) { @@ -1328,8 +1409,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 @@ -1387,8 +1469,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) } @@ -1417,7 +1503,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 @@ -1427,6 +1515,7 @@ class RoomDetailFragment @Inject constructor( } private fun observerUserTyping() { + if (isThreadTimeLine()) return views.composerLayout.views.composerEditText.textChanges() .skipInitialValue() .debounce(300) @@ -1439,7 +1528,7 @@ class RoomDetailFragment @Inject constructor( views.composerLayout.views.composerEditText.focusChanges() .onEach { - roomDetailViewModel.handle(RoomDetailAction.ComposerFocusChange(it)) + timelineViewModel.handle(RoomDetailAction.ComposerFocusChange(it)) } .launchIn(viewLifecycleOwner.lifecycleScope) } @@ -1453,7 +1542,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) @@ -1496,7 +1585,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) @@ -1507,29 +1596,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)) @@ -1543,27 +1646,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 @@ -1601,7 +1707,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() @@ -1617,7 +1723,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)) } } @@ -1639,7 +1745,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() } @@ -1649,7 +1755,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() } @@ -1659,7 +1765,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() } @@ -1668,14 +1774,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") @@ -1686,7 +1792,7 @@ class RoomDetailFragment @Inject constructor( setArguments(VerificationBottomSheet.VerificationArgs( otherUserId = otherUserId, verificationId = data.transactionId, - roomId = roomDetailArgs.roomId + roomId = timelineArgs.roomId )) }.show(parentFragmentManager, "REQ") } @@ -1694,25 +1800,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 { @@ -1754,11 +1874,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) { @@ -1768,7 +1888,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 -> @@ -1780,7 +1900,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 -> @@ -1789,60 +1909,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 -> { - Timber.d("No click action defined for this message content") + 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 @@ -1850,26 +1961,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) { @@ -1879,36 +1999,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 } @@ -1922,7 +2042,7 @@ class RoomDetailFragment @Inject constructor( } override fun onReadMarkerVisible() { - roomDetailViewModel.handle(RoomDetailAction.EnterTrackingUnreadMessagesState) + timelineViewModel.handle(RoomDetailAction.EnterTrackingUnreadMessagesState) } override fun onPreviewUrlClicked(url: String) { @@ -1930,7 +2050,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?) { @@ -2007,10 +2127,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 -> { @@ -2046,11 +2166,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 { @@ -2067,26 +2187,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 -> { @@ -2102,7 +2236,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 { @@ -2115,13 +2249,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() } @@ -2132,7 +2270,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() } @@ -2152,7 +2290,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) } @@ -2205,29 +2343,58 @@ 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) + timelineViewModel.handle(RoomDetailAction.AcceptInvite) } override fun onRejectInvite() { - notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) } - roomDetailViewModel.handle(RoomDetailAction.RejectInvite) + 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 @@ -2262,13 +2429,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 @@ -2277,13 +2444,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)) @@ -2321,4 +2487,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 90% 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 9149ae1dca..0198c77280 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,6 +53,7 @@ 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.notifications.NotificationDrawerManager import im.vector.app.features.powerlevel.PowerLevelsFlowFactory import im.vector.app.features.session.coroutineScope import im.vector.app.features.settings.VectorDataStore @@ -78,6 +79,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 @@ -90,11 +92,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 @@ -103,7 +108,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, @@ -119,6 +124,7 @@ class RoomDetailViewModel @AssistedInject constructor( private val analyticsTracker: AnalyticsTracker, private val activeConferenceHolder: JitsiActiveConferenceHolder, private val decryptionFailureTracker: DecryptionFailureTracker, + private val notificationDrawerManager: NotificationDrawerManager, timelineFactory: TimelineFactory, appStateHandler: AppStateHandler ) : VectorViewModel(initialState), @@ -129,7 +135,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) @@ -146,16 +152,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() @@ -186,6 +192,11 @@ class RoomDetailViewModel @AssistedInject constructor( prepareForEncryption() } + // If the user had already accepted the invitation in the room list + if (initialState.isInviteAlreadyAccepted) { + handleAcceptInvite() + } + if (initialState.switchToParentSpace) { // We are coming from a notification, try to switch to the most relevant space // so that when hitting back the room will appear in the list @@ -203,6 +214,17 @@ class RoomDetailViewModel @AssistedInject constructor( } } } + + // Threads + initThreads() + } + + /** + * Threads specific initialization + */ + private fun initThreads() { + markThreadTimelineAsReadLocal() + observeLocalThreadNotifications() } private fun observeDataStore() { @@ -316,8 +338,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) @@ -463,7 +517,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) { @@ -650,20 +708,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 + } } } @@ -679,19 +747,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) { @@ -726,16 +807,24 @@ class RoomDetailViewModel @AssistedInject constructor( } private fun handleRejectInvite() { + notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(initialState.roomId) } viewModelScope.launch { - tryOrNull { room.leave(null) } + try { + session.leaveRoom(room.roomId) + } catch (throwable: Throwable) { + _viewEvents.post(RoomDetailViewEvents.Failure(throwable, showInDialog = true)) + } } } private fun handleAcceptInvite() { + notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(initialState.roomId) } viewModelScope.launch { - tryOrNull { - room.join() + try { + session.joinRoom(room.roomId) analyticsTracker.capture(room.roomSummary().toAnalyticsJoinedRoom()) + } catch (throwable: Throwable) { + _viewEvents.post(RoomDetailViewEvents.Failure(throwable, showInDialog = true)) } } } @@ -1128,6 +1217,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..a21567acb1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/arguments/TimelineArgs.kt @@ -0,0 +1,33 @@ +/* + * 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, + val isInviteAlreadyAccepted: 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..43fa9e0c2e 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 @@ -473,9 +516,13 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec val event = itr.previous() timelineEventsGroups.addOrIgnore(event) val currentReadReceipts = ArrayList(event.readReceipts).filter { - it.user.userId != session.myUserId + it.roomMember.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 086a093068..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 @@ -45,6 +45,7 @@ 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 @@ -77,10 +78,12 @@ 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 locationUrl = state.timelineEvent()?.root?.getClearContent() + + val locationContent = state.timelineEvent()?.root?.getClearContent() ?.toModel(catchError = true) - ?.toLocationData() + 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") @@ -96,6 +99,7 @@ class MessageActionsEpoxyController @Inject constructor( time(formattedDate) 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..76ed024370 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 @@ -16,6 +16,7 @@ package im.vector.app.features.home.room.detail.timeline.factory +import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.extensions.prevOrNull import im.vector.app.features.home.AvatarRenderer @@ -26,10 +27,10 @@ import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisi import im.vector.app.features.home.room.detail.timeline.helper.canBeMerged import im.vector.app.features.home.room.detail.timeline.helper.isRoomConfiguration import im.vector.app.features.home.room.detail.timeline.item.BasedMergedItem -import im.vector.app.features.home.room.detail.timeline.item.MergedMembershipEventsItem -import im.vector.app.features.home.room.detail.timeline.item.MergedMembershipEventsItem_ import im.vector.app.features.home.room.detail.timeline.item.MergedRoomCreationItem import im.vector.app.features.home.room.detail.timeline.item.MergedRoomCreationItem_ +import im.vector.app.features.home.room.detail.timeline.item.MergedSimilarEventsItem +import im.vector.app.features.home.room.detail.timeline.item.MergedSimilarEventsItem_ import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovementMethod import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.query.QueryStringValue @@ -82,8 +83,14 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde event: TimelineEvent, eventIdToHighlight: String?, requestModelBuild: () -> Unit, - callback: TimelineEventController.Callback?): MergedMembershipEventsItem_? { - val mergedEvents = timelineEventVisibilityHelper.prevSameTypeEvents(items, currentPosition, 2, eventIdToHighlight) + callback: TimelineEventController.Callback?): MergedSimilarEventsItem_? { + val mergedEvents = timelineEventVisibilityHelper.prevSameTypeEvents( + items, + currentPosition, + 2, + eventIdToHighlight, + partialState.rootThreadEventId, + partialState.isFromThreadTimeline()) return if (mergedEvents.isEmpty()) { null } else { @@ -116,23 +123,31 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde collapsedEventIds.removeAll(mergedEventIds) } val mergeId = mergedEventIds.joinToString(separator = "_") { it.toString() } - val attributes = MergedMembershipEventsItem.Attributes( - isCollapsed = isCollapsed, - mergeData = mergedData, - avatarRenderer = avatarRenderer, - onCollapsedStateChanged = { - mergeItemCollapseStates[event.localId] = it - requestModelBuild() - } - ) - MergedMembershipEventsItem_() - .id(mergeId) - .leftGuideline(avatarSizeProvider.leftGuideline) - .highlighted(isCollapsed && highlighted) - .attributes(attributes) - .also { - it.setOnVisibilityStateChanged(MergedTimelineEventVisibilityStateChangedListener(callback, mergedEvents)) - } + val summaryTitleResId = when (event.root.getClearType()) { + EventType.STATE_ROOM_MEMBER -> R.plurals.membership_changes + EventType.STATE_ROOM_SERVER_ACL -> R.plurals.notice_room_server_acl_changes + else -> null + } + summaryTitleResId?.let { summaryTitle -> + val attributes = MergedSimilarEventsItem.Attributes( + summaryTitleResId = summaryTitle, + isCollapsed = isCollapsed, + mergeData = mergedData, + avatarRenderer = avatarRenderer, + onCollapsedStateChanged = { + mergeItemCollapseStates[event.localId] = it + requestModelBuild() + } + ) + MergedSimilarEventsItem_() + .id(mergeId) + .leftGuideline(avatarSizeProvider.leftGuideline) + .highlighted(isCollapsed && highlighted) + .attributes(attributes) + .also { + it.setOnVisibilityStateChanged(MergedTimelineEventVisibilityStateChangedListener(callback, mergedEvents)) + } + } } } 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 352b87a4d8..aa1758dd6c 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 @@ -16,7 +16,6 @@ package im.vector.app.features.home.room.detail.timeline.factory -import android.content.res.Resources import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned @@ -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 @@ -64,9 +61,9 @@ import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem_ import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem_ +import im.vector.app.features.home.room.detail.timeline.render.EventTextRenderer import im.vector.app.features.home.room.detail.timeline.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 @@ -79,11 +76,11 @@ 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 @@ -106,6 +103,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( @@ -115,6 +113,7 @@ class MessageItemFactory @Inject constructor( private val timelineMediaSizeProvider: TimelineMediaSizeProvider, private val htmlRenderer: Lazy, private val htmlCompressor: VectorHtmlCompressor, + private val textRendererFactory: EventTextRenderer.Factory, private val stringProvider: StringProvider, private val imageContentRenderer: ImageContentRenderer, private val messageInformationDataFactory: MessageInformationDataFactory, @@ -125,13 +124,13 @@ 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 urlMapProvider: UrlMapProvider, - private val resources: Resources ) { // TODO inject this properly? @@ -141,6 +140,10 @@ class MessageItemFactory @Inject constructor( pillsPostProcessorFactory.create(roomId) } + private val textRenderer by lazy { + textRendererFactory.create(roomId) + } + fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*>? { val event = params.event val highlight = params.isHighlighted @@ -148,9 +151,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) } @@ -165,11 +170,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) @@ -194,23 +206,30 @@ class MessageItemFactory @Inject constructor( } else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes) } + return messageItem?.apply { + layout(informationData.messageLayout.layoutRes) + } } private fun buildLocationItem(locationContent: MessageLocationContent, informationData: MessageInformationData, highlight: Boolean, attributes: AbsMessageItem.Attributes): MessageLocationItem? { - val width = resources.displayMetrics.widthPixels - dimensionConverter.dpToPx(60) + val width = timelineMediaSizeProvider.getMaxSize().first val height = dimensionConverter.dpToPx(200) 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) .locationUrl(locationUrl) - .userId(informationData.senderId) + .mapWidth(width) + .mapHeight(height) + .userId(userId) .locationPinProvider(locationPinProvider) .highlighted(highlight) .leftGuideline(avatarSizeProvider.leftGuideline) @@ -512,46 +531,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, @@ -560,8 +555,9 @@ class MessageItemFactory @Inject constructor( highlight: Boolean, callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes): MessageTextItem? { - val bindingOptions = spanUtils.getBindingOptions(body) - val linkifiedBody = body.linkify(callback) + val renderedBody = textRenderer.render(body) + val bindingOptions = spanUtils.getBindingOptions(renderedBody) + val linkifiedBody = renderedBody.linkify(callback) return MessageTextItem_() .message( @@ -584,24 +580,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 { @@ -707,6 +685,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..e66dd4b043 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,13 +26,17 @@ 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 } val readReceiptsData = readReceipts .map { - ReadReceiptData(it.user.userId, it.user.avatarUrl, it.user.displayName, it.originServerTs) + ReadReceiptData(it.roomMember.userId, it.roomMember.avatarUrl, it.roomMember.displayName, it.originServerTs) } .toList() @@ -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 e92376c44d..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 @@ -45,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 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/TimelineDisplayableEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt index bcccbc9f7c..53a9fbbaea 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt @@ -56,7 +56,8 @@ object TimelineDisplayableEvents { } fun TimelineEvent.canBeMerged(): Boolean { - return root.getClearType() == EventType.STATE_ROOM_MEMBER + return root.getClearType() == EventType.STATE_ROOM_MEMBER || + root.getClearType() == EventType.STATE_ROOM_SERVER_ACL } fun TimelineEvent.isRoomConfiguration(roomCreatorUserId: String?): Boolean { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt index 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..430e0970bb 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.getQuantityString(R.plurals.message_reaction_show_more, moreCount, 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/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() { +abstract class MergedSimilarEventsItem : BasedMergedItem() { - override fun getViewType() = STUB_ID + override fun getViewStubId() = STUB_ID @EpoxyAttribute override lateinit var attributes: Attributes @@ -37,7 +38,7 @@ abstract class MergedMembershipEventsItem : BasedMergedItem, override val avatarRenderer: AvatarRenderer, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageBlockCodeItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageBlockCodeItem.kt deleted file mode 100644 index 915ad6a17d..0000000000 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageBlockCodeItem.kt +++ /dev/null @@ -1,57 +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.detail.timeline.item - -import android.widget.TextView -import com.airbnb.epoxy.EpoxyAttribute -import com.airbnb.epoxy.EpoxyModelClass -import im.vector.app.R -import im.vector.app.core.epoxy.onClick -import im.vector.app.core.extensions.setTextOrHide -import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence -import me.saket.bettermovementmethod.BetterLinkMovementMethod - -@EpoxyModelClass(layout = R.layout.item_timeline_event_base) -abstract class MessageBlockCodeItem : AbsMessageItem() { - - @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 6f0b6abb72..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,13 +16,25 @@ package im.vector.app.features.home.room.detail.timeline.item +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.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.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() { @@ -34,32 +46,66 @@ abstract class MessageLocationItem : AbsMessageItem( 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.view, null) - val location = locationUrl ?: return - val locationOwnerId = userId ?: 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.staticMapImageView.updateLayoutParams { + width = mapWidth + height = mapHeight + } GlideApp.with(holder.staticMapImageView) .load(location) .apply(RequestOptions.centerCropTransform()) - .into(holder.staticMapImageView) + .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 + } - locationPinProvider?.create(locationOwnerId) { pinDrawable -> - GlideApp.with(holder.staticMapPinImageView) - .load(pinDrawable) - .into(holder.staticMapPinImageView) - } + 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 staticMapImageView by bind(R.id.staticMapImageView) val staticMapPinImageView by bind(R.id.staticMapPinImageView) + val staticMapErrorTextView by bind(R.id.staticMapErrorTextView) } companion object { 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 e5ac321d90..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 @@ -121,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= 0) { + val endSpan = foundIndex + MatrixItem.NOTIFY_EVERYONE.length + addPillSpan(text, createPillImageSpan(matrixItem), foundIndex, endSpan) + foundIndex = text.indexOf(MatrixItem.NOTIFY_EVERYONE, endSpan) + } + } + + private fun createPillImageSpan(matrixItem: MatrixItem) = + PillImageSpan(GlideApp.with(context), avatarRenderer, context, matrixItem) + + private fun addPillSpan( + renderedText: Spannable, + pillSpan: PillImageSpan, + startSpan: Int, + endSpan: Int + ) { + renderedText.setSpan(pillSpan, startSpan, endSpan, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/CornersRadius.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/CornersRadius.kt new file mode 100644 index 0000000000..c10bebdcf4 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/CornersRadius.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.style + +import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners +import com.google.android.material.shape.CornerFamily +import com.google.android.material.shape.ShapeAppearanceModel + +fun TimelineMessageLayout.Bubble.CornersRadius.granularRoundedCorners(): GranularRoundedCorners { + return GranularRoundedCorners(topStartRadius, topEndRadius, bottomEndRadius, bottomStartRadius) +} + +fun TimelineMessageLayout.Bubble.CornersRadius.shapeAppearanceModel(): ShapeAppearanceModel { + return ShapeAppearanceModel().toBuilder() + .setTopRightCorner(topEndRadius.cornerFamily(), topEndRadius) + .setBottomRightCorner(bottomEndRadius.cornerFamily(), bottomEndRadius) + .setTopLeftCorner(topStartRadius.cornerFamily(), topStartRadius) + .setBottomLeftCorner(bottomStartRadius.cornerFamily(), bottomStartRadius) + .build() +} + +private fun Float.cornerFamily(): Int { + return if (this == 0F) CornerFamily.CUT else CornerFamily.ROUNDED +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineLayoutSettings.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineLayoutSettings.kt new file mode 100644 index 0000000000..873ef8c907 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineLayoutSettings.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.style + +enum class TimelineLayoutSettings { + MODERN, + BUBBLE +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineLayoutSettingsProvider.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineLayoutSettingsProvider.kt new file mode 100644 index 0000000000..9e351a706d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineLayoutSettingsProvider.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.style + +import im.vector.app.features.settings.VectorPreferences +import javax.inject.Inject + +class TimelineLayoutSettingsProvider @Inject constructor(private val vectorPreferences: VectorPreferences) { + + fun getLayoutSettings(): TimelineLayoutSettings { + return if (vectorPreferences.useMessageBubblesLayout()) { + TimelineLayoutSettings.BUBBLE + } else { + TimelineLayoutSettings.MODERN + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayout.kt new file mode 100644 index 0000000000..c87680de0a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayout.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.home.room.detail.timeline.style + +import android.os.Parcelable +import im.vector.app.R +import kotlinx.parcelize.Parcelize + +sealed interface TimelineMessageLayout : Parcelable { + val layoutRes: Int + val showAvatar: Boolean + val showDisplayName: Boolean + val showTimestamp: Boolean + + @Parcelize + data class Default(override val showAvatar: Boolean, + override val showDisplayName: Boolean, + override val showTimestamp: Boolean, + // Keep defaultLayout generated on epoxy items + override val layoutRes: Int = 0) : TimelineMessageLayout + + @Parcelize + data class Bubble( + override val showAvatar: Boolean, + override val showDisplayName: Boolean, + override val showTimestamp: Boolean = true, + val isIncoming: Boolean, + val isPseudoBubble: Boolean, + val cornersRadius: CornersRadius, + val timestampAsOverlay: Boolean, + override val layoutRes: Int = if (isIncoming) { + R.layout.item_timeline_event_bubble_incoming_base + } else { + R.layout.item_timeline_event_bubble_outgoing_base + } + ) : TimelineMessageLayout { + + @Parcelize + data class CornersRadius( + val topStartRadius: Float, + val topEndRadius: Float, + val bottomStartRadius: Float, + val bottomEndRadius: Float + ) : Parcelable + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt new file mode 100644 index 0000000000..8c1c308bb5 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt @@ -0,0 +1,197 @@ +/* + * 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.style + +import android.content.res.Resources +import im.vector.app.R +import im.vector.app.core.extensions.localDateTime +import im.vector.app.core.resources.LocaleProvider +import im.vector.app.core.resources.isRTL +import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFactoryParams +import im.vector.app.features.settings.VectorPreferences +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.model.message.MessageType +import org.matrix.android.sdk.api.session.room.model.message.MessageVerificationRequestContent +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.isEdition +import javax.inject.Inject + +class TimelineMessageLayoutFactory @Inject constructor(private val session: Session, + private val layoutSettingsProvider: TimelineLayoutSettingsProvider, + private val localeProvider: LocaleProvider, + private val resources: Resources, + private val vectorPreferences: VectorPreferences) { + + companion object { + // Can be rendered in bubbles, other types will fallback to default + private val EVENT_TYPES_WITH_BUBBLE_LAYOUT = setOf( + EventType.MESSAGE, + EventType.POLL_START, + EventType.ENCRYPTED, + EventType.STICKER + ) + + // Can't be rendered in bubbles, so get back to default layout + private val MSG_TYPES_WITHOUT_BUBBLE_LAYOUT = setOf( + MessageType.MSGTYPE_VERIFICATION_REQUEST + ) + + // Use the bubble layout but without borders + private val MSG_TYPES_WITH_PSEUDO_BUBBLE_LAYOUT = setOf( + MessageType.MSGTYPE_IMAGE, + MessageType.MSGTYPE_VIDEO, + MessageType.MSGTYPE_STICKER_LOCAL, + MessageType.MSGTYPE_EMOTE + ) + private val MSG_TYPES_WITH_TIMESTAMP_AS_OVERLAY = setOf( + MessageType.MSGTYPE_IMAGE, MessageType.MSGTYPE_VIDEO + ) + } + + private val cornerRadius: Float by lazy { + resources.getDimensionPixelSize(R.dimen.chat_bubble_corner_radius).toFloat() + } + + private val isRTL: Boolean by lazy { + localeProvider.isRTL() + } + + fun create(params: TimelineItemFactoryParams): TimelineMessageLayout { + val event = params.event + val nextDisplayableEvent = params.nextDisplayableEvent + val prevDisplayableEvent = params.prevDisplayableEvent + val isSentByMe = event.root.senderId == session.myUserId + + 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 messageLayout = when (layoutSettingsProvider.getLayoutSettings()) { + TimelineLayoutSettings.MODERN -> { + 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/filtered/FilteredRoomsActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/filtered/FilteredRoomsActivity.kt index 0e16b4b0df..cf7c4a0e80 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/filtered/FilteredRoomsActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/filtered/FilteredRoomsActivity.kt @@ -24,7 +24,7 @@ import dagger.hilt.android.AndroidEntryPoint import im.vector.app.core.extensions.replaceFragment import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivityFilteredRoomsBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.home.RoomListDisplayMode import im.vector.app.features.home.room.list.RoomListFragment import im.vector.app.features.home.room.list.RoomListParams @@ -43,7 +43,7 @@ class FilteredRoomsActivity : VectorBaseActivity() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.RoomFilter + analyticsScreenName = MobileScreen.ScreenName.RoomFilter setupToolbar(views.filteredRoomsToolbar) .allowBack() if (isFirstCreation()) { 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/RoomListFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt index b6481c9cbb..28849204c4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt @@ -42,7 +42,7 @@ import im.vector.app.core.platform.StateView import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.resources.UserPreferencesProvider import im.vector.app.databinding.FragmentRoomListBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.home.RoomListDisplayMode import im.vector.app.features.home.room.filtered.FilteredRoomFooterItem import im.vector.app.features.home.room.list.actions.RoomListQuickActionsBottomSheet @@ -104,8 +104,8 @@ class RoomListFragment @Inject constructor( override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) analyticsScreenName = when (roomListParams.displayMode) { - RoomListDisplayMode.PEOPLE -> Screen.ScreenName.MobilePeople - RoomListDisplayMode.ROOMS -> Screen.ScreenName.MobileRooms + RoomListDisplayMode.PEOPLE -> MobileScreen.ScreenName.People + RoomListDisplayMode.ROOMS -> MobileScreen.ScreenName.Rooms else -> null } } @@ -121,7 +121,7 @@ class RoomListFragment @Inject constructor( when (it) { is RoomListViewEvents.Loading -> showLoading(it.message) is RoomListViewEvents.Failure -> showFailure(it.throwable) - is RoomListViewEvents.SelectRoom -> handleSelectRoom(it) + is RoomListViewEvents.SelectRoom -> handleSelectRoom(it, it.isInviteAlreadyAccepted) is RoomListViewEvents.Done -> Unit is RoomListViewEvents.NavigateToMxToBottomSheet -> handleShowMxToLink(it.link) }.exhaustive @@ -184,8 +184,8 @@ class RoomListFragment @Inject constructor( super.onDestroyView() } - private fun handleSelectRoom(event: RoomListViewEvents.SelectRoom) { - navigator.openRoom(requireActivity(), event.roomSummary.roomId) + private fun handleSelectRoom(event: RoomListViewEvents.SelectRoom, isInviteAlreadyAccepted: Boolean) { + navigator.openRoom(context = requireActivity(), roomId = event.roomSummary.roomId, isInviteAlreadyAccepted = isInviteAlreadyAccepted) } private fun setupCreateRoomButton() { diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewEvents.kt index df2ff58da6..15e16c464f 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewEvents.kt @@ -27,7 +27,7 @@ sealed class RoomListViewEvents : VectorViewEvents { data class Loading(val message: CharSequence? = null) : RoomListViewEvents() data class Failure(val throwable: Throwable) : RoomListViewEvents() - data class SelectRoom(val roomSummary: RoomSummary) : RoomListViewEvents() + data class SelectRoom(val roomSummary: RoomSummary, val isInviteAlreadyAccepted: Boolean = false) : RoomListViewEvents() object Done : RoomListViewEvents() data class NavigateToMxToBottomSheet(val link: String) : RoomListViewEvents() } 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..4a81a8b526 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 @@ -33,7 +33,6 @@ import im.vector.app.core.extensions.exhaustive import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.resources.StringProvider import im.vector.app.features.analytics.AnalyticsTracker -import im.vector.app.features.analytics.extensions.toAnalyticsJoinedRoom import im.vector.app.features.displayname.getBestName import im.vector.app.features.invite.AutoAcceptInvites import im.vector.app.features.settings.VectorPreferences @@ -174,7 +173,7 @@ class RoomListViewModel @AssistedInject constructor( // PRIVATE METHODS ***************************************************************************** private fun handleSelectRoom(action: RoomListAction.SelectRoom) = withState { - _viewEvents.post(RoomListViewEvents.SelectRoom(action.roomSummary)) + _viewEvents.post(RoomListViewEvents.SelectRoom(action.roomSummary, false)) } private fun handleToggleSection(roomSection: RoomsSection) { @@ -208,6 +207,7 @@ class RoomListViewModel @AssistedInject constructor( Timber.w("Try to join an already joining room. Should not happen") return@withState } + _viewEvents.post(RoomListViewEvents.SelectRoom(action.roomSummary, true)) // quick echo setState { @@ -221,19 +221,6 @@ class RoomListViewModel @AssistedInject constructor( } ) } - - val room = session.getRoom(roomId) ?: return@withState - viewModelScope.launch { - try { - room.join() - 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 - } catch (failure: Throwable) { - // Notify the user - _viewEvents.post(RoomListViewEvents.Failure(failure)) - } - } } private fun handleRejectInvitation(action: RoomListAction.RejectInvitation) = withState { state -> @@ -245,10 +232,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 +319,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..ae285b074c 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 @@ -19,6 +19,7 @@ package im.vector.app.features.html import android.content.Context +import android.content.res.ColorStateList import android.graphics.Canvas import android.graphics.Paint import android.graphics.drawable.Drawable @@ -32,6 +33,7 @@ import im.vector.app.R import im.vector.app.core.glide.GlideRequests import im.vector.app.features.displayname.getBestName import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.themes.ThemeUtils import org.matrix.android.sdk.api.session.room.send.MatrixItemSpan import org.matrix.android.sdk.api.util.MatrixItem import java.lang.ref.WeakReference @@ -65,10 +67,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 +89,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() @@ -110,6 +119,11 @@ class PillImageSpan(private val glideRequests: GlideRequests, setChipMinHeightResource(R.dimen.pill_min_height) setChipIconSizeResource(R.dimen.pill_avatar_size) chipIcon = icon + if (matrixItem is MatrixItem.EveryoneInRoomItem) { + chipBackgroundColor = ColorStateList.valueOf(ThemeUtils.getColor(context, R.attr.colorError)) + // setTextColor API does not exist right now for ChipDrawable, use textAppearance + setTextAppearanceResource(R.style.TextAppearance_Vector_Body_OnError) + } setBounds(0, 0, intrinsicWidth, intrinsicHeight) } } diff --git a/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt b/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt index f8a2ee5137..506f5e773c 100644 --- a/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt +++ b/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt @@ -36,57 +36,87 @@ class PillsPostProcessor @AssistedInject constructor(@Assisted private val roomI private val context: Context, private val avatarRenderer: AvatarRenderer, private val sessionHolder: ActiveSessionHolder) : - EventHtmlRenderer.PostProcessor { + EventHtmlRenderer.PostProcessor { + + /* ========================================================================================== + * Public api + * ========================================================================================== */ @AssistedFactory interface Factory { fun create(roomId: String?): PillsPostProcessor } + /* ========================================================================================== + * Specialization + * ========================================================================================== */ + override fun afterRender(renderedText: Spannable) { addPillSpans(renderedText, roomId) } + /* ========================================================================================== + * Helper methods + * ========================================================================================== */ + private fun addPillSpans(renderedText: Spannable, roomId: String?) { + addLinkSpans(renderedText, roomId) + } + + private fun addPillSpan( + renderedText: Spannable, + pillSpan: PillImageSpan, + startSpan: Int, + endSpan: Int + ) { + renderedText.setSpan(pillSpan, startSpan, endSpan, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + private fun addLinkSpans(renderedText: Spannable, roomId: String?) { // We let markdown handle links and then we add PillImageSpan if needed. val linkSpans = renderedText.getSpans(0, renderedText.length, LinkSpan::class.java) linkSpans.forEach { linkSpan -> val pillSpan = linkSpan.createPillSpan(roomId) ?: return@forEach val startSpan = renderedText.getSpanStart(linkSpan) val endSpan = renderedText.getSpanEnd(linkSpan) - renderedText.setSpan(pillSpan, startSpan, endSpan, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + addPillSpan(renderedText, pillSpan, startSpan, endSpan) } } + private fun createPillImageSpan(matrixItem: MatrixItem) = + PillImageSpan(GlideApp.with(context), avatarRenderer, context, matrixItem) + private fun LinkSpan.createPillSpan(roomId: String?): PillImageSpan? { - val permalinkData = PermalinkParser.parse(url) - val matrixItem = when (permalinkData) { - is PermalinkData.UserLink -> { - if (roomId == null) { - sessionHolder.getSafeActiveSession()?.getUser(permalinkData.userId)?.toMatrixItem() - } else { - sessionHolder.getSafeActiveSession()?.getRoomMember(permalinkData.userId, roomId)?.toMatrixItem() - } - } - is PermalinkData.RoomLink -> { - if (permalinkData.eventId == null) { - val room: RoomSummary? = sessionHolder.getSafeActiveSession()?.getRoomSummary(permalinkData.roomIdOrAlias) - if (permalinkData.isRoomAlias) { - MatrixItem.RoomAliasItem(permalinkData.roomIdOrAlias, room?.displayName, room?.avatarUrl) - } else { - MatrixItem.RoomItem(permalinkData.roomIdOrAlias, room?.displayName, room?.avatarUrl) - } - } else { - // Exclude event link (used in reply events, we do not want to pill the "in reply to") - null - } - } - is PermalinkData.GroupLink -> { - val group = sessionHolder.getSafeActiveSession()?.getGroupSummary(permalinkData.groupId) - MatrixItem.GroupItem(permalinkData.groupId, group?.displayName, group?.avatarUrl) - } + val matrixItem = when (val permalinkData = PermalinkParser.parse(url)) { + is PermalinkData.UserLink -> permalinkData.toMatrixItem(roomId) + is PermalinkData.RoomLink -> permalinkData.toMatrixItem() + is PermalinkData.GroupLink -> permalinkData.toMatrixItem() else -> null } ?: return null - return PillImageSpan(GlideApp.with(context), avatarRenderer, context, matrixItem) + return createPillImageSpan(matrixItem) + } + + private fun PermalinkData.UserLink.toMatrixItem(roomId: String?): MatrixItem? = + if (roomId == null) { + sessionHolder.getSafeActiveSession()?.getUser(userId)?.toMatrixItem() + } else { + sessionHolder.getSafeActiveSession()?.getRoomMember(userId, roomId)?.toMatrixItem() + } + + private fun PermalinkData.RoomLink.toMatrixItem(): MatrixItem? = + if (eventId == null) { + val room: RoomSummary? = sessionHolder.getSafeActiveSession()?.getRoomSummary(roomIdOrAlias) + when { + isRoomAlias -> MatrixItem.RoomAliasItem(roomIdOrAlias, room?.displayName, room?.avatarUrl) + else -> MatrixItem.RoomItem(roomIdOrAlias, room?.displayName, room?.avatarUrl) + } + } else { + // Exclude event link (used in reply events, we do not want to pill the "in reply to") + null + } + + private fun PermalinkData.GroupLink.toMatrixItem(): MatrixItem? { + val group = sessionHolder.getSafeActiveSession()?.getGroupSummary(groupId) + return MatrixItem.GroupItem(groupId, group?.displayName, group?.avatarUrl) } } 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 29ca6b81a9..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 @@ -18,6 +18,7 @@ package im.vector.app.features.location 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 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 c4f2f148bf..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 @@ -54,8 +54,11 @@ class LocationPreviewFragment @Inject constructor( mapView = WeakReference(views.mapView) views.mapView.onCreate(savedInstanceState) - views.mapView.initialize(urlMapProvider.mapUrl) - loadPinDrawable() + + lifecycleScope.launchWhenCreated { + views.mapView.initialize(urlMapProvider.getMapUrl()) + loadPinDrawable() + } } override fun onResume() { @@ -121,7 +124,7 @@ class LocationPreviewFragment @Inject constructor( MapState( zoomOnlyOnce = true, pinLocationData = location, - pinId = args.locationOwnerId, + pinId = args.locationOwnerId ?: DEFAULT_PIN_ID, pinDrawable = pinDrawable ) ) 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 f6bad2826b..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 @@ -21,6 +21,7 @@ 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 @@ -53,7 +54,10 @@ class LocationSharingFragment @Inject constructor( mapView = WeakReference(views.mapView) views.mapView.onCreate(savedInstanceState) - views.mapView.initialize(urlMapProvider.mapUrl) + + lifecycleScope.launchWhenCreated { + views.mapView.initialize(urlMapProvider.getMapUrl()) + } views.shareLocationContainer.debouncedClicks { viewModel.handle(LocationSharingAction.OnShareLocation) @@ -118,8 +122,4 @@ class LocationSharingFragment @Inject constructor( views.mapView.render(state.toMapState()) views.shareLocationGpsLoading.isGone = state.lastKnownLocation != null } - - companion object { - const val USER_PIN_NAME = "USER_PIN_NAME" - } } 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 f3b937855a..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 @@ -42,6 +42,6 @@ data class LocationSharingViewState( fun LocationSharingViewState.toMapState() = MapState( zoomOnlyOnce = true, pinLocationData = lastKnownLocation, - pinId = LocationSharingFragment.USER_PIN_NAME, + pinId = DEFAULT_PIN_ID, pinDrawable = pinDrawable ) 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 index 76d44f5ece..adb5c27a02 100644 --- a/vector/src/main/java/im/vector/app/features/location/UrlMapProvider.kt +++ b/vector/src/main/java/im/vector/app/features/location/UrlMapProvider.kt @@ -16,22 +16,34 @@ package im.vector.app.features.location -import android.content.res.Resources import im.vector.app.BuildConfig -import im.vector.app.R +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 resources: Resources + private val localeProvider: LocaleProvider, + private val session: Session, + private val rawService: RawService ) { private val keyParam = "?key=${BuildConfig.mapTilerKey}" - // This is static so no need for a fun - val mapUrl = buildString { + 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, @@ -49,7 +61,7 @@ class UrlMapProvider @Inject constructor( append(height) append(".png") append(keyParam) - if (!resources.getBoolean(R.bool.is_rtl)) { + 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/login/LoginActivity.kt b/vector/src/main/java/im/vector/app/features/login/LoginActivity.kt index edc77d73f6..bf596fc6aa 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginActivity.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginActivity.kt @@ -38,7 +38,7 @@ import im.vector.app.core.extensions.addFragmentToBackstack import im.vector.app.core.extensions.exhaustive import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivityLoginBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.home.HomeActivity import im.vector.app.features.login.terms.LoginTermsFragment import im.vector.app.features.login.terms.LoginTermsFragmentArgument @@ -81,7 +81,7 @@ open class LoginActivity : VectorBaseActivity(), UnlockedA override fun getCoordinatorLayout() = views.coordinatorLayout override fun initUiAndData() { - analyticsScreenName = Screen.ScreenName.Login + analyticsScreenName = MobileScreen.ScreenName.Login if (isFirstCreation()) { addFirstFragment() @@ -203,7 +203,7 @@ open class LoginActivity : VectorBaseActivity(), UnlockedA if (loginViewState.isUserLogged()) { if (loginViewState.signMode == SignMode.SignUp) { // change the screen name - analyticsScreenName = Screen.ScreenName.Register + analyticsScreenName = MobileScreen.ScreenName.Register } val intent = HomeActivity.newIntent( this, diff --git a/vector/src/main/java/im/vector/app/features/login/LoginResetPasswordFragment.kt b/vector/src/main/java/im/vector/app/features/login/LoginResetPasswordFragment.kt index 0328d09427..d121245532 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginResetPasswordFragment.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginResetPasswordFragment.kt @@ -31,7 +31,7 @@ import im.vector.app.core.extensions.hidePassword import im.vector.app.core.extensions.isEmail import im.vector.app.core.extensions.toReducedUrl import im.vector.app.databinding.FragmentLoginResetPasswordBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -48,7 +48,7 @@ class LoginResetPasswordFragment @Inject constructor() : AbstractLoginFragment() { - val softLogoutViewModel: SoftLogoutViewModel by activityViewModel() + private val softLogoutViewModel: SoftLogoutViewModel by activityViewModel() override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginWebBinding { return FragmentLoginWebBinding.inflate(inflater, container, false) diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginWebFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginWebFragment2.kt index ebe59ee1b9..4427f08309 100644 --- a/vector/src/main/java/im/vector/app/features/login2/LoginWebFragment2.kt +++ b/vector/src/main/java/im/vector/app/features/login2/LoginWebFragment2.kt @@ -56,7 +56,7 @@ class LoginWebFragment2 @Inject constructor( return FragmentLoginWebBinding.inflate(inflater, container, false) } - val softLogoutViewModel: SoftLogoutViewModel by activityViewModel() + private val softLogoutViewModel: SoftLogoutViewModel by activityViewModel() private var isWebViewLoaded = false private var isForSessionRecovery = false @@ -82,7 +82,7 @@ class LoginWebFragment2 @Inject constructor( private fun setupTitle(state: LoginViewState2) { toolbar?.title = when (state.signMode) { SignMode2.SignIn -> getString(R.string.login_signin) - else -> getString(R.string.login_signup) + else -> getString(R.string.login_signup) } } 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 8f0b1723a0..cc02687d93 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,18 @@ 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, + isInviteAlreadyAccepted: 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 = roomId, eventId = eventId, isInviteAlreadyAccepted = isInviteAlreadyAccepted) val intent = RoomDetailActivity.newIntent(context, args) startActivity(context, intent, buildTask) } @@ -168,7 +177,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 +275,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 +524,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) } @@ -545,7 +557,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) @@ -562,4 +574,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 d6ca83a8a3..a31dc8fb89 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 @@ -49,7 +50,7 @@ interface Navigator { fun softLogout(context: Context) - fun openRoom(context: Context, roomId: String, eventId: String? = null, buildTask: Boolean = false) + fun openRoom(context: Context, roomId: String, eventId: String? = null, buildTask: Boolean = false, isInviteAlreadyAccepted: Boolean = false) sealed class PostSwitchSpaceAction { object None : PostSwitchSpaceAction() @@ -145,7 +146,7 @@ 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) @@ -161,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/BitmapLoader.kt b/vector/src/main/java/im/vector/app/features/notifications/BitmapLoader.kt deleted file mode 100644 index d1c4624cdc..0000000000 --- a/vector/src/main/java/im/vector/app/features/notifications/BitmapLoader.kt +++ /dev/null @@ -1,67 +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.notifications - -import android.content.Context -import android.graphics.Bitmap -import androidx.annotation.WorkerThread -import com.bumptech.glide.Glide -import com.bumptech.glide.load.DecodeFormat -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class BitmapLoader @Inject constructor(private val context: Context) { - - /** - * Avatar Url -> Bitmap - */ - private val cache = HashMap() - - /** - * Get icon of a room. - * If already in cache, use it, else load it and call BitmapLoaderListener.onBitmapsLoaded() when ready - */ - @WorkerThread - fun getRoomBitmap(path: String?): Bitmap? { - if (path == null) { - return null - } - - return cache.getOrPut(path) { - loadRoomBitmap(path) - } - } - - @WorkerThread - private fun loadRoomBitmap(path: String): Bitmap? { - return path.let { - try { - Glide.with(context) - .asBitmap() - .load(path) - .format(DecodeFormat.PREFER_ARGB_8888) - .submit() - .get() - } catch (e: Exception) { - Timber.e(e, "decodeFile failed") - null - } - } - } -} diff --git a/vector/src/main/java/im/vector/app/features/notifications/IconLoader.kt b/vector/src/main/java/im/vector/app/features/notifications/IconLoader.kt deleted file mode 100644 index 3e68744c88..0000000000 --- a/vector/src/main/java/im/vector/app/features/notifications/IconLoader.kt +++ /dev/null @@ -1,73 +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.notifications - -import android.content.Context -import android.os.Build -import androidx.annotation.WorkerThread -import androidx.core.graphics.drawable.IconCompat -import com.bumptech.glide.Glide -import com.bumptech.glide.load.DecodeFormat -import com.bumptech.glide.request.RequestOptions -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class IconLoader @Inject constructor(private val context: Context) { - - /** - * Avatar Url -> IconCompat - */ - private val cache = HashMap() - - /** - * Get icon of a user. - * If already in cache, use it, else load it and call IconLoaderListener.onIconsLoaded() when ready - * Before Android P, this does nothing because the icon won't be used - */ - @WorkerThread - fun getUserIcon(path: String?): IconCompat? { - if (path == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - return null - } - - return cache.getOrPut(path) { - loadUserIcon(path) - } - } - - @WorkerThread - private fun loadUserIcon(path: String): IconCompat? { - return path.let { - try { - Glide.with(context) - .asBitmap() - .load(path) - .apply(RequestOptions.circleCropTransform() - .format(DecodeFormat.PREFER_ARGB_8888)) - .submit() - .get() - } catch (e: Exception) { - Timber.e(e, "decodeFile failed") - null - }?.let { bitmap -> - IconCompat.createWithBitmap(bitmap) - } - } - } -} diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationBitmapLoader.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationBitmapLoader.kt new file mode 100644 index 0000000000..518b011ffd --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationBitmapLoader.kt @@ -0,0 +1,92 @@ +/* + * 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.notifications + +import android.content.Context +import android.graphics.Bitmap +import android.os.Build +import androidx.annotation.WorkerThread +import androidx.core.graphics.drawable.IconCompat +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DecodeFormat +import com.bumptech.glide.load.resource.bitmap.CircleCrop +import com.bumptech.glide.signature.ObjectKey +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NotificationBitmapLoader @Inject constructor(private val context: Context) { + + /** + * Get icon of a room + */ + @WorkerThread + fun getRoomBitmap(path: String?): Bitmap? { + if (path == null) { + return null + } + return loadRoomBitmap(path) + } + + @WorkerThread + private fun loadRoomBitmap(path: String): Bitmap? { + return try { + Glide.with(context) + .asBitmap() + .load(path) + .format(DecodeFormat.PREFER_ARGB_8888) + .signature(ObjectKey("room-icon-notification")) + .submit() + .get() + } catch (e: Exception) { + Timber.e(e, "decodeFile failed") + null + } + } + + /** + * Get icon of a user. + * Before Android P, this does nothing because the icon won't be used + */ + @WorkerThread + fun getUserIcon(path: String?): IconCompat? { + if (path == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return null + } + + return loadUserIcon(path) + } + + @WorkerThread + private fun loadUserIcon(path: String): IconCompat? { + return try { + val bitmap = Glide.with(context) + .asBitmap() + .load(path) + .transform(CircleCrop()) + .format(DecodeFormat.PREFER_ARGB_8888) + .signature(ObjectKey("user-icon-notification")) + .submit() + .get() + IconCompat.createWithBitmap(bitmap) + } catch (e: Exception) { + Timber.e(e, "decodeFile failed") + null + } + } +} 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/RoomGroupMessageCreator.kt b/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt index b57c81f686..8310c15daa 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt @@ -16,7 +16,6 @@ package im.vector.app.features.notifications -import android.content.Context import android.graphics.Bitmap import androidx.core.app.NotificationCompat import androidx.core.app.Person @@ -28,11 +27,9 @@ import timber.log.Timber import javax.inject.Inject class RoomGroupMessageCreator @Inject constructor( - private val iconLoader: IconLoader, - private val bitmapLoader: BitmapLoader, + private val bitmapLoader: NotificationBitmapLoader, private val stringProvider: StringProvider, - private val notificationUtils: NotificationUtils, - private val appContext: Context + private val notificationUtils: NotificationUtils ) { fun createRoomMessage(events: List, roomId: String, userDisplayName: String, userAvatarUrl: String?): RoomNotification.Message { @@ -41,7 +38,7 @@ class RoomGroupMessageCreator @Inject constructor( val roomIsGroup = !firstKnownRoomEvent.roomIsDirect val style = NotificationCompat.MessagingStyle(Person.Builder() .setName(userDisplayName) - .setIcon(iconLoader.getUserIcon(userAvatarUrl)) + .setIcon(bitmapLoader.getUserIcon(userAvatarUrl)) .setKey(firstKnownRoomEvent.matrixID) .build() ).also { @@ -92,7 +89,7 @@ class RoomGroupMessageCreator @Inject constructor( } else { Person.Builder() .setName(event.senderName) - .setIcon(iconLoader.getUserIcon(event.senderAvatarPath)) + .setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath)) .setKey(event.senderId) .build() } 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/OnboardingAction.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingAction.kt index 2ca6a1f2fd..3ddea5ca2e 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingAction.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingAction.kt @@ -21,7 +21,6 @@ import im.vector.app.features.login.LoginConfig import im.vector.app.features.login.ServerType import im.vector.app.features.login.SignMode import org.matrix.android.sdk.api.auth.data.Credentials -import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider import org.matrix.android.sdk.api.auth.registration.RegisterThreePid import org.matrix.android.sdk.internal.network.ssl.Fingerprint @@ -71,11 +70,6 @@ sealed class OnboardingAction : VectorViewModelAction { // Homeserver history object ClearHomeServerHistory : OnboardingAction() - // For the soft logout case - data class SetupSsoForSessionRecovery(val homeServerUrl: String, - val deviceId: String, - val ssoIdentityProviders: List?) : OnboardingAction() - data class PostViewEvent(val viewEvent: OnboardingViewEvents) : OnboardingAction() data class UserAcceptCertificate(val fingerprint: Fingerprint) : OnboardingAction() 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..d279c5bbe9 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) @@ -138,7 +143,6 @@ class OnboardingViewModel @AssistedInject constructor( is OnboardingAction.ResetPasswordMailConfirmed -> handleResetPasswordMailConfirmed() is OnboardingAction.RegisterAction -> handleRegisterAction(action) is OnboardingAction.ResetAction -> handleResetAction(action) - is OnboardingAction.SetupSsoForSessionRecovery -> handleSetupSsoForSessionRecovery(action) is OnboardingAction.UserAcceptCertificate -> handleUserAcceptCertificate(action) OnboardingAction.ClearHomeServerHistory -> handleClearHomeServerHistory() is OnboardingAction.PostViewEvent -> _viewEvents.post(action.viewEvent) @@ -244,18 +248,6 @@ class OnboardingViewModel @AssistedInject constructor( } } - private fun handleSetupSsoForSessionRecovery(action: OnboardingAction.SetupSsoForSessionRecovery) { - setState { - copy( - signMode = SignMode.SignIn, - loginMode = LoginMode.Sso(action.ssoIdentityProviders), - homeServerUrlFromUser = action.homeServerUrl, - homeServerUrl = action.homeServerUrl, - deviceId = action.deviceId - ) - } - } - private fun handleRegisterAction(action: OnboardingAction.RegisterAction) { when (action) { is OnboardingAction.CaptchaDone -> handleCaptchaDone(action) @@ -458,13 +450,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 +737,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/onboarding/ftueauth/FtueAuthWebFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthWebFragment.kt index 879830a1c0..4c99a4d1d8 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthWebFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthWebFragment.kt @@ -30,7 +30,6 @@ import android.view.ViewGroup import android.webkit.SslErrorHandler import android.webkit.WebView import android.webkit.WebViewClient -import com.airbnb.mvrx.activityViewModel import com.google.android.material.dialog.MaterialAlertDialogBuilder import im.vector.app.R import im.vector.app.core.utils.AssetReader @@ -40,8 +39,6 @@ import im.vector.app.features.login.SignMode import im.vector.app.features.onboarding.OnboardingAction import im.vector.app.features.onboarding.OnboardingViewEvents import im.vector.app.features.onboarding.OnboardingViewState -import im.vector.app.features.signout.soft.SoftLogoutAction -import im.vector.app.features.signout.soft.SoftLogoutViewModel import org.matrix.android.sdk.api.auth.data.Credentials import org.matrix.android.sdk.internal.di.MoshiProvider import timber.log.Timber @@ -56,14 +53,11 @@ class FtueAuthWebFragment @Inject constructor( private val assetReader: AssetReader ) : AbstractFtueAuthFragment() { - val softLogoutViewModel: SoftLogoutViewModel by activityViewModel() - override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginWebBinding { return FragmentLoginWebBinding.inflate(inflater, container, false) } private var isWebViewLoaded = false - private var isForSessionRecovery = false override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -75,8 +69,6 @@ class FtueAuthWebFragment @Inject constructor( override fun updateWithState(state: OnboardingViewState) { setupTitle(state) - isForSessionRecovery = state.deviceId?.isNotBlank() == true - if (!isWebViewLoaded) { setupWebView(state) isWebViewLoaded = true @@ -239,11 +231,7 @@ class FtueAuthWebFragment @Inject constructor( } private fun notifyViewModel(credentials: Credentials) { - if (isForSessionRecovery) { - softLogoutViewModel.handle(SoftLogoutAction.WebLoginSuccess(credentials)) - } else { - viewModel.handle(OnboardingAction.WebLoginSuccess(credentials)) - } + viewModel.handle(OnboardingAction.WebLoginSuccess(credentials)) } override fun resetViewModel() { diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/SplashCarouselStateFactory.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/SplashCarouselStateFactory.kt index da5f8b6379..006492f6dc 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/SplashCarouselStateFactory.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/SplashCarouselStateFactory.kt @@ -43,26 +43,26 @@ class SplashCarouselStateFactory @Inject constructor( fun hero(@DrawableRes lightDrawable: Int, @DrawableRes darkDrawable: Int) = if (lightTheme) lightDrawable else darkDrawable return SplashCarouselState(listOf( SplashCarouselState.Item( - R.string.ftue_auth_carousel_1_title.colorTerminatingFullStop(R.attr.colorAccent), - R.string.ftue_auth_carousel_body_secure, + R.string.ftue_auth_carousel_secure_title.colorTerminatingFullStop(R.attr.colorAccent), + R.string.ftue_auth_carousel_secure_body, hero(R.drawable.ic_splash_conversations, R.drawable.ic_splash_conversations_dark), background(R.drawable.bg_carousel_page_1) ), SplashCarouselState.Item( - R.string.ftue_auth_carousel_2_title.colorTerminatingFullStop(R.attr.colorAccent), - R.string.ftue_auth_carousel_body_control, + R.string.ftue_auth_carousel_control_title.colorTerminatingFullStop(R.attr.colorAccent), + R.string.ftue_auth_carousel_control_body, hero(R.drawable.ic_splash_control, R.drawable.ic_splash_control_dark), background(R.drawable.bg_carousel_page_2) ), SplashCarouselState.Item( - R.string.ftue_auth_carousel_3_title.colorTerminatingFullStop(R.attr.colorAccent), - R.string.ftue_auth_carousel_body_encrypted, + R.string.ftue_auth_carousel_encrypted_title.colorTerminatingFullStop(R.attr.colorAccent), + R.string.ftue_auth_carousel_encrypted_body, hero(R.drawable.ic_splash_secure, R.drawable.ic_splash_secure_dark), background(R.drawable.bg_carousel_page_3) ), SplashCarouselState.Item( collaborationTitle().colorTerminatingFullStop(R.attr.colorAccent), - R.string.ftue_auth_carousel_body_workplace, + R.string.ftue_auth_carousel_workplace_body, hero(R.drawable.ic_splash_collaboration, R.drawable.ic_splash_collaboration_dark), background(R.drawable.bg_carousel_page_4) ) @@ -72,7 +72,7 @@ class SplashCarouselStateFactory @Inject constructor( private fun collaborationTitle(): Int { return when { localeProvider.isEnglishSpeaking() -> R.string.cut_the_slack_from_teams - else -> R.string.ftue_auth_carousel_title_messaging + else -> R.string.ftue_auth_carousel_workplace_title } } 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..b4f61dbc1f 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 @@ -54,7 +54,6 @@ class CreatePollController @Inject constructor( title(host.stringProvider.getString(R.string.poll_type_title).toEpoxyCharSequence()) } - /* pollTypeSelectionItem { id("poll_type_selection") pollType(currentState.pollType) @@ -68,7 +67,6 @@ class CreatePollController @Inject constructor( ) } } - */ genericItem { id("question_title") @@ -121,6 +119,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..9dc7fa6548 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 + +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..b6454b89e4 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,11 +68,13 @@ 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, private val vectorFileLogger: VectorFileLogger, - private val systemLocaleProvider: SystemLocaleProvider + private val systemLocaleProvider: SystemLocaleProvider, + private val matrix: Matrix ) { var inMultiWindowMode = false @@ -153,7 +155,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 +164,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 +182,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 +203,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 +213,7 @@ class BugReporter @Inject constructor( } } - val crashDescription = getCrashFile(context) + val crashDescription = getCrashFile() if (crashDescription.exists()) { val compressedCrashDescription = compressFile(crashDescription) @@ -265,8 +265,8 @@ class BugReporter @Inject constructor( // build the multi part request val builder = BugReporterMultipartBody.Builder() .addFormDataPart("text", text) - .addFormDataPart("app", rageShakeAppNameForReport(context, reportType)) - .addFormDataPart("user_agent", Matrix.getInstance(context).getUserAgent()) + .addFormDataPart("app", rageShakeAppNameForReport(reportType)) + .addFormDataPart("user_agent", matrix.getUserAgent()) .addFormDataPart("user_id", userId) .addFormDataPart("can_contact", canContact.toString()) .addFormDataPart("device_id", deviceId) @@ -352,9 +352,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 +487,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 +505,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 +528,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 +549,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 +641,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/room/VectorRoomDisplayNameFallbackProvider.kt b/vector/src/main/java/im/vector/app/features/room/VectorRoomDisplayNameFallbackProvider.kt index 33e63434ce..0be2a21ef4 100644 --- a/vector/src/main/java/im/vector/app/features/room/VectorRoomDisplayNameFallbackProvider.kt +++ b/vector/src/main/java/im/vector/app/features/room/VectorRoomDisplayNameFallbackProvider.kt @@ -19,8 +19,9 @@ package im.vector.app.features.room import android.content.Context import im.vector.app.R import org.matrix.android.sdk.api.RoomDisplayNameFallbackProvider +import javax.inject.Inject -class VectorRoomDisplayNameFallbackProvider( +class VectorRoomDisplayNameFallbackProvider @Inject constructor( private val context: Context ) : RoomDisplayNameFallbackProvider { 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/RoomDirectoryActivity.kt b/vector/src/main/java/im/vector/app/features/roomdirectory/RoomDirectoryActivity.kt index 3cd9955e74..48da9f4fa0 100644 --- a/vector/src/main/java/im/vector/app/features/roomdirectory/RoomDirectoryActivity.kt +++ b/vector/src/main/java/im/vector/app/features/roomdirectory/RoomDirectoryActivity.kt @@ -28,7 +28,7 @@ import im.vector.app.core.extensions.addFragmentToBackstack import im.vector.app.core.extensions.popBackstack import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivitySimpleBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.matrixto.MatrixToBottomSheet import im.vector.app.features.navigation.Navigator import im.vector.app.features.roomdirectory.createroom.CreateRoomArgs @@ -51,7 +51,7 @@ class RoomDirectoryActivity : VectorBaseActivity(), Matri override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.RoomDirectory + analyticsScreenName = MobileScreen.ScreenName.RoomDirectory sharedActionViewModel = viewModelProvider.get(RoomDirectorySharedActionViewModel::class.java) if (isFirstCreation()) { diff --git a/vector/src/main/java/im/vector/app/features/roomdirectory/createroom/CreateRoomActivity.kt b/vector/src/main/java/im/vector/app/features/roomdirectory/createroom/CreateRoomActivity.kt index 339c819a65..e4c350b88e 100644 --- a/vector/src/main/java/im/vector/app/features/roomdirectory/createroom/CreateRoomActivity.kt +++ b/vector/src/main/java/im/vector/app/features/roomdirectory/createroom/CreateRoomActivity.kt @@ -26,7 +26,7 @@ import dagger.hilt.android.AndroidEntryPoint import im.vector.app.core.extensions.addFragment import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivitySimpleBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.roomdirectory.RoomDirectorySharedAction import im.vector.app.features.roomdirectory.RoomDirectorySharedActionViewModel import kotlinx.coroutines.flow.launchIn @@ -57,7 +57,7 @@ class CreateRoomActivity : VectorBaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.CreateRoom + analyticsScreenName = MobileScreen.ScreenName.CreateRoom sharedActionViewModel = viewModelProvider.get(RoomDirectorySharedActionViewModel::class.java) sharedActionViewModel .stream() diff --git a/vector/src/main/java/im/vector/app/features/roomdirectory/picker/RoomDirectoryPickerFragment.kt b/vector/src/main/java/im/vector/app/features/roomdirectory/picker/RoomDirectoryPickerFragment.kt index 48610dda7b..cb71f93a0e 100644 --- a/vector/src/main/java/im/vector/app/features/roomdirectory/picker/RoomDirectoryPickerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomdirectory/picker/RoomDirectoryPickerFragment.kt @@ -29,7 +29,7 @@ import im.vector.app.core.extensions.configureWith import im.vector.app.core.platform.OnBackPressed import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.databinding.FragmentRoomDirectoryPickerBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.roomdirectory.RoomDirectoryAction import im.vector.app.features.roomdirectory.RoomDirectoryData import im.vector.app.features.roomdirectory.RoomDirectoryServer @@ -54,7 +54,7 @@ class RoomDirectoryPickerFragment @Inject constructor(private val roomDirectoryP override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.MobileSwitchDirectory + analyticsScreenName = MobileScreen.ScreenName.SwitchDirectory } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 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/roommemberprofile/RoomMemberProfileFragment.kt b/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileFragment.kt index c68bfca973..fcebe9adbb 100644 --- a/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileFragment.kt @@ -47,7 +47,7 @@ import im.vector.app.databinding.DialogBaseEditTextBinding import im.vector.app.databinding.DialogShareQrCodeBinding import im.vector.app.databinding.FragmentMatrixProfileBinding import im.vector.app.databinding.ViewStubRoomMemberProfileHeaderBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.crypto.verification.VerificationBottomSheet import im.vector.app.features.displayname.getBestName import im.vector.app.features.home.AvatarRenderer @@ -91,7 +91,7 @@ class RoomMemberProfileFragment @Inject constructor( override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.User + analyticsScreenName = MobileScreen.ScreenName.User } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt index 8acf53088d..251b99e318 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt @@ -44,7 +44,7 @@ import im.vector.app.core.utils.copyToClipboard import im.vector.app.core.utils.startSharePlainTextIntent import im.vector.app.databinding.FragmentMatrixProfileBinding import im.vector.app.databinding.ViewStubRoomProfileHeaderBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.RoomDetailPendingAction import im.vector.app.features.home.room.detail.RoomDetailPendingActionStore @@ -89,7 +89,7 @@ class RoomProfileFragment @Inject constructor( override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.RoomSettings + analyticsScreenName = MobileScreen.ScreenName.RoomSettings setFragmentResultListener(MigrateRoomBottomSheet.REQUEST_KEY) { _, bundle -> bundle.getString(MigrateRoomBottomSheet.BUNDLE_KEY_REPLACEMENT_ROOM)?.let { replacementRoomId -> roomDetailPendingActionStore.data = RoomDetailPendingAction.OpenRoom(replacementRoomId, closeCurrentRoom = true) 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/roomprofile/uploads/RoomUploadsFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsFragment.kt index 3c1a763072..a0adf42d5b 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsFragment.kt @@ -34,7 +34,7 @@ import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.utils.saveMedia import im.vector.app.core.utils.shareMedia import im.vector.app.databinding.FragmentRoomUploadsBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.notifications.NotificationUtils import im.vector.app.features.roomprofile.RoomProfileArgs @@ -57,7 +57,7 @@ class RoomUploadsFragment @Inject constructor( override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.RoomUploads + analyticsScreenName = MobileScreen.ScreenName.RoomUploads } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 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/VectorSettingsBaseFragment.kt b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsBaseFragment.kt index 7cefd20269..dae234eecc 100644 --- a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsBaseFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsBaseFragment.kt @@ -30,8 +30,7 @@ import im.vector.app.core.extensions.singletonEntryPoint import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.utils.toast import im.vector.app.features.analytics.AnalyticsTracker -import im.vector.app.features.analytics.plan.Screen -import im.vector.app.features.analytics.screen.ScreenEvent +import im.vector.app.features.analytics.plan.MobileScreen import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.matrix.android.sdk.api.session.Session @@ -43,8 +42,7 @@ abstract class VectorSettingsBaseFragment : PreferenceFragmentCompat(), Maverick * Analytics * ========================================================================================== */ - protected var analyticsScreenName: Screen.ScreenName? = null - private var screenEvent: ScreenEvent? = null + protected var analyticsScreenName: MobileScreen.ScreenName? = null protected lateinit var analyticsTracker: AnalyticsTracker @@ -91,17 +89,14 @@ abstract class VectorSettingsBaseFragment : PreferenceFragmentCompat(), Maverick override fun onResume() { super.onResume() Timber.i("onResume Fragment ${javaClass.simpleName}") - screenEvent = analyticsScreenName?.let { ScreenEvent(it) } + analyticsScreenName?.let { + analyticsTracker.screen(MobileScreen(screenName = it)) + } vectorActivity.supportActionBar?.setTitle(titleRes) // find the view from parent activity mLoadingView = vectorActivity.findViewById(R.id.vector_settings_spinner_views) } - override fun onPause() { - super.onPause() - screenEvent?.send(analyticsTracker) - } - abstract fun bindPref() abstract var titleRes: Int 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..51011e29a2 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 @@ -19,7 +19,7 @@ package im.vector.app.features.settings import android.os.Bundle import im.vector.app.R import im.vector.app.core.preference.VectorPreference -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import javax.inject.Inject class VectorSettingsRootFragment @Inject constructor() : VectorSettingsBaseFragment() { @@ -29,7 +29,7 @@ class VectorSettingsRootFragment @Inject constructor() : VectorSettingsBaseFragm override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.Settings + analyticsScreenName = MobileScreen.ScreenName.Settings } 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..ef87d908ea 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 @@ -51,7 +51,7 @@ import im.vector.app.core.utils.copyToClipboard import im.vector.app.core.utils.openFileSelection import im.vector.app.core.utils.toast import im.vector.app.databinding.DialogImportE2eKeysBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.ui.consent.AnalyticsConsentViewActions import im.vector.app.features.analytics.ui.consent.AnalyticsConsentViewModel import im.vector.app.features.analytics.ui.consent.AnalyticsConsentViewState @@ -94,7 +94,7 @@ class VectorSettingsSecurityPrivacyFragment @Inject constructor( override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.SettingsSecurity + analyticsScreenName = MobileScreen.ScreenName.SettingsSecurity } // cryptography diff --git a/vector/src/main/java/im/vector/app/features/settings/account/deactivation/DeactivateAccountFragment.kt b/vector/src/main/java/im/vector/app/features/settings/account/deactivation/DeactivateAccountFragment.kt index 867526c009..631c375e62 100644 --- a/vector/src/main/java/im/vector/app/features/settings/account/deactivation/DeactivateAccountFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/account/deactivation/DeactivateAccountFragment.kt @@ -31,7 +31,7 @@ import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.databinding.FragmentDeactivateAccountBinding import im.vector.app.features.MainActivity import im.vector.app.features.MainActivityArgs -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.auth.ReAuthActivity import im.vector.app.features.settings.VectorSettingsActivity import org.matrix.android.sdk.api.auth.data.LoginFlowTypes @@ -66,7 +66,7 @@ class DeactivateAccountFragment @Inject constructor() : VectorBaseFragment by hiltMavericksViewModelFactory() { - override fun initialState(viewModelContext: ViewModelContext): SoftLogoutViewState? { - val activity: SoftLogoutActivity = (viewModelContext as ActivityViewModelContext).activity() - val userId = activity.session.myUserId - return SoftLogoutViewState( - homeServerUrl = activity.session.sessionParams.homeServerUrl, - userId = userId, - deviceId = activity.session.sessionParams.deviceId ?: "", - userDisplayName = activity.session.getUser(userId)?.displayName ?: userId, - hasUnsavedKeys = activity.session.hasUnsavedKeys() - ) + override fun initialState(viewModelContext: ViewModelContext): SoftLogoutViewState { + val sessionHolder = EntryPoints.get(viewModelContext.app(), SingletonEntryPoint::class.java) + .activeSessionHolder() + + return if (sessionHolder.hasActiveSession()) { + val session = sessionHolder.getActiveSession() + val userId = session.myUserId + + SoftLogoutViewState( + homeServerUrl = session.sessionParams.homeServerUrl, + userId = userId, + deviceId = session.sessionParams.deviceId.orEmpty(), + userDisplayName = session.getUser(userId)?.displayName ?: userId, + hasUnsavedKeys = session.hasUnsavedKeys() + ) + } else { + SoftLogoutViewState( + homeServerUrl = "", + userId = "", + deviceId = "", + userDisplayName = "", + hasUnsavedKeys = false + ) + } } } 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/bg_unread_notification.xml b/vector/src/main/res/drawable/bg_unread_notification.xml index 7c9ea18eec..4926f588da 100644 --- a/vector/src/main/res/drawable/bg_unread_notification.xml +++ b/vector/src/main/res/drawable/bg_unread_notification.xml @@ -4,5 +4,5 @@ - - \ 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 @@ - - - - - - - - - - - - - - - - -