diff --git a/.github/ISSUE_TEMPLATE/release.yml b/.github/ISSUE_TEMPLATE/release.yml index b41188a920..a84f4dfd3b 100644 --- a/.github/ISSUE_TEMPLATE/release.yml +++ b/.github/ISSUE_TEMPLATE/release.yml @@ -24,8 +24,7 @@ body: ### Do the release - - [ ] Make sure `develop` and `main` are up to date (git pull) - - [ ] Checkout develop and create a release with gitflow, branch name `release/1.2.3` + - [ ] Make sure `develop` and `main` are up to date and create a release with gitflow: `git checkout main; git pull; git checkout develop; git pull; git flow release start '1.2.3'` - [ ] Check the crashes from the PlayStore - [ ] Check the rageshake with the current dev version: https://github.com/matrix-org/element-android-rageshakes/labels/1.2.3-dev - [ ] Run the integration test, and especially `UiAllScreensSanityTest.allScreensTest()` @@ -34,12 +33,12 @@ body: - [ ] Check the file CHANGES.md consistency. It's possible to reorder items (most important changes first) or change their section if relevant. Also an opportunity to fix some typo, or rewrite things - [ ] Add file for fastlane under ./fastlane/metadata/android/en-US/changelogs - [ ] (optional) Push the branch and start a draft PR (will not be merged), to check that the CI is happy with all the changes. - - [ ] Finish release with gitflow, delete the draft PR (if created) - - [ ] Push `main` and the new tag `v1.2.3` to origin - - [ ] Checkout `develop` + - [ ] Finish release with gitflow, delete the draft PR (if created): `git flow release finish '1.2.3'` + - [ ] Push `main` and the new tag `v1.2.3` to origin: `git push origin main; git push origin 'v1.2.3'` + - [ ] Checkout `develop`: `git checkout develop` - [ ] Increase version (versionPatch + 2) in `./vector/build.gradle` - [ ] Change the value of SDK_VERSION in the file `./matrix-sdk-android/build.gradle` - - [ ] Commit and push `develop` + - [ ] Commit and push `develop`: `git commit -m 'version++'; git push origin develop` - [ ] Wait for [Buildkite](https://buildkite.com/matrix-dot-org/element-android/builds?branch=main) to build the `main` branch. - [ ] Run the script `~/scripts/releaseElement.sh`. It will download the APKs from Buildkite check them and sign them. - [ ] Install the APK on your phone to check that the upgrade went well (no init sync, etc.) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b6333c5940..a44872e0ef 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Build docs run: ./gradlew dokkaHtml diff --git a/.github/workflows/triage-move-review-requests.yml b/.github/workflows/triage-move-review-requests.yml index 61f1f114dd..6aeba66ccc 100644 --- a/.github/workflows/triage-move-review-requests.yml +++ b/.github/workflows/triage-move-review-requests.yml @@ -60,8 +60,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!, $contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } @@ -129,8 +129,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!, $contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } diff --git a/CHANGES.md b/CHANGES.md index 18bb2480c3..442d3641dd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,43 @@ +Changes in Element v1.5.8 (2022-11-17) +====================================== + +Features ✨ +---------- + - [Session manager] Multi-session signout ([#7418](https://github.com/vector-im/element-android/issues/7418)) + - Rich text editor: add full screen mode. ([#7436](https://github.com/vector-im/element-android/issues/7436)) + - [Rich text editor] Add plain text mode ([#7452](https://github.com/vector-im/element-android/issues/7452)) + - Move TypingView inside the timeline items. ([#7496](https://github.com/vector-im/element-android/issues/7496)) + - Push notifications toggle: align implementation for current session ([#7512](https://github.com/vector-im/element-android/issues/7512)) + - Voice messages - Persist the playback position across different screens ([#7582](https://github.com/vector-im/element-android/issues/7582)) + +Bugfixes 🐛 +---------- + - [Voice Broadcast] Do not display the recorder view for a live broadcast started from another session ([#7431](https://github.com/vector-im/element-android/issues/7431)) + - [Session manager] Hide push notification toggle when there is no server support ([#7457](https://github.com/vector-im/element-android/issues/7457)) + - Fix rich text editor textfield not growing to fill parent on full screen. ([#7491](https://github.com/vector-im/element-android/issues/7491)) + - Fix duplicated mention pills in some cases ([#7501](https://github.com/vector-im/element-android/issues/7501)) + - Voice Broadcast - Fix duplicated voice messages in the internal playlist ([#7502](https://github.com/vector-im/element-android/issues/7502)) + - When joining a room, the message composer is displayed once the room is loaded. ([#7509](https://github.com/vector-im/element-android/issues/7509)) + - Voice Broadcast - Fix error on voice messages in unencrypted rooms ([#7519](https://github.com/vector-im/element-android/issues/7519)) + - Fix description of verified sessions ([#7533](https://github.com/vector-im/element-android/issues/7533)) + +In development 🚧 +---------------- + - [Voice Broadcast] Improve timeline items factory and handle bad recording state display ([#7448](https://github.com/vector-im/element-android/issues/7448)) + - [Voice Broadcast] Stop recording when opening the room after an app restart ([#7450](https://github.com/vector-im/element-android/issues/7450)) + - [Voice Broadcast] Improve playlist fetching and player codebase ([#7478](https://github.com/vector-im/element-android/issues/7478)) + - [Voice Broadcast] Display an error dialog if the user fails to start a voice broadcast ([#7485](https://github.com/vector-im/element-android/issues/7485)) + - [Voice Broadcast] Add seekbar in listening tile ([#7496](https://github.com/vector-im/element-android/issues/7496)) + - [Voice Broadcast] Improve the live indicator icon rendering in the timeline ([#7579](https://github.com/vector-im/element-android/issues/7579)) + - Voice Broadcast - Add maximum length ([#7588](https://github.com/vector-im/element-android/issues/7588)) + +SDK API changes ⚠️ +------------------ + - [Metrics] Add `SpannableMetricPlugin` to support spans within transactions. ([#7514](https://github.com/vector-im/element-android/issues/7514)) + - Fix a bug that caused messages with no formatted text to be quoted as "null". ([#7530](https://github.com/vector-im/element-android/issues/7530)) + - If message content has no `formattedBody`, default to `body` when editing. ([#7574](https://github.com/vector-im/element-android/issues/7574)) + + Changes in Element v1.5.7 (2022-11-07) ====================================== diff --git a/build.gradle b/build.gradle index 7e7da48295..78cc9abb02 100644 --- a/build.gradle +++ b/build.gradle @@ -26,7 +26,7 @@ buildscript { classpath libs.gradle.hiltPlugin classpath 'com.google.firebase:firebase-appdistribution-gradle:3.0.3' classpath 'com.google.gms:google-services:4.3.14' - classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.4.0.2513' + classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.5.0.2730' classpath 'com.google.android.gms:oss-licenses-plugin:0.10.5' classpath "com.likethesalad.android:stem-plugin:2.2.3" classpath 'org.owasp:dependency-check-gradle:7.3.0' @@ -45,7 +45,7 @@ plugins { // Detekt id "io.gitlab.arturbosch.detekt" version "1.21.0" // Ksp - id "com.google.devtools.ksp" version "1.7.20-1.0.7" + id "com.google.devtools.ksp" version "1.7.21-1.0.8" // Dependency Analysis id 'com.autonomousapps.dependency-analysis' version "1.13.1" diff --git a/dependencies.gradle b/dependencies.gradle index 33a2096a43..dc66de43ea 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -8,7 +8,7 @@ ext.versions = [ def gradle = "7.3.1" // Ref: https://kotlinlang.org/releases.html -def kotlin = "1.7.20" +def kotlin = "1.7.21" def kotlinCoroutines = "1.6.4" def dagger = "2.44" def appDistribution = "16.0.0-beta05" @@ -17,7 +17,7 @@ def markwon = "4.6.2" def moshi = "1.14.0" def lifecycle = "2.5.1" def flowBinding = "1.2.0" -def flipper = "0.171.1" +def flipper = "0.174.0" def epoxy = "5.0.0" def mavericks = "3.0.1" def glide = "4.14.2" @@ -26,13 +26,13 @@ def jjwt = "0.11.5" // Temporary version to unblock #6929. Once 0.16.0 is released we should use it, and revert // the whole commit which set version 0.16.0-SNAPSHOT def vanniktechEmoji = "0.16.0-SNAPSHOT" -def sentry = "6.6.0" +def sentry = "6.7.0" def fragment = "1.5.4" // Testing def mockk = "1.12.3" // We need to use 1.12.3 to have mocking in androidTest until a new version is released: https://github.com/mockk/mockk/issues/819 def espresso = "3.4.0" def androidxTest = "1.4.0" -def androidxOrchestrator = "1.4.1" +def androidxOrchestrator = "1.4.2" def paparazzi = "1.1.0" ext.libs = [ @@ -83,7 +83,7 @@ ext.libs = [ 'appdistributionApi' : "com.google.firebase:firebase-appdistribution-api-ktx:$appDistribution", 'appdistribution' : "com.google.firebase:firebase-appdistribution:$appDistribution", // Phone number https://github.com/google/libphonenumber - 'phonenumber' : "com.googlecode.libphonenumber:libphonenumber:8.12.57" + 'phonenumber' : "com.googlecode.libphonenumber:libphonenumber:8.13.0" ], dagger : [ 'dagger' : "com.google.dagger:dagger:$dagger", @@ -98,7 +98,7 @@ ext.libs = [ ], element : [ 'opusencoder' : "io.element.android:opusencoder:1.1.0", - 'wysiwyg' : "io.element.android:wysiwyg:0.2.1" + 'wysiwyg' : "io.element.android:wysiwyg:0.4.0" ], squareup : [ 'moshi' : "com.squareup.moshi:moshi:$moshi", diff --git a/fastlane/metadata/android/az/short_description.txt b/fastlane/metadata/android/az/short_description.txt new file mode 100644 index 0000000000..ecf3d5008c --- /dev/null +++ b/fastlane/metadata/android/az/short_description.txt @@ -0,0 +1 @@ +Qrup mesajlaşma - şifrəli mesajlaşma, qrup söhbəti və video zənglər diff --git a/fastlane/metadata/android/az/title.txt b/fastlane/metadata/android/az/title.txt new file mode 100644 index 0000000000..4ca0ffb55b --- /dev/null +++ b/fastlane/metadata/android/az/title.txt @@ -0,0 +1 @@ +Element - Təhlükəsiz Mesajlaşma diff --git a/fastlane/metadata/android/cs-CZ/changelogs/40105060.txt b/fastlane/metadata/android/cs-CZ/changelogs/40105060.txt new file mode 100644 index 0000000000..e966dbbd92 --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/changelogs/40105060.txt @@ -0,0 +1,2 @@ +Hlavní změny v této verzi: nové uživatelské rozhraní pro výběr přílohy. +Úplný seznam změn: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/cs-CZ/changelogs/40105070.txt b/fastlane/metadata/android/cs-CZ/changelogs/40105070.txt new file mode 100644 index 0000000000..e966dbbd92 --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/changelogs/40105070.txt @@ -0,0 +1,2 @@ +Hlavní změny v této verzi: nové uživatelské rozhraní pro výběr přílohy. +Úplný seznam změn: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/de-DE/changelogs/40105060.txt b/fastlane/metadata/android/de-DE/changelogs/40105060.txt new file mode 100644 index 0000000000..0b36faff1e --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/40105060.txt @@ -0,0 +1,2 @@ +Die wichtigste Änderung in dieser Version: Neues Anhangauswahl-UI. +Vollständiges Änderungsprotokoll: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/de-DE/changelogs/40105070.txt b/fastlane/metadata/android/de-DE/changelogs/40105070.txt new file mode 100644 index 0000000000..3141cea7cb --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/40105070.txt @@ -0,0 +1,2 @@ +Die wichtigste Änderung in dieser Version: Neue Anhangauswahl-Oberfläche. +Vollständiges Änderungsprotokoll: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40105080.txt b/fastlane/metadata/android/en-US/changelogs/40105080.txt new file mode 100644 index 0000000000..f9ca8cdd7c --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40105080.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/et/changelogs/40105060.txt b/fastlane/metadata/android/et/changelogs/40105060.txt new file mode 100644 index 0000000000..d5606e24b3 --- /dev/null +++ b/fastlane/metadata/android/et/changelogs/40105060.txt @@ -0,0 +1,2 @@ +Põhilised muutused selles versioonis: uus liides manuste lisamiseks. +Kogu ingliskeelne muudatuste logi: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/et/changelogs/40105070.txt b/fastlane/metadata/android/et/changelogs/40105070.txt new file mode 100644 index 0000000000..061e09814d --- /dev/null +++ b/fastlane/metadata/android/et/changelogs/40105070.txt @@ -0,0 +1,2 @@ +Põhilised muutused selles versioonis: uus liides manuste valimiseks. +Kogu ingliskeelne muudatuste logi: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/fa/changelogs/40105060.txt b/fastlane/metadata/android/fa/changelogs/40105060.txt new file mode 100644 index 0000000000..b677c05c89 --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/40105060.txt @@ -0,0 +1,2 @@ +تغییرات عمده در این نگارش: رابط کاربری جدید برای گزینش پیوست. +گزارش دگرگونی کامل: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/fa/changelogs/40105070.txt b/fastlane/metadata/android/fa/changelogs/40105070.txt new file mode 100644 index 0000000000..b677c05c89 --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/40105070.txt @@ -0,0 +1,2 @@ +تغییرات عمده در این نگارش: رابط کاربری جدید برای گزینش پیوست. +گزارش دگرگونی کامل: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/fr-FR/changelogs/40105060.txt b/fastlane/metadata/android/fr-FR/changelogs/40105060.txt new file mode 100644 index 0000000000..b33f290d0d --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/40105060.txt @@ -0,0 +1,2 @@ +Principaux changements pour cette version : nouvelle interface de sélection d’une pièce jointe. +Intégralité des changements : https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/fr-FR/changelogs/40105070.txt b/fastlane/metadata/android/fr-FR/changelogs/40105070.txt new file mode 100644 index 0000000000..b33f290d0d --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/40105070.txt @@ -0,0 +1,2 @@ +Principaux changements pour cette version : nouvelle interface de sélection d’une pièce jointe. +Intégralité des changements : https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/id/changelogs/40105060.txt b/fastlane/metadata/android/id/changelogs/40105060.txt new file mode 100644 index 0000000000..32fb87563e --- /dev/null +++ b/fastlane/metadata/android/id/changelogs/40105060.txt @@ -0,0 +1,2 @@ +Perubahan utama dalam versi ini: Antarmuka baru untuk memilih sebuah lampiran. +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/id/changelogs/40105070.txt b/fastlane/metadata/android/id/changelogs/40105070.txt new file mode 100644 index 0000000000..32fb87563e --- /dev/null +++ b/fastlane/metadata/android/id/changelogs/40105070.txt @@ -0,0 +1,2 @@ +Perubahan utama dalam versi ini: Antarmuka baru untuk memilih sebuah lampiran. +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/pt-BR/changelogs/40105060.txt b/fastlane/metadata/android/pt-BR/changelogs/40105060.txt new file mode 100644 index 0000000000..108a8a88b4 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/40105060.txt @@ -0,0 +1,2 @@ +Principais mudanças nesta versão: novo UI para selecionar um anexo. +Changelog completo: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/pt-BR/changelogs/40105070.txt b/fastlane/metadata/android/pt-BR/changelogs/40105070.txt new file mode 100644 index 0000000000..108a8a88b4 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/40105070.txt @@ -0,0 +1,2 @@ +Principais mudanças nesta versão: novo UI para selecionar um anexo. +Changelog completo: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sk/changelogs/40105060.txt b/fastlane/metadata/android/sk/changelogs/40105060.txt new file mode 100644 index 0000000000..0d1d4965ca --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/40105060.txt @@ -0,0 +1,2 @@ +Hlavné zmeny v tejto verzii: nové používateľské rozhranie na výber príloh. +Úplný zoznam zmien: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sk/changelogs/40105070.txt b/fastlane/metadata/android/sk/changelogs/40105070.txt new file mode 100644 index 0000000000..0d1d4965ca --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/40105070.txt @@ -0,0 +1,2 @@ +Hlavné zmeny v tejto verzii: nové používateľské rozhranie na výber príloh. +Úplný zoznam zmien: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40104120.txt b/fastlane/metadata/android/sq/changelogs/40104120.txt new file mode 100644 index 0000000000..f93220235b --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40104120.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: U lejon përdoruesve të shfaqen si jo në linjë dhe shton një lojtës audio për bashkëngjitje audio +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40104130.txt b/fastlane/metadata/android/sq/changelogs/40104130.txt new file mode 100644 index 0000000000..f93220235b --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40104130.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: U lejon përdoruesve të shfaqen si jo në linjë dhe shton një lojtës audio për bashkëngjitje audio +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40104140.txt b/fastlane/metadata/android/sq/changelogs/40104140.txt new file mode 100644 index 0000000000..c8b2eb09ab --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40104140.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Përmirësim i administrimit të përdoruesve të shpërfillur. Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40104160.txt b/fastlane/metadata/android/sq/changelogs/40104160.txt new file mode 100644 index 0000000000..987197f0f6 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40104160.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Administrim më i mirë i mesazheve të fshehtëzuar. Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40104180.txt b/fastlane/metadata/android/sq/changelogs/40104180.txt new file mode 100644 index 0000000000..87f801d1f4 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40104180.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40104190.txt b/fastlane/metadata/android/sq/changelogs/40104190.txt new file mode 100644 index 0000000000..87f801d1f4 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40104190.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40104200.txt b/fastlane/metadata/android/sq/changelogs/40104200.txt new file mode 100644 index 0000000000..87f801d1f4 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40104200.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40104220.txt b/fastlane/metadata/android/sq/changelogs/40104220.txt new file mode 100644 index 0000000000..87f801d1f4 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40104220.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40104230.txt b/fastlane/metadata/android/sq/changelogs/40104230.txt new file mode 100644 index 0000000000..87f801d1f4 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40104230.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40104240.txt b/fastlane/metadata/android/sq/changelogs/40104240.txt new file mode 100644 index 0000000000..87f801d1f4 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40104240.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40104250.txt b/fastlane/metadata/android/sq/changelogs/40104250.txt new file mode 100644 index 0000000000..87f801d1f4 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40104250.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40104260.txt b/fastlane/metadata/android/sq/changelogs/40104260.txt new file mode 100644 index 0000000000..c5ffad38c9 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40104260.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Përdorim i UnifiedPush dhe lejim i përdoruesve të kenë push pa FCM. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40104270.txt b/fastlane/metadata/android/sq/changelogs/40104270.txt new file mode 100644 index 0000000000..87f801d1f4 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40104270.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40104280.txt b/fastlane/metadata/android/sq/changelogs/40104280.txt new file mode 100644 index 0000000000..87f801d1f4 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40104280.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40104300.txt b/fastlane/metadata/android/sq/changelogs/40104300.txt new file mode 100644 index 0000000000..6c1be8f556 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40104300.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Bërje e mundur hapash të përmirësuar hyrje dhe dalje nga llogaria. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40104310.txt b/fastlane/metadata/android/sq/changelogs/40104310.txt new file mode 100644 index 0000000000..6c1be8f556 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40104310.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Bërje e mundur hapash të përmirësuar hyrje dhe dalje nga llogaria. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40104320.txt b/fastlane/metadata/android/sq/changelogs/40104320.txt new file mode 100644 index 0000000000..87f801d1f4 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40104320.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40104340.txt b/fastlane/metadata/android/sq/changelogs/40104340.txt new file mode 100644 index 0000000000..87f801d1f4 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40104340.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40104360.txt b/fastlane/metadata/android/sq/changelogs/40104360.txt new file mode 100644 index 0000000000..ef9251a497 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40104360.txt @@ -0,0 +1,3 @@ +Skema e re e Aplikacionit mund të aktivizohet që nga rregullimet Labs. Ju lutemi, provojeni! +Ndreqje problemesh me njoftim që mungon dhe njëkohësim i gjatë shtues. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40105000.txt b/fastlane/metadata/android/sq/changelogs/40105000.txt new file mode 100644 index 0000000000..2ee2ded823 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40105000.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Hedhje poshtë MD e aktivizuar, si parazgjedhje. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40105020.txt b/fastlane/metadata/android/sq/changelogs/40105020.txt new file mode 100644 index 0000000000..26647d519f --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40105020.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Skema e re e aplikacionit e aktivizuar, si parazgjedhje! +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40105040.txt b/fastlane/metadata/android/sq/changelogs/40105040.txt new file mode 100644 index 0000000000..4e38434f89 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40105040.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: Veçori të reja nën rregullimet Labs: hartues teksti të pasur, administrim i ri pajisjesh, transmetim zanor. Ende nën zhvillim aktivt! +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40105060.txt b/fastlane/metadata/android/sq/changelogs/40105060.txt new file mode 100644 index 0000000000..eb300bafed --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40105060.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë version: ndërfaqe e re UI për përzgjedhjen e një bashkëngjitjeje! +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sq/changelogs/40105070.txt b/fastlane/metadata/android/sq/changelogs/40105070.txt new file mode 100644 index 0000000000..f4beb912a5 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/40105070.txt @@ -0,0 +1,2 @@ +Ndryshimet kryesore në këtë verson: ndërfaqe UI e re për përzgjedhje të një bashkëngjitjeje. +Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/uk/changelogs/40105060.txt b/fastlane/metadata/android/uk/changelogs/40105060.txt new file mode 100644 index 0000000000..4be635901f --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/40105060.txt @@ -0,0 +1,2 @@ +Основні зміни в цій версії: новий інтерфейс для вибору вкладення. +Перелік усіх змін: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/uk/changelogs/40105070.txt b/fastlane/metadata/android/uk/changelogs/40105070.txt new file mode 100644 index 0000000000..65254059c5 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/40105070.txt @@ -0,0 +1,2 @@ +Основні зміни в цій версії: новий інтерфейс для вибору вкладень. +Перелік усіх змін: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/zh-TW/changelogs/40105060.txt b/fastlane/metadata/android/zh-TW/changelogs/40105060.txt new file mode 100644 index 0000000000..56667ccfc0 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/40105060.txt @@ -0,0 +1,2 @@ +此版本中的主要變動:選取附件的新使用者介面。 +完整的變更紀錄:https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/zh-TW/changelogs/40105070.txt b/fastlane/metadata/android/zh-TW/changelogs/40105070.txt new file mode 100644 index 0000000000..56667ccfc0 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/40105070.txt @@ -0,0 +1,2 @@ +此版本中的主要變動:選取附件的新使用者介面。 +完整的變更紀錄:https://github.com/vector-im/element-android/releases diff --git a/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoViewHolder.kt b/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoViewHolder.kt index 92d28d26c9..07c7b4588f 100644 --- a/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoViewHolder.kt +++ b/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoViewHolder.kt @@ -103,14 +103,12 @@ class VideoViewHolder constructor(itemView: View) : views.videoView.setOnPreparedListener { stopTimer() countUpTimer = CountUpTimer(100).also { - it.tickListener = object : CountUpTimer.TickListener { - override fun onTick(milliseconds: Long) { - val duration = views.videoView.duration - val progress = views.videoView.currentPosition - val isPlaying = views.videoView.isPlaying -// Log.v("FOO", "isPlaying $isPlaying $progress/$duration") - eventListener?.get()?.onEvent(AttachmentEvents.VideoEvent(isPlaying, progress, duration)) - } + it.tickListener = CountUpTimer.TickListener { + val duration = views.videoView.duration + val progress = views.videoView.currentPosition + val isPlaying = views.videoView.isPlaying + // Log.v("FOO", "isPlaying $isPlaying $progress/$duration") + eventListener?.get()?.onEvent(AttachmentEvents.VideoEvent(isPlaying, progress, duration)) } it.resume() } diff --git a/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt b/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt index e9d311fe03..a4fd8bb4e1 100644 --- a/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt +++ b/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt @@ -66,7 +66,7 @@ class CountUpTimer(private val intervalInMs: Long = 1_000) { coroutineScope.cancel() } - interface TickListener { + fun interface TickListener { fun onTick(milliseconds: Long) } } diff --git a/library/ui-strings/src/main/res/values-ar/strings.xml b/library/ui-strings/src/main/res/values-ar/strings.xml index 70b9a33ab5..a49ecc3d08 100644 --- a/library/ui-strings/src/main/res/values-ar/strings.xml +++ b/library/ui-strings/src/main/res/values-ar/strings.xml @@ -1167,4 +1167,12 @@ البريد الإلكتروني كلمة السر الجديدة التالي - + + صفر + واحد + اثنان + قليلة + كثيرة + اخرى + + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-az/strings.xml b/library/ui-strings/src/main/res/values-az/strings.xml index 044ecf900c..6fe322bdd0 100644 --- a/library/ui-strings/src/main/res/values-az/strings.xml +++ b/library/ui-strings/src/main/res/values-az/strings.xml @@ -20,7 +20,7 @@ %s səsli zəng etdi. %s zəngə cavab verdi. %s zəng başa çatdı. - "%1$s gələcək otaq tarixçəsini %2$s-ə görünən etdi" + %1$s gələcək otaq tarixçəsini %2$s-ə görünən etdi bütün otaq üzvləri, dəvət olunduğu andan. bütün otaq üzvləri, qoşulduğu andan. bütün otaq üzvləri. @@ -48,8 +48,9 @@ \nKriptografiyanın idxalı İlkin sinxronizasiya: \nOtaqlar idxalı - İlkin sinxronizasiya: -\nOtaqlara daxil olmaq + İlkin sinxronizasiya: +\nSöhbətləriniz yüklənilir +\nƏgər çoxlu otaqlara qoşulmusunuzsa, bu, bir az vaxt apara bilər İlkin sinxronizasiya: \nDəvət olunmuş otaqların idxalı İlkin sinxronizasiya: @@ -133,4 +134,6 @@ Otağa qoşulmaq üçün %1$s-a dəvət göndərdiniz %s, bu otaq üçün server ACL-lərini dəyişdi. • %s ilə uyğunlaşan serverlərə icazə verildi. + Siz %1$s üçün otağa qoşulmaq dəvətin ləğv etdiniz + %1$s-ı dəvət etdiniz \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-ca/strings.xml b/library/ui-strings/src/main/res/values-ca/strings.xml index ce786fb87d..f9d7145b66 100644 --- a/library/ui-strings/src/main/res/values-ca/strings.xml +++ b/library/ui-strings/src/main/res/values-ca/strings.xml @@ -2836,4 +2836,5 @@ Adjunts Adhesius Galeria + Format de text \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-cs/strings.xml b/library/ui-strings/src/main/res/values-cs/strings.xml index 53599adce2..47caa52149 100644 --- a/library/ui-strings/src/main/res/values-cs/strings.xml +++ b/library/ui-strings/src/main/res/values-cs/strings.xml @@ -2891,4 +2891,12 @@ %1$d vybrané %1$d vybraných + Přepnutí režimu celé obrazovky + Formátování textu + Již nahráváte hlasové vysílání. Ukončete prosím aktuální hlasové vysílání a zahajte nové. + Hlasové vysílání už nahrává někdo jiný. Počkejte, až jeho hlasové vysílání skončí, a zahajte nové. + Nemáte potřebná oprávnění k zahájení hlasového vysílání v této místnosti. Obraťte se na správce místnosti, aby vám zvýšil oprávnění. + Nelze zahájit nové hlasové vysílání + Přetočení o 30 sekund zpět + Přetočení o 30 sekund dopředu \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-de/strings.xml b/library/ui-strings/src/main/res/values-de/strings.xml index 409fc564f4..cd215e175d 100644 --- a/library/ui-strings/src/main/res/values-de/strings.xml +++ b/library/ui-strings/src/main/res/values-de/strings.xml @@ -2835,4 +2835,12 @@ %1$d ausgewählt %1$d ausgewählt + Du hast nicht die nötigen Berechtigungen, um eine Sprachübertragung in diesem Raum zu starten. Kontaktiere einen Raumadministrator, um deine Berechtigungen anzupassen. + Sprachübertragung kann nicht gestartet werden + Vollbildmodus umschalten + Textformatierung + Du zeichnest bereits eine Sprachübertragung auf. Bitte beende die laufende Übertragung, um eine neue zu beginnen. + Jemand anderes nimmt bereits eine Sprachübertragung auf. Warte auf das Ende der Übertragung, bevor du eine neue startest. + 30 Sekunden vorspulen + 30 Sekunden zurückspulen \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-et/strings.xml b/library/ui-strings/src/main/res/values-et/strings.xml index 9bfbbe8eeb..22572a0f36 100644 --- a/library/ui-strings/src/main/res/values-et/strings.xml +++ b/library/ui-strings/src/main/res/values-et/strings.xml @@ -2827,4 +2827,12 @@ %1$d valitud %1$d valitud + Lülita täisekraanivaade sisse/välja + Tekstivorming + Sa juba salvestad ringhäälingukõnet. Uue alustamiseks palun lõpeta eelmine salvestus. + Keegi juba salvestab ringhäälingukõnet. Uue ringhäälingukõne salvestamiseks palun oota, kuni see teine ringhäälingukõne on lõppenud. + Sul pole piisavalt õigusi selles jututoas ringhäälingukõne algatamiseks. Õiguste lisamiseks palun võta ühendust jututoa haldajaga. + Uue ringhäälingukõne alustamine pole võimalik + Keri tagasi 30 sekundi kaupa + Keri edasi 30 sekundi kaupa \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-fa/strings.xml b/library/ui-strings/src/main/res/values-fa/strings.xml index f2701519e7..313734290f 100644 --- a/library/ui-strings/src/main/res/values-fa/strings.xml +++ b/library/ui-strings/src/main/res/values-fa/strings.xml @@ -2802,4 +2802,27 @@ ۱ گزیده %1$d گزیده + اجازه‌های لازم برای آغاز پخش صوتی در این اتاق را ندارید. برای ارتقای اجازه‌هایتان با یک مدیر اتاق تماس بگیرید. + فرد دیگری در حال ضبط یک پخش صوتی است. برای آغاز یک پخش جدید، منتظر پایان پخشش بمانید. + با بررسی افزاره‌های وارد شده‌تان باید کد زیر را ببینید. تأیید کنید که این کد با آن افزاره مطابق است: + دارید یک پخش صوتی ضبط می‌کنید. لطفاً برای آغاز یک پخش جدید، به پخش کنونی پایان دهید. + ⚠ افزاره‌های تأییدنشده‌ای در این اتاق وجود دارند. آن‌ها قادر به رمزگشایی پیام‌هایی که فرستاده‌اید نیستند. + استفاده از دوربین روی این افزاره برای پویش کد QR نشان داده شده روی افزارهٔ دیگرتان: + ضبط نام کارخواه، نگارش و نشانی برای بازشناسی آسان‌تر نشست‌ها در مدیر نشست. + 🔒 رمزگذاری به نشست‌های تأیید شده را فقط برای تمامی اتاق‌ها در تنظیمات امنیت به کار انداخته‌اید. + + خارج شدن از نشست‌های قدیمی (۱ روز یا بیش‌تر) که دیگر استفاده نمی‌کنید را در نظر داشته باشید. + خارج شدن از نشست‌های قدیمی (%1$d روز یا بیش‌تر) که دیگر استفاده نمی‌کنید را در نظر داشته باشید. + + توانایی ضبط و فرستادن پخش صدا در خط زمانی اتاق. + پویش کد QR زیر با افزاره‌ای که خارج شده. + استفاده از افزارهٔ وارد شده‌تان برای پویش کد QR زیر: + چیزی اشتباه پیش رفت. لطفاً اتّصال شبکه‌تان را بررسی و دوباره تلاش کنید. + ${app_name} برای نمایش آگاهی‌ها نیازمند اجازه است. +\nلطفاً اجازه را اعطا کنید. + نمی‌توان پخش صدایی جدید را آغاز کرد + تغییر حالت تمام‌صفحه + ۳۰ ثانیه پیش‌روی + ۳۰ ثانیه پس‌روی + قالب‌بندی متن \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-fr/strings.xml b/library/ui-strings/src/main/res/values-fr/strings.xml index 6c767fc350..a02b062596 100644 --- a/library/ui-strings/src/main/res/values-fr/strings.xml +++ b/library/ui-strings/src/main/res/values-fr/strings.xml @@ -2836,4 +2836,12 @@ %1$d sélectionné %1$d sélectionnés + Basculer en mode plein écran + Formatage de texte + Vous êtes déjà en train de réaliser une diffusion audio. Veuillez terminer votre diffusion audio actuelle pour en démarrer une nouvelle. + Une autre personne est déjà en train de réaliser une diffusion audio. Attendez que sa diffusion audio soit terminée pour en démarrer une nouvelle. + Vous n’avez pas les permissions requises pour démarrer une nouvelle diffusion audio dans ce salon. Contactez un administrateur du salon pour mettre-à-jour vos permissions. + Impossible de commencer une nouvelle diffusion audio + Avance rapide de 30 secondes + Retour rapide de 30 secondes \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-in/strings.xml b/library/ui-strings/src/main/res/values-in/strings.xml index 6ed423bb70..cde367faf9 100644 --- a/library/ui-strings/src/main/res/values-in/strings.xml +++ b/library/ui-strings/src/main/res/values-in/strings.xml @@ -2783,4 +2783,12 @@ Di masa mendatang proses verifikasi ini akan dimutakhirkan. %1$d dipilih + Ubah mode layar penuh + Format teks + Anda sedang merekam sebuah siaran suara. Mohon akhiri siaran suara Anda saat ini untuk memulai yang baru. + Orang lain sedang merekam sebuah siaran suara. Tunggu untuk siaran suara berakhir untuk memulai yang baru. + Anda tidak memiliki izin yang dibutuhkan untuk memulai sebuah siaran suara di ruangan ini. Hubungi sebuah administrator ruangan untuk meningkatkan izin Anda. + Tidak dapat memulai siaran suara baru + Maju cepat 30 detik + Mundur cepat 30 detik \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-ja/strings.xml b/library/ui-strings/src/main/res/values-ja/strings.xml index 37c0bca52f..11ab6ee857 100644 --- a/library/ui-strings/src/main/res/values-ja/strings.xml +++ b/library/ui-strings/src/main/res/values-ja/strings.xml @@ -2459,4 +2459,18 @@ ルームを作成 チャットを開始 全ての会話 + ${app_name}にようこそ、 +\n%s。 + 認証済のセッション + QRコードでサインイン + 新しいセッションマネージャーを有効にする + QRコードでサインイン + 3 + 2 + 1 + リクエストが失敗しました。 + QRコードをスキャン + QRコードをスキャン + QRコードをスキャン + QRコードが不正です。 \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-pt-rBR/strings.xml b/library/ui-strings/src/main/res/values-pt-rBR/strings.xml index 2ec5f394bd..d3061371fa 100644 --- a/library/ui-strings/src/main/res/values-pt-rBR/strings.xml +++ b/library/ui-strings/src/main/res/values-pt-rBR/strings.xml @@ -2836,4 +2836,12 @@ %1$d selecionada(o) %1$d selecionadas(os) + Alguma outra pessoa já está gravando um broadcast de voz. Espere que o broadcast de voz dela termine para começar um novo. + Alternar modo de tela cheia + Formatação de texto + Você já está gravando um broadcast de voz. Por favor termine seu broadcast de voz atual para começar um novo. + Você não tem as permissões requeridas para começar um broadcast de voz nesta sala. Contacte um/uma administrador(a) para fazer upgrade de suas permissões. + Não dá pra começar um novo broadcast de voz + Avançar rápido 30 segundos + Retroceder 30 segundos \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-sk/strings.xml b/library/ui-strings/src/main/res/values-sk/strings.xml index bf57233b37..9eac092a62 100644 --- a/library/ui-strings/src/main/res/values-sk/strings.xml +++ b/library/ui-strings/src/main/res/values-sk/strings.xml @@ -2653,7 +2653,7 @@ V záujme čo najlepšieho zabezpečenia, overte svoje relácie a odhláste sa z každej relácie, ktorú už nepoznáte alebo nepoužívate. Iné relácie Relácie - Otvoriť zoznam priestorov + Zoznam priestorov Vytvoriť novú konverzáciu alebo miestnosť Ľudia Obľúbené @@ -2891,4 +2891,12 @@ %1$d vybraté %1$d vybraných + Prepnutie režimu na celú obrazovku + Formátovanie textu + Už nahrávate hlasové vysielanie. Ukončite aktuálne hlasové vysielanie a spustite nové. + Niekto iný už nahráva hlasové vysielanie. Počkajte, kým sa skončí jeho hlasové vysielanie, a potom spustite nové. + Nemáte požadované oprávnenia na spustenie hlasového vysielania v tejto miestnosti. Obráťte sa na správcu miestnosti, aby vám rozšíril oprávnenia. + Nie je možné spustiť nové hlasové vysielanie + Rýchle posunutie dozadu o 30 sekúnd + Rýchle posunutie dopredu o 30 sekúnd \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-sq/strings.xml b/library/ui-strings/src/main/res/values-sq/strings.xml index a6af0a4921..773454c39f 100644 --- a/library/ui-strings/src/main/res/values-sq/strings.xml +++ b/library/ui-strings/src/main/res/values-sq/strings.xml @@ -601,9 +601,7 @@ Formatojini mesazhet duke përdorur sintaksën Markdown përpara se të dërgohen. Kjo lejon formatim të thelluar, f.v., përdorimi i yllthit për ta shfaqur tekstin me të pjerrëta. Nuk prek ftesat, heqjet dhe dëbimet. ${app_name}-i grumbullon të dhëna analitike anonime që të na lejojë ta përmirësojmë aplikacionin. - Të shfaqen krejt mesazhet prej %s\? -\n -\nKini parasysh që ky veprim do të sjellë rinisjen e aplikacionit dhe mund të hajë ca kohë. + Të shfaqen krejt mesazhet prej %s\? Nis kamerën e sistemit, në vend se skenën e kamerës vetjake. Shfaq veprimin On/Off sintakse Markdown @@ -897,10 +895,10 @@ S’u arrit të dërgohej sugjerimi (%s) Shfaq te rrjedha kohore akte të fshehura Përgjegjës integrimesh - app_id: - push_key: - app_display_name: - emër_sesioni: + ID Aplikacioni: + + Emër Aplikacioni Në Ekran: + Emër Sesioni Në Ekran: Mesazhe të Drejtpërdrejtë Po pritet… Po fshehtëzohet miniatura… @@ -949,11 +947,11 @@ Po përdorni %1$s për të zbuluar dhe për të qenë i zbulueshëm nga kontakte ekzistues që njihni. S’po përdorni ndonjë shërbyes identitetesh. Që të zbuloni dhe të jini i zbulueshëm nga kontakte ekzistuese që njihni, formësoni një të tillë më poshtë. Adresa email të zbulueshme - Mundësitë rreth zbulimesh do të shfaqen sapo të keni shtuar një email. + Mundësitë e zbulimit do të shfaqen sapo të keni shtuar një adresë email. Mundësi zbulimesh do të shfaqen sapo të keni shtuar një numër telefoni. Shkëputja prej shërbyesit tuaj të identiteteve do të thotë se s’do të jeni i zbulueshëm prej përdoruesish të tjerë dhe s’do të jeni në gjendje të ftoni të tjerë me email ose telefon. Numra telefoni të zbulueshëm - Ju dërguam një email ripohimi te %s, hapeni dhe klikoni mbi lidhjen e ripohimit + Ju dërguam një email te %s, hapeni dhe klikoni mbi lidhjen e ripohimit Jepni një URL shërbyesi identitetesh S’u lidh dot te shërbyes identitetesh Ju lutemi, jepni URL-në e shërbyesit të identiteteve @@ -1080,7 +1078,7 @@ Aplikacioni s’është në gjendje të krijojë llogari në këtë shërbyes Home. \n \nDoni të regjistroheni duke përdorur një klient web\? - Ky emai s’është përshoqëruar me ndonjë llogari. + Kjo adresë email s’është e përshoqëruar me ndonjë llogari. Ricaktoni fjalëkalimin në %1$s Te mesazhet tuaj do të dërgohet një email verifikimi, për të ripohuar caktimin e fjalëkalimit tuaj të ri. Pasuesi @@ -1089,7 +1087,7 @@ Kujdes! Ndryshimi i fjalëkalimit tuaj do të sjellë zerim të çfarëdo kyçesh fshehtëzimi skaj-më-skaj në krejt sesionet tuaj, duke e bërë të palexueshëm historikun e bisedave të fshehtëzuara. Ujdisni një Kopjeruajtje Kyçesh ose eksportoni kyçet e dhomës tuaj prej një tjetër sesioni, përpara se të ricaktoni fjalëkalimin tuaj. Vazhdo - Ky email s’është i lidhur me ndonjë llogari + Kjo adresë email s’është e lidhur me ndonjë llogari Kontrolloni te mesazhet tuaj të marrë Një email verifikimi u dërgua te %1$s. Prekni mbi lidhjen që të ripohohet fjalëkalimi juaj i ri. Pasi të keni ndjekur lidhjen që përmban, klikoni më poshtë. @@ -1103,7 +1101,7 @@ \n \nTë ndalet procesi i ndryshimit të fjalëkalimit\? Caktoni adresë email - Caktoni një email për rimarrje të llogarisë tuaj. Më vonë, mundeni të lejoni persona që njihni t’ju zbulojnë përmes email-it tuaj. + Caktoni një adresë email për rimarrje të llogarisë tuaj. Më vonë, mundeni të lejoni persona që njihni t’ju zbulojnë përmes kësaj adrese. Email Email (në daçi) Pasuesi @@ -1445,7 +1443,7 @@ Mesazhi u fshi Shfaq mesazhe të hequr Shfaq një vendmbajtëse për mesazhe të hequr - Ju dërguam një email ripohimi te %s, ju lutemi, së pari, shihni email-in tuaj dhe klikoni mbi lidhjen e ripohimit + Ju dërguam një email te %s, ju lutemi, së pari, shihni email-in tuaj dhe klikoni mbi lidhjen e ripohimit Kodi i verifikimit s’është i saktë. MEDIA S’ka media në këtë dhomë @@ -1518,9 +1516,7 @@ \n \nKëtë veprim mund ta zhbëni në çfarëdo kohe, te rregullimet e përgjithshme. Hiqe shpërfilljen e përdoruesit - Heqja e shpërfilljes së këtij përdoruesi do të shfaqë sërish krejt mesazhet prej tij. -\n -\nKini parasysh se ky veprim do të sjellë rinisjen e aplikacionit dhe do të hajë ca kohë. + Heqja e shpërfilljes së këtij përdoruesi do të shfaqë sërish krejt mesazhet prej tij. Anuloje ftesën Jeni i sigurt se doni të anulohet ftesa për këtë përdorues\? Përzëre përdoruesin @@ -1534,7 +1530,7 @@ Heqja e dëbimit përdoruesit do t’i lejojë të marrë pjesë sërish në dhomë. Te llogaria juaj s’është shtuar ndonjë numër telefoni Adresa email - Te llogaria juaj s’është shtuar ndonjë email + Te llogaria juaj s’është shtuar ndonjë adresë email Numra telefoni Të hiqet %s\? Sigurohuni që keni klikuar te lidhja në email-in që ju kemi dërguar. @@ -1552,7 +1548,7 @@ Integrimet janë të çaktivizuara Që të bëhet kjo, aktivizoni “Lejo integrime”, te Rregullimet. Email-e dhe numra telefonash - Administroni email-e dhe numra telefonash të lidhur me llogarinë tuaj Matrix + Administroni adresa email dhe numra telefonash të lidhur me llogarinë tuaj Matrix %d përdorues i dëbuar %d përdorues të dëbuar @@ -1605,7 +1601,7 @@ Kjo llogari është çaktivizuar. S’u ruajt dot kartelë media Ripohoni identitetin tuaj duke verifikuar këto kredenciale hyrjeje, duke i akorduar hyrje te mesazhe të fshehtëzuar. - Për privatësinë tuaj, ${app_name}-i mbulon vetëm dërgim email-esh dhe numrash telefoni përdoruesi të koduar. + Për privatësinë tuaj, ${app_name}-i mbulon vetëm dërgim adresash email dhe numrash telefoni përdoruesi të koduar. Caktoni rol Rol Hapni fjalosje @@ -1769,7 +1765,7 @@ %1$d nga %2$d Jepe pranimin Shfuqizoje pranimin tim - Keni dhënë pranimin tuaj për të dërguar email-e dhe numra telefonash te ky shërbyes identitetesh që të zbulojë përdorues të tjerë prej kontakteve tuaj. + Keni dhënë pranimin tuaj për të dërguar adresa email-e dhe numra telefonash te ky shërbyes identitetesh që të zbulojë përdorues të tjerë prej kontakteve tuaj. Dërgo email-e dhe numra telefonash Sugjerime Përdorues të Ditur @@ -2135,7 +2131,7 @@ Përmendje dhe Fjalëkyçe Njoftime Parazgjedhje %s te Rregullimet, që të merrni ftesa drejt e në ${app_name}. - Lidheni këtë email me llogarinë tuaj + Lidheni këtë adresë email me llogarinë tuaj Kjo ftesë për te kjo hapësirë u dërgua te %s që s’është i përshoqëruar me llogarinë tuaj Kjo ftesë për te kjo dhomë qe dërguar për %s që s’është i përshoqëruar me llogarinë tuaj Krejt dhomat ku gjendeni do të shfaqen te Home. @@ -2203,7 +2199,7 @@ Hyrje në hapësirë Kush mund të hyjë\? Aktivizo njoftime me email për %s - Që të merrni email me njoftim, ju lutemi, përshoqërojini llogarisë tuaj Matrix një email + Që të merrni email me njoftim, ju lutemi, përshoqërojini llogarisë tuaj Matrix një adresë email Njoftim me email Të përmirësojë hapësirën Të ndryshojë emrin e hapësirës @@ -2249,8 +2245,8 @@ Pyetje ose temë pyetësori Krijoni Pyetësor A pranoni të dërgohen këto hollësi\? - Për të zbuluar kontakte ekzistuese, duhet të dërgoni hollësi kontakti (email-e dhe numra telefonash) te shërbyesi juaj i identiteteve. Para dërgimit, i fshehtëzojmë të dhënat tuaja, për privatësi. - Dërgo email-e dhe numra telefonash te %s + Për të zbuluar kontakte ekzistuese, duhet të dërgoni hollësi kontakti (adresa email dhe numra telefonash) te shërbyesi juaj i identiteteve. Para dërgimit, i fshehtëzojmë të dhënat tuaja, për privatësi. + Dërgo adresa email dhe numra telefonash te %s Kontaktet tuaja janë private. Për të zbuluar përdorues prej kontakteve tuaja, na duhet leja juaj për të dërguar hollësi kontakti te shërbyesi juaj i identiteteve. Është bërë dalja nga sesioni! U dol nga dhoma! @@ -2355,7 +2351,7 @@ Bashkësi Ekipe Shokë dhe familje - Do t’ju ndihmojmë të lidheni. + Do t’ju ndihmojmë të lidheni Me kë do të bisedoni më shumë\? Po e shihni tashmë këtë rrjedhë! Shiheni në Dhomë @@ -2411,15 +2407,15 @@ Shërbyesi Home s’pranon emër përdorues vetëm me shifra. Anashkalojeni këtë hap Ruajeni dhe vazhdoni - Parapëlqimet tuaja u ruajtën. + Kaloni te rregullimet, kur të doni, që të përditësoni profilin tuaj Kaq qe! Shkojmë - Këtë mund ta ndryshoni kurdo. + Erdh koha t’i jepet surrat emrit Shtoni një foto profili Këtë mund ta ndryshoni më vonë Emër Në Ekran Zgjidhni një emër për në ekran - Llogaria juaj %s u krijua. + Llogaria juaj %s u krijua Përgëzime! Shpjemëni në shtëpi Personalizoni profil @@ -2450,4 +2446,380 @@ Prani Mësoni më tepër Provojeni - + Aktivizo shkurtore lejesh për Thirrje Element + S’u gjet metodë tjetër veç njëkohësimit në prapaskenë. + ${app_name}-it i duhet një fshehtinë e pastër, për të qenë i përditësuar, për arsyen vijuese: +\n%s +\n +\nKini parasysh se ky veprim do të sjellë rinisjen e aplikacionit dhe mund të dojë ca kohë. + Regjistro emrin, versionin dhe URL-në e klientit, për të dalluar më kollaj sesionet te përgjegjës sesionesh. + Veprimtaria e fundit më %1$s + Apliko format me të nënvizuara + Apliko format me të hequravije + Apliko format me të pjerrta + Apliko format me të trasha + Ju lutemi, sigurohuni se e dini origjinën e këtij kodi. Duke lidhur pajisje, do t’i jepni dikujt hyrje të plotë në llogarinë tuaj. + Ripohojeni + Riprovoni + Pa përputhje\? + Po bëhet hyrja juaj + Po lidhet me pajisjen + Skanoni kodin QR + Po bëhet hyrja te një pajisje celulare\? + Shfaq kod QR te kjo pajisje + Përzgjidhni “Skanoni kod QR” + Filloja në skenën e hyrjes + Përzgjidhni “Hyni me kod QR” + Filloja në skenën e hyrjes + Përzgjidhni “Shfaq kod QR” + Kaloni te Rregullime -> Siguri & Privatësi + Hapeni aplikacionin në pajisjen tuaj tjetër + Hyrja u anulua në pajisjen tuaj tjetër. + Ai kod QR është i pavlefshëm. + Duhet bërë hyrja te pajisja tjetër. + Nga pajisja tjetër është bërë tashmë hyrja. + Kërkesa dështoi. + Kërkesa u hodh poshtë në pajisjen tjetër. + Lidhja me këtë pajisje nuk mbulohet. + Lidhje e pasuksesshme + U vendos lidhje e siguruar + Hyni me kod QR + Skanoni kodin QR + 3 + 2 + 1 + Provojeni + Prekeni djathtas në krye që të shihni mundësinë për dhënie përshtypjesh. + Jepni Përshtypje + Hyni në Hapësirat tuaja (poshtë djathtas) më shpejt dhe më kollaj se kurrë më parë. + Hyni Në Hapësira + Që të thjeshtohet ${app_name} juaj, skedat tanimë janë opsionale. Administrojini duke përdorur menunë djathtas në krye. + Mirë se vini te një pamje e re! + Ky është vendi ku do të shfaqen mesazhet tuaj të palexuar, kur të ketë të tillë. + S’ka gjë për ta raportuar. + Aplikacioni “all-in-one” i fjalosjeve të siguruara, për ekipe, shokë dhe ente. Që t’ia filloni, krijoni një fjalosje, ose hyni në një dhomë ekzistuese. + Mirë se vini te ${app_name}, +\n%s. + Hapësirat janë një mënyrë e re për të grupuar dhoma dhe persona. Shtoni një dhomë ekzistuese, ose krijoni një të re, duke përdorur butonin poshtë djathtas. + %s +\nduket paksa si i zbrazët. + Jini në gjendje të incizoni dhe dërgoni transmetim zanor në rrjedhën kohore të dhomës. + Aktivizoni transmetim zanor (nën zhvillim aktiv) + Aktivizo regjistrim hollësish klienti + Shihini më qartë dhe kontrolloni më mirë krejt sesionet tuaj. + Aktivizo përgjegjës të ri sesionesh + Përdorues të tjerë në mesazhe të drejtpërdrejtë dhe dhoma ku hyni janë në gjendje të shohin një listë të plotë të sesioneve tuaj. +\n +\nKjo u jep atyre besim se po flasin vërtet me ju, por do të thotë gjithashtu që mund shohin emrin e sesionit që jepni këtu. + Riemërtim sesionesh + Sesionet e verifikuar përfaqësojnë sesione ku është bërë hyrja dhe janë verifikuar, ose duke përdorur togfjalëshin tuaj të sigurt, ose me verifikim. +\n +\nKjo do të thotë se zotërojnë kyçe fshehtëzimi për mesazhe tuajt të mëparshëm dhe u ripohojnë përdoruesve të tjerë, me të cilët po komunikoni, se këto sesione ju takojnë juve. + Sesione të verifikuar + Sesionet e paverifikuar janë sesione në të cilët është bërë hyrja me kredencialet tuaja, por pa u bërë verifikim. +\n +\nDuhet të jeni posaçërisht të qartë se i njihni këto sesione, ngaqë mund të përbëjnë përdorim të paautorizuar të llogarisë tuaj. + Sesione të paverifikuar + Sesioni joaktive janë sesione që keni ca kohë që s’i përdorni, por që vazhdojnë të marrin kyçe fshehtëzimi. +\n +\nHeqja e sesioneve joaktive përmirëson sigurinë dhe punimin dhe e bën më të lehtë për ju të pikasni nëse një sesion i ri është i dyshimtë. + Sesione joaktive + Mund të përdorni këtë pajisje për të bërë hyrjen në një pajisje celulare apo web me një kod QR. Për ta bërë këtë ka dy mënyra: + Hyni me Kod QR + Ju lutemi, kini parasysh se emrat e sesioneve janë të dukshëm edhe për personat me të cilët komunikoni. + Emra vetjakë sesionesh mund t’ju ndihmojnë të njihni më kollaj pajisjet tuaja. + Emër sesioni + Riemërtoni sesionin + Adresë IP + Sistem operativ + Model + Shfletues + URL + Version + Ëmër + Aplikacion + Veprimtaria e fundit + Emër sesioni + Merrni njoftime push për këtë sesion. + Njoftime Push + Hollësi aplikacioni, pajisjeje dhe veprimtarie. + Hollësi sesioni + Dilni nga ky sesion + Përzgjidhni sesione + Spastroje Filtrin + S’u gjetën sesione joaktive. + S’u gjetën seanca të paverifikuara. + S’u gjetën sesione të verifikuara. + + Shihni mundësinë e daljes nga sesione të vjetër (%1$d ditë ose më tepër) të cilët s’i përdorni më. + Shihni mundësinë e daljes nga sesione të vjetër (%1$d ditë ose më tepër) të cilët s’i përdorni më. + + Joaktive + Verifikoni sesionet tuaj, për shkëmbim më të sigurt mesazhesh, ose dilni prej atyre që nuk i njihni, apo përdorni më. + Të paverifikuar + Për sigurinë më të mirë, dilni nga çfarëdo sesioni që nuk e njihni apo përdorni më. + Të verifikuar + Filtroji + + Joaktiv për %1$d ditë, ose më gjatë + Joaktiv për %1$d ditë, ose më gjatë + + Jo aktiv + Jo gati për shkëmbim të sigurt mesazhesh + E paverifikuar + Gati për shkëmbim të sigurt mesazhesh + E verifikuar + Krejt sesionet + Filtroji + Pajisje + Sesion + Sesioni i Tanishëm + + Shihni mundësinë e daljes nga sesione të vjetër (%1$d ditë ose më tepër) të cilët s’i përdorni më. + Shihni mundësinë e daljes nga sesione të vjetër (%1$d ditë ose më tepër) të cilët s’i përdorni më. + + Sesione joaktive + Verifikojini, ose dilni nga sesione të paverifikuar. + Sesione të paverifikuar + Përmirësoni sigurinë e llogarisë tuaj duke ndjekur këto rekomandime. + Rekomandime sigurie + + Joaktiv për %1$d+ ditë (%2$s) + Joaktiv për %1$d+ ditë (%2$s) + + I paverifikuar · Sesioni juaj i tanishëm + I paverifikuar · Veprimtari së fundi më %1$s + I verifikuar · Veprimtaria e fundit më %1$s + Shihni Krejt (%1$d) + Shihni Hollësitë + Verifiko Sesion + Verifikoni sesionin tuaj të tanishëm, që të shfaqni gjendjen e verifikimit të këtij sesioni. + Për sigurinë dhe besueshmërinë më të mirë, verifikojeni, ose dilni nga ky sesion. + Verifikoni sesionin tuaj të tanishëm, për shkëmbim më të sigurt të mesazheve. + Ky sesion është gati për shkëmbim të sigurt mesazhesh. + Sesioni juaj i tanishëm është gati për shkëmbim të sigurt mesazhesh. + Gjendje e panjohur verifikimi + Sesion i paverifikuar + Sesion i verifikuar + Lloj i panjohur pajisjeje + Desktop + Web + Celular + Për sigurinë më të mirë, verifikoni sesionet tuaja dhe dilni nga çfarëdo sesioni që s’e njihni, ose s’e përdorni më. + Sesione të tjera + + U hoq %d mesazh + U hoqë %d mesazhe + + Aktivizoni tregim vendndodhjeje + Ju lutemi, kini parasysh: kjo është një veçori në zhvillim, që përdor një sendërtim të përkohshëm. Kjo do të thotë se s’do të jeni në gjendje të fshini historikun e vendndodhjeve tuaja dhe përdoruesit e përparuar do të jenë në gjendje të shohin historikun e vendndodhjeve tuaja, edhe pasi të keni ndalur dhënien “live” për këtë dhomë të vendndodhjes tuaj. + Tregim “live” vendndodhjeje + Kanal i tanishëm: %s + Kanal + S’gjendet pikëmbarimi. + Pikëmbarim i tanishëm: %s + Pikëmbarim + Hëpërhë po përdoret %s. + Metodë + + U gjet %d metodë. + U gjetën %d metoda. + + S’u gjet metodë tjetër veç Google Play Service. + Metoda të gatshme + Metodë njoftimi + Njëkohësim në prapaskenë + Shërbime Google + Zgjidhni si të merren njoftime + Tregimi i ekranit është në punë e sipër + Tregim Ekrani ${app_name} + Kontakt + Kamerë + Vendndodhje + Pyetësorë + Transmetim zanor + Bashkëngjitje + Ngjitës + Fototekë + Nisni një transmetim zanor + Vendndodhje drejtpërsëdrejti + Jepe vendndodhjen + Që të mund të ndani drejtpërsëdrejti vendndodhje me të tjerë në këtë dhomë, lypset të keni lejet e duhura. + S’keni leje të tregoni vendndodhje drejtpërsëdrejti + Përditësuar %1$s më parë + Sendërtim i përkohshëm: vendndodhjet mbeten në historikun e dhomës + Aktivizo Tregim Vendndodhjeje “Live” + Vendndodhje Drejtpërsëdrejti ${app_name} + Edhe %1$s + “Live” deri më %1$s + Shihni vendndodhje “live” + Tregimi “live” i vendndodhjes përfundoi + Po ngarkohet vendndodhje “live”… + S’arrihet të ngarkohet hartë +\nKy shërbyes Home mund të mos jetë formësuar të shfaqë harta. + Përfundimet do të jenë të dukshme pasi të ketë përfunduar pyetësori + Kur bëhet ftesë në një dhomë të fshehtëzuar që ka historik ndarjesh me të tjerët, historiku i fshehtëzuar do të jetë i dukshëm. + Përdo + Ndal transmetim zanor + Luani ose vazhdoni luajtje transmetimi zanor + Ndal incizim transmetimi zanor + Ndal incizim transmetimi zanor + Vazhdo incizim transmetimi zanor + Drejtpërdrejt + Shfaq hollësitë më të reja të përdoruesit + Disa përfundime mund të jenë të fshehura, ngaqë janë private dhe ju duhet një ftesë për to. + S’u gjetën përfundime + Mos braktis ndonjë + Braktisi krejt + Gjëra në këtë hapësirë + I zënë + Hap rregullimet + S’u aktivizua dot mirëfilltësim biometrik. + Mirëfilltësimi biometrik qe çaktivizuar ngaqë tani së fundi është shtuar një metodë e re mirëfilltësimi biometrik. Mund ta riaktivizoni që nga Rregullimet. + S’mund të garantohet mirëfilltësia e këtij mesazhi të fshehtëzuar në këtë pajisje. + Tastierë inkonjito + Dërgoni mesazhin tuaj të parë për të ftuar në fjalosje %s + Mesazhet në këtë fjalosje do të jenë të fshehtëzuar skaj-më-skaj. + S’do të jeni në gjendje të shihni historikun e mesazheve të fshehtëzuara. Që t’ia rifilloni nga e para, ricaktoni kyçet tuaja për Kopjeruajtje të Sigurt Mesazhesh dhe kyçe verifikimi. + S’arrihet të verifikohet kjo pajisje + Sesione + Tregoi vendndodhjen e vet drejtpërsëdrejti + E paraprin një mesazh tekst i thjeshtë me (╯°□°)╯︵ ┻━┻ + S’hapet dot kjo lidhje: bashkësitë janë zëvendësuar nga hapësirat + Skanoni kodin QR + Emër përdoruesi / Email / Telefon + Jeni qenie njerëzore\? + Ndiqni udhëzimet e dërguara te %s + Ricaktim fjalëkalimi + Harrova fjalëkalimin + Ridërgo email + S’morët email\? + Ndiqni udhëzimet e dërguara te %s + Verifikoni email-in tuaj + Ridërgomëni kodin + Te %s u dërgua një kod + Ripohoni numrin e telefonit tuaj + Dil nga krejt pajisjet + Ricaktoni fjalëkalimin + Sigurohuni të jetë 8 ose më shumë shenja. + Zgjidhni një fjalëkalim të ri + Fjalëkalim i Ri + Kontrolloni email-in tuaj. + %s do t’ju dërgojë një lidhje verifikimi + Kod ripohimi + Numër Telefoni + %s lyp verifikimin e llogarisë tuaj + Jepni numrin e telefonit tuaj + Email + %s lyp verifikimin e llogarisë tuaj + Jepni email-in tuaj + Ju lutemi, lexoni kushte dhe rregulla të %s + Rregulla shërbyesi + Lidhuni + Element Matrix Services (EMS) është një shërbim strehimi i fuqishëm dhe i besueshëm, për komunikim të shpejtë, të sigurt dhe të atypëratyshëm. Shihni më tepër se si, teelement.io/ems + Doni të strehoni shërbyesin tuaj\? + URL Shërbyesi + Cila është adresa e shërbyesit tuaj\? + Cila është adresa e shërbyesit tuaj\? Kjo është si një shtëpi për krejt të dhënat tuaja + Përzgjidhni shërbyesin tuaj + Mirë se u kthyet! + Përpunojeni + Ose + Ku gjenden bisedat tuaja + Ku do të gjenden bisedat tuaja + Duhet të jetë 8 ose më shumë shenja + Të tjerët mund t’ju zbulojnë %s + Krijoni llogarinë tuaj + Transmetim Zanor + Hap listë hapësirash + Krijoni një bisedë ose dhomë të re + Ricaktoni metodë njoftimesh + Të aktivizuara: + Etiketë profili: + ID sesioni: + Jepi + Po përditësohen të dhënat tuaja… + Diç shkoi ters. Ju lutemi, kontrolloni lidhjen tuaj në rrjet dhe riprovoni. + Persona + Të parapëlqyera + Të palexuara + Krejt + Kopjeruajtja ka një nënshkrim të vlefshëm prej këtij përdoruesi. + Hap skenën e mjeteve të zhvilluesit + Na ndjeni, kjo dhomë s’u gjet. +\nJu lutemi, riprovoni më vonë.%s + Përdor parazgjedhje sistemi + Zgjidheni dorazi + Caktoje vetvetiu + Zgjidhni madhësi shkronjash + ⚠ Në këtë dhomë ka pajisje të paverifikuara, ato s’do të jenë në gjendje të shfshehtëzojnë mesazhet që dërgoni. + Mos dërgo kurrë prej këtij sesioni mesazhe të fshehtëzuar te sesione të paverifikuar në këtë dhomë. + Figurat e animuara vetëluaji + S’u arrit të regjistrohej token pikëmbarimi te shërbyesi Home: +\n%1$s + Pikëmbarim i regjistruar me sukses te shërbyesi Home. + Regjistrim Pikëmbarimi + Akordojini Leje + ${app_name} lyp lejen për shfaqje njoftimesh. +\nJu lutemi, akordoni lejen. + + %1$s dhe %2$d tjetër + %1$s dhe %2$d të tjerë + + %1$s dhe %2$s + ${app_name} lyp leje të shfaqë njoftime. Njoftimet mund të shfaqin mesazhet tuaja, ftesa tuajat, etj. +\n +\nJu lutemi, lejoni përdorimin e tyre te flluska pasuese, që të jeni në gjendje të shihni njoftime. + Email jo i verifikuar, kontrolloni te Të marrët tuaj + Reshtni tregimin e ekranit tuaj + Tregojuani ekranin të tjerëve + Ky është vendi ku do të gjenden kërkesat dhe ftesat tuaja të reja. + S’ka gjë të re. + Ftesa + Hapësirat janë një mënyrë e re për të grupuar dhoma dhe njerëz. Që t’ia filloni, krijoni një hapësirë. + Ende pa hapësira. + Provoni përpunuesin e teksteve të pasur (për tekst të thjeshtë vjen së shpejti) + Aktivizo përpunues teksti të pasur + Krijo MD vetëm për mesazhin e parë + Një Element i thjeshtuar, me skeda opsionale + Aktivizo skemë të re + A - Z + Veprimtari + Renditi sipas + Shfaq të freskëta + Shfaq filtra + Parapëlqime skeme grafike + Shpërzgjidhi krejt + Përzgjidhi krejt + E mora + Më pas + Rifillo + sek + min + h + - Për disa përdorues u hoq shpërfillja + Kërkesë njëkohësimi fillestar + Eksploroni Dhoma + Ndërroni Hapësire + Krijo Dhomë + Filloni Fjalosje + Krejt Fjalosjet + + %1$d i përzgjedhura + %1$d të përzgjedhura + + Shërbyesi Home nuk mbulon hyrje me kod QR. + U has një problem sigurie, kur ujdisej shkëmbim i siguruar mesazhesh. Mund të jetë komprometuar një nga sa vijon: shërbyesi juaj Home; lidhja(et) tuaja internet; pajisja(et) tuaja; + Lidhja s’u plotësua në kohën e duhur. + Kontrolloni pajisjen ku jeni i futur, duhet të shfaqet kodi më poshtë. Sigurohuni se kodi më poshtë përputhet me atë pajisje: + Skanoni kodin QR më poshtë me pajisjen tuaj prej nga është dalë nga llogaria. + Përdorni pajisjen tuaj ku jeni brenda llogarisë që të skanoni kodin QR më poshtë: + Përdorni kamerën në këtë pajisje që të skanoni kodin QR të shfaqur në pajisjen tuaj tjetër: + Mirato vetvetiu widget-e Thirrjesh Element Call dhe akordo përdorim kamere / mikfrofoni + MSC3061: Po jepen kyçe dhome për mesazhe të dikurshëm + Shfaq hollësitë më të reja të profileve (avatar dhe emër në ekran) për krejt mesazhet. + Kërko doemos që tastiera të mos përditësojë ndonjë të dhënë të personalizuar, bie fjala, historik shtypjeje në të dhe fjalor bazuar në ç’keni shtypur në biseda. Kini parasysh se disa tastiera mund të mos e respektojnë këtë rregullim. + Ky kod QR duket i formuar keq. Ju lutemi, provoni ta verifikoni me tjetër metodë. + 🔒 Keni aktivizuar fshehtëzim për sesionie të verifikuar vetëm për krejt dhomat, që nga Rregullime Sigurie. + Luaj figura të animuara te rrjedha kohora sapo zënë të duken + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-uk/strings.xml b/library/ui-strings/src/main/res/values-uk/strings.xml index f633a0ef2f..8cbfeca6ba 100644 --- a/library/ui-strings/src/main/res/values-uk/strings.xml +++ b/library/ui-strings/src/main/res/values-uk/strings.xml @@ -2946,4 +2946,12 @@ Вибрано %1$d Вибрати все + Перемкнути повноекранний режим + Форматування тексту + Ви вже записуєте голосове повідомлення. Завершіть поточну трансляцію, щоб розпочати нову. + Хтось інший вже записує голосове повідомлення. Зачекайте, поки закінчиться трансляція, щоб розпочати нову. + Ви не маєте необхідних дозволів для початку передавання голосового повідомлення в цю кімнату. Зверніться до адміністратора кімнати, щоб оновити ваші дозволи. + Не вдалося розпочати передавання нового голосового повідомлення + Перемотати вперед на 30 секунд + Перемотати назад на 30 секунд \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-zh-rCN/strings.xml b/library/ui-strings/src/main/res/values-zh-rCN/strings.xml index 688652265b..5ab8a351d1 100644 --- a/library/ui-strings/src/main/res/values-zh-rCN/strings.xml +++ b/library/ui-strings/src/main/res/values-zh-rCN/strings.xml @@ -30,7 +30,7 @@ 发送者的设备没有向我们发送此消息的密钥。 无法发送消息 Matrix 错误 - 电子邮箱地址 + 电子邮件地址 手机号码 %1$s 撤回了对 %2$s 的邀请 %1$s 让未来的房间历史记录对 %2$s 可见 @@ -214,8 +214,8 @@ 登录 提交 错误的用户名和/或密码 - 此电子邮箱地址似乎无效 - 此电子邮箱地址已被使用。 + 此电子邮件地址似乎无效 + 此电子邮件地址已被使用。 忘记密码? 请输入有效的 URL 没有包含有效的 JSON @@ -228,7 +228,7 @@ 搜索 过滤房间成员 没有结果 - 添加电子邮箱地址 + 添加电子邮件地址 添加手机号码 版本 olm 版本 @@ -257,7 +257,7 @@ 开始视频通话 拍摄照片或视频 此主服务器想确认你不是机器人 - 电子邮箱地址验证失败:请确保你已点击邮件中的链接 + 电子邮件地址验证失败:请确保你已点击邮件中的链接 原始 通话正在连接…… ${app_name} 需要权限以访问你的麦克风来进行语音通话。 @@ -348,11 +348,11 @@ 显示系统设置中的应用程序信息。 通话请求 使用条款 - 其他 + 其它 通知目标 登录为 - 请检查你的电子邮箱并点击里面包含的链接。完成时请点击继续。 - 此电子邮箱地址已被使用。 + 请检查你的电子邮件并点击里面包含的链接。完成时请点击继续。 + 此电子邮件地址已被使用。 此手机号码已被使用。 设置为主要地址 取消设置为主要地址 @@ -434,7 +434,7 @@ 你添加了一个新会话“%s”,它正在请求加密密钥。 你的未验证会话“%s”正在请求加密密钥。 开始验证 - bug报告 + 错误报告 拍摄照片 拍摄视频 使用原生相机 @@ -828,7 +828,7 @@ 撤消 断开连接 拒绝 - 这不是有效的Matrix服务器地址 + 这不是有效的 Matrix 服务器地址 无法在此 URL 找到主服务器,请检查 播放 忽略 @@ -990,13 +990,13 @@ 更改身份服务器 你正在使用 %1$s 与你知道的现有联系人相互发现。 你当前未使用身份服务器。若要与你知道的现有联系人相互发现,请在下方配置。 - 可发现电子邮件地址 + 可发现的电子邮件地址 发现选项将在你添加电子邮件地址后出现。 发现选项将在你添加电话号码后出现。 - 与你的身份服务器断开意味着你将无法被其它用户发现并且无法通过电子邮件和电话邀请他人。 + 与您的身份服务器断开连接意味着您将不会被其他用户发现,并且您将无法通过电子邮件或电话邀请其他人。 可发现电话号码 我们向%s发送了一封电子邮件,请检查你的电子邮件并点击确认链接 - 我们向%s发送了电子邮件,请先检查你的电子邮件并点击确认链接 + 我们向 %s 发送了一封电子邮件,请先检查您的电子邮件并点击确认链接 输入身份服务器 URL 无法连接到身份服务器 请输入身份服务器 url @@ -1143,14 +1143,14 @@ 输入验证码 重新发送 下一个 - 国际电话号码必须以 ‘+’ 开头 + 国际电话号码必须以“+”开头 电话号码似乎无效。请检查 在 %1$s 上注册 用户名或电子邮件 用户名 密码 下一个 - 用户名已占用 + 该用户名已被使用 警告 你的账户尚未创建。是否中止注册过程? 选择 matrix.org @@ -1171,7 +1171,7 @@ 如果你在主服务器上设置了账户,在下方使用你的 Matrix ID(例 @user:domain.com)和密码。 Matrix ID 如果你不知道你的密码,返回并重置。 - 这不是一个有效的用户标识符。期望的格式:\'@user:homeserver.org\' + 这不是有效的用户标识符。预期格式:\'@user:homeserver.org\' 无法找到有效的主服务器。请检查你的标识符 你已登出 这可能由于多种原因: @@ -1199,7 +1199,7 @@ 除非你登录以恢复加密密钥,否则你将无法访问安全消息。 当前会话用于用户 %1$s 而你提供了用户 %2$s 的凭证。${app_name} 不支持此功能。 \n请先清除数据,然后重新登录另一个账户。 - 你的 matrix.to 链接更是不正确 + 您的 matrix.to 链接格式错误 描述太短 初始同步… 高级设置 @@ -1531,7 +1531,7 @@ 你无法访问此消息因为发送者有意不发送密钥 正在等待加密历史 Riot 现已成为 Element! - 我们很高兴地宣布我们改名了!你的应用已经更新到最新版本,并且你已登录你的账户。 + 我们很高兴地宣布我们已经更名了!您的应用程序是最新的,并且您已登录到您的帐户。 明白了 了解更多 将恢复密钥保存到 @@ -1588,9 +1588,9 @@ 移除 %s? 请确认你已点击我们向你发送的电子邮件中的链接。 电子邮件和电话号码 - 管理链接到你的Matrix账户的电子邮件地址和电话号码 + 管理与您的 Matrix 帐户链接的电子邮件地址和电话号码 代码 - 请使用国际格式(电话号码必须以“+”开始) + 请使用国际格式(电话号码必须以“+”开头) 验证此登录来确认你的身份,授权其访问加密消息。 无法打开你被封禁的房间。 无法找到此房间。请确认它存在。 @@ -1714,7 +1714,7 @@ 建议 已知用户 二维码 - 通过QR码添加 + 通过二维码添加 房间设置 话题 房间话题(可选) @@ -1804,7 +1804,7 @@ %d 个条目 - 不是有效的 Matrix 二维码 + 这不是有效的 Matrix 二维码 扫描二维码 添加人员 邀请朋友 @@ -2099,15 +2099,15 @@ 我的用户名 我的显示名称 通知事项 - 其他 + 其它 提及和关键词 默认通知 可用视频通话 可用语音通话 在 ${app_name} 中直接接收邀请的设置 %s。 - 将此电子邮件地址与您的帐户相关联 - 加入这个空间的邀请被发送至 %s,此邮箱未与您的账户相关联 - 加入这个房间的邀请被发送至 %s,此邮箱未与您的账户相关联 + 将此电子邮件地址与您的帐户链接 + 此空间的邀请已发送至与您的帐户无关的 %s + 此房间的邀请已发送至与您的帐户无关的 %s 你所在的全部房间将显示在主页上。 在主页上显示所有房间 滑动结束通话 @@ -2171,7 +2171,7 @@ 空间访问 谁可以访问? 为 %s 启用电子邮件通知 - 要接收通知邮件,请将一个电子邮件地址关联到你的Matrix账户 + 要接收带有通知的电子邮件,请将电子邮件地址链接到您的 Matrix 帐户 电子邮件通知 升级空间 更改空间名称 @@ -2303,7 +2303,7 @@ BETA 共享你的实时位置 缩放到当前位置 - 地图上选定位置的图钉 + 地图上选定位置的固定标记 无投票 验证你的电子邮件 @@ -2336,12 +2336,12 @@ 共享位置 您需要拥有正确的权限才能在此房间中共享实时位置。 你没有权限共享实时位置 - %1$s前已更新 + %1$s 前已更新 临时执行:地点在房间历史中持续存在 启用实时位置共享 位置共享正在进行中 - ${app_name}实时位置 - 剩余%1$s + ${app_name} 实时位置 + 剩余 %1$s 停止 实时共享直到 %1$s 查看实时位置 @@ -2350,14 +2350,14 @@ 启用实时位置 加载地图失败 打开,用 - ${app_name}无法访问你的位置。请稍后再试。 - ${app_name}无法访问你的位置 + ${app_name} 无法访问你的位置。请稍后再试。 + ${app_name} 无法访问你的位置 在房间中查看 MSC3061:为过去的消息共享房间密钥 在共享历史的加密房间中邀请时,加密历史将可见。 - 8小时 - 1小时 - 15分钟 + 8 小时 + 1 小时 + 15 分钟 共享此位置 共享此位置 共享实时位置 @@ -2409,7 +2409,7 @@ 发送图片和视频 打开相机 服务器政策 - Element Matrix Services(EMS)是一个健壮且可靠的主机托管服务,可实现快速、安全和实时的通信。在<a href=\"${ftue_ems_url}\">element.io/ems</a>上了解如何使用 + Element Matrix Services (EMS) 是一种强大且可靠的托管服务,可实现快速、安全和实时的通信。 了解如何在 <a href=\"${ftue_ems_url}\">element.io/ems</a> 想架设自己的服务器? 服务器URL 选择你的服务器 @@ -2539,13 +2539,13 @@ 自动允许 Element 通话小部件并授予相机/麦克风访问权限 启用 Element 通话权限快捷方式 实时位置 - 这个QR码看起来不正常。请尝试用另一个方法验证。 + 此二维码看起来格式不正确。请尝试使用其它方法进行验证。 你无法访问加密消息历史。重置你的安全消息备份和验证密钥以重新开始。 无法验证此设备 你的服务器地址是什么? 你的对话发生的地方 %1$s 和 %2$s - 电子邮件未确认,检查你的收件箱 + 电子邮件未验证,请检查您的收件箱 无法加载地图 \n此主服务器可能没有设置好显示地图。 打开设置 @@ -2562,7 +2562,7 @@ A—Z 活动 排序方式 - 显示最近的 + 显示最近 显示过滤条件 布局偏好 探索房间 @@ -2622,7 +2622,7 @@ 你当前的会话已准备好安全地收发消息。 仅在首条消息创建私聊消息 启用延迟的私聊消息 - 简化的Element,带有可选的标签 + 简化的 Element,带有可选的标签 无痕键盘 请求键盘不要根据您在对话中输入的内容更新任何个性化数据,例如输入历史记录和字典。 请注意,某些键盘可能不遵守此设置。 ${app_name}需要权限来显示通知。通知可以显示消息、邀请等。 @@ -2762,9 +2762,31 @@ 停止语音广播录制 暂停语音广播录制 继续语音广播录制 - 扫描QR码 + 扫描二维码 语音广播 已启用: 会话ID: 出了点差错。请检查您的网络连接并重试。 + 联系人 + 切换全屏模式 + 选择会话 + 文本格式 + 相机 + 位置 + 投票 + 语音广播 + 附件 + 贴纸 + 照片库 + 您没有在此房间内开始语音广播所需的权限。联系房间管理员升级您的权限。 + 其他人已经在录制语音广播。等待他们的语音广播结束以开始新的广播。 + 您已经在录制语音广播。请结束您当前的语音广播以开始新的语音广播。 + 无法开始新的语音广播 + 快进 30 秒 + 快退 30 秒 + 取消全选 + 全选 + + 已选择 %1$d + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-zh-rTW/strings.xml b/library/ui-strings/src/main/res/values-zh-rTW/strings.xml index 739ea09755..91e08c803a 100644 --- a/library/ui-strings/src/main/res/values-zh-rTW/strings.xml +++ b/library/ui-strings/src/main/res/values-zh-rTW/strings.xml @@ -2781,4 +2781,12 @@ 已選取 %1$d + 切換全螢幕模式 + 文字格式化 + 您已在錄製語音廣播。請結束您目前的語音廣播以開始新的。 + 其他人已在錄製語音廣播。等待他們的語音廣播結束以開始新的。 + 您沒有在此聊天室中開始語音廣播的必要權限。請聯絡聊天室管理員以升級您的權限。 + 無法開始新的語音廣播 + 快轉30秒 + 快退30秒 \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values/donottranslate.xml b/library/ui-strings/src/main/res/values/donottranslate.xml index 741d23dbc6..bfe751ef5a 100755 --- a/library/ui-strings/src/main/res/values/donottranslate.xml +++ b/library/ui-strings/src/main/res/values/donottranslate.xml @@ -2,6 +2,7 @@ + Not implemented yet in ${app_name} diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index 897c2853d8..e503cb3fe7 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -1679,7 +1679,8 @@ Create New Room Create New Space No network. Please check your Internet connection. - Something went wrong. Please check your network connection and try again. + + Something went wrong. Please check your network connection and try again. "Change network" "Please wait…" Updating your data… @@ -3094,6 +3095,14 @@ Play or resume voice broadcast Pause voice broadcast Buffering + Fast backward 30 seconds + Fast forward 30 seconds + Can’t start a new voice broadcast + You don’t have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions. + Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one. + You are already recording a voice broadcast. Please end your current voice broadcast to start a new one. + + %1$s left Anyone in %s will be able to find and join this room - no need to manually invite everyone. You’ll be able to change this in room settings anytime. Anyone in a parent space will be able to find and join this room - no need to manually invite everyone. You’ll be able to change this in room settings anytime. @@ -3222,6 +3231,7 @@ Location Camera Contact + Text formatting Show less @@ -3338,6 +3348,11 @@ No inactive sessions found. Clear Filter Select sessions + Sign out + + Sign out of %1$d session + Sign out of %1$d sessions + Sign out of this session Session details Application, device, and activity information. @@ -3366,7 +3381,9 @@ Unverified sessions Unverified sessions are sessions that have logged in with your credentials but not been cross-verified.\n\nYou should make especially certain that you recognise these sessions as they could represent an unauthorised use of your account. Verified sessions - Verified sessions have logged in with your credentials and then been verified, either using your secure passphrase or by cross-verifying.\n\nThis means they hold encryption keys for your previous messages, and confirm to other users you are communicating with that these sessions are really you. + + Verified sessions have logged in with your credentials and then been verified, either using your secure passphrase or by cross-verifying.\n\nThis means they hold encryption keys for your previous messages, and confirm to other users you are communicating with that these sessions are really you. + Verified sessions are anywhere you are using this account after entering your passphrase or confirming your identity with another verified session.\n\nThis means that you have all the keys needed to unlock your encrypted messages and confirm to other users that you trust this session. Renaming sessions Other users in direct messages and rooms that you join are able to view a full list of your sessions.\n\nThis provides them with confidence that they are really speaking to you, but it also means they can see the session name you enter here. Enable new session manager @@ -3442,5 +3459,6 @@ Apply italic format Apply strikethrough format Apply underline format + Toggle full screen mode diff --git a/library/ui-styles/src/main/res/values/dimens.xml b/library/ui-styles/src/main/res/values/dimens.xml index 50d5aaf014..22c2a3e62c 100644 --- a/library/ui-styles/src/main/res/values/dimens.xml +++ b/library/ui-styles/src/main/res/values/dimens.xml @@ -74,7 +74,8 @@ 22dp - 48dp + 48dp + 36dp 112dp diff --git a/library/ui-styles/src/main/res/values/stylable_sessions_list_header_view.xml b/library/ui-styles/src/main/res/values/stylable_sessions_list_header_view.xml index 098ec263fc..c1a51000b7 100644 --- a/library/ui-styles/src/main/res/values/stylable_sessions_list_header_view.xml +++ b/library/ui-styles/src/main/res/values/stylable_sessions_list_header_view.xml @@ -5,6 +5,7 @@ + diff --git a/library/ui-styles/src/main/res/values/stylable_voice_broadcast_metadata_view.xml b/library/ui-styles/src/main/res/values/stylable_voice_broadcast_metadata_view.xml new file mode 100644 index 0000000000..1f72eeb396 --- /dev/null +++ b/library/ui-styles/src/main/res/values/stylable_voice_broadcast_metadata_view.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/library/ui-styles/src/main/res/values/styles_voice_broadcast.xml b/library/ui-styles/src/main/res/values/styles_voice_broadcast.xml new file mode 100644 index 0000000000..eb85378141 --- /dev/null +++ b/library/ui-styles/src/main/res/values/styles_voice_broadcast.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index 8bfcef3643..f50b672077 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -62,7 +62,7 @@ android { // that the app's state is completely cleared between tests. testInstrumentationRunnerArguments clearPackageData: 'true' - buildConfigField "String", "SDK_VERSION", "\"1.5.7\"" + buildConfigField "String", "SDK_VERSION", "\"1.5.8\"" buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\"" buildConfigField "String", "GIT_SDK_REVISION_UNIX_DATE", "\"${gitRevisionUnixDate()}\"" diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/search/SearchMessagesTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/search/SearchMessagesTest.kt index 6ef90193d8..81351523e9 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/search/SearchMessagesTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/search/SearchMessagesTest.kt @@ -16,6 +16,7 @@ package org.matrix.android.sdk.session.search +import org.amshove.kluent.shouldBeEqualTo import org.junit.Assert.assertTrue import org.junit.FixMethodOrder import org.junit.Test @@ -43,7 +44,7 @@ class SearchMessagesTest : InstrumentedTest { cryptoTestData.firstSession .searchService() .search( - searchTerm = "lore", + searchTerm = "lorem", limit = 10, includeProfile = true, afterLimit = 0, @@ -61,7 +62,7 @@ class SearchMessagesTest : InstrumentedTest { cryptoTestData.firstSession .searchService() .search( - searchTerm = "lore", + searchTerm = "lorem", roomId = cryptoTestData.roomId, limit = 10, includeProfile = true, @@ -73,7 +74,28 @@ class SearchMessagesTest : InstrumentedTest { } } - private fun doTest(block: suspend (CryptoTestData) -> SearchResult) = runCryptoTest(context()) { cryptoTestHelper, commonTestHelper -> + @Test + fun sendTextMessageAndSearchPartOfItIncompleteWord() { + doTest(expectedNumberOfResult = 0) { cryptoTestData -> + cryptoTestData.firstSession + .searchService() + .search( + searchTerm = "lore", /* incomplete word */ + roomId = cryptoTestData.roomId, + limit = 10, + includeProfile = true, + afterLimit = 0, + beforeLimit = 10, + orderByRecent = true, + nextBatch = null + ) + } + } + + private fun doTest( + expectedNumberOfResult: Int = 2, + block: suspend (CryptoTestData) -> SearchResult, + ) = runCryptoTest(context()) { cryptoTestHelper, commonTestHelper -> val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceInARoom(false) val aliceSession = cryptoTestData.firstSession val aliceRoomId = cryptoTestData.roomId @@ -87,7 +109,7 @@ class SearchMessagesTest : InstrumentedTest { val data = block.invoke(cryptoTestData) - assertTrue(data.results?.size == 2) + data.results?.size shouldBeEqualTo expectedNumberOfResult assertTrue( data.results ?.all { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/extensions/MetricsExtensions.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/extensions/MetricsExtensions.kt index 9487a27086..7f0e828f62 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/extensions/MetricsExtensions.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/extensions/MetricsExtensions.kt @@ -17,25 +17,51 @@ package org.matrix.android.sdk.api.extensions import org.matrix.android.sdk.api.metrics.MetricPlugin +import org.matrix.android.sdk.api.metrics.SpannableMetricPlugin import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind import kotlin.contracts.contract /** * Executes the given [block] while measuring the transaction. + * + * @param block Action/Task to be executed within this span. */ @OptIn(ExperimentalContracts::class) -inline fun measureMetric(metricMeasurementPlugins: List, block: () -> Unit) { +inline fun List.measureMetric(block: () -> Unit) { contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } try { - metricMeasurementPlugins.forEach { plugin -> plugin.startTransaction() } // Start the transaction. + this.forEach { plugin -> plugin.startTransaction() } // Start the transaction. block() } catch (throwable: Throwable) { - metricMeasurementPlugins.forEach { plugin -> plugin.onError(throwable) } // Capture if there is any exception thrown. + this.forEach { plugin -> plugin.onError(throwable) } // Capture if there is any exception thrown. throw throwable } finally { - metricMeasurementPlugins.forEach { plugin -> plugin.finishTransaction() } // Finally, finish this transaction. + this.forEach { plugin -> plugin.finishTransaction() } // Finally, finish this transaction. + } +} + +/** + * Executes the given [block] while measuring a span. + * + * @param operation Name of the new span. + * @param description Description of the new span. + * @param block Action/Task to be executed within this span. + */ +@OptIn(ExperimentalContracts::class) +inline fun List.measureSpan(operation: String, description: String, block: () -> Unit) { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + try { + this.forEach { plugin -> plugin.startSpan(operation, description) } // Start the transaction. + block() + } catch (throwable: Throwable) { + this.forEach { plugin -> plugin.onError(throwable) } // Capture if there is any exception thrown. + throw throwable + } finally { + this.forEach { plugin -> plugin.finishSpan() } // Finally, finish this transaction. } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/SpannableMetricPlugin.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/SpannableMetricPlugin.kt new file mode 100644 index 0000000000..54aa21877e --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/SpannableMetricPlugin.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.metrics + +/** + * A plugin that tracks span along with transactions. + */ +interface SpannableMetricPlugin : MetricPlugin { + + /** + * Starts the span for a sub-task. + * + * @param operation Name of the new span. + * @param description Description of the new span. + */ + fun startSpan(operation: String, description: String) + + /** + * Finish the span when sub-task is completed. + */ + fun finishSpan() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/SyncDurationMetricPlugin.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/SyncDurationMetricPlugin.kt new file mode 100644 index 0000000000..79ece002e9 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/SyncDurationMetricPlugin.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.metrics + +import org.matrix.android.sdk.api.logger.LoggerTag +import timber.log.Timber + +private val loggerTag = LoggerTag("SyncDurationMetricPlugin", LoggerTag.CRYPTO) + +/** + * An spannable metric plugin for sync response handling task. + */ +interface SyncDurationMetricPlugin : SpannableMetricPlugin { + + override fun logTransaction(message: String?) { + Timber.tag(loggerTag.value).v("## syncResponseHandler() : $message") + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt index d2aa8020e8..971d04261e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.api.session.crypto import android.content.Context +import androidx.annotation.Size import androidx.lifecycle.LiveData import androidx.paging.PagedList import org.matrix.android.sdk.api.MatrixCallback @@ -55,6 +56,8 @@ interface CryptoService { fun deleteDevice(deviceId: String, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, callback: MatrixCallback) + fun deleteDevices(@Size(min = 1) deviceIds: List, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, callback: MatrixCallback) + fun getCryptoVersion(context: Context, longFormat: Boolean): String fun isCryptoEnabled(): Boolean diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt index 1f16041b54..6ae585a273 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 @@ -53,7 +53,7 @@ inline fun Content?.toModel(catchError: Boolean = true): T? { val moshiAdapter = moshi.adapter(T::class.java) return try { moshiAdapter.fromJsonValue(this) - } catch (e: Exception) { + } catch (e: Throwable) { if (catchError) { Timber.e(e, "To model failed : $e") null diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt index 773e870ffd..11638837cc 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt @@ -70,6 +70,11 @@ data class HomeServerCapabilities( * True if the home server supports threaded read receipts and unread notifications. */ val canUseThreadReadReceiptsAndNotifications: Boolean = false, + + /** + * True if the home server supports remote toggle of Pusher for a given device. + */ + val canRemotelyTogglePushNotificationsOfDevices: Boolean = false, ) { enum class RoomCapabilitySupport { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilitiesService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilitiesService.kt index 9d2c48e194..c65a5382fb 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilitiesService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilitiesService.kt @@ -16,6 +16,9 @@ package org.matrix.android.sdk.api.session.homeserver +import androidx.lifecycle.LiveData +import org.matrix.android.sdk.api.util.Optional + /** * This interface defines a method to retrieve the homeserver capabilities. */ @@ -30,4 +33,9 @@ interface HomeServerCapabilitiesService { * Get the HomeServer capabilities. */ fun getHomeServerCapabilities(): HomeServerCapabilities + + /** + * Get a LiveData on the HomeServer capabilities. + */ + fun getHomeServerCapabilitiesLive(): LiveData> } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt index 223acd1b9c..6f4049de36 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 @@ -180,11 +180,13 @@ fun TimelineEvent.isRootThread(): Boolean { /** * Get the latest message body, after a possible edition, stripping the reply prefix if necessary. + * @param formatted Indicates whether the formatted HTML body of the message should be retrieved of the plain text one. + * @return If [formatted] is `true`, the HTML body of the message will be retrieved if available. Otherwise, the plain text/markdown version will be returned. */ fun TimelineEvent.getTextEditableContent(formatted: Boolean): String { val lastMessageContent = getLastMessageContent() val lastContentBody = if (formatted && lastMessageContent is MessageContentWithFormattedBody) { - lastMessageContent.formattedBody + lastMessageContent.formattedBody ?: lastMessageContent.body } else { lastMessageContent?.body } ?: return "" diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt index 1245d8df4b..f4de6a9ae9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.auth.version import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import org.matrix.android.sdk.api.extensions.orFalse /** * Model for https://matrix.org/docs/spec/client_server/latest#get-matrix-client-versions. @@ -56,6 +57,7 @@ private const val FEATURE_THREADS_MSC3440_STABLE = "org.matrix.msc3440.stable" private const val FEATURE_QR_CODE_LOGIN = "org.matrix.msc3882" private const val FEATURE_THREADS_MSC3771 = "org.matrix.msc3771" private const val FEATURE_THREADS_MSC3773 = "org.matrix.msc3773" +private const val FEATURE_REMOTE_TOGGLE_PUSH_NOTIFICATIONS_MSC3881 = "org.matrix.msc3881" /** * Return true if the SDK supports this homeserver version. @@ -142,3 +144,12 @@ private fun Versions.getMaxVersion(): HomeServerVersion { ?.maxOrNull() ?: HomeServerVersion.r0_0_0 } + +/** + * Indicate if the server supports MSC3881: https://github.com/matrix-org/matrix-spec-proposals/pull/3881. + * + * @return true if remote toggle of push notifications is supported + */ +internal fun Versions.doesServerSupportRemoteToggleOfPushNotifications(): Boolean { + return unstableFeatures?.get(FEATURE_REMOTE_TOGGLE_PUSH_NOTIFICATIONS_MSC3881).orFalse() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt index 9c3e0ba1c5..7862da1c17 100755 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt @@ -242,8 +242,12 @@ internal class DefaultCryptoService @Inject constructor( } override fun deleteDevice(deviceId: String, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, callback: MatrixCallback) { + deleteDevices(listOf(deviceId), userInteractiveAuthInterceptor, callback) + } + + override fun deleteDevices(deviceIds: List, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, callback: MatrixCallback) { deleteDeviceTask - .configureWith(DeleteDeviceTask.Params(deviceId, userInteractiveAuthInterceptor, null)) { + .configureWith(DeleteDeviceTask.Params(deviceIds, userInteractiveAuthInterceptor, null)) { this.executionThread = TaskThread.CRYPTO this.callback = callback } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DeviceListManager.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DeviceListManager.kt index 2ac6b8c854..7e9e156003 100755 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DeviceListManager.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DeviceListManager.kt @@ -355,7 +355,7 @@ internal class DeviceListManager @Inject constructor( val relevantPlugins = metricPlugins.filterIsInstance() val response: KeysQueryResponse - measureMetric(relevantPlugins) { + relevantPlugins.measureMetric { response = try { downloadKeysForUsersTask.execute(params) } catch (throwable: Throwable) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/api/CryptoApi.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/api/CryptoApi.kt index d5a8bdfd7c..cfe4681bfd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/api/CryptoApi.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/api/CryptoApi.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.crypto.api import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo import org.matrix.android.sdk.api.session.crypto.model.DevicesListResponse import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDeviceParams +import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDevicesParams import org.matrix.android.sdk.internal.crypto.model.rest.KeyChangesResponse import org.matrix.android.sdk.internal.crypto.model.rest.KeysClaimBody import org.matrix.android.sdk.internal.crypto.model.rest.KeysClaimResponse @@ -136,6 +137,17 @@ internal interface CryptoApi { @Body params: DeleteDeviceParams ) + /** + * Deletes the given devices, and invalidates any access token associated with them. + * Doc: https://spec.matrix.org/v1.4/client-server-api/#post_matrixclientv3delete_devices + * + * @param params the deletion parameters + */ + @POST(NetworkConstants.URI_API_PREFIX_PATH_V3 + "delete_devices") + suspend fun deleteDevices( + @Body params: DeleteDevicesParams + ) + /** * Update the device information. * Doc: https://matrix.org/docs/spec/client_server/r0.4.0.html#put-matrix-client-r0-devices-deviceid diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDeviceParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDeviceParams.kt index c26c6107c4..24dccc4d90 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDeviceParams.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDeviceParams.kt @@ -23,6 +23,9 @@ import com.squareup.moshi.JsonClass */ @JsonClass(generateAdapter = true) internal data class DeleteDeviceParams( + /** + * Additional authentication information for the user-interactive authentication API. + */ @Json(name = "auth") - val auth: Map? = null + val auth: Map? = null, ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDevicesParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDevicesParams.kt new file mode 100644 index 0000000000..19b33b2a69 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDevicesParams.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.matrix.android.sdk.internal.crypto.model.rest + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * This class provides the parameter to delete several devices. + */ +@JsonClass(generateAdapter = true) +internal data class DeleteDevicesParams( + /** + * Additional authentication information for the user-interactive authentication API. + */ + @Json(name = "auth") + val auth: Map? = null, + + /** + * Required: The list of device IDs to delete. + */ + @Json(name = "devices") + val deviceIds: List, +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/DeleteDeviceTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/DeleteDeviceTask.kt index 0a77d33acc..549122447e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/DeleteDeviceTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/DeleteDeviceTask.kt @@ -16,12 +16,14 @@ package org.matrix.android.sdk.internal.crypto.tasks +import androidx.annotation.Size import org.matrix.android.sdk.api.auth.UIABaseAuth import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor import org.matrix.android.sdk.api.session.uia.UiaResult import org.matrix.android.sdk.internal.auth.registration.handleUIA import org.matrix.android.sdk.internal.crypto.api.CryptoApi import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDeviceParams +import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDevicesParams import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.task.Task @@ -30,7 +32,7 @@ import javax.inject.Inject internal interface DeleteDeviceTask : Task { data class Params( - val deviceId: String, + @Size(min = 1) val deviceIds: List, val userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor?, val userAuthParam: UIABaseAuth? ) @@ -42,9 +44,24 @@ internal class DefaultDeleteDeviceTask @Inject constructor( ) : DeleteDeviceTask { override suspend fun execute(params: DeleteDeviceTask.Params) { + require(params.deviceIds.isNotEmpty()) + try { executeRequest(globalErrorReceiver) { - cryptoApi.deleteDevice(params.deviceId, DeleteDeviceParams(params.userAuthParam?.asMap())) + val userAuthParam = params.userAuthParam?.asMap() + if (params.deviceIds.size == 1) { + cryptoApi.deleteDevice( + deviceId = params.deviceIds.first(), + DeleteDeviceParams(auth = userAuthParam) + ) + } else { + cryptoApi.deleteDevices( + DeleteDevicesParams( + auth = userAuthParam, + deviceIds = params.deviceIds + ) + ) + } } } catch (throwable: Throwable) { if (params.userInteractiveAuthInterceptor == null || diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt index 58c015b13b..30836c027e 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 @@ -58,6 +58,7 @@ import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo038 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo039 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo040 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo041 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo042 import org.matrix.android.sdk.internal.util.Normalizer import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration import javax.inject.Inject @@ -66,7 +67,7 @@ internal class RealmSessionStoreMigration @Inject constructor( private val normalizer: Normalizer ) : MatrixRealmMigration( dbName = "Session", - schemaVersion = 41L, + schemaVersion = 42L, ) { /** * Forces all RealmSessionStoreMigration instances to be equal. @@ -117,5 +118,6 @@ internal class RealmSessionStoreMigration @Inject constructor( if (oldVersion < 39) MigrateSessionTo039(realm).perform() if (oldVersion < 40) MigrateSessionTo040(realm).perform() if (oldVersion < 41) MigrateSessionTo041(realm).perform() + if (oldVersion < 42) MigrateSessionTo042(realm).perform() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt index 3528ca0051..89657ad882 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt @@ -45,7 +45,8 @@ internal object HomeServerCapabilitiesMapper { canUseThreading = entity.canUseThreading, canControlLogoutDevices = entity.canControlLogoutDevices, canLoginWithQrCode = entity.canLoginWithQrCode, - canUseThreadReadReceiptsAndNotifications = entity.canUseThreadReadReceiptsAndNotifications + canUseThreadReadReceiptsAndNotifications = entity.canUseThreadReadReceiptsAndNotifications, + canRemotelyTogglePushNotificationsOfDevices = entity.canRemotelyTogglePushNotificationsOfDevices, ) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo042.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo042.kt new file mode 100644 index 0000000000..8826d894c1 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo042.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntityFields +import org.matrix.android.sdk.internal.extensions.forceRefreshOfHomeServerCapabilities +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +internal class MigrateSessionTo042(realm: DynamicRealm) : RealmMigrator(realm, 42) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("HomeServerCapabilitiesEntity") + ?.addField(HomeServerCapabilitiesEntityFields.CAN_REMOTELY_TOGGLE_PUSH_NOTIFICATIONS_OF_DEVICES, Boolean::class.java) + ?.forceRefreshOfHomeServerCapabilities() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt index 89f1e50b30..2b60f7723c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt @@ -33,6 +33,7 @@ internal open class HomeServerCapabilitiesEntity( var canControlLogoutDevices: Boolean = false, var canLoginWithQrCode: Boolean = false, var canUseThreadReadReceiptsAndNotifications: Boolean = false, + var canRemotelyTogglePushNotificationsOfDevices: Boolean = false, ) : RealmObject() { companion object diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt index 5aec7db66c..4bfda0bf3c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt @@ -22,6 +22,7 @@ internal object NetworkConstants { const val URI_API_PREFIX_PATH_ = "$URI_API_PREFIX_PATH/" const val URI_API_PREFIX_PATH_R0 = "$URI_API_PREFIX_PATH/r0/" const val URI_API_PREFIX_PATH_V1 = "$URI_API_PREFIX_PATH/v1/" + const val URI_API_PREFIX_PATH_V3 = "$URI_API_PREFIX_PATH/v3/" const val URI_API_PREFIX_PATH_UNSTABLE = "$URI_API_PREFIX_PATH/unstable/" // Media diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt index db1cd1b33b..3dd440737a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt @@ -408,7 +408,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter newAttachmentAttributes: NewAttachmentAttributes ) { localEchoRepository.updateEcho(eventId) { _, event -> - val content: Content? = event.asDomain().content + val content: Content? = event.asDomain(castJsonNumbers = true).content val messageContent: MessageContent? = content.toModel() // Retrieve potential additional content from the original event val additionalContent = content.orEmpty() - messageContent?.toContent().orEmpty().keys diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/DefaultHomeServerCapabilitiesService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/DefaultHomeServerCapabilitiesService.kt index 4c755b54b5..eb9e862de2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/DefaultHomeServerCapabilitiesService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/DefaultHomeServerCapabilitiesService.kt @@ -16,8 +16,10 @@ package org.matrix.android.sdk.internal.session.homeserver +import androidx.lifecycle.LiveData import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService +import org.matrix.android.sdk.api.util.Optional import javax.inject.Inject internal class DefaultHomeServerCapabilitiesService @Inject constructor( @@ -33,4 +35,8 @@ internal class DefaultHomeServerCapabilitiesService @Inject constructor( return homeServerCapabilitiesDataSource.getHomeServerCapabilities() ?: HomeServerCapabilities() } + + override fun getHomeServerCapabilitiesLive(): LiveData> { + return homeServerCapabilitiesDataSource.getHomeServerCapabilitiesLive() + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt index a5953d870c..11e86a5c51 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt @@ -25,6 +25,7 @@ import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities import org.matrix.android.sdk.internal.auth.version.Versions import org.matrix.android.sdk.internal.auth.version.doesServerSupportLogoutDevices import org.matrix.android.sdk.internal.auth.version.doesServerSupportQrCodeLogin +import org.matrix.android.sdk.internal.auth.version.doesServerSupportRemoteToggleOfPushNotifications import org.matrix.android.sdk.internal.auth.version.doesServerSupportThreadUnreadNotifications import org.matrix.android.sdk.internal.auth.version.doesServerSupportThreads import org.matrix.android.sdk.internal.auth.version.isLoginAndRegistrationSupportedBySdk @@ -141,13 +142,18 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor( } if (getVersionResult != null) { - homeServerCapabilitiesEntity.lastVersionIdentityServerSupported = getVersionResult.isLoginAndRegistrationSupportedBySdk() - homeServerCapabilitiesEntity.canControlLogoutDevices = getVersionResult.doesServerSupportLogoutDevices() + homeServerCapabilitiesEntity.lastVersionIdentityServerSupported = + getVersionResult.isLoginAndRegistrationSupportedBySdk() + homeServerCapabilitiesEntity.canControlLogoutDevices = + getVersionResult.doesServerSupportLogoutDevices() homeServerCapabilitiesEntity.canUseThreading = /* capabilities?.threads?.enabled.orFalse() || */ getVersionResult.doesServerSupportThreads() homeServerCapabilitiesEntity.canUseThreadReadReceiptsAndNotifications = getVersionResult.doesServerSupportThreadUnreadNotifications() - homeServerCapabilitiesEntity.canLoginWithQrCode = getVersionResult.doesServerSupportQrCodeLogin() + homeServerCapabilitiesEntity.canLoginWithQrCode = + getVersionResult.doesServerSupportQrCodeLogin() + homeServerCapabilitiesEntity.canRemotelyTogglePushNotificationsOfDevices = + getVersionResult.doesServerSupportRemoteToggleOfPushNotifications() } if (getWellknownResult != null && getWellknownResult is WellknownResult.Prompt) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/HomeServerCapabilitiesDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/HomeServerCapabilitiesDataSource.kt index 6c913fa41e..beb1e67e40 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/HomeServerCapabilitiesDataSource.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/HomeServerCapabilitiesDataSource.kt @@ -16,9 +16,14 @@ package org.matrix.android.sdk.internal.session.homeserver +import androidx.lifecycle.LiveData +import androidx.lifecycle.Transformations import com.zhuinden.monarchy.Monarchy import io.realm.Realm +import io.realm.kotlin.where import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities +import org.matrix.android.sdk.api.util.Optional +import org.matrix.android.sdk.api.util.toOptional import org.matrix.android.sdk.internal.database.mapper.HomeServerCapabilitiesMapper import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntity import org.matrix.android.sdk.internal.database.query.get @@ -26,7 +31,7 @@ import org.matrix.android.sdk.internal.di.SessionDatabase import javax.inject.Inject internal class HomeServerCapabilitiesDataSource @Inject constructor( - @SessionDatabase private val monarchy: Monarchy + @SessionDatabase private val monarchy: Monarchy, ) { fun getHomeServerCapabilities(): HomeServerCapabilities? { return Realm.getInstance(monarchy.realmConfiguration).use { realm -> @@ -35,4 +40,14 @@ internal class HomeServerCapabilitiesDataSource @Inject constructor( } } } + + fun getHomeServerCapabilitiesLive(): LiveData> { + val liveData = monarchy.findAllMappedWithChanges( + { realm: Realm -> realm.where() }, + { HomeServerCapabilitiesMapper.map(it) } + ) + return Transformations.map(liveData) { + it.firstOrNull().toOptional() + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt index 7d8605c2bd..55ba78c2a5 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 @@ -804,20 +804,12 @@ internal class LocalEchoEventFactory @Inject constructor( additionalContent: Content? = null, ): Event { val messageContent = quotedEvent.getLastMessageContent() - val textMsg = if (messageContent is MessageContentWithFormattedBody) { - messageContent.formattedBody - } else { - messageContent?.body - } - val quoteText = legacyRiotQuoteText(textMsg, text) - val quoteFormattedText = "
$textMsg
$formattedText" - + val formattedQuotedText = (messageContent as? MessageContentWithFormattedBody)?.formattedBody + val textContent = createQuoteTextContent(messageContent?.body, formattedQuotedText, text, formattedText, autoMarkdown) return if (rootThreadEventId != null) { createMessageEvent( roomId, - markdownParser - .parse(quoteText, force = true, advanced = autoMarkdown).copy(formattedText = quoteFormattedText) - .toThreadTextContent( + textContent.toThreadTextContent( rootThreadEventId = rootThreadEventId, latestThreadEventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId), msgType = MessageType.MSGTYPE_TEXT @@ -827,31 +819,54 @@ internal class LocalEchoEventFactory @Inject constructor( } else { createFormattedTextEvent( roomId, - markdownParser.parse(quoteText, force = true, advanced = autoMarkdown).copy(formattedText = quoteFormattedText), + textContent, MessageType.MSGTYPE_TEXT, additionalContent, ) } } - private fun legacyRiotQuoteText(quotedText: String?, myText: String): String { - val messageParagraphs = quotedText?.split("\n\n".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray() - return buildString { - if (messageParagraphs != null) { - for (i in messageParagraphs.indices) { - if (messageParagraphs[i].isNotBlank()) { - append("> ") - append(messageParagraphs[i]) - } + private fun createQuoteTextContent( + quotedText: String?, + formattedQuotedText: String?, + text: String, + formattedText: String?, + autoMarkdown: Boolean + ): TextContent { + val currentFormattedText = formattedText ?: if (autoMarkdown) { + val parsed = markdownParser.parse(text, force = true, advanced = true) + // If formattedText == text, formattedText is returned as null + parsed.formattedText ?: parsed.text + } else { + text + } + val processedFormattedQuotedText = formattedQuotedText ?: quotedText - if (i != messageParagraphs.lastIndex) { - append("\n\n") - } + val plainTextBody = buildString { + val plainMessageParagraphs = quotedText?.split("\n\n".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray().orEmpty() + plainMessageParagraphs.forEachIndexed { index, paragraph -> + if (paragraph.isNotBlank()) { + append("> ") + append(paragraph) + } + + if (index != plainMessageParagraphs.lastIndex) { + append("\n\n") } } append("\n\n") - append(myText) + append(text) } + val formattedTextBody = buildString { + if (!processedFormattedQuotedText.isNullOrBlank()) { + append("
") + append(processedFormattedQuotedText) + append("
") + } + append("
") + append(currentFormattedText) + } + return TextContent(plainTextBody, formattedTextBody) } companion object { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt index 05216d1de1..05d50d9595 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt @@ -17,6 +17,11 @@ package org.matrix.android.sdk.internal.session.sync import com.zhuinden.monarchy.Monarchy +import io.realm.Realm +import org.matrix.android.sdk.api.MatrixConfiguration +import org.matrix.android.sdk.api.extensions.measureMetric +import org.matrix.android.sdk.api.extensions.measureSpan +import org.matrix.android.sdk.api.metrics.SyncDurationMetricPlugin import org.matrix.android.sdk.api.session.pushrules.PushRuleService import org.matrix.android.sdk.api.session.pushrules.RuleScope import org.matrix.android.sdk.api.session.sync.InitialSyncStep @@ -52,9 +57,12 @@ internal class SyncResponseHandler @Inject constructor( private val tokenStore: SyncTokenStore, private val processEventForPushTask: ProcessEventForPushTask, private val pushRuleService: PushRuleService, - private val presenceSyncHandler: PresenceSyncHandler + private val presenceSyncHandler: PresenceSyncHandler, + matrixConfiguration: MatrixConfiguration, ) { + private val relevantPlugins = matrixConfiguration.metricPlugins.filterIsInstance() + suspend fun handleResponse( syncResponse: SyncResponse, fromToken: String?, @@ -63,39 +71,91 @@ internal class SyncResponseHandler @Inject constructor( val isInitialSync = fromToken == null Timber.v("Start handling sync, is InitialSync: $isInitialSync") - measureTimeMillis { - if (!cryptoService.isStarted()) { - Timber.v("Should start cryptoService") - cryptoService.start() - } - cryptoService.onSyncWillProcess(isInitialSync) - }.also { - Timber.v("Finish handling start cryptoService in $it ms") - } + relevantPlugins.measureMetric { + startCryptoService(isInitialSync) - // Handle the to device events before the room ones - // to ensure to decrypt them properly - measureTimeMillis { - Timber.v("Handle toDevice") - reportSubtask(reporter, InitialSyncStep.ImportingAccountCrypto, 100, 0.1f) { - if (syncResponse.toDevice != null) { - cryptoSyncHandler.handleToDevice(syncResponse.toDevice, reporter) + // Handle the to device events before the room ones + // to ensure to decrypt them properly + handleToDevice(syncResponse, reporter) + + val aggregator = SyncResponsePostTreatmentAggregator() + + // Prerequisite for thread events handling in RoomSyncHandler + // Disabled due to the new fallback + // if (!lightweightSettingsStorage.areThreadMessagesEnabled()) { + // threadsAwarenessHandler.fetchRootThreadEventsIfNeeded(syncResponse) + // } + + startMonarchyTransaction(syncResponse, isInitialSync, reporter, aggregator) + + aggregateSyncResponse(aggregator) + + postTreatmentSyncResponse(syncResponse, isInitialSync) + + markCryptoSyncCompleted(syncResponse) + + handlePostSync() + + Timber.v("On sync completed") + } + } + + private fun startCryptoService(isInitialSync: Boolean) { + relevantPlugins.measureSpan("task", "start_crypto_service") { + measureTimeMillis { + if (!cryptoService.isStarted()) { + Timber.v("Should start cryptoService") + cryptoService.start() } + cryptoService.onSyncWillProcess(isInitialSync) + }.also { + Timber.v("Finish handling start cryptoService in $it ms") } - }.also { - Timber.v("Finish handling toDevice in $it ms") } - val aggregator = SyncResponsePostTreatmentAggregator() + } - // Prerequisite for thread events handling in RoomSyncHandler -// Disabled due to the new fallback -// if (!lightweightSettingsStorage.areThreadMessagesEnabled()) { -// threadsAwarenessHandler.fetchRootThreadEventsIfNeeded(syncResponse) -// } + private suspend fun handleToDevice(syncResponse: SyncResponse, reporter: ProgressReporter?) { + relevantPlugins.measureSpan("task", "handle_to_device") { + measureTimeMillis { + Timber.v("Handle toDevice") + reportSubtask(reporter, InitialSyncStep.ImportingAccountCrypto, 100, 0.1f) { + if (syncResponse.toDevice != null) { + cryptoSyncHandler.handleToDevice(syncResponse.toDevice, reporter) + } + } + }.also { + Timber.v("Finish handling toDevice in $it ms") + } + } + } + private suspend fun startMonarchyTransaction( + syncResponse: SyncResponse, + isInitialSync: Boolean, + reporter: ProgressReporter?, + aggregator: SyncResponsePostTreatmentAggregator + ) { // Start one big transaction - monarchy.awaitTransaction { realm -> - // IMPORTANT nothing should be suspend here as we are accessing the realm instance (thread local) + relevantPlugins.measureSpan("task", "monarchy_transaction") { + monarchy.awaitTransaction { realm -> + // IMPORTANT nothing should be suspend here as we are accessing the realm instance (thread local) + handleRooms(reporter, syncResponse, realm, isInitialSync, aggregator) + handleAccountData(reporter, realm, syncResponse) + handlePresence(realm, syncResponse) + + tokenStore.saveToken(realm, syncResponse.nextBatch) + } + } + } + + private fun handleRooms( + reporter: ProgressReporter?, + syncResponse: SyncResponse, + realm: Realm, + isInitialSync: Boolean, + aggregator: SyncResponsePostTreatmentAggregator + ) { + relevantPlugins.measureSpan("task", "handle_rooms") { measureTimeMillis { Timber.v("Handle rooms") reportSubtask(reporter, InitialSyncStep.ImportingAccountRoom, 1, 0.8f) { @@ -106,7 +166,11 @@ internal class SyncResponseHandler @Inject constructor( }.also { Timber.v("Finish handling rooms in $it ms") } + } + } + private fun handleAccountData(reporter: ProgressReporter?, realm: Realm, syncResponse: SyncResponse) { + relevantPlugins.measureSpan("task", "handle_account_data") { measureTimeMillis { reportSubtask(reporter, InitialSyncStep.ImportingAccountData, 1, 0.1f) { Timber.v("Handle accountData") @@ -115,44 +179,59 @@ internal class SyncResponseHandler @Inject constructor( }.also { Timber.v("Finish handling accountData in $it ms") } + } + } + private fun handlePresence(realm: Realm, syncResponse: SyncResponse) { + relevantPlugins.measureSpan("task", "handle_presence") { measureTimeMillis { Timber.v("Handle Presence") presenceSyncHandler.handle(realm, syncResponse.presence) }.also { Timber.v("Finish handling Presence in $it ms") } - tokenStore.saveToken(realm, syncResponse.nextBatch) } + } - // Everything else we need to do outside the transaction - measureTimeMillis { - aggregatorHandler.handle(aggregator) - }.also { - Timber.v("Aggregator management took $it ms") - } - - measureTimeMillis { - syncResponse.rooms?.let { - checkPushRules(it, isInitialSync) - userAccountDataSyncHandler.synchronizeWithServerIfNeeded(it.invite) - dispatchInvitedRoom(it) + private suspend fun aggregateSyncResponse(aggregator: SyncResponsePostTreatmentAggregator) { + relevantPlugins.measureSpan("task", "aggregator_management") { + // Everything else we need to do outside the transaction + measureTimeMillis { + aggregatorHandler.handle(aggregator) + }.also { + Timber.v("Aggregator management took $it ms") } - }.also { - Timber.v("SyncResponse.rooms post treatment took $it ms") } + } - measureTimeMillis { - cryptoSyncHandler.onSyncCompleted(syncResponse) - }.also { - Timber.v("cryptoSyncHandler.onSyncCompleted took $it ms") + private suspend fun postTreatmentSyncResponse(syncResponse: SyncResponse, isInitialSync: Boolean) { + relevantPlugins.measureSpan("task", "sync_response_post_treatment") { + measureTimeMillis { + syncResponse.rooms?.let { + checkPushRules(it, isInitialSync) + userAccountDataSyncHandler.synchronizeWithServerIfNeeded(it.invite) + dispatchInvitedRoom(it) + } + }.also { + Timber.v("SyncResponse.rooms post treatment took $it ms") + } } + } - // post sync stuffs + private fun markCryptoSyncCompleted(syncResponse: SyncResponse) { + relevantPlugins.measureSpan("task", "crypto_sync_handler_onSyncCompleted") { + measureTimeMillis { + cryptoSyncHandler.onSyncCompleted(syncResponse) + }.also { + Timber.v("cryptoSyncHandler.onSyncCompleted took $it ms") + } + } + } + + private fun handlePostSync() { monarchy.writeAsync { roomSyncHandler.postSyncSpaceHierarchyHandle(it) } - Timber.v("On sync completed") } private fun dispatchInvitedRoom(roomsSyncResponse: RoomsSyncResponse) { diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactoryTests.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactoryTests.kt new file mode 100644 index 0000000000..b30428e5e1 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactoryTests.kt @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.send + +import org.amshove.kluent.internal.assertEquals +import org.junit.Before +import org.junit.Test +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.EditAggregatedSummary +import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.model.message.MessageContentWithFormattedBody +import org.matrix.android.sdk.api.session.room.sender.SenderInfo +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.util.TextContent +import org.matrix.android.sdk.test.fakes.FakeClock +import org.matrix.android.sdk.test.fakes.FakeContext +import org.matrix.android.sdk.test.fakes.internal.session.content.FakeThumbnailExtractor +import org.matrix.android.sdk.test.fakes.internal.session.permalinks.FakePermalinkFactory +import org.matrix.android.sdk.test.fakes.internal.session.room.send.FakeLocalEchoRepository +import org.matrix.android.sdk.test.fakes.internal.session.room.send.FakeMarkdownParser +import org.matrix.android.sdk.test.fakes.internal.session.room.send.FakeWaveFormSanitizer +import org.matrix.android.sdk.test.fakes.internal.session.room.send.pills.FakeTextPillsUtils + +@Suppress("MaxLineLength") +class LocalEchoEventFactoryTests { + + companion object { + internal const val A_USER_ID_1 = "@user_1:matrix.org" + internal const val A_ROOM_ID = "!sUeOGZKsBValPTUMax:matrix.org" + internal const val AN_EVENT_ID = "\$vApgexcL8Vfh-WxYKsFKCDooo67ttbjm3TiVKXaWijU" + internal const val AN_EPOCH = 1655210176L + + val A_START_EVENT = Event( + type = EventType.STATE_ROOM_CREATE, + eventId = AN_EVENT_ID, + originServerTs = 1652435922563, + senderId = A_USER_ID_1, + roomId = A_ROOM_ID + ) + } + + private val fakeContext = FakeContext() + private val fakeMarkdownParser = FakeMarkdownParser() + private val fakeTextPillsUtils = FakeTextPillsUtils() + private val fakeThumbnailExtractor = FakeThumbnailExtractor() + private val fakeWaveFormSanitizer = FakeWaveFormSanitizer() + private val fakeLocalEchoRepository = FakeLocalEchoRepository() + private val fakePermalinkFactory = FakePermalinkFactory() + private val fakeClock = FakeClock() + + private val localEchoEventFactory = LocalEchoEventFactory( + context = fakeContext.instance, + userId = A_USER_ID_1, + markdownParser = fakeMarkdownParser.instance, + textPillsUtils = fakeTextPillsUtils.instance, + thumbnailExtractor = fakeThumbnailExtractor.instance, + waveformSanitizer = fakeWaveFormSanitizer.instance, + localEchoRepository = fakeLocalEchoRepository.instance, + permalinkFactory = fakePermalinkFactory.instance, + clock = fakeClock + ) + + @Before + fun setup() { + fakeClock.givenEpoch(AN_EPOCH) + fakeMarkdownParser.givenBoldMarkdown() + } + + @Test + fun `given a null quotedText, when a quote event is created, then the result message should only contain the new text after new lines`() { + val event = createTimelineEvent(null, null) + val quotedContent = localEchoEventFactory.createQuotedTextEvent( + roomId = A_ROOM_ID, + quotedEvent = event, + text = "Text", + formattedText = null, + autoMarkdown = false, + rootThreadEventId = null, + additionalContent = null, + ).content.toModel() + assertEquals("\n\nText", quotedContent?.body) + assertEquals("
Text", (quotedContent as? MessageContentWithFormattedBody)?.formattedBody) + } + + @Test + fun `given a plain text quoted message, when a quote event is created, then the result message should contain both the quoted and new text`() { + val event = createTimelineEvent("Quoted", null) + val quotedContent = localEchoEventFactory.createQuotedTextEvent( + roomId = A_ROOM_ID, + quotedEvent = event, + text = "Text", + formattedText = null, + autoMarkdown = false, + rootThreadEventId = null, + additionalContent = null, + ).content.toModel() + assertEquals("> Quoted\n\nText", quotedContent?.body) + assertEquals("
Quoted

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

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

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

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

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

Text", + (quotedContent as? MessageContentWithFormattedBody)?.formattedBody + ) + } + + private fun createTimelineEvent(quotedText: String?, formattedQuotedText: String?): TimelineEvent { + val textContent = quotedText?.let { + TextContent( + quotedText, + formattedQuotedText + ).toMessageTextContent().toContent() + } + return TimelineEvent( + root = A_START_EVENT, + localId = 1234, + eventId = AN_EVENT_ID, + displayIndex = 0, + senderInfo = SenderInfo(A_USER_ID_1, A_USER_ID_1, true, null), + annotations = if (textContent != null) { + EventAnnotationsSummary( + editSummary = EditAggregatedSummary(latestContent = textContent, emptyList(), emptyList()) + ) + } else null + ) + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeClipboardManager.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeClipboardManager.kt new file mode 100644 index 0000000000..bce8b41aa9 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeClipboardManager.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fakes + +import android.content.ClipData +import android.content.ClipboardManager +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify + +class FakeClipboardManager { + val instance = mockk() + + fun givenSetPrimaryClip() { + every { instance.setPrimaryClip(any()) } just runs + } + + fun verifySetPrimaryClip(clipData: ClipData) { + verify { instance.setPrimaryClip(clipData) } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeConnectivityManager.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeConnectivityManager.kt new file mode 100644 index 0000000000..5c3a245c51 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeConnectivityManager.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fakes + +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import io.mockk.every +import io.mockk.mockk + +class FakeConnectivityManager { + val instance = mockk() + + fun givenNoActiveConnection() { + every { instance.activeNetwork } returns null + } + + fun givenHasActiveConnection() { + val network = mockk() + every { instance.activeNetwork } returns network + + val networkCapabilities = FakeNetworkCapabilities() + networkCapabilities.givenTransports( + NetworkCapabilities.TRANSPORT_CELLULAR, + NetworkCapabilities.TRANSPORT_WIFI, + NetworkCapabilities.TRANSPORT_VPN + ) + every { instance.getNetworkCapabilities(network) } returns networkCapabilities.instance + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeContext.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeContext.kt new file mode 100644 index 0000000000..966c6a1bb2 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeContext.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fakes + +import android.content.ClipboardManager +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.net.Uri +import android.os.ParcelFileDescriptor +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import java.io.OutputStream + +class FakeContext( + private val contentResolver: ContentResolver = mockk() +) { + + val instance = mockk() + + init { + every { instance.contentResolver } returns contentResolver + every { instance.applicationContext } returns instance + } + + fun givenFileDescriptor(uri: Uri, mode: String, factory: () -> ParcelFileDescriptor?) { + val fileDescriptor = factory() + every { contentResolver.openFileDescriptor(uri, mode, null) } returns fileDescriptor + } + + fun givenSafeOutputStreamFor(uri: Uri): OutputStream { + val outputStream = mockk(relaxed = true) + every { contentResolver.openOutputStream(uri, "wt") } returns outputStream + return outputStream + } + + fun givenMissingSafeOutputStreamFor(uri: Uri) { + every { contentResolver.openOutputStream(uri, "wt") } returns null + } + + fun givenNoConnection() { + val connectivityManager = FakeConnectivityManager() + connectivityManager.givenNoActiveConnection() + givenService(Context.CONNECTIVITY_SERVICE, ConnectivityManager::class.java, connectivityManager.instance) + } + + fun givenService(name: String, klass: Class, service: T) { + every { instance.getSystemService(name) } returns service + every { instance.getSystemService(klass) } returns service + } + + fun givenHasConnection() { + val connectivityManager = FakeConnectivityManager() + connectivityManager.givenHasActiveConnection() + givenService(Context.CONNECTIVITY_SERVICE, ConnectivityManager::class.java, connectivityManager.instance) + } + + fun givenStartActivity(intent: Intent) { + every { instance.startActivity(intent) } just runs + } + + fun givenClipboardManager(): FakeClipboardManager { + val fakeClipboardManager = FakeClipboardManager() + givenService(Context.CLIPBOARD_SERVICE, ClipboardManager::class.java, fakeClipboardManager.instance) + return fakeClipboardManager + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeNetworkCapabilities.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeNetworkCapabilities.kt new file mode 100644 index 0000000000..c630b94d47 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeNetworkCapabilities.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fakes + +import android.net.NetworkCapabilities +import io.mockk.every +import io.mockk.mockk + +class FakeNetworkCapabilities { + val instance = mockk() + + fun givenTransports(vararg type: Int) { + every { instance.hasTransport(any()) } answers { + val input = it.invocation.args.first() as Int + type.contains(input) + } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/content/FakeThumbnailExtractor.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/content/FakeThumbnailExtractor.kt new file mode 100644 index 0000000000..b541d24161 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/content/FakeThumbnailExtractor.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fakes.internal.session.content + +import io.mockk.mockk +import org.matrix.android.sdk.internal.session.content.ThumbnailExtractor + +class FakeThumbnailExtractor { + internal val instance = mockk() +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/permalinks/FakePermalinkFactory.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/permalinks/FakePermalinkFactory.kt new file mode 100644 index 0000000000..3d7e85424e --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/permalinks/FakePermalinkFactory.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fakes.internal.session.permalinks + +import io.mockk.mockk +import org.matrix.android.sdk.internal.session.permalinks.PermalinkFactory + +class FakePermalinkFactory { + internal val instance = mockk() +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/FakeLocalEchoRepository.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/FakeLocalEchoRepository.kt new file mode 100644 index 0000000000..b10d13824b --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/FakeLocalEchoRepository.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fakes.internal.session.room.send + +import io.mockk.mockk +import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository + +class FakeLocalEchoRepository { + internal val instance = mockk() +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/FakeMarkdownParser.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/FakeMarkdownParser.kt new file mode 100644 index 0000000000..a27c9284e7 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/FakeMarkdownParser.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fakes.internal.session.room.send + +import io.mockk.every +import io.mockk.mockk +import org.matrix.android.sdk.api.util.TextContent +import org.matrix.android.sdk.internal.session.room.send.MarkdownParser + +class FakeMarkdownParser { + internal val instance = mockk() + fun givenBoldMarkdown() { + every { instance.parse(any(), any(), any()) } answers { + val text = arg(0) + TextContent(text, "${text.replace("*", "")}") + } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/FakeWaveFormSanitizer.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/FakeWaveFormSanitizer.kt new file mode 100644 index 0000000000..052ddf7831 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/FakeWaveFormSanitizer.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fakes.internal.session.room.send + +import io.mockk.mockk +import org.matrix.android.sdk.internal.session.room.send.WaveFormSanitizer + +class FakeWaveFormSanitizer { + internal val instance = mockk() +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/pills/FakeTextPillsUtils.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/pills/FakeTextPillsUtils.kt new file mode 100644 index 0000000000..0d783d6628 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/pills/FakeTextPillsUtils.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fakes.internal.session.room.send.pills + +import io.mockk.mockk +import org.matrix.android.sdk.internal.session.room.send.pills.TextPillsUtils + +class FakeTextPillsUtils { + internal val instance = mockk() +} diff --git a/vector-app/build.gradle b/vector-app/build.gradle index b1b7e5f748..bff0193509 100644 --- a/vector-app/build.gradle +++ b/vector-app/build.gradle @@ -37,7 +37,7 @@ ext.versionMinor = 5 // Note: even values are reserved for regular release, odd values for hotfix release. // When creating a hotfix, you should decrease the value, since the current value // is the value for the next regular release. -ext.versionPatch = 7 +ext.versionPatch = 8 static def getGitTimestamp() { def cmd = 'git show -s --format=%ct' @@ -372,7 +372,7 @@ dependencies { debugImplementation 'com.facebook.soloader:soloader:0.10.4' debugImplementation "com.kgurgul.flipper:flipper-realm-android:2.2.0" - gplayImplementation "com.google.android.gms:play-services-location:21.0.0" + gplayImplementation "com.google.android.gms:play-services-location:21.0.1" // UnifiedPush gplay flavor only gplayImplementation('com.google.firebase:firebase-messaging:23.1.0') { exclude group: 'com.google.firebase', module: 'firebase-core' @@ -412,7 +412,7 @@ dependencies { androidTestImplementation libs.mockk.mockkAndroid androidTestUtil libs.androidx.orchestrator androidTestImplementation libs.androidx.fragmentTesting - androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.7.20" + androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.7.21" debugImplementation libs.androidx.fragmentTesting debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1' } diff --git a/vector/build.gradle b/vector/build.gradle index 9857f88479..890236422e 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -132,7 +132,7 @@ dependencies { implementation libs.androidx.biometric api "org.threeten:threetenbp:1.4.0:no-tzdb" - api "com.gabrielittner.threetenbp:lazythreetenbp:0.11.0" + api "com.gabrielittner.threetenbp:lazythreetenbp:0.12.0" implementation libs.squareup.moshi kapt libs.squareup.moshiKotlin @@ -233,7 +233,7 @@ dependencies { kapt libs.dagger.hiltCompiler // Analytics - implementation('com.posthog.android:posthog:1.1.2') { + implementation('com.posthog.android:posthog:2.0.0') { exclude group: 'com.android.support', module: 'support-annotations' } implementation libs.sentry.sentryAndroid @@ -308,7 +308,7 @@ dependencies { // Fix issue with Jitsi. Inspired from https://github.com/android/android-test/issues/861#issuecomment-872067868 // Error was lots of `Duplicate class org.checkerframework.common.reflection.qual.MethodVal found in modules jetified-checker-3.1 (org.checkerframework:checker:3.1.1) and jetified-checker-qual-3.12.0 (org.checkerframework:checker-qual:3.12.0) //noinspection GradleDependency Cannot use latest 3.15.0 since it required min API 26. - implementation "org.checkerframework:checker:3.11.0" + implementation "org.checkerframework:checker:3.27.0" androidTestImplementation libs.androidx.testCore androidTestImplementation libs.androidx.testRunner @@ -331,5 +331,5 @@ dependencies { androidTestImplementation libs.mockk.mockkAndroid androidTestUtil libs.androidx.orchestrator debugImplementation libs.androidx.fragmentTesting - androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.7.20" + androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.7.21" } diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index b7401079e4..d28312ac1c 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -150,7 +150,8 @@ + android:parentActivityName=".features.home.HomeActivity" + android:windowSoftInputMode="adjustResize"> diff --git a/vector/src/main/java/im/vector/app/core/di/VoiceModule.kt b/vector/src/main/java/im/vector/app/core/di/VoiceModule.kt index 54d556ea91..30a8565771 100644 --- a/vector/src/main/java/im/vector/app/core/di/VoiceModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/VoiceModule.kt @@ -18,24 +18,33 @@ package im.vector.app.core.di import android.content.Context import android.os.Build +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorderQ +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayerImpl +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorderQ import javax.inject.Singleton -@Module @InstallIn(SingletonComponent::class) -object VoiceModule { - @Provides - @Singleton - fun providesVoiceBroadcastRecorder(context: Context): VoiceBroadcastRecorder? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - VoiceBroadcastRecorderQ(context) - } else { - null +@Module +abstract class VoiceModule { + + companion object { + @Provides + @Singleton + fun providesVoiceBroadcastRecorder(context: Context): VoiceBroadcastRecorder? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + VoiceBroadcastRecorderQ(context) + } else { + null + } } } + + @Binds + abstract fun bindVoiceBroadcastPlayer(player: VoiceBroadcastPlayerImpl): VoiceBroadcastPlayer } diff --git a/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt b/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt index a09f852958..380c80775b 100644 --- a/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt +++ b/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt @@ -21,6 +21,8 @@ import im.vector.app.R import im.vector.app.core.resources.StringProvider import im.vector.app.features.call.dialpad.DialPadLookup import im.vector.app.features.voice.VoiceFailure +import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure +import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure.RecordingError import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.failure.MatrixIdFailure @@ -135,6 +137,7 @@ class DefaultErrorFormatter @Inject constructor( is MatrixIdFailure.InvalidMatrixId -> stringProvider.getString(R.string.login_signin_matrix_id_error_invalid_matrix_id) is VoiceFailure -> voiceMessageError(throwable) + is VoiceBroadcastFailure -> voiceBroadcastMessageError(throwable) is ActivityNotFoundException -> stringProvider.getString(R.string.error_no_external_application_found) else -> throwable.localizedMessage @@ -149,6 +152,14 @@ class DefaultErrorFormatter @Inject constructor( } } + private fun voiceBroadcastMessageError(throwable: VoiceBroadcastFailure): String { + return when (throwable) { + RecordingError.BlockedBySomeoneElse -> stringProvider.getString(R.string.error_voice_broadcast_blocked_by_someone_else_message) + RecordingError.NoPermission -> stringProvider.getString(R.string.error_voice_broadcast_permission_denied_message) + RecordingError.UserAlreadyBroadcasting -> stringProvider.getString(R.string.error_voice_broadcast_already_in_progress_message) + } + } + private fun limitExceededError(error: MatrixError): String { val delay = error.retryAfterMillis diff --git a/vector/src/main/java/im/vector/app/core/extensions/MenuItemExt.kt b/vector/src/main/java/im/vector/app/core/extensions/MenuItemExt.kt new file mode 100644 index 0000000000..7d62a0c357 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/extensions/MenuItemExt.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.core.extensions + +import android.view.MenuItem +import androidx.annotation.ColorInt +import androidx.core.text.toSpannable +import im.vector.app.core.utils.colorizeMatchingText + +fun MenuItem.setTextColor(@ColorInt color: Int) { + val currentTitle = title.orEmpty().toString() + title = currentTitle + .toSpannable() + .colorizeMatchingText(currentTitle, color) +} diff --git a/vector/src/main/java/im/vector/app/core/extensions/ViewExtensions.kt b/vector/src/main/java/im/vector/app/core/extensions/ViewExtensions.kt index 625ff15ef7..156809d5ad 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/ViewExtensions.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/ViewExtensions.kt @@ -29,7 +29,13 @@ import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.isVisible +import androidx.transition.ChangeBounds +import androidx.transition.Fade +import androidx.transition.Transition +import androidx.transition.TransitionManager +import androidx.transition.TransitionSet import im.vector.app.R +import im.vector.app.core.animations.SimpleTransitionListener import im.vector.app.features.themes.ThemeUtils /** @@ -90,3 +96,18 @@ fun View.setAttributeBackground(@AttrRes attributeId: Int) { val attribute = ThemeUtils.getAttribute(context, attributeId)!! setBackgroundResource(attribute.resourceId) } + +fun ViewGroup.animateLayoutChange(animationDuration: Long, transitionComplete: (() -> Unit)? = null) { + val transition = TransitionSet().apply { + ordering = TransitionSet.ORDERING_SEQUENTIAL + addTransition(ChangeBounds()) + addTransition(Fade(Fade.IN)) + duration = animationDuration + addListener(object : SimpleTransitionListener() { + override fun onTransitionEnd(transition: Transition) { + transitionComplete?.invoke() + } + }) + } + TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition) +} diff --git a/vector/src/main/java/im/vector/app/core/notification/EnableNotificationsSettingUpdater.kt b/vector/src/main/java/im/vector/app/core/notification/EnableNotificationsSettingUpdater.kt new file mode 100644 index 0000000000..81b524cde9 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/notification/EnableNotificationsSettingUpdater.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.core.notification + +import im.vector.app.features.session.coroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.session.Session +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class EnableNotificationsSettingUpdater @Inject constructor( + private val updateEnableNotificationsSettingOnChangeUseCase: UpdateEnableNotificationsSettingOnChangeUseCase, +) { + + private var job: Job? = null + + fun onSessionsStarted(session: Session) { + job?.cancel() + job = session.coroutineScope.launch { + updateEnableNotificationsSettingOnChangeUseCase.execute(session) + } + } +} diff --git a/vector/src/main/java/im/vector/app/core/notification/UpdateEnableNotificationsSettingOnChangeUseCase.kt b/vector/src/main/java/im/vector/app/core/notification/UpdateEnableNotificationsSettingOnChangeUseCase.kt new file mode 100644 index 0000000000..36df939bad --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/notification/UpdateEnableNotificationsSettingOnChangeUseCase.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.core.notification + +import im.vector.app.features.settings.VectorPreferences +import im.vector.app.features.settings.devices.v2.notification.GetNotificationsStatusUseCase +import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import org.matrix.android.sdk.api.session.Session +import javax.inject.Inject + +/** + * Listen for changes in either Pusher or Account data to update the local enable notifications + * setting for the current device. + */ +class UpdateEnableNotificationsSettingOnChangeUseCase @Inject constructor( + private val vectorPreferences: VectorPreferences, + private val getNotificationsStatusUseCase: GetNotificationsStatusUseCase, +) { + + suspend fun execute(session: Session) { + val deviceId = session.sessionParams.deviceId ?: return + getNotificationsStatusUseCase.execute(session, deviceId) + .onEach(::updatePreference) + .collect() + } + + private fun updatePreference(notificationStatus: NotificationsStatus) { + when (notificationStatus) { + NotificationsStatus.ENABLED -> vectorPreferences.setNotificationEnabledForDevice(true) + NotificationsStatus.DISABLED -> vectorPreferences.setNotificationEnabledForDevice(false) + else -> Unit + } + } +} diff --git a/vector/src/main/java/im/vector/app/core/pushers/PushersManager.kt b/vector/src/main/java/im/vector/app/core/pushers/PushersManager.kt index cda6f5bae8..6f186262fc 100644 --- a/vector/src/main/java/im/vector/app/core/pushers/PushersManager.kt +++ b/vector/src/main/java/im/vector/app/core/pushers/PushersManager.kt @@ -97,12 +97,6 @@ class PushersManager @Inject constructor( return session.pushersService().getPushers().firstOrNull { it.deviceId == deviceId } } - suspend fun togglePusherForCurrentSession(enable: Boolean) { - val session = activeSessionHolder.getSafeActiveSession() ?: return - val pusher = getPusherForCurrentSession() ?: return - session.pushersService().togglePusher(pusher, enable) - } - suspend fun unregisterEmailPusher(email: String) { val currentSession = activeSessionHolder.getSafeActiveSession() ?: return currentSession.pushersService().removeEmailPusher(email) diff --git a/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt b/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt index a5e1fe68bd..71863b8642 100644 --- a/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt +++ b/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt @@ -19,6 +19,7 @@ package im.vector.app.core.session import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import im.vector.app.core.extensions.startSyncing +import im.vector.app.core.notification.EnableNotificationsSettingUpdater import im.vector.app.core.session.clientinfo.UpdateMatrixClientInfoUseCase import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.settings.VectorPreferences @@ -32,6 +33,7 @@ class ConfigureAndStartSessionUseCase @Inject constructor( private val webRtcCallManager: WebRtcCallManager, private val updateMatrixClientInfoUseCase: UpdateMatrixClientInfoUseCase, private val vectorPreferences: VectorPreferences, + private val enableNotificationsSettingUpdater: EnableNotificationsSettingUpdater, ) { suspend fun execute(session: Session, startSyncing: Boolean = true) { @@ -46,5 +48,6 @@ class ConfigureAndStartSessionUseCase @Inject constructor( if (vectorPreferences.isClientInfoRecordingEnabled()) { updateMatrixClientInfoUseCase.execute(session) } + enableNotificationsSettingUpdater.onSessionsStarted(session) } } diff --git a/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageView.kt b/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageView.kt index 263f043fad..b6dc404d01 100644 --- a/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageView.kt +++ b/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageView.kt @@ -48,9 +48,4 @@ class TypingMessageView @JvmOverloads constructor( views.typingUserText.text = typingHelper.getNotificationTypingMessage(typingUsers) views.typingUserAvatars.render(typingUsers, avatarRenderer) } - - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() - removeAllViews() - } } diff --git a/vector/src/main/java/im/vector/app/features/analytics/metrics/VectorPlugins.kt b/vector/src/main/java/im/vector/app/features/analytics/metrics/VectorPlugins.kt index 64f143a2fd..4278c1011b 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/metrics/VectorPlugins.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/metrics/VectorPlugins.kt @@ -17,6 +17,7 @@ package im.vector.app.features.analytics.metrics import im.vector.app.features.analytics.metrics.sentry.SentryDownloadDeviceKeysMetrics +import im.vector.app.features.analytics.metrics.sentry.SentrySyncDurationMetrics import org.matrix.android.sdk.api.metrics.MetricPlugin import javax.inject.Inject import javax.inject.Singleton @@ -27,9 +28,10 @@ import javax.inject.Singleton @Singleton data class VectorPlugins @Inject constructor( val sentryDownloadDeviceKeysMetrics: SentryDownloadDeviceKeysMetrics, + val sentrySyncDurationMetrics: SentrySyncDurationMetrics, ) { /** * Returns [List] of all [MetricPlugin] hold by this class. */ - fun plugins(): List = listOf(sentryDownloadDeviceKeysMetrics) + fun plugins(): List = listOf(sentryDownloadDeviceKeysMetrics, sentrySyncDurationMetrics) } diff --git a/vector/src/main/java/im/vector/app/features/analytics/metrics/sentry/SentryDownloadDeviceKeysMetrics.kt b/vector/src/main/java/im/vector/app/features/analytics/metrics/sentry/SentryDownloadDeviceKeysMetrics.kt index 92213d380c..488b72bfd9 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/metrics/sentry/SentryDownloadDeviceKeysMetrics.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/metrics/sentry/SentryDownloadDeviceKeysMetrics.kt @@ -26,8 +26,10 @@ class SentryDownloadDeviceKeysMetrics @Inject constructor() : DownloadDeviceKeys private var transaction: ITransaction? = null override fun startTransaction() { - transaction = Sentry.startTransaction("download_device_keys", "task") - logTransaction("Sentry transaction started") + if (Sentry.isEnabled()) { + transaction = Sentry.startTransaction("download_device_keys", "task") + logTransaction("Sentry transaction started") + } } override fun finishTransaction() { diff --git a/vector/src/main/java/im/vector/app/features/analytics/metrics/sentry/SentrySyncDurationMetrics.kt b/vector/src/main/java/im/vector/app/features/analytics/metrics/sentry/SentrySyncDurationMetrics.kt new file mode 100644 index 0000000000..d69ed01526 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/analytics/metrics/sentry/SentrySyncDurationMetrics.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.analytics.metrics.sentry + +import io.sentry.ISpan +import io.sentry.ITransaction +import io.sentry.Sentry +import io.sentry.SpanStatus +import org.matrix.android.sdk.api.metrics.SyncDurationMetricPlugin +import java.util.EmptyStackException +import java.util.Stack +import javax.inject.Inject + +/** + * Sentry based implementation of SyncDurationMetricPlugin. + */ +class SentrySyncDurationMetrics @Inject constructor() : SyncDurationMetricPlugin { + private var transaction: ITransaction? = null + + // Stacks to keep spans in LIFO order. + private var spans: Stack = Stack() + + /** + * Starts the span for a sub-task. + * + * @param operation Name of the new span. + * @param description Description of the new span. + * + * @throws IllegalStateException if this is called without starting a transaction ie. `measureSpan` must be called within `measureMetric`. + */ + override fun startSpan(operation: String, description: String) { + if (Sentry.isEnabled()) { + val span = Sentry.getSpan() ?: throw IllegalStateException("measureSpan block must be called within measureMetric") + val innerSpan = span.startChild(operation, description) + spans.push(innerSpan) + logTransaction("Sentry span started: operation=[$operation], description=[$description]") + } + } + + override fun finishSpan() { + try { + spans.pop() + } catch (e: EmptyStackException) { + null + }?.finish() + logTransaction("Sentry span finished") + } + + override fun startTransaction() { + if (Sentry.isEnabled()) { + transaction = Sentry.startTransaction("sync_response_handler", "task", true) + logTransaction("Sentry transaction started") + } + } + + override fun finishTransaction() { + transaction?.finish() + logTransaction("Sentry transaction finished") + } + + override fun onError(throwable: Throwable) { + try { + spans.peek() + } catch (e: EmptyStackException) { + null + }?.apply { + this.throwable = throwable + this.status = SpanStatus.INTERNAL_ERROR + } ?: transaction?.apply { + this.throwable = throwable + this.status = SpanStatus.INTERNAL_ERROR + } + logTransaction("Sentry transaction encountered error ${throwable.message}") + } +} diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/UserProperties.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/UserProperties.kt index 28732c9a42..01720453ce 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/plan/UserProperties.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/plan/UserProperties.kt @@ -24,26 +24,6 @@ package im.vector.app.features.analytics.plan * definition. These properties must all be device independent. */ data class UserProperties( - /** - * Whether the user has the favourites space enabled. - */ - val webMetaSpaceFavouritesEnabled: Boolean? = null, - /** - * Whether the user has the home space set to all rooms. - */ - val webMetaSpaceHomeAllRooms: Boolean? = null, - /** - * Whether the user has the home space enabled. - */ - val webMetaSpaceHomeEnabled: Boolean? = null, - /** - * Whether the user has the other rooms space enabled. - */ - val webMetaSpaceOrphansEnabled: Boolean? = null, - /** - * Whether the user has the people space enabled. - */ - val webMetaSpacePeopleEnabled: Boolean? = null, /** * The active filter in the All Chats screen. */ @@ -109,11 +89,6 @@ data class UserProperties( fun getProperties(): Map? { return mutableMapOf().apply { - webMetaSpaceFavouritesEnabled?.let { put("WebMetaSpaceFavouritesEnabled", it) } - webMetaSpaceHomeAllRooms?.let { put("WebMetaSpaceHomeAllRooms", it) } - webMetaSpaceHomeEnabled?.let { put("WebMetaSpaceHomeEnabled", it) } - webMetaSpaceOrphansEnabled?.let { put("WebMetaSpaceOrphansEnabled", it) } - webMetaSpacePeopleEnabled?.let { put("WebMetaSpacePeopleEnabled", it) } allChatsActiveFilter?.let { put("allChatsActiveFilter", it.name) } ftueUseCaseSelection?.let { put("ftueUseCaseSelection", it.name) } numFavouriteRooms?.let { put("numFavouriteRooms", it) } diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt index af17800455..f8d5d768ef 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt @@ -23,10 +23,10 @@ import android.view.ViewGroup import androidx.core.view.isVisible import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels -import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.parentFragmentViewModel import com.airbnb.mvrx.withState import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.R import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment import im.vector.app.databinding.BottomSheetAttachmentTypeSelectorBinding import im.vector.app.features.home.room.detail.TimelineViewModel @@ -34,7 +34,7 @@ import im.vector.app.features.home.room.detail.TimelineViewModel @AndroidEntryPoint class AttachmentTypeSelectorBottomSheet : VectorBaseBottomSheetDialogFragment() { - private val viewModel: AttachmentTypeSelectorViewModel by fragmentViewModel() + private val viewModel: AttachmentTypeSelectorViewModel by parentFragmentViewModel() private val timelineViewModel: TimelineViewModel by parentFragmentViewModel() private val sharedActionViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels( ownerProducer = { requireParentFragment() } @@ -51,6 +51,14 @@ class AttachmentTypeSelectorBottomSheet : VectorBaseBottomSheetDialogFragment onTextFormattingToggled(isChecked) } } private fun onAttachmentSelected(attachmentType: AttachmentType) { @@ -71,6 +80,9 @@ class AttachmentTypeSelectorBottomSheet : VectorBaseBottomSheetDialogFragment(initialState) { + private val vectorPreferences: VectorPreferences, +) : VectorViewModel(initialState) { @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { override fun create(initialState: AttachmentTypeSelectorViewState): AttachmentTypeSelectorViewModel @@ -39,8 +41,8 @@ class AttachmentTypeSelectorViewModel @AssistedInject constructor( companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() - override fun handle(action: EmptyAction) { - // do nothing + override fun handle(action: AttachmentTypeSelectorAction) = when (action) { + is AttachmentTypeSelectorAction.ToggleTextFormatting -> setTextFormattingEnabled(action.isEnabled) } init { @@ -48,6 +50,16 @@ class AttachmentTypeSelectorViewModel @AssistedInject constructor( copy( isLocationVisible = vectorFeatures.isLocationSharingEnabled(), isVoiceBroadcastVisible = vectorFeatures.isVoiceBroadcastEnabled(), + isTextFormattingEnabled = vectorPreferences.isTextFormattingEnabled(), + ) + } + } + + private fun setTextFormattingEnabled(isEnabled: Boolean) { + vectorPreferences.setTextFormattingEnabled(isEnabled) + setState { + copy( + isTextFormattingEnabled = isEnabled ) } } @@ -56,4 +68,9 @@ class AttachmentTypeSelectorViewModel @AssistedInject constructor( data class AttachmentTypeSelectorViewState( val isLocationVisible: Boolean = false, val isVoiceBroadcastVisible: Boolean = false, + val isTextFormattingEnabled: Boolean = false, ) : MavericksState + +sealed interface AttachmentTypeSelectorAction : VectorViewModelAction { + data class ToggleTextFormatting(val isEnabled: Boolean) : AttachmentTypeSelectorAction +} diff --git a/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt index 00b9a76de7..0bf70690ba 100644 --- a/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt +++ b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt @@ -167,12 +167,10 @@ class WebRtcCall( private var screenSender: RtpSender? = null private val timer = CountUpTimer(1000L).apply { - tickListener = object : CountUpTimer.TickListener { - override fun onTick(milliseconds: Long) { - val formattedDuration = formatDuration(Duration.ofMillis(milliseconds)) - listeners.forEach { - tryOrNull { it.onTick(formattedDuration) } - } + tickListener = CountUpTimer.TickListener { milliseconds -> + val formattedDuration = formatDuration(Duration.ofMillis(milliseconds)) + listeners.forEach { + tryOrNull { it.onTick(formattedDuration) } } } } diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt index 61a8e5b79e..49f2079625 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt @@ -42,6 +42,7 @@ import im.vector.app.features.raw.wellknown.isSecureBackupRequired import im.vector.app.features.raw.wellknown.withElementWellKnown import im.vector.app.features.session.coroutineScope import im.vector.app.features.settings.VectorPreferences +import im.vector.app.features.voicebroadcast.recording.usecase.StopOngoingVoiceBroadcastUseCase import im.vector.lib.core.utils.compat.getParcelableExtraCompat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -92,6 +93,7 @@ class HomeActivityViewModel @AssistedInject constructor( private val analyticsConfig: AnalyticsConfig, private val releaseNotesPreferencesStore: ReleaseNotesPreferencesStore, private val vectorFeatures: VectorFeatures, + private val stopOngoingVoiceBroadcastUseCase: StopOngoingVoiceBroadcastUseCase, ) : VectorViewModel(initialState) { @AssistedFactory @@ -123,6 +125,7 @@ class HomeActivityViewModel @AssistedInject constructor( observeReleaseNotes() observeLocalNotificationsSilenced() initThreadsMigration() + viewModelScope.launch { stopOngoingVoiceBroadcastUseCase.execute() } } private fun observeReleaseNotes() = withState { state -> diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt index 0f7dc251ae..1368b71ec6 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt @@ -34,6 +34,8 @@ class JumpToBottomViewVisibilityManager( private val layoutManager: LinearLayoutManager ) { + private var canShowButtonOnScroll = true + init { recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { @@ -43,7 +45,7 @@ class JumpToBottomViewVisibilityManager( if (scrollingToPast) { jumpToBottomView.hide() - } else { + } else if (canShowButtonOnScroll) { maybeShowJumpToBottomViewVisibility() } } @@ -66,7 +68,13 @@ class JumpToBottomViewVisibilityManager( } } + fun hideAndPreventVisibilityChangesWithScrolling() { + jumpToBottomView.hide() + canShowButtonOnScroll = false + } + private fun maybeShowJumpToBottomViewVisibility() { + canShowButtonOnScroll = true if (layoutManager.findFirstVisibleItemPosition() > 1) { jumpToBottomView.show() } else { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt index f773671694..faee8f652c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt @@ -20,6 +20,7 @@ import android.net.Uri import android.view.View import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.features.call.conference.ConferenceEvent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent @@ -129,9 +130,10 @@ sealed class RoomDetailAction : VectorViewModelAction { } sealed class Listening : VoiceBroadcastAction() { - data class PlayOrResume(val eventId: String) : Listening() + data class PlayOrResume(val voiceBroadcast: VoiceBroadcast) : Listening() object Pause : Listening() object Stop : Listening() + data class SeekTo(val voiceBroadcast: VoiceBroadcast, val positionMillis: Int, val duration: Int) : Listening() } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index 9d50cdb070..e1392b7580 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -32,7 +32,10 @@ import android.view.ViewGroup import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView +import androidx.activity.addCallback +import androidx.annotation.StringRes import androidx.appcompat.view.menu.MenuBuilder +import androidx.constraintlayout.widget.ConstraintSet import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat import androidx.core.net.toUri @@ -64,6 +67,7 @@ import im.vector.app.core.dialogs.ConfirmationDialogBuilder import im.vector.app.core.dialogs.GalleryOrCameraDialogHelper import im.vector.app.core.dialogs.GalleryOrCameraDialogHelperFactory import im.vector.app.core.epoxy.LayoutManagerStateRestorer +import im.vector.app.core.extensions.animateLayoutChange import im.vector.app.core.extensions.cleanup import im.vector.app.core.extensions.commitTransaction import im.vector.app.core.extensions.containsRtLOverride @@ -183,7 +187,9 @@ import im.vector.app.features.widgets.WidgetArgs import im.vector.app.features.widgets.WidgetKind import im.vector.app.features.widgets.permissions.RoomWidgetPermissionBottomSheet import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -337,6 +343,7 @@ class TimelineFragment : setupJumpToBottomView() setupRemoveJitsiWidgetView() setupLiveLocationIndicator() + setupBackPressHandling() views.includeRoomToolbar.roomToolbarContentView.debouncedClicks { navigator.openRoomProfile(requireActivity(), timelineArgs.roomId) @@ -414,6 +421,31 @@ class TimelineFragment : if (savedInstanceState == null) { handleSpaceShare() } + + views.scrim.setOnClickListener { + messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(false)) + } + + messageComposerViewModel.stateFlow.map { it.isFullScreen } + .distinctUntilChanged() + .onEach { isFullScreen -> + toggleFullScreenEditor(isFullScreen) + } + .launchIn(viewLifecycleOwner.lifecycleScope) + } + + private fun setupBackPressHandling() { + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + withState(messageComposerViewModel) { state -> + if (state.isFullScreen) { + messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(false)) + } else { + remove() // Remove callback to avoid infinite loop + @Suppress("DEPRECATION") + requireActivity().onBackPressed() + } + } + } } private fun setupRemoveJitsiWidgetView() { @@ -1016,7 +1048,13 @@ class TimelineFragment : override fun onLayoutCompleted(state: RecyclerView.State) { super.onLayoutCompleted(state) updateJumpToReadMarkerViewVisibility() - jumpToBottomViewVisibilityManager.maybeShowJumpToBottomViewVisibilityWithDelay() + withState(messageComposerViewModel) { composerState -> + if (!composerState.isFullScreen) { + jumpToBottomViewVisibilityManager.maybeShowJumpToBottomViewVisibilityWithDelay() + } else { + jumpToBottomViewVisibilityManager.hideAndPreventVisibilityChangesWithScrolling() + } + } } }.apply { // For local rooms, pin the view's content to the top edge (the layout is reversed) @@ -1116,7 +1154,6 @@ class TimelineFragment : } val summary = mainState.asyncRoomSummary() renderToolbar(summary) - renderTypingMessageNotification(summary, mainState) views.removeJitsiWidgetView.render(mainState) if (mainState.hasFailedSending) { lazyLoadedViews.failedMessagesWarningView(inflateIfNeeded = true, createFailedMessagesWarningCallback())?.isVisible = true @@ -1131,6 +1168,9 @@ class TimelineFragment : lazyLoadedViews.inviteView(false)?.isVisible = false if (mainState.tombstoneEvent == null) { + views.composerContainer.isInvisible = !messageComposerState.isComposerVisible + views.voiceMessageRecorderContainer.isVisible = messageComposerState.isVoiceMessageRecorderVisible + when (messageComposerState.canSendMessage) { CanSendStatus.Allowed -> { NotificationAreaView.State.Hidden @@ -1186,17 +1226,7 @@ class TimelineFragment : private fun FragmentTimelineBinding.hideComposerViews() { composerContainer.isVisible = false - } - - private fun renderTypingMessageNotification(roomSummary: RoomSummary?, state: RoomDetailViewState) { - if (!isThreadTimeLine() && roomSummary != null) { - views.typingMessageView.isInvisible = state.typingUsers.isNullOrEmpty() - state.typingUsers - ?.take(MAX_TYPING_MESSAGE_USERS_COUNT) - ?.let { senders -> views.typingMessageView.render(senders, avatarRenderer) } - } else { - views.typingMessageView.isInvisible = true - } + voiceMessageRecorderContainer.isVisible = false } private fun renderToolbar(roomSummary: RoomSummary?) { @@ -1283,8 +1313,12 @@ class TimelineFragment : } private fun displayRoomDetailActionFailure(result: RoomDetailViewEvents.ActionFailure) { + @StringRes val titleResId = when (result.action) { + RoomDetailAction.VoiceBroadcastAction.Recording.Start -> R.string.error_voice_broadcast_unauthorized_title + else -> R.string.dialog_title_error + } MaterialAlertDialogBuilder(requireActivity()) - .setTitle(R.string.dialog_title_error) + .setTitle(titleResId) .setMessage(errorFormatter.toHumanReadable(result.throwable)) .setPositiveButton(R.string.ok, null) .show() @@ -2002,6 +2036,19 @@ class TimelineFragment : } } + private fun toggleFullScreenEditor(isFullScreen: Boolean) { + views.composerContainer.animateLayoutChange(200) + + val constraintSet = ConstraintSet() + val constraintSetId = if (isFullScreen) { + R.layout.fragment_timeline_fullscreen + } else { + R.layout.fragment_timeline + } + constraintSet.clone(requireContext(), constraintSetId) + constraintSet.applyTo(views.rootConstraintLayout) + } + /** * Returns true if the current room is a Thread room, false otherwise. */ diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt index 62f3cad5aa..ef238d56e6 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt @@ -50,6 +50,7 @@ import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.createdirect.DirectRoomHelper import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy import im.vector.app.features.crypto.verification.SupportedVerificationMethodsProvider +import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction import im.vector.app.features.home.room.detail.error.RoomNotFound import im.vector.app.features.home.room.detail.location.RedactLiveLocationShareEventUseCase import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler @@ -478,7 +479,7 @@ class TimelineViewModel @AssistedInject constructor( is RoomDetailAction.ReRequestKeys -> handleReRequestKeys(action) is RoomDetailAction.TapOnFailedToDecrypt -> handleTapOnFailedToDecrypt(action) is RoomDetailAction.SelectStickerAttachment -> handleSelectStickerAttachment() - is RoomDetailAction.VoiceBroadcastAction -> handleVoiceBroadcastAction(action) + is VoiceBroadcastAction -> handleVoiceBroadcastAction(action) is RoomDetailAction.OpenIntegrationManager -> handleOpenIntegrationManager() is RoomDetailAction.StartCall -> handleStartCall(action) is RoomDetailAction.AcceptCall -> handleAcceptCall(action) @@ -620,17 +621,23 @@ class TimelineViewModel @AssistedInject constructor( } } - private fun handleVoiceBroadcastAction(action: RoomDetailAction.VoiceBroadcastAction) { + private fun handleVoiceBroadcastAction(action: VoiceBroadcastAction) { if (room == null) return viewModelScope.launch { when (action) { - RoomDetailAction.VoiceBroadcastAction.Recording.Start -> voiceBroadcastHelper.startVoiceBroadcast(room.roomId) - RoomDetailAction.VoiceBroadcastAction.Recording.Pause -> voiceBroadcastHelper.pauseVoiceBroadcast(room.roomId) - RoomDetailAction.VoiceBroadcastAction.Recording.Resume -> voiceBroadcastHelper.resumeVoiceBroadcast(room.roomId) - RoomDetailAction.VoiceBroadcastAction.Recording.Stop -> voiceBroadcastHelper.stopVoiceBroadcast(room.roomId) - is RoomDetailAction.VoiceBroadcastAction.Listening.PlayOrResume -> voiceBroadcastHelper.playOrResumePlayback(room.roomId, action.eventId) - RoomDetailAction.VoiceBroadcastAction.Listening.Pause -> voiceBroadcastHelper.pausePlayback() - RoomDetailAction.VoiceBroadcastAction.Listening.Stop -> voiceBroadcastHelper.stopPlayback() + VoiceBroadcastAction.Recording.Start -> { + voiceBroadcastHelper.startVoiceBroadcast(room.roomId).fold( + { _viewEvents.post(RoomDetailViewEvents.ActionSuccess(action)) }, + { _viewEvents.post(RoomDetailViewEvents.ActionFailure(action, it)) }, + ) + } + VoiceBroadcastAction.Recording.Pause -> voiceBroadcastHelper.pauseVoiceBroadcast(room.roomId) + VoiceBroadcastAction.Recording.Resume -> voiceBroadcastHelper.resumeVoiceBroadcast(room.roomId) + VoiceBroadcastAction.Recording.Stop -> voiceBroadcastHelper.stopVoiceBroadcast(room.roomId) + is VoiceBroadcastAction.Listening.PlayOrResume -> voiceBroadcastHelper.playOrResumePlayback(action.voiceBroadcast) + VoiceBroadcastAction.Listening.Pause -> voiceBroadcastHelper.pausePlayback() + VoiceBroadcastAction.Listening.Stop -> voiceBroadcastHelper.stopPlayback() + is VoiceBroadcastAction.Listening.SeekTo -> voiceBroadcastHelper.seekTo(action.voiceBroadcast, action.positionMillis, action.duration) } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt index bede02c17f..b5ea528bd7 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt @@ -66,7 +66,7 @@ class AudioMessageHelper @Inject constructor( fun startRecording(roomId: String) { stopPlayback() - playbackTracker.makeAllPlaybacksIdle() + playbackTracker.pauseAllPlaybacks() amplitudeList.clear() try { @@ -199,11 +199,7 @@ class AudioMessageHelper @Inject constructor( private fun startRecordingAmplitudes() { amplitudeTicker?.stop() amplitudeTicker = CountUpTimer(50).apply { - tickListener = object : CountUpTimer.TickListener { - override fun onTick(milliseconds: Long) { - onAmplitudeTick() - } - } + tickListener = CountUpTimer.TickListener { onAmplitudeTick() } resume() } } @@ -234,11 +230,7 @@ class AudioMessageHelper @Inject constructor( private fun startPlaybackTicker(id: String) { playbackTicker?.stop() playbackTicker = CountUpTimer().apply { - tickListener = object : CountUpTimer.TickListener { - override fun onTick(milliseconds: Long) { - onPlaybackTick(id) - } - } + tickListener = CountUpTimer.TickListener { onPlaybackTick(id) } resume() } onPlaybackTick(id) @@ -261,8 +253,8 @@ class AudioMessageHelper @Inject constructor( playbackTicker = null } - fun clearTracker() { - playbackTracker.clear() + fun stopTracking() { + playbackTracker.unregisterListeners() } fun stopAllVoiceActions(deleteRecord: Boolean = true): MultiPickerAudioType? { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt index 82adcd014a..30437a016d 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 @@ -34,6 +34,8 @@ sealed class MessageComposerAction : VectorViewModelAction { data class SlashCommandConfirmed(val parsedCommand: ParsedCommand) : MessageComposerAction() data class InsertUserDisplayName(val userId: String) : MessageComposerAction() + data class SetFullScreen(val isFullScreen: Boolean) : MessageComposerAction() + // Voice Message data class InitializeVoiceRecorder(val attachmentData: ContentAttachmentData) : MessageComposerAction() data class OnVoiceRecordingUiStateChanged(val uiState: VoiceMessageRecorderView.RecordingUiState) : MessageComposerAction() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt index 2bbd5c3474..aaf63d7f41 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -43,6 +43,7 @@ import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.parentFragmentViewModel import com.airbnb.mvrx.withState import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -69,6 +70,7 @@ import im.vector.app.features.attachments.AttachmentTypeSelectorBottomSheet import im.vector.app.features.attachments.AttachmentTypeSelectorSharedAction import im.vector.app.features.attachments.AttachmentTypeSelectorSharedActionViewModel import im.vector.app.features.attachments.AttachmentTypeSelectorView +import im.vector.app.features.attachments.AttachmentTypeSelectorViewModel import im.vector.app.features.attachments.AttachmentsHelper import im.vector.app.features.attachments.ContactAttachment import im.vector.app.features.attachments.ShareIntentHandler @@ -97,6 +99,7 @@ import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.share.SharedData import im.vector.app.features.voice.VoiceFailure import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -167,7 +170,8 @@ class MessageComposerFragment : VectorBaseFragment(), A private val timelineViewModel: TimelineViewModel by parentFragmentViewModel() private val messageComposerViewModel: MessageComposerViewModel by parentFragmentViewModel() private lateinit var sharedActionViewModel: MessageSharedActionViewModel - private val attachmentViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels() + private val attachmentViewModel: AttachmentTypeSelectorViewModel by fragmentViewModel() + private val attachmentActionsViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels() private val composer: MessageComposerView get() { return if (vectorPreferences.isRichTextEditorEnabled()) { @@ -213,6 +217,13 @@ class MessageComposerFragment : VectorBaseFragment(), A } } + messageComposerViewModel.stateFlow.map { it.isFullScreen } + .distinctUntilChanged() + .onEach { isFullScreen -> + composer.toggleFullScreen(isFullScreen) + } + .launchIn(viewLifecycleOwner.lifecycleScope) + messageComposerViewModel.onEach(MessageComposerViewState::sendMode, MessageComposerViewState::canSendMessage) { mode, canSend -> if (!canSend.boolean()) { return@onEach @@ -226,7 +237,7 @@ class MessageComposerFragment : VectorBaseFragment(), A } } - attachmentViewModel.stream() + attachmentActionsViewModel.stream() .filterIsInstance() .onEach { onTypeSelected(it.attachmentType) } .launchIn(lifecycleScope) @@ -246,7 +257,7 @@ class MessageComposerFragment : VectorBaseFragment(), A } // TODO remove this when there will be a recording indicator outside of the timeline // Pause voice broadcast if the timeline is not shown anymore - it.isVoiceBroadcasting && !requireActivity().isChangingConfigurations -> timelineViewModel.handle(VoiceBroadcastAction.Recording.Pause) + it.isRecordingVoiceBroadcast && !requireActivity().isChangingConfigurations -> timelineViewModel.handle(VoiceBroadcastAction.Recording.Pause) else -> { timelineViewModel.handle(VoiceBroadcastAction.Listening.Pause) messageComposerViewModel.handle(MessageComposerAction.OnEntersBackground(composer.text.toString())) @@ -264,11 +275,14 @@ class MessageComposerFragment : VectorBaseFragment(), A messageComposerViewModel.endAllVoiceActions() } - override fun invalidate() = withState(timelineViewModel, messageComposerViewModel) { mainState, messageComposerState -> + override fun invalidate() = withState( + timelineViewModel, messageComposerViewModel, attachmentViewModel + ) { mainState, messageComposerState, attachmentState -> if (mainState.tombstoneEvent != null) return@withState composer.setInvisible(!messageComposerState.isComposerVisible) composer.sendButton.isInvisible = !messageComposerState.isSendButtonVisible + (composer as? RichTextComposerLayout)?.isTextFormattingEnabled = attachmentState.isTextFormattingEnabled } private fun setupComposer() { @@ -309,7 +323,7 @@ class MessageComposerFragment : VectorBaseFragment(), A // Show keyboard when the user started a thread composerEditText.showKeyboard(andRequestFocus = true) } - composer.callback = object : PlainTextComposerLayout.Callback { + composer.callback = object : Callback { override fun onAddAttachment() { if (vectorPreferences.isRichTextEditorEnabled()) { AttachmentTypeSelectorBottomSheet.show(childFragmentManager) @@ -336,8 +350,12 @@ class MessageComposerFragment : VectorBaseFragment(), A composer.emojiButton?.isVisible = isEmojiKeyboardVisible } - override fun onSendMessage(text: CharSequence) { + override fun onSendMessage(text: CharSequence) = withState(messageComposerViewModel) { state -> sendTextMessage(text, composer.formattedText) + + if (state.isFullScreen) { + messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(false)) + } } override fun onCloseRelatedMessage() { @@ -351,6 +369,10 @@ class MessageComposerFragment : VectorBaseFragment(), A override fun onTextChanged(text: CharSequence) { messageComposerViewModel.handle(MessageComposerAction.OnTextChanged(text)) } + + override fun onFullScreenModeChanged() = withState(messageComposerViewModel) { state -> + messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(!state.isFullScreen)) + } } } @@ -477,7 +499,7 @@ class MessageComposerFragment : VectorBaseFragment(), A composer.sendButton.alpha = 0f composer.sendButton.isVisible = true composer.sendButton.animate().alpha(1f).setDuration(150).start() - } else { + } else if (!event.isVisible) { composer.sendButton.isInvisible = true } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt index 09357191b4..b7e0e29679 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt @@ -30,13 +30,14 @@ interface MessageComposerView { val emojiButton: ImageButton? val sendButton: ImageButton val attachmentButton: ImageButton + val fullScreenButton: ImageButton? val composerRelatedMessageTitle: TextView val composerRelatedMessageContent: TextView val composerRelatedMessageImage: ImageView val composerRelatedMessageActionIcon: ImageView val composerRelatedMessageAvatar: ImageView - var callback: PlainTextComposerLayout.Callback? + var callback: Callback? var isVisible: Boolean @@ -44,6 +45,15 @@ interface MessageComposerView { fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) fun setTextIfDifferent(text: CharSequence?): Boolean fun replaceFormattedContent(text: CharSequence) + fun toggleFullScreen(newValue: Boolean) fun setInvisible(isInvisible: Boolean) } + +interface Callback : ComposerEditText.Callback { + fun onCloseRelatedMessage() + fun onSendMessage(text: CharSequence) + fun onAddAttachment() + fun onExpandOrCompactChange() + fun onFullScreenModeChanged() +} 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 1a9f9e6291..a8be2be5e2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt @@ -16,6 +16,7 @@ package im.vector.app.features.home.room.detail.composer +import android.text.SpannableString import androidx.lifecycle.asFlow import com.airbnb.mvrx.MavericksViewModelFactory import dagger.assisted.Assisted @@ -122,6 +123,7 @@ class MessageComposerViewModel @AssistedInject constructor( is MessageComposerAction.AudioSeekBarMovedTo -> handleAudioSeekBarMovedTo(action) is MessageComposerAction.SlashCommandConfirmed -> handleSlashCommandConfirmed(action) is MessageComposerAction.InsertUserDisplayName -> handleInsertUserDisplayName(action) + is MessageComposerAction.SetFullScreen -> handleSetFullScreen(action) } } @@ -130,12 +132,11 @@ class MessageComposerViewModel @AssistedInject constructor( } private fun handleOnTextChanged(action: MessageComposerAction.OnTextChanged) { - setState { - // Makes sure currentComposerText is upToDate when accessing further setState - currentComposerText = action.text - this + val needsSendButtonVisibilityUpdate = currentComposerText.isEmpty() != action.text.isEmpty() + currentComposerText = SpannableString(action.text) + if (needsSendButtonVisibilityUpdate) { + updateIsSendButtonVisibility(true) } - updateIsSendButtonVisibility(true) } private fun subscribeToStateInternal() { @@ -163,6 +164,10 @@ class MessageComposerViewModel @AssistedInject constructor( } } + private fun handleSetFullScreen(action: MessageComposerAction.SetFullScreen) { + setState { copy(isFullScreen = action.isFullScreen) } + } + private fun observePowerLevelAndEncryption() { combine( PowerLevelsFlowFactory(room).createFlow(), @@ -955,7 +960,7 @@ class MessageComposerViewModel @AssistedInject constructor( } fun endAllVoiceActions(deleteRecord: Boolean = true) { - audioMessageHelper.clearTracker() + audioMessageHelper.stopTracking() audioMessageHelper.stopAllVoiceActions(deleteRecord) } 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 0df1dbebd8..bf40c18995 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 @@ -70,6 +70,7 @@ data class MessageComposerViewState( val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle, val voiceBroadcastState: VoiceBroadcastState? = null, val text: CharSequence? = null, + val isFullScreen: Boolean = false, ) : MavericksState { val isVoiceRecording = when (voiceRecordingUiState) { @@ -79,9 +80,8 @@ data class MessageComposerViewState( is VoiceMessageRecorderView.RecordingUiState.Recording -> true } - val isVoiceBroadcasting = when (voiceBroadcastState) { + val isRecordingVoiceBroadcast = when (voiceBroadcastState) { VoiceBroadcastState.STARTED, - VoiceBroadcastState.PAUSED, VoiceBroadcastState.RESUMED -> true else -> false } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt index acb5a1b42a..939a59fcca 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt @@ -49,13 +49,6 @@ class PlainTextComposerLayout @JvmOverloads constructor( defStyleAttr: Int = 0 ) : ConstraintLayout(context, attrs, defStyleAttr), MessageComposerView { - interface Callback : ComposerEditText.Callback { - fun onCloseRelatedMessage() - fun onSendMessage(text: CharSequence) - fun onAddAttachment() - fun onExpandOrCompactChange() - } - private val views: ComposerLayoutBinding override var callback: Callback? = null @@ -83,6 +76,7 @@ class PlainTextComposerLayout @JvmOverloads constructor( } override val attachmentButton: ImageButton get() = views.attachmentButton + override val fullScreenButton: ImageButton? = null override val composerRelatedMessageActionIcon: ImageView get() = views.composerRelatedMessageActionIcon override val composerRelatedMessageAvatar: ImageView @@ -155,6 +149,10 @@ class PlainTextComposerLayout @JvmOverloads constructor( return views.composerEditText.setTextIfDifferent(text) } + override fun toggleFullScreen(newValue: Boolean) { + // Plain text composer has no full screen + } + private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { // val wasSendButtonInvisible = views.sendButton.isInvisible if (animate) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt index 07b7d151ad..2d2a4a8cd2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt @@ -21,7 +21,6 @@ import android.text.Editable import android.text.TextWatcher import android.util.AttributeSet import android.view.LayoutInflater -import android.view.ViewGroup import android.widget.EditText import android.widget.ImageButton import android.widget.ImageView @@ -33,18 +32,13 @@ import androidx.constraintlayout.widget.ConstraintSet import androidx.core.text.toSpannable import androidx.core.view.isInvisible import androidx.core.view.isVisible -import androidx.transition.ChangeBounds -import androidx.transition.Fade -import androidx.transition.Transition -import androidx.transition.TransitionManager -import androidx.transition.TransitionSet import im.vector.app.R -import im.vector.app.core.animations.SimpleTransitionListener +import im.vector.app.core.extensions.animateLayoutChange import im.vector.app.core.extensions.setTextIfDifferent import im.vector.app.databinding.ComposerRichTextLayoutBinding import im.vector.app.databinding.ViewRichTextMenuButtonBinding import io.element.android.wysiwyg.EditorEditText -import io.element.android.wysiwyg.InlineFormat +import io.element.android.wysiwyg.inputhandlers.models.InlineFormat import uniffi.wysiwyg_composer.ComposerAction import uniffi.wysiwyg_composer.MenuState @@ -56,24 +50,40 @@ class RichTextComposerLayout @JvmOverloads constructor( private val views: ComposerRichTextLayoutBinding - override var callback: PlainTextComposerLayout.Callback? = null + override var callback: Callback? = null private var currentConstraintSetId: Int = -1 - private val animationDuration = 100L + private val maxEditTextLinesWhenCollapsed = 12 + + private val isFullScreen: Boolean get() = currentConstraintSetId == R.layout.composer_rich_text_layout_constraint_set_fullscreen + + var isTextFormattingEnabled = true + set(value) { + if (field == value) return + syncEditTexts() + field = value + updateEditTextVisibility() + } override val text: Editable? - get() = views.composerEditText.text + get() = editText.text override val formattedText: String? - get() = views.composerEditText.getHtmlOutput() + get() = (editText as? EditorEditText)?.getHtmlOutput() override val editText: EditText - get() = views.composerEditText + get() = if (isTextFormattingEnabled) { + views.richTextComposerEditText + } else { + views.plainTextComposerEditText + } override val emojiButton: ImageButton? get() = null override val sendButton: ImageButton get() = views.sendButton override val attachmentButton: ImageButton get() = views.attachmentButton + override val fullScreenButton: ImageButton? + get() = views.composerFullScreenButton override val composerRelatedMessageActionIcon: ImageView get() = views.composerRelatedMessageActionIcon override val composerRelatedMessageAvatar: ImageView @@ -94,21 +104,12 @@ class RichTextComposerLayout @JvmOverloads constructor( collapse(false) - views.composerEditText.addTextChangedListener(object : TextWatcher { - private var previousTextWasExpanded = false - - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} - override fun afterTextChanged(s: Editable) { - callback?.onTextChanged(s) - - val isExpanded = s.lines().count() > 1 - if (previousTextWasExpanded != isExpanded) { - updateTextFieldBorder(isExpanded) - } - previousTextWasExpanded = isExpanded - } - }) + views.richTextComposerEditText.addTextChangedListener( + TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder() }) + ) + views.plainTextComposerEditText.addTextChangedListener( + TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder() }) + ) views.composerRelatedMessageCloseButton.setOnClickListener { collapse() @@ -124,24 +125,32 @@ class RichTextComposerLayout @JvmOverloads constructor( callback?.onAddAttachment() } + views.composerFullScreenButton.setOnClickListener { + callback?.onFullScreenModeChanged() + } + setupRichTextMenu() } private fun setupRichTextMenu() { addRichTextMenuItem(R.drawable.ic_composer_bold, R.string.rich_text_editor_format_bold, ComposerAction.Bold) { - views.composerEditText.toggleInlineFormat(InlineFormat.Bold) + views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Bold) } addRichTextMenuItem(R.drawable.ic_composer_italic, R.string.rich_text_editor_format_italic, ComposerAction.Italic) { - views.composerEditText.toggleInlineFormat(InlineFormat.Italic) + views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Italic) } addRichTextMenuItem(R.drawable.ic_composer_underlined, R.string.rich_text_editor_format_underline, ComposerAction.Underline) { - views.composerEditText.toggleInlineFormat(InlineFormat.Underline) + views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Underline) } addRichTextMenuItem(R.drawable.ic_composer_strikethrough, R.string.rich_text_editor_format_strikethrough, ComposerAction.StrikeThrough) { - views.composerEditText.toggleInlineFormat(InlineFormat.StrikeThrough) + views.richTextComposerEditText.toggleInlineFormat(InlineFormat.StrikeThrough) } + } - views.composerEditText.menuStateChangedListener = EditorEditText.OnMenuStateChangedListener { state -> + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + views.richTextComposerEditText.menuStateChangedListener = EditorEditText.OnMenuStateChangedListener { state -> if (state is MenuState.Update) { updateMenuStateFor(ComposerAction.Bold, state) updateMenuStateFor(ComposerAction.Italic, state) @@ -149,8 +158,26 @@ class RichTextComposerLayout @JvmOverloads constructor( updateMenuStateFor(ComposerAction.StrikeThrough, state) } } + + updateEditTextVisibility() } + private fun updateEditTextVisibility() { + views.richTextComposerEditText.isVisible = isTextFormattingEnabled + views.richTextMenu.isVisible = isTextFormattingEnabled + views.plainTextComposerEditText.isVisible = !isTextFormattingEnabled + } + + /** + * Updates the non-active input with the contents of the active input. + */ + private fun syncEditTexts() = + if (isTextFormattingEnabled) { + views.plainTextComposerEditText.setText(views.richTextComposerEditText.getPlainText()) + } else { + views.richTextComposerEditText.setText(views.plainTextComposerEditText.text.toString()) + } + private fun addRichTextMenuItem(@DrawableRes iconId: Int, @StringRes description: Int, action: ComposerAction, onClick: () -> Unit) { val inflater = LayoutInflater.from(context) val button = ViewRichTextMenuButtonBinding.inflate(inflater, views.richTextMenu, true) @@ -170,8 +197,9 @@ class RichTextComposerLayout @JvmOverloads constructor( button.isSelected = menuState.reversedActions.contains(action) } - private fun updateTextFieldBorder(isExpanded: Boolean) { - val borderResource = if (isExpanded) { + private fun updateTextFieldBorder() { + val isExpanded = editText.editableText.lines().count() > 1 + val borderResource = if (isExpanded || isFullScreen) { R.drawable.bg_composer_rich_edit_text_expanded } else { R.drawable.bg_composer_rich_edit_text_single_line @@ -180,7 +208,7 @@ class RichTextComposerLayout @JvmOverloads constructor( } override fun replaceFormattedContent(text: CharSequence) { - views.composerEditText.setHtml(text.toString()) + views.richTextComposerEditText.setHtml(text.toString()) } override fun collapse(animate: Boolean, transitionComplete: (() -> Unit)?) { @@ -190,6 +218,7 @@ class RichTextComposerLayout @JvmOverloads constructor( } currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_compact applyNewConstraintSet(animate, transitionComplete) + updateEditTextVisibility() } override fun expand(animate: Boolean, transitionComplete: (() -> Unit)?) { @@ -199,41 +228,71 @@ class RichTextComposerLayout @JvmOverloads constructor( } currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_expanded applyNewConstraintSet(animate, transitionComplete) + updateEditTextVisibility() } override fun setTextIfDifferent(text: CharSequence?): Boolean { - return views.composerEditText.setTextIfDifferent(text) + return editText.setTextIfDifferent(text) + } + + override fun toggleFullScreen(newValue: Boolean) { + val constraintSetId = if (newValue) R.layout.composer_rich_text_layout_constraint_set_fullscreen else currentConstraintSetId + ConstraintSet().also { + it.clone(context, constraintSetId) + it.applyTo(this) + } + + updateTextFieldBorder() + updateEditTextVisibility() + + updateEditTextFullScreenState(views.richTextComposerEditText, newValue) + updateEditTextFullScreenState(views.plainTextComposerEditText, newValue) + } + + private fun updateEditTextFullScreenState(editText: EditText, isFullScreen: Boolean) { + if (isFullScreen) { + editText.maxLines = Int.MAX_VALUE + // This is a workaround to fix incorrect scroll position when maximised + post { editText.requestLayout() } + } else { + editText.maxLines = maxEditTextLinesWhenCollapsed + } } private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { // val wasSendButtonInvisible = views.sendButton.isInvisible if (animate) { - configureAndBeginTransition(transitionComplete) + animateLayoutChange(animationDuration, transitionComplete) } ConstraintSet().also { it.clone(context, currentConstraintSetId) it.applyTo(this) } + // Might be updated by view state just after, but avoid blinks // views.sendButton.isInvisible = wasSendButtonInvisible } - private fun configureAndBeginTransition(transitionComplete: (() -> Unit)? = null) { - val transition = TransitionSet().apply { - ordering = TransitionSet.ORDERING_SEQUENTIAL - addTransition(ChangeBounds()) - addTransition(Fade(Fade.IN)) - duration = animationDuration - addListener(object : SimpleTransitionListener() { - override fun onTransitionEnd(transition: Transition) { - transitionComplete?.invoke() - } - }) - } - TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition) - } - override fun setInvisible(isInvisible: Boolean) { this.isInvisible = isInvisible } + + private class TextChangeListener( + private val onTextChanged: (s: Editable) -> Unit, + private val onExpandedChanged: (isExpanded: Boolean) -> Unit, + ) : TextWatcher { + private var previousTextWasExpanded = false + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable) { + onTextChanged.invoke(s) + + val isExpanded = s.lines().count() > 1 + if (previousTextWasExpanded != isExpanded) { + onExpandedChanged(isExpanded) + } + previousTextWasExpanded = isExpanded + } + } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt index 13e0477ab6..a7b926f29a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt @@ -189,11 +189,9 @@ class VoiceMessageRecorderView @JvmOverloads constructor( val startMs = ((clock.epochMillis() - startAt)).coerceAtLeast(0) recordingTicker?.stop() recordingTicker = CountUpTimer().apply { - tickListener = object : CountUpTimer.TickListener { - override fun onTick(milliseconds: Long) { - val isLocked = startFromLocked || lastKnownState is RecordingUiState.Locked - onRecordingTick(isLocked, milliseconds + startMs) - } + tickListener = CountUpTimer.TickListener { milliseconds -> + val isLocked = startFromLocked || lastKnownState is RecordingUiState.Locked + onRecordingTick(isLocked, milliseconds + startMs) } resume() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt index 18c626bda8..57ad4331ce 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt @@ -32,6 +32,7 @@ import im.vector.app.core.extensions.localDateTime import im.vector.app.core.extensions.nextOrNull import im.vector.app.core.extensions.prevOrNull import im.vector.app.core.time.Clock +import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.JitsiState import im.vector.app.features.home.room.detail.RoomDetailAction import im.vector.app.features.home.room.detail.RoomDetailViewState @@ -57,6 +58,7 @@ import im.vector.app.features.home.room.detail.timeline.item.MessageInformationD import im.vector.app.features.home.room.detail.timeline.item.ReactionsSummaryEvents import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptsItem +import im.vector.app.features.home.room.detail.timeline.item.TypingItem_ import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever import im.vector.app.features.media.AttachmentData import im.vector.app.features.media.ImageContentRenderer @@ -94,6 +96,7 @@ class TimelineEventController @Inject constructor( private val readReceiptsItemFactory: ReadReceiptsItemFactory, private val reactionListFactory: ReactionsSummaryFactory, private val clock: Clock, + private val avatarRenderer: AvatarRenderer, ) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener, EpoxyController.Interceptor { /** @@ -104,7 +107,7 @@ class TimelineEventController @Inject constructor( val highlightedEventId: String? = null, val jitsiState: JitsiState = JitsiState(), val roomSummary: RoomSummary? = null, - val rootThreadEventId: String? = null + val rootThreadEventId: String? = null, ) { constructor(state: RoomDetailViewState) : this( @@ -112,7 +115,7 @@ class TimelineEventController @Inject constructor( highlightedEventId = state.highlightedEventId, jitsiState = state.jitsiState, roomSummary = state.asyncRoomSummary(), - rootThreadEventId = state.rootThreadEventId + rootThreadEventId = state.rootThreadEventId, ) fun isFromThreadTimeline(): Boolean = rootThreadEventId != null @@ -286,7 +289,7 @@ class TimelineEventController @Inject constructor( private val interceptorHelper = TimelineControllerInterceptorHelper( ::positionOfReadMarker, - adapterPositionMapping + adapterPositionMapping, ) init { @@ -334,6 +337,12 @@ class TimelineEventController @Inject constructor( .setVisibilityStateChangedListener(Timeline.Direction.FORWARDS) .addWhenLoading(Timeline.Direction.FORWARDS) + if (!showingForwardLoader) { + val typingUsers = partialState.roomSummary?.typingUsers.orEmpty() + val typingItem = TypingItem_().id("typing_view").avatarRenderer(avatarRenderer).users(typingUsers) + add(typingItem) + } + val timelineModels = getModels() add(timelineModels) if (hasReachedInvite && hasUTD) { 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 245d92f95b..f4d506fa4b 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 @@ -201,7 +201,7 @@ class MessageItemFactory @Inject constructor( is MessagePollContent -> buildPollItem(messageContent, informationData, highlight, callback, attributes) is MessageLocationContent -> buildLocationItem(messageContent, informationData, highlight, attributes) is MessageBeaconInfoContent -> liveLocationShareMessageItemFactory.create(params.event, highlight, attributes) - is MessageVoiceBroadcastInfoContent -> voiceBroadcastItemFactory.create(params, messageContent, highlight, callback, attributes) + is MessageVoiceBroadcastInfoContent -> voiceBroadcastItemFactory.create(params, messageContent, highlight, attributes) else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes) } return messageItem?.apply { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt index 5dc601a91a..e4f7bed72f 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt @@ -15,26 +15,27 @@ */ package im.vector.app.features.home.room.detail.timeline.factory -import im.vector.app.core.epoxy.VectorEpoxyHolder -import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.DrawableProvider -import im.vector.app.features.home.room.detail.timeline.TimelineEventController +import im.vector.app.features.displayname.getBestName +import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.app.features.home.room.detail.timeline.helper.VoiceBroadcastEventsGroup import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem +import im.vector.app.features.home.room.detail.timeline.item.AbsMessageVoiceBroadcastItem import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastListeningItem import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastListeningItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastRecordingItem import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastRecordingItem_ -import im.vector.app.features.voicebroadcast.VoiceBroadcastPlayer -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.getRoom -import org.matrix.android.sdk.api.session.getUser +import org.matrix.android.sdk.api.session.getUserOrDefault import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject @@ -45,87 +46,70 @@ class VoiceBroadcastItemFactory @Inject constructor( private val drawableProvider: DrawableProvider, private val voiceBroadcastRecorder: VoiceBroadcastRecorder?, private val voiceBroadcastPlayer: VoiceBroadcastPlayer, + private val playbackTracker: AudioMessagePlaybackTracker, ) { fun create( params: TimelineItemFactoryParams, messageContent: MessageVoiceBroadcastInfoContent, highlight: Boolean, - callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes, - ): VectorEpoxyModel? { + ): AbsMessageVoiceBroadcastItem<*>? { // Only display item of the initial event with updated data if (messageContent.voiceBroadcastState != VoiceBroadcastState.STARTED) return null - val eventsGroup = params.eventsGroup ?: return null - val voiceBroadcastEventsGroup = VoiceBroadcastEventsGroup(eventsGroup) - val mostRecentTimelineEvent = voiceBroadcastEventsGroup.getLastDisplayableEvent() - val mostRecentEvent = mostRecentTimelineEvent.root.asVoiceBroadcastEvent() - val mostRecentMessageContent = mostRecentEvent?.content ?: return null - val isRecording = mostRecentMessageContent.voiceBroadcastState != VoiceBroadcastState.STOPPED && mostRecentEvent.root.stateKey == session.myUserId - val recorderName = mostRecentTimelineEvent.root.stateKey?.let { session.getUser(it) }?.displayName ?: mostRecentTimelineEvent.root.stateKey + + val voiceBroadcastEventsGroup = params.eventsGroup?.let { VoiceBroadcastEventsGroup(it) } ?: return null + val voiceBroadcastEvent = voiceBroadcastEventsGroup.getLastDisplayableEvent().root.asVoiceBroadcastEvent() ?: return null + val voiceBroadcastContent = voiceBroadcastEvent.content ?: return null + val voiceBroadcast = VoiceBroadcast(voiceBroadcastId = voiceBroadcastEventsGroup.voiceBroadcastId, roomId = params.event.roomId) + + val isRecording = voiceBroadcastContent.voiceBroadcastState != VoiceBroadcastState.STOPPED && + voiceBroadcastEvent.root.stateKey == session.myUserId && + messageContent.deviceId == session.sessionParams.deviceId + + val voiceBroadcastAttributes = AbsMessageVoiceBroadcastItem.Attributes( + voiceBroadcast = voiceBroadcast, + voiceBroadcastState = voiceBroadcastContent.voiceBroadcastState, + duration = voiceBroadcastEventsGroup.getDuration(), + recorderName = params.event.root.stateKey?.let { session.getUserOrDefault(it) }?.toMatrixItem()?.getBestName().orEmpty(), + recorder = voiceBroadcastRecorder, + player = voiceBroadcastPlayer, + playbackTracker = playbackTracker, + roomItem = session.getRoom(params.event.roomId)?.roomSummary()?.toMatrixItem(), + colorProvider = colorProvider, + drawableProvider = drawableProvider, + ) + return if (isRecording) { - createRecordingItem( - params.event.roomId, - eventsGroup.groupId, - highlight, - callback, - attributes - ) + createRecordingItem(highlight, attributes, voiceBroadcastAttributes) } else { - createListeningItem( - params.event.roomId, - eventsGroup.groupId, - mostRecentMessageContent.voiceBroadcastState, - recorderName, - highlight, - callback, - attributes - ) + createListeningItem(highlight, attributes, voiceBroadcastAttributes) } } private fun createRecordingItem( - roomId: String, - voiceBroadcastId: String, highlight: Boolean, - callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes, + voiceBroadcastAttributes: AbsMessageVoiceBroadcastItem.Attributes, ): MessageVoiceBroadcastRecordingItem { - val roomSummary = session.getRoom(roomId)?.roomSummary() return MessageVoiceBroadcastRecordingItem_() - .id("voice_broadcast_$voiceBroadcastId") + .id("voice_broadcast_${voiceBroadcastAttributes.voiceBroadcast.voiceBroadcastId}") .attributes(attributes) + .voiceBroadcastAttributes(voiceBroadcastAttributes) .highlighted(highlight) - .roomItem(roomSummary?.toMatrixItem()) - .colorProvider(colorProvider) - .drawableProvider(drawableProvider) - .voiceBroadcastRecorder(voiceBroadcastRecorder) .leftGuideline(avatarSizeProvider.leftGuideline) - .callback(callback) } private fun createListeningItem( - roomId: String, - voiceBroadcastId: String, - voiceBroadcastState: VoiceBroadcastState?, - broadcasterName: String?, highlight: Boolean, - callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes, + voiceBroadcastAttributes: AbsMessageVoiceBroadcastItem.Attributes, ): MessageVoiceBroadcastListeningItem { - val roomSummary = session.getRoom(roomId)?.roomSummary() return MessageVoiceBroadcastListeningItem_() - .id("voice_broadcast_$voiceBroadcastId") + .id("voice_broadcast_${voiceBroadcastAttributes.voiceBroadcast.voiceBroadcastId}") .attributes(attributes) + .voiceBroadcastAttributes(voiceBroadcastAttributes) .highlighted(highlight) - .roomItem(roomSummary?.toMatrixItem()) - .colorProvider(colorProvider) - .drawableProvider(drawableProvider) - .voiceBroadcastPlayer(voiceBroadcastPlayer) - .voiceBroadcastId(voiceBroadcastId) - .voiceBroadcastState(voiceBroadcastState) - .broadcasterName(broadcasterName) .leftGuideline(avatarSizeProvider.leftGuideline) - .callback(callback) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt index 6937cd3a46..90fd66f9ab 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt @@ -51,15 +51,7 @@ class AudioMessagePlaybackTracker @Inject constructor() { } fun pauseAllPlaybacks() { - listeners.keys.forEach { key -> - pausePlayback(key) - } - } - - fun makeAllPlaybacksIdle() { - listeners.keys.forEach { key -> - setState(key, Listener.State.Idle) - } + listeners.keys.forEach(::pausePlayback) } /** @@ -127,7 +119,7 @@ class AudioMessagePlaybackTracker @Inject constructor() { } } - private fun getPercentage(id: String): Float { + fun getPercentage(id: String): Float { return when (val state = states[id]) { is Listener.State.Playing -> state.percentage is Listener.State.Paused -> state.percentage @@ -136,19 +128,18 @@ class AudioMessagePlaybackTracker @Inject constructor() { } } - fun clear() { + fun unregisterListeners() { listeners.forEach { it.value.onUpdate(Listener.State.Idle) } listeners.clear() - states.clear() } companion object { const val RECORDING_ID = "RECORDING_ID" } - interface Listener { + fun interface Listener { fun onUpdate(state: State) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt index d8817c1f44..a4bfa9e155 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt @@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.helper import im.vector.app.core.utils.TextUtils import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants +import im.vector.app.features.voicebroadcast.duration import im.vector.app.features.voicebroadcast.getVoiceBroadcastEventId import im.vector.app.features.voicebroadcast.isVoiceBroadcast import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState @@ -141,8 +142,15 @@ class CallSignalingEventsGroup(private val group: TimelineEventsGroup) { } class VoiceBroadcastEventsGroup(private val group: TimelineEventsGroup) { + + val voiceBroadcastId = group.groupId + fun getLastDisplayableEvent(): TimelineEvent { return group.events.find { it.root.asVoiceBroadcastEvent()?.content?.voiceBroadcastState == VoiceBroadcastState.STOPPED } ?: group.events.filter { it.root.type == VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO }.maxBy { it.root.originServerTs ?: 0L } } + + fun getDuration(): Int { + return group.events.mapNotNull { it.root.asMessageAudioEvent()?.duration }.sum() + } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt new file mode 100644 index 0000000000..c6b90cdabe --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.item + +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.IdRes +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import im.vector.app.R +import im.vector.app.core.extensions.tintBackground +import im.vector.app.core.resources.ColorProvider +import im.vector.app.core.resources.DrawableProvider +import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder +import org.matrix.android.sdk.api.util.MatrixItem + +abstract class AbsMessageVoiceBroadcastItem : AbsMessageItem() { + + @EpoxyAttribute + lateinit var voiceBroadcastAttributes: Attributes + + protected val voiceBroadcast get() = voiceBroadcastAttributes.voiceBroadcast + protected val voiceBroadcastState get() = voiceBroadcastAttributes.voiceBroadcastState + protected val recorderName get() = voiceBroadcastAttributes.recorderName + protected val recorder get() = voiceBroadcastAttributes.recorder + protected val player get() = voiceBroadcastAttributes.player + protected val playbackTracker get() = voiceBroadcastAttributes.playbackTracker + protected val duration get() = voiceBroadcastAttributes.duration + protected val roomItem get() = voiceBroadcastAttributes.roomItem + protected val colorProvider get() = voiceBroadcastAttributes.colorProvider + protected val drawableProvider get() = voiceBroadcastAttributes.drawableProvider + protected val avatarRenderer get() = attributes.avatarRenderer + protected val callback get() = attributes.callback + + override fun isCacheable(): Boolean = false + + override fun bind(holder: H) { + super.bind(holder) + renderHeader(holder) + } + + private fun renderHeader(holder: H) { + with(holder) { + roomItem?.let { + avatarRenderer.render(it, roomAvatarImageView) + titleText.text = it.displayName + } + } + renderLiveIndicator(holder) + renderMetadata(holder) + } + + abstract fun renderLiveIndicator(holder: H) + + protected fun renderPlayingLiveIndicator(holder: H) { + with(holder) { + liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.colorError)) + liveIndicator.isVisible = true + } + } + + protected fun renderPausedLiveIndicator(holder: H) { + with(holder) { + liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.vctr_content_quaternary)) + liveIndicator.isVisible = true + } + } + + protected fun renderNoLiveIndicator(holder: H) { + holder.liveIndicator.isVisible = false + } + + abstract fun renderMetadata(holder: H) + + abstract class Holder(@IdRes stubId: Int) : AbsMessageItem.Holder(stubId) { + val liveIndicator by bind(R.id.liveIndicator) + val roomAvatarImageView by bind(R.id.roomAvatarImageView) + val titleText by bind(R.id.titleText) + } + + data class Attributes( + val voiceBroadcast: VoiceBroadcast, + val voiceBroadcastState: VoiceBroadcastState?, + val duration: Int, + val recorderName: String, + val recorder: VoiceBroadcastRecorder?, + val player: VoiceBroadcastPlayer, + val playbackTracker: AudioMessagePlaybackTracker, + val roomItem: MatrixItem?, + val colorProvider: ColorProvider, + val drawableProvider: DrawableProvider, + ) +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageAudioItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageAudioItem.kt index fda9a1465f..3e8d6cb487 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageAudioItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageAudioItem.kt @@ -140,16 +140,14 @@ abstract class MessageAudioItem : AbsMessageItem() { } private fun renderStateBasedOnAudioPlayback(holder: Holder) { - audioMessagePlaybackTracker.track(attributes.informationData.eventId, object : AudioMessagePlaybackTracker.Listener { - override fun onUpdate(state: AudioMessagePlaybackTracker.Listener.State) { - when (state) { - is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder) - is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state) - is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state) - is AudioMessagePlaybackTracker.Listener.State.Recording -> Unit - } + audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state -> + when (state) { + is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder) + is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state) + is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state) + is AudioMessagePlaybackTracker.Listener.State.Recording -> Unit } - }) + } } private fun renderIdleState(holder: Holder) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt index 5b58dda4e6..e5cb677763 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt @@ -16,57 +16,27 @@ package im.vector.app.features.home.room.detail.timeline.item +import android.text.format.DateUtils import android.view.View import android.widget.ImageButton -import android.widget.ImageView +import android.widget.SeekBar import android.widget.TextView +import androidx.core.view.isInvisible import androidx.core.view.isVisible -import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R import im.vector.app.core.epoxy.onClick -import im.vector.app.core.extensions.tintBackground -import im.vector.app.core.resources.ColorProvider -import im.vector.app.core.resources.DrawableProvider -import im.vector.app.features.home.room.detail.RoomDetailAction -import im.vector.app.features.home.room.detail.timeline.TimelineEventController -import im.vector.app.features.voicebroadcast.VoiceBroadcastPlayer +import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction +import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker.Listener.State +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState -import org.matrix.android.sdk.api.util.MatrixItem +import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView @EpoxyModelClass -abstract class MessageVoiceBroadcastListeningItem : AbsMessageItem() { - - @EpoxyAttribute - var callback: TimelineEventController.Callback? = null - - @EpoxyAttribute - var voiceBroadcastPlayer: VoiceBroadcastPlayer? = null - - @EpoxyAttribute - lateinit var voiceBroadcastId: String - - @EpoxyAttribute - var voiceBroadcastState: VoiceBroadcastState? = null - - @EpoxyAttribute - var broadcasterName: String? = null - - @EpoxyAttribute - lateinit var colorProvider: ColorProvider - - @EpoxyAttribute - lateinit var drawableProvider: DrawableProvider - - @EpoxyAttribute - var roomItem: MatrixItem? = null - - @EpoxyAttribute - var title: String? = null +abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem() { private lateinit var playerListener: VoiceBroadcastPlayer.Listener - - override fun isCacheable(): Boolean = false + private var isUserSeeking = false override fun bind(holder: Holder) { super.bind(holder) @@ -74,52 +44,62 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageItem - renderState(holder, state) - } - voiceBroadcastPlayer?.addListener(playerListener) - renderHeader(holder) - renderLiveIcon(holder) - } - - private fun renderHeader(holder: Holder) { - with(holder) { - roomItem?.let { - attributes.avatarRenderer.render(it, roomAvatarImageView) - titleText.text = it.displayName + playerListener = object : VoiceBroadcastPlayer.Listener { + override fun onPlayingStateChanged(state: VoiceBroadcastPlayer.State) { + renderPlayingState(holder, state) + } + + override fun onLiveModeChanged(isLive: Boolean) { + renderLiveIndicator(holder) } - broadcasterNameText.text = broadcasterName } + player.addListener(voiceBroadcast, playerListener) + bindSeekBar(holder) + bindButtons(holder) } - private fun renderLiveIcon(holder: Holder) { + private fun bindButtons(holder: Holder) { with(holder) { - when (voiceBroadcastState) { - VoiceBroadcastState.STARTED, - VoiceBroadcastState.RESUMED -> { - liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.colorError)) - liveIndicator.isVisible = true - } - VoiceBroadcastState.PAUSED -> { - liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.vctr_content_quaternary)) - liveIndicator.isVisible = true - } - VoiceBroadcastState.STOPPED, null -> { - liveIndicator.isVisible = false + playPauseButton.setOnClickListener { + if (player.currentVoiceBroadcast == voiceBroadcast) { + when (player.playingState) { + VoiceBroadcastPlayer.State.PLAYING -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.Pause) + VoiceBroadcastPlayer.State.PAUSED, + VoiceBroadcastPlayer.State.IDLE -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast)) + VoiceBroadcastPlayer.State.BUFFERING -> Unit + } + } else { + callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast)) } } + fastBackwardButton.setOnClickListener { + val newPos = seekBar.progress.minus(30_000).coerceIn(0, duration) + callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.SeekTo(voiceBroadcast, newPos, duration)) + } + fastForwardButton.setOnClickListener { + val newPos = seekBar.progress.plus(30_000).coerceIn(0, duration) + callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.SeekTo(voiceBroadcast, newPos, duration)) + } } } - private fun renderState(holder: Holder, state: VoiceBroadcastPlayer.State) { - if (isCurrentMediaActive()) { - renderActiveMedia(holder, state) - } else { - renderInactiveMedia(holder) + override fun renderMetadata(holder: Holder) { + with(holder) { + broadcasterNameMetadata.value = recorderName + voiceBroadcastMetadata.isVisible = true + listenersCountMetadata.isVisible = false } } - private fun renderActiveMedia(holder: Holder, state: VoiceBroadcastPlayer.State) { + override fun renderLiveIndicator(holder: Holder) { + when { + voiceBroadcastState == null || voiceBroadcastState == VoiceBroadcastState.STOPPED -> renderNoLiveIndicator(holder) + voiceBroadcastState == VoiceBroadcastState.PAUSED || !player.isLiveListening -> renderPausedLiveIndicator(holder) + else -> renderPlayingLiveIndicator(holder) + } + } + + private fun renderPlayingState(holder: Holder, state: VoiceBroadcastPlayer.State) { with(holder) { bufferingView.isVisible = state == VoiceBroadcastPlayer.State.BUFFERING playPauseButton.isVisible = state != VoiceBroadcastPlayer.State.BUFFERING @@ -127,50 +107,81 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageItem { playPauseButton.setImageResource(R.drawable.ic_play_pause_pause) - playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast) - playPauseButton.onClick { attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.Pause) } + playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast) } VoiceBroadcastPlayer.State.IDLE, VoiceBroadcastPlayer.State.PAUSED -> { playPauseButton.setImageResource(R.drawable.ic_play_pause_play) - playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast) - playPauseButton.onClick { - attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcastId)) - } + playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast) } VoiceBroadcastPlayer.State.BUFFERING -> Unit } + + renderLiveIndicator(holder) } } - private fun renderInactiveMedia(holder: Holder) { + private fun bindSeekBar(holder: Holder) { with(holder) { - bufferingView.isVisible = false - playPauseButton.isVisible = true - playPauseButton.setImageResource(R.drawable.ic_play_pause_play) - playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast) - playPauseButton.onClick { - attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcastId)) + durationView.text = formatPlaybackTime(duration) + seekBar.max = duration + seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) = Unit + + override fun onStartTrackingTouch(seekBar: SeekBar) { + isUserSeeking = true + } + + override fun onStopTrackingTouch(seekBar: SeekBar) { + callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.SeekTo(voiceBroadcast, seekBar.progress, duration)) + isUserSeeking = false + } + }) + } + playbackTracker.track(voiceBroadcast.voiceBroadcastId) { playbackState -> + renderBackwardForwardButtons(holder, playbackState) + renderLiveIndicator(holder) + if (!isUserSeeking) { + holder.seekBar.progress = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) } } } - private fun isCurrentMediaActive() = voiceBroadcastPlayer?.currentVoiceBroadcastId == voiceBroadcastId + private fun renderBackwardForwardButtons(holder: Holder, playbackState: State) { + val isPlayingOrPaused = playbackState is State.Playing || playbackState is State.Paused + val playbackTime = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) + val canBackward = isPlayingOrPaused && playbackTime > 0 + val canForward = isPlayingOrPaused && playbackTime < duration + holder.fastBackwardButton.isInvisible = !canBackward + holder.fastForwardButton.isInvisible = !canForward + } + + private fun formatPlaybackTime(time: Int) = DateUtils.formatElapsedTime((time / 1000).toLong()) override fun unbind(holder: Holder) { super.unbind(holder) - voiceBroadcastPlayer?.removeListener(playerListener) + player.removeListener(voiceBroadcast, playerListener) + playbackTracker.untrack(voiceBroadcast.voiceBroadcastId) + with(holder) { + seekBar.setOnSeekBarChangeListener(null) + playPauseButton.onClick(null) + fastForwardButton.onClick(null) + fastBackwardButton.onClick(null) + } } override fun getViewStubId() = STUB_ID - class Holder : AbsMessageItem.Holder(STUB_ID) { - val liveIndicator by bind(R.id.liveIndicator) - val roomAvatarImageView by bind(R.id.roomAvatarImageView) - val titleText by bind(R.id.titleText) + class Holder : AbsMessageVoiceBroadcastItem.Holder(STUB_ID) { val playPauseButton by bind(R.id.playPauseButton) val bufferingView by bind(R.id.bufferingView) - val broadcasterNameText by bind(R.id.broadcasterNameText) + val fastBackwardButton by bind(R.id.fastBackwardButton) + val fastForwardButton by bind(R.id.fastForwardButton) + val seekBar by bind(R.id.seekBar) + val durationView by bind(R.id.playbackDuration) + val broadcasterNameMetadata by bind(R.id.broadcasterNameMetadata) + val voiceBroadcastMetadata by bind(R.id.voiceBroadcastMetadata) + val listenersCountMetadata by bind(R.id.listenersCountMetadata) } companion object { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt index c417053b2a..39d2d73c68 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt @@ -17,45 +17,21 @@ package im.vector.app.features.home.room.detail.timeline.item import android.widget.ImageButton -import android.widget.ImageView -import android.widget.TextView import androidx.core.view.isVisible -import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R import im.vector.app.core.epoxy.onClick -import im.vector.app.core.extensions.tintBackground -import im.vector.app.core.resources.ColorProvider -import im.vector.app.core.resources.DrawableProvider +import im.vector.app.core.utils.TextUtils import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction -import im.vector.app.features.home.room.detail.timeline.TimelineEventController -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder -import org.matrix.android.sdk.api.util.MatrixItem +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView +import org.threeten.bp.Duration @EpoxyModelClass -abstract class MessageVoiceBroadcastRecordingItem : AbsMessageItem() { +abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem() { - @EpoxyAttribute - var callback: TimelineEventController.Callback? = null - - @EpoxyAttribute - var voiceBroadcastRecorder: VoiceBroadcastRecorder? = null - - @EpoxyAttribute - lateinit var colorProvider: ColorProvider - - @EpoxyAttribute - lateinit var drawableProvider: DrawableProvider - - @EpoxyAttribute - var roomItem: MatrixItem? = null - - @EpoxyAttribute - var title: String? = null - - private lateinit var recorderListener: VoiceBroadcastRecorder.Listener - - override fun isCacheable(): Boolean = false + private var recorderListener: VoiceBroadcastRecorder.Listener? = null override fun bind(holder: Holder) { super.bind(holder) @@ -63,73 +39,107 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageItem { - stopRecordButton.isEnabled = true - recordButton.isEnabled = true - - liveIndicator.isVisible = true - liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.colorError)) - - val drawableColor = colorProvider.getColorFromAttribute(R.attr.vctr_content_secondary) - val drawable = drawableProvider.getDrawable(R.drawable.ic_play_pause_pause, drawableColor) - recordButton.setImageDrawable(drawable) - recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_pause_voice_broadcast_record) - recordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Pause) } - stopRecordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } - } - VoiceBroadcastRecorder.State.Paused -> { - stopRecordButton.isEnabled = true - recordButton.isEnabled = true - - liveIndicator.isVisible = true - liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.vctr_content_quaternary)) - - recordButton.setImageResource(R.drawable.ic_recording_dot) - recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_resume_voice_broadcast_record) - recordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Resume) } - stopRecordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } - } - VoiceBroadcastRecorder.State.Idle -> { - recordButton.isEnabled = false - stopRecordButton.isEnabled = false - liveIndicator.isVisible = false - } - } + override fun renderLiveIndicator(holder: Holder) { + when (voiceBroadcastState) { + VoiceBroadcastState.STARTED, + VoiceBroadcastState.RESUMED -> renderPlayingLiveIndicator(holder) + VoiceBroadcastState.PAUSED -> renderPausedLiveIndicator(holder) + VoiceBroadcastState.STOPPED, null -> renderNoLiveIndicator(holder) } } + override fun renderMetadata(holder: Holder) { + holder.listenersCountMetadata.isVisible = false + } + + private fun renderRemainingTime(holder: Holder, remainingTime: Long?) { + if (remainingTime != null) { + val formattedDuration = TextUtils.formatDurationWithUnits( + holder.view.context, + Duration.ofSeconds(remainingTime.coerceAtLeast(0L)) + ) + holder.remainingTimeMetadata.value = holder.view.resources.getString(R.string.voice_broadcast_recording_time_left, formattedDuration) + holder.remainingTimeMetadata.isVisible = true + } else { + holder.remainingTimeMetadata.isVisible = false + } + } + + private fun renderRecordingState(holder: Holder, state: VoiceBroadcastRecorder.State) { + when (state) { + VoiceBroadcastRecorder.State.Recording -> renderRecordingState(holder) + VoiceBroadcastRecorder.State.Paused -> renderPausedState(holder) + VoiceBroadcastRecorder.State.Idle -> renderStoppedState(holder) + } + } + + private fun renderVoiceBroadcastState(holder: Holder) { + when (voiceBroadcastState) { + VoiceBroadcastState.STARTED, + VoiceBroadcastState.RESUMED -> renderRecordingState(holder) + VoiceBroadcastState.PAUSED -> renderPausedState(holder) + VoiceBroadcastState.STOPPED, + null -> renderStoppedState(holder) + } + } + + private fun renderRecordingState(holder: Holder) = with(holder) { + stopRecordButton.isEnabled = true + recordButton.isEnabled = true + + val drawableColor = colorProvider.getColorFromAttribute(R.attr.vctr_content_secondary) + val drawable = drawableProvider.getDrawable(R.drawable.ic_play_pause_pause, drawableColor) + recordButton.setImageDrawable(drawable) + recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_pause_voice_broadcast_record) + recordButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Pause) } + stopRecordButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } + } + + private fun renderPausedState(holder: Holder) = with(holder) { + stopRecordButton.isEnabled = true + recordButton.isEnabled = true + + recordButton.setImageResource(R.drawable.ic_recording_dot) + recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_resume_voice_broadcast_record) + recordButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Resume) } + stopRecordButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } + } + + private fun renderStoppedState(holder: Holder) = with(holder) { + recordButton.isEnabled = false + stopRecordButton.isEnabled = false + } + override fun unbind(holder: Holder) { super.unbind(holder) - voiceBroadcastRecorder?.removeListener(recorderListener) + recorderListener?.let { recorder?.removeListener(it) } + recorderListener = null + with(holder) { + recordButton.onClick(null) + stopRecordButton.onClick(null) + } } override fun getViewStubId() = STUB_ID - class Holder : AbsMessageItem.Holder(STUB_ID) { - val liveIndicator by bind(R.id.liveIndicator) - val roomAvatarImageView by bind(R.id.roomAvatarImageView) - val titleText by bind(R.id.titleText) + class Holder : AbsMessageVoiceBroadcastItem.Holder(STUB_ID) { + val listenersCountMetadata by bind(R.id.listenersCountMetadata) + val remainingTimeMetadata by bind(R.id.remainingTimeMetadata) val recordButton by bind(R.id.recordButton) val stopRecordButton by bind(R.id.stopRecordButton) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt index e057950790..d3f320db7d 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 @@ -122,16 +122,14 @@ abstract class MessageVoiceItem : AbsMessageItem() { true } - audioMessagePlaybackTracker.track(attributes.informationData.eventId, object : AudioMessagePlaybackTracker.Listener { - override fun onUpdate(state: AudioMessagePlaybackTracker.Listener.State) { - when (state) { - is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder, waveformColorIdle, waveformColorPlayed) - is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state, waveformColorIdle, waveformColorPlayed) - is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state, waveformColorIdle, waveformColorPlayed) - is AudioMessagePlaybackTracker.Listener.State.Recording -> Unit - } + audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state -> + when (state) { + is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder, waveformColorIdle, waveformColorPlayed) + is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state, waveformColorIdle, waveformColorPlayed) + is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state, waveformColorIdle, waveformColorPlayed) + is AudioMessagePlaybackTracker.Listener.State.Recording -> Unit } - }) + } } private fun getTouchedPositionPercentage(motionEvent: MotionEvent, view: View) = (motionEvent.x / view.width).coerceIn(0f, 1f) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/TypingItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/TypingItem.kt new file mode 100644 index 0000000000..2ca0ebea48 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/TypingItem.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.item + +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import com.airbnb.epoxy.EpoxyModelWithHolder +import im.vector.app.R +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.ui.views.TypingMessageView +import im.vector.app.features.home.AvatarRenderer +import org.matrix.android.sdk.api.session.room.sender.SenderInfo + +@EpoxyModelClass +abstract class TypingItem : EpoxyModelWithHolder() { + + companion object { + private const val MAX_TYPING_MESSAGE_USERS_COUNT = 4 + } + + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) + lateinit var avatarRenderer: AvatarRenderer + + @EpoxyAttribute + var users: List = emptyList() + + override fun getDefaultLayout(): Int = R.layout.item_typing_users + + override fun bind(holder: TypingHolder) { + super.bind(holder) + + val typingUsers = users.take(MAX_TYPING_MESSAGE_USERS_COUNT) + holder.typingView.apply { + animate().cancel() + val duration = 100L + if (typingUsers.isEmpty()) { + animate().translationY(height.toFloat()) + .alpha(0f) + .setDuration(duration) + .withEndAction { + isInvisible = true + }.start() + } else { + isVisible = true + + translationY = height.toFloat() + alpha = 0f + render(typingUsers, avatarRenderer) + animate().translationY(0f) + .alpha(1f) + .setDuration(duration) + .start() + } + } + } + + class TypingHolder : VectorEpoxyHolder() { + val typingView by bind(R.id.typingMessageView) + } +} diff --git a/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt b/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt index 85cfb76ff7..f6e10a6df9 100644 --- a/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt +++ b/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt @@ -83,6 +83,20 @@ class PillsPostProcessor @AssistedInject constructor( val pillSpan = linkSpan.createPillSpan(roomId) ?: return@forEach val startSpan = renderedText.getSpanStart(linkSpan) val endSpan = renderedText.getSpanEnd(linkSpan) + // GlideImagesPlugin causes duplicated pills if we have a nested spans in the pill span, + // such as images or italic text. + // Accordingly, it's better to remove all spans that are contained in this span before rendering. + renderedText.getSpans(startSpan, endSpan, Any::class.java).forEach remove@{ + if (it !is LinkSpan) { + // Make sure to only remove spans that are contained in this link, and not are bigger than this link, e.g. like reply-blocks + val start = renderedText.getSpanStart(it) + if (start < startSpan) return@remove + val end = renderedText.getSpanEnd(it) + if (end > endSpan) return@remove + + renderedText.removeSpan(it) + } + } addPillSpan(renderedText, pillSpan, startSpan, endSpan) } } diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationUserItem.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationUserItem.kt index bab7f4c7f9..c108e83e76 100644 --- a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationUserItem.kt +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationUserItem.kt @@ -79,10 +79,8 @@ abstract class LiveLocationUserItem : VectorEpoxyModel(initialState, activeSessionHolder, refreshDevicesUseCase) { @@ -97,8 +106,12 @@ class DevicesViewModel @AssistedInject constructor( override fun handle(action: DevicesAction) { when (action) { + is DevicesAction.PasswordAuthDone -> handlePasswordAuthDone(action) + DevicesAction.ReAuthCancelled -> handleReAuthCancelled() + DevicesAction.SsoAuthDone -> handleSsoAuthDone() is DevicesAction.VerifyCurrentSession -> handleVerifyCurrentSessionAction() is DevicesAction.MarkAsManuallyVerified -> handleMarkAsManuallyVerifiedAction() + DevicesAction.MultiSignoutOtherSessions -> handleMultiSignoutOtherSessions() } } @@ -116,4 +129,66 @@ class DevicesViewModel @AssistedInject constructor( private fun handleMarkAsManuallyVerifiedAction() { // TODO implement when needed } + + private fun handleMultiSignoutOtherSessions() = withState { state -> + viewModelScope.launch { + setLoading(true) + val deviceIds = getDeviceIdsOfOtherSessions(state) + if (deviceIds.isEmpty()) { + return@launch + } + val result = signout(deviceIds) + setLoading(false) + + val error = result.exceptionOrNull() + if (error == null) { + onSignoutSuccess() + } else { + onSignoutFailure(error) + } + } + } + + private fun getDeviceIdsOfOtherSessions(state: DevicesViewState): List { + val currentDeviceId = state.currentSessionCrossSigningInfo.deviceId + return state.devices() + ?.mapNotNull { fullInfo -> fullInfo.deviceInfo.deviceId.takeUnless { it == currentDeviceId } } + .orEmpty() + } + + private suspend fun signout(deviceIds: List) = signoutSessionsUseCase.execute(deviceIds, this::onReAuthNeeded) + + private fun onReAuthNeeded(reAuthNeeded: SignoutSessionsReAuthNeeded) { + Timber.d("onReAuthNeeded") + pendingAuthHandler.pendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session) + pendingAuthHandler.uiaContinuation = reAuthNeeded.uiaContinuation + _viewEvents.post(DevicesViewEvent.RequestReAuth(reAuthNeeded.flowResponse, reAuthNeeded.errCode)) + } + + private fun setLoading(isLoading: Boolean) { + setState { copy(isLoading = isLoading) } + } + + private fun onSignoutSuccess() { + Timber.d("signout success") + refreshDeviceList() + _viewEvents.post(DevicesViewEvent.SignoutSuccess) + } + + private fun onSignoutFailure(failure: Throwable) { + Timber.e("signout failure", failure) + _viewEvents.post(DevicesViewEvent.SignoutError(failure)) + } + + private fun handleSsoAuthDone() { + pendingAuthHandler.ssoAuthDone() + } + + private fun handlePasswordAuthDone(action: DevicesAction.PasswordAuthDone) { + pendingAuthHandler.passwordAuthDone(action.password) + } + + private fun handleReAuthCancelled() { + pendingAuthHandler.reAuthCancelled() + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt index 1c348af4f9..3a3c3463fb 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt @@ -16,6 +16,7 @@ package im.vector.app.features.settings.devices.v2 +import android.app.Activity import android.content.Context import android.os.Bundle import android.view.LayoutInflater @@ -30,12 +31,15 @@ import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.date.VectorDateFormatter import im.vector.app.core.dialogs.ManuallyVerifyDialog +import im.vector.app.core.extensions.registerStartForActivityResult +import im.vector.app.core.extensions.setTextColor import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.resources.StringProvider import im.vector.app.databinding.FragmentSettingsDevicesBinding import im.vector.app.features.VectorFeatures +import im.vector.app.features.auth.ReAuthActivity import im.vector.app.features.crypto.recover.SetupMode import im.vector.app.features.crypto.verification.VerificationBottomSheet import im.vector.app.features.login.qr.QrCodeLoginArgs @@ -47,6 +51,8 @@ import im.vector.app.features.settings.devices.v2.list.SESSION_IS_MARKED_AS_INAC import im.vector.app.features.settings.devices.v2.list.SecurityRecommendationView import im.vector.app.features.settings.devices.v2.list.SecurityRecommendationViewState import im.vector.app.features.settings.devices.v2.list.SessionInfoViewState +import im.vector.app.features.settings.devices.v2.signout.BuildConfirmSignoutDialogUseCase +import org.matrix.android.sdk.api.auth.data.LoginFlowTypes import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel import javax.inject.Inject @@ -70,6 +76,8 @@ class VectorSettingsDevicesFragment : @Inject lateinit var stringProvider: StringProvider + @Inject lateinit var buildConfirmSignoutDialogUseCase: BuildConfirmSignoutDialogUseCase + private val viewModel: DevicesViewModel by fragmentViewModel() override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSettingsDevicesBinding { @@ -91,6 +99,7 @@ class VectorSettingsDevicesFragment : super.onViewCreated(view, savedInstanceState) initWaitingView() + initOtherSessionsHeaderView() initOtherSessionsView() initSecurityRecommendationsView() initQrLoginView() @@ -100,10 +109,7 @@ class VectorSettingsDevicesFragment : private fun observeViewEvents() { viewModel.observeViewEvents { when (it) { - is DevicesViewEvent.Loading -> showLoading(it.message) - is DevicesViewEvent.Failure -> showFailure(it.throwable) - is DevicesViewEvent.RequestReAuth -> Unit // TODO. Next PR - is DevicesViewEvent.PromptRenameDevice -> Unit // TODO. Next PR + is DevicesViewEvent.RequestReAuth -> askForReAuthentication(it) is DevicesViewEvent.ShowVerifyDevice -> { VerificationBottomSheet.withArgs( roomId = null, @@ -122,6 +128,8 @@ class VectorSettingsDevicesFragment : is DevicesViewEvent.PromptResetSecrets -> { navigator.open4SSetup(requireActivity(), SetupMode.PASSPHRASE_AND_NEEDED_SECRETS_RESET) } + is DevicesViewEvent.SignoutError -> showFailure(it.error) + is DevicesViewEvent.SignoutSuccess -> Unit // do nothing } } } @@ -131,6 +139,29 @@ class VectorSettingsDevicesFragment : views.waitingView.waitingStatusText.isVisible = true } + private fun initOtherSessionsHeaderView() { + views.deviceListHeaderOtherSessions.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.otherSessionsHeaderMultiSignout -> { + confirmMultiSignoutOtherSessions() + true + } + else -> false + } + } + } + + private fun confirmMultiSignoutOtherSessions() { + activity?.let { + buildConfirmSignoutDialogUseCase.execute(it, this::multiSignoutOtherSessions) + .show() + } + } + + private fun multiSignoutOtherSessions() { + viewModel.handle(DevicesAction.MultiSignoutOtherSessions) + } + private fun initOtherSessionsView() { views.deviceListOtherSessions.callback = this } @@ -142,7 +173,7 @@ class VectorSettingsDevicesFragment : requireActivity(), R.string.device_manager_header_section_security_recommendations_title, DeviceManagerFilterType.UNVERIFIED, - excludeCurrentDevice = false + excludeCurrentDevice = true ) } } @@ -152,7 +183,7 @@ class VectorSettingsDevicesFragment : requireActivity(), R.string.device_manager_header_section_security_recommendations_title, DeviceManagerFilterType.INACTIVE, - excludeCurrentDevice = false + excludeCurrentDevice = true ) } } @@ -271,6 +302,11 @@ class VectorSettingsDevicesFragment : hideOtherSessionsView() } else { views.deviceListHeaderOtherSessions.isVisible = true + val color = colorProvider.getColorFromAttribute(R.attr.colorError) + val multiSignoutItem = views.deviceListHeaderOtherSessions.menu.findItem(R.id.otherSessionsHeaderMultiSignout) + val nbDevices = otherDevices.size + multiSignoutItem.title = stringProvider.getQuantityString(R.plurals.device_manager_other_sessions_multi_signout_all, nbDevices, nbDevices) + multiSignoutItem.setTextColor(color) views.deviceListOtherSessions.isVisible = true views.deviceListOtherSessions.render( devices = otherDevices.take(NUMBER_OF_OTHER_DEVICES_TO_RENDER), @@ -347,4 +383,37 @@ class VectorSettingsDevicesFragment : excludeCurrentDevice = true ) } + + private val reAuthActivityResultLauncher = registerStartForActivityResult { activityResult -> + if (activityResult.resultCode == Activity.RESULT_OK) { + when (activityResult.data?.extras?.getString(ReAuthActivity.RESULT_FLOW_TYPE)) { + LoginFlowTypes.SSO -> { + viewModel.handle(DevicesAction.SsoAuthDone) + } + LoginFlowTypes.PASSWORD -> { + val password = activityResult.data?.extras?.getString(ReAuthActivity.RESULT_VALUE) ?: "" + viewModel.handle(DevicesAction.PasswordAuthDone(password)) + } + else -> { + viewModel.handle(DevicesAction.ReAuthCancelled) + } + } + } else { + viewModel.handle(DevicesAction.ReAuthCancelled) + } + } + + /** + * Launch the re auth activity to get credentials. + */ + private fun askForReAuthentication(reAuthReq: DevicesViewEvent.RequestReAuth) { + ReAuthActivity.newIntent( + requireContext(), + reAuthReq.registrationFlowResponse, + reAuthReq.lastErrorCode, + getString(R.string.devices_delete_dialog_title) + ).let { intent -> + reAuthActivityResultLauncher.launch(intent) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionsListHeaderView.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionsListHeaderView.kt index 0660e7d642..f74d88790c 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionsListHeaderView.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionsListHeaderView.kt @@ -20,6 +20,10 @@ import android.content.Context import android.content.res.TypedArray import android.util.AttributeSet import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import androidx.appcompat.view.menu.MenuBuilder +import androidx.appcompat.widget.ActionMenuView.OnMenuItemClickListener import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.res.use import androidx.core.view.isVisible @@ -39,6 +43,7 @@ class SessionsListHeaderView @JvmOverloads constructor( this ) + val menu: Menu = binding.sessionsListHeaderMenu.menu var onLearnMoreClickListener: (() -> Unit)? = null init { @@ -50,6 +55,7 @@ class SessionsListHeaderView @JvmOverloads constructor( ).use { setTitle(it) setDescription(it) + setMenu(it) } } @@ -90,4 +96,19 @@ class SessionsListHeaderView @JvmOverloads constructor( onLearnMoreClickListener?.invoke() } } + + private fun setMenu(typedArray: TypedArray) { + val menuResId = typedArray.getResourceId(R.styleable.SessionsListHeaderView_sessionsListHeaderMenu, -1) + if (menuResId == -1) { + binding.sessionsListHeaderMenu.isVisible = false + } else { + binding.sessionsListHeaderMenu.showOverflowMenu() + val menuBuilder = binding.sessionsListHeaderMenu.menu as? MenuBuilder + menuBuilder?.let { MenuInflater(context).inflate(menuResId, it) } + } + } + + fun setOnMenuItemClickListener(listener: OnMenuItemClickListener) { + binding.sessionsListHeaderMenu.setOnMenuItemClickListener(listener) + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/CanTogglePushNotificationsViaPusherUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/CanTogglePushNotificationsViaPusherUseCase.kt new file mode 100644 index 0000000000..0125d92ba6 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/CanTogglePushNotificationsViaPusherUseCase.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2.notification + +import androidx.lifecycle.asFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.flow.unwrap +import javax.inject.Inject + +class CanTogglePushNotificationsViaPusherUseCase @Inject constructor() { + + fun execute(session: Session): Flow { + return session + .homeServerCapabilitiesService() + .getHomeServerCapabilitiesLive() + .asFlow() + .unwrap() + .map { it.canRemotelyTogglePushNotificationsOfDevices } + .distinctUntilChanged() + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/CheckIfCanTogglePushNotificationsViaAccountDataUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/CheckIfCanTogglePushNotificationsViaAccountDataUseCase.kt new file mode 100644 index 0000000000..194a2aebbf --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/CheckIfCanTogglePushNotificationsViaAccountDataUseCase.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2.notification + +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes +import javax.inject.Inject + +class CheckIfCanTogglePushNotificationsViaAccountDataUseCase @Inject constructor() { + + fun execute(session: Session, deviceId: String): Boolean { + return session + .accountDataService() + .getUserAccountDataEvent(UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + deviceId) != null + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/CheckIfCanTogglePushNotificationsViaPusherUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/CheckIfCanTogglePushNotificationsViaPusherUseCase.kt new file mode 100644 index 0000000000..ca314bf145 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/CheckIfCanTogglePushNotificationsViaPusherUseCase.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2.notification + +import org.matrix.android.sdk.api.session.Session +import javax.inject.Inject + +class CheckIfCanTogglePushNotificationsViaPusherUseCase @Inject constructor() { + + fun execute(session: Session): Boolean { + return session + .homeServerCapabilitiesService() + .getHomeServerCapabilities() + .canRemotelyTogglePushNotificationsOfDevices + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/GetNotificationsStatusUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/GetNotificationsStatusUseCase.kt new file mode 100644 index 0000000000..03e4e31f2e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/GetNotificationsStatusUseCase.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2.notification + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import org.matrix.android.sdk.api.account.LocalNotificationSettingsContent +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.flow.flow +import org.matrix.android.sdk.flow.unwrap +import javax.inject.Inject + +class GetNotificationsStatusUseCase @Inject constructor( + private val canTogglePushNotificationsViaPusherUseCase: CanTogglePushNotificationsViaPusherUseCase, + private val checkIfCanTogglePushNotificationsViaAccountDataUseCase: CheckIfCanTogglePushNotificationsViaAccountDataUseCase, +) { + + fun execute(session: Session, deviceId: String): Flow { + return when { + checkIfCanTogglePushNotificationsViaAccountDataUseCase.execute(session, deviceId) -> { + session.flow() + .liveUserAccountData(UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + deviceId) + .unwrap() + .map { it.content.toModel()?.isSilenced?.not() } + .map { if (it == true) NotificationsStatus.ENABLED else NotificationsStatus.DISABLED } + .distinctUntilChanged() + } + else -> canTogglePushNotificationsViaPusherUseCase.execute(session) + .flatMapLatest { canToggle -> + if (canToggle) { + session.flow() + .livePushers() + .map { it.filter { pusher -> pusher.deviceId == deviceId } } + .map { it.takeIf { it.isNotEmpty() }?.any { pusher -> pusher.enabled } } + .map { if (it == true) NotificationsStatus.ENABLED else NotificationsStatus.DISABLED } + .distinctUntilChanged() + } else { + flowOf(NotificationsStatus.NOT_SUPPORTED) + } + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/NotificationsStatus.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/NotificationsStatus.kt new file mode 100644 index 0000000000..7ff1f04381 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/NotificationsStatus.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2.notification + +enum class NotificationsStatus { + ENABLED, + DISABLED, + NOT_SUPPORTED, +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/TogglePushNotificationUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/TogglePushNotificationUseCase.kt similarity index 67% rename from vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/TogglePushNotificationUseCase.kt rename to vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/TogglePushNotificationUseCase.kt index 45c234aaef..7969bbbe9b 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/TogglePushNotificationUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/TogglePushNotificationUseCase.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package im.vector.app.features.settings.devices.v2.overview +package im.vector.app.features.settings.devices.v2.notification import im.vector.app.core.di.ActiveSessionHolder import org.matrix.android.sdk.api.account.LocalNotificationSettingsContent @@ -24,17 +24,21 @@ import javax.inject.Inject class TogglePushNotificationUseCase @Inject constructor( private val activeSessionHolder: ActiveSessionHolder, + private val checkIfCanTogglePushNotificationsViaPusherUseCase: CheckIfCanTogglePushNotificationsViaPusherUseCase, + private val checkIfCanTogglePushNotificationsViaAccountDataUseCase: CheckIfCanTogglePushNotificationsViaAccountDataUseCase, ) { suspend fun execute(deviceId: String, enabled: Boolean) { val session = activeSessionHolder.getSafeActiveSession() ?: return - val devicePusher = session.pushersService().getPushers().firstOrNull { it.deviceId == deviceId } - devicePusher?.let { pusher -> - session.pushersService().togglePusher(pusher, enabled) + + if (checkIfCanTogglePushNotificationsViaPusherUseCase.execute(session)) { + val devicePusher = session.pushersService().getPushers().firstOrNull { it.deviceId == deviceId } + devicePusher?.let { pusher -> + session.pushersService().togglePusher(pusher, enabled) + } } - val accountData = session.accountDataService().getUserAccountDataEvent(UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + deviceId) - if (accountData != null) { + if (checkIfCanTogglePushNotificationsViaAccountDataUseCase.execute(session, deviceId)) { val newNotificationSettingsContent = LocalNotificationSettingsContent(isSilenced = !enabled) session.accountDataService().updateUserAccountData( UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + deviceId, diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt index 1978708ebf..24d2a08bdc 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt @@ -20,10 +20,17 @@ import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType sealed class OtherSessionsAction : VectorViewModelAction { + // ReAuth + object SsoAuthDone : OtherSessionsAction() + data class PasswordAuthDone(val password: String) : OtherSessionsAction() + object ReAuthCancelled : OtherSessionsAction() + + // Others data class FilterDevices(val filterType: DeviceManagerFilterType) : OtherSessionsAction() data class EnableSelectMode(val deviceId: String?) : OtherSessionsAction() object DisableSelectMode : OtherSessionsAction() data class ToggleSelectionForDevice(val deviceId: String) : OtherSessionsAction() object SelectAll : OtherSessionsAction() object DeselectAll : OtherSessionsAction() + object MultiSignout : OtherSessionsAction() } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt index 4f1c8353f5..74a78b2415 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt @@ -16,6 +16,7 @@ package im.vector.app.features.settings.devices.v2.othersessions +import android.app.Activity import android.os.Bundle import android.view.LayoutInflater import android.view.Menu @@ -32,6 +33,8 @@ import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R +import im.vector.app.core.extensions.registerStartForActivityResult +import im.vector.app.core.extensions.setTextColor import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment.ResultListener.Companion.RESULT_OK import im.vector.app.core.platform.VectorBaseFragment @@ -39,13 +42,16 @@ import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.StringProvider import im.vector.app.databinding.FragmentOtherSessionsBinding +import im.vector.app.features.auth.ReAuthActivity import im.vector.app.features.settings.devices.v2.DeviceFullInfo import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterBottomSheet import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType import im.vector.app.features.settings.devices.v2.list.OtherSessionsView import im.vector.app.features.settings.devices.v2.list.SESSION_IS_MARKED_AS_INACTIVE_AFTER_DAYS import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreBottomSheet +import im.vector.app.features.settings.devices.v2.signout.BuildConfirmSignoutDialogUseCase import im.vector.app.features.themes.ThemeUtils +import org.matrix.android.sdk.api.auth.data.LoginFlowTypes import org.matrix.android.sdk.api.extensions.orFalse import javax.inject.Inject @@ -65,6 +71,8 @@ class OtherSessionsFragment : @Inject lateinit var viewNavigator: OtherSessionsViewNavigator + @Inject lateinit var buildConfirmSignoutDialogUseCase: BuildConfirmSignoutDialogUseCase + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentOtherSessionsBinding { return FragmentOtherSessionsBinding.inflate(layoutInflater, container, false) } @@ -77,9 +85,33 @@ class OtherSessionsFragment : menu.findItem(R.id.otherSessionsSelectAll).isVisible = isSelectModeEnabled menu.findItem(R.id.otherSessionsDeselectAll).isVisible = isSelectModeEnabled menu.findItem(R.id.otherSessionsSelect).isVisible = !isSelectModeEnabled && state.devices()?.isNotEmpty().orFalse() + updateMultiSignoutMenuItem(menu, state) } } + private fun updateMultiSignoutMenuItem(menu: Menu, viewState: OtherSessionsViewState) { + val multiSignoutItem = menu.findItem(R.id.otherSessionsMultiSignout) + multiSignoutItem.title = if (viewState.isSelectModeEnabled) { + getString(R.string.device_manager_other_sessions_multi_signout_selection).uppercase() + } else { + val nbDevices = viewState.devices()?.size ?: 0 + stringProvider.getQuantityString(R.plurals.device_manager_other_sessions_multi_signout_all, nbDevices, nbDevices) + } + multiSignoutItem.isVisible = if (viewState.isSelectModeEnabled) { + viewState.devices.invoke()?.any { it.isSelected }.orFalse() + } else { + viewState.devices.invoke()?.isNotEmpty().orFalse() + } + val showAsActionFlag = if (viewState.isSelectModeEnabled) MenuItem.SHOW_AS_ACTION_IF_ROOM else MenuItem.SHOW_AS_ACTION_NEVER + multiSignoutItem.setShowAsAction(showAsActionFlag or MenuItem.SHOW_AS_ACTION_WITH_TEXT) + changeTextColorOfDestructiveAction(multiSignoutItem) + } + + private fun changeTextColorOfDestructiveAction(menuItem: MenuItem) { + val titleColor = colorProvider.getColorFromAttribute(R.attr.colorError) + menuItem.setTextColor(titleColor) + } + override fun handleMenuItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.otherSessionsSelect -> { @@ -94,10 +126,25 @@ class OtherSessionsFragment : viewModel.handle(OtherSessionsAction.DeselectAll) true } + R.id.otherSessionsMultiSignout -> { + confirmMultiSignout() + true + } else -> false } } + private fun confirmMultiSignout() { + activity?.let { + buildConfirmSignoutDialogUseCase.execute(it, this::multiSignout) + .show() + } + } + + private fun multiSignout() { + viewModel.handle(OtherSessionsAction.MultiSignout) + } + private fun enableSelectMode(isEnabled: Boolean, deviceId: String? = null) { val action = if (isEnabled) OtherSessionsAction.EnableSelectMode(deviceId) else OtherSessionsAction.DisableSelectMode viewModel.handle(action) @@ -129,8 +176,9 @@ class OtherSessionsFragment : private fun observeViewEvents() { viewModel.observeViewEvents { when (it) { - is OtherSessionsViewEvents.Loading -> showLoading(it.message) - is OtherSessionsViewEvents.Failure -> showFailure(it.throwable) + is OtherSessionsViewEvents.SignoutError -> showFailure(it.error) + is OtherSessionsViewEvents.RequestReAuth -> askForReAuthentication(it) + OtherSessionsViewEvents.SignoutSuccess -> enableSelectMode(false) } } } @@ -162,6 +210,7 @@ class OtherSessionsFragment : } override fun invalidate() = withState(viewModel) { state -> + updateLoading(state.isLoading) if (state.devices is Success) { val devices = state.devices.invoke() renderDevices(devices, state.currentFilter) @@ -169,6 +218,14 @@ class OtherSessionsFragment : } } + private fun updateLoading(isLoading: Boolean) { + if (isLoading) { + showLoading(null) + } else { + dismissLoadingDialog() + } + } + private fun updateToolbar(devices: List, isSelectModeEnabled: Boolean) { invalidateOptionsMenu() val title = if (isSelectModeEnabled) { @@ -196,7 +253,10 @@ class OtherSessionsFragment : ) ) views.otherSessionsNotFoundTextView.text = getString(R.string.device_manager_other_sessions_no_verified_sessions_found) - updateSecurityLearnMoreButton(R.string.device_manager_learn_more_sessions_verified_title, R.string.device_manager_learn_more_sessions_verified) + updateSecurityLearnMoreButton( + R.string.device_manager_learn_more_sessions_verified_title, + R.string.device_manager_learn_more_sessions_verified_description + ) } DeviceManagerFilterType.UNVERIFIED -> { views.otherSessionsSecurityRecommendationView.render( @@ -283,4 +343,37 @@ class OtherSessionsFragment : override fun onViewAllOtherSessionsClicked() { // NOOP. We don't have this button in this screen } + + private val reAuthActivityResultLauncher = registerStartForActivityResult { activityResult -> + if (activityResult.resultCode == Activity.RESULT_OK) { + when (activityResult.data?.extras?.getString(ReAuthActivity.RESULT_FLOW_TYPE)) { + LoginFlowTypes.SSO -> { + viewModel.handle(OtherSessionsAction.SsoAuthDone) + } + LoginFlowTypes.PASSWORD -> { + val password = activityResult.data?.extras?.getString(ReAuthActivity.RESULT_VALUE) ?: "" + viewModel.handle(OtherSessionsAction.PasswordAuthDone(password)) + } + else -> { + viewModel.handle(OtherSessionsAction.ReAuthCancelled) + } + } + } else { + viewModel.handle(OtherSessionsAction.ReAuthCancelled) + } + } + + /** + * Launch the re auth activity to get credentials. + */ + private fun askForReAuthentication(reAuthReq: OtherSessionsViewEvents.RequestReAuth) { + ReAuthActivity.newIntent( + requireContext(), + reAuthReq.registrationFlowResponse, + reAuthReq.lastErrorCode, + getString(R.string.devices_delete_dialog_title) + ).let { intent -> + reAuthActivityResultLauncher.launch(intent) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewEvents.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewEvents.kt index 95f9c72b33..55753e35be 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewEvents.kt @@ -17,8 +17,14 @@ package im.vector.app.features.settings.devices.v2.othersessions import im.vector.app.core.platform.VectorViewEvents +import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse sealed class OtherSessionsViewEvents : VectorViewEvents { - data class Loading(val message: CharSequence? = null) : OtherSessionsViewEvents() - data class Failure(val throwable: Throwable) : OtherSessionsViewEvents() + data class RequestReAuth( + val registrationFlowResponse: RegistrationFlowResponse, + val lastErrorCode: String? + ) : OtherSessionsViewEvents() + + object SignoutSuccess : OtherSessionsViewEvents() + data class SignoutError(val error: Throwable) : OtherSessionsViewEvents() } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt index 2cd0c6af66..9b4c26ee4f 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt @@ -24,16 +24,24 @@ import dagger.assisted.AssistedInject import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.features.auth.PendingAuthHandler import im.vector.app.features.settings.devices.v2.GetDeviceFullInfoListUseCase import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase import im.vector.app.features.settings.devices.v2.VectorSessionsListViewModel import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType +import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsReAuthNeeded +import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsUseCase import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth +import timber.log.Timber class OtherSessionsViewModel @AssistedInject constructor( @Assisted private val initialState: OtherSessionsViewState, activeSessionHolder: ActiveSessionHolder, private val getDeviceFullInfoListUseCase: GetDeviceFullInfoListUseCase, + private val signoutSessionsUseCase: SignoutSessionsUseCase, + private val pendingAuthHandler: PendingAuthHandler, refreshDevicesUseCase: RefreshDevicesUseCase ) : VectorSessionsListViewModel( initialState, activeSessionHolder, refreshDevicesUseCase @@ -67,12 +75,16 @@ class OtherSessionsViewModel @AssistedInject constructor( override fun handle(action: OtherSessionsAction) { when (action) { + is OtherSessionsAction.PasswordAuthDone -> handlePasswordAuthDone(action) + OtherSessionsAction.ReAuthCancelled -> handleReAuthCancelled() + OtherSessionsAction.SsoAuthDone -> handleSsoAuthDone() is OtherSessionsAction.FilterDevices -> handleFilterDevices(action) OtherSessionsAction.DisableSelectMode -> handleDisableSelectMode() is OtherSessionsAction.EnableSelectMode -> handleEnableSelectMode(action.deviceId) is OtherSessionsAction.ToggleSelectionForDevice -> handleToggleSelectionForDevice(action.deviceId) OtherSessionsAction.DeselectAll -> handleDeselectAll() OtherSessionsAction.SelectAll -> handleSelectAll() + OtherSessionsAction.MultiSignout -> handleMultiSignout() } } @@ -142,4 +154,67 @@ class OtherSessionsViewModel @AssistedInject constructor( ) } } + + private fun handleMultiSignout() = withState { state -> + viewModelScope.launch { + setLoading(true) + val deviceIds = getDeviceIdsToSignout(state) + if (deviceIds.isEmpty()) { + return@launch + } + val result = signout(deviceIds) + setLoading(false) + + val error = result.exceptionOrNull() + if (error == null) { + onSignoutSuccess() + } else { + onSignoutFailure(error) + } + } + } + + private fun getDeviceIdsToSignout(state: OtherSessionsViewState): List { + return if (state.isSelectModeEnabled) { + state.devices()?.filter { it.isSelected }.orEmpty() + } else { + state.devices().orEmpty() + }.mapNotNull { it.deviceInfo.deviceId } + } + + private suspend fun signout(deviceIds: List) = signoutSessionsUseCase.execute(deviceIds, this::onReAuthNeeded) + + private fun onReAuthNeeded(reAuthNeeded: SignoutSessionsReAuthNeeded) { + Timber.d("onReAuthNeeded") + pendingAuthHandler.pendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session) + pendingAuthHandler.uiaContinuation = reAuthNeeded.uiaContinuation + _viewEvents.post(OtherSessionsViewEvents.RequestReAuth(reAuthNeeded.flowResponse, reAuthNeeded.errCode)) + } + + private fun setLoading(isLoading: Boolean) { + setState { copy(isLoading = isLoading) } + } + + private fun onSignoutSuccess() { + Timber.d("signout success") + refreshDeviceList() + _viewEvents.post(OtherSessionsViewEvents.SignoutSuccess) + } + + private fun onSignoutFailure(failure: Throwable) { + Timber.e("signout failure", failure) + _viewEvents.post(OtherSessionsViewEvents.SignoutError(failure)) + } + + private fun handleSsoAuthDone() { + pendingAuthHandler.ssoAuthDone() + } + + private fun handlePasswordAuthDone(action: OtherSessionsAction.PasswordAuthDone) { + pendingAuthHandler.passwordAuthDone(action.password) + } + + private fun handleReAuthCancelled() { + pendingAuthHandler.reAuthCancelled() + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt index 0db3c8cd0e..c0b50fded8 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt @@ -27,6 +27,7 @@ data class OtherSessionsViewState( val currentFilter: DeviceManagerFilterType = DeviceManagerFilterType.ALL_SESSIONS, val excludeCurrentDevice: Boolean = false, val isSelectModeEnabled: Boolean = false, + val isLoading: Boolean = false, ) : MavericksState { constructor(args: OtherSessionsArgs) : this(excludeCurrentDevice = args.excludeCurrentDevice) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt index a1cd7ea586..d722cda7a1 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt @@ -24,11 +24,11 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.isGone import androidx.core.view.isVisible import com.airbnb.mvrx.Success import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState -import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.date.VectorDateFormatter @@ -43,6 +43,8 @@ import im.vector.app.features.auth.ReAuthActivity import im.vector.app.features.crypto.recover.SetupMode import im.vector.app.features.settings.devices.v2.list.SessionInfoViewState import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreBottomSheet +import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus +import im.vector.app.features.settings.devices.v2.signout.BuildConfirmSignoutDialogUseCase import im.vector.app.features.workers.signout.SignOutUiWorker import org.matrix.android.sdk.api.auth.data.LoginFlowTypes import org.matrix.android.sdk.api.extensions.orFalse @@ -67,6 +69,8 @@ class SessionOverviewFragment : @Inject lateinit var stringProvider: StringProvider + @Inject lateinit var buildConfirmSignoutDialogUseCase: BuildConfirmSignoutDialogUseCase + private val viewModel: SessionOverviewViewModel by fragmentViewModel() override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSessionOverviewBinding { @@ -132,13 +136,7 @@ class SessionOverviewFragment : private fun confirmSignoutOtherSession() { activity?.let { - MaterialAlertDialogBuilder(it) - .setTitle(R.string.action_sign_out) - .setMessage(R.string.action_sign_out_confirmation_simple) - .setPositiveButton(R.string.action_sign_out) { _, _ -> - signoutSession() - } - .setNegativeButton(R.string.action_cancel, null) + buildConfirmSignoutDialogUseCase.execute(it, this::signoutSession) .show() } } @@ -177,7 +175,7 @@ class SessionOverviewFragment : updateEntryDetails(state.deviceId) updateSessionInfo(state) updateLoading(state.isLoading) - updatePushNotificationToggle(state.deviceId, state.notificationsEnabled) + updatePushNotificationToggle(state.deviceId, state.notificationsStatus) } private fun updateToolbar(viewState: SessionOverviewViewState) { @@ -218,15 +216,19 @@ class SessionOverviewFragment : } } - private fun updatePushNotificationToggle(deviceId: String, enabled: Boolean) { - views.sessionOverviewPushNotifications.apply { - setOnCheckedChangeListener(null) - setChecked(enabled) - post { - setOnCheckedChangeListener { _, isChecked -> - viewModel.handle(SessionOverviewAction.TogglePushNotifications(deviceId, isChecked)) + private fun updatePushNotificationToggle(deviceId: String, notificationsStatus: NotificationsStatus) { + views.sessionOverviewPushNotifications.isGone = notificationsStatus == NotificationsStatus.NOT_SUPPORTED + when (notificationsStatus) { + NotificationsStatus.ENABLED, NotificationsStatus.DISABLED -> { + views.sessionOverviewPushNotifications.setOnCheckedChangeListener(null) + views.sessionOverviewPushNotifications.setChecked(notificationsStatus == NotificationsStatus.ENABLED) + views.sessionOverviewPushNotifications.post { + views.sessionOverviewPushNotifications.setOnCheckedChangeListener { _, isChecked -> + viewModel.handle(SessionOverviewAction.TogglePushNotifications(deviceId, isChecked)) + } } } + else -> Unit } } @@ -278,7 +280,7 @@ class SessionOverviewFragment : R.string.device_manager_verification_status_unverified } val descriptionResId = if (isVerified) { - R.string.device_manager_learn_more_sessions_verified + R.string.device_manager_learn_more_sessions_verified_description } else { R.string.device_manager_learn_more_sessions_unverified } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt index 21054270f8..a56872e648 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt @@ -21,50 +21,38 @@ import com.airbnb.mvrx.Success import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory -import im.vector.app.core.resources.StringProvider import im.vector.app.features.auth.PendingAuthHandler import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase import im.vector.app.features.settings.devices.v2.VectorSessionsListViewModel +import im.vector.app.features.settings.devices.v2.notification.GetNotificationsStatusUseCase +import im.vector.app.features.settings.devices.v2.notification.TogglePushNotificationUseCase import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase -import im.vector.app.features.settings.devices.v2.signout.SignoutSessionResult -import im.vector.app.features.settings.devices.v2.signout.SignoutSessionUseCase +import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsReAuthNeeded +import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsUseCase import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import org.matrix.android.sdk.api.account.LocalNotificationSettingsContent -import org.matrix.android.sdk.api.auth.UIABaseAuth -import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor -import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse import org.matrix.android.sdk.api.extensions.orFalse -import org.matrix.android.sdk.api.failure.Failure -import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel -import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth -import org.matrix.android.sdk.flow.flow -import org.matrix.android.sdk.flow.unwrap import timber.log.Timber -import javax.net.ssl.HttpsURLConnection -import kotlin.coroutines.Continuation class SessionOverviewViewModel @AssistedInject constructor( @Assisted val initialState: SessionOverviewViewState, - private val stringProvider: StringProvider, private val getDeviceFullInfoUseCase: GetDeviceFullInfoUseCase, private val checkIfCurrentSessionCanBeVerifiedUseCase: CheckIfCurrentSessionCanBeVerifiedUseCase, - private val signoutSessionUseCase: SignoutSessionUseCase, + private val signoutSessionsUseCase: SignoutSessionsUseCase, private val interceptSignoutFlowResponseUseCase: InterceptSignoutFlowResponseUseCase, private val pendingAuthHandler: PendingAuthHandler, private val activeSessionHolder: ActiveSessionHolder, private val togglePushNotificationUseCase: TogglePushNotificationUseCase, + private val getNotificationsStatusUseCase: GetNotificationsStatusUseCase, refreshDevicesUseCase: RefreshDevicesUseCase, ) : VectorSessionsListViewModel( initialState, activeSessionHolder, refreshDevicesUseCase @@ -81,7 +69,7 @@ class SessionOverviewViewModel @AssistedInject constructor( refreshPushers() observeSessionInfo(initialState.deviceId) observeCurrentSessionInfo() - observePushers(initialState.deviceId) + observeNotificationsStatus(initialState.deviceId) } private fun refreshPushers() { @@ -107,21 +95,12 @@ class SessionOverviewViewModel @AssistedInject constructor( } } - private fun observePushers(deviceId: String) { - val session = activeSessionHolder.getSafeActiveSession() ?: return - val pusherFlow = session.flow() - .livePushers() - .map { it.filter { pusher -> pusher.deviceId == deviceId } } - .map { it.takeIf { it.isNotEmpty() }?.any { pusher -> pusher.enabled } } - - val accountDataFlow = session.flow() - .liveUserAccountData(TYPE_LOCAL_NOTIFICATION_SETTINGS + deviceId) - .unwrap() - .map { it.content.toModel()?.isSilenced?.not() } - - merge(pusherFlow, accountDataFlow) - .onEach { it?.let { setState { copy(notificationsEnabled = it) } } } - .launchIn(viewModelScope) + private fun observeNotificationsStatus(deviceId: String) { + activeSessionHolder.getSafeActiveSession()?.let { session -> + getNotificationsStatusUseCase.execute(session, deviceId) + .onEach { setState { copy(notificationsStatus = it) } } + .launchIn(viewModelScope) + } } override fun handle(action: SessionOverviewAction) { @@ -168,30 +147,21 @@ class SessionOverviewViewModel @AssistedInject constructor( private fun handleSignoutOtherSession(deviceId: String) { viewModelScope.launch { setLoading(true) - val signoutResult = signout(deviceId) + val result = signout(deviceId) setLoading(false) - if (signoutResult.isSuccess) { + val error = result.exceptionOrNull() + if (error == null) { onSignoutSuccess() } else { - when (val failure = signoutResult.exceptionOrNull()) { - null -> onSignoutSuccess() - else -> onSignoutFailure(failure) - } + onSignoutFailure(error) } } } - private suspend fun signout(deviceId: String) = signoutSessionUseCase.execute(deviceId, object : UserInteractiveAuthInterceptor { - override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation) { - when (val result = interceptSignoutFlowResponseUseCase.execute(flowResponse, errCode, promise)) { - is SignoutSessionResult.ReAuthNeeded -> onReAuthNeeded(result) - is SignoutSessionResult.Completed -> Unit - } - } - }) + private suspend fun signout(deviceId: String) = signoutSessionsUseCase.execute(listOf(deviceId), this::onReAuthNeeded) - private fun onReAuthNeeded(reAuthNeeded: SignoutSessionResult.ReAuthNeeded) { + private fun onReAuthNeeded(reAuthNeeded: SignoutSessionsReAuthNeeded) { Timber.d("onReAuthNeeded") pendingAuthHandler.pendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session) pendingAuthHandler.uiaContinuation = reAuthNeeded.uiaContinuation @@ -210,12 +180,7 @@ class SessionOverviewViewModel @AssistedInject constructor( private fun onSignoutFailure(failure: Throwable) { Timber.e("signout failure", failure) - val failureMessage = if (failure is Failure.OtherServerError && failure.httpCode == HttpsURLConnection.HTTP_UNAUTHORIZED) { - stringProvider.getString(R.string.authentication_error) - } else { - stringProvider.getString(R.string.matrix_error) - } - _viewEvents.post(SessionOverviewViewEvent.SignoutError(Exception(failureMessage))) + _viewEvents.post(SessionOverviewViewEvent.SignoutError(failure)) } private fun handleSsoAuthDone() { @@ -233,7 +198,6 @@ class SessionOverviewViewModel @AssistedInject constructor( private fun handleTogglePusherAction(action: SessionOverviewAction.TogglePushNotifications) { viewModelScope.launch { togglePushNotificationUseCase.execute(action.deviceId, action.enabled) - setState { copy(notificationsEnabled = action.enabled) } } } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewState.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewState.kt index 440805bad6..019dd2d724 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewState.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewState.kt @@ -20,13 +20,14 @@ import com.airbnb.mvrx.Async import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.Uninitialized import im.vector.app.features.settings.devices.v2.DeviceFullInfo +import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus data class SessionOverviewViewState( val deviceId: String, val isCurrentSessionTrusted: Boolean = false, val deviceInfo: Async = Uninitialized, val isLoading: Boolean = false, - val notificationsEnabled: Boolean = false, + val notificationsStatus: NotificationsStatus = NotificationsStatus.NOT_SUPPORTED, ) : MavericksState { constructor(args: SessionOverviewArgs) : this( deviceId = args.deviceId diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/BuildConfirmSignoutDialogUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/BuildConfirmSignoutDialogUseCase.kt new file mode 100644 index 0000000000..4edfc2febe --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/BuildConfirmSignoutDialogUseCase.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2.signout + +import android.content.Context +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import im.vector.app.R +import javax.inject.Inject + +class BuildConfirmSignoutDialogUseCase @Inject constructor() { + + fun execute(context: Context, onConfirm: () -> Unit) = + MaterialAlertDialogBuilder(context) + .setTitle(R.string.action_sign_out) + .setMessage(R.string.action_sign_out_confirmation_simple) + .setPositiveButton(R.string.action_sign_out) { _, _ -> + onConfirm() + } + .setNegativeButton(R.string.action_cancel, null) + .create() +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCase.kt index 4316995272..42ebd7782e 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCase.kt @@ -37,17 +37,16 @@ class InterceptSignoutFlowResponseUseCase @Inject constructor( flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation - ): SignoutSessionResult { + ): SignoutSessionsReAuthNeeded? { return if (flowResponse.nextUncompletedStage() == LoginFlowTypes.PASSWORD && reAuthHelper.data != null && errCode == null) { UserPasswordAuth( session = null, user = activeSessionHolder.getActiveSession().myUserId, password = reAuthHelper.data ).let { promise.resume(it) } - - SignoutSessionResult.Completed + null } else { - SignoutSessionResult.ReAuthNeeded( + SignoutSessionsReAuthNeeded( pendingAuth = DefaultBaseAuth(session = flowResponse.session), uiaContinuation = promise, flowResponse = flowResponse, diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionUseCase.kt deleted file mode 100644 index 60ca8e91c6..0000000000 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionUseCase.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.app.features.settings.devices.v2.signout - -import im.vector.app.core.di.ActiveSessionHolder -import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor -import org.matrix.android.sdk.api.util.awaitCallback -import javax.inject.Inject - -class SignoutSessionUseCase @Inject constructor( - private val activeSessionHolder: ActiveSessionHolder, -) { - - suspend fun execute(deviceId: String, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor): Result { - return deleteDevice(deviceId, userInteractiveAuthInterceptor) - } - - private suspend fun deleteDevice(deviceId: String, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor) = runCatching { - awaitCallback { matrixCallback -> - activeSessionHolder.getActiveSession() - .cryptoService() - .deleteDevice(deviceId, userInteractiveAuthInterceptor, matrixCallback) - } - } -} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionResult.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsReAuthNeeded.kt similarity index 71% rename from vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionResult.kt rename to vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsReAuthNeeded.kt index fa1fb31b66..56e3d17686 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionResult.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsReAuthNeeded.kt @@ -20,13 +20,9 @@ import org.matrix.android.sdk.api.auth.UIABaseAuth import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse import kotlin.coroutines.Continuation -sealed class SignoutSessionResult { - data class ReAuthNeeded( - val pendingAuth: UIABaseAuth, - val uiaContinuation: Continuation, - val flowResponse: RegistrationFlowResponse, - val errCode: String? - ) : SignoutSessionResult() - - object Completed : SignoutSessionResult() -} +data class SignoutSessionsReAuthNeeded( + val pendingAuth: UIABaseAuth, + val uiaContinuation: Continuation, + val flowResponse: RegistrationFlowResponse, + val errCode: String? +) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsUseCase.kt new file mode 100644 index 0000000000..1cf713a711 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsUseCase.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2.signout + +import androidx.annotation.Size +import im.vector.app.core.di.ActiveSessionHolder +import org.matrix.android.sdk.api.auth.UIABaseAuth +import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor +import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse +import org.matrix.android.sdk.api.util.awaitCallback +import timber.log.Timber +import javax.inject.Inject +import kotlin.coroutines.Continuation + +class SignoutSessionsUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, + private val interceptSignoutFlowResponseUseCase: InterceptSignoutFlowResponseUseCase, +) { + + suspend fun execute( + @Size(min = 1) deviceIds: List, + onReAuthNeeded: (SignoutSessionsReAuthNeeded) -> Unit, + ): Result = runCatching { + Timber.d("start execute with ${deviceIds.size} deviceIds") + + val authInterceptor = object : UserInteractiveAuthInterceptor { + override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation) { + val result = interceptSignoutFlowResponseUseCase.execute(flowResponse, errCode, promise) + result?.let(onReAuthNeeded) + } + } + + deleteDevices(deviceIds, authInterceptor) + Timber.d("end execute") + } + + private suspend fun deleteDevices(deviceIds: List, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor) = + awaitCallback { matrixCallback -> + activeSessionHolder.getActiveSession() + .cryptoService() + .deleteDevices(deviceIds, userInteractiveAuthInterceptor, matrixCallback) + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/notifications/DisableNotificationsForCurrentSessionUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/notifications/DisableNotificationsForCurrentSessionUseCase.kt new file mode 100644 index 0000000000..61c884f0bc --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/notifications/DisableNotificationsForCurrentSessionUseCase.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.notifications + +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.pushers.PushersManager +import im.vector.app.core.pushers.UnifiedPushHelper +import im.vector.app.features.settings.devices.v2.notification.CheckIfCanTogglePushNotificationsViaPusherUseCase +import im.vector.app.features.settings.devices.v2.notification.TogglePushNotificationUseCase +import javax.inject.Inject + +class DisableNotificationsForCurrentSessionUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, + private val unifiedPushHelper: UnifiedPushHelper, + private val pushersManager: PushersManager, + private val checkIfCanTogglePushNotificationsViaPusherUseCase: CheckIfCanTogglePushNotificationsViaPusherUseCase, + private val togglePushNotificationUseCase: TogglePushNotificationUseCase, +) { + + suspend fun execute() { + val session = activeSessionHolder.getSafeActiveSession() ?: return + val deviceId = session.sessionParams.deviceId ?: return + if (checkIfCanTogglePushNotificationsViaPusherUseCase.execute(session)) { + togglePushNotificationUseCase.execute(deviceId, enabled = false) + } else { + unifiedPushHelper.unregister(pushersManager) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/notifications/EnableNotificationsForCurrentSessionUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/notifications/EnableNotificationsForCurrentSessionUseCase.kt new file mode 100644 index 0000000000..180627a15f --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/notifications/EnableNotificationsForCurrentSessionUseCase.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.notifications + +import androidx.fragment.app.FragmentActivity +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.pushers.FcmHelper +import im.vector.app.core.pushers.PushersManager +import im.vector.app.core.pushers.UnifiedPushHelper +import im.vector.app.features.settings.devices.v2.notification.CheckIfCanTogglePushNotificationsViaPusherUseCase +import im.vector.app.features.settings.devices.v2.notification.TogglePushNotificationUseCase +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +class EnableNotificationsForCurrentSessionUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, + private val unifiedPushHelper: UnifiedPushHelper, + private val pushersManager: PushersManager, + private val fcmHelper: FcmHelper, + private val checkIfCanTogglePushNotificationsViaPusherUseCase: CheckIfCanTogglePushNotificationsViaPusherUseCase, + private val togglePushNotificationUseCase: TogglePushNotificationUseCase, +) { + + suspend fun execute(fragmentActivity: FragmentActivity) { + val pusherForCurrentSession = pushersManager.getPusherForCurrentSession() + if (pusherForCurrentSession == null) { + registerPusher(fragmentActivity) + } + + val session = activeSessionHolder.getSafeActiveSession() ?: return + if (checkIfCanTogglePushNotificationsViaPusherUseCase.execute(session)) { + val deviceId = session.sessionParams.deviceId ?: return + togglePushNotificationUseCase.execute(deviceId, enabled = true) + } + } + + private suspend fun registerPusher(fragmentActivity: FragmentActivity) { + suspendCoroutine { continuation -> + try { + unifiedPushHelper.register(fragmentActivity) { + if (unifiedPushHelper.isEmbeddedDistributor()) { + fcmHelper.ensureFcmTokenIsRetrieved( + fragmentActivity, + pushersManager, + registerPusher = true + ) + } + continuation.resume(Unit) + } + } catch (error: Exception) { + continuation.resumeWithException(error) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationPreferenceFragment.kt b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationPreferenceFragment.kt index f800c518f3..58f86bc949 100644 --- a/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationPreferenceFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationPreferenceFragment.kt @@ -57,7 +57,6 @@ import im.vector.app.features.settings.VectorSettingsBaseFragment import im.vector.app.features.settings.VectorSettingsFragmentInteractionListener import im.vector.lib.core.utils.compat.getParcelableExtraCompat import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.Session @@ -81,6 +80,8 @@ class VectorSettingsNotificationPreferenceFragment : @Inject lateinit var guardServiceStarter: GuardServiceStarter @Inject lateinit var vectorFeatures: VectorFeatures @Inject lateinit var notificationPermissionManager: NotificationPermissionManager + @Inject lateinit var disableNotificationsForCurrentSessionUseCase: DisableNotificationsForCurrentSessionUseCase + @Inject lateinit var enableNotificationsForCurrentSessionUseCase: EnableNotificationsForCurrentSessionUseCase override var titleRes: Int = R.string.settings_notifications override val preferenceXmlRes = R.xml.vector_settings_notifications @@ -119,48 +120,25 @@ class VectorSettingsNotificationPreferenceFragment : (pref as SwitchPreference).isChecked = areNotifEnabledAtAccountLevel } - findPreference(VectorPreferences.SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY)?.let { - pushersManager.getPusherForCurrentSession()?.let { pusher -> - it.isChecked = pusher.enabled - } + findPreference(VectorPreferences.SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY) + ?.setTransactionalSwitchChangeListener(lifecycleScope) { isChecked -> + if (isChecked) { + enableNotificationsForCurrentSessionUseCase.execute(requireActivity()) - it.setTransactionalSwitchChangeListener(lifecycleScope) { isChecked -> - if (isChecked) { - unifiedPushHelper.register(requireActivity()) { - // Update the summary - if (unifiedPushHelper.isEmbeddedDistributor()) { - fcmHelper.ensureFcmTokenIsRetrieved( - requireActivity(), - pushersManager, - vectorPreferences.areNotificationEnabledForDevice() - ) - } findPreference(VectorPreferences.SETTINGS_NOTIFICATION_METHOD_KEY) ?.summary = unifiedPushHelper.getCurrentDistributorName() - lifecycleScope.launch { - val result = runCatching { - pushersManager.togglePusherForCurrentSession(true) - } - result.exceptionOrNull()?.let { _ -> - Toast.makeText(context, R.string.error_check_network, Toast.LENGTH_SHORT).show() - it.isChecked = false - } - } + notificationPermissionManager.eventuallyRequestPermission( + requireActivity(), + postPermissionLauncher, + showRationale = false, + ignorePreference = true + ) + } else { + disableNotificationsForCurrentSessionUseCase.execute() + notificationPermissionManager.eventuallyRevokePermission(requireActivity()) } - notificationPermissionManager.eventuallyRequestPermission( - requireActivity(), - postPermissionLauncher, - showRationale = false, - ignorePreference = true - ) - } else { - unifiedPushHelper.unregister(pushersManager) - session.pushersService().refreshPushers() - notificationPermissionManager.eventuallyRevokePermission(requireActivity()) } - } - } findPreference(VectorPreferences.SETTINGS_FDROID_BACKGROUND_SYNC_MODE)?.let { it.onPreferenceClickListener = Preference.OnPreferenceClickListener { diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastConstants.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastConstants.kt index 551eaa4dac..11b4f50d2f 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastConstants.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastConstants.kt @@ -28,4 +28,7 @@ object VoiceBroadcastConstants { /** Default voice broadcast chunk duration, in seconds. */ const val DEFAULT_CHUNK_LENGTH_IN_SECONDS = 120 + + /** Maximum length of the voice broadcast in seconds. */ + const val MAX_VOICE_BROADCAST_LENGTH_IN_SECONDS = 14_400 // 4 hours } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastExtensions.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastExtensions.kt index f9da2e76b1..6faec5a262 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastExtensions.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastExtensions.kt @@ -16,7 +16,11 @@ package im.vector.app.features.voicebroadcast +import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastChunk +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.events.model.getRelationContent import org.matrix.android.sdk.api.session.events.model.toModel @@ -32,3 +36,14 @@ fun MessageAudioEvent.getVoiceBroadcastChunk(): VoiceBroadcastChunk? { } val MessageAudioEvent.sequence: Int? get() = getVoiceBroadcastChunk()?.sequence + +val MessageAudioEvent.duration get() = content.audioInfo?.duration ?: content.audioWaveformInfo?.duration ?: 0 + +val VoiceBroadcastEvent.voiceBroadcastId + get() = reference?.eventId + +val VoiceBroadcastEvent.isLive + get() = content?.isLive.orFalse() + +val MessageVoiceBroadcastInfoContent.isLive + get() = voiceBroadcastState != null && voiceBroadcastState != VoiceBroadcastState.STOPPED diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastFailure.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastFailure.kt new file mode 100644 index 0000000000..76b50c78ab --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastFailure.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.voicebroadcast + +sealed class VoiceBroadcastFailure : Throwable() { + sealed class RecordingError : VoiceBroadcastFailure() { + object NoPermission : RecordingError() + object BlockedBySomeoneElse : RecordingError() + object UserAlreadyBroadcasting : RecordingError() + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt index 58e7de7f32..38fb157748 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt @@ -16,10 +16,12 @@ package im.vector.app.features.voicebroadcast -import im.vector.app.features.voicebroadcast.usecase.PauseVoiceBroadcastUseCase -import im.vector.app.features.voicebroadcast.usecase.ResumeVoiceBroadcastUseCase -import im.vector.app.features.voicebroadcast.usecase.StartVoiceBroadcastUseCase -import im.vector.app.features.voicebroadcast.usecase.StopVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast +import im.vector.app.features.voicebroadcast.recording.usecase.PauseVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.recording.usecase.ResumeVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.recording.usecase.StartVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.recording.usecase.StopVoiceBroadcastUseCase import javax.inject.Inject /** @@ -40,9 +42,13 @@ class VoiceBroadcastHelper @Inject constructor( suspend fun stopVoiceBroadcast(roomId: String) = stopVoiceBroadcastUseCase.execute(roomId) - fun playOrResumePlayback(roomId: String, eventId: String) = voiceBroadcastPlayer.playOrResume(roomId, eventId) + fun playOrResumePlayback(voiceBroadcast: VoiceBroadcast) = voiceBroadcastPlayer.playOrResume(voiceBroadcast) fun pausePlayback() = voiceBroadcastPlayer.pause() fun stopPlayback() = voiceBroadcastPlayer.stop() + + fun seekTo(voiceBroadcast: VoiceBroadcast, positionMillis: Int, duration: Int) { + voiceBroadcastPlayer.seekTo(voiceBroadcast, positionMillis, duration) + } } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt deleted file mode 100644 index 2c892c8306..0000000000 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt +++ /dev/null @@ -1,338 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.app.features.voicebroadcast - -import android.media.AudioAttributes -import android.media.MediaPlayer -import androidx.annotation.MainThread -import im.vector.app.core.di.ActiveSessionHolder -import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker -import im.vector.app.features.voice.VoiceFailure -import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState -import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent -import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastUseCase -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.matrix.android.sdk.api.session.events.model.RelationType -import org.matrix.android.sdk.api.session.getRoom -import org.matrix.android.sdk.api.session.room.Room -import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent -import org.matrix.android.sdk.api.session.room.model.message.MessageAudioEvent -import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent -import org.matrix.android.sdk.api.session.room.timeline.Timeline -import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent -import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings -import timber.log.Timber -import java.util.concurrent.CopyOnWriteArrayList -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class VoiceBroadcastPlayer @Inject constructor( - private val sessionHolder: ActiveSessionHolder, - private val playbackTracker: AudioMessagePlaybackTracker, - private val getVoiceBroadcastUseCase: GetVoiceBroadcastUseCase, -) { - private val session - get() = sessionHolder.getActiveSession() - - private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private var voiceBroadcastStateJob: Job? = null - private var currentTimeline: Timeline? = null - set(value) { - field?.removeAllListeners() - field?.dispose() - field = value - } - - private val mediaPlayerListener = MediaPlayerListener() - private var timelineListener: TimelineListener? = null - - private var currentMediaPlayer: MediaPlayer? = null - private var nextMediaPlayer: MediaPlayer? = null - set(value) { - field = value - currentMediaPlayer?.setNextMediaPlayer(value) - } - private var currentSequence: Int? = null - - private var playlist = emptyList() - var currentVoiceBroadcastId: String? = null - - private var state: State = State.IDLE - @MainThread - set(value) { - Timber.w("## VoiceBroadcastPlayer state: $field -> $value") - field = value - listeners.forEach { it.onStateChanged(value) } - } - private var currentRoomId: String? = null - private var listeners = CopyOnWriteArrayList() - - fun playOrResume(roomId: String, eventId: String) { - val hasChanged = currentVoiceBroadcastId != eventId - when { - hasChanged -> startPlayback(roomId, eventId) - state == State.PAUSED -> resumePlayback() - else -> Unit - } - } - - fun pause() { - currentMediaPlayer?.pause() - currentVoiceBroadcastId?.let { playbackTracker.pausePlayback(it) } - state = State.PAUSED - } - - fun stop() { - // Stop playback - currentMediaPlayer?.stop() - currentVoiceBroadcastId?.let { playbackTracker.stopPlayback(it) } - - // Release current player - release(currentMediaPlayer) - currentMediaPlayer = null - - // Release next player - release(nextMediaPlayer) - nextMediaPlayer = null - - // Do not observe anymore voice broadcast state changes - voiceBroadcastStateJob?.cancel() - voiceBroadcastStateJob = null - - // In case of live broadcast, stop observing new chunks - currentTimeline = null - timelineListener = null - - // Update state - state = State.IDLE - - // Clear playlist - playlist = emptyList() - currentSequence = null - currentRoomId = null - currentVoiceBroadcastId = null - } - - fun addListener(listener: Listener) { - listeners.add(listener) - listener.onStateChanged(state) - } - - fun removeListener(listener: Listener) { - listeners.remove(listener) - } - - private fun startPlayback(roomId: String, eventId: String) { - val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") - // Stop listening previous voice broadcast if any - if (state != State.IDLE) stop() - - currentRoomId = roomId - currentVoiceBroadcastId = eventId - - state = State.BUFFERING - - val voiceBroadcastState = getVoiceBroadcastUseCase.execute(roomId, eventId)?.content?.voiceBroadcastState - if (voiceBroadcastState == VoiceBroadcastState.STOPPED) { - // Get static playlist - updatePlaylist(getExistingChunks(room, eventId)) - startPlayback(false) - } else { - playLiveVoiceBroadcast(room, eventId) - } - } - - private fun startPlayback(isLive: Boolean) { - val event = if (isLive) playlist.lastOrNull() else playlist.firstOrNull() - val content = event?.content ?: run { Timber.w("## VoiceBroadcastPlayer: No content to play"); return } - val sequence = event.getVoiceBroadcastChunk()?.sequence - coroutineScope.launch { - try { - currentMediaPlayer = prepareMediaPlayer(content) - currentMediaPlayer?.start() - currentVoiceBroadcastId?.let { playbackTracker.startPlayback(it) } - currentSequence = sequence - withContext(Dispatchers.Main) { state = State.PLAYING } - nextMediaPlayer = prepareNextMediaPlayer() - } catch (failure: Throwable) { - Timber.e(failure, "Unable to start playback") - throw VoiceFailure.UnableToPlay(failure) - } - } - } - - private fun playLiveVoiceBroadcast(room: Room, eventId: String) { - room.timelineService().getTimelineEvent(eventId)?.root?.asVoiceBroadcastEvent() ?: error("Cannot retrieve voice broadcast $eventId") - updatePlaylist(getExistingChunks(room, eventId)) - startPlayback(true) - observeIncomingEvents(room, eventId) - } - - private fun getExistingChunks(room: Room, eventId: String): List { - return room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, eventId) - .mapNotNull { it.root.asMessageAudioEvent() } - .filter { it.isVoiceBroadcast() } - } - - private fun observeIncomingEvents(room: Room, eventId: String) { - currentTimeline = room.timelineService().createTimeline(null, TimelineSettings(5)).also { timeline -> - timelineListener = TimelineListener(eventId).also { timeline.addListener(it) } - timeline.start() - } - } - - private fun resumePlayback() { - currentMediaPlayer?.start() - currentVoiceBroadcastId?.let { playbackTracker.startPlayback(it) } - state = State.PLAYING - } - - private fun updatePlaylist(playlist: List) { - this.playlist = playlist.sortedBy { it.getVoiceBroadcastChunk()?.sequence?.toLong() ?: it.root.originServerTs } - } - - private fun getNextAudioContent(): MessageAudioContent? { - val nextSequence = currentSequence?.plus(1) - ?: timelineListener?.let { playlist.lastOrNull()?.sequence } - ?: 1 - return playlist.find { it.getVoiceBroadcastChunk()?.sequence == nextSequence }?.content - } - - private suspend fun prepareNextMediaPlayer(): MediaPlayer? { - val nextContent = getNextAudioContent() ?: return null - return prepareMediaPlayer(nextContent) - } - - private suspend fun prepareMediaPlayer(messageAudioContent: MessageAudioContent): MediaPlayer { - // Download can fail - val audioFile = try { - session.fileService().downloadFile(messageAudioContent) - } catch (failure: Throwable) { - Timber.e(failure, "Unable to start playback") - throw VoiceFailure.UnableToPlay(failure) - } - - return audioFile.inputStream().use { fis -> - MediaPlayer().apply { - setAudioAttributes( - AudioAttributes.Builder() - // Do not use CONTENT_TYPE_SPEECH / USAGE_VOICE_COMMUNICATION because we want to play loud here - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .setUsage(AudioAttributes.USAGE_MEDIA) - .build() - ) - setDataSource(fis.fd) - setOnInfoListener(mediaPlayerListener) - setOnErrorListener(mediaPlayerListener) - setOnCompletionListener(mediaPlayerListener) - prepare() - } - } - } - - private fun release(mp: MediaPlayer?) { - mp?.apply { - release() - setOnInfoListener(null) - setOnCompletionListener(null) - setOnErrorListener(null) - } - } - - private inner class TimelineListener(private val voiceBroadcastId: String) : Timeline.Listener { - override fun onTimelineUpdated(snapshot: List) { - val currentSequences = playlist.map { it.sequence } - val newChunks = snapshot - .mapNotNull { timelineEvent -> - timelineEvent.root.asMessageAudioEvent() - ?.takeIf { it.isVoiceBroadcast() && it.getVoiceBroadcastEventId() == voiceBroadcastId && it.sequence !in currentSequences } - } - if (newChunks.isEmpty()) return - updatePlaylist(playlist + newChunks) - - when (state) { - State.PLAYING -> { - if (nextMediaPlayer == null) { - coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() } - } - } - State.PAUSED -> { - if (nextMediaPlayer == null) { - coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() } - } - } - State.BUFFERING -> { - val newMediaContent = getNextAudioContent() - if (newMediaContent != null) startPlayback(true) - } - State.IDLE -> startPlayback(true) - } - } - } - - private inner class MediaPlayerListener : MediaPlayer.OnInfoListener, MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener { - - override fun onInfo(mp: MediaPlayer, what: Int, extra: Int): Boolean { - when (what) { - MediaPlayer.MEDIA_INFO_STARTED_AS_NEXT -> { - release(currentMediaPlayer) - currentMediaPlayer = mp - currentSequence = currentSequence?.plus(1) - coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() } - } - } - return false - } - - override fun onCompletion(mp: MediaPlayer) { - if (nextMediaPlayer != null) return - val roomId = currentRoomId ?: return - val voiceBroadcastId = currentVoiceBroadcastId ?: return - val voiceBroadcastEventContent = getVoiceBroadcastUseCase.execute(roomId, voiceBroadcastId)?.content ?: return - val isLive = voiceBroadcastEventContent.voiceBroadcastState != null && voiceBroadcastEventContent.voiceBroadcastState != VoiceBroadcastState.STOPPED - - if (!isLive && voiceBroadcastEventContent.lastChunkSequence == currentSequence) { - // We'll not receive new chunks anymore so we can stop the live listening - stop() - } else { - state = State.BUFFERING - } - } - - override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean { - stop() - return true - } - } - - enum class State { - PLAYING, - PAUSED, - BUFFERING, - IDLE - } - - fun interface Listener { - fun onStateChanged(state: State) - } -} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayer.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayer.kt new file mode 100644 index 0000000000..0de88e9992 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayer.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.listening + +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast + +interface VoiceBroadcastPlayer { + + /** + * The current playing voice broadcast, if any. + */ + val currentVoiceBroadcast: VoiceBroadcast? + + /** + * The current playing [State], [State.IDLE] by default. + */ + val playingState: State + + /** + * Tells whether the player is listening a live voice broadcast in "live" position. + */ + val isLiveListening: Boolean + + /** + * Start playback of the given voice broadcast. + */ + fun playOrResume(voiceBroadcast: VoiceBroadcast) + + /** + * Pause playback of the current voice broadcast, if any. + */ + fun pause() + + /** + * Stop playback of the current voice broadcast, if any, and reset the player state. + */ + fun stop() + + /** + * Seek the given voice broadcast playback to the given position, is milliseconds. + */ + fun seekTo(voiceBroadcast: VoiceBroadcast, positionMillis: Int, duration: Int) + + /** + * Add a [Listener] to the given voice broadcast. + */ + fun addListener(voiceBroadcast: VoiceBroadcast, listener: Listener) + + /** + * Remove a [Listener] from the given voice broadcast. + */ + fun removeListener(voiceBroadcast: VoiceBroadcast, listener: Listener) + + /** + * Player states. + */ + enum class State { + PLAYING, + PAUSED, + BUFFERING, + IDLE + } + + /** + * Listener related to [VoiceBroadcastPlayer]. + */ + interface Listener { + /** + * Notify about [VoiceBroadcastPlayer.playingState] changes. + */ + fun onPlayingStateChanged(state: State) = Unit + + /** + * Notify about [VoiceBroadcastPlayer.isLiveListening] changes. + */ + fun onLiveModeChanged(isLive: Boolean) = Unit + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt new file mode 100644 index 0000000000..5b0e5b2b1c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt @@ -0,0 +1,469 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.listening + +import android.media.AudioAttributes +import android.media.MediaPlayer +import android.media.MediaPlayer.OnPreparedListener +import androidx.annotation.MainThread +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker +import im.vector.app.features.session.coroutineScope +import im.vector.app.features.voice.VoiceFailure +import im.vector.app.features.voicebroadcast.isLive +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.Listener +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.State +import im.vector.app.features.voicebroadcast.listening.usecase.GetLiveVoiceBroadcastChunksUseCase +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastEventUseCase +import im.vector.lib.core.utils.timer.CountUpTimer +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent +import timber.log.Timber +import java.util.concurrent.CopyOnWriteArrayList +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class VoiceBroadcastPlayerImpl @Inject constructor( + private val sessionHolder: ActiveSessionHolder, + private val playbackTracker: AudioMessagePlaybackTracker, + private val getVoiceBroadcastEventUseCase: GetVoiceBroadcastEventUseCase, + private val getLiveVoiceBroadcastChunksUseCase: GetLiveVoiceBroadcastChunksUseCase +) : VoiceBroadcastPlayer { + + private val session get() = sessionHolder.getActiveSession() + private val sessionScope get() = session.coroutineScope + + private val mediaPlayerListener = MediaPlayerListener() + private val playbackTicker = PlaybackTicker() + private val playlist = VoiceBroadcastPlaylist() + + private var fetchPlaylistTask: Job? = null + private var voiceBroadcastStateObserver: Job? = null + + private var currentMediaPlayer: MediaPlayer? = null + private var nextMediaPlayer: MediaPlayer? = null + private var isPreparingNextPlayer: Boolean = false + + private var currentVoiceBroadcastEvent: VoiceBroadcastEvent? = null + + override var currentVoiceBroadcast: VoiceBroadcast? = null + override var isLiveListening: Boolean = false + @MainThread + set(value) { + if (field != value) { + Timber.w("isLiveListening: $field -> $value") + field = value + onLiveListeningChanged(value) + } + } + + override var playingState = State.IDLE + @MainThread + set(value) { + if (field != value) { + Timber.w("playingState: $field -> $value") + field = value + onPlayingStateChanged(value) + } + } + + /** Map voiceBroadcastId to listeners. */ + private val listeners: MutableMap> = mutableMapOf() + + override fun playOrResume(voiceBroadcast: VoiceBroadcast) { + val hasChanged = currentVoiceBroadcast != voiceBroadcast + when { + hasChanged -> startPlayback(voiceBroadcast) + playingState == State.PAUSED -> resumePlayback() + else -> Unit + } + } + + override fun pause() { + pausePlayback() + } + + override fun stop() { + // Update state + playingState = State.IDLE + + // Stop and release media players + stopPlayer() + + // Do not observe anymore voice broadcast changes + fetchPlaylistTask?.cancel() + fetchPlaylistTask = null + voiceBroadcastStateObserver?.cancel() + voiceBroadcastStateObserver = null + + // Clear playlist + playlist.reset() + + currentVoiceBroadcastEvent = null + currentVoiceBroadcast = null + } + + override fun addListener(voiceBroadcast: VoiceBroadcast, listener: Listener) { + listeners[voiceBroadcast.voiceBroadcastId]?.add(listener) ?: run { + listeners[voiceBroadcast.voiceBroadcastId] = CopyOnWriteArrayList().apply { add(listener) } + } + listener.onPlayingStateChanged(if (voiceBroadcast == currentVoiceBroadcast) playingState else State.IDLE) + listener.onLiveModeChanged(voiceBroadcast == currentVoiceBroadcast && isLiveListening) + } + + override fun removeListener(voiceBroadcast: VoiceBroadcast, listener: Listener) { + listeners[voiceBroadcast.voiceBroadcastId]?.remove(listener) + } + + private fun startPlayback(voiceBroadcast: VoiceBroadcast) { + // Stop listening previous voice broadcast if any + if (playingState != State.IDLE) stop() + + currentVoiceBroadcast = voiceBroadcast + + playingState = State.BUFFERING + + observeVoiceBroadcastLiveState(voiceBroadcast) + fetchPlaylistAndStartPlayback(voiceBroadcast) + } + + private fun observeVoiceBroadcastLiveState(voiceBroadcast: VoiceBroadcast) { + voiceBroadcastStateObserver = getVoiceBroadcastEventUseCase.execute(voiceBroadcast) + .onEach { + currentVoiceBroadcastEvent = it.getOrNull() + updateLiveListeningMode() + } + .launchIn(sessionScope) + } + + private fun fetchPlaylistAndStartPlayback(voiceBroadcast: VoiceBroadcast) { + fetchPlaylistTask = getLiveVoiceBroadcastChunksUseCase.execute(voiceBroadcast) + .onEach { + playlist.setItems(it) + onPlaylistUpdated() + } + .launchIn(sessionScope) + } + + private fun onPlaylistUpdated() { + when (playingState) { + State.PLAYING -> { + if (nextMediaPlayer == null && !isPreparingNextPlayer) { + prepareNextMediaPlayer() + } + } + State.PAUSED -> { + if (nextMediaPlayer == null && !isPreparingNextPlayer) { + prepareNextMediaPlayer() + } + } + State.BUFFERING -> { + val nextItem = playlist.getNextItem() + if (nextItem != null) { + val savedPosition = currentVoiceBroadcast?.let { playbackTracker.getPlaybackTime(it.voiceBroadcastId) } + startPlayback(savedPosition?.takeIf { it > 0 }) + } + } + State.IDLE -> { + val savedPosition = currentVoiceBroadcast?.let { playbackTracker.getPlaybackTime(it.voiceBroadcastId) } + startPlayback(savedPosition?.takeIf { it > 0 }) + } + } + } + + private fun startPlayback(position: Int? = null) { + stopPlayer() + + val playlistItem = when { + position != null -> playlist.findByPosition(position) + currentVoiceBroadcastEvent?.isLive.orFalse() -> playlist.lastOrNull() + else -> playlist.firstOrNull() + } + val content = playlistItem?.audioEvent?.content ?: run { Timber.w("## VoiceBroadcastPlayer: No content to play"); return } + val sequence = playlistItem.sequence ?: run { Timber.w("## VoiceBroadcastPlayer: playlist item has no sequence"); return } + val sequencePosition = position?.let { it - playlistItem.startTime } ?: 0 + sessionScope.launch { + try { + prepareMediaPlayer(content) { mp -> + currentMediaPlayer = mp + playlist.currentSequence = sequence + mp.start() + if (sequencePosition > 0) { + mp.seekTo(sequencePosition) + } + playingState = State.PLAYING + prepareNextMediaPlayer() + } + } catch (failure: Throwable) { + Timber.e(failure, "Unable to start playback") + throw VoiceFailure.UnableToPlay(failure) + } + } + } + + private fun pausePlayback(positionMillis: Int? = null) { + if (positionMillis == null) { + currentMediaPlayer?.pause() + } else { + stopPlayer() + val voiceBroadcastId = currentVoiceBroadcast?.voiceBroadcastId + val duration = playlist.duration.takeIf { it > 0 } + if (voiceBroadcastId != null && duration != null) { + playbackTracker.updatePausedAtPlaybackTime(voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration) + } + } + playingState = State.PAUSED + } + + private fun resumePlayback() { + if (currentMediaPlayer != null) { + currentMediaPlayer?.start() + playingState = State.PLAYING + } else { + val position = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) } + startPlayback(position) + } + } + + override fun seekTo(voiceBroadcast: VoiceBroadcast, positionMillis: Int, duration: Int) { + when { + voiceBroadcast != currentVoiceBroadcast -> { + playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration) + } + playingState == State.PLAYING || playingState == State.BUFFERING -> { + updateLiveListeningMode(positionMillis) + startPlayback(positionMillis) + } + playingState == State.IDLE || playingState == State.PAUSED -> { + pausePlayback(positionMillis) + } + } + } + + private fun prepareNextMediaPlayer() { + val nextItem = playlist.getNextItem() + if (nextItem != null) { + isPreparingNextPlayer = true + sessionScope.launch { + prepareMediaPlayer(nextItem.audioEvent.content) { mp -> + nextMediaPlayer = mp + currentMediaPlayer?.setNextMediaPlayer(mp) + isPreparingNextPlayer = false + } + } + } + } + + private suspend fun prepareMediaPlayer(messageAudioContent: MessageAudioContent, onPreparedListener: OnPreparedListener): MediaPlayer { + // Download can fail + val audioFile = try { + session.fileService().downloadFile(messageAudioContent) + } catch (failure: Throwable) { + Timber.e(failure, "Unable to start playback") + throw VoiceFailure.UnableToPlay(failure) + } + + return audioFile.inputStream().use { fis -> + MediaPlayer().apply { + setAudioAttributes( + AudioAttributes.Builder() + // Do not use CONTENT_TYPE_SPEECH / USAGE_VOICE_COMMUNICATION because we want to play loud here + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + ) + setDataSource(fis.fd) + setOnInfoListener(mediaPlayerListener) + setOnErrorListener(mediaPlayerListener) + setOnPreparedListener(onPreparedListener) + setOnCompletionListener(mediaPlayerListener) + prepare() + } + } + } + + private fun stopPlayer() { + tryOrNull { currentMediaPlayer?.stop() } + currentMediaPlayer?.release() + currentMediaPlayer = null + + nextMediaPlayer?.release() + nextMediaPlayer = null + isPreparingNextPlayer = false + } + + private fun onPlayingStateChanged(playingState: State) { + // Update live playback flag + updateLiveListeningMode() + + currentVoiceBroadcast?.voiceBroadcastId?.let { voiceBroadcastId -> + // Start or stop playback ticker + when (playingState) { + State.PLAYING -> playbackTicker.startPlaybackTicker(voiceBroadcastId) + State.PAUSED, + State.BUFFERING, + State.IDLE -> playbackTicker.stopPlaybackTicker(voiceBroadcastId) + } + // Notify state change to all the listeners attached to the current voice broadcast id + listeners[voiceBroadcastId]?.forEach { listener -> listener.onPlayingStateChanged(playingState) } + } + } + + /** + * Update the live listening state according to: + * - the voice broadcast state (started/paused/resumed/stopped), + * - the playing state (IDLE, PLAYING, PAUSED, BUFFERING), + * - the potential seek position (backward/forward). + */ + private fun updateLiveListeningMode(seekPosition: Int? = null) { + isLiveListening = when { + // the current voice broadcast is not live (ended) + currentVoiceBroadcastEvent?.isLive?.not().orFalse() -> false + // the player is stopped or paused + playingState == State.IDLE || playingState == State.PAUSED -> false + seekPosition != null -> { + val seekDirection = seekPosition.compareTo(getCurrentPlaybackPosition() ?: 0) + val newSequence = playlist.findByPosition(seekPosition)?.sequence + // the user has sought forward + if (seekDirection >= 0) { + // stay in live or latest sequence reached + isLiveListening || newSequence == playlist.lastOrNull()?.sequence + } + // the user has sought backward + else { + // was in live and stay in the same sequence + isLiveListening && newSequence == playlist.currentSequence + } + } + // otherwise, stay in live or go in live if we reached the latest sequence + else -> isLiveListening || playlist.currentSequence == playlist.lastOrNull()?.sequence + } + } + + private fun onLiveListeningChanged(isLiveListening: Boolean) { + currentVoiceBroadcast?.voiceBroadcastId?.let { voiceBroadcastId -> + // Notify live mode change to all the listeners attached to the current voice broadcast id + listeners[voiceBroadcastId]?.forEach { listener -> listener.onLiveModeChanged(isLiveListening) } + } + } + + private fun getCurrentPlaybackPosition(): Int? { + val playlistPosition = playlist.currentItem?.startTime + val computedPosition = currentMediaPlayer?.currentPosition?.let { playlistPosition?.plus(it) } ?: playlistPosition + val savedPosition = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) } + return computedPosition ?: savedPosition + } + + private fun getCurrentPlaybackPercentage(): Float? { + val playlistPosition = playlist.currentItem?.startTime + val computedPosition = currentMediaPlayer?.currentPosition?.let { playlistPosition?.plus(it) } ?: playlistPosition + val duration = playlist.duration.takeIf { it > 0 } + val computedPercentage = if (computedPosition != null && duration != null) computedPosition.toFloat() / duration else null + val savedPercentage = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPercentage(it) } + return computedPercentage ?: savedPercentage + } + + private inner class MediaPlayerListener : + MediaPlayer.OnInfoListener, + MediaPlayer.OnCompletionListener, + MediaPlayer.OnErrorListener { + + override fun onInfo(mp: MediaPlayer, what: Int, extra: Int): Boolean { + when (what) { + MediaPlayer.MEDIA_INFO_STARTED_AS_NEXT -> { + playlist.currentSequence = playlist.currentSequence?.inc() + currentMediaPlayer = mp + nextMediaPlayer = null + playingState = State.PLAYING + prepareNextMediaPlayer() + } + } + return false + } + + override fun onCompletion(mp: MediaPlayer) { + if (nextMediaPlayer != null) return + + val content = currentVoiceBroadcastEvent?.content + val isLive = content?.isLive.orFalse() + if (!isLive && content?.lastChunkSequence == playlist.currentSequence) { + // We'll not receive new chunks anymore so we can stop the live listening + stop() + } else { + playingState = State.BUFFERING + } + } + + override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean { + stop() + return true + } + } + + private inner class PlaybackTicker( + private var playbackTicker: CountUpTimer? = null, + ) { + + fun startPlaybackTicker(id: String) { + playbackTicker?.stop() + playbackTicker = CountUpTimer(50L).apply { + tickListener = CountUpTimer.TickListener { onPlaybackTick(id) } + resume() + } + onPlaybackTick(id) + } + + fun stopPlaybackTicker(id: String) { + playbackTicker?.stop() + playbackTicker = null + onPlaybackTick(id) + } + + private fun onPlaybackTick(id: String) { + val playbackTime = getCurrentPlaybackPosition() + val percentage = getCurrentPlaybackPercentage() + when (playingState) { + State.PLAYING -> { + if (playbackTime != null && percentage != null) { + playbackTracker.updatePlayingAtPlaybackTime(id, playbackTime, percentage) + } + } + State.PAUSED, + State.BUFFERING -> { + if (playbackTime != null && percentage != null) { + playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage) + } + } + State.IDLE -> { + if (playbackTime == null || percentage == null || (playlist.duration - playbackTime) < 50) { + playbackTracker.stopPlayback(id) + } else { + playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage) + } + } + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlaylist.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlaylist.kt new file mode 100644 index 0000000000..36b737f23f --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlaylist.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.listening + +import im.vector.app.features.voicebroadcast.duration +import im.vector.app.features.voicebroadcast.sequence +import org.matrix.android.sdk.api.session.room.model.message.MessageAudioEvent + +class VoiceBroadcastPlaylist( + private val items: MutableList = mutableListOf(), +) : List by items { + + var currentSequence: Int? = null + val currentItem get() = currentSequence?.let { findBySequence(it) } + + val duration + get() = items.lastOrNull()?.let { it.startTime + it.audioEvent.duration } ?: 0 + + fun setItems(audioEvents: List) { + items.clear() + val sorted = audioEvents.sortedBy { it.sequence?.toLong() ?: it.root.originServerTs } + val chunkPositions = sorted + .map { it.duration } + .runningFold(0) { acc, i -> acc + i } + .dropLast(1) + val newItems = sorted.mapIndexed { index, messageAudioEvent -> + PlaylistItem( + audioEvent = messageAudioEvent, + startTime = chunkPositions.getOrNull(index) ?: 0 + ) + } + items.addAll(newItems) + } + + fun reset() { + currentSequence = null + items.clear() + } + + fun findByPosition(positionMillis: Int): PlaylistItem? { + return items.lastOrNull { it.startTime <= positionMillis } + } + + fun findBySequence(sequenceNumber: Int): PlaylistItem? { + return items.find { it.sequence == sequenceNumber } + } + + fun getNextItem() = findBySequence(currentSequence?.plus(1) ?: 1) + + fun firstOrNull() = findBySequence(1) +} + +data class PlaylistItem(val audioEvent: MessageAudioEvent, val startTime: Int) { + val sequence: Int? + get() = audioEvent.sequence +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt new file mode 100644 index 0000000000..16b15b9a77 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.listening.usecase + +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.features.voicebroadcast.getVoiceBroadcastEventId +import im.vector.app.features.voicebroadcast.isVoiceBroadcast +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.sequence +import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastEventUseCase +import im.vector.app.features.voicebroadcast.voiceBroadcastId +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.runningReduce +import kotlinx.coroutines.runBlocking +import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.room.model.message.MessageAudioEvent +import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent +import org.matrix.android.sdk.api.session.room.timeline.Timeline +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings +import javax.inject.Inject + +/** + * Get a [Flow] of [MessageAudioEvent]s related to the given voice broadcast. + */ +class GetLiveVoiceBroadcastChunksUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, + private val getVoiceBroadcastEventUseCase: GetVoiceBroadcastEventUseCase, +) { + + fun execute(voiceBroadcast: VoiceBroadcast): Flow> { + val session = activeSessionHolder.getSafeActiveSession() ?: return emptyFlow() + val room = session.roomService().getRoom(voiceBroadcast.roomId) ?: return emptyFlow() + val timeline = room.timelineService().createTimeline(null, TimelineSettings(5)) + + // Get initial chunks + val existingChunks = room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, voiceBroadcast.voiceBroadcastId) + .mapNotNull { timelineEvent -> timelineEvent.root.asMessageAudioEvent().takeIf { it.isVoiceBroadcast() } } + + val voiceBroadcastEvent = runBlocking { getVoiceBroadcastEventUseCase.execute(voiceBroadcast).firstOrNull()?.getOrNull() } + val voiceBroadcastState = voiceBroadcastEvent?.content?.voiceBroadcastState + + return if (voiceBroadcastState == null || voiceBroadcastState == VoiceBroadcastState.STOPPED) { + // Just send the existing chunks if voice broadcast is stopped + flowOf(existingChunks) + } else { + // Observe new timeline events if voice broadcast is ongoing + callbackFlow { + // Init with existing chunks + send(existingChunks) + + // Observe new timeline events + val listener = object : Timeline.Listener { + private var latestEventId: String? = null + private var lastSequence: Int? = null + + override fun onTimelineUpdated(snapshot: List) { + val latestEventIndex = latestEventId?.let { eventId -> snapshot.indexOfFirst { it.eventId == eventId } } + val newEvents = if (latestEventIndex != null) snapshot.subList(0, latestEventIndex) else snapshot + + // Detect a potential stopped voice broadcast state event + val stopEvent = newEvents.findStopEvent(voiceBroadcast) + if (stopEvent != null) { + lastSequence = stopEvent.content?.lastChunkSequence + } + + val newChunks = newEvents.mapToChunkEvents(voiceBroadcast.voiceBroadcastId, voiceBroadcastEvent.root.senderId) + + // Notify about new chunks + if (newChunks.isNotEmpty()) { + trySend(newChunks) + } + + // Automatically stop observing the timeline if the last chunk has been received + if (lastSequence != null && newChunks.any { it.sequence == lastSequence }) { + timeline.removeListener(this) + timeline.dispose() + } + + latestEventId = snapshot.firstOrNull()?.eventId + } + } + + timeline.addListener(listener) + timeline.start() + awaitClose { + timeline.removeListener(listener) + timeline.dispose() + } + } + .runningReduce { accumulator: List, value: List -> accumulator.plus(value) } + .map { events -> events.distinctBy { it.sequence } } + } + } + + /** + * Find a [VoiceBroadcastEvent] with a [VoiceBroadcastState.STOPPED] state. + */ + private fun List.findStopEvent(voiceBroadcast: VoiceBroadcast): VoiceBroadcastEvent? = + this.mapNotNull { timelineEvent -> timelineEvent.root.asVoiceBroadcastEvent()?.takeIf { it.voiceBroadcastId == voiceBroadcast.voiceBroadcastId } } + .find { it.content?.voiceBroadcastState == VoiceBroadcastState.STOPPED } + + /** + * Transform the list of [TimelineEvent] to a mapped list of [MessageAudioEvent] related to a given voice broadcast. + */ + private fun List.mapToChunkEvents(voiceBroadcastId: String, senderId: String?): List = + this.mapNotNull { timelineEvent -> + timelineEvent.root.asMessageAudioEvent() + ?.takeIf { + it.isVoiceBroadcast() && it.getVoiceBroadcastEventId() == voiceBroadcastId && + it.root.senderId == senderId + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcast.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcast.kt new file mode 100644 index 0000000000..62207d5b87 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcast.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.model + +data class VoiceBroadcast( + val voiceBroadcastId: String, + val roomId: String, +) diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorder.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt similarity index 73% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorder.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt index 8b69051823..bc13d1fea8 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorder.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package im.vector.app.features.voicebroadcast +package im.vector.app.features.voicebroadcast.recording import androidx.annotation.IntRange import im.vector.app.features.voice.VoiceRecorder @@ -22,16 +22,23 @@ import java.io.File interface VoiceBroadcastRecorder : VoiceRecorder { + /** The current chunk number. */ val currentSequence: Int - val state: State - fun startRecord(roomId: String, chunkLength: Int) + /** Current state of the recorder. */ + val recordingState: State + + /** Current remaining time of recording, in seconds, if any. */ + val currentRemainingTime: Long? + + fun startRecord(roomId: String, chunkLength: Int, maxLength: Int) fun addListener(listener: Listener) fun removeListener(listener: Listener) interface Listener { fun onVoiceMessageCreated(file: File, @IntRange(from = 1) sequence: Int) = Unit fun onStateUpdated(state: State) = Unit + fun onRemainingTimeUpdated(remainingTime: Long?) = Unit } enum class State { diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorderQ.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt similarity index 57% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorderQ.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt index 5285dc5e3b..c5408b768b 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorderQ.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt @@ -14,16 +14,18 @@ * limitations under the License. */ -package im.vector.app.features.voicebroadcast +package im.vector.app.features.voicebroadcast.recording import android.content.Context import android.media.MediaRecorder import android.os.Build import androidx.annotation.RequiresApi import im.vector.app.features.voice.AbstractVoiceRecorderQ +import im.vector.lib.core.utils.timer.CountUpTimer import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.content.ContentAttachmentData import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit @RequiresApi(Build.VERSION_CODES.Q) class VoiceBroadcastRecorderQ( @@ -32,13 +34,21 @@ class VoiceBroadcastRecorderQ( private var maxFileSize = 0L // zero or negative for no limit private var currentRoomId: String? = null + private var currentMaxLength: Int = 0 + override var currentSequence = 0 - override var state = VoiceBroadcastRecorder.State.Idle + override var recordingState = VoiceBroadcastRecorder.State.Idle set(value) { field = value listeners.forEach { it.onStateUpdated(value) } } + override var currentRemainingTime: Long? = null + set(value) { + field = value + listeners.forEach { it.onRemainingTimeUpdated(value) } + } + private val recordingTicker = RecordingTicker() private val listeners = CopyOnWriteArrayList() override val outputFormat = MediaRecorder.OutputFormat.MPEG_4 @@ -58,33 +68,47 @@ class VoiceBroadcastRecorderQ( } } - override fun startRecord(roomId: String, chunkLength: Int) { + override fun startRecord(roomId: String, chunkLength: Int, maxLength: Int) { currentRoomId = roomId maxFileSize = (chunkLength * audioEncodingBitRate / 8).toLong() + currentMaxLength = maxLength currentSequence = 1 startRecord(roomId) - state = VoiceBroadcastRecorder.State.Recording + recordingState = VoiceBroadcastRecorder.State.Recording + recordingTicker.start() } override fun pauseRecord() { tryOrNull { mediaRecorder?.stop() } mediaRecorder?.reset() + recordingState = VoiceBroadcastRecorder.State.Paused + recordingTicker.pause() notifyOutputFileCreated() - state = VoiceBroadcastRecorder.State.Paused } override fun resumeRecord() { currentSequence++ currentRoomId?.let { startRecord(it) } - state = VoiceBroadcastRecorder.State.Recording + recordingState = VoiceBroadcastRecorder.State.Recording + recordingTicker.resume() } override fun stopRecord() { super.stopRecord() + + // Stop recording + recordingState = VoiceBroadcastRecorder.State.Idle + recordingTicker.stop() notifyOutputFileCreated() + + // Remove listeners listeners.clear() + + // Reset data currentSequence = 0 - state = VoiceBroadcastRecorder.State.Idle + currentMaxLength = 0 + currentRemainingTime = null + currentRoomId = null } override fun release() { @@ -94,7 +118,8 @@ class VoiceBroadcastRecorderQ( override fun addListener(listener: VoiceBroadcastRecorder.Listener) { listeners.add(listener) - listener.onStateUpdated(state) + listener.onStateUpdated(recordingState) + listener.onRemainingTimeUpdated(currentRemainingTime) } override fun removeListener(listener: VoiceBroadcastRecorder.Listener) { @@ -117,4 +142,53 @@ class VoiceBroadcastRecorderQ( nextOutputFile = null } } + + private fun onElapsedTimeUpdated(elapsedTimeMillis: Long) { + currentRemainingTime = if (currentMaxLength > 0 && recordingState != VoiceBroadcastRecorder.State.Idle) { + val currentMaxLengthMillis = TimeUnit.SECONDS.toMillis(currentMaxLength.toLong()) + val remainingTimeMillis = currentMaxLengthMillis - elapsedTimeMillis + TimeUnit.MILLISECONDS.toSeconds(remainingTimeMillis) + } else { + null + } + } + + private inner class RecordingTicker( + private var recordingTicker: CountUpTimer? = null, + ) { + fun start() { + recordingTicker?.stop() + recordingTicker = CountUpTimer().apply { + tickListener = CountUpTimer.TickListener { onTick(elapsedTime()) } + resume() + onTick(elapsedTime()) + } + } + + fun pause() { + recordingTicker?.apply { + pause() + onTick(elapsedTime()) + } + } + + fun resume() { + recordingTicker?.apply { + resume() + onTick(elapsedTime()) + } + } + + fun stop() { + recordingTicker?.apply { + stop() + onTick(elapsedTime()) + recordingTicker = null + } + } + + private fun onTick(elapsedTimeMillis: Long) { + onElapsedTimeUpdated(elapsedTimeMillis) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt similarity index 95% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCase.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt index 1430dd8c86..58e1f26f44 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt @@ -14,13 +14,13 @@ * limitations under the License. */ -package im.vector.app.features.voicebroadcast.usecase +package im.vector.app.features.voicebroadcast.recording.usecase import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.toContent diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt similarity index 95% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCase.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt index 2f03d4194c..524b64e095 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt @@ -14,13 +14,13 @@ * limitations under the License. */ -package im.vector.app.features.voicebroadcast.usecase +package im.vector.app.features.voicebroadcast.recording.usecase import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.toContent diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt similarity index 57% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt index 7934d18e36..45f622ad92 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt @@ -14,26 +14,35 @@ * limitations under the License. */ -package im.vector.app.features.voicebroadcast.usecase +package im.vector.app.features.voicebroadcast.recording.usecase import android.content.Context import androidx.core.content.FileProvider import im.vector.app.core.resources.BuildMeta import im.vector.app.features.attachments.toContentAttachmentData +import im.vector.app.features.session.coroutineScope import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastChunk import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState -import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.usecase.GetOngoingVoiceBroadcastsUseCase import im.vector.lib.multipicker.utils.toMultiPickerAudioType +import kotlinx.coroutines.launch +import org.jetbrains.annotations.VisibleForTesting import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.session.room.getStateEvent +import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent +import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper import timber.log.Timber import java.io.File import javax.inject.Inject @@ -43,6 +52,8 @@ class StartVoiceBroadcastUseCase @Inject constructor( private val voiceBroadcastRecorder: VoiceBroadcastRecorder?, private val context: Context, private val buildMeta: BuildMeta, + private val getOngoingVoiceBroadcastsUseCase: GetOngoingVoiceBroadcastsUseCase, + private val stopVoiceBroadcastUseCase: StopVoiceBroadcastUseCase, ) { suspend fun execute(roomId: String): Result = runCatching { @@ -50,23 +61,14 @@ class StartVoiceBroadcastUseCase @Inject constructor( Timber.d("## StartVoiceBroadcastUseCase: Start voice broadcast requested") - val onGoingVoiceBroadcastEvents = room.stateService().getStateEvents( - setOf(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO), - QueryStringValue.IsNotEmpty - ) - .mapNotNull { it.asVoiceBroadcastEvent() } - .filter { it.content?.voiceBroadcastState != null && it.content?.voiceBroadcastState != VoiceBroadcastState.STOPPED } - - if (onGoingVoiceBroadcastEvents.isEmpty()) { - startVoiceBroadcast(room) - } else { - Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: currentVoiceBroadcastEvents=$onGoingVoiceBroadcastEvents") - } + assertCanStartVoiceBroadcast(room) + startVoiceBroadcast(room) } private suspend fun startVoiceBroadcast(room: Room) { Timber.d("## StartVoiceBroadcastUseCase: Send new voice broadcast info state event") - val chunkLength = VoiceBroadcastConstants.DEFAULT_CHUNK_LENGTH_IN_SECONDS // Todo Get the length from the room settings + val chunkLength = VoiceBroadcastConstants.DEFAULT_CHUNK_LENGTH_IN_SECONDS // Todo Get the chunk length from the room settings + val maxLength = VoiceBroadcastConstants.MAX_VOICE_BROADCAST_LENGTH_IN_SECONDS // Todo Get the max length from the room settings val eventId = room.stateService().sendStateEvent( eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, stateKey = session.myUserId, @@ -77,16 +79,22 @@ class StartVoiceBroadcastUseCase @Inject constructor( ).toContent() ) - startRecording(room, eventId, chunkLength) + startRecording(room, eventId, chunkLength, maxLength) } - private fun startRecording(room: Room, eventId: String, chunkLength: Int) { + private fun startRecording(room: Room, eventId: String, chunkLength: Int, maxLength: Int) { voiceBroadcastRecorder?.addListener(object : VoiceBroadcastRecorder.Listener { override fun onVoiceMessageCreated(file: File, sequence: Int) { sendVoiceFile(room, file, eventId, sequence) } + + override fun onRemainingTimeUpdated(remainingTime: Long?) { + if (remainingTime != null && remainingTime <= 0) { + session.coroutineScope.launch { stopVoiceBroadcastUseCase.execute(room.roomId) } + } + } }) - voiceBroadcastRecorder?.startRecord(room.roomId, chunkLength) + voiceBroadcastRecorder?.startRecord(room.roomId, chunkLength, maxLength) } private fun sendVoiceFile(room: Room, voiceMessageFile: File, referenceEventId: String, sequence: Int) { @@ -107,4 +115,37 @@ class StartVoiceBroadcastUseCase @Inject constructor( ) ) } + + private fun assertCanStartVoiceBroadcast(room: Room) { + assertHasEnoughPowerLevels(room) + assertNoOngoingVoiceBroadcast(room) + } + + @VisibleForTesting + fun assertHasEnoughPowerLevels(room: Room) { + val powerLevelsHelper = room.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS, QueryStringValue.IsEmpty) + ?.content + ?.toModel() + ?.let { PowerLevelsHelper(it) } + + if (powerLevelsHelper?.isUserAllowedToSend(session.myUserId, true, VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO) != true) { + Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: no permission") + throw VoiceBroadcastFailure.RecordingError.NoPermission + } + } + + @VisibleForTesting + fun assertNoOngoingVoiceBroadcast(room: Room) { + when { + voiceBroadcastRecorder?.recordingState == VoiceBroadcastRecorder.State.Recording || + voiceBroadcastRecorder?.recordingState == VoiceBroadcastRecorder.State.Paused -> { + Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: another voice broadcast") + throw VoiceBroadcastFailure.RecordingError.UserAlreadyBroadcasting + } + getOngoingVoiceBroadcastsUseCase.execute(room.roomId).isNotEmpty() -> { + Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: user already broadcasting") + throw VoiceBroadcastFailure.RecordingError.BlockedBySomeoneElse + } + } + } } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopOngoingVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopOngoingVoiceBroadcastUseCase.kt new file mode 100644 index 0000000000..791409b869 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopOngoingVoiceBroadcastUseCase.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.recording.usecase + +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.features.voicebroadcast.VoiceBroadcastHelper +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.usecase.GetOngoingVoiceBroadcastsUseCase +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams +import timber.log.Timber +import javax.inject.Inject + +/** + * Stop ongoing voice broadcast if any. + */ +class StopOngoingVoiceBroadcastUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, + private val getOngoingVoiceBroadcastsUseCase: GetOngoingVoiceBroadcastsUseCase, + private val voiceBroadcastHelper: VoiceBroadcastHelper, +) { + + suspend fun execute() { + Timber.d("## StopOngoingVoiceBroadcastUseCase: Stop ongoing voice broadcast requested") + + val session = activeSessionHolder.getSafeActiveSession() ?: run { + Timber.w("## StopOngoingVoiceBroadcastUseCase: no active session") + return + } + // FIXME Iterate only on recent rooms for the moment, improve this + val recentRooms = session.roomService() + .getBreadcrumbs(roomSummaryQueryParams { + displayName = QueryStringValue.NoCondition + memberships = listOf(Membership.JOIN) + }) + .mapNotNull { session.getRoom(it.roomId) } + + recentRooms + .forEach { room -> + val ongoingVoiceBroadcasts = getOngoingVoiceBroadcastsUseCase.execute(room.roomId) + val myOngoingVoiceBroadcastId = ongoingVoiceBroadcasts.find { it.root.stateKey == session.myUserId }?.reference?.eventId + val initialEvent = myOngoingVoiceBroadcastId?.let { room.timelineService().getTimelineEvent(it)?.root?.asVoiceBroadcastEvent() } + if (myOngoingVoiceBroadcastId != null && initialEvent?.content?.deviceId == session.sessionParams.deviceId) { + voiceBroadcastHelper.stopVoiceBroadcast(room.roomId) + return // No need to iterate more as we should not have more than one recording VB + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt similarity index 95% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCase.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt index bc6a3e7be6..da13100609 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt @@ -14,13 +14,13 @@ * limitations under the License. */ -package im.vector.app.features.voicebroadcast.usecase +package im.vector.app.features.voicebroadcast.recording.usecase import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.toContent diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt new file mode 100644 index 0000000000..ec50618969 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.usecase + +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.getRoom +import timber.log.Timber +import javax.inject.Inject + +class GetOngoingVoiceBroadcastsUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, +) { + + fun execute(roomId: String): List { + val session = activeSessionHolder.getSafeActiveSession() ?: run { + Timber.d("## GetOngoingVoiceBroadcastsUseCase: no active session") + return emptyList() + } + val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") + + Timber.d("## GetLastVoiceBroadcastUseCase: get last voice broadcast in $roomId") + + return room.stateService().getStateEvents( + setOf(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO), + QueryStringValue.IsNotEmpty + ) + .mapNotNull { it.asVoiceBroadcastEvent() } + .filter { it.content?.voiceBroadcastState != null && it.content?.voiceBroadcastState != VoiceBroadcastState.STOPPED } + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastEventUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastEventUseCase.kt new file mode 100644 index 0000000000..94eca2b54e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastEventUseCase.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.features.voicebroadcast.usecase + +import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.voiceBroadcastId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.onStart +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.util.Optional +import org.matrix.android.sdk.api.util.toOptional +import org.matrix.android.sdk.flow.flow +import org.matrix.android.sdk.flow.mapOptional +import timber.log.Timber +import javax.inject.Inject + +class GetVoiceBroadcastEventUseCase @Inject constructor( + private val session: Session, +) { + + fun execute(voiceBroadcast: VoiceBroadcast): Flow> { + val room = session.getRoom(voiceBroadcast.roomId) ?: error("Unknown roomId: ${voiceBroadcast.roomId}") + + Timber.d("## GetVoiceBroadcastUseCase: get voice broadcast $voiceBroadcast") + + val initialEvent = room.timelineService().getTimelineEvent(voiceBroadcast.voiceBroadcastId)?.root?.asVoiceBroadcastEvent() + val latestEvent = room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, voiceBroadcast.voiceBroadcastId) + .mapNotNull { it.root.asVoiceBroadcastEvent() } + .maxByOrNull { it.root.originServerTs ?: 0 } + ?: initialEvent + + return when (latestEvent?.content?.voiceBroadcastState) { + null, VoiceBroadcastState.STOPPED -> flowOf(latestEvent.toOptional()) + else -> { + room.flow() + .liveStateEvent(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, QueryStringValue.Equals(latestEvent.root.stateKey.orEmpty())) + .onStart { emit(latestEvent.root.toOptional()) } + .distinctUntilChanged() + .filter { !it.hasValue() || it.getOrNull()?.asVoiceBroadcastEvent()?.voiceBroadcastId == voiceBroadcast.voiceBroadcastId } + .mapOptional { it.asVoiceBroadcastEvent() } + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastUseCase.kt deleted file mode 100644 index d08fa14a95..0000000000 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastUseCase.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.app.features.voicebroadcast.usecase - -import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent -import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent -import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.session.events.model.RelationType -import org.matrix.android.sdk.api.session.getRoom -import timber.log.Timber -import javax.inject.Inject - -class GetVoiceBroadcastUseCase @Inject constructor( - private val session: Session, -) { - - fun execute(roomId: String, eventId: String): VoiceBroadcastEvent? { - val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") - - Timber.d("## GetVoiceBroadcastUseCase: get voice broadcast $eventId") - - val initialEvent = room.timelineService().getTimelineEvent(eventId)?.root?.asVoiceBroadcastEvent() // Fallback to initial event - val relatedEvents = room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, eventId).sortedBy { it.root.originServerTs } - return relatedEvents.mapNotNull { it.root.asVoiceBroadcastEvent() }.lastOrNull() ?: initialEvent - } -} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastMetadataView.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastMetadataView.kt new file mode 100644 index 0000000000..e142cb15ce --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastMetadataView.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.views + +import android.content.Context +import android.content.res.TypedArray +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.LinearLayout +import androidx.core.content.res.use +import im.vector.app.R +import im.vector.app.databinding.ViewVoiceBroadcastMetadataBinding + +class VoiceBroadcastMetadataView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + private val views = ViewVoiceBroadcastMetadataBinding.inflate( + LayoutInflater.from(context), + this + ) + + var value: String + get() = views.metadataValue.text.toString() + set(newValue) { + views.metadataValue.text = newValue + } + + init { + context.obtainStyledAttributes( + attrs, + R.styleable.VoiceBroadcastMetadataView, + 0, + 0 + ).use { + setIcon(it) + setValue(it) + } + } + + private fun setIcon(typedArray: TypedArray) { + val icon = typedArray.getDrawable(R.styleable.VoiceBroadcastMetadataView_metadataIcon) + views.metadataIcon.setImageDrawable(icon) + } + + private fun setValue(typedArray: TypedArray) { + val value = typedArray.getString(R.styleable.VoiceBroadcastMetadataView_metadataValue) + views.metadataValue.text = value + } +} diff --git a/vector/src/main/res/drawable/ic_composer_full_screen.xml b/vector/src/main/res/drawable/ic_composer_full_screen.xml new file mode 100644 index 0000000000..394dc52279 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_full_screen.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_player_backward_30.xml b/vector/src/main/res/drawable/ic_player_backward_30.xml new file mode 100644 index 0000000000..cb244806b3 --- /dev/null +++ b/vector/src/main/res/drawable/ic_player_backward_30.xml @@ -0,0 +1,12 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_player_forward_30.xml b/vector/src/main/res/drawable/ic_player_forward_30.xml new file mode 100644 index 0000000000..be61fda8ff --- /dev/null +++ b/vector/src/main/res/drawable/ic_player_forward_30.xml @@ -0,0 +1,12 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_text_formatting.xml b/vector/src/main/res/drawable/ic_text_formatting.xml new file mode 100644 index 0000000000..375c459692 --- /dev/null +++ b/vector/src/main/res/drawable/ic_text_formatting.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/vector/src/main/res/drawable/ic_text_formatting_disabled.xml b/vector/src/main/res/drawable/ic_text_formatting_disabled.xml new file mode 100644 index 0000000000..bb34211c7a --- /dev/null +++ b/vector/src/main/res/drawable/ic_text_formatting_disabled.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/vector/src/main/res/drawable/ic_timer.xml b/vector/src/main/res/drawable/ic_timer.xml new file mode 100644 index 0000000000..11a42b0696 --- /dev/null +++ b/vector/src/main/res/drawable/ic_timer.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_voice_broadcast_16.xml b/vector/src/main/res/drawable/ic_voice_broadcast.xml similarity index 100% rename from vector/src/main/res/drawable/ic_voice_broadcast_16.xml rename to vector/src/main/res/drawable/ic_voice_broadcast.xml diff --git a/vector/src/main/res/drawable/ic_voice_broadcast_mic.xml b/vector/src/main/res/drawable/ic_voice_broadcast_mic.xml new file mode 100644 index 0000000000..edadb55b81 --- /dev/null +++ b/vector/src/main/res/drawable/ic_voice_broadcast_mic.xml @@ -0,0 +1,12 @@ + + + + diff --git a/vector/src/main/res/layout/bottom_sheet_attachment_type_selector.xml b/vector/src/main/res/layout/bottom_sheet_attachment_type_selector.xml index 79a60624cf..7a22ab57f8 100644 --- a/vector/src/main/res/layout/bottom_sheet_attachment_type_selector.xml +++ b/vector/src/main/res/layout/bottom_sheet_attachment_type_selector.xml @@ -1,6 +1,7 @@ @@ -82,5 +83,24 @@ app:tint="?colorPrimary" app:titleTextColor="?vctr_content_primary" /> + + + + diff --git a/vector/src/main/res/layout/composer_rich_text_layout.xml b/vector/src/main/res/layout/composer_rich_text_layout.xml index 09e4b03887..c5afe1eb44 100644 --- a/vector/src/main/res/layout/composer_rich_text_layout.xml +++ b/vector/src/main/res/layout/composer_rich_text_layout.xml @@ -3,7 +3,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="wrap_content" + android:layout_height="match_parent" tools:constraintSet="@layout/composer_rich_text_layout_constraint_set_compact" tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"> @@ -104,16 +104,41 @@ android:background="@drawable/bg_composer_rich_edit_text_single_line" /> + + + + + @@ -114,6 +114,7 @@ android:background="?android:attr/selectableItemBackground" android:contentDescription="@string/option_send_files" android:src="@drawable/ic_attachment" + app:layout_constraintVertical_bias="1" app:layout_constraintBottom_toBottomOf="@id/sendButton" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@id/sendButton" @@ -135,13 +136,13 @@ app:layout_constraintEnd_toEndOf="parent" /> + + + + @@ -173,6 +203,7 @@ app:layout_constraintStart_toEndOf="@id/attachmentButton" app:layout_constraintEnd_toStartOf="@id/sendButton" app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintVertical_bias="1" android:fillViewport="true"> @@ -149,21 +149,49 @@ app:layout_constraintEnd_toEndOf="parent" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_composer.xml b/vector/src/main/res/layout/fragment_composer.xml index 8703af7471..41c052367a 100644 --- a/vector/src/main/res/layout/fragment_composer.xml +++ b/vector/src/main/res/layout/fragment_composer.xml @@ -4,7 +4,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="wrap_content"> + android:layout_height="match_parent"> diff --git a/vector/src/main/res/layout/fragment_timeline.xml b/vector/src/main/res/layout/fragment_timeline.xml index 2078d729db..2d07464e89 100644 --- a/vector/src/main/res/layout/fragment_timeline.xml +++ b/vector/src/main/res/layout/fragment_timeline.xml @@ -6,6 +6,21 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + + + - - @@ -165,6 +170,7 @@ android:layout_margin="16dp" android:contentDescription="@string/a11y_jump_to_bottom" android:src="@drawable/ic_expand_more" + android:visibility="gone" app:backgroundTint="#FFFFFF" app:badgeBackgroundColor="?colorPrimary" app:badgeTextColor="?colorOnPrimary" diff --git a/vector/src/main/res/layout/fragment_timeline_fullscreen.xml b/vector/src/main/res/layout/fragment_timeline_fullscreen.xml new file mode 100644 index 0000000000..373ca74f56 --- /dev/null +++ b/vector/src/main/res/layout/fragment_timeline_fullscreen.xml @@ -0,0 +1,258 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml index 248c04a2f6..1d31afba99 100644 --- a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml @@ -7,25 +7,14 @@ android:layout_height="wrap_content" android:background="@drawable/rounded_rect_shape_8" android:backgroundTint="?vctr_content_quinary" - android:padding="@dimen/layout_vertical_margin" - tools:viewBindingIgnore="true"> + android:padding="@dimen/layout_vertical_margin"> @@ -54,96 +43,123 @@ android:contentDescription="@string/avatar" app:layout_constraintStart_toEndOf="@id/avatarRightBarrier" app:layout_constraintTop_toTopOf="parent" - tools:src="@sample/rooms.json/data/name" /> + tools:text="@sample/rooms.json/data/name" /> - + app:layout_constraintTop_toBottomOf="@id/titleText" /> - - - - - - + app:metadataIcon="@drawable/ic_voice_broadcast_mic" + tools:metadataValue="@sample/users.json/data/displayName" /> - + - - + + app:barrierMargin="10dp" + app:constraint_referenced_ids="roomAvatarImageView,titleText,metadataFlow" /> + + + + - + android:visibility="gone" + tools:visibility="visible" /> + + + + + diff --git a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_recording_stub.xml b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_recording_stub.xml index e3bb85138d..7da0701cc7 100644 --- a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_recording_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_recording_stub.xml @@ -7,25 +7,14 @@ android:layout_height="wrap_content" android:background="@drawable/rounded_rect_shape_8" android:backgroundTint="?vctr_content_quinary" - android:padding="@dimen/layout_vertical_margin" - tools:viewBindingIgnore="true"> + android:padding="@dimen/layout_vertical_margin"> @@ -54,7 +43,34 @@ android:contentDescription="@string/avatar" app:layout_constraintStart_toEndOf="@id/avatarRightBarrier" app:layout_constraintTop_toTopOf="parent" - tools:src="@sample/users.json/data/displayName" /> + tools:text="@sample/users.json/data/displayName" /> + + + + + + + app:constraint_referenced_ids="roomAvatarImageView,titleText,metadataFlow" /> - + + + android:src="@drawable/ic_stop" /> diff --git a/vector/src/main/res/layout/item_typing_users.xml b/vector/src/main/res/layout/item_typing_users.xml new file mode 100644 index 0000000000..7902f0c814 --- /dev/null +++ b/vector/src/main/res/layout/item_typing_users.xml @@ -0,0 +1,8 @@ + + diff --git a/vector/src/main/res/layout/view_sessions_list_header.xml b/vector/src/main/res/layout/view_sessions_list_header.xml index 6139ff4815..9f581a1d03 100644 --- a/vector/src/main/res/layout/view_sessions_list_header.xml +++ b/vector/src/main/res/layout/view_sessions_list_header.xml @@ -13,7 +13,7 @@ android:layout_height="wrap_content" android:layout_marginHorizontal="@dimen/layout_horizontal_margin" android:layout_marginTop="20dp" - app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintEnd_toStartOf="@id/sessionsListHeaderMenu" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:text="Other sessions" /> @@ -29,4 +29,13 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/sessions_list_header_title" tools:text="For best security, verify your sessions and sign out from any session that you don’t recognize or use anymore. Learn More." /> + + diff --git a/vector/src/main/res/layout/view_voice_broadcast_metadata.xml b/vector/src/main/res/layout/view_voice_broadcast_metadata.xml new file mode 100644 index 0000000000..3bc31cd9a0 --- /dev/null +++ b/vector/src/main/res/layout/view_voice_broadcast_metadata.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/vector/src/main/res/menu/menu_other_sessions.xml b/vector/src/main/res/menu/menu_other_sessions.xml index 8339286fe7..7893575dde 100644 --- a/vector/src/main/res/menu/menu_other_sessions.xml +++ b/vector/src/main/res/menu/menu_other_sessions.xml @@ -9,6 +9,11 @@ android:title="@string/device_manager_other_sessions_select" app:showAsAction="withText|never" /> + + + + + + + diff --git a/vector/src/test/java/im/vector/app/core/notification/UpdateEnableNotificationsSettingOnChangeUseCaseTest.kt b/vector/src/test/java/im/vector/app/core/notification/UpdateEnableNotificationsSettingOnChangeUseCaseTest.kt new file mode 100644 index 0000000000..5cced75735 --- /dev/null +++ b/vector/src/test/java/im/vector/app/core/notification/UpdateEnableNotificationsSettingOnChangeUseCaseTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.core.notification + +import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus +import im.vector.app.test.fakes.FakeGetNotificationsStatusUseCase +import im.vector.app.test.fakes.FakeSession +import im.vector.app.test.fakes.FakeVectorPreferences +import kotlinx.coroutines.test.runTest +import org.junit.Test + +private const val A_SESSION_ID = "session-id" + +class UpdateEnableNotificationsSettingOnChangeUseCaseTest { + + private val fakeSession = FakeSession().also { it.givenSessionId(A_SESSION_ID) } + private val fakeVectorPreferences = FakeVectorPreferences() + private val fakeGetNotificationsStatusUseCase = FakeGetNotificationsStatusUseCase() + + private val updateEnableNotificationsSettingOnChangeUseCase = UpdateEnableNotificationsSettingOnChangeUseCase( + vectorPreferences = fakeVectorPreferences.instance, + getNotificationsStatusUseCase = fakeGetNotificationsStatusUseCase.instance, + ) + + @Test + fun `given notifications are enabled when execute then setting is updated to true`() = runTest { + // Given + fakeGetNotificationsStatusUseCase.givenExecuteReturns( + fakeSession, + A_SESSION_ID, + NotificationsStatus.ENABLED, + ) + fakeVectorPreferences.givenSetNotificationEnabledForDevice() + + // When + updateEnableNotificationsSettingOnChangeUseCase.execute(fakeSession) + + // Then + fakeVectorPreferences.verifySetNotificationEnabledForDevice(true) + } + + @Test + fun `given notifications are disabled when execute then setting is updated to false`() = runTest { + // Given + fakeGetNotificationsStatusUseCase.givenExecuteReturns( + fakeSession, + A_SESSION_ID, + NotificationsStatus.DISABLED, + ) + fakeVectorPreferences.givenSetNotificationEnabledForDevice() + + // When + updateEnableNotificationsSettingOnChangeUseCase.execute(fakeSession) + + // Then + fakeVectorPreferences.verifySetNotificationEnabledForDevice(false) + } + + @Test + fun `given notifications toggle is not supported when execute then nothing is done`() = runTest { + // Given + fakeGetNotificationsStatusUseCase.givenExecuteReturns( + fakeSession, + A_SESSION_ID, + NotificationsStatus.NOT_SUPPORTED, + ) + fakeVectorPreferences.givenSetNotificationEnabledForDevice() + + // When + updateEnableNotificationsSettingOnChangeUseCase.execute(fakeSession) + + // Then + fakeVectorPreferences.verifySetNotificationEnabledForDevice(true, inverse = true) + fakeVectorPreferences.verifySetNotificationEnabledForDevice(false, inverse = true) + } +} diff --git a/vector/src/test/java/im/vector/app/core/pushers/PushersManagerTest.kt b/vector/src/test/java/im/vector/app/core/pushers/PushersManagerTest.kt index 113a810ac2..7a1833e057 100644 --- a/vector/src/test/java/im/vector/app/core/pushers/PushersManagerTest.kt +++ b/vector/src/test/java/im/vector/app/core/pushers/PushersManagerTest.kt @@ -29,7 +29,6 @@ import im.vector.app.test.fixtures.CryptoDeviceInfoFixture.aCryptoDeviceInfo import im.vector.app.test.fixtures.PusherFixture import im.vector.app.test.fixtures.SessionParamsFixture import io.mockk.mockk -import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo import org.junit.Test import org.matrix.android.sdk.api.session.crypto.model.UnsignedDeviceInfo @@ -101,19 +100,4 @@ class PushersManagerTest { pusher shouldBeEqualTo expectedPusher } - - @Test - fun `when togglePusherForCurrentSession, then do service toggle pusher`() = runTest { - val deviceId = "device_id" - val sessionParams = SessionParamsFixture.aSessionParams( - credentials = CredentialsFixture.aCredentials(deviceId = deviceId) - ) - session.givenSessionParams(sessionParams) - val pusher = PusherFixture.aPusher(deviceId = deviceId) - pushersService.givenGetPushers(listOf(pusher)) - - pushersManager.togglePusherForCurrentSession(true) - - pushersService.verifyTogglePusherCalled(pusher, true) - } } diff --git a/vector/src/test/java/im/vector/app/core/session/ConfigureAndStartSessionUseCaseTest.kt b/vector/src/test/java/im/vector/app/core/session/ConfigureAndStartSessionUseCaseTest.kt index 8d4507e85d..861e59e0f1 100644 --- a/vector/src/test/java/im/vector/app/core/session/ConfigureAndStartSessionUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/core/session/ConfigureAndStartSessionUseCaseTest.kt @@ -19,6 +19,7 @@ package im.vector.app.core.session import im.vector.app.core.extensions.startSyncing import im.vector.app.core.session.clientinfo.UpdateMatrixClientInfoUseCase import im.vector.app.test.fakes.FakeContext +import im.vector.app.test.fakes.FakeEnableNotificationsSettingUpdater import im.vector.app.test.fakes.FakeSession import im.vector.app.test.fakes.FakeVectorPreferences import im.vector.app.test.fakes.FakeWebRtcCallManager @@ -43,12 +44,14 @@ class ConfigureAndStartSessionUseCaseTest { private val fakeWebRtcCallManager = FakeWebRtcCallManager() private val fakeUpdateMatrixClientInfoUseCase = mockk() private val fakeVectorPreferences = FakeVectorPreferences() + private val fakeEnableNotificationsSettingUpdater = FakeEnableNotificationsSettingUpdater() private val configureAndStartSessionUseCase = ConfigureAndStartSessionUseCase( context = fakeContext.instance, webRtcCallManager = fakeWebRtcCallManager.instance, updateMatrixClientInfoUseCase = fakeUpdateMatrixClientInfoUseCase, vectorPreferences = fakeVectorPreferences.instance, + enableNotificationsSettingUpdater = fakeEnableNotificationsSettingUpdater.instance, ) @Before @@ -68,6 +71,7 @@ class ConfigureAndStartSessionUseCaseTest { fakeWebRtcCallManager.givenCheckForProtocolsSupportIfNeededSucceeds() coJustRun { fakeUpdateMatrixClientInfoUseCase.execute(any()) } fakeVectorPreferences.givenIsClientInfoRecordingEnabled(isEnabled = true) + fakeEnableNotificationsSettingUpdater.givenOnSessionsStarted(fakeSession) // When configureAndStartSessionUseCase.execute(fakeSession, startSyncing = true) @@ -87,6 +91,7 @@ class ConfigureAndStartSessionUseCaseTest { fakeWebRtcCallManager.givenCheckForProtocolsSupportIfNeededSucceeds() coJustRun { fakeUpdateMatrixClientInfoUseCase.execute(any()) } fakeVectorPreferences.givenIsClientInfoRecordingEnabled(isEnabled = false) + fakeEnableNotificationsSettingUpdater.givenOnSessionsStarted(fakeSession) // When configureAndStartSessionUseCase.execute(fakeSession, startSyncing = true) @@ -106,6 +111,7 @@ class ConfigureAndStartSessionUseCaseTest { fakeWebRtcCallManager.givenCheckForProtocolsSupportIfNeededSucceeds() coJustRun { fakeUpdateMatrixClientInfoUseCase.execute(any()) } fakeVectorPreferences.givenIsClientInfoRecordingEnabled(isEnabled = true) + fakeEnableNotificationsSettingUpdater.givenOnSessionsStarted(fakeSession) // When configureAndStartSessionUseCase.execute(fakeSession, startSyncing = false) diff --git a/vector/src/test/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModelTest.kt b/vector/src/test/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModelTest.kt index 478f631c06..e20d498a37 100644 --- a/vector/src/test/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModelTest.kt @@ -18,7 +18,9 @@ package im.vector.app.features.attachments import com.airbnb.mvrx.test.MavericksTestRule import im.vector.app.test.fakes.FakeVectorFeatures +import im.vector.app.test.fakes.FakeVectorPreferences import im.vector.app.test.test +import io.mockk.verifyOrder import org.junit.Before import org.junit.Rule import org.junit.Test @@ -29,6 +31,7 @@ internal class AttachmentTypeSelectorViewModelTest { val mavericksTestRule = MavericksTestRule() private val fakeVectorFeatures = FakeVectorFeatures() + private val fakeVectorPreferences = FakeVectorPreferences() private val initialState = AttachmentTypeSelectorViewState() @Before @@ -36,6 +39,7 @@ internal class AttachmentTypeSelectorViewModelTest { // Disable all features by default fakeVectorFeatures.givenLocationSharing(isEnabled = false) fakeVectorFeatures.givenVoiceBroadcast(isEnabled = false) + fakeVectorPreferences.givenTextFormatting(isEnabled = false) } @Test @@ -82,10 +86,57 @@ internal class AttachmentTypeSelectorViewModelTest { .finish() } + @Test + fun `given text formatting is enabled, then text formatting option is checked`() { + fakeVectorPreferences.givenTextFormatting(isEnabled = true) + + createViewModel() + .test() + .assertStates( + listOf( + initialState.copy( + isTextFormattingEnabled = true + ), + ) + ) + .finish() + } + + @Test + fun `when text formatting is changed, then it updates the UI`() { + createViewModel() + .apply { + handle(AttachmentTypeSelectorAction.ToggleTextFormatting(isEnabled = true)) + } + .test() + .assertStates( + listOf( + initialState.copy( + isTextFormattingEnabled = true + ), + ) + ) + .finish() + } + + @Test + fun `when text formatting is changed, then it persists the change`() { + createViewModel() + .apply { + handle(AttachmentTypeSelectorAction.ToggleTextFormatting(isEnabled = true)) + handle(AttachmentTypeSelectorAction.ToggleTextFormatting(isEnabled = false)) + } + verifyOrder { + fakeVectorPreferences.instance.setTextFormattingEnabled(true) + fakeVectorPreferences.instance.setTextFormattingEnabled(false) + } + } + private fun createViewModel(): AttachmentTypeSelectorViewModel { return AttachmentTypeSelectorViewModel( initialState, vectorFeatures = fakeVectorFeatures, + vectorPreferences = fakeVectorPreferences.instance, ) } } diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt index c5edfb868d..65da1a9385 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt @@ -22,30 +22,41 @@ import com.airbnb.mvrx.test.MavericksTestRule import im.vector.app.core.session.clientinfo.MatrixClientInfoContent import im.vector.app.features.settings.devices.v2.details.extended.DeviceExtendedInfo import im.vector.app.features.settings.devices.v2.list.DeviceType +import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase import im.vector.app.features.settings.devices.v2.verification.CurrentSessionCrossSigningInfo import im.vector.app.features.settings.devices.v2.verification.GetCurrentSessionCrossSigningInfoUseCase import im.vector.app.test.fakes.FakeActiveSessionHolder +import im.vector.app.test.fakes.FakePendingAuthHandler +import im.vector.app.test.fakes.FakeSignoutSessionsUseCase import im.vector.app.test.fakes.FakeVerificationService import im.vector.app.test.test import im.vector.app.test.testDispatcher import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every -import io.mockk.just +import io.mockk.justRun import io.mockk.mockk import io.mockk.mockkStatic -import io.mockk.runs import io.mockk.unmockkAll import io.mockk.verify +import io.mockk.verifyAll import kotlinx.coroutines.flow.flowOf +import org.amshove.kluent.shouldBeEqualTo import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.matrix.android.sdk.api.session.crypto.crosssigning.DeviceTrustLevel import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo +import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel +import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth + +private const val A_CURRENT_DEVICE_ID = "current-device-id" +private const val A_DEVICE_ID_1 = "device-id-1" +private const val A_DEVICE_ID_2 = "device-id-2" +private const val A_PASSWORD = "password" class DevicesViewModelTest { @@ -55,19 +66,25 @@ class DevicesViewModelTest { private val fakeActiveSessionHolder = FakeActiveSessionHolder() private val getCurrentSessionCrossSigningInfoUseCase = mockk() private val getDeviceFullInfoListUseCase = mockk() - private val refreshDevicesUseCase = mockk(relaxUnitFun = true) - private val refreshDevicesOnCryptoDevicesChangeUseCase = mockk() + private val refreshDevicesOnCryptoDevicesChangeUseCase = mockk(relaxed = true) private val checkIfCurrentSessionCanBeVerifiedUseCase = mockk() + private val fakeSignoutSessionsUseCase = FakeSignoutSessionsUseCase() + private val fakeInterceptSignoutFlowResponseUseCase = mockk() + private val fakePendingAuthHandler = FakePendingAuthHandler() + private val fakeRefreshDevicesUseCase = mockk(relaxUnitFun = true) private fun createViewModel(): DevicesViewModel { return DevicesViewModel( - DevicesViewState(), - fakeActiveSessionHolder.instance, - getCurrentSessionCrossSigningInfoUseCase, - getDeviceFullInfoListUseCase, - refreshDevicesOnCryptoDevicesChangeUseCase, - checkIfCurrentSessionCanBeVerifiedUseCase, - refreshDevicesUseCase, + initialState = DevicesViewState(), + activeSessionHolder = fakeActiveSessionHolder.instance, + getCurrentSessionCrossSigningInfoUseCase = getCurrentSessionCrossSigningInfoUseCase, + getDeviceFullInfoListUseCase = getDeviceFullInfoListUseCase, + refreshDevicesOnCryptoDevicesChangeUseCase = refreshDevicesOnCryptoDevicesChangeUseCase, + checkIfCurrentSessionCanBeVerifiedUseCase = checkIfCurrentSessionCanBeVerifiedUseCase, + signoutSessionsUseCase = fakeSignoutSessionsUseCase.instance, + interceptSignoutFlowResponseUseCase = fakeInterceptSignoutFlowResponseUseCase, + pendingAuthHandler = fakePendingAuthHandler.instance, + refreshDevicesUseCase = fakeRefreshDevicesUseCase, ) } @@ -76,6 +93,20 @@ class DevicesViewModelTest { // Needed for internal usage of Flow.throttleFirst() inside the ViewModel mockkStatic(SystemClock::class) every { SystemClock.elapsedRealtime() } returns 1234 + + givenVerificationService() + givenCurrentSessionCrossSigningInfo() + givenDeviceFullInfoList(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_DEVICE_ID_2) + } + + private fun givenVerificationService(): FakeVerificationService { + val fakeVerificationService = fakeActiveSessionHolder + .fakeSession + .fakeCryptoService + .fakeVerificationService + fakeVerificationService.givenAddListenerSucceeds() + fakeVerificationService.givenRemoveListenerSucceeds() + return fakeVerificationService } @After @@ -87,9 +118,6 @@ class DevicesViewModelTest { fun `given the viewModel when initializing it then verification listener is added`() { // Given val fakeVerificationService = givenVerificationService() - givenCurrentSessionCrossSigningInfo() - givenDeviceFullInfoList() - givenRefreshDevicesOnCryptoDevicesChange() // When val viewModel = createViewModel() @@ -104,9 +132,6 @@ class DevicesViewModelTest { fun `given the viewModel when clearing it then verification listener is removed`() { // Given val fakeVerificationService = givenVerificationService() - givenCurrentSessionCrossSigningInfo() - givenDeviceFullInfoList() - givenRefreshDevicesOnCryptoDevicesChange() // When val viewModel = createViewModel() @@ -121,10 +146,7 @@ class DevicesViewModelTest { @Test fun `given the viewModel when initializing it then view state is updated with current session cross signing info`() { // Given - givenVerificationService() val currentSessionCrossSigningInfo = givenCurrentSessionCrossSigningInfo() - givenDeviceFullInfoList() - givenRefreshDevicesOnCryptoDevicesChange() // When val viewModelTest = createViewModel().test() @@ -137,10 +159,7 @@ class DevicesViewModelTest { @Test fun `given the viewModel when initializing it then view state is updated with current device full info list`() { // Given - givenVerificationService() - givenCurrentSessionCrossSigningInfo() - val deviceFullInfoList = givenDeviceFullInfoList() - givenRefreshDevicesOnCryptoDevicesChange() + val deviceFullInfoList = givenDeviceFullInfoList(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_DEVICE_ID_2) // When val viewModelTest = createViewModel().test() @@ -156,10 +175,6 @@ class DevicesViewModelTest { @Test fun `given the viewModel when initializing it then devices are refreshed on crypto devices change`() { // Given - givenVerificationService() - givenCurrentSessionCrossSigningInfo() - givenDeviceFullInfoList() - givenRefreshDevicesOnCryptoDevicesChange() // When createViewModel() @@ -171,10 +186,6 @@ class DevicesViewModelTest { @Test fun `given current session can be verified when handling verify current session action then self verification event is posted`() { // Given - givenVerificationService() - givenCurrentSessionCrossSigningInfo() - givenDeviceFullInfoList() - givenRefreshDevicesOnCryptoDevicesChange() val verifyCurrentSessionAction = DevicesAction.VerifyCurrentSession coEvery { checkIfCurrentSessionCanBeVerifiedUseCase.execute() } returns true @@ -195,10 +206,6 @@ class DevicesViewModelTest { @Test fun `given current session cannot be verified when handling verify current session action then reset secrets event is posted`() { // Given - givenVerificationService() - givenCurrentSessionCrossSigningInfo() - givenDeviceFullInfoList() - givenRefreshDevicesOnCryptoDevicesChange() val verifyCurrentSessionAction = DevicesAction.VerifyCurrentSession coEvery { checkIfCurrentSessionCanBeVerifiedUseCase.execute() } returns false @@ -216,18 +223,129 @@ class DevicesViewModelTest { } } - private fun givenVerificationService(): FakeVerificationService { - val fakeVerificationService = fakeActiveSessionHolder - .fakeSession - .fakeCryptoService - .fakeVerificationService - fakeVerificationService.givenAddListenerSucceeds() - fakeVerificationService.givenRemoveListenerSucceeds() - return fakeVerificationService + @Test + fun `given no reAuth is needed when handling multiSignout other sessions action then signout process is performed`() { + // Given + val expectedViewState = givenInitialViewState(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_CURRENT_DEVICE_ID) + // signout all devices except the current device + fakeSignoutSessionsUseCase.givenSignoutSuccess(listOf(A_DEVICE_ID_1)) + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(DevicesAction.MultiSignoutOtherSessions) + + // Then + viewModelTest + .assertStatesChanges( + expectedViewState, + { copy(isLoading = true) }, + { copy(isLoading = false) } + ) + .assertEvent { it is DevicesViewEvent.SignoutSuccess } + .finish() + verify { + fakeRefreshDevicesUseCase.execute() + } + } + + @Test + fun `given unexpected error during multiSignout when handling multiSignout action then signout process is performed`() { + // Given + val error = Exception() + fakeSignoutSessionsUseCase.givenSignoutError(listOf(A_DEVICE_ID_1, A_DEVICE_ID_2), error) + val expectedViewState = givenInitialViewState(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_DEVICE_ID_2) + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(DevicesAction.MultiSignoutOtherSessions) + + // Then + viewModelTest + .assertStatesChanges( + expectedViewState, + { copy(isLoading = true) }, + { copy(isLoading = false) } + ) + .assertEvent { it is DevicesViewEvent.SignoutError && it.error == error } + .finish() + } + + @Test + fun `given reAuth is needed during multiSignout when handling multiSignout action then requestReAuth is sent and pending auth is stored`() { + // Given + val reAuthNeeded = fakeSignoutSessionsUseCase.givenSignoutReAuthNeeded(listOf(A_DEVICE_ID_1, A_DEVICE_ID_2)) + val expectedPendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session) + val expectedReAuthEvent = DevicesViewEvent.RequestReAuth(reAuthNeeded.flowResponse, reAuthNeeded.errCode) + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(DevicesAction.MultiSignoutOtherSessions) + + // Then + viewModelTest + .assertEvent { it == expectedReAuthEvent } + .finish() + fakePendingAuthHandler.instance.pendingAuth shouldBeEqualTo expectedPendingAuth + fakePendingAuthHandler.instance.uiaContinuation shouldBeEqualTo reAuthNeeded.uiaContinuation + } + + @Test + fun `given SSO auth has been done when handling ssoAuthDone action then corresponding method of pending auth handler is called`() { + // Given + justRun { fakePendingAuthHandler.instance.ssoAuthDone() } + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(DevicesAction.SsoAuthDone) + + // Then + viewModelTest.finish() + verifyAll { + fakePendingAuthHandler.instance.ssoAuthDone() + } + } + + @Test + fun `given password auth has been done when handling passwordAuthDone action then corresponding method of pending auth handler is called`() { + // Given + justRun { fakePendingAuthHandler.instance.passwordAuthDone(any()) } + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(DevicesAction.PasswordAuthDone(A_PASSWORD)) + + // Then + viewModelTest.finish() + verifyAll { + fakePendingAuthHandler.instance.passwordAuthDone(A_PASSWORD) + } + } + + @Test + fun `given reAuth has been cancelled when handling reAuthCancelled action then corresponding method of pending auth handler is called`() { + // Given + justRun { fakePendingAuthHandler.instance.reAuthCancelled() } + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(DevicesAction.ReAuthCancelled) + + // Then + viewModelTest.finish() + verifyAll { + fakePendingAuthHandler.instance.reAuthCancelled() + } } private fun givenCurrentSessionCrossSigningInfo(): CurrentSessionCrossSigningInfo { val currentSessionCrossSigningInfo = mockk() + every { currentSessionCrossSigningInfo.deviceId } returns A_CURRENT_DEVICE_ID every { getCurrentSessionCrossSigningInfoUseCase.execute() } returns flowOf(currentSessionCrossSigningInfo) return currentSessionCrossSigningInfo } @@ -235,14 +353,19 @@ class DevicesViewModelTest { /** * Generate mocked deviceFullInfo list with 1 unverified and inactive + 1 verified and active. */ - private fun givenDeviceFullInfoList(): List { + private fun givenDeviceFullInfoList(deviceId1: String, deviceId2: String): List { val verifiedCryptoDeviceInfo = mockk() every { verifiedCryptoDeviceInfo.trustLevel } returns DeviceTrustLevel(crossSigningVerified = true, locallyVerified = true) val unverifiedCryptoDeviceInfo = mockk() every { unverifiedCryptoDeviceInfo.trustLevel } returns DeviceTrustLevel(crossSigningVerified = false, locallyVerified = false) + val deviceInfo1 = mockk() + every { deviceInfo1.deviceId } returns deviceId1 + val deviceInfo2 = mockk() + every { deviceInfo2.deviceId } returns deviceId2 + val deviceFullInfo1 = DeviceFullInfo( - deviceInfo = mockk(), + deviceInfo = deviceInfo1, cryptoDeviceInfo = verifiedCryptoDeviceInfo, roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Trusted, isInactive = false, @@ -251,7 +374,7 @@ class DevicesViewModelTest { matrixClientInfo = MatrixClientInfoContent(), ) val deviceFullInfo2 = DeviceFullInfo( - deviceInfo = mockk(), + deviceInfo = deviceInfo2, cryptoDeviceInfo = unverifiedCryptoDeviceInfo, roomEncryptionTrustLevel = RoomEncryptionTrustLevel.Warning, isInactive = true, @@ -265,7 +388,15 @@ class DevicesViewModelTest { return deviceFullInfoList } - private fun givenRefreshDevicesOnCryptoDevicesChange() { - coEvery { refreshDevicesOnCryptoDevicesChangeUseCase.execute() } just runs + private fun givenInitialViewState(deviceId1: String, deviceId2: String): DevicesViewState { + val currentSessionCrossSigningInfo = givenCurrentSessionCrossSigningInfo() + val deviceFullInfoList = givenDeviceFullInfoList(deviceId1, deviceId2) + return DevicesViewState( + currentSessionCrossSigningInfo = currentSessionCrossSigningInfo, + devices = Success(deviceFullInfoList), + unverifiedSessionsCount = 1, + inactiveSessionsCount = 1, + isLoading = false, + ) } } diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/notification/CanTogglePushNotificationsViaPusherUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/notification/CanTogglePushNotificationsViaPusherUseCaseTest.kt new file mode 100644 index 0000000000..997fa827f5 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/notification/CanTogglePushNotificationsViaPusherUseCaseTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2.notification + +import im.vector.app.test.fakes.FakeFlowLiveDataConversions +import im.vector.app.test.fakes.FakeSession +import im.vector.app.test.fakes.givenAsFlow +import im.vector.app.test.fixtures.aHomeServerCapabilities +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.After +import org.junit.Before +import org.junit.Test + +private val A_HOMESERVER_CAPABILITIES = aHomeServerCapabilities(canRemotelyTogglePushNotificationsOfDevices = true) + +class CanTogglePushNotificationsViaPusherUseCaseTest { + + private val fakeSession = FakeSession() + private val fakeFlowLiveDataConversions = FakeFlowLiveDataConversions() + + private val canTogglePushNotificationsViaPusherUseCase = + CanTogglePushNotificationsViaPusherUseCase() + + @Before + fun setUp() { + fakeFlowLiveDataConversions.setup() + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given current session when execute then flow of the toggle capability is returned`() = runTest { + // Given + fakeSession + .fakeHomeServerCapabilitiesService + .givenCapabilitiesLiveReturns(A_HOMESERVER_CAPABILITIES) + .givenAsFlow() + + // When + val result = canTogglePushNotificationsViaPusherUseCase.execute(fakeSession).firstOrNull() + + // Then + result shouldBeEqualTo A_HOMESERVER_CAPABILITIES.canRemotelyTogglePushNotificationsOfDevices + } +} diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/notification/CheckIfCanTogglePushNotificationsViaAccountDataUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/notification/CheckIfCanTogglePushNotificationsViaAccountDataUseCaseTest.kt new file mode 100644 index 0000000000..37433364e8 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/notification/CheckIfCanTogglePushNotificationsViaAccountDataUseCaseTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2.notification + +import im.vector.app.test.fakes.FakeSession +import io.mockk.mockk +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes + +private const val A_DEVICE_ID = "device-id" + +class CheckIfCanTogglePushNotificationsViaAccountDataUseCaseTest { + + private val fakeSession = FakeSession() + + private val checkIfCanTogglePushNotificationsViaAccountDataUseCase = + CheckIfCanTogglePushNotificationsViaAccountDataUseCase() + + @Test + fun `given current session and an account data for the device id when execute then result is true`() { + // Given + fakeSession + .accountDataService() + .givenGetUserAccountDataEventReturns( + type = UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + A_DEVICE_ID, + content = mockk(), + ) + + // When + val result = checkIfCanTogglePushNotificationsViaAccountDataUseCase.execute(fakeSession, A_DEVICE_ID) + + // Then + result shouldBeEqualTo true + } + + @Test + fun `given current session and NO account data for the device id when execute then result is false`() { + // Given + fakeSession + .accountDataService() + .givenGetUserAccountDataEventReturns( + type = UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + A_DEVICE_ID, + content = null, + ) + + // When + val result = checkIfCanTogglePushNotificationsViaAccountDataUseCase.execute(fakeSession, A_DEVICE_ID) + + // Then + result shouldBeEqualTo false + } +} diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/notification/CheckIfCanTogglePushNotificationsViaPusherUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/notification/CheckIfCanTogglePushNotificationsViaPusherUseCaseTest.kt new file mode 100644 index 0000000000..508a05acd6 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/notification/CheckIfCanTogglePushNotificationsViaPusherUseCaseTest.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2.notification + +import im.vector.app.test.fakes.FakeSession +import im.vector.app.test.fixtures.aHomeServerCapabilities +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +private val A_HOMESERVER_CAPABILITIES = aHomeServerCapabilities(canRemotelyTogglePushNotificationsOfDevices = true) + +class CheckIfCanTogglePushNotificationsViaPusherUseCaseTest { + + private val fakeSession = FakeSession() + + private val checkIfCanTogglePushNotificationsViaPusherUseCase = + CheckIfCanTogglePushNotificationsViaPusherUseCase() + + @Test + fun `given current session when execute then toggle capability is returned`() { + // Given + fakeSession + .fakeHomeServerCapabilitiesService + .givenCapabilities(A_HOMESERVER_CAPABILITIES) + + // When + val result = checkIfCanTogglePushNotificationsViaPusherUseCase.execute(fakeSession) + + // Then + result shouldBeEqualTo A_HOMESERVER_CAPABILITIES.canRemotelyTogglePushNotificationsOfDevices + } +} diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/notification/GetNotificationsStatusUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/notification/GetNotificationsStatusUseCaseTest.kt new file mode 100644 index 0000000000..b38367b098 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/notification/GetNotificationsStatusUseCaseTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2.notification + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import im.vector.app.test.fakes.FakeSession +import im.vector.app.test.fixtures.PusherFixture +import im.vector.app.test.testDispatcher +import io.mockk.every +import io.mockk.mockk +import io.mockk.verifyOrder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.amshove.kluent.shouldBeEqualTo +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.matrix.android.sdk.api.account.LocalNotificationSettingsContent +import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes +import org.matrix.android.sdk.api.session.events.model.toContent + +private const val A_DEVICE_ID = "device-id" + +class GetNotificationsStatusUseCaseTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val fakeSession = FakeSession() + private val fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase = + mockk() + private val fakeCanTogglePushNotificationsViaPusherUseCase = + mockk() + + private val getNotificationsStatusUseCase = + GetNotificationsStatusUseCase( + checkIfCanTogglePushNotificationsViaAccountDataUseCase = fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase, + canTogglePushNotificationsViaPusherUseCase = fakeCanTogglePushNotificationsViaPusherUseCase, + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `given current session and toggle is not supported when execute then resulting flow contains NOT_SUPPORTED value`() = runTest { + // Given + every { fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase.execute(fakeSession, A_DEVICE_ID) } returns false + every { fakeCanTogglePushNotificationsViaPusherUseCase.execute(fakeSession) } returns flowOf(false) + + // When + val result = getNotificationsStatusUseCase.execute(fakeSession, A_DEVICE_ID) + + // Then + result.firstOrNull() shouldBeEqualTo NotificationsStatus.NOT_SUPPORTED + verifyOrder { + // we should first check account data + fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase.execute(fakeSession, A_DEVICE_ID) + fakeCanTogglePushNotificationsViaPusherUseCase.execute(fakeSession) + } + } + + @Test + fun `given current session and toggle via pusher is supported when execute then resulting flow contains status based on pusher value`() = runTest { + // Given + val pushers = listOf( + PusherFixture.aPusher( + deviceId = A_DEVICE_ID, + enabled = true, + ) + ) + fakeSession.pushersService().givenPushersLive(pushers) + every { fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase.execute(fakeSession, A_DEVICE_ID) } returns false + every { fakeCanTogglePushNotificationsViaPusherUseCase.execute(fakeSession) } returns flowOf(true) + + // When + val result = getNotificationsStatusUseCase.execute(fakeSession, A_DEVICE_ID) + + // Then + result.firstOrNull() shouldBeEqualTo NotificationsStatus.ENABLED + } + + @Test + fun `given current session and toggle via account data is supported when execute then resulting flow contains status based on settings value`() = runTest { + // Given + fakeSession + .accountDataService() + .givenGetUserAccountDataEventReturns( + type = UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + A_DEVICE_ID, + content = LocalNotificationSettingsContent( + isSilenced = false + ).toContent(), + ) + every { fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase.execute(fakeSession, A_DEVICE_ID) } returns true + every { fakeCanTogglePushNotificationsViaPusherUseCase.execute(fakeSession) } returns flowOf(false) + + // When + val result = getNotificationsStatusUseCase.execute(fakeSession, A_DEVICE_ID) + + // Then + result.firstOrNull() shouldBeEqualTo NotificationsStatus.ENABLED + } +} diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/TogglePushNotificationUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/notification/TogglePushNotificationUseCaseTest.kt similarity index 57% rename from vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/TogglePushNotificationUseCaseTest.kt rename to vector/src/test/java/im/vector/app/features/settings/devices/v2/notification/TogglePushNotificationUseCaseTest.kt index dc64c74836..35c5979e53 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/TogglePushNotificationUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/notification/TogglePushNotificationUseCaseTest.kt @@ -14,10 +14,12 @@ * limitations under the License. */ -package im.vector.app.features.settings.devices.v2.overview +package im.vector.app.features.settings.devices.v2.notification import im.vector.app.test.fakes.FakeActiveSessionHolder import im.vector.app.test.fixtures.PusherFixture +import io.mockk.every +import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.Test import org.matrix.android.sdk.api.account.LocalNotificationSettingsContent @@ -27,38 +29,60 @@ import org.matrix.android.sdk.api.session.events.model.toContent class TogglePushNotificationUseCaseTest { private val activeSessionHolder = FakeActiveSessionHolder() - private val togglePushNotificationUseCase = TogglePushNotificationUseCase(activeSessionHolder.instance) + private val fakeCheckIfCanTogglePushNotificationsViaPusherUseCase = + mockk() + private val fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase = + mockk() + + private val togglePushNotificationUseCase = + TogglePushNotificationUseCase( + activeSessionHolder = activeSessionHolder.instance, + checkIfCanTogglePushNotificationsViaPusherUseCase = fakeCheckIfCanTogglePushNotificationsViaPusherUseCase, + checkIfCanTogglePushNotificationsViaAccountDataUseCase = fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase, + ) @Test fun `when execute, then toggle enabled for device pushers`() = runTest { + // Given val sessionId = "a_session_id" val pushers = listOf( PusherFixture.aPusher(deviceId = sessionId, enabled = false), PusherFixture.aPusher(deviceId = "another id", enabled = false) ) - activeSessionHolder.fakeSession.pushersService().givenPushersLive(pushers) - activeSessionHolder.fakeSession.pushersService().givenGetPushers(pushers) + val fakeSession = activeSessionHolder.fakeSession + fakeSession.pushersService().givenPushersLive(pushers) + fakeSession.pushersService().givenGetPushers(pushers) + every { fakeCheckIfCanTogglePushNotificationsViaPusherUseCase.execute(fakeSession) } returns true + every { fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase.execute(fakeSession, sessionId) } returns false + // When togglePushNotificationUseCase.execute(sessionId, true) + // Then activeSessionHolder.fakeSession.pushersService().verifyTogglePusherCalled(pushers.first(), true) } @Test fun `when execute, then toggle local notification settings`() = runTest { + // Given val sessionId = "a_session_id" val pushers = listOf( PusherFixture.aPusher(deviceId = sessionId, enabled = false), PusherFixture.aPusher(deviceId = "another id", enabled = false) ) - activeSessionHolder.fakeSession.pushersService().givenPushersLive(pushers) - activeSessionHolder.fakeSession.accountDataService().givenGetUserAccountDataEventReturns( + val fakeSession = activeSessionHolder.fakeSession + fakeSession.pushersService().givenPushersLive(pushers) + fakeSession.accountDataService().givenGetUserAccountDataEventReturns( UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + sessionId, LocalNotificationSettingsContent(isSilenced = true).toContent() ) + every { fakeCheckIfCanTogglePushNotificationsViaPusherUseCase.execute(fakeSession) } returns false + every { fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase.execute(fakeSession, sessionId) } returns true + // When togglePushNotificationUseCase.execute(sessionId, true) + // Then activeSessionHolder.fakeSession.accountDataService().verifyUpdateUserAccountDataEventSucceeds( UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + sessionId, LocalNotificationSettingsContent(isSilenced = false).toContent(), diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt index e7b8eeee9b..1e8c511c42 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt @@ -24,23 +24,31 @@ import im.vector.app.features.settings.devices.v2.GetDeviceFullInfoListUseCase import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType import im.vector.app.test.fakes.FakeActiveSessionHolder +import im.vector.app.test.fakes.FakePendingAuthHandler +import im.vector.app.test.fakes.FakeSignoutSessionsUseCase import im.vector.app.test.fakes.FakeVerificationService import im.vector.app.test.fixtures.aDeviceFullInfo import im.vector.app.test.test import im.vector.app.test.testDispatcher import io.mockk.every +import io.mockk.justRun import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkAll +import io.mockk.verify import io.mockk.verifyAll import kotlinx.coroutines.flow.flowOf +import org.amshove.kluent.shouldBeEqualTo import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test +import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth private const val A_TITLE_RES_ID = 1 -private const val A_DEVICE_ID = "device-id" +private const val A_DEVICE_ID_1 = "device-id-1" +private const val A_DEVICE_ID_2 = "device-id-2" +private const val A_PASSWORD = "password" class OtherSessionsViewModelTest { @@ -55,14 +63,19 @@ class OtherSessionsViewModelTest { private val fakeActiveSessionHolder = FakeActiveSessionHolder() private val fakeGetDeviceFullInfoListUseCase = mockk() - private val fakeRefreshDevicesUseCaseUseCase = mockk() + private val fakeRefreshDevicesUseCase = mockk(relaxed = true) + private val fakeSignoutSessionsUseCase = FakeSignoutSessionsUseCase() + private val fakePendingAuthHandler = FakePendingAuthHandler() - private fun createViewModel(args: OtherSessionsArgs = defaultArgs) = OtherSessionsViewModel( - initialState = OtherSessionsViewState(args), - activeSessionHolder = fakeActiveSessionHolder.instance, - getDeviceFullInfoListUseCase = fakeGetDeviceFullInfoListUseCase, - refreshDevicesUseCase = fakeRefreshDevicesUseCaseUseCase, - ) + private fun createViewModel(viewState: OtherSessionsViewState = OtherSessionsViewState(defaultArgs)) = + OtherSessionsViewModel( + initialState = viewState, + activeSessionHolder = fakeActiveSessionHolder.instance, + getDeviceFullInfoListUseCase = fakeGetDeviceFullInfoListUseCase, + signoutSessionsUseCase = fakeSignoutSessionsUseCase.instance, + pendingAuthHandler = fakePendingAuthHandler.instance, + refreshDevicesUseCase = fakeRefreshDevicesUseCase, + ) @Before fun setup() { @@ -88,6 +101,39 @@ class OtherSessionsViewModelTest { unmockkAll() } + @Test + fun `given the viewModel when initializing it then verification listener is added`() { + // Given + val fakeVerificationService = givenVerificationService() + val devices = mockk>() + givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) + + // When + val viewModel = createViewModel() + + // Then + verify { + fakeVerificationService.addListener(viewModel) + } + } + + @Test + fun `given the viewModel when clearing it then verification listener is removed`() { + // Given + val fakeVerificationService = givenVerificationService() + val devices = mockk>() + givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) + + // When + val viewModel = createViewModel() + viewModel.onCleared() + + // Then + verify { + fakeVerificationService.removeListener(viewModel) + } + } + @Test fun `given the viewModel has been initialized then viewState is updated with devices list`() { // Given @@ -143,7 +189,7 @@ class OtherSessionsViewModelTest { @Test fun `given enable select mode action when handling the action then viewState is updated with correct info`() { // Given - val deviceFullInfo = aDeviceFullInfo(A_DEVICE_ID, isSelected = false) + val deviceFullInfo = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false) val devices: List = listOf(deviceFullInfo) givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) val expectedState = OtherSessionsViewState( @@ -156,7 +202,7 @@ class OtherSessionsViewModelTest { // When val viewModel = createViewModel() val viewModelTest = viewModel.test() - viewModel.handle(OtherSessionsAction.EnableSelectMode(A_DEVICE_ID)) + viewModel.handle(OtherSessionsAction.EnableSelectMode(A_DEVICE_ID_1)) // Then viewModelTest @@ -167,8 +213,8 @@ class OtherSessionsViewModelTest { @Test fun `given disable select mode action when handling the action then viewState is updated with correct info`() { // Given - val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID, isSelected = true) - val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID, isSelected = true) + val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = true) + val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = true) val devices: List = listOf(deviceFullInfo1, deviceFullInfo2) givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) val expectedState = OtherSessionsViewState( @@ -192,7 +238,7 @@ class OtherSessionsViewModelTest { @Test fun `given toggle selection for device action when handling the action then viewState is updated with correct info`() { // Given - val deviceFullInfo = aDeviceFullInfo(A_DEVICE_ID, isSelected = false) + val deviceFullInfo = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false) val devices: List = listOf(deviceFullInfo) givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) val expectedState = OtherSessionsViewState( @@ -205,7 +251,7 @@ class OtherSessionsViewModelTest { // When val viewModel = createViewModel() val viewModelTest = viewModel.test() - viewModel.handle(OtherSessionsAction.ToggleSelectionForDevice(A_DEVICE_ID)) + viewModel.handle(OtherSessionsAction.ToggleSelectionForDevice(A_DEVICE_ID_1)) // Then viewModelTest @@ -216,8 +262,8 @@ class OtherSessionsViewModelTest { @Test fun `given select all action when handling the action then viewState is updated with correct info`() { // Given - val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID, isSelected = false) - val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID, isSelected = true) + val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false) + val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_2, isSelected = true) val devices: List = listOf(deviceFullInfo1, deviceFullInfo2) givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) val expectedState = OtherSessionsViewState( @@ -241,8 +287,8 @@ class OtherSessionsViewModelTest { @Test fun `given deselect all action when handling the action then viewState is updated with correct info`() { // Given - val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID, isSelected = false) - val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID, isSelected = true) + val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false) + val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_2, isSelected = true) val devices: List = listOf(deviceFullInfo1, deviceFullInfo2) givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) val expectedState = OtherSessionsViewState( @@ -263,6 +309,190 @@ class OtherSessionsViewModelTest { .finish() } + @Test + fun `given no reAuth is needed and in selectMode when handling multiSignout action then signout process is performed`() { + // Given + val isSelectModeEnabled = true + val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false) + val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_2, isSelected = true) + val devices: List = listOf(deviceFullInfo1, deviceFullInfo2) + givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) + // signout only selected devices + fakeSignoutSessionsUseCase.givenSignoutSuccess(listOf(A_DEVICE_ID_2)) + val expectedViewState = OtherSessionsViewState( + devices = Success(listOf(deviceFullInfo1, deviceFullInfo2)), + currentFilter = defaultArgs.defaultFilter, + excludeCurrentDevice = defaultArgs.excludeCurrentDevice, + isSelectModeEnabled = isSelectModeEnabled, + ) + + // When + val viewModel = createViewModel(OtherSessionsViewState(defaultArgs).copy(isSelectModeEnabled = isSelectModeEnabled)) + val viewModelTest = viewModel.test() + viewModel.handle(OtherSessionsAction.MultiSignout) + + // Then + viewModelTest + .assertStatesChanges( + expectedViewState, + { copy(isLoading = true) }, + { copy(isLoading = false) } + ) + .assertEvent { it is OtherSessionsViewEvents.SignoutSuccess } + .finish() + verify { + fakeRefreshDevicesUseCase.execute() + } + } + + @Test + fun `given no reAuth is needed and NOT in selectMode when handling multiSignout action then signout process is performed`() { + // Given + val isSelectModeEnabled = false + val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false) + val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_2, isSelected = true) + val devices: List = listOf(deviceFullInfo1, deviceFullInfo2) + givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) + // signout all devices + fakeSignoutSessionsUseCase.givenSignoutSuccess(listOf(A_DEVICE_ID_1, A_DEVICE_ID_2)) + val expectedViewState = OtherSessionsViewState( + devices = Success(listOf(deviceFullInfo1, deviceFullInfo2)), + currentFilter = defaultArgs.defaultFilter, + excludeCurrentDevice = defaultArgs.excludeCurrentDevice, + isSelectModeEnabled = isSelectModeEnabled, + ) + + // When + val viewModel = createViewModel(OtherSessionsViewState(defaultArgs).copy(isSelectModeEnabled = isSelectModeEnabled)) + val viewModelTest = viewModel.test() + viewModel.handle(OtherSessionsAction.MultiSignout) + + // Then + viewModelTest + .assertStatesChanges( + expectedViewState, + { copy(isLoading = true) }, + { copy(isLoading = false) } + ) + .assertEvent { it is OtherSessionsViewEvents.SignoutSuccess } + .finish() + verify { + fakeRefreshDevicesUseCase.execute() + } + } + + @Test + fun `given unexpected error during multiSignout when handling multiSignout action then signout process is performed`() { + // Given + val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false) + val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_2, isSelected = true) + val devices: List = listOf(deviceFullInfo1, deviceFullInfo2) + givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) + val error = Exception() + fakeSignoutSessionsUseCase.givenSignoutError(listOf(A_DEVICE_ID_1, A_DEVICE_ID_2), error) + val expectedViewState = OtherSessionsViewState( + devices = Success(listOf(deviceFullInfo1, deviceFullInfo2)), + currentFilter = defaultArgs.defaultFilter, + excludeCurrentDevice = defaultArgs.excludeCurrentDevice, + ) + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(OtherSessionsAction.MultiSignout) + + // Then + viewModelTest + .assertStatesChanges( + expectedViewState, + { copy(isLoading = true) }, + { copy(isLoading = false) } + ) + .assertEvent { it is OtherSessionsViewEvents.SignoutError && it.error == error } + .finish() + } + + @Test + fun `given reAuth is needed during multiSignout when handling multiSignout action then requestReAuth is sent and pending auth is stored`() { + // Given + val deviceFullInfo1 = aDeviceFullInfo(A_DEVICE_ID_1, isSelected = false) + val deviceFullInfo2 = aDeviceFullInfo(A_DEVICE_ID_2, isSelected = true) + val devices: List = listOf(deviceFullInfo1, deviceFullInfo2) + givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) + val reAuthNeeded = fakeSignoutSessionsUseCase.givenSignoutReAuthNeeded(listOf(A_DEVICE_ID_1, A_DEVICE_ID_2)) + val expectedPendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session) + val expectedReAuthEvent = OtherSessionsViewEvents.RequestReAuth(reAuthNeeded.flowResponse, reAuthNeeded.errCode) + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(OtherSessionsAction.MultiSignout) + + // Then + viewModelTest + .assertEvent { it == expectedReAuthEvent } + .finish() + fakePendingAuthHandler.instance.pendingAuth shouldBeEqualTo expectedPendingAuth + fakePendingAuthHandler.instance.uiaContinuation shouldBeEqualTo reAuthNeeded.uiaContinuation + } + + @Test + fun `given SSO auth has been done when handling ssoAuthDone action then corresponding method of pending auth handler is called`() { + // Given + val devices = mockk>() + givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) + justRun { fakePendingAuthHandler.instance.ssoAuthDone() } + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(OtherSessionsAction.SsoAuthDone) + + // Then + viewModelTest.finish() + verifyAll { + fakePendingAuthHandler.instance.ssoAuthDone() + } + } + + @Test + fun `given password auth has been done when handling passwordAuthDone action then corresponding method of pending auth handler is called`() { + // Given + val devices = mockk>() + givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) + justRun { fakePendingAuthHandler.instance.passwordAuthDone(any()) } + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(OtherSessionsAction.PasswordAuthDone(A_PASSWORD)) + + // Then + viewModelTest.finish() + verifyAll { + fakePendingAuthHandler.instance.passwordAuthDone(A_PASSWORD) + } + } + + @Test + fun `given reAuth has been cancelled when handling reAuthCancelled action then corresponding method of pending auth handler is called`() { + // Given + val devices = mockk>() + givenGetDeviceFullInfoListReturns(filterType = defaultArgs.defaultFilter, devices) + justRun { fakePendingAuthHandler.instance.reAuthCancelled() } + + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(OtherSessionsAction.ReAuthCancelled) + + // Then + viewModelTest.finish() + verifyAll { + fakePendingAuthHandler.instance.reAuthCancelled() + } + } + private fun givenGetDeviceFullInfoListReturns( filterType: DeviceManagerFilterType, devices: List, diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt index 544059b77f..1a57b76020 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt @@ -20,19 +20,17 @@ import android.os.SystemClock import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.airbnb.mvrx.Success import com.airbnb.mvrx.test.MavericksTestRule -import im.vector.app.R import im.vector.app.features.settings.devices.v2.DeviceFullInfo import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase +import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase -import im.vector.app.features.settings.devices.v2.signout.SignoutSessionResult -import im.vector.app.features.settings.devices.v2.signout.SignoutSessionUseCase import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase import im.vector.app.test.fakes.FakeActiveSessionHolder +import im.vector.app.test.fakes.FakeGetNotificationsStatusUseCase import im.vector.app.test.fakes.FakePendingAuthHandler -import im.vector.app.test.fakes.FakeStringProvider +import im.vector.app.test.fakes.FakeSignoutSessionsUseCase import im.vector.app.test.fakes.FakeTogglePushNotificationUseCase import im.vector.app.test.fakes.FakeVerificationService -import im.vector.app.test.fixtures.PusherFixture.aPusher import im.vector.app.test.test import im.vector.app.test.testDispatcher import io.mockk.coEvery @@ -42,7 +40,6 @@ import io.mockk.just import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.runs -import io.mockk.slot import io.mockk.unmockkAll import io.mockk.verify import io.mockk.verifyAll @@ -52,19 +49,11 @@ import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test -import org.matrix.android.sdk.api.auth.UIABaseAuth -import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor -import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse -import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth -import javax.net.ssl.HttpsURLConnection -import kotlin.coroutines.Continuation private const val A_SESSION_ID_1 = "session-id-1" private const val A_SESSION_ID_2 = "session-id-2" -private const val AUTH_ERROR_MESSAGE = "auth-error-message" -private const val AN_ERROR_MESSAGE = "error-message" private const val A_PASSWORD = "password" class SessionOverviewViewModelTest { @@ -80,25 +69,26 @@ class SessionOverviewViewModelTest { ) private val getDeviceFullInfoUseCase = mockk(relaxed = true) private val fakeActiveSessionHolder = FakeActiveSessionHolder() - private val fakeStringProvider = FakeStringProvider() private val checkIfCurrentSessionCanBeVerifiedUseCase = mockk() - private val signoutSessionUseCase = mockk() + private val fakeSignoutSessionsUseCase = FakeSignoutSessionsUseCase() private val interceptSignoutFlowResponseUseCase = mockk() private val fakePendingAuthHandler = FakePendingAuthHandler() - private val refreshDevicesUseCase = mockk() + private val refreshDevicesUseCase = mockk(relaxed = true) private val togglePushNotificationUseCase = FakeTogglePushNotificationUseCase() + private val fakeGetNotificationsStatusUseCase = FakeGetNotificationsStatusUseCase() + private val notificationsStatus = NotificationsStatus.ENABLED private fun createViewModel() = SessionOverviewViewModel( initialState = SessionOverviewViewState(args), - stringProvider = fakeStringProvider.instance, getDeviceFullInfoUseCase = getDeviceFullInfoUseCase, checkIfCurrentSessionCanBeVerifiedUseCase = checkIfCurrentSessionCanBeVerifiedUseCase, - signoutSessionUseCase = signoutSessionUseCase, + signoutSessionsUseCase = fakeSignoutSessionsUseCase.instance, interceptSignoutFlowResponseUseCase = interceptSignoutFlowResponseUseCase, pendingAuthHandler = fakePendingAuthHandler.instance, activeSessionHolder = fakeActiveSessionHolder.instance, refreshDevicesUseCase = refreshDevicesUseCase, togglePushNotificationUseCase = togglePushNotificationUseCase.instance, + getNotificationsStatusUseCase = fakeGetNotificationsStatusUseCase.instance, ) @Before @@ -108,6 +98,21 @@ class SessionOverviewViewModelTest { every { SystemClock.elapsedRealtime() } returns 1234 givenVerificationService() + fakeGetNotificationsStatusUseCase.givenExecuteReturns( + fakeActiveSessionHolder.fakeSession, + A_SESSION_ID_1, + notificationsStatus + ) + } + + private fun givenVerificationService(): FakeVerificationService { + val fakeVerificationService = fakeActiveSessionHolder + .fakeSession + .fakeCryptoService + .fakeVerificationService + fakeVerificationService.givenAddListenerSucceeds() + fakeVerificationService.givenRemoveListenerSucceeds() + return fakeVerificationService } @After @@ -115,6 +120,35 @@ class SessionOverviewViewModelTest { unmockkAll() } + @Test + fun `given the viewModel when initializing it then verification listener is added`() { + // Given + val fakeVerificationService = givenVerificationService() + + // When + val viewModel = createViewModel() + + // Then + verify { + fakeVerificationService.addListener(viewModel) + } + } + + @Test + fun `given the viewModel when clearing it then verification listener is removed`() { + // Given + val fakeVerificationService = givenVerificationService() + + // When + val viewModel = createViewModel() + viewModel.onCleared() + + // Then + verify { + fakeVerificationService.removeListener(viewModel) + } + } + @Test fun `given the viewModel has been initialized then pushers are refreshed`() { createViewModel() @@ -131,7 +165,7 @@ class SessionOverviewViewModelTest { deviceId = A_SESSION_ID_1, deviceInfo = Success(deviceFullInfo), isCurrentSessionTrusted = true, - notificationsEnabled = true, + notificationsStatus = notificationsStatus, ) val viewModel = createViewModel() @@ -218,8 +252,7 @@ class SessionOverviewViewModelTest { val deviceFullInfo = mockk() every { deviceFullInfo.isCurrentDevice } returns false every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo) - givenSignoutSuccess(A_SESSION_ID_1) - every { refreshDevicesUseCase.execute() } just runs + fakeSignoutSessionsUseCase.givenSignoutSuccess(listOf(A_SESSION_ID_1)) val signoutAction = SessionOverviewAction.SignoutOtherSession givenCurrentSessionIsTrusted() val expectedViewState = SessionOverviewViewState( @@ -227,7 +260,7 @@ class SessionOverviewViewModelTest { isCurrentSessionTrusted = true, deviceInfo = Success(deviceFullInfo), isLoading = false, - notificationsEnabled = true, + notificationsStatus = notificationsStatus, ) // When @@ -249,41 +282,6 @@ class SessionOverviewViewModelTest { } } - @Test - fun `given another session and server error during signout when handling signout action then signout process is performed`() { - // Given - val deviceFullInfo = mockk() - every { deviceFullInfo.isCurrentDevice } returns false - every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo) - val serverError = Failure.OtherServerError(errorBody = "", httpCode = HttpsURLConnection.HTTP_UNAUTHORIZED) - givenSignoutError(A_SESSION_ID_1, serverError) - val signoutAction = SessionOverviewAction.SignoutOtherSession - givenCurrentSessionIsTrusted() - val expectedViewState = SessionOverviewViewState( - deviceId = A_SESSION_ID_1, - isCurrentSessionTrusted = true, - deviceInfo = Success(deviceFullInfo), - isLoading = false, - notificationsEnabled = true, - ) - fakeStringProvider.given(R.string.authentication_error, AUTH_ERROR_MESSAGE) - - // When - val viewModel = createViewModel() - val viewModelTest = viewModel.test() - viewModel.handle(signoutAction) - - // Then - viewModelTest - .assertStatesChanges( - expectedViewState, - { copy(isLoading = true) }, - { copy(isLoading = false) } - ) - .assertEvent { it is SessionOverviewViewEvent.SignoutError && it.error.message == AUTH_ERROR_MESSAGE } - .finish() - } - @Test fun `given another session and unexpected error during signout when handling signout action then signout process is performed`() { // Given @@ -291,7 +289,7 @@ class SessionOverviewViewModelTest { every { deviceFullInfo.isCurrentDevice } returns false every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo) val error = Exception() - givenSignoutError(A_SESSION_ID_1, error) + fakeSignoutSessionsUseCase.givenSignoutError(listOf(A_SESSION_ID_1), error) val signoutAction = SessionOverviewAction.SignoutOtherSession givenCurrentSessionIsTrusted() val expectedViewState = SessionOverviewViewState( @@ -299,9 +297,8 @@ class SessionOverviewViewModelTest { isCurrentSessionTrusted = true, deviceInfo = Success(deviceFullInfo), isLoading = false, - notificationsEnabled = true, + notificationsStatus = notificationsStatus, ) - fakeStringProvider.given(R.string.matrix_error, AN_ERROR_MESSAGE) // When val viewModel = createViewModel() @@ -315,7 +312,7 @@ class SessionOverviewViewModelTest { { copy(isLoading = true) }, { copy(isLoading = false) } ) - .assertEvent { it is SessionOverviewViewEvent.SignoutError && it.error.message == AN_ERROR_MESSAGE } + .assertEvent { it is SessionOverviewViewEvent.SignoutError && it.error == error } .finish() } @@ -325,7 +322,7 @@ class SessionOverviewViewModelTest { val deviceFullInfo = mockk() every { deviceFullInfo.isCurrentDevice } returns false every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo) - val reAuthNeeded = givenSignoutReAuthNeeded(A_SESSION_ID_1) + val reAuthNeeded = fakeSignoutSessionsUseCase.givenSignoutReAuthNeeded(listOf(A_SESSION_ID_1)) val signoutAction = SessionOverviewAction.SignoutOtherSession givenCurrentSessionIsTrusted() val expectedPendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session) @@ -410,53 +407,6 @@ class SessionOverviewViewModelTest { } } - private fun givenSignoutSuccess(deviceId: String) { - val interceptor = slot() - val flowResponse = mockk() - val errorCode = "errorCode" - val promise = mockk>() - every { interceptSignoutFlowResponseUseCase.execute(flowResponse, errorCode, promise) } returns SignoutSessionResult.Completed - coEvery { signoutSessionUseCase.execute(deviceId, capture(interceptor)) } coAnswers { - secondArg().performStage(flowResponse, errorCode, promise) - Result.success(Unit) - } - } - - private fun givenSignoutReAuthNeeded(deviceId: String): SignoutSessionResult.ReAuthNeeded { - val interceptor = slot() - val flowResponse = mockk() - every { flowResponse.session } returns A_SESSION_ID_1 - val errorCode = "errorCode" - val promise = mockk>() - val reAuthNeeded = SignoutSessionResult.ReAuthNeeded( - pendingAuth = mockk(), - uiaContinuation = promise, - flowResponse = flowResponse, - errCode = errorCode, - ) - every { interceptSignoutFlowResponseUseCase.execute(flowResponse, errorCode, promise) } returns reAuthNeeded - coEvery { signoutSessionUseCase.execute(deviceId, capture(interceptor)) } coAnswers { - secondArg().performStage(flowResponse, errorCode, promise) - Result.success(Unit) - } - - return reAuthNeeded - } - - private fun givenSignoutError(deviceId: String, error: Throwable) { - coEvery { signoutSessionUseCase.execute(deviceId, any()) } returns Result.failure(error) - } - - private fun givenVerificationService(): FakeVerificationService { - val fakeVerificationService = fakeActiveSessionHolder - .fakeSession - .fakeCryptoService - .fakeVerificationService - fakeVerificationService.givenAddListenerSucceeds() - fakeVerificationService.givenRemoveListenerSucceeds() - return fakeVerificationService - } - private fun givenCurrentSessionIsTrusted() { fakeActiveSessionHolder.fakeSession.givenSessionId(A_SESSION_ID_2) val deviceFullInfo = mockk() @@ -466,13 +416,10 @@ class SessionOverviewViewModelTest { @Test fun `when viewModel init, then observe pushers and emit to state`() { - val pushers = listOf(aPusher(deviceId = A_SESSION_ID_1)) - fakeActiveSessionHolder.fakeSession.pushersService().givenPushersLive(pushers) - val viewModel = createViewModel() viewModel.test() - .assertLatestState { state -> state.notificationsEnabled } + .assertLatestState { state -> state.notificationsStatus == notificationsStatus } .finish() } @@ -483,6 +430,6 @@ class SessionOverviewViewModelTest { viewModel.handle(SessionOverviewAction.TogglePushNotifications(A_SESSION_ID_1, true)) togglePushNotificationUseCase.verifyExecute(A_SESSION_ID_1, true) - viewModel.test().assertLatestState { state -> state.notificationsEnabled }.finish() + viewModel.test().assertLatestState { state -> state.notificationsStatus == NotificationsStatus.ENABLED }.finish() } } diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCaseTest.kt index 35551ba36e..cd0575f2a0 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCaseTest.kt @@ -24,8 +24,8 @@ import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.runs import io.mockk.unmockkAll +import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldBeEqualTo -import org.amshove.kluent.shouldBeInstanceOf import org.junit.After import org.junit.Before import org.junit.Test @@ -63,7 +63,7 @@ class InterceptSignoutFlowResponseUseCaseTest { } @Test - fun `given no error and a stored password and a next stage as password when intercepting then promise is resumed and success is returned`() { + fun `given no error and a stored password and a next stage as password when intercepting then promise is resumed and null is returned`() { // Given val registrationFlowResponse = givenNextUncompletedStage(LoginFlowTypes.PASSWORD, A_SESSION_ID) fakeReAuthHelper.givenStoredPassword(A_PASSWORD) @@ -84,7 +84,7 @@ class InterceptSignoutFlowResponseUseCaseTest { ) // Then - result shouldBeInstanceOf (SignoutSessionResult.Completed::class) + result shouldBe null every { promise.resume(expectedAuth) } @@ -97,7 +97,7 @@ class InterceptSignoutFlowResponseUseCaseTest { fakeReAuthHelper.givenStoredPassword(A_PASSWORD) val errorCode = AN_ERROR_CODE val promise = mockk>() - val expectedResult = SignoutSessionResult.ReAuthNeeded( + val expectedResult = SignoutSessionsReAuthNeeded( pendingAuth = DefaultBaseAuth(session = A_SESSION_ID), uiaContinuation = promise, flowResponse = registrationFlowResponse, @@ -122,7 +122,7 @@ class InterceptSignoutFlowResponseUseCaseTest { fakeReAuthHelper.givenStoredPassword(A_PASSWORD) val errorCode: String? = null val promise = mockk>() - val expectedResult = SignoutSessionResult.ReAuthNeeded( + val expectedResult = SignoutSessionsReAuthNeeded( pendingAuth = DefaultBaseAuth(session = A_SESSION_ID), uiaContinuation = promise, flowResponse = registrationFlowResponse, @@ -147,7 +147,7 @@ class InterceptSignoutFlowResponseUseCaseTest { fakeReAuthHelper.givenStoredPassword(null) val errorCode: String? = null val promise = mockk>() - val expectedResult = SignoutSessionResult.ReAuthNeeded( + val expectedResult = SignoutSessionsReAuthNeeded( pendingAuth = DefaultBaseAuth(session = A_SESSION_ID), uiaContinuation = promise, flowResponse = registrationFlowResponse, diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionUseCaseTest.kt deleted file mode 100644 index 5af91c16ce..0000000000 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionUseCaseTest.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.app.features.settings.devices.v2.signout - -import im.vector.app.test.fakes.FakeActiveSessionHolder -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.amshove.kluent.shouldBe -import org.junit.Test -import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor - -private const val A_DEVICE_ID = "device-id" - -class SignoutSessionUseCaseTest { - - private val fakeActiveSessionHolder = FakeActiveSessionHolder() - - private val signoutSessionUseCase = SignoutSessionUseCase( - activeSessionHolder = fakeActiveSessionHolder.instance - ) - - @Test - fun `given a device id when signing out with success then success result is returned`() = runTest { - // Given - val interceptor = givenAuthInterceptor() - fakeActiveSessionHolder.fakeSession - .fakeCryptoService - .givenDeleteDeviceSucceeds(A_DEVICE_ID) - - // When - val result = signoutSessionUseCase.execute(A_DEVICE_ID, interceptor) - - // Then - result.isSuccess shouldBe true - every { - fakeActiveSessionHolder.fakeSession - .fakeCryptoService - .deleteDevice(A_DEVICE_ID, interceptor, any()) - } - } - - @Test - fun `given a device id when signing out with error then failure result is returned`() = runTest { - // Given - val interceptor = givenAuthInterceptor() - val error = mockk() - fakeActiveSessionHolder.fakeSession - .fakeCryptoService - .givenDeleteDeviceFailsWithError(A_DEVICE_ID, error) - - // When - val result = signoutSessionUseCase.execute(A_DEVICE_ID, interceptor) - - // Then - result.isFailure shouldBe true - every { - fakeActiveSessionHolder.fakeSession - .fakeCryptoService - .deleteDevice(A_DEVICE_ID, interceptor, any()) - } - } - - private fun givenAuthInterceptor() = mockk() -} diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsUseCaseTest.kt new file mode 100644 index 0000000000..70d2b4b039 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsUseCaseTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2.signout + +import im.vector.app.test.fakes.FakeActiveSessionHolder +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBe +import org.junit.Test + +private const val A_DEVICE_ID_1 = "device-id-1" +private const val A_DEVICE_ID_2 = "device-id-2" + +class SignoutSessionsUseCaseTest { + + private val fakeActiveSessionHolder = FakeActiveSessionHolder() + private val fakeInterceptSignoutFlowResponseUseCase = mockk() + + private val signoutSessionsUseCase = SignoutSessionsUseCase( + activeSessionHolder = fakeActiveSessionHolder.instance, + interceptSignoutFlowResponseUseCase = fakeInterceptSignoutFlowResponseUseCase, + ) + + @Test + fun `given a list of device ids when signing out with success then success result is returned`() = runTest { + // Given + val callback = givenOnReAuthCallback() + val deviceIds = listOf(A_DEVICE_ID_1, A_DEVICE_ID_2) + fakeActiveSessionHolder.fakeSession + .fakeCryptoService + .givenDeleteDevicesSucceeds(deviceIds) + + // When + val result = signoutSessionsUseCase.execute(deviceIds, callback) + + // Then + result.isSuccess shouldBe true + verify { + fakeActiveSessionHolder.fakeSession + .fakeCryptoService + .deleteDevices(deviceIds, any(), any()) + } + } + + @Test + fun `given a list of device ids when signing out with error then failure result is returned`() = runTest { + // Given + val interceptor = givenOnReAuthCallback() + val deviceIds = listOf(A_DEVICE_ID_1, A_DEVICE_ID_2) + val error = mockk() + fakeActiveSessionHolder.fakeSession + .fakeCryptoService + .givenDeleteDevicesFailsWithError(deviceIds, error) + + // When + val result = signoutSessionsUseCase.execute(deviceIds, interceptor) + + // Then + result.isFailure shouldBe true + verify { + fakeActiveSessionHolder.fakeSession + .fakeCryptoService + .deleteDevices(deviceIds, any(), any()) + } + } + + @Test + fun `given a list of device ids when signing out with reAuth needed then callback is called`() = runTest { + // Given + val callback = givenOnReAuthCallback() + val deviceIds = listOf(A_DEVICE_ID_1, A_DEVICE_ID_2) + fakeActiveSessionHolder.fakeSession + .fakeCryptoService + .givenDeleteDevicesNeedsUIAuth(deviceIds) + val reAuthNeeded = SignoutSessionsReAuthNeeded( + pendingAuth = mockk(), + uiaContinuation = mockk(), + flowResponse = mockk(), + errCode = "errorCode" + ) + every { fakeInterceptSignoutFlowResponseUseCase.execute(any(), any(), any()) } returns reAuthNeeded + + // When + val result = signoutSessionsUseCase.execute(deviceIds, callback) + + // Then + result.isSuccess shouldBe true + verify { + fakeActiveSessionHolder.fakeSession + .fakeCryptoService + .deleteDevices(deviceIds, any(), any()) + callback(reAuthNeeded) + } + } + + private fun givenOnReAuthCallback(): (SignoutSessionsReAuthNeeded) -> Unit = {} +} diff --git a/vector/src/test/java/im/vector/app/features/settings/notifications/DisableNotificationsForCurrentSessionUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/settings/notifications/DisableNotificationsForCurrentSessionUseCaseTest.kt new file mode 100644 index 0000000000..e460413a39 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/settings/notifications/DisableNotificationsForCurrentSessionUseCaseTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.notifications + +import im.vector.app.features.settings.devices.v2.notification.CheckIfCanTogglePushNotificationsViaPusherUseCase +import im.vector.app.features.settings.devices.v2.notification.TogglePushNotificationUseCase +import im.vector.app.test.fakes.FakeActiveSessionHolder +import im.vector.app.test.fakes.FakePushersManager +import im.vector.app.test.fakes.FakeUnifiedPushHelper +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test + +private const val A_SESSION_ID = "session-id" + +class DisableNotificationsForCurrentSessionUseCaseTest { + + private val fakeActiveSessionHolder = FakeActiveSessionHolder() + private val fakeUnifiedPushHelper = FakeUnifiedPushHelper() + private val fakePushersManager = FakePushersManager() + private val fakeCheckIfCanTogglePushNotificationsViaPusherUseCase = mockk() + private val fakeTogglePushNotificationUseCase = mockk() + + private val disableNotificationsForCurrentSessionUseCase = DisableNotificationsForCurrentSessionUseCase( + activeSessionHolder = fakeActiveSessionHolder.instance, + unifiedPushHelper = fakeUnifiedPushHelper.instance, + pushersManager = fakePushersManager.instance, + checkIfCanTogglePushNotificationsViaPusherUseCase = fakeCheckIfCanTogglePushNotificationsViaPusherUseCase, + togglePushNotificationUseCase = fakeTogglePushNotificationUseCase, + ) + + @Test + fun `given toggle via pusher is possible when execute then disable notification via toggle of existing pusher`() = runTest { + // Given + val fakeSession = fakeActiveSessionHolder.fakeSession + fakeSession.givenSessionId(A_SESSION_ID) + every { fakeCheckIfCanTogglePushNotificationsViaPusherUseCase.execute(fakeSession) } returns true + coJustRun { fakeTogglePushNotificationUseCase.execute(A_SESSION_ID, any()) } + + // When + disableNotificationsForCurrentSessionUseCase.execute() + + // Then + coVerify { fakeTogglePushNotificationUseCase.execute(A_SESSION_ID, false) } + } + + @Test + fun `given toggle via pusher is NOT possible when execute then disable notification by unregistering the pusher`() = runTest { + // Given + val fakeSession = fakeActiveSessionHolder.fakeSession + fakeSession.givenSessionId(A_SESSION_ID) + every { fakeCheckIfCanTogglePushNotificationsViaPusherUseCase.execute(fakeSession) } returns false + fakeUnifiedPushHelper.givenUnregister(fakePushersManager.instance) + + // When + disableNotificationsForCurrentSessionUseCase.execute() + + // Then + fakeUnifiedPushHelper.verifyUnregister(fakePushersManager.instance) + } +} diff --git a/vector/src/test/java/im/vector/app/features/settings/notifications/EnableNotificationsForCurrentSessionUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/settings/notifications/EnableNotificationsForCurrentSessionUseCaseTest.kt new file mode 100644 index 0000000000..eb6629cb13 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/settings/notifications/EnableNotificationsForCurrentSessionUseCaseTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.notifications + +import androidx.fragment.app.FragmentActivity +import im.vector.app.features.settings.devices.v2.notification.CheckIfCanTogglePushNotificationsViaPusherUseCase +import im.vector.app.features.settings.devices.v2.notification.TogglePushNotificationUseCase +import im.vector.app.test.fakes.FakeActiveSessionHolder +import im.vector.app.test.fakes.FakeFcmHelper +import im.vector.app.test.fakes.FakePushersManager +import im.vector.app.test.fakes.FakeUnifiedPushHelper +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test + +private const val A_SESSION_ID = "session-id" + +class EnableNotificationsForCurrentSessionUseCaseTest { + + private val fakeActiveSessionHolder = FakeActiveSessionHolder() + private val fakeUnifiedPushHelper = FakeUnifiedPushHelper() + private val fakePushersManager = FakePushersManager() + private val fakeFcmHelper = FakeFcmHelper() + private val fakeCheckIfCanTogglePushNotificationsViaPusherUseCase = mockk() + private val fakeTogglePushNotificationUseCase = mockk() + + private val enableNotificationsForCurrentSessionUseCase = EnableNotificationsForCurrentSessionUseCase( + activeSessionHolder = fakeActiveSessionHolder.instance, + unifiedPushHelper = fakeUnifiedPushHelper.instance, + pushersManager = fakePushersManager.instance, + fcmHelper = fakeFcmHelper.instance, + checkIfCanTogglePushNotificationsViaPusherUseCase = fakeCheckIfCanTogglePushNotificationsViaPusherUseCase, + togglePushNotificationUseCase = fakeTogglePushNotificationUseCase, + ) + + @Test + fun `given no existing pusher for current session when execute then a new pusher is registered`() = runTest { + // Given + val fragmentActivity = mockk() + fakePushersManager.givenGetPusherForCurrentSessionReturns(null) + fakeUnifiedPushHelper.givenRegister(fragmentActivity) + fakeUnifiedPushHelper.givenIsEmbeddedDistributorReturns(true) + fakeFcmHelper.givenEnsureFcmTokenIsRetrieved(fragmentActivity, fakePushersManager.instance) + every { fakeCheckIfCanTogglePushNotificationsViaPusherUseCase.execute(fakeActiveSessionHolder.fakeSession) } returns false + + // When + enableNotificationsForCurrentSessionUseCase.execute(fragmentActivity) + + // Then + fakeUnifiedPushHelper.verifyRegister(fragmentActivity) + fakeFcmHelper.verifyEnsureFcmTokenIsRetrieved(fragmentActivity, fakePushersManager.instance, registerPusher = true) + } + + @Test + fun `given toggle via Pusher is possible when execute then current pusher is toggled to true`() = runTest { + // Given + val fragmentActivity = mockk() + fakePushersManager.givenGetPusherForCurrentSessionReturns(mockk()) + val fakeSession = fakeActiveSessionHolder.fakeSession + fakeSession.givenSessionId(A_SESSION_ID) + every { fakeCheckIfCanTogglePushNotificationsViaPusherUseCase.execute(fakeActiveSessionHolder.fakeSession) } returns true + coJustRun { fakeTogglePushNotificationUseCase.execute(A_SESSION_ID, any()) } + + // When + enableNotificationsForCurrentSessionUseCase.execute(fragmentActivity) + + // Then + coVerify { fakeTogglePushNotificationUseCase.execute(A_SESSION_ID, true) } + } +} diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCaseTest.kt index 5c42b26c54..a1ec91aab8 100644 --- a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCaseTest.kt @@ -17,9 +17,10 @@ package im.vector.app.features.voicebroadcast.usecase import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.recording.usecase.PauseVoiceBroadcastUseCase import im.vector.app.test.fakes.FakeRoom import im.vector.app.test.fakes.FakeRoomService import im.vector.app.test.fakes.FakeSession diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCaseTest.kt index a1bc3a04ec..8b66d45dd4 100644 --- a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCaseTest.kt @@ -17,9 +17,10 @@ package im.vector.app.features.voicebroadcast.usecase import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.recording.usecase.ResumeVoiceBroadcastUseCase import im.vector.app.test.fakes.FakeRoom import im.vector.app.test.fakes.FakeRoomService import im.vector.app.test.fakes.FakeSession diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt index 9fa6b7a450..5b4076378c 100644 --- a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt @@ -17,23 +17,27 @@ package im.vector.app.features.voicebroadcast.usecase import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.recording.usecase.StartVoiceBroadcastUseCase import im.vector.app.test.fakes.FakeContext import im.vector.app.test.fakes.FakeRoom import im.vector.app.test.fakes.FakeRoomService import im.vector.app.test.fakes.FakeSession -import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every +import io.mockk.justRun import io.mockk.mockk import io.mockk.slot +import io.mockk.spyk import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldBeNull +import org.junit.Before import org.junit.Test -import org.matrix.android.sdk.api.query.QueryStringValue 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.toContent @@ -48,13 +52,25 @@ class StartVoiceBroadcastUseCaseTest { private val fakeRoom = FakeRoom() private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom)) private val fakeVoiceBroadcastRecorder = mockk(relaxed = true) - private val startVoiceBroadcastUseCase = StartVoiceBroadcastUseCase( - fakeSession, - fakeVoiceBroadcastRecorder, - FakeContext().instance, - mockk() + private val fakeGetOngoingVoiceBroadcastsUseCase = mockk() + private val startVoiceBroadcastUseCase = spyk( + StartVoiceBroadcastUseCase( + session = fakeSession, + voiceBroadcastRecorder = fakeVoiceBroadcastRecorder, + context = FakeContext().instance, + buildMeta = mockk(), + getOngoingVoiceBroadcastsUseCase = fakeGetOngoingVoiceBroadcastsUseCase, + stopVoiceBroadcastUseCase = mockk() + ) ) + @Before + fun setup() { + every { fakeRoom.roomId } returns A_ROOM_ID + justRun { startVoiceBroadcastUseCase.assertHasEnoughPowerLevels(fakeRoom) } + every { fakeVoiceBroadcastRecorder.recordingState } returns VoiceBroadcastRecorder.State.Idle + } + @Test fun `given a room id with potential several existing voice broadcast states when calling execute then the voice broadcast is started or not`() = runTest { val cases = VoiceBroadcastState.values() @@ -79,8 +95,8 @@ class StartVoiceBroadcastUseCaseTest { private suspend fun testVoiceBroadcastStarted(voiceBroadcasts: List) { // Given - clearAllMocks() - givenAVoiceBroadcasts(voiceBroadcasts) + setup() + givenVoiceBroadcasts(voiceBroadcasts) val voiceBroadcastInfoContentInterceptor = slot() coEvery { fakeRoom.stateService().sendStateEvent(any(), any(), capture(voiceBroadcastInfoContentInterceptor)) } coAnswers { AN_EVENT_ID } @@ -102,8 +118,8 @@ class StartVoiceBroadcastUseCaseTest { private suspend fun testVoiceBroadcastNotStarted(voiceBroadcasts: List) { // Given - clearAllMocks() - givenAVoiceBroadcasts(voiceBroadcasts) + setup() + givenVoiceBroadcasts(voiceBroadcasts) // When startVoiceBroadcastUseCase.execute(A_ROOM_ID) @@ -112,7 +128,7 @@ class StartVoiceBroadcastUseCaseTest { coVerify(exactly = 0) { fakeRoom.stateService().sendStateEvent(any(), any(), any()) } } - private fun givenAVoiceBroadcasts(voiceBroadcasts: List) { + private fun givenVoiceBroadcasts(voiceBroadcasts: List) { val events = voiceBroadcasts.map { Event( type = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, @@ -122,7 +138,9 @@ class StartVoiceBroadcastUseCaseTest { ).toContent() ) } - fakeRoom.stateService().givenGetStateEvents(QueryStringValue.IsNotEmpty, events) + .mapNotNull { it.asVoiceBroadcastEvent() } + .filter { it.content?.voiceBroadcastState != VoiceBroadcastState.STOPPED } + every { fakeGetOngoingVoiceBroadcastsUseCase.execute(any()) } returns events } private data class VoiceBroadcast(val userId: String, val state: VoiceBroadcastState) diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCaseTest.kt index ee6b141bd9..4b15f50be9 100644 --- a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCaseTest.kt @@ -17,9 +17,10 @@ package im.vector.app.features.voicebroadcast.usecase import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.recording.usecase.StopVoiceBroadcastUseCase import im.vector.app.test.fakes.FakeRoom import im.vector.app.test.fakes.FakeRoomService import im.vector.app.test.fakes.FakeSession diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeCryptoService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeCryptoService.kt index e96a58faa0..b23f018cf5 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeCryptoService.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeCryptoService.kt @@ -22,6 +22,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.slot import org.matrix.android.sdk.api.MatrixCallback +import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor import org.matrix.android.sdk.api.session.crypto.CryptoService import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo @@ -70,16 +71,21 @@ class FakeCryptoService( } } - fun givenDeleteDeviceSucceeds(deviceId: String) { - val matrixCallback = slot>() - every { deleteDevice(deviceId, any(), capture(matrixCallback)) } answers { + fun givenDeleteDevicesSucceeds(deviceIds: List) { + every { deleteDevices(deviceIds, any(), any()) } answers { thirdArg>().onSuccess(Unit) } } - fun givenDeleteDeviceFailsWithError(deviceId: String, error: Exception) { - val matrixCallback = slot>() - every { deleteDevice(deviceId, any(), capture(matrixCallback)) } answers { + fun givenDeleteDevicesNeedsUIAuth(deviceIds: List) { + every { deleteDevices(deviceIds, any(), any()) } answers { + secondArg().performStage(mockk(), "", mockk()) + thirdArg>().onSuccess(Unit) + } + } + + fun givenDeleteDevicesFailsWithError(deviceIds: List, error: Exception) { + every { deleteDevices(deviceIds, any(), any()) } answers { thirdArg>().onFailure(error) } } diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeEnableNotificationsSettingUpdater.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeEnableNotificationsSettingUpdater.kt new file mode 100644 index 0000000000..a78dd1a34b --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeEnableNotificationsSettingUpdater.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.test.fakes + +import im.vector.app.core.notification.EnableNotificationsSettingUpdater +import io.mockk.justRun +import io.mockk.mockk +import org.matrix.android.sdk.api.session.Session + +class FakeEnableNotificationsSettingUpdater { + + val instance = mockk() + + fun givenOnSessionsStarted(session: Session) { + justRun { instance.onSessionsStarted(session) } + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeFcmHelper.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeFcmHelper.kt new file mode 100644 index 0000000000..11abf18794 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeFcmHelper.kt @@ -0,0 +1,44 @@ +/* + * 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.test.fakes + +import androidx.fragment.app.FragmentActivity +import im.vector.app.core.pushers.FcmHelper +import im.vector.app.core.pushers.PushersManager +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify + +class FakeFcmHelper { + + val instance = mockk() + + fun givenEnsureFcmTokenIsRetrieved( + fragmentActivity: FragmentActivity, + pushersManager: PushersManager, + ) { + justRun { instance.ensureFcmTokenIsRetrieved(fragmentActivity, pushersManager, any()) } + } + + fun verifyEnsureFcmTokenIsRetrieved( + fragmentActivity: FragmentActivity, + pushersManager: PushersManager, + registerPusher: Boolean, + ) { + verify { instance.ensureFcmTokenIsRetrieved(fragmentActivity, pushersManager, registerPusher) } + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeGetNotificationsStatusUseCase.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeGetNotificationsStatusUseCase.kt new file mode 100644 index 0000000000..a9c1b37d69 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeGetNotificationsStatusUseCase.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import im.vector.app.features.settings.devices.v2.notification.GetNotificationsStatusUseCase +import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import org.matrix.android.sdk.api.session.Session + +class FakeGetNotificationsStatusUseCase { + + val instance = mockk() + + fun givenExecuteReturns( + session: Session, + sessionId: String, + notificationsStatus: NotificationsStatus + ) { + every { instance.execute(session, sessionId) } returns flowOf(notificationsStatus) + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeHomeServerCapabilitiesService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeHomeServerCapabilitiesService.kt index 006789f62b..c816c51c0f 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeHomeServerCapabilitiesService.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeHomeServerCapabilitiesService.kt @@ -16,14 +16,24 @@ package im.vector.app.test.fakes +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import io.mockk.every import io.mockk.mockk import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService +import org.matrix.android.sdk.api.util.Optional +import org.matrix.android.sdk.api.util.toOptional class FakeHomeServerCapabilitiesService : HomeServerCapabilitiesService by mockk() { fun givenCapabilities(homeServerCapabilities: HomeServerCapabilities) { every { getHomeServerCapabilities() } returns homeServerCapabilities } + + fun givenCapabilitiesLiveReturns(homeServerCapabilities: HomeServerCapabilities): LiveData> { + return MutableLiveData(homeServerCapabilities.toOptional()).also { + every { getHomeServerCapabilitiesLive() } returns it + } + } } diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakePushersManager.kt b/vector/src/test/java/im/vector/app/test/fakes/FakePushersManager.kt new file mode 100644 index 0000000000..46d852f4f8 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakePushersManager.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.test.fakes + +import im.vector.app.core.pushers.PushersManager +import io.mockk.every +import io.mockk.mockk +import org.matrix.android.sdk.api.session.pushers.Pusher + +class FakePushersManager { + + val instance = mockk() + + fun givenGetPusherForCurrentSessionReturns(pusher: Pusher?) { + every { instance.getPusherForCurrentSession() } returns pusher + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeSessionAccountDataService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeSessionAccountDataService.kt index 615330463b..c44fc4a497 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeSessionAccountDataService.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeSessionAccountDataService.kt @@ -28,8 +28,8 @@ import org.matrix.android.sdk.api.session.events.model.Content class FakeSessionAccountDataService : SessionAccountDataService by mockk(relaxed = true) { - fun givenGetUserAccountDataEventReturns(type: String, content: Content) { - every { getUserAccountDataEvent(type) } returns UserAccountDataEvent(type, content) + fun givenGetUserAccountDataEventReturns(type: String, content: Content?) { + every { getUserAccountDataEvent(type) } returns content?.let { UserAccountDataEvent(type, it) } } fun givenUpdateUserAccountDataEventSucceeds() { diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeSignoutSessionsUseCase.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeSignoutSessionsUseCase.kt new file mode 100644 index 0000000000..9eb3676475 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeSignoutSessionsUseCase.kt @@ -0,0 +1,55 @@ +/* + * 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.test.fakes + +import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsReAuthNeeded +import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsUseCase +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse + +class FakeSignoutSessionsUseCase { + + val instance = mockk() + + fun givenSignoutSuccess(deviceIds: List) { + coEvery { instance.execute(deviceIds, any()) } returns Result.success(Unit) + } + + fun givenSignoutReAuthNeeded(deviceIds: List): SignoutSessionsReAuthNeeded { + val flowResponse = mockk() + every { flowResponse.session } returns "a-session-id" + val errorCode = "errorCode" + val reAuthNeeded = SignoutSessionsReAuthNeeded( + pendingAuth = mockk(), + uiaContinuation = mockk(), + flowResponse = flowResponse, + errCode = errorCode, + ) + coEvery { instance.execute(deviceIds, any()) } coAnswers { + secondArg<(SignoutSessionsReAuthNeeded) -> Unit>().invoke(reAuthNeeded) + Result.success(Unit) + } + + return reAuthNeeded + } + + fun givenSignoutError(deviceIds: List, error: Throwable) { + coEvery { instance.execute(deviceIds, any()) } returns Result.failure(error) + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeTogglePushNotificationUseCase.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeTogglePushNotificationUseCase.kt index 92e311cfb7..bfbbb87705 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeTogglePushNotificationUseCase.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeTogglePushNotificationUseCase.kt @@ -16,7 +16,7 @@ package im.vector.app.test.fakes -import im.vector.app.features.settings.devices.v2.overview.TogglePushNotificationUseCase +import im.vector.app.features.settings.devices.v2.notification.TogglePushNotificationUseCase import io.mockk.coJustRun import io.mockk.coVerify import io.mockk.mockk diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeUnifiedPushHelper.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeUnifiedPushHelper.kt new file mode 100644 index 0000000000..1f2cc8a1ce --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeUnifiedPushHelper.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import androidx.fragment.app.FragmentActivity +import im.vector.app.core.pushers.PushersManager +import im.vector.app.core.pushers.UnifiedPushHelper +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class FakeUnifiedPushHelper { + + val instance = mockk() + + fun givenRegister(fragmentActivity: FragmentActivity) { + every { instance.register(fragmentActivity, any()) } answers { + secondArg().run() + } + } + + fun verifyRegister(fragmentActivity: FragmentActivity) { + verify { instance.register(fragmentActivity, any()) } + } + + fun givenUnregister(pushersManager: PushersManager) { + coJustRun { instance.unregister(pushersManager) } + } + + fun verifyUnregister(pushersManager: PushersManager) { + coVerify { instance.unregister(pushersManager) } + } + + fun givenIsEmbeddedDistributorReturns(isEmbedded: Boolean) { + every { instance.isEmbeddedDistributor() } returns isEmbedded + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt index 8b0630c24f..4baa7e2b90 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt @@ -18,6 +18,7 @@ package im.vector.app.test.fakes import im.vector.app.features.settings.VectorPreferences import io.mockk.every +import io.mockk.justRun import io.mockk.mockk import io.mockk.verify @@ -40,4 +41,15 @@ class FakeVectorPreferences { fun givenIsClientInfoRecordingEnabled(isEnabled: Boolean) { every { instance.isClientInfoRecordingEnabled() } returns isEnabled } + + fun givenTextFormatting(isEnabled: Boolean) = + every { instance.isTextFormattingEnabled() } returns isEnabled + + fun givenSetNotificationEnabledForDevice() { + justRun { instance.setNotificationEnabledForDevice(any()) } + } + + fun verifySetNotificationEnabledForDevice(enabled: Boolean, inverse: Boolean = false) { + verify(inverse = inverse) { instance.setNotificationEnabledForDevice(enabled) } + } } diff --git a/vector/src/test/java/im/vector/app/test/fixtures/HomeserverCapabilityFixture.kt b/vector/src/test/java/im/vector/app/test/fixtures/HomeserverCapabilityFixture.kt index a4d9869a89..c9f32c2cf2 100644 --- a/vector/src/test/java/im/vector/app/test/fixtures/HomeserverCapabilityFixture.kt +++ b/vector/src/test/java/im/vector/app/test/fixtures/HomeserverCapabilityFixture.kt @@ -27,14 +27,16 @@ fun aHomeServerCapabilities( maxUploadFileSize: Long = 100L, lastVersionIdentityServerSupported: Boolean = false, defaultIdentityServerUrl: String? = null, - roomVersions: RoomVersionCapabilities? = null + roomVersions: RoomVersionCapabilities? = null, + canRemotelyTogglePushNotificationsOfDevices: Boolean = true, ) = HomeServerCapabilities( - canChangePassword, - canChangeDisplayName, - canChangeAvatar, - canChange3pid, - maxUploadFileSize, - lastVersionIdentityServerSupported, - defaultIdentityServerUrl, - roomVersions + canChangePassword = canChangePassword, + canChangeDisplayName = canChangeDisplayName, + canChangeAvatar = canChangeAvatar, + canChange3pid = canChange3pid, + maxUploadFileSize = maxUploadFileSize, + lastVersionIdentityServerSupported = lastVersionIdentityServerSupported, + defaultIdentityServerUrl = defaultIdentityServerUrl, + roomVersions = roomVersions, + canRemotelyTogglePushNotificationsOfDevices = canRemotelyTogglePushNotificationsOfDevices, )