diff --git a/.github/ISSUE_TEMPLATE/release.yml b/.github/ISSUE_TEMPLATE/release.yml
index b41188a9209..a84f4dfd3bb 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 b6333c5940f..a44872e0ef7 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 61f1f114ddb..6aeba66ccc1 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 18bb2480c3d..442d3641dd6 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 7e7da482951..78cc9abb029 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 33a2096a434..dc66de43eac 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 00000000000..ecf3d5008c4
--- /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 00000000000..4ca0ffb55b4
--- /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 00000000000..e966dbbd921
--- /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 00000000000..e966dbbd921
--- /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 00000000000..0b36faff1e1
--- /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 00000000000..3141cea7cb8
--- /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 00000000000..f9ca8cdd7ca
--- /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 00000000000..d5606e24b36
--- /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 00000000000..061e09814de
--- /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 00000000000..b677c05c89b
--- /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 00000000000..b677c05c89b
--- /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 00000000000..b33f290d0d3
--- /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 00000000000..b33f290d0d3
--- /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 00000000000..32fb87563ee
--- /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 00000000000..32fb87563ee
--- /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 00000000000..108a8a88b49
--- /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 00000000000..108a8a88b49
--- /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 00000000000..0d1d4965ca6
--- /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 00000000000..0d1d4965ca6
--- /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 00000000000..f93220235b2
--- /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 00000000000..f93220235b2
--- /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 00000000000..c8b2eb09ab0
--- /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 00000000000..987197f0f6d
--- /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 00000000000..87f801d1f4c
--- /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 00000000000..87f801d1f4c
--- /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 00000000000..87f801d1f4c
--- /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 00000000000..87f801d1f4c
--- /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 00000000000..87f801d1f4c
--- /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 00000000000..87f801d1f4c
--- /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 00000000000..87f801d1f4c
--- /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 00000000000..c5ffad38c9f
--- /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 00000000000..87f801d1f4c
--- /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 00000000000..87f801d1f4c
--- /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 00000000000..6c1be8f556c
--- /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 00000000000..6c1be8f556c
--- /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 00000000000..87f801d1f4c
--- /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 00000000000..87f801d1f4c
--- /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 00000000000..ef9251a4973
--- /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 00000000000..2ee2ded8234
--- /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 00000000000..26647d519f5
--- /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 00000000000..4e38434f898
--- /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 00000000000..eb300bafeda
--- /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 00000000000..f4beb912a56
--- /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 00000000000..4be635901f6
--- /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 00000000000..65254059c5b
--- /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 00000000000..56667ccfc09
--- /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 00000000000..56667ccfc09
--- /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 92d28d26c95..07c7b4588ff 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 e9d311fe03c..a4fd8bb4e17 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 70b9a33ab53..a49ecc3d085 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 044ecf900c5..6fe322bdd08 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 ce786fb87d9..f9d7145b66d 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 53599adce20..47caa521492 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 409fc564f45..cd215e175d2 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 9bfbbe8eebc..22572a0f36a 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 f2701519e74..313734290fc 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 6c767fc3508..a02b0625961 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 6ed423bb705..cde367faf92 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 37c0bca52fa..11ab6ee8575 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 2ec5f394bd3..d3061371fa2 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 bf57233b371..9eac092a62b 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 a6af0a49212..773454c39fe 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 f633a0ef2fa..8cbfeca6baf 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 688652265bb..5ab8a351d1b 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 739ea09755e..91e08c803ab 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 741d23dbc6e..bfe751ef5a5 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 897c2853d81..e503cb3fe7d 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 50d5aaf0143..22c2a3e62c7 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 098ec263fc0..c1a51000b72 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 00000000000..1f72eeb3969
--- /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 00000000000..eb85378141c
--- /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 8bfcef3643c..f50b672077c 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 6ef90193d80..81351523e93 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 9487a270863..7f0e828f628 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/extensions/MetricsExtensions.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/extensions/MetricsExtensions.kt
@@ -17,25 +17,51 @@
package org.matrix.android.sdk.api.extensions
import org.matrix.android.sdk.api.metrics.MetricPlugin
+import org.matrix.android.sdk.api.metrics.SpannableMetricPlugin
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
/**
* Executes the given [block] while measuring the transaction.
+ *
+ * @param block Action/Task to be executed within this span.
+ */
+@OptIn(ExperimentalContracts::class)
+inline fun List.measureMetric(block: () -> Unit) {
+ contract {
+ callsInPlace(block, InvocationKind.EXACTLY_ONCE)
+ }
+ try {
+ this.forEach { plugin -> plugin.startTransaction() } // Start the transaction.
+ block()
+ } catch (throwable: Throwable) {
+ this.forEach { plugin -> plugin.onError(throwable) } // Capture if there is any exception thrown.
+ throw throwable
+ } finally {
+ this.forEach { plugin -> plugin.finishTransaction() } // Finally, finish this transaction.
+ }
+}
+
+/**
+ * Executes the given [block] while measuring a span.
+ *
+ * @param operation Name of the new span.
+ * @param description Description of the new span.
+ * @param block Action/Task to be executed within this span.
*/
@OptIn(ExperimentalContracts::class)
-inline fun measureMetric(metricMeasurementPlugins: List, block: () -> Unit) {
+inline fun List.measureSpan(operation: String, description: String, block: () -> Unit) {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
try {
- metricMeasurementPlugins.forEach { plugin -> plugin.startTransaction() } // Start the transaction.
+ this.forEach { plugin -> plugin.startSpan(operation, description) } // Start the transaction.
block()
} catch (throwable: Throwable) {
- metricMeasurementPlugins.forEach { plugin -> plugin.onError(throwable) } // Capture if there is any exception thrown.
+ this.forEach { plugin -> plugin.onError(throwable) } // Capture if there is any exception thrown.
throw throwable
} finally {
- metricMeasurementPlugins.forEach { plugin -> plugin.finishTransaction() } // Finally, finish this transaction.
+ this.forEach { plugin -> plugin.finishSpan() } // Finally, finish this transaction.
}
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/SpannableMetricPlugin.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/SpannableMetricPlugin.kt
new file mode 100644
index 00000000000..54aa21877ec
--- /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 00000000000..79ece002e98
--- /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 d2aa8020e82..971d04261eb 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 1f16041b543..6ae585a2732 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 773e870ffd9..11638837ccd 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 9d2c48e194f..c65a5382fb8 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 223acd1b9c6..6f4049de364 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 1245d8df4b6..f4de6a9ae94 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 9c3e0ba1c58..7862da1c171 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 2ac6b8c8541..7e9e156003c 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 d5a8bdfd7cb..cfe4681bfd5 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 c26c6107c4e..24dccc4d904 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 00000000000..19b33b2a691
--- /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 0a77d33accc..549122447e9 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 58c015b13b6..30836c027ea 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 3528ca0051d..89657ad8822 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 00000000000..8826d894c10
--- /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 89f1e50b309..2b60f7723cb 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 5aec7db66cc..4bfda0bf3cd 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 db1cd1b33bb..3dd440737ad 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 4c755b54b51..eb9e862de29 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 a5953d870c8..11e86a5c513 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 6c913fa41ee..beb1e67e409 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 7d8605c2bd3..55ba78c2a59 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])
- }
-
- if (i != messageParagraphs.lastIndex) {
- append("\n\n")
- }
+ private fun createQuoteTextContent(
+ quotedText: String?,
+ formattedQuotedText: String?,
+ text: String,
+ formattedText: String?,
+ autoMarkdown: Boolean
+ ): TextContent {
+ val currentFormattedText = formattedText ?: if (autoMarkdown) {
+ val parsed = markdownParser.parse(text, force = true, advanced = true)
+ // If formattedText == text, formattedText is returned as null
+ parsed.formattedText ?: parsed.text
+ } else {
+ text
+ }
+ val processedFormattedQuotedText = formattedQuotedText ?: quotedText
+
+ val plainTextBody = buildString {
+ val plainMessageParagraphs = quotedText?.split("\n\n".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray().orEmpty()
+ plainMessageParagraphs.forEachIndexed { index, paragraph ->
+ if (paragraph.isNotBlank()) {
+ append("> ")
+ append(paragraph)
+ }
+
+ if (index != plainMessageParagraphs.lastIndex) {
+ append("\n\n")
}
}
append("\n\n")
- append(myText)
+ append(text)
+ }
+ val formattedTextBody = buildString {
+ if (!processedFormattedQuotedText.isNullOrBlank()) {
+ append("")
+ append(processedFormattedQuotedText)
+ append("
")
+ }
+ append("
")
+ append(currentFormattedText)
}
+ return TextContent(plainTextBody, formattedTextBody)
}
companion object {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt
index 05216d1de13..05d50d95952 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt
@@ -17,6 +17,11 @@
package org.matrix.android.sdk.internal.session.sync
import com.zhuinden.monarchy.Monarchy
+import io.realm.Realm
+import org.matrix.android.sdk.api.MatrixConfiguration
+import org.matrix.android.sdk.api.extensions.measureMetric
+import org.matrix.android.sdk.api.extensions.measureSpan
+import org.matrix.android.sdk.api.metrics.SyncDurationMetricPlugin
import org.matrix.android.sdk.api.session.pushrules.PushRuleService
import org.matrix.android.sdk.api.session.pushrules.RuleScope
import org.matrix.android.sdk.api.session.sync.InitialSyncStep
@@ -52,9 +57,12 @@ internal class SyncResponseHandler @Inject constructor(
private val tokenStore: SyncTokenStore,
private val processEventForPushTask: ProcessEventForPushTask,
private val pushRuleService: PushRuleService,
- private val presenceSyncHandler: PresenceSyncHandler
+ private val presenceSyncHandler: PresenceSyncHandler,
+ matrixConfiguration: MatrixConfiguration,
) {
+ private val relevantPlugins = matrixConfiguration.metricPlugins.filterIsInstance()
+
suspend fun handleResponse(
syncResponse: SyncResponse,
fromToken: String?,
@@ -63,39 +71,91 @@ internal class SyncResponseHandler @Inject constructor(
val isInitialSync = fromToken == null
Timber.v("Start handling sync, is InitialSync: $isInitialSync")
- measureTimeMillis {
- if (!cryptoService.isStarted()) {
- Timber.v("Should start cryptoService")
- cryptoService.start()
- }
- cryptoService.onSyncWillProcess(isInitialSync)
- }.also {
- Timber.v("Finish handling start cryptoService in $it ms")
+ relevantPlugins.measureMetric {
+ startCryptoService(isInitialSync)
+
+ // Handle the to device events before the room ones
+ // to ensure to decrypt them properly
+ handleToDevice(syncResponse, reporter)
+
+ val aggregator = SyncResponsePostTreatmentAggregator()
+
+ // Prerequisite for thread events handling in RoomSyncHandler
+ // Disabled due to the new fallback
+ // if (!lightweightSettingsStorage.areThreadMessagesEnabled()) {
+ // threadsAwarenessHandler.fetchRootThreadEventsIfNeeded(syncResponse)
+ // }
+
+ startMonarchyTransaction(syncResponse, isInitialSync, reporter, aggregator)
+
+ aggregateSyncResponse(aggregator)
+
+ postTreatmentSyncResponse(syncResponse, isInitialSync)
+
+ markCryptoSyncCompleted(syncResponse)
+
+ handlePostSync()
+
+ Timber.v("On sync completed")
}
+ }
- // Handle the to device events before the room ones
- // to ensure to decrypt them properly
- measureTimeMillis {
- Timber.v("Handle toDevice")
- reportSubtask(reporter, InitialSyncStep.ImportingAccountCrypto, 100, 0.1f) {
- if (syncResponse.toDevice != null) {
- cryptoSyncHandler.handleToDevice(syncResponse.toDevice, reporter)
+ private fun startCryptoService(isInitialSync: Boolean) {
+ relevantPlugins.measureSpan("task", "start_crypto_service") {
+ measureTimeMillis {
+ if (!cryptoService.isStarted()) {
+ Timber.v("Should start cryptoService")
+ cryptoService.start()
}
+ cryptoService.onSyncWillProcess(isInitialSync)
+ }.also {
+ Timber.v("Finish handling start cryptoService in $it ms")
}
- }.also {
- Timber.v("Finish handling toDevice in $it ms")
}
- val aggregator = SyncResponsePostTreatmentAggregator()
+ }
- // Prerequisite for thread events handling in RoomSyncHandler
-// Disabled due to the new fallback
-// if (!lightweightSettingsStorage.areThreadMessagesEnabled()) {
-// threadsAwarenessHandler.fetchRootThreadEventsIfNeeded(syncResponse)
-// }
+ private suspend fun handleToDevice(syncResponse: SyncResponse, reporter: ProgressReporter?) {
+ relevantPlugins.measureSpan("task", "handle_to_device") {
+ measureTimeMillis {
+ Timber.v("Handle toDevice")
+ reportSubtask(reporter, InitialSyncStep.ImportingAccountCrypto, 100, 0.1f) {
+ if (syncResponse.toDevice != null) {
+ cryptoSyncHandler.handleToDevice(syncResponse.toDevice, reporter)
+ }
+ }
+ }.also {
+ Timber.v("Finish handling toDevice in $it ms")
+ }
+ }
+ }
+ private suspend fun startMonarchyTransaction(
+ syncResponse: SyncResponse,
+ isInitialSync: Boolean,
+ reporter: ProgressReporter?,
+ aggregator: SyncResponsePostTreatmentAggregator
+ ) {
// Start one big transaction
- monarchy.awaitTransaction { realm ->
- // IMPORTANT nothing should be suspend here as we are accessing the realm instance (thread local)
+ relevantPlugins.measureSpan("task", "monarchy_transaction") {
+ monarchy.awaitTransaction { realm ->
+ // IMPORTANT nothing should be suspend here as we are accessing the realm instance (thread local)
+ handleRooms(reporter, syncResponse, realm, isInitialSync, aggregator)
+ handleAccountData(reporter, realm, syncResponse)
+ handlePresence(realm, syncResponse)
+
+ tokenStore.saveToken(realm, syncResponse.nextBatch)
+ }
+ }
+ }
+
+ private fun handleRooms(
+ reporter: ProgressReporter?,
+ syncResponse: SyncResponse,
+ realm: Realm,
+ isInitialSync: Boolean,
+ aggregator: SyncResponsePostTreatmentAggregator
+ ) {
+ relevantPlugins.measureSpan("task", "handle_rooms") {
measureTimeMillis {
Timber.v("Handle rooms")
reportSubtask(reporter, InitialSyncStep.ImportingAccountRoom, 1, 0.8f) {
@@ -106,7 +166,11 @@ internal class SyncResponseHandler @Inject constructor(
}.also {
Timber.v("Finish handling rooms in $it ms")
}
+ }
+ }
+ private fun handleAccountData(reporter: ProgressReporter?, realm: Realm, syncResponse: SyncResponse) {
+ relevantPlugins.measureSpan("task", "handle_account_data") {
measureTimeMillis {
reportSubtask(reporter, InitialSyncStep.ImportingAccountData, 1, 0.1f) {
Timber.v("Handle accountData")
@@ -115,44 +179,59 @@ internal class SyncResponseHandler @Inject constructor(
}.also {
Timber.v("Finish handling accountData in $it ms")
}
+ }
+ }
+ private fun handlePresence(realm: Realm, syncResponse: SyncResponse) {
+ relevantPlugins.measureSpan("task", "handle_presence") {
measureTimeMillis {
Timber.v("Handle Presence")
presenceSyncHandler.handle(realm, syncResponse.presence)
}.also {
Timber.v("Finish handling Presence in $it ms")
}
- tokenStore.saveToken(realm, syncResponse.nextBatch)
}
+ }
- // Everything else we need to do outside the transaction
- measureTimeMillis {
- aggregatorHandler.handle(aggregator)
- }.also {
- Timber.v("Aggregator management took $it ms")
+ private suspend fun aggregateSyncResponse(aggregator: SyncResponsePostTreatmentAggregator) {
+ relevantPlugins.measureSpan("task", "aggregator_management") {
+ // Everything else we need to do outside the transaction
+ measureTimeMillis {
+ aggregatorHandler.handle(aggregator)
+ }.also {
+ Timber.v("Aggregator management took $it ms")
+ }
}
+ }
- measureTimeMillis {
- syncResponse.rooms?.let {
- checkPushRules(it, isInitialSync)
- userAccountDataSyncHandler.synchronizeWithServerIfNeeded(it.invite)
- dispatchInvitedRoom(it)
+ private suspend fun postTreatmentSyncResponse(syncResponse: SyncResponse, isInitialSync: Boolean) {
+ relevantPlugins.measureSpan("task", "sync_response_post_treatment") {
+ measureTimeMillis {
+ syncResponse.rooms?.let {
+ checkPushRules(it, isInitialSync)
+ userAccountDataSyncHandler.synchronizeWithServerIfNeeded(it.invite)
+ dispatchInvitedRoom(it)
+ }
+ }.also {
+ Timber.v("SyncResponse.rooms post treatment took $it ms")
}
- }.also {
- Timber.v("SyncResponse.rooms post treatment took $it ms")
}
+ }
- measureTimeMillis {
- cryptoSyncHandler.onSyncCompleted(syncResponse)
- }.also {
- Timber.v("cryptoSyncHandler.onSyncCompleted took $it ms")
+ private fun markCryptoSyncCompleted(syncResponse: SyncResponse) {
+ relevantPlugins.measureSpan("task", "crypto_sync_handler_onSyncCompleted") {
+ measureTimeMillis {
+ cryptoSyncHandler.onSyncCompleted(syncResponse)
+ }.also {
+ Timber.v("cryptoSyncHandler.onSyncCompleted took $it ms")
+ }
}
+ }
- // post sync stuffs
+ private fun handlePostSync() {
monarchy.writeAsync {
roomSyncHandler.postSyncSpaceHierarchyHandle(it)
}
- Timber.v("On sync completed")
}
private fun dispatchInvitedRoom(roomsSyncResponse: RoomsSyncResponse) {
diff --git a/matrix-sdk-android/src/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 00000000000..b30428e5e10
--- /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 00000000000..bce8b41aa9b
--- /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 00000000000..5c3a245c517
--- /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 00000000000..966c6a1bb20
--- /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 00000000000..c630b94d474
--- /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 00000000000..b541d241617
--- /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 00000000000..3d7e85424e8
--- /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 00000000000..b10d13824bd
--- /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 00000000000..a27c9284e7c
--- /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 00000000000..052ddf78316
--- /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 00000000000..0d783d66284
--- /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 b1b7e5f7485..bff01935094 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 9857f88479f..890236422e8 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 b7401079e4d..d28312ac1c1 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 54d556ea913..30a8565771c 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 a09f8529580..380c80775be 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 00000000000..7d62a0c357b
--- /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 625ff15ef76..156809d5ad9 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 00000000000..81b524cde96
--- /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 00000000000..36df939badb
--- /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 cda6f5bae89..6f186262fc6 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 a5e1fe68bd7..71863b86427 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 263f043fad6..b6dc404d015 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 64f143a2fd3..4278c1011bc 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 92213d380c1..488b72bfd99 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 00000000000..d69ed01526f
--- /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 28732c9a42a..01720453ce4 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 af17800455f..f8d5d768efd 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 00b9a76de70..0bf70690ba5 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 61a8e5b79ec..49f2079625f 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 0f7dc251ae0..1368b71ec63 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 f773671694a..faee8f652c6 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 9d50cdb0706..e1392b75804 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 62f3cad5aab..ef238d56e6f 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 bede02c17fc..b5ea528bd7d 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 82adcd014aa..30437a016d3 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 2bbd5c3474c..aaf63d7f416 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 09357191b44..b7e0e29679d 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 1a9f9e62910..a8be2be5e20 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 0df1dbebd86..bf40c189953 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 acb5a1b42aa..939a59fccaf 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 07b7d151adb..2d2a4a8cd26 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)
}
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
- views.composerEditText.menuStateChangedListener = EditorEditText.OnMenuStateChangedListener { state ->
+ 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 13e0477ab66..a7b926f29ad 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 18c626bda8e..57ad4331ce0 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 245d92f95b3..f4d506fa4b6 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 5dc601a91a2..e4f7bed72f3 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 6937cd3a467..90fd66f9ab6 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 d8817c1f449..a4bfa9e155e 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 00000000000..c6b90cdabe7
--- /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 fda9a1465ff..3e8d6cb487a 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 5b58dda4e6c..e5cb677763c 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)
+ playerListener = object : VoiceBroadcastPlayer.Listener {
+ override fun onPlayingStateChanged(state: VoiceBroadcastPlayer.State) {
+ renderPlayingState(holder, state)
+ }
+
+ override fun onLiveModeChanged(isLive: Boolean) {
+ renderLiveIndicator(holder)
+ }
}
- voiceBroadcastPlayer?.addListener(playerListener)
- renderHeader(holder)
- renderLiveIcon(holder)
+ player.addListener(voiceBroadcast, playerListener)
+ bindSeekBar(holder)
+ bindButtons(holder)
}
- private fun renderHeader(holder: Holder) {
+ private fun bindButtons(holder: Holder) {
with(holder) {
- roomItem?.let {
- attributes.avatarRenderer.render(it, roomAvatarImageView)
- titleText.text = it.displayName
+ playPauseButton.setOnClickListener {
+ if (player.currentVoiceBroadcast == voiceBroadcast) {
+ when (player.playingState) {
+ VoiceBroadcastPlayer.State.PLAYING -> 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))
}
- broadcasterNameText.text = broadcasterName
}
}
- private fun renderLiveIcon(holder: Holder) {
+ override fun renderMetadata(holder: Holder) {
with(holder) {
- when (voiceBroadcastState) {
- VoiceBroadcastState.STARTED,
- VoiceBroadcastState.RESUMED -> {
- liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.colorError))
- liveIndicator.isVisible = true
- }
- VoiceBroadcastState.PAUSED -> {
- liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.vctr_content_quaternary))
- liveIndicator.isVisible = true
- }
- VoiceBroadcastState.STOPPED, null -> {
- liveIndicator.isVisible = false
- }
- }
+ broadcasterNameMetadata.value = recorderName
+ voiceBroadcastMetadata.isVisible = true
+ listenersCountMetadata.isVisible = false
}
}
- private fun renderState(holder: Holder, state: VoiceBroadcastPlayer.State) {
- if (isCurrentMediaActive()) {
- renderActiveMedia(holder, state)
- } else {
- renderInactiveMedia(holder)
+ override fun renderLiveIndicator(holder: Holder) {
+ when {
+ voiceBroadcastState == null || voiceBroadcastState == VoiceBroadcastState.STOPPED -> renderNoLiveIndicator(holder)
+ voiceBroadcastState == VoiceBroadcastState.PAUSED || !player.isLiveListening -> renderPausedLiveIndicator(holder)
+ else -> renderPlayingLiveIndicator(holder)
}
}
- private fun renderActiveMedia(holder: Holder, state: VoiceBroadcastPlayer.State) {
+ private fun renderPlayingState(holder: Holder, state: VoiceBroadcastPlayer.State) {
with(holder) {
bufferingView.isVisible = state == VoiceBroadcastPlayer.State.BUFFERING
playPauseButton.isVisible = state != VoiceBroadcastPlayer.State.BUFFERING
@@ -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 c417053b2a5..39d2d73c685 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt
@@ -17,45 +17,21 @@
package im.vector.app.features.home.room.detail.timeline.item
import android.widget.ImageButton
-import android.widget.ImageView
-import android.widget.TextView
import androidx.core.view.isVisible
-import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.epoxy.onClick
-import im.vector.app.core.extensions.tintBackground
-import im.vector.app.core.resources.ColorProvider
-import im.vector.app.core.resources.DrawableProvider
+import im.vector.app.core.utils.TextUtils
import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction
-import im.vector.app.features.home.room.detail.timeline.TimelineEventController
-import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder
-import org.matrix.android.sdk.api.util.MatrixItem
+import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
+import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder
+import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
+import org.threeten.bp.Duration
@EpoxyModelClass
-abstract class MessageVoiceBroadcastRecordingItem : AbsMessageItem() {
+abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem() {
- @EpoxyAttribute
- var callback: TimelineEventController.Callback? = null
-
- @EpoxyAttribute
- var voiceBroadcastRecorder: VoiceBroadcastRecorder? = null
-
- @EpoxyAttribute
- lateinit var colorProvider: ColorProvider
-
- @EpoxyAttribute
- lateinit var drawableProvider: DrawableProvider
-
- @EpoxyAttribute
- var roomItem: MatrixItem? = null
-
- @EpoxyAttribute
- var title: String? = null
-
- private lateinit var recorderListener: VoiceBroadcastRecorder.Listener
-
- override fun isCacheable(): Boolean = false
+ private var recorderListener: VoiceBroadcastRecorder.Listener? = null
override fun bind(holder: Holder) {
super.bind(holder)
@@ -63,73 +39,107 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageItem renderPlayingLiveIndicator(holder)
+ VoiceBroadcastState.PAUSED -> renderPausedLiveIndicator(holder)
+ VoiceBroadcastState.STOPPED, null -> renderNoLiveIndicator(holder)
}
}
- private fun renderState(holder: Holder, state: VoiceBroadcastRecorder.State) {
- with(holder) {
- when (state) {
- VoiceBroadcastRecorder.State.Recording -> {
- stopRecordButton.isEnabled = true
- recordButton.isEnabled = true
-
- liveIndicator.isVisible = true
- liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.colorError))
-
- val drawableColor = colorProvider.getColorFromAttribute(R.attr.vctr_content_secondary)
- val drawable = drawableProvider.getDrawable(R.drawable.ic_play_pause_pause, drawableColor)
- recordButton.setImageDrawable(drawable)
- recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_pause_voice_broadcast_record)
- recordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Pause) }
- stopRecordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) }
- }
- VoiceBroadcastRecorder.State.Paused -> {
- stopRecordButton.isEnabled = true
- recordButton.isEnabled = true
+ override fun renderMetadata(holder: Holder) {
+ holder.listenersCountMetadata.isVisible = false
+ }
- liveIndicator.isVisible = true
- liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.vctr_content_quaternary))
+ private fun renderRemainingTime(holder: Holder, remainingTime: Long?) {
+ if (remainingTime != null) {
+ val formattedDuration = TextUtils.formatDurationWithUnits(
+ holder.view.context,
+ Duration.ofSeconds(remainingTime.coerceAtLeast(0L))
+ )
+ holder.remainingTimeMetadata.value = holder.view.resources.getString(R.string.voice_broadcast_recording_time_left, formattedDuration)
+ holder.remainingTimeMetadata.isVisible = true
+ } else {
+ holder.remainingTimeMetadata.isVisible = false
+ }
+ }
- recordButton.setImageResource(R.drawable.ic_recording_dot)
- recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_resume_voice_broadcast_record)
- recordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Resume) }
- stopRecordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) }
- }
- VoiceBroadcastRecorder.State.Idle -> {
- recordButton.isEnabled = false
- stopRecordButton.isEnabled = false
- liveIndicator.isVisible = false
- }
- }
+ private fun renderRecordingState(holder: Holder, state: VoiceBroadcastRecorder.State) {
+ when (state) {
+ VoiceBroadcastRecorder.State.Recording -> renderRecordingState(holder)
+ VoiceBroadcastRecorder.State.Paused -> renderPausedState(holder)
+ VoiceBroadcastRecorder.State.Idle -> renderStoppedState(holder)
+ }
+ }
+
+ private fun renderVoiceBroadcastState(holder: Holder) {
+ when (voiceBroadcastState) {
+ VoiceBroadcastState.STARTED,
+ VoiceBroadcastState.RESUMED -> renderRecordingState(holder)
+ VoiceBroadcastState.PAUSED -> renderPausedState(holder)
+ VoiceBroadcastState.STOPPED,
+ null -> renderStoppedState(holder)
}
}
+ private fun renderRecordingState(holder: Holder) = with(holder) {
+ stopRecordButton.isEnabled = true
+ recordButton.isEnabled = true
+
+ val drawableColor = colorProvider.getColorFromAttribute(R.attr.vctr_content_secondary)
+ val drawable = drawableProvider.getDrawable(R.drawable.ic_play_pause_pause, drawableColor)
+ recordButton.setImageDrawable(drawable)
+ recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_pause_voice_broadcast_record)
+ recordButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Pause) }
+ stopRecordButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) }
+ }
+
+ private fun renderPausedState(holder: Holder) = with(holder) {
+ stopRecordButton.isEnabled = true
+ recordButton.isEnabled = true
+
+ recordButton.setImageResource(R.drawable.ic_recording_dot)
+ recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_resume_voice_broadcast_record)
+ recordButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Resume) }
+ stopRecordButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) }
+ }
+
+ private fun renderStoppedState(holder: Holder) = with(holder) {
+ recordButton.isEnabled = false
+ stopRecordButton.isEnabled = false
+ }
+
override fun unbind(holder: Holder) {
super.unbind(holder)
- voiceBroadcastRecorder?.removeListener(recorderListener)
+ recorderListener?.let { recorder?.removeListener(it) }
+ recorderListener = null
+ with(holder) {
+ recordButton.onClick(null)
+ stopRecordButton.onClick(null)
+ }
}
override fun getViewStubId() = STUB_ID
- class Holder : AbsMessageItem.Holder(STUB_ID) {
- val liveIndicator by bind(R.id.liveIndicator)
- val roomAvatarImageView by bind(R.id.roomAvatarImageView)
- val titleText by bind(R.id.titleText)
+ class Holder : AbsMessageVoiceBroadcastItem.Holder(STUB_ID) {
+ val listenersCountMetadata by bind(R.id.listenersCountMetadata)
+ val remainingTimeMetadata by bind(R.id.remainingTimeMetadata)
val recordButton by bind(R.id.recordButton)
val stopRecordButton by bind(R.id.stopRecordButton)
}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt
index e057950790d..d3f320db7d3 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 00000000000..2ca0ebea484
--- /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 85cfb76ff75..f6e10a6df9a 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 bab7f4c7f91..c108e83e762 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 1c348af4f95..3a3c3463fbd 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 0660e7d642d..f74d88790c1 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 00000000000..0125d92ba60
--- /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 00000000000..194a2aebbf3
--- /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 00000000000..ca314bf145b
--- /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 00000000000..03e4e31f2ec
--- /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 00000000000..7ff1f043818
--- /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 45c234aaefb..7969bbbe9bd 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 1978708ebfb..24d2a08bdcc 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 4f1c8353f5e..74a78b2415f 100644
--- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt
@@ -16,6 +16,7 @@
package im.vector.app.features.settings.devices.v2.othersessions
+import android.app.Activity
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
@@ -32,6 +33,8 @@ import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
+import im.vector.app.core.extensions.registerStartForActivityResult
+import im.vector.app.core.extensions.setTextColor
import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment
import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment.ResultListener.Companion.RESULT_OK
import im.vector.app.core.platform.VectorBaseFragment
@@ -39,13 +42,16 @@ import im.vector.app.core.platform.VectorMenuProvider
import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.StringProvider
import im.vector.app.databinding.FragmentOtherSessionsBinding
+import im.vector.app.features.auth.ReAuthActivity
import im.vector.app.features.settings.devices.v2.DeviceFullInfo
import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterBottomSheet
import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType
import im.vector.app.features.settings.devices.v2.list.OtherSessionsView
import im.vector.app.features.settings.devices.v2.list.SESSION_IS_MARKED_AS_INACTIVE_AFTER_DAYS
import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreBottomSheet
+import im.vector.app.features.settings.devices.v2.signout.BuildConfirmSignoutDialogUseCase
import im.vector.app.features.themes.ThemeUtils
+import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.extensions.orFalse
import javax.inject.Inject
@@ -65,6 +71,8 @@ class OtherSessionsFragment :
@Inject lateinit var viewNavigator: OtherSessionsViewNavigator
+ @Inject lateinit var buildConfirmSignoutDialogUseCase: BuildConfirmSignoutDialogUseCase
+
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentOtherSessionsBinding {
return FragmentOtherSessionsBinding.inflate(layoutInflater, container, false)
}
@@ -77,7 +85,31 @@ 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 {
@@ -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 95f9c72b335..55753e35be3 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 2cd0c6af667..9b4c26ee4ff 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 0db3c8cd0e5..c0b50fded8c 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 a1cd7ea5860..d722cda7a1e 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 21054270f82..a56872e648b 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 440805bad6d..019dd2d7240 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 00000000000..4edfc2febe1
--- /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 43169952723..42ebd7782e3 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 60ca8e91c61..00000000000
--- 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 fa1fb31b660..56e3d176867 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 00000000000..1cf713a711b
--- /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 00000000000..61c884f0bcd
--- /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 00000000000..180627a15f4
--- /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 f800c518f30..58f86bc949c 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 551eaa4dac4..11b4f50d2f6 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 f9da2e76b10..6faec5a262b 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 00000000000..76b50c78ab5
--- /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 58e7de7f323..38fb1577487 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 2c892c8306f..00000000000
--- 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 00000000000..0de88e99923
--- /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 00000000000..5b0e5b2b1cc
--- /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 00000000000..36b737f23fa
--- /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 00000000000..16b15b9a771
--- /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 00000000000..62207d5b87b
--- /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 8b69051823b..bc13d1fea87 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 5285dc5e3be..c5408b768bb 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 1430dd8c86d..58e1f26f445 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 2f03d4194c1..524b64e095a 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 7934d18e360..45f622ad92d 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 00000000000..791409b8698
--- /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 bc6a3e7be6b..da13100609c 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 00000000000..ec50618969b
--- /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 00000000000..94eca2b54ed
--- /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 d08fa14a95e..00000000000
--- 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 00000000000..e142cb15cea
--- /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 00000000000..394dc522798
--- /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 00000000000..cb244806b33
--- /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 00000000000..be61fda8ff8
--- /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 00000000000..375c459692f
--- /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 00000000000..bb34211c7a9
--- /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 00000000000..11a42b0696d
--- /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 00000000000..edadb55b814
--- /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 79a60624cf0..7a22ab57f82 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 09e4b038877..c5afe1eb442 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 8703af74719..41c052367a3 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 2078d729db8..2d07464e89c 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 00000000000..373ca74f569
--- /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 248c04a2f62..1d31afba993 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:metadataIcon="@drawable/ic_voice_broadcast_mic"
+ tools:metadataValue="@sample/users.json/data/displayName" />
+
+
+
+
+ app:barrierMargin="10dp"
+ app:constraint_referenced_ids="roomAvatarImageView,titleText,metadataFlow" />
+
+
+
+
-
+
+
+
+
+ app:layout_constraintTop_toBottomOf="@id/controllerButtonsFlow"
+ tools:progress="40" />
+
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 e3bb85138d0..7da0701cc7b 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_recording_dot" />
+ 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 00000000000..7902f0c814d
--- /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 6139ff4815e..9f581a1d03f 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 00000000000..3bc31cd9a02
--- /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 8339286fe79..7893575dded 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 00000000000..5cced757353
--- /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 113a810ac22..7a1833e0573 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 8d4507e85d0..861e59e0f1c 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 478f631c062..e20d498a37a 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 c5edfb868db..65da1a9385d 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 00000000000..997fa827f53
--- /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 00000000000..37433364e88
--- /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 00000000000..508a05acd67
--- /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 00000000000..b38367b098c
--- /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 dc64c748362..35c5979e538 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 e7b8eeee9bd..1e8c511c429 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 fun createViewModel(args: OtherSessionsArgs = defaultArgs) = OtherSessionsViewModel(
- initialState = OtherSessionsViewState(args),
- activeSessionHolder = fakeActiveSessionHolder.instance,
- getDeviceFullInfoListUseCase = fakeGetDeviceFullInfoListUseCase,
- refreshDevicesUseCase = fakeRefreshDevicesUseCaseUseCase,
- )
+ private val fakeRefreshDevicesUseCase = mockk(relaxed = true)
+ private val fakeSignoutSessionsUseCase = FakeSignoutSessionsUseCase()
+ private val fakePendingAuthHandler = FakePendingAuthHandler()
+
+ 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 544059b77f9..1a57b760204 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 35551ba36e6..cd0575f2a0f 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 5af91c16ce4..00000000000
--- 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 00000000000..70d2b4b039d
--- /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 00000000000..e460413a397
--- /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 00000000000..eb6629cb139
--- /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 5c42b26c54d..a1ec91aab83 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 a1bc3a04ec8..8b66d45dd40 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 9fa6b7a4503..5b4076378c8 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 ee6b141bd9f..4b15f50be96 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 e96a58faa0e..b23f018cf58 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 00000000000..a78dd1a34bb
--- /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 00000000000..11abf187946
--- /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 00000000000..a9c1b37d69a
--- /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 006789f62b4..c816c51c0fb 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 00000000000..46d852f4f87
--- /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 615330463b6..c44fc4a4977 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 00000000000..9eb36764750
--- /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 92e311cfb72..bfbbb877057 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 00000000000..1f2cc8a1ce0
--- /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