diff --git a/.github/ISSUE_TEMPLATE/release.yml b/.github/ISSUE_TEMPLATE/release.yml index b28dbbde69..b41188a920 100644 --- a/.github/ISSUE_TEMPLATE/release.yml +++ b/.github/ISSUE_TEMPLATE/release.yml @@ -20,7 +20,6 @@ body: - [ ] Check the update of the store descriptions (using Google Translate if necessary) to ensure that the changes are acceptable to be published to the stores. - [ ] While Weblate is locked, and after the PR from Weblate has been merged, handle all the TODOs in the main `strings.xml` file - [ ] Run the script `./tools/release/pushPlayStoreMetaData.sh`. You can check in the GooglePlay console the Activity log to check the effect. - - [ ] Ensure all [the required PRs](https://github.com/vector-im/element-android/pulls?q=is%3Aopen+is%3Apr+label%3AZ-NextRelease) have been merged ### Do the release @@ -32,7 +31,6 @@ body: - [ ] Run the integration test, and especially `UiAllScreensSanityTest.allScreensTest()` - [ ] Create an account on matrix.org and do some smoke tests that the sanity test does not cover like: 1-1 call, 1-1 video call, Jitsi call for instance - [ ] Run towncrier: `towncrier build --version v1.2.3 --draft` (remove `--draft` do write the file CHANGES.md) - - [ ] Check that the folder `changelog.d` is empty. It can happen that some remaining files stay here - [ ] Check the file CHANGES.md consistency. It's possible to reorder items (most important changes first) or change their section if relevant. Also an opportunity to fix some typo, or rewrite things - [ ] Add file for fastlane under ./fastlane/metadata/android/en-US/changelogs - [ ] (optional) Push the branch and start a draft PR (will not be merged), to check that the CI is happy with all the changes. diff --git a/.github/workflows/triage-labelled.yml b/.github/workflows/triage-labelled.yml index 174e3c54c0..34e36a55da 100644 --- a/.github/workflows/triage-labelled.yml +++ b/.github/workflows/triage-labelled.yml @@ -48,7 +48,13 @@ jobs: # Skip in forks if: > github.repository == 'vector-im/element-android' && - contains(github.event.issue.labels.*.name, 'X-Needs-Design') + contains(github.event.issue.labels.*.name, 'X-Needs-Design') && + (contains(github.event.issue.labels.*.name, 'S-Critical') && + (contains(github.event.issue.labels.*.name, 'O-Frequent') || + contains(github.event.issue.labels.*.name, 'O-Occasional')) || + (contains(github.event.issue.labels.*.name, 'S-Major') && + contains(github.event.issue.labels.*.name, 'O-Frequent')) || + contains(github.event.issue.labels.*.name, 'A11y')) steps: - uses: octokit/graphql-action@v2.x id: add_to_project diff --git a/CHANGES.md b/CHANGES.md index d1e4834988..7e2df7716b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,64 @@ +Changes in Element v1.5.4 (2022-10-19) +====================================== + +Features ✨ +---------- + - Add WYSIWYG editor, under a lab flag. ([#7288](https://github.com/vector-im/element-android/issues/7288)) + - New Device management, can be enabled in the labs settings. + - Voice broadcast can be enabled in the labs settings (recording is possible only on Android 10 and up). + +Bugfixes 🐛 +---------- + - Fix wrong mic button direction to cancel on RTL languages ([#5968](https://github.com/vector-im/element-android/issues/5968)) + - Handle properly when getUser returns null - prefer using getUserOrDefault ([#7372](https://github.com/vector-im/element-android/issues/7372)) + - [Device Management] Long session names not handled well ([#7310](https://github.com/vector-im/element-android/issues/7310)) + - Fix editing formatted messages with plain text editor ([#7359](https://github.com/vector-im/element-android/issues/7359)) + +In development 🚧 +---------------- + - [Device Management] Save "matrix_client_information" events on login/registration ([#7257](https://github.com/vector-im/element-android/issues/7257)) + - [Device management] Add lab flag for the feature ([#7336](https://github.com/vector-im/element-android/issues/7336)) + - [Device management] Add lab flag for matrix client info account data event ([#7344](https://github.com/vector-im/element-android/issues/7344)) + - [Device Management] Redirect to the new screen everywhere when lab flag is on ([#7374](https://github.com/vector-im/element-android/issues/7374)) + - [Device Management] Show correct device type icons ([#7277](https://github.com/vector-im/element-android/issues/7277)) + - [Device Management] Render extended device info ([#7294](https://github.com/vector-im/element-android/issues/7294)) + - [Device management] Improve the parsing for OS of Desktop/Web sessions ([#7321](https://github.com/vector-im/element-android/issues/7321)) + - [Device management] Hide the IP address and last activity date on current session ([#7324](https://github.com/vector-im/element-android/issues/7324)) + - [Device management] Update the unknown verification status icon ([#7327](https://github.com/vector-im/element-android/issues/7327)) + - [Voice Broadcast] Add the "io.element.voice_broadcast_info" state event with a minimalist timeline widget ([#7273](https://github.com/vector-im/element-android/issues/7273)) + - [Voice Broadcast] Aggregate state events in the timeline ([#7283](https://github.com/vector-im/element-android/issues/7283)) + - [Voice Broadcast] Record and send non aggregated voice messages to the room ([#7363](https://github.com/vector-im/element-android/issues/7363)) + - [Voice Broadcast] Start listening to a voice broadcast ([#7387](https://github.com/vector-im/element-android/issues/7387)) + - [Voice Broadcast] Enable the feature (behind a lab flag and only for Android 10 and up) ([#7393](https://github.com/vector-im/element-android/issues/7393)) + - [Voice Broadcast] Add additional data in events ([#7397](https://github.com/vector-im/element-android/issues/7397)) + - Implements MSC3881: Parses `enabled` and `device_id` fields from updated Pusher API ([#7217](https://github.com/vector-im/element-android/issues/7217)) + - Adds pusher toggle setting to device manager v2 ([#7261](https://github.com/vector-im/element-android/issues/7261)) + - Implement QR Code Login UI ([#7338](https://github.com/vector-im/element-android/issues/7338)) + - Implements client-side of local notification settings event ([#7300](https://github.com/vector-im/element-android/issues/7300)) + - Links "Enable Notifications for this session" setting to enabled value in pusher ([#7281](https://github.com/vector-im/element-android/issues/7281)) + +SDK API changes ⚠️ +------------------ + - Stop using `original_event` field from `/relations` endpoint ([#7282](https://github.com/vector-im/element-android/issues/7282)) + - Add `formattedText` or similar optional parameters in several methods: + * RelationService: + * editTextMessage + * editReply + * replyToMessage + * SendService: + * sendQuotedTextMessage + This allows us to send any HTML formatted text message without needing to rely on automatic Markdown > HTML translation. All these new parameters have a `null` value by default, so previous calls to these API methods remain compatible. ([#7288](https://github.com/vector-im/element-android/issues/7288)) + - Add support for `m.login.token` auth during QR code based sign in ([#7358](https://github.com/vector-im/element-android/issues/7358)) + - Allow getting the formatted or plain text body of a message for the fun `TimelineEvent.getTextEditableContent()`. ([#7359](https://github.com/vector-im/element-android/issues/7359)) + +Other changes +------------- + - Refactor TimelineFragment, split it into MessageComposerFragment and VoiceRecorderFragment. ([#7285](https://github.com/vector-im/element-android/issues/7285)) + - Dependency to arrow has been removed. Please use `org.matrix.android.sdk.api.util.Optional` instead. ([#7335](https://github.com/vector-im/element-android/issues/7335)) + - Update WYSIWYG editor designs. ([#7354](https://github.com/vector-im/element-android/issues/7354)) + - Update WYSIWYG library to v0.2.1. ([#7384](https://github.com/vector-im/element-android/issues/7384)) + + Changes in Element v1.5.2 (2022-10-05) ====================================== diff --git a/build.gradle b/build.gradle index d0f093a451..f162685d7d 100644 --- a/build.gradle +++ b/build.gradle @@ -29,7 +29,7 @@ buildscript { classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.4.0.2513' 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.2.1' + classpath 'org.owasp:dependency-check-gradle:7.3.0' classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.7.20" classpath "org.jetbrains.kotlinx:kotlinx-knit:0.4.0" classpath 'com.jakewharton:butterknife-gradle-plugin:10.2.3' diff --git a/changelog.d/5968.bugfix b/changelog.d/5968.bugfix deleted file mode 100644 index 05cf5cea60..0000000000 --- a/changelog.d/5968.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix wrong mic button direction to cancel on RTL languages diff --git a/changelog.d/7217.wip b/changelog.d/7217.wip deleted file mode 100644 index a8cc2a3ef3..0000000000 --- a/changelog.d/7217.wip +++ /dev/null @@ -1 +0,0 @@ -Implements MSC3881: Parses `enabled` and `device_id` fields from updated Pusher API diff --git a/changelog.d/7257.wip b/changelog.d/7257.wip deleted file mode 100644 index c6f9aefbd8..0000000000 --- a/changelog.d/7257.wip +++ /dev/null @@ -1 +0,0 @@ -[Device Management] Save "matrix_client_information" events on login/registration diff --git a/changelog.d/7261.wip b/changelog.d/7261.wip deleted file mode 100644 index f7063fcc1b..0000000000 --- a/changelog.d/7261.wip +++ /dev/null @@ -1 +0,0 @@ -Adds pusher toggle setting to device manager v2 diff --git a/changelog.d/7273.wip b/changelog.d/7273.wip deleted file mode 100644 index c480a79a43..0000000000 --- a/changelog.d/7273.wip +++ /dev/null @@ -1 +0,0 @@ -[Voice Broadcast] Add the "io.element.voice_broadcast_info" state event with a minimalist timeline widget diff --git a/changelog.d/7277.wip b/changelog.d/7277.wip deleted file mode 100644 index 168d10b809..0000000000 --- a/changelog.d/7277.wip +++ /dev/null @@ -1 +0,0 @@ -[Device Management] Show correct device type icons diff --git a/changelog.d/7281.wip b/changelog.d/7281.wip deleted file mode 100644 index c457ffbdb9..0000000000 --- a/changelog.d/7281.wip +++ /dev/null @@ -1 +0,0 @@ -Links "Enable Notifications for this session" setting to enabled value in pusher diff --git a/changelog.d/7282.sdk b/changelog.d/7282.sdk deleted file mode 100644 index 14b71045cf..0000000000 --- a/changelog.d/7282.sdk +++ /dev/null @@ -1 +0,0 @@ -Stop using `original_event` field from `/relations` endpoint diff --git a/changelog.d/7283.wip b/changelog.d/7283.wip deleted file mode 100644 index f7cbd323f1..0000000000 --- a/changelog.d/7283.wip +++ /dev/null @@ -1 +0,0 @@ -[Voice Broadcast] Aggregate state events in the timeline diff --git a/changelog.d/7285.misc b/changelog.d/7285.misc deleted file mode 100644 index ce94383146..0000000000 --- a/changelog.d/7285.misc +++ /dev/null @@ -1 +0,0 @@ -Refactor TimelineFragment, split it into MessageComposerFragment and VoiceRecorderFragment. diff --git a/changelog.d/7288.feature b/changelog.d/7288.feature deleted file mode 100644 index be00e26179..0000000000 --- a/changelog.d/7288.feature +++ /dev/null @@ -1 +0,0 @@ -Add WYSIWYG editor. diff --git a/changelog.d/7288.sdk b/changelog.d/7288.sdk deleted file mode 100644 index 9c4a33ad22..0000000000 --- a/changelog.d/7288.sdk +++ /dev/null @@ -1,10 +0,0 @@ -Add `formattedText` or similar optional parameters in several methods: - -* RelationService: - * editTextMessage - * editReply - * replyToMessage -* SendService: - * sendQuotedTextMessage - -This allows us to send any HTML formatted text message without needing to rely on automatic Markdown > HTML translation. All these new parameters have a `null` value by default, so previous calls to these API methods remain compatible. diff --git a/changelog.d/7294.wip b/changelog.d/7294.wip deleted file mode 100644 index f163f6b680..0000000000 --- a/changelog.d/7294.wip +++ /dev/null @@ -1 +0,0 @@ -[Device Management] Render extended device info diff --git a/changelog.d/7300.wip b/changelog.d/7300.wip deleted file mode 100644 index 0a1777e651..0000000000 --- a/changelog.d/7300.wip +++ /dev/null @@ -1 +0,0 @@ -Implements client-side of local notification settings event diff --git a/changelog.d/7310.bugfix b/changelog.d/7310.bugfix deleted file mode 100644 index 3570b2d3ad..0000000000 --- a/changelog.d/7310.bugfix +++ /dev/null @@ -1 +0,0 @@ -[Device Management] Long session names not handled well diff --git a/changelog.d/7321.wip b/changelog.d/7321.wip deleted file mode 100644 index 2a539503b7..0000000000 --- a/changelog.d/7321.wip +++ /dev/null @@ -1 +0,0 @@ -[Device management] Improve the parsing for OS of Desktop/Web sessions diff --git a/changelog.d/7324.wip b/changelog.d/7324.wip deleted file mode 100644 index 6602ef3c85..0000000000 --- a/changelog.d/7324.wip +++ /dev/null @@ -1 +0,0 @@ -[Device management] Hide the IP address and last activity date on current session diff --git a/changelog.d/7327.wip b/changelog.d/7327.wip deleted file mode 100644 index 8f0191f948..0000000000 --- a/changelog.d/7327.wip +++ /dev/null @@ -1 +0,0 @@ -[Device management] Update the unknown verification status icon diff --git a/changelog.d/7335.misc b/changelog.d/7335.misc deleted file mode 100644 index 3b14aa1339..0000000000 --- a/changelog.d/7335.misc +++ /dev/null @@ -1 +0,0 @@ -Dependency to arrow has been removed. Please use `org.matrix.android.sdk.api.util.Optional` instead. diff --git a/changelog.d/7336.feature b/changelog.d/7336.feature deleted file mode 100644 index fb2d165b57..0000000000 --- a/changelog.d/7336.feature +++ /dev/null @@ -1 +0,0 @@ -[Device management] Add lab flag for the feature diff --git a/changelog.d/7338.wip b/changelog.d/7338.wip deleted file mode 100644 index fc47ecb2f9..0000000000 --- a/changelog.d/7338.wip +++ /dev/null @@ -1 +0,0 @@ -Implement QR Code Login UI diff --git a/changelog.d/7344.feature b/changelog.d/7344.feature deleted file mode 100644 index a6deb4a23a..0000000000 --- a/changelog.d/7344.feature +++ /dev/null @@ -1 +0,0 @@ -[Device management] Add lab flag for matrix client info account data event diff --git a/changelog.d/7354.misc b/changelog.d/7354.misc deleted file mode 100644 index 0e146a8e02..0000000000 --- a/changelog.d/7354.misc +++ /dev/null @@ -1 +0,0 @@ -Update WYSIWYG editor designs. diff --git a/changelog.d/7358.sdk b/changelog.d/7358.sdk deleted file mode 100644 index 3d17076a44..0000000000 --- a/changelog.d/7358.sdk +++ /dev/null @@ -1 +0,0 @@ -Add support for `m.login.token` auth during QR code based sign in diff --git a/changelog.d/7359.bugfix b/changelog.d/7359.bugfix deleted file mode 100644 index 98e29fb697..0000000000 --- a/changelog.d/7359.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix editing formatted messages with plain text editor diff --git a/changelog.d/7359.sdk b/changelog.d/7359.sdk deleted file mode 100644 index c78c591d67..0000000000 --- a/changelog.d/7359.sdk +++ /dev/null @@ -1 +0,0 @@ -Allow getting the formatted or plain text body of a message for the fun `TimelineEvent.getTextEditableContent()`. diff --git a/changelog.d/7363.wip b/changelog.d/7363.wip deleted file mode 100644 index b5a5f4c352..0000000000 --- a/changelog.d/7363.wip +++ /dev/null @@ -1 +0,0 @@ -[Voice Broadcast] Record and send not aggregated voice messages to the room diff --git a/changelog.d/7369.feature b/changelog.d/7369.feature new file mode 100644 index 0000000000..240fac3516 --- /dev/null +++ b/changelog.d/7369.feature @@ -0,0 +1 @@ +Add logic for sign in with QR code diff --git a/changelog.d/7374.feature b/changelog.d/7374.feature deleted file mode 100644 index aa10696dca..0000000000 --- a/changelog.d/7374.feature +++ /dev/null @@ -1 +0,0 @@ -[Device Management] Redirect to the new screen everywhere when lab flag is on diff --git a/changelog.d/7384.misc b/changelog.d/7384.misc deleted file mode 100644 index 3994dc0fa1..0000000000 --- a/changelog.d/7384.misc +++ /dev/null @@ -1 +0,0 @@ -Update WYSIWYG library to v0.2.1. diff --git a/changelog.d/7419.wip b/changelog.d/7419.wip new file mode 100644 index 0000000000..06f69dfa7f --- /dev/null +++ b/changelog.d/7419.wip @@ -0,0 +1 @@ +[Voice Broadcast] Live listening support diff --git a/changelog.d/7421.wip b/changelog.d/7421.wip new file mode 100644 index 0000000000..4a399eee04 --- /dev/null +++ b/changelog.d/7421.wip @@ -0,0 +1 @@ +[Voice Broadcast] Improve rendering in the timeline diff --git a/changelog.d/7428.bugfix b/changelog.d/7428.bugfix new file mode 100644 index 0000000000..8f014af31b --- /dev/null +++ b/changelog.d/7428.bugfix @@ -0,0 +1 @@ +Fix crash by disabling Flipper on Android API 22 and below - only affects debug version of the application. diff --git a/dependencies.gradle b/dependencies.gradle index 80380c2be2..f081e0a874 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -18,7 +18,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.0" +def flipper = "0.171.1" def epoxy = "5.0.0" def mavericks = "3.0.1" def glide = "4.14.2" diff --git a/fastlane/metadata/android/en-US/changelogs/40105040.txt b/fastlane/metadata/android/en-US/changelogs/40105040.txt new file mode 100644 index 0000000000..1073dc57e0 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40105040.txt @@ -0,0 +1,2 @@ +Main changes in this version: New features under the labs settings: Rich text composer, new device management, voice broadcast. Still under active development! +Full changelog: https://github.com/vector-im/element-android/releases 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 84f2772950..53100db285 100644 --- a/library/ui-strings/src/main/res/values-az/strings.xml +++ b/library/ui-strings/src/main/res/values-az/strings.xml @@ -1,6 +1,5 @@ - %s-nin dəvəti %1$s dəvət etdi %2$s %1$s sizi dəvət etdi @@ -27,37 +26,22 @@ bütün otaq üzvləri. hər kəs. %s bu otağı təkmilləşdirdi. - - (avatar da dəyişdirilib) %1$s otaq adını sildi %1$s otaq mövzusunu sildi %1$s otağa qoşulmaq üçün %2$s dəvətnamə göndərdi %1$s otağa qoşulmaq üçün %2$s dəvətini ləğv etdi %1$s %2$s üçün dəvəti qəbul etdi - ** Şifrəni aça bilmir: %s ** Göndərənin cihazı bu mesaj üçün açarları bizə göndərməyib. - Mesaj göndərmək olmur - - Matris xətası - - Şifrəli mesaj - Elektron poçt ünvanı Telefon nömrəsi - Otağa dəvət - %1$s və %2$s - - - Boş otaq - İlkin sinxronizasiya: \nHesab idxal olunur… İlkin sinxronizasiya: @@ -72,9 +56,7 @@ \nTərk olunmuş otaqların idxalı İlkin sinxronizasiya: \nHesab məlumatlarının idxalı - Mesaj göndərilir… - %1$s-nin dəvəti. Səbəb: %2$s %1$s dəvət olunmuş %2$s. Səbəb: %3$s %1$s sizi dəvət etdi. Səbəb: %2$s @@ -86,4 +68,5 @@ %1$s blokladı %2$s. Səbəb: %3$s %1$s %2$s üçün dəvəti qəbul etdi. Səbəb: %3$s %1$s %2$s dəvətini geri götürdü. Səbəb: %3$s - + Otağ yaratdınız + \ 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 eddae8297e..fa363cee8c 100644 --- a/library/ui-strings/src/main/res/values-ca/strings.xml +++ b/library/ui-strings/src/main/res/values-ca/strings.xml @@ -1200,7 +1200,7 @@ Url: session_name: push_key: - app_id: + ID d\'aplicació: Revisa la configuració per activar les notificacions Estàs veient la notificació! Clica\'m! Vetat per %1$s @@ -2742,4 +2742,7 @@ Sol·licita que no es desi cap dada personalitzada del teclat en funció del que escrius a les converses (per exemple l\'historial d\'escriptura o el diccionari). Tingues en compte que alguns teclats poden no respectar aquesta configuració. Teclat incògnit 🔒 Has activat el xifrat a només en sessions verificades a totes les sales, a Configuració > Seguretat. - + Estat de verificació desconegut + Escaneja codi QR + ID de sessió: + \ 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 ffa7123dae..3410858988 100644 --- a/library/ui-strings/src/main/res/values-cs/strings.xml +++ b/library/ui-strings/src/main/res/values-cs/strings.xml @@ -962,10 +962,10 @@ Push pravidla Žádná push pravidla nejsou definována Žádné push brány nejsou registrovány - app_id: - push_key: - app_display_name: - session_name: + ID aplikace: + Klíč push: + Zobrazovaný název aplikace: + Název relace: Url: Formát: Hlas a video @@ -1136,7 +1136,7 @@ \n \nZastavit proces změny hesla\? Nastavit emailovou adresu - Nastavte emailovou adresu pro obnovu svého účtu. Později můžete volitelně dovolit lidem, které znáte, aby Vás podle emailu nalezli. + Nastavte e-mailovou adresu pro obnovení účtu. Později můžete volitelně povolit svým známým, aby vás podle této adresy nalezli. Email Email (volitelné) Dále @@ -1305,7 +1305,7 @@ \nKlíče nejsou důvěryhodné Křížové podpisování není zapnuto Aktivní relace - Ukázat všechny relace + Zobrazit všechny relace Správa relací Odhlásit se z této relace Žádná kryptografická informace není k dispozici @@ -2796,4 +2796,67 @@ ⚠ V této místnosti jsou neověřená zařízení, která nebudou schopna dešifrovat odeslané zprávy. Nikdy neodesílat šifrované zprávy do neověřených relací v této místnosti. Rozumím - + Použít podtržení + Použít přeškrtnutí + Použít tučný text + Použít kurzívu + Zaznamenávat název, verzi a url pro snadnější rozpoznání relací ve správci relací. + Povolit záznamenávání informací o klientu + Získejte lepší přehled a kontrolu nad všemi relacemi. + Použít nový správce relací + Operační systém + Model + Prohlížeč + URL + Verze + Název + Aplikace + Přijímat push oznámení v této relaci. + Push oznámení + Ověřením aktuální relace zjistíte stav ověření této relace. + Neznámý stav ověření + Zapnuto: + ID relace: + Něco se pokazilo. Zkontrolujte prosím síťové připojení a zkuste to znovu. + Udělit oprávnění + ${app_name} potřebuje oprávnění k zobrazování oznámení. +\nUdělte prosím toto oprávnění. + ${app_name} potřebuje oprávnění k zobrazování oznámení. Oznámení mohou zobrazovat vaše zprávy, pozvánky atd. +\n +\nPro zobrazování oznámení povolte přístup na dalších vyskakovacích oknech. + Vyzkoušejte rozšířený textový editor (textový režim již brzy) + Povolit rozšířený textový editor + Ujistěte se, že znáte původ tohoto kódu. Propojením zařízení poskytnete někomu plný přístup ke svému účtu. + Potvrdit + Zkuste to znovu + Neshoduje se\? + Probíhá přihlašování + Připojování k zařízení + Naskenujte QR kód + Přihlašování na mobilním zařízením\? + Zobrazit QR kód na tomto zařízení + Vyberte možnost \"Naskenovat QR kód\" + Začněte na přihlašovací obrazovce + Vyberte možnost \"Přihlásit se pomocí QR kódu\" + Začněte na přihlašovací obrazovce + Vyberte možnost \"Zobrazit QR kód na tomto zařízení\" + Přejděte do Nastavení -> Zabezpečení a soukromí -> Zobrazit všechny relace + Otevřete ${app_name} na vašem druhém zařízení + Žádost byla na druhém zařízení zamítnuta. + Propojení nebylo dokončeno v požadovaném čase. + Propojení s tímto zařízením není podporováno. + Neúspěšné připojení + Zkontrolujte vaše přihlášené zařízení, měl by se zobrazit níže uvedený kód. Zkontrolujte, zda níže uvedený kód odpovídá danému zařízení: + Zabezpečené připojení navázáno + Pomocí odhlášeného zařízení naskenujte níže uvedený QR kód. + Pomocí přihlášeného zařízení naskenujte níže uvedený QR kód: + Přihlásit se pomocí QR kódu + Pomocí fotoaparátu na tomto zařízení naskenujte QR kód zobrazený na druhém zařízení: + Naskenovat QR kód + 3 + 2 + 1 + Pomocí tohoto zařízení se můžete přihlásit do mobilního nebo webového zařízení pomocí QR kódu. Můžete to provést dvěma způsoby: + Přihlásit se pomocí QR kódu + Naskenovat QR kód + \ 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 da59f03e18..fe849a1ddf 100644 --- a/library/ui-strings/src/main/res/values-de/strings.xml +++ b/library/ui-strings/src/main/res/values-de/strings.xml @@ -708,10 +708,10 @@ Unerwarteter Fehler Bist du sicher\? Wiederherstellungsschlüssel eingeben - Stelle Backup wieder her: + Stelle Sicherung wieder her: Historie entschlüsseln Von Sicherung wiederherstellen - Sicherung löschen + Lösche Sicherung Lösche Sicherung … Lösche Sicherung Präferenz der Benachrichtigungen nach Ereignis @@ -722,7 +722,7 @@ [%1$s] \nDieser Fehler ist außerhalb von ${app_name} passiert. Es gibt kein Google-Konto auf dem Gerät. Bitte füge ein Google-Konto hinzu. Verwaltung der Kryptoschlüssel - Schlüssel-Sicherung verwalten + Schlüsselsicherung verwalten Nachrichten in verschlüsselten Räumen sind mit Ende-zu-Ende-Verschlüsselung gesichert. Nur du und der Empfänger haben die Schlüssel um diese Nachrichten zu lesen. \n \nSichere deine Schlüssel, um sie nicht zu verlieren. @@ -763,7 +763,7 @@ Schlüsselsicherung sollte bei allen Sitzungen aktiviert sein, um den Verlust verschlüsselter Nachrichten zu verhindern. Ich möchte meine verschlüsselten Nachrichten nicht Sichere Schlüssel … - Sicher\? + Bist du sicher\? Sicherung Alle verschlüsselten Nachrichten gehen verloren, wenn Du dich abmeldest ohne die Schlüssel gesichert zu haben. Bist du sicher, dass du dich abmelden möchtest\? @@ -783,7 +783,7 @@ Deine Schlüssel wurden gesichert. Dein Wiederherstellungsschlüssel ist ein Sicherungsnetz - du kannst es benutzen um den Zugriff auf deine verschlüsselten Nachrichten wiederherzustellen, falls du deine Passphrase vergisst. \nVerwahre deinen Wiederherstellungsschlüssel an einem sehr sicheren Ort wie einem Passwortmanager (oder Safe) - Bewahre deinen Wiederherstellungsschlüssel an einem sehr sicheren Ort auf, wie z.B. einem Passwortmanager (oder Tresor) auf + Bewahre deinen Wiederherstellungsschlüssel an einem sehr sicheren Ort wie einem Passwortmanager (oder Safe) auf Ich habe eine Kopie angefertigt Teilen Verliere nie wieder verschlüsselte Nachrichten @@ -1495,11 +1495,11 @@ Grund für den Bann Bann des Benutzers aufheben Das Aufheben des Bannes wird dem Benutzer erlauben dem Raum wieder beizutreten. - Sicheres Backup - Backup einrichten - Backup zurücksetzen + Verschlüsselte Sicherung + Sicherung einrichten + Sicherung zurücksetzen Auf diesem Gerät einrichten - Verlust verschlüsselter Nachrichten und Daten verhindern, indem die Schlüssel für die Entschlüsselung auf dem Server gesichert werden. + Verhindere, den Zugriff auf verschlüsselte Nachrichten und Daten zu verlieren, indem du die Verschlüsselungs-Schlüssel auf deinem Server sicherst. Generiere einen neuen Sicherheitsschlüssel oder setze eine neue Sicherheitspassphrase für dein existierendes Backup. Dieses wird deinen aktuellen Schlüssel oder deine aktuelle Phrase ersetzen. Integrationen sind deaktiviert @@ -1512,9 +1512,9 @@ ANSICHT Aktive Widgets Der Sicherheitsschlüssel ist gespeichert worden. - Backup + Verschlüsselte Sicherung Absicherung gegen den Verlust verschlüsselter Nachrichten - Richte Backup ein + Sicherung einrichten Nachricht entfernt Gelöschte Nachrichten zeigen Zeigt einen Platzhalter für gelöschte Nachrichten an @@ -1534,7 +1534,7 @@ Gib die Adresse des Servers ein, den du benutzen möchtest Einloggen mit Matrix-ID Einloggen mit Matrix-ID - Wenn du einen Account auf einem Homeserver eingerichtet hast, benutze deine Matrix-ID (z.B. @benutzer:domain.com) und Passwort. + Falls du ein Konto auf einem Heim-Server eingerichtet hast, verwende nachstehend deine Matrix-ID (z. B. @benutzer:domain.com) und dein Passwort. Matrix-ID Wenn du dein Passwort nicht weißt, gehe zurück um es zurücksetzen zu lassen. Dies ist keine gültige Benutzerkennung. Erwartetes Format: \'@benutzer:homeserver.org\' @@ -1547,7 +1547,7 @@ Gib eine Sicherheitsphrase ein, die nur du kennst. Diese wird benutzt um deine Daten auf dem Server geheim zu halten. Wenn du jetzt abbrichst und den Zugriff zu deinen Sitzungen verlierst, kannst du verschlüsselte Nachrichten und Daten verlieren. \n -\nDu kannst auch ein Backup einrichten und deine Schlüssel in den Einstellungen verwalten. +\nDu kannst auch eine Sicherung einrichten und deine Schlüssel in den Einstellungen verwalten. Du hast den Raum erstellt und konfiguriert. Dieser Account ist deaktiviert worden. Konnte Mediendatei nicht speichern @@ -1575,14 +1575,14 @@ Aktiviere Mikrophon Stoppe Kamera Starte Kamera - Backup - Verlust verschlüsselter Nachrichten und Daten verhindern, indem die Schlüssel für die Entschlüsselung am Server gesichert werden. + Verschlüsselte Sicherung + Verhindere, den Zugriff auf verschlüsselte Nachrichten und Daten zu verlieren, indem du die Verschlüsselungs-Schlüssel auf deinem Server sicherst. Sicherheitsschlüssel benutzen - Generiere einen Sicherheitsschlüssel, welcher z.B. in einem Passwortmanager oder in einem Tresor sicher aufbewahrt werden sollte. + Generiere einen Sicherheitsschlüssel, den du in einem Passwort-Manager oder Tresor sicher aufbewahren solltest. Eine Sicherheitsphrase benutzen Gib eine geheime Phrase ein, die nur du kennst und generiere einen Schlüssel als Backup. Speichere deinen Sicherheitsschlüssel - Bewahre deinen Sicherheitsschlüssel irgendwo sicher auf, wie z.B. in einem Passwortmanager oder in einem Tresor. + Bewahre deinen Sicherheitsschlüssel in einem Passwort-Manager oder Tresor sicher auf. Sicherheitsphrase setzen Gib eine Sicherheitsphrase ein, welche nur du kennst und deine Daten auf dem Server geheim halten soll. Sicherheitsphrase @@ -1810,7 +1810,7 @@ Dieser Raum hat keine lokalen Adressen Füge Adressen für diesen Raum hinzu, damit andere Nutzer ihn auf %1$s finden können Lokale Adresse - Neue öffentliche Adresse (z.B. #alias:server) + Neue öffentliche Adresse (z. B. #alias:server) Noch keine weiteren öffentlichen Adressen vorhanden. Noch keine weiteren öffentlichen Adressen vorhanden, füge unten eine hinzu. Die Adresse \"%1$s\" löschen\? @@ -2741,4 +2741,67 @@ ⚠ Es befinden sich nicht verifizierte Geräte in diesem Raum. Sie werden deine Nachrichten nicht entschlüsseln können. Niemals verschlüsselte Nachrichten zu unverifizierten Sitzungen in diesem Raum senden. Verstanden - + Probiere den Rich-Text-Editor aus (bald auch mit Plain-Text-Modus) + Aktiviere Rich-Text-Editor + Browser + Durchgestrichen formatieren + Kursiv formatieren + Fett formatieren + Unterstrichen formatieren + ${app_name} benötigt die Berechtigung zur Anzeige von Benachrichtigungen. +\nBitte gewähre diese Berechtigung. + Bezeichnung, Version und URL der Anwendung registrieren, damit diese Sitzung in der Sitzungsverwaltung besser erkennbar ist. + Anwendungsinformationen erfassen + URL + Bessere Übersicht und Kontrolle über all deine Sitzungen. + Aktiviere neue Sitzungsverwaltung + Betriebssystem + Modell + Version + Name + Anwendung + Erhalte Push-Benachrichtigungen in dieser Sitzung. + Push-Benachrichtigungen + Verifiziere deine aktuelle Sitzung, um den Verifizierungsstatus dieser Sitzung anzuzeigen. + Unbekannter Verifizierungsstatus + Sitzungs-ID: + Aktiviert: + Etwas ist schiefgelaufen. Bitte überprüfe deine Internetverbindung und versuche es erneut. + Berechtigung geben + ${app_name} braucht die Berechtigung, um Benachrichtigungen anzuzeigen. Benachrichtigungen können deine Nachrichten, Einladungen etc. anzeigen. +\n +\nBitte erlaube den Zugriff im nächsten Dialog, damit Benachrichtigungen angezeigt werden können. + Bitte vergewissere dich, dass du den Ursprung dieses Codes kennst. Durch Verbindung neuer Geräte gewährst du vollen Zugriff auf dein Konto. + Bestätigen + Erneut versuchen + Keine Übereinstimmung\? + Du wirst angemeldet + Verbinde mit Gerät + QR-Code einlesen + Mobiles Gerät anmelden\? + QR-Code auf diesem Gerät anzeigen + Wähle „QR-Code einlesen“ + Beginne auf dem Anmeldebildschirm + Wähle „Mit QR-Code anmelden“ + Beginne auf dem Anmeldebildschirm + Wähle \'QR-Code auf diesem Gerät anzeigen\' + Gehe zu Einstellungen -> Sicherheit und Privatsphäre -> Alle Sitzungen anzeigen + Öffne ${app_name} auf deinem anderen Gerät + Die Anfrage wurde auf dem anderen Gerät abgelehnt. + Die Verbindung konnte nicht in der erforderlichen Zeit hergestellt werden. + Verbindung mit diesem Gerät nicht unterstützt. + Verbindung fehlgeschlagen + Überprüfe dein angemeldetes Gerät. Der unten gezeigte Code sollte angezeigt werden. Bestätige, dass beide Codes übereinstimmen: + Sichere Verbindung hergestellt + Lese den unten angezeigten QR-Code mit deinem nicht angemeldeten Gerät ein. + Benutze dein angemeldetes Gerät um den unten angezeigten QR-Code einzulesen: + Mit QR-Code anmelden + Benutze die Kamera auf diesem Gerät um den vom anderen Gerät angezeigten QR-Code zu scannen: + QR-Code scannen + 3 + 2 + 1 + Du kannst dieses Gerät benutzen um ein anderes Gerät per QR-Code anzumelden. Dafür gibt es zwei Wege: + Mit QR-Code anmelden + QR-Code scannen + \ 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 d0f3b540e2..7ead21394c 100644 --- a/library/ui-strings/src/main/res/values-et/strings.xml +++ b/library/ui-strings/src/main/res/values-et/strings.xml @@ -1158,10 +1158,10 @@ Tõuketeavituste reeglid Tõuketeavituste reegleid pole kirjeldatud Tõuketeavituste võrguväravaid pole registreeritud - app_id: - push_key: - app_display_name: - session_name: + Rakenduse ID: + Tõuketeenuse võti: + Rakenduse kuvatav nimi: + Sessiooni nimi: URL: Vorming: Heli ja video @@ -2733,4 +2733,67 @@ Ava arendaja töövahendite vaade Ära iialgi saada selles jututoas krüptitud sõnumeid verifitseerimata sessioonidesse. Selge lugu - + Proovi vormindatud teksti alusel töötavat tekstitoimetit (varsti lisandub ka vormindamata teksti režiim) + Võta kasutusele vormindatud teksti pruukiv tekstitoimeti + Vaata seadet, kus sa oled Matrix\'i võtku loginud - seal peaks nüüd kuvatama QR-koodi. Kinnita, et allpool toodud QR-kood on sama kui tolles seadmes kuvatav kood: + Sa võid seda seadet kasutada nutiseadme või veebirakenduse sisselogimiseks QR-koodi alusel. Sa saad seda teha kahel moel: + Kasuta allajoonitud kirja + Kasuta läbijoonitud kirja + Kasuta kaldkirja + Kasuta paksu kirja + Palun vaata, et sa kindlasti tead, kust see QR-kood kuvatakse. Sellisel viisil seadmete sidumisel sa annad oma kasutajakontole täiemahulise ligipääsu. + Kinnita + Proovi uuesti + Ei klapi\? + Logime sind võrku + Loon ühendust seadmega + Loe QR-koodi + Kas logid sisse nutiseadmest\? + Näita selles seadmes QR-koodi + Vali „Loe QR-koodi“ + Alusta sisselogimisvaatest + Vali „Logi võrku QR-koodi abil“ + Alusta sisselogimisvaatest + Vali „Näita selles seadmes QR-koodi“ + Ava Seadistused -> Turvalisus ja privaatsus -> Näita kõiki sessioone + Ava ${app_name} oma teises seades + Teine seade lükkas päringu tagasi. + Sidumine ei lõppenud etteantud aja jooksul. + Sidumine selle seadmega ei ole toetatud. + Seoste loomine ei õnnestunud + Turvaline ühendus on olemas + Loe QR-koodi seadmega, kus sa oled Matrix\'i võrgust välja loginud. + Järgneva QR-koodi skaneerimiseks kasuta seadet, kus sa oled Matrix\'i võrku loginud: + Logi sisse QR-koodi abil + Kasuta selle seadme kaamerat ja logi sisse teises seadmes kuvatud QR-koodi alusel: + Loe QR-koodi + 3 + 2 + 1 + Sessioonide paremaks tuvastamiseks saad nüüd sessioonihalduris salvestada klientrakenduse nime, versiooni ja aadressi. + Luba klientrakenduse teabe salvestamine + Sellega saad parema ülevaate oma sessioonidest ja võimaluse neid mugavasti hallata. + Kasuta uut sessioonihaldurit + Logi sisse QR-koodi abil + Operatsioonisüsteem + Mudel + Brauser + URL + Versioon + Nimi + Rakendus + Luba selles sessioonis tõuketeavitused. + Tõuketeavitused + Selle sessiooni olekut ei saa tuvastada enne kui oled ta verifitseerinud. + Verifitseerimise olek on määratlemata + Loe QR-koodi + Kasutusel: + Sessiooni tunnus: + Midagi läks nüüd sassi. Palun kontrolli oma seadme võrguühendust ja proovi uuesti. + Anna õigused + ${app_name} vajab teavituste näitamiseks õigusi. +\nPalun luba vastavad õigused. + ${app_name} vajab teavituste näitamiseks õigusi. Teavituste sisuks võivad olla sulle saadetud sõnumid, kutsed ja muud olulist. +\n +\nJärgmistes vaadetes palun anna sellele rakendusele teavituste kuvamiseks vajalikud õigused. + \ 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 47cade0bf8..dbf3658887 100644 --- a/library/ui-strings/src/main/res/values-fa/strings.xml +++ b/library/ui-strings/src/main/res/values-fa/strings.xml @@ -1492,10 +1492,10 @@ ثبت ژتون فرمت: آدرس: - نام نشست: - نام برنامه: - کلید push: - شناسه برنامه: + نام نمایشی نشست: + نام نمایشی کاره: + کلید ارسال: + شناسهٔ کاره: هیچ push gateway‌ای ثبت نشده است هیچ قانونی برای push تعریف نشده است شما در حال مشاهده این اتاق هستید! @@ -2720,4 +2720,10 @@ گشودن صفحهٔ ابزارهای توسعه‌دهنده به کار انداختن پیام‌های مستقیم تعویقی گرفتم - + آغاز یک پخش همگانی صوتی + (╯°□°)╯︵ ┻━┻ را به ابتدای پیام متنی خام می‌افزاید + پخش همگانی صدا + اعطای دسترسی + ویرایشگر متن غنی را بیازمایید (حالت متن خام به زودی) + به کار انداختن ویرایشگر متن غنی + \ 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 860840486e..b77173519d 100644 --- a/library/ui-strings/src/main/res/values-fr/strings.xml +++ b/library/ui-strings/src/main/res/values-fr/strings.xml @@ -869,10 +869,10 @@ Règles de notification Aucune règle de notification définie Aucune passerelle de notification enregistrée - app_id : - push_key : - app_display_name : - session_name : + App ID : + Clé Push : + Nom d’affichage de l’application : + Nom d’affichage de la session : URL : Format : Voix et vidéo @@ -2742,4 +2742,34 @@ ⚠ Il y a des appareils non vérifiés dans ce salon, ils ne pourront pas déchiffrer vos messages envoyés. Ne jamais envoyer de messages chiffrés aux sessions non vérifiées dans ce salon. Compris - + Souligner le texte + Barrer le texte + Mettre en italique + Mettre en gras + Enregistre le nom du client, sa version, et son URL pour retrouvez vos sessions plus facilement dans le gestionnaire de sessions. + Activer l’enregistrement des informations du client + Ayez une meilleur visibilité et plus de contrôle sur toutes vos sessions. + Activer le nouveau gestionnaire de session + Système d’exploitation + Modèle + Navigateur + URL + Version + Nom + Application + Recevoir les notifications push sur cette session. + Notifications push + Vérifiez votre session actuelle pour découvrir le statut de vérification de cette session. + Status de vérification inconnu + Activer : + Identifiant de session : + Quelque chose s’est mal passé. Vérifiez votre connexion réseau et réessayez. + Accorder la permission + ${app_name} a besoin d’une permission pour afficher les notifications. +\nVeuillez accorder la permission. + ${app_name} a besoin de la permission pour afficher les notifications. Les notifications peuvent afficher vos messages, vos invitations, etc. +\n +\nVeuillez autoriser l’accès sur la prochaine fenêtre pour pouvoir voir des notifications. + Essayer l’éditeur de texte formaté (le mode texte brut arrive bientôt) + Activer l’éditeur de texte formaté + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-hu/strings.xml b/library/ui-strings/src/main/res/values-hu/strings.xml index d2e9568053..59792a9218 100644 --- a/library/ui-strings/src/main/res/values-hu/strings.xml +++ b/library/ui-strings/src/main/res/values-hu/strings.xml @@ -569,7 +569,7 @@ Matrixban az üzenetek láthatósága hasonlít az e-mailre. Az üzenet törlés Alapszintű diagnosztika nem talált hibát. Ha még mindig nem kapsz értesítéseket, kérlek küldj egy hiba jegyet amivel segítheted a hibakeresésünket. Egy vagy több teszt is sikertelen volt, próbáld ki a javasolt javítást, javításokat. Egy vagy több teszt sikertelenül végződött, kérlek küldj egy hibabejelentést ami segít nekünk a problémát kivizsgálni. - Rendszer beállítások. + Rendszerbeállítások. Az értesítések engedélyezve vannak a rendszerbeállításokban. Az értesítések tiltva vannak a rendszerbeállításokban. Kérlek ellenőrizd a rendszerbeállításokat. @@ -582,7 +582,7 @@ Kérlek ellenőrizd a fiókbeállításokat. Munkamenet beállítások. Az értesítések engedélyezve vannak ezen az munkameneten. Az értesítések tiltva vannak ezen a munkameneten. Kérlek ellenőrizd a ${app_name} beállításokat. - Engedélyez + Engedélyezés Play Szolgáltatások ellenőrzése Google Play Services APK elérhető és a legújabb verziójú. "${app_name} a Google Play Services-t használja a „push” értesítések fogadásához, de úgy tűnik az nincs megfelelően beállítva: @@ -824,18 +824,18 @@ A Visszaállítási Kulcsot tartsd biztonságos helyen, mint pl. egy jelszókeze Már nézed ezt a szobát! Általános Beállítások - Biztonság & Adatvédelem + Biztonság és adatvédelem „Push” szabályok „Push” szabályok nincsenek „Push” átjárók nincsenek regisztrálva - app_id: - push_key: - app_display_name: - session_name: + Alk azon: + Push kulcs: + Alk. képernyő név: + Munkamenet képernyő név: Url: Formátum: - Hang & Videó - Segítség & Névjegy + Hang és videó + Súgó és névjegy Token regisztrálása Javaslat tétel A javaslatodat kérlek ír le alulra. @@ -897,7 +897,7 @@ A Visszaállítási Kulcsot tartsd biztonságos helyen, mint pl. egy jelszókeze Amint hozzáadtál egy telefonszámot megjelenik a felderítési beállítási lehetőség. Az azonosítási szerverről való lecsatlakozással nem leszel mások által megtalálható és másokat sem tudsz meghívni e-mail címmel vagy telefonszámmal. Felderíthető telefonszámok - Megerősítő levelet küldtünk ide: %s, ellenőrizd az e-mailedet és kattints a megerősítő hivatkozásra + E-mailt küldtünk ide: %s, ellenőrizd és kattints a megerősítő hivatkozásra Add meg az azonosítási szerver URL-jét Az azonosítási szerverhez nem lehet csatlakozni Kérlek add meg az azonosítási szerver url-jét @@ -1391,7 +1391,7 @@ A Visszaállítási Kulcsot tartsd biztonságos helyen, mint pl. egy jelszókeze Felhasználókat nem tudtuk meghívni. Ellenőrizd azokat a felhasználókat akiket meg szeretnél hívni és próbáld újra. Üzenet eltávolítva Helykitöltő mutatása a törölt szövegek helyett - Megerősítő levelet küldtünk ide: %s, először ellenőrizd az e-mailedet és kattints a megerősítő hivatkozásra + E-mailt küldtünk ide: %s, először ellenőrizd és kattints a megerősítő hivatkozásra MÉDIA FÁJLOK %1$s itt: %2$s @@ -2709,4 +2709,100 @@ A Visszaállítási Kulcsot tartsd biztonságos helyen, mint pl. egy jelszókeze Késleltetett közvetlen üzenetek engedélyezése Egyszerűsített Element opcionálisan lapokkal Új kinézet engedélyezése - + Más felhasználók akikkel közvetlenül vagy szobában beszélgetsz látják a teljes listát a munkameneteidről. +\n +\nEzzel ők biztosak lehetnek abban, hogy ténylegesen veled beszélgetnek. Ez azt is jelenti, hogy látják a munkamenet nevét amit itt megadsz. + Ellenőrzött munkamenetbe a neveddel és jelszavaddal léptek be és ellenőrizve lett vagy a biztonsági jelmondattal vagy másik munkamenetből. +\n +\nEz azt jelenti, hogy tartalmazzák a titkosítási kulcsokat az régi üzenetekhez, és biztosítja a többieket a kommunikációban, hogy ezt a munkamenetet tényleg te használod. + Aláhúzott + Áthúzott + Dőlt + Félkövér + Kliens neve, verziója és url felvétele a munkamenet könnyebb azonosításához a munkamenet kezelőben. + Kliens információ felvételének engedélyezése + Jobb áttekintés és felügyelet a munkamenetek felett. + Új munkamenet kezelő engedélyezése + Munkamenet átnevezése + Hitelesített munkamenetek + Az ellenőrizetlen munkamenetek azok amikre a felhasználói neveddel és jelszavaddal léptek be de nem lett ellenőrizve. +\n +\nMindenképpen győződj meg arról, hogy felismered ezeket a munkameneteket mert lehet, hogy illetéktelenül használják a fiókodat. + Ellenőrizetlen munkamenetek + Az inaktív munkamenetek azok amiket egy ideje nem használtál, de továbbra is megkapják a titkosítási kulcsokat. +\n +\nA nem aktív munkamenetek törlésével növelhető a biztonság és a sebesség valamint könnyebb lesz felismerni a gyanús munkameneteket. + Nem aktív munkamenetek + Fontos, hogy a munkamenet neve a kommunikációban résztvevők számára látható. + Az egyedi munkamenet név segíthet az eszköz könnyebb felismerésében. + Munkamenet neve + Munkamenet átnevezése + Operációs rendszer + Modell + Böngésző + URL + Verzió + Név + Alkalmazás + Push értesítések fogadása ebben a munkamenetben. + Push értesítések + Kijelentkezés ebből a munkamenetből + Ellenőrizetlen · A jelenlegi munkameneted + Ellenőrizd a jelenlegi munkamenetedet, hogy ismert állapotba kerüljön. + Ismeretlen ellenőrzési státusz + Hang közvetítés indítása + A titkosított üzenetek valódiságát ezen az eszközön nem lehet garantálni. + Utasítja a billentyűzetet, hogy ne mentsen személyre szabott adatokat, mint előzmények vagy szótár abból amit a beszélgetésekben írsz. Vedd figyelembe, hogy nem minden billentyűzet veszi ezt figyelembe. + Inkognitó billentyűzet + (╯°□°)╯︵ ┻━┻ -t tesz a szöveg elejére + Hang közvetítés + Engedélyezve: + Munkamenet azon.: + Valami nem sikerült. Kérlek ellenőrizd a hálózati kapcsolatot és próbáld újra. + A fejlesztői eszközök képernyő megnyitása + 🔒 Bekapcsoltad a Biztonsági beállításoknál, hogy csak ellenőrzött munkamenetek számára legyen titkosítva az üzenet bármely szobában. + ⚠ Ellenőrizetlen eszközök vannak a szobában, ezek nem fogják tudni visszafejteni az általad küldött üzeneteket. + Sose küldj titkosított üzenetet ellenőrizetlen munkamenetbe ebből a munkamenetből ebben a szobában. + Engedély megadása + ${app_name} alkalmazásnak értesítések megjelenítéséhez engedélyre van szüksége. +\nKérjük, adj rá engedélyt. + ${app_name} alkalmazásnak szüksége van engedélyre az értesítések megjelenítéséhez. Az értesítés megjelenítheti az üzenetet, meghívót, stb. +\n +\nA következő felugró ablakban adj rá engedélyt, hogy az értesítések megjelenhessenek. + Próbálja ki az új szövegbevitelt (hamarosan érkezik a sima szöveges üzemmód) + Vizuális szerkesztő engedélyezése + Értem + Nem egyezik\? + Bejelentkeztetés + Mobil eszközzel jelentkezel be\? + Kezd a bejelentkező képernyőn + Kezd a bejelentkező képernyőn + Nézd meg a már bejelentkezett eszközödet, az alábbi kódot kell megjelenítenie. Erősítsd meg, hogy az alábbi kód megegyezik a másik eszközön láthatóval: + Használd a már belépett eszközt az alábbi QR kód beolvasásához: + Ezzel az eszközzel, QR kód segítségével, bejelentkezhetsz mobil és webes munkamenetbe. Két lehetőséged is van: + Győződj meg a kód eredetéről. Az eszközök összekötésével esetleg valakinek teljes hozzáférést adhatsz a fiókodhoz. + Megerősítés + Próbáld újra + Csatlakozás az eszközhöz + QR kód beolvasása + QR kód megjelenítése ezen az eszközön + Válaszd ezt: „QR kód beolvasása” + Válaszd ezt: „Belépés QR kóddal” + Válaszd ezt: „QR kód megjelenítése ezen az eszközön” + Menj a Beállítások -> Biztonság és Adatvédelem -> Minden munkamenet megjelenítése menübe + Nyisd meg a(z) ${app_name} alkalmazást a másik eszközön + A kérést elutasították a másik eszközön. + Az összekötés az elvárt időn belül nem fejeződött be. + Összekötés ezzel az eszközzel nem támogatott. + Kapcsolat sikertelen + Biztonságos kapcsolat beállítva + A kijelentkezett eszközzel olvasd be a QR kódot alább. + Belépés QR kóddal + Használd a kamerát ezen az eszközön a másik eszközödön megjelenő QR kód beolvasására: + QR kód beolvasása + 3 + 2 + 1 + Belépés QR kóddal + QR kód beolvasása + \ 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 7a389a3fca..6d20563d55 100644 --- a/library/ui-strings/src/main/res/values-in/strings.xml +++ b/library/ui-strings/src/main/res/values-in/strings.xml @@ -1240,10 +1240,10 @@ Di masa mendatang proses verifikasi ini akan dimutakhirkan. Suara & Video Format: Url: - session_name: - app_display_name: - push_key: - app_id: + Nama Tampilan Sesi: + Nama Tampilan Aplikasi: + Kunci Dorongan: + ID Aplikasi: Tidak ada gateway dorong terdaftar Tidak ada aturan push yang ditentukan Aturan Push @@ -1632,7 +1632,7 @@ Di masa mendatang proses verifikasi ini akan dimutakhirkan. Lanjut Email (opsional) Email - Atur sebuah alamat email untuk memulihkan akun Anda. Nantinya, Anda dapat mengizinkan orang yang Anda tahu untuk menemukan Anda dari email secara opsional. + Atur sebuah alamat email untuk memulihkan akun Anda. Nantinya, Anda dapat mengizinkan orang yang Anda tahu untuk menemukan Anda dari email ini secara opsional. Atur alamat email Kata sandi Anda belum diubah. \n @@ -2690,4 +2690,67 @@ Di masa mendatang proses verifikasi ini akan dimutakhirkan. ⚠ Ada perangkat yang belum diverifikasi di ruangan ini, mereka tidak akan mendekripsikan pesan yang Anda kirim. Jangan kirim pesan terenkripsi ke sesi yang belum diverifikasi di ruangan ini. Saya mengerti - + Terapkan format garis bawah + Terapkan format coret + Terapkan format miring + Terapkan format tebal + Rekam nama klien, versi, dan URL untuk lebih mudah mengenal sesi di pengelola sesi. + Aktifkan perekaman info klien + Miliki keterlihatan dan kendali yang lebih baik pada semua sesi Anda. + Aktifkan pengelola sesi baru + Sistem operasi + Model + Peramban + URL + Versi + Nama + Aplikasi + Terima notifikasi dorongan di sesi ini. + Notifikasi dorongan + Verifikasi sesi Anda saat ini untuk menampilkan status verifikasi sesi ini. + Status verifikasi tidak diketahui + Diaktifkan: + ID Sesi: + Ada sesuatu yang salah. Mohon periksa koneksi jaringan Anda dan coba lagi. + Berikan Izin + ${app_name} membutuhkan izin untuk menampilkan notifikasi. +\nMohon berikan izin itu. + ${app_name} membutuhkan izin untuk menampilkan notifikasi. Notifikasi dapat menampilkan pesan Anda, undangan Anda, dll. +\n +\nMohon perbolehkan akses di munculan berikutnya untuk dapat melihat notifikasi. + Coba editor teks kaya (mode teks biasa akan datang) + Aktifkan editor teks kaya + Pastikan Anda tahu asal kode ini. Dengan menautkan perangkat, Anda akan memberikan seseorang akses penuh ke akun Anda. + Konfirmasi + Coba lagi + Tidak cocok\? + Memasukkan Anda + Menghubungkan ke perangkat + Pindai kode QR + Ingin masuk di perangkat ponsel\? + Tampilkan kode QR di perangkat ini + Pilih \'Pindai dengan kode QR\' + Mulai dari layar masuk + Pilih \'Masuk dengan kode QR\' + Mulai dari layar masuk + Pilih \'Tampilkan kode QR di perangkat ini\' + Pergi ke Pengaturan → Keamanan & Privasi → Tampilkan Semua Sesi + Buka ${app_name} di perangkat Anda yang lain + Permintaan ditolak di perangkat lain. + Penautan tidak selesai dalam waktu yang dibutuhkan. + Penautan dengan perangkat ini tidak didukung. + Koneksi tidak berhasil + Periksa perangkat yang masuk, kode di bawah seharusnya ditampilkan. Konfirmasi bahwa kode di bawah cocok dengan perangkat itu: + Koneksi aman dibuat + Pindai kode QR di bawah dengan perangkat Anda yang telah keluar dari akun. + Gunakan perangkat yang sudah masuk untuk memindai kode QR di bawah: + Masuk dengan kode QR + Gunakan kamera pada perangkat ini untuk memindai kode QR yang ditampilkan pada perangkat Anda yang lain: + Pindai kode QR + 3 + 2 + 1 + Anda dapat menggunakan perangkat ini untuk masuk ke perangkat ponsel atau web dengan sebuah kode QR. Ada dua cara untuk melalukan ini: + Masuk dengan Kode QR + Pindai kode QR + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-it/strings.xml b/library/ui-strings/src/main/res/values-it/strings.xml index f65aca5bb7..cea69030bc 100644 --- a/library/ui-strings/src/main/res/values-it/strings.xml +++ b/library/ui-strings/src/main/res/values-it/strings.xml @@ -889,10 +889,10 @@ Regole di push Nessuna regola di push definita Nessun gateway di push registrato - id_app: - chiave_push: - nome_visualizzato_app: - nome_sessione: + ID app: + Chiave push: + Nome mostrato app: + Nome mostrato sessione: Url: Formato: Audio e Video @@ -2733,4 +2733,67 @@ ⚠ Ci sono dispositivi non verificati in questa stanza, non potranno decifrare i messaggi che invii. Non inviare mai messaggi cifrati a sessioni non verificate in questa stanza. Capito - + Applica formato sottolineato + Applica formato sbarrato + Applica formato corsivo + Applica formato grassetto + Registra il nome, la versione e l\'url del client per riconoscere le sessioni più facilmente nel gestore di sessioni. + Attiva registrazione info client + Maggiore visibilità e controllo su tutte le tue sessioni. + Attiva il nuovo gestore di sessioni + Sistema operativo + Modello + Browser + URL + Versione + Nome + Applicazione + Ricevi notifiche push in questa sessione. + Notifiche push + Verifica l\'attuale sessione per rivelare lo stato di verifica di questa sessione. + Stato di verifica sconosciuto + Attivato: + ID sessione: + Qualcosa è andato storto. Controlla la tua connessione di rete e riprova. + Concedi l\'autorizzazione + ${app_name} chiede l\'autorizzazione per mostrare notifiche. +\nConcedi l\'autorizzazione. + ${app_name} chiede l\'autorizzazione per mostrare notifiche. Le notifiche possono mostrare i messaggi, gli inviti, ecc. +\n +\nConsenti l\'accesso nelle prossime schermate per potere vedere la notifica. + Prova l\'editor in rich text (il testo semplice è in arrivo) + Attiva editor in rich text + Assicurati di conoscere l\'origine di questo codice. Collegando i dispositivi, fornirai a qualcuno l\'accesso totale al tuo account. + Conferma + Riprova + Non corrisponde\? + Accesso in corso + Connessione al dispositivo + Scansiona codice QR + Effettuare l\'accesso in un dispositivo mobile\? + Mostra codice QR in questo dispositivo + Seleziona \'Scansiona codice QR\' + Inizia nella schermata di accesso + Seleziona ‘Accedi con codice QR’ + Inizia nella schermata di accesso + Seleziona ‘Mostra codice QR in questo dispositivo’ + Vai in Impostazioni -> Sicurezza e privacy -> Mostra tutte le sessioni + Apri ${app_name} sull\'altro dispositivo + La richiesta è stata negata sull\'altro dispositivo. + Il collegamento non è stato completato nel tempo previsto. + Il collegamento con questo dispositivo non è supportato. + Connessione non riuscita + Controlla il dispositivo che ha l\'accesso, dovresti vedere il codice sotto. Conferma che il codice corrisponda con quel dispositivo: + Connessione sicura stabilita + Scansiona il codice QR sottostante con il dispositivo che è disconnesso. + Usa il dispositivo che ha l\'accesso per scansionare il codice QR sotto: + Accedi con codice QR + Usa la fotocamera di questo dispositivo per scansionare il codice QR mostrato nell\'altro dispositivo: + Scansiona codice QR + 3 + 2 + 1 + Puoi usare questo dispositivo per accedere in un dispositivo mobile o web con un codice QR. Ci sono due modi: + Accedi con codice QR + Scansiona codice QR + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-iw/strings.xml b/library/ui-strings/src/main/res/values-iw/strings.xml index ff19310c8e..b9f81ae446 100644 --- a/library/ui-strings/src/main/res/values-iw/strings.xml +++ b/library/ui-strings/src/main/res/values-iw/strings.xml @@ -861,9 +861,7 @@ בחר שרת בית מותאם אישית בחר שירותי מטריקס אלמנט בחר matrix.org - חשבונך טרם נוצר. -\n -\nלהפסיק את תהליך ההרשמה\? + חשבונך טרם נוצר. להפסיק את תהליך ההרשמה\? אזהרה שם המשתמש הזה תפוס הבא @@ -2304,7 +2302,7 @@ קהילות צוותים חברים ומשפחה - נעזור לך להתחבר. + נעזור לך להתחבר עם מי תדברו הכי הרבה\? מוצפן מקצה לקצה ואין צורך במספר טלפון. ללא פרסומות או עיבוד נתונים. בחר היכן השיחות שלך נשמרות, נותן לך שליטה ועצמאות. מחובר דרך Matrix. @@ -2508,4 +2506,4 @@ \nזה יהיה מעבר חד פעמי שכן שרשורים הם כעת חלק ממפרט Matrix. שיתוף מסך של ${app_name} המסך משותף כרגע - + \ 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 3e817e398c..37c0bca52f 100644 --- a/library/ui-strings/src/main/res/values-ja/strings.xml +++ b/library/ui-strings/src/main/res/values-ja/strings.xml @@ -987,7 +987,7 @@ プッシュ通知のテスト FCMトークンのホームサーバーへの登録に失敗しました: \n%1$s - FCMトークンのホームサーバーへの登録が成功しました。 + FCMトークンがホームサーバーに登録されました。 トークンの登録 アカウントを追加 [%1$s] @@ -1233,7 +1233,7 @@ 続行するには利用規約を承認してください ホームサーバーの利用規約を承認したら、再試行してください。 次に - 次に + 次へ 次に 次に 次に @@ -1282,9 +1282,9 @@ 提案の送信に失敗しました(%s) ありがとうございます、提案は正常に送信されました トークンの登録 - app_display_name: - app_id: - push_key: + アプリケーションの表示名: + App ID: + Push Key: 登録されたプッシュゲートウェイはありません プッシュ通知に関するルールが定義されていません プッシュ通知に関するルール @@ -1383,8 +1383,8 @@ 同意を撤回 あなたの連絡先から他のユーザーを発見するために、メールアドレスや電話番号をこのIDサーバーに送信することに同意しています。 メールと電話番号を送信 - %sに確認メールを送りました。まず、メールを確認してリンクをクリックしてください - %sに確認のためのメールを送りました。メールにて確認リンクをクリックしてください + %sにメールを送りました。メールを確認してリンクをクリックしてください + %sにメールを送りました。メールの確認リンクをクリックしてください 発見可能な電話番号 IDサーバーとの接続を解除すると、他のユーザーによって発見されなくなり、また、メールアドレスや電話で他のユーザーを招待することができなくなります。 電話番号を追加すると、発見可能に設定する電話番号を選択できるようになります。 @@ -1412,7 +1412,7 @@ 提案する フォーマット: URL: - セッション名: + セッションの表示名: 以下のうちいずれかが流出、あるいはハッキングされた恐れがあります。 \n \n- あなたのパスワード @@ -1696,7 +1696,7 @@ メッセージを送る… このファイルは大きすぎてアップロードできません。 この情報の送信に同意しますか? - 連絡先を発見するには、連絡先のデータ(電話番号や電子メール)をあなたのIDサーバーに送信する必要があります。プライバシーの保護のため、データは送信前にハッシュ化されます。 + 連絡先を発見するには、連絡先のデータ(メールアドレスと電話番号)をあなたのIDサーバーに送信する必要があります。プライバシーの保護のため、データは送信前にハッシュ化されます。 メールアドレスと電話番号を%sに送信 このIDサーバーは運営方針を提供していません IDサーバーの運営方針を隠す @@ -2359,4 +2359,104 @@ ベータ版 ベータ版 試す - + オフラインモード + 新着はありません。 + - ユーザーの無視が解除されました + 試してみる + 右上をタップするとフィードバックを送信するオプションが表示されます。 + フィードバックを送信 + 右下からスペースにより早く簡単にアクセスできます。 + スペースにアクセス + ${app_name}をシンプルにするために、タブはオプションになりました。右上のメニューから管理できます。 + 新しいレイアウトにようこそ! + アニメーション画像を自動再生 + エンドポイントのホームサーバーへの登録に失敗しました: +\n%1$s + エンドポイントがホームサーバーに登録されました。 + エンドポイントの登録 + 権限を与える + ${app_name}は通知の表示に権限が必要です。 +\n権限を与えてください。 + + %1$sと他%2$d名 + + %1$sと%2$s + ホームサーバーがサポートしていないため、スレッド機能は不安定かもしれません。スレッドのメッセージは安定して表示されないおそれがあります。%sスレッド機能を有効にしてよろしいですか? + スレッド(ベータ版) + スレッドを用いると、会話のテーマを保ったり、会話を追跡したりするのが容易になります。%sスレッドを有効にするとアプリケーションが再起動します。再起動には時間がかかる可能性があります。 + スレッド(ベータ版) + ${app_name}は通知を表示するために許可を必要としています。通知にはメッセージや招待などが表示されます。 +\n +\n通知を表示するには、次のポップアップでアクセスを許可してください。 + メールアドレスが認証されていません。メールボックスを確認してください + 画面共有を停止 + 画面を共有 + 招待 + プッシュ通知 + セッション名 + セッションを改名 + IPアドレス + オペレーティングシステム + 形式 + ブラウザー + URL + バージョン + 名称 + アプリケーション + このステップをスキップ + 問題ありません! + 進みましょう + ユーザー名 / メールアドレス / 電話番号 + あなたは人間ですか? + %sに送信された手順に従ってください + パスワードを再設定 + パスワードを忘れた場合 + 電子メールを再送信 + 電子メールが届いていませんか? + %sに送信された手順に従ってください + メールアドレスを認証 + コードを再送信 + コードが%sに送信されました + 電話番号を確認してください + 全ての端末からサインアウト + パスワードを再設定 + パスワードは8文字以上に設定してください。 + パスワードを選択 + 新しいパスワード + 電子メールを確認してください。 + %sは認証リンクを送信します + 確認コード + 電話番号 + %sはアカウントの認証が必要です + 電話番号を入力してください + メールアドレス + %sはアカウントの認証が必要です + リッチテキストエディターを有効にする + 最初のメッセージを送信する際にダイレクトメッセージを作成 + 遅延DMを有効にする + スペースがありません。 + 新しいレイアウトを有効にする + アクティビティー順 + アルファベット順 + 並び替え + フィルターを表示 + レイアウトの設定 + 了解 + 次へ + 詳しく知る + + + + ${app_name}は以下の理由で、キャッシュを消去して最新の状態にする必要があります。 +\n%s +\n +\nアプリケーションが再起動します。再起動には時間がかかる可能性があります。 + 初期同期のリクエスト + %sの子スペースを折りたたむ + %sの子スペースを展開 + ルームを探索 + スペースを変更 + ルームを作成 + チャットを開始 + 全ての会話 + \ 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 e0d6e6aa86..97141a9765 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 @@ -1007,10 +1007,10 @@ Regras de Push Nenhuma regra de push definida Nenhum gateway de push registrado - app_id: - push_key: - app_display_name: - session_name: + ID do App: + Chave Push: + Nome de Exibição do App: + Nome de Exibição da Sessão: Url: Formato: Voz & Vídeo @@ -2742,4 +2742,67 @@ ⚠ Existem dispositivos não-verificados nesta sala, eles não vão ser capazes de decriptar mensagens que você enviar. Nunca enviar mensagens encriptadas a sessões não-verificadas nesta sala. Entendido - + Aplicar formato tachar + Aplicar formato sublinhar + Aplicar formato itálico + Aplicar formato negrito + Gravar o nome de cliente, versão, e url para reconhecer sessões mais facilmente em gerenciador de sessão. + Habilitar gravação de info de cliente + Tenha visibilidade e controle maiores sobre todas suas sessões. + Habilitar novo gerenciador de sessão + Sistema operativo + Modelo + Browser + URL + Versão + Nome + Aplicativo + Receber notificações push nesta sessão. + Notificações push + Verifique sua sessão atual para revelar o status de verificação desta sessão. + Status de verificação desconhecido + Habilitado: + ID da Sessão: + Algo deu errado. Por favor cheque sua conexão de rede e tente de novo. + Conceder Permissão + ${app_name} precisa de permissão para mostrar notificações. +\nPor favor conceda a permissão. + ${app_name} precisa de permissão para exibir notificações. Notificações podem exibir suas mensagens, seus convites, etc. +\n +\nPor favor permita acesso nos próximos pop-ups para ser capaz de visualizar notificação. + Experimente o editor de texto rico (modo de texto puro vindo em breve) + Habilitar editor de texto rico + Por favor assegure que você sabe a origem deste código. Ao linkar dispositivos, você vai prover alguém com acesso completo a sua conta. + Confirmar + Tentar de novo + Nenhuma correspondência\? + Fazendo-lhe signin + Conectando a dispositivo + Scannar QR code + Fazendo signin com um dispositivo móvel\? + Mostrar QR code neste dispositivo + Selecione \'Scannar QR code\' + Comece na tela de signin + Selecione \'Fazer signin com QR code\' + Comece na tela de signin + Selecione \'Mostrar QR code neste dispositivo\' + Vá para Configurações -> Segurança & Privacidade -> Mostrar Todas as Sessões + Obra ${app_name} em seu outro dispositivo + A requisição foi negada no outro dispositivo. + A linkagem não foi completada no tempo requerido. + Linkagem com este dispositivo não é suportado. + Conexão malsucedida + Cheque seu dispositivo feito signin, o código abaixo deveria ser exibido. Confirme que o código abaixo corresponde com esse dispositivo: + Conexão segura estabelecida + Scanne o QR code abaixo com seu dispositivo que está feito signout. + Use seu dispositivo feito signin para scannar o QR code abaixo: + Fazer signin com QR code + Use a câmera neste dispositivo para scannar o QR code mostrado em seu outro dispositivo: + Scannar QR code + 3 + 2 + 1 + Você pode usar este dispositivo para fazer signin com um dispositivo móvel ou web com um QR code. Existem duas maneiras de fazer isto: + Fazer signin com QR Code + Scannar QR code + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-ru/strings.xml b/library/ui-strings/src/main/res/values-ru/strings.xml index 8d19e8d9d0..0349acedd1 100644 --- a/library/ui-strings/src/main/res/values-ru/strings.xml +++ b/library/ui-strings/src/main/res/values-ru/strings.xml @@ -398,8 +398,8 @@ Прикрепить комнаты с отключенными уведомлениями Прикрепить комнаты с непрочитанными сообщениями ID - Публичное имя - Обновить публичное имя + Публичное название + Обновить публичное название Недавно %1$s @ %2$s Аутентификация @@ -431,7 +431,7 @@ Установить как основной адрес Сбросить основной адрес Ошибка дешифровки - Публичное имя + Публичное название ID сессии Ключ сессии Экспорт E2E ключей комнаты @@ -727,21 +727,21 @@ Беззвучный Пожалуйста, введите мнемоническую фразу Парольная фраза слишком простая - Пожалуйста, удалите мнемоническую фразу, если хотите, чтобы ${app_name} сгенерировал ключ восстановления. + Пожалуйста, удалите мнемоническую фразу, если хотите, чтобы ${app_name} сгенерировал бумажный ключ. Никогда не теряйте зашифрованных сообщений Сообщения в зашифрованных комнатах защищены сквозным шифрованием. Ключи для прочтения этих сообщений есть только у вас и получателя(ей). \n \nНадёжно сохраните резервную копию ключей, чтобы не потерять их. Установите парольную фразу - Сохраните ключ восстановления + Сохранить бумажный ключ Готово Сохранить как файл Пожалуйста, сделайте копию - Поделиться ключом восстановления с… - Ключ для восстановления + Поделиться бумажным ключом с… + Бумажный ключ Непредвиденная ошибка Уверены? - Удалить резервную копию ключей шифрования с сервера? Вы больше не сможете использовать ключ восстановления для чтения истории зашифрованных сообщений. + Удалить резервную копию ключей шифрования с сервера\? Вы больше не сможете использовать бумажный ключ для чтения истории зашифрованных сообщений. Удалить резервную копию Удаление резервной копии… Чтобы использовать резервную копию ключа в этой сессии, восстановите его с помощью своей парольной фразы или ключа восстановления. @@ -755,17 +755,17 @@ Резервное копирование ключей успешно настроено для этой сессии. Удалить резервную копию Восстановить из резервной копии - Пожалуйста, введите ключ восстановления + Пожалуйста, введите бумажный ключ Разблокировать историю Восстановление резервной копии: - Введите ключ восстановления - Используйте ключ восстановления для разблокировки истории зашифрованных сообщений - Если вы не знаете вашу парольную фразу для восстановления, вы можете %s. - используйте ключ восстановления + Введите бумажный ключ + Используйте бумажный ключ для разблокировки зашифрованных сообщений + Если забыли свою мнемоническую фразу, вы можете %s. + используйте бумажный ключ Вы можете потерять доступ к сообщениям, если выйдете из системы или потеряете это устройство. Получение версии резервной копии… - Используйте парольную фразу для разблокировки истории зашифрованных сообщений - Потеряли ключ восстановления? В настройках вы можете создать новый. + Используйте мнемоническую фразу для разблокировки зашифрованных сообщений + Потеряли бумажный ключ\? В настройках вы можете создать новый. Резервная копия восстановлена %s ! Резервная копия имеет недействительную подпись из неподтвержденной сессии %s Не удалось получить последнюю версию ключей восстановления (%s). @@ -780,9 +780,9 @@ Восстановлены резервные копии с %d ключами. Восстановлены резервные копии с %d ключами. - Невозможно расшифровать резервную копию с помощью этого ключа восстановления: убедитесь, что вы ввели правильный ключ. - Невозможно расшифровать резервную копию с помощью этого пароля: убедитесь, что вы ввели правильный пароль. - Генерация ключей восстановления с использованием парольной фразы может занять несколько секунд. + Невозможно расшифровать резервную копию с помощью этого бумажного ключа: пожалуйста, убедитесь, что вы ввели правильный бумажный ключ. + Невозможно расшифровать резервную копию с помощью этой мнемонической фразы: пожалуйста, убедитесь, что вы ввели правильную мнемоническую фразу. + Генерация бумажного ключа с использованием мнемонической фразы может занять несколько секунд. [%1$s] \nЭта ошибка вне контроля ${app_name}. На телефоне нет учетной записи Google. Пожалуйста, добавьте аккаунт Google. [%1$s] @@ -816,7 +816,7 @@ Никогда не теряйте зашифрованные сообщения Поделиться Я сделал(а) копию - Храните ключ восстановления в надежном месте, например, в диспетчере паролей (или в сейфе) + Храните бумажный ключ в очень надёжном месте, например, в менеджере паролей (или в сейфе) Защитите резервную копию мнемонической фразой. Восстановление зашифрованных сообщений Начать использовать резервное копирование ключей @@ -825,16 +825,16 @@ Новые ключи зашифрованных сообщений Ваши ключи копируются. (Дополнительно) Настройка с ключом восстановления - Или защитите резервную копию с помощью ключа восстановления, сохранив его в безопасном месте. + Или защитите резервную копию бумажным ключом, сохранив его в надёжном месте. Безопасная резервная копия ключей должна быть активирована на всех ваших сессиях, чтобы не потерять доступ к зашифрованным сообщениям. - Зашифрованная копия ключей будет храниться на вашем сервере. Для безопасности защитите её парольной фразой. + Зашифрованная копия ключей будет храниться на вашем сервере. Для безопасности защитите её мнемонической фразой. \n -\nДля максимальной безопасности парольная фраза должна отличаться от пароля вашей учётной записи. +\nДля максимальной безопасности мнемоническая фраза должна отличаться от пароля вашей учётной записи. Ключ восстановления — это страховка, вы можете использовать его для восстановления доступа к вашим зашифрованным сообщениям, если забудете вашу парольную фразу. \nХраните ключ восстановления в надёжном месте, например, в диспетчере паролей (или в сейфе) Импортирование ключей… Скачивание ключей… - Вычисление ключа восстановления… + Вычисление бумажного ключа… Игнорировать Отметить как прочитанное Войти с помощью единого входа @@ -929,10 +929,10 @@ Предпочтения Безопасность Правила push-уведомлений - app_id: + ID приложения: push_key: - app_display_name: - session_name: + Отображаемое название приложения: + Отображаемое название сессии: Url: Формат: Голос и видео @@ -1219,10 +1219,10 @@ Ещё QR-код Соединение с сервером потеряно - Используйте пароль восстановления или ключ + Используйте мнемоническую фразу или бумажный ключ Разблокировать историю зашифрованных сообщений Проверка была отменена. Вы можете начать проверку снова. - Мнемоническая фраза для восстановления + Мнемоническая фраза Введите %s, чтобы продолжить. Не переиспользуйте пароль учётной записи. Это может занять несколько секунд, пожалуйста, наберитесь терпения. @@ -1314,7 +1314,7 @@ Сброс безопасного резервного копирования Настроить на этом устройстве Защитите себя от потери доступа к зашифрованным сообщениям и данным, создав резервные копии ключей шифрования на вашем сервере. - Создайте новый ключ безопасности или задайте новую секретную фразу для существующей резервной копии. + Создайте новый бумажный ключ или задайте новую мнемоническую фразу для существующей резервной копии. Это заменит ваш текущий ключ или фразу. Интеграции отключены Включите «Управление интеграциями» в настройках, чтобы сделать это. @@ -1326,7 +1326,7 @@ Ключи успешно экспортированы ОБЗОР Активные виджеты - Ключ восстановления был сохранён. + Бумажный ключ сохранён. Безопасное резервное копирование Защита от потери доступа к зашифрованным сообщениям и данным Настроить безопасное резервное копирование @@ -1553,12 +1553,12 @@ Эта учётная запись была деактивирована. Введите %s, чтобы продолжить Использовать файл - Это недействительный ключ восстановления - Пожалуйста, введите ключ восстановления + Этот бумажный ключ недействителен + Пожалуйста, введите бумажный ключ Проверка ключа резервного копирования Проверка ключа резервного копирования (%s) Получение кривой ключа - Генерация ключа SSSS из ключа восстановления + Генерация ключа SSSS из бумажного ключа Сохранение резервной копии ключа в SSSS используйте ваш ключ восстановления ключа резервной копии Ключ восстановления ключа резервной копии @@ -1571,7 +1571,7 @@ \n${app_name} для Android или другой клиент Matrix поддерживающий перекрестную подпись Принудительно отбрасывает текущую групповую сессию для отправки сообщений в зашифрованную комнату - Чтобы продолжить, используйте ваш %1$s или используйте ваш %2$s. + Чтобы продолжить, используйте %1$s или %2$s. Используйте ключ восстановления Выберите ключ восстановления или введите его вручную, введя или вставив из буфера обмена Не удалось получить доступ к защищенному хранилищу данных @@ -1624,13 +1624,13 @@ Настроить Используйте ключ безопасности Создайте ключ безопасности для хранения в надежном месте, например в менеджере паролей или сейфе. - Использовать секретную фразу + Использовать мнемоническую фразу Введите секретную фразу, известную только вам, и создайте ключ для резервного копирования. Сохраните свой ключ безопасности - Храните ключ безопасности в надежном месте, например в менеджере паролей или сейфе. + Храните бумажный ключ в надёжном месте, например, в менеджере паролей или в сейфе. Задайте секретную фразу Введите секретную фразу, известную только вам, для защиты данных на вашем сервере. - Секретная фраза + Мнемоническая фраза Для подтверждения введите вашу секретную фразу ещё раз. Название комнаты Тема @@ -1646,7 +1646,7 @@ Мы рады сообщить, что сменили имя! Ваше приложение обновлено, и вы вошли в свою учетную запись. ПОНЯТНО УЗНАТЬ БОЛЬШЕ - Сохранить ключ восстановления в + Сохранить бумажный ключ в Получаем ваши контакты… Ваша контактная книга пуста Книга контактов @@ -1701,12 +1701,12 @@ Этот номер телефона уже используется. В ваш аккаунт не добавлен номер телефона Адрес электронной почты - В ваш аккаунт не добавлен адрес электронной почты + В вашу учётную запись не добавлен адрес электронной почты Телефонные номера Удалить %s\? Убедитесь, что вы перешли по ссылке в электронном письме, которое мы вам отправили. Электронная почта и номера телефонов - Управляйте электронной почтой и номерами телефонов, привязанными к вашей учетной записи Matrix + Управляйте адресами электронной почты и номерами телефонов, привязанными к вашей учётной записи Matrix Код Используйте международный формат (номер телефона должен начинаться с \'+\') Подтвердите свою личность, проверив этот логин, предоставив ему доступ к зашифрованным сообщениям. @@ -2633,7 +2633,7 @@ Где хранятся ваши переписки Где будут храниться ваши переписки Должно быть 8 или более символов - Не удалось подтвердить это устройство + Не удалось подтвердить эту сессию Невозможно открыть эту ссылку: сообщества были заменены пространствами Имя пользователя / Почта / Телефон Следуйте инструкциям, отправленным на %s @@ -2758,11 +2758,38 @@ Понятно 🔒 В настройках безопасности вы включили шифрование только для заверенных сессий во всех комнатах. Не отправлять зашифрованные сообщения незаверенным сессиям в этой комнате. - Неактивные сессии — это сессии, которыми вы не пользовались определенное время, но они продолжают получать ключи шифрования. + Неактивные сессии — это сессии, которыми вы не пользовались определённое время, но они продолжают получать ключи шифрования. \n \nУдаление неактивных сессий повышает безопасность и производительность, а также облегчает выявление подозрительных новых сессий. Переименование сессий Другие пользователи в личных сообщениях и комнатах, к которым вы присоединились, могут просматривать весь список ваших сессий. \n \nЭто даёт им уверенность в том, что они действительно общаются с вами, но это также означает, что они могут видеть название сессии, которое вы ввели здесь. - + Визуальный редактор текста + ID сессии: + Уведомления + Получать push-уведомления в этой сессии. + URL-адрес + Приложение + Название + Версия + Веб-браузер + Модель + Операционная система + Новый менеджер сессий + + Рассмотрите возможность выхода из старых сессий (%1$d день или дольше), которые вы более не используете. + Рассмотрите возможность выхода из старых сессий (%1$d дня или дольше), которые вы более не используете. + Рассмотрите возможность выхода из старых сессий (%1$d дней или дольше), которые вы более не используете. + Рассмотрите возможность выхода из старых сессий (%1$d дней или дольше), которые вы более не используете. + + Результаты будут видны после завершения опроса + Доступ к пространствам (внизу справа) быстрее и проще, чем когда-либо прежде. + Доступ к пространствам + + Рассмотрите возможность выхода из старых сессий (%1$d день или дольше), которые вы более не используете. + Рассмотрите возможность выхода из старых сессий (%1$d дня или дольше), которые вы более не используете. + Рассмотрите возможность выхода из старых сессий (%1$d дней или дольше), которые вы более не используете. + Рассмотрите возможность выхода из старых сессий (%1$d дней или дольше), которые вы более не используете. + + \ 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 d4c3f8c40c..43a8301d58 100644 --- a/library/ui-strings/src/main/res/values-sk/strings.xml +++ b/library/ui-strings/src/main/res/values-sk/strings.xml @@ -1700,7 +1700,7 @@ Prosím, použite medzinárodný formát. Nastavte si telefónne číslo, aby ste voliteľne umožnili ľuďom, ktorých poznáte, aby vás objavili. Toto nevyzerá ako platná e-mailová adresa - Nastavte si e-mail na obnovenie konta. Neskôr môžete voliteľne povoliť známym, aby vás objavili podľa vášho e-mailu. + Nastavte si e-mail na obnovenie konta. Neskôr môžete voliteľne povoliť svojim známym, aby vás objavili podľa tohto e-mailu. Nastaviť e-mailovú adresu Späť na prihlásenie Vaše heslo bolo obnovené. @@ -2796,4 +2796,67 @@ ⚠ V tejto miestnosti sa nachádzajú neoverené zariadenia, ktoré nebudú schopné dešifrovať odoslané správy. Nikdy neposielať šifrované správy do neoverených relácií v tejto miestnosti. Rozumiem - + Použiť formát podčiarknutia + Použiť formát prečiarknutia + Použiť formát kurzívou + Použiť tučný formát + Zaznamenať názov klienta, verziu a url, aby bolo možné ľahšie rozpoznať relácie v správcovi relácií. + Povoliť zaznamenanie informácií o klientovi + Majte lepší prehľad a kontrolu nad všetkými reláciami. + Použiť nového správcu relácií + Operačný systém + Model + Prehliadač + URL + Verzia + Názov + Aplikácia + Prijímať push oznámenia v tejto relácii. + Push oznámenia + Overením aktuálnej relácie zistíte stav overenia tejto relácie. + Neznámy stav overenia + Zapnuté: + ID relácie: + Niečo sa pokazilo. Skontrolujte, prosím, svoje sieťové pripojenie a skúste to znova. + Udeliť oprávnenie + ${app_name} potrebuje povolenie na zobrazovanie oznámení. +\nProsím, udeľte toto povolenie. + ${app_name} potrebuje povolenie na zobrazovanie oznámení. Oznámenia môžu zobrazovať vaše správy, pozvánky atď. +\n +\nPovoľte prístup na ďalších vyskakovacích oknách, aby ste mohli zobrazovať oznámenia. + Vyskúšajte rozšírený textový editor (čistý textový režim sa objaví čoskoro) + Povoliť rozšírený textový editor + Uistite sa prosím, že poznáte pôvod tohto kódu. Prepojením zariadení poskytnete niekomu plný prístup k svojmu účtu. + Potvrdiť + Skúste to znova + Nezhoduje sa\? + Prebieha prihlasovanie + Pripájanie k zariadeniu + Skenovať QR kód + Prihlasovanie do mobilného zariadenia\? + Zobraziť QR kód na tomto zariadení + Vyberte možnosť \"Skenovať QR kód\" + Začnite na prihlasovacej obrazovke + Vyberte možnosť \"Prihlásiť sa pomocou QR kódu\" + Začnite na prihlasovacej obrazovke + Vyberte možnosť \"Zobraziť QR kód na tomto zariadení\" + Prejdite do Nastavenia -> Zabezpečenie a súkromie -> Zobraziť všetky relácie + Otvorte ${app_name} na vašom druhom zariadení + Žiadosť bola na druhom zariadení zamietnutá. + Prepojenie nebolo dokončené v požadovanom čase. + Prepojenie s týmto zariadením nie je podporované. + Neúspešné pripojenie + Skontrolujte svoje prihlásené zariadenie, mal by sa zobraziť nasledujúci kód. Skontrolujte, či sa nižšie uvedený kód zhoduje s daným zariadením: + Zabezpečené pripojenie bolo vytvorené + Naskenujte nižšie uvedený QR kód pomocou zariadenia, ktoré je odhlásené. + Pomocou prihláseného zariadenia naskenujte nižšie uvedený QR kód: + Prihlásiť sa pomocou QR kódu + Pomocou fotoaparátu na tomto zariadení naskenujte QR kód zobrazený na vašom druhom zariadení: + Skenovať QR kód + 3 + 2 + 1 + Pomocou tohto zariadenia sa môžete prihlásiť do mobilného alebo webového zariadenia pomocou QR kódu. Môžete to urobiť dvoma spôsobmi: + Prihlásiť sa pomocou QR kódu + Skenovať QR kód + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-sv/strings.xml b/library/ui-strings/src/main/res/values-sv/strings.xml index bf083a1117..1f38d2069c 100644 --- a/library/ui-strings/src/main/res/values-sv/strings.xml +++ b/library/ui-strings/src/main/res/values-sv/strings.xml @@ -615,7 +615,7 @@ Jag har verifierat min e-postadress Du har blivit utloggad ur alla sessioner och kommer inte längre motta pushnotiser. För att återaktivera pushnotiser, logga in igen på varje enhet. Sätt e-postadress - Sätt en e-postadress för att kunna återförva ditt konto. Senare kan du valfritt låta personer du känner upptäcka dig med din e-postadress. + Sätt en e-postadress för att kunna återförvärva ditt konto. Senare kan du valfritt låta personer du känner upptäcka dig med den här e-postadressen. E-post E-post (valfritt) Sätt ett telefonnummer som valfritt kan användas för att vara upptäckbar av folk som känner dig. @@ -1312,10 +1312,10 @@ Ett fel inträffade vid hämtning av nyckelsäkerhetskopia Du tittar redan på det här rummet! Inga registrerade pushgateways - app_id: - push_key: - app_display_name: - session_name: + App-ID: + Pushnyckel: + Appens visningsnamn: + Sessionens visningsnamn: Url: Format: Registrera token @@ -2651,4 +2651,71 @@ Favoriter Olästa Alla - + Pushnotiser + Applikations-, enhets- och aktivitetsinformation. + Sessionsdetaljer + Logga ut ur den här sessionen + Rensa filter + Inga inaktiva sessioner hittade. + Inga overifierade sessioner hittade. + Inga verifierade sessioner hittade. + + Överväg att logga ut ur gamla sessioner (%1$d dag eller längre) du inte använder längre. + Överväg att logga ut ur gamla sessioner (%1$d dagar eller längre) du inte använder längre. + + Inaktiv + Verifiera dina sessioner för förbättrad säker meddelandehantering eller logga ut ur de du inte känner igen eller använder längre. + Overifierad + För bäst säkerhet, logga ut från sessioner du inte känner igen eller använder längre. + Verifierad + Filter + + Överväg att logga ut ur gamla sessioner (%1$d dag eller längre) som du inte använder längre. + Överväg att logga ut ur gamla sessioner (%1$d dagar eller längre) som du inte använder längre. + + + Inaktiv %1$d dag eller längre + Inaktiv %1$d dagar eller längre + + Inaktiv + Inte redo för säkra meddelanden + Overifierad + Redo för säkra meddelanden + Verifierade + Alla sessioner + Filter + Senast aktiv %1$s + Enhet + Session + Nuvarande session + Inaktiva sessioner + Verifiera eller logga ut ur overifierade sessioner. + Overifierade sessioner + Förbättra din kontosäkerhet genom att följa dessa rekommendationer. + Säkerhetsrekommendationer + + Inaktiv %1$d+ dag (%2$s) + Inaktiv %1$d+ dagar (%2$s) + + Overifierad · Din nuvarande session + Overifierad · Senast aktiv %1$s + Verifierad · Senast aktiv %1$s + Visa alla (%1$d) + Visa detaljer + Verifiera session + Verifiera din nuvarande session för att visa den här sessionens verifieringsstatus. + Verifiera eller logga ut från den här sessionen för bäst säkerhet och pålitlighet. + Verifiera din nuvarande session för förbättrad säker meddelandehantering. + Okänd verifieringsstatus + Aktiverad: + Sessions-ID: + Nåt gick fel. Kolla din nätverksanslutning och pröva igen. + Ge åtkomst + ${app_name} behöver behörighet att visa aviseringar. +\nVänligen ge åtkomst. + ${app_name} behöver behörighet att visa aviseringar. Aviseringar kan visa dina meddelanden, dina inbjudningar, o.s.v. +\n +\nVänligen ge åtkomst på nästa pop-uper för att kunna se aviseringar. + Aktivera rik-text-redigerare + Testa den nya rik-text-redigeraren + \ 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 45ee44213c..2a04e58f41 100644 --- a/library/ui-strings/src/main/res/values-uk/strings.xml +++ b/library/ui-strings/src/main/res/values-uk/strings.xml @@ -1740,10 +1740,10 @@ Відгук Формат: Url: - session_name: - app_display_name: - push_key: - app_id: + Показувана назва сеансу: + Показувана назва застосунку: + Ключ Push: + ID застосунку: Версія Matrix SDK Кімнату створено, але деякі запрошення не надіслано з такої причини: \n @@ -2850,4 +2850,67 @@ ⚠ У цій кімнаті є неперевірені пристрої, вони не зможуть розшифрувати повідомлення, які ви надсилаєте. Ніколи не надсилати зашифровані повідомлення на неперевірені сеанси в цій кімнаті. Зрозуміло - + Застосувати форматування підкресленим + Застосувати форматування перекресленим + Застосувати форматування курсивом + Застосувати форматування жирним + Записуйте назву клієнта, версію та URL-адресу, щоб легше розпізнавати сеанси в менеджері сеансів. + Увімкнути запис відомостей про клієнт + Отримайте кращу видимість і контроль над усіма вашими сеансами. + Увімкнути новий менеджер сеансів + Операційна система + Модель + Браузер + URL + Версія + Назва + Застосунок + Отримувати push-сповіщення про цей сеанс. + Push-сповіщення + Звірте свій поточний сеанс, щоб побачити стан перевірки цього сеансу. + Невідомий стан перевірки + Увімкнено: + ID сеансу: + Щось пішло не так. Будь ласка, перевірте мережеве з\'єднання та спробуйте ще раз. + Надати дозвіл + ${app_name} потребує дозволу на показ сповіщень. +\nНадайте дозвіл. + Для показу сповіщень ${app_name} потрібен дозвіл. Сповіщення можуть показувати ваші повідомлення, запрошення тощо. +\n +\nДозвольте доступ до наступних спливних вікон, щоб мати змогу переглядати сповіщення. + Спробуйте розширений текстовий редактор (незабаром з\'явиться режим звичайного тексту) + Увімкнути розширений текстовий редактор + Переконайтеся, що ви знаєте походження цього коду. Пов\'язавши пристрої, ви надасте будь-кому повний доступ до свого облікового запису. + Підтвердити + Повторити спробу + Не збігається\? + Вхід + Під\'єднання до пристрою + Входите на мобільному пристрої\? + Показати QR-код на цьому пристрої + Виберіть «Сканувати QR-код» + Виберіть «Увійти за допомогою QR-коду» + Почніть з екрана входу + Почніть з екрана входу + Виберіть «Показати QR-код на цьому пристрої» + Перейдіть до Налаштування -> Безпека й приватність -> Показати всі сеанси + Відкрийте ${app_name} на іншому своєму пристрої + Запит на іншому пристрої було відхилено. + Пов\'язування не було завершено у встановлені терміни. + Пов\'язування з цим пристроєм не підтримується. + Невдале з\'єднання + Перевірте свій пристрій, на якому ви ввійшли. На екрані повинен з\'явитися код, наведений нижче. Переконайтеся, що наведений код збігається з кодом на вашому пристрої: + Безпечне з\'єднання встановлено + Зіскануйте QR-код нижче своїм пристроєм, з якого ви вийшли. + Скануйте QR-код нижче за допомогою свого пристрою для входу: + Увійти за допомогою QR-коду + Використовуйте камеру цього пристрою, щоб зісканувати QR-код, показаний на іншому пристрої: + 3 + 2 + 1 + За допомогою цього пристрою ви можете ввійти на мобільному або вебпристрої за допомогою QR-коду. Зробити це можна двома способами: + Увійти за допомогою QR-коду + Сканувати QR-код + Сканувати QR-код + Сканувати QR-код + \ 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 39992ff418..ae29132c91 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 @@ -2623,4 +2623,6 @@ 仅在首条消息创建私聊消息 启用延迟的私聊消息 简化的Element,带有可选的标签 - + 无痕键盘 + 要求键盘不要基于你在对话中的输入更新任何个性化数据,如输入历史和字典。请注意,某些键盘可能不会遵守此设置。 + \ 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 e3cd44adca..c9057cd289 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 @@ -873,10 +873,10 @@ 推送規則 未定義通送規則 沒有已註冊的推送閘道 - app_id: - push_key: - app_display_name: - session_name: + App ID: + 推送金鑰: + 應用程式顯示名稱: + 工作階段顯示名稱: Url: 格式: 音訊與視訊 @@ -1092,7 +1092,7 @@ \n \n停止密碼變更流程? 設定電子郵件地址 - 設定電子郵件地址以復原您的帳號。之後您也可以選擇性地讓您認識的人透過您的這個地址找到您。 + 設定電子郵件地址以復原您的帳號。之後您也可以選擇性地讓您認識的人透過此地址找到您。 電子郵件 電子郵件(選擇性) 下一個 @@ -2688,4 +2688,67 @@ ⚠ 此聊天室中有未驗證的裝置,它們將無法解密您傳送的訊息。 切莫向此聊天室中未經驗證的工作階段傳送加密訊息。 知道了 - + 套用底線格式 + 套用刪除線格式 + 套用義式斜體格式 + 套用粗體格式 + 記錄客戶端名稱、版本與 URL,以便在工作階段管理程式中可以更簡單地辨認工作階段。 + 啟用客戶端資訊記錄 + 對所有工作階段有更大的能見度與控制。 + 啟用新的工作階段管理程式 + 作業系統 + 模型 + 瀏覽器 + URL + 版本 + 名稱 + 應用程式 + 接收關於此工作階段的推播通知。 + 推播通知 + 驗證您目前的工作階段以顯示此工作階段的驗證狀態。 + 未知的驗證狀態 + 已啟用: + 工作階段 ID: + 發生了一些問題。請檢查您的網路連線並再試一次。 + 授予權限 + ${app_name} 需要權限以顯示通知。 +\n請授予權限。 + ${app_name} 需要權限才能顯示通知。通知可以顯示您的訊息、您的邀請等等。 +\n +\n請在下一個彈出式視窗允許存取以檢視通知。 + 試用格式化文字編輯器(純文字模式即將推出) + 啟用格式化文字編輯器 + 請確保您知道此驗證碼的來源。透過連結裝置,您將為某人提供對您帳號的完整存取權限。 + 確認 + 再試一次 + 不相符? + 登入 + 連線至裝置 + 掃描 QR code + 正在使用行動裝置登入? + 在此裝置顯示 QR code + 選取「掃描 QR code」 + 從登入畫面開始 + 選取「使用 QR code 登入」 + 從登入畫面開始 + 選取「在此裝置上顯示 QR code」 + 到「設定」→「安全與隱私」→「顯示所有工作階段」 + 在您的其他裝置上開啟 ${app_name} + 請求在另一台裝置上被拒絕。 + 連結未在規定時間內完成。 + 不支援與其裝置連結。 + 連線不成功 + 請檢查您已登入的裝置,應該會顯示以下驗證碼。請確認以下驗證碼與該裝置相符: + 已建立安全連線 + 使用您已登出的裝置掃描以下 QR code。 + 使用您已登入的裝置來掃描下方的 QR code: + 使用 QR code 登入 + 使用此裝置的相機掃描您其他裝置上顯示的 QR code: + 掃描 QR code + 3 + 2 + 1 + 您可以使用此裝置透過 QR code 登入移動裝置或網路裝置。有兩種方法可以作到: + 使用 QR code 登入 + 掃描 QR code + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index 74ec175d17..ea9b4b5999 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -3078,6 +3078,14 @@ %1$s (%2$s) (%1$s) + Live + Resume voice broadcast record + Pause voice broadcast record + Stop voice broadcast record + Play or resume voice broadcast + Pause voice broadcast + Buffering + 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. @@ -3346,6 +3354,8 @@ Have greater visibility and control over all your sessions. Enable client info recording Record the client name, version, and url to recognise sessions more easily in session manager. + Enable voice broadcast (under active development) + Be able to record and send voice broadcast in room timeline. %s\nis looking a little empty. @@ -3384,9 +3394,16 @@ Linking with this device is not supported. The linking wasn’t completed in the required time. The request was denied on the other device. - Open ${app_name} on your other device - Go to Settings -> Security & Privacy -> Show All Sessions - Select \'Show QR code in this device\' + The request failed. + A security issue was encountered setting up secure messaging. One of the following may be compromised: Your homeserver; Your internet connection(s); Your device(s); + The other device is already signed in. + The other device must be signed in. + That QR code is invalid. + The sign in was cancelled on the other device. + The homeserver doesn\'t support sign in with QR code. + Open the app on your other device + Go to Settings -> Security & Privacy + Select \'Show QR code\' Start at the sign in screen Select \'Sign in with QR code\' Start at the sign in screen diff --git a/library/ui-styles/src/main/res/values/dimens.xml b/library/ui-styles/src/main/res/values/dimens.xml index 52d16eae7d..50d5aaf014 100644 --- a/library/ui-styles/src/main/res/values/dimens.xml +++ b/library/ui-styles/src/main/res/values/dimens.xml @@ -73,6 +73,9 @@ 12dp 22dp + + 48dp + 112dp diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index 4a6c0edf10..968d8515ac 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.4\"" + buildConfigField "String", "SDK_VERSION", "\"1.5.6\"" buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\"" buildConfigField "String", "GIT_SDK_REVISION_UNIX_DATE", "\"${gitRevisionUnixDate()}\"" diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/logger/LoggerTag.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/logger/LoggerTag.kt index ae65963f37..22af8cebbd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/logger/LoggerTag.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/logger/LoggerTag.kt @@ -27,6 +27,7 @@ open class LoggerTag(name: String, parentTag: LoggerTag? = null) { object SYNC : LoggerTag("SYNC") object VOIP : LoggerTag("VOIP") object CRYPTO : LoggerTag("CRYPTO") + object RENDEZVOUS : LoggerTag("RZ") val value: String = if (parentTag == null) { name diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt new file mode 100644 index 0000000000..f724ac4b62 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt @@ -0,0 +1,229 @@ +/* + * 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.rendezvous + +import android.net.Uri +import org.matrix.android.sdk.api.auth.AuthenticationService +import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig +import org.matrix.android.sdk.api.logger.LoggerTag +import org.matrix.android.sdk.api.rendezvous.channels.ECDHRendezvousChannel +import org.matrix.android.sdk.api.rendezvous.model.ECDHRendezvousCode +import org.matrix.android.sdk.api.rendezvous.model.Outcome +import org.matrix.android.sdk.api.rendezvous.model.Payload +import org.matrix.android.sdk.api.rendezvous.model.PayloadType +import org.matrix.android.sdk.api.rendezvous.model.Protocol +import org.matrix.android.sdk.api.rendezvous.model.RendezvousError +import org.matrix.android.sdk.api.rendezvous.model.RendezvousIntent +import org.matrix.android.sdk.api.rendezvous.transports.SimpleHttpRendezvousTransport +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.crypto.crosssigning.DeviceTrustLevel +import org.matrix.android.sdk.api.session.crypto.crosssigning.KEYBACKUP_SECRET_SSSS_NAME +import org.matrix.android.sdk.api.session.crypto.crosssigning.MASTER_KEY_SSSS_NAME +import org.matrix.android.sdk.api.session.crypto.crosssigning.SELF_SIGNING_KEY_SSSS_NAME +import org.matrix.android.sdk.api.session.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME +import org.matrix.android.sdk.api.util.MatrixJsonParser +import timber.log.Timber + +/** + * Implementation of MSC3906 to sign in + E2EE set up using a QR code. + */ +class Rendezvous( + val channel: RendezvousChannel, + val theirIntent: RendezvousIntent, +) { + companion object { + private val TAG = LoggerTag(Rendezvous::class.java.simpleName, LoggerTag.RENDEZVOUS).value + + @Throws(RendezvousError::class) + fun buildChannelFromCode(code: String): Rendezvous { + val parsed = try { + // we rely on moshi validating the code and throwing exception if invalid JSON or doesn't + MatrixJsonParser.getMoshi().adapter(ECDHRendezvousCode::class.java).fromJson(code) + } catch (a: Throwable) { + throw RendezvousError("Invalid code", RendezvousFailureReason.InvalidCode) + } ?: throw RendezvousError("Invalid code", RendezvousFailureReason.InvalidCode) + + val transport = SimpleHttpRendezvousTransport(parsed.rendezvous.transport.uri) + + return Rendezvous( + ECDHRendezvousChannel(transport, parsed.rendezvous.key), + parsed.intent + ) + } + } + + private val adapter = MatrixJsonParser.getMoshi().adapter(Payload::class.java) + + // not yet implemented: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE + val ourIntent: RendezvousIntent = RendezvousIntent.LOGIN_ON_NEW_DEVICE + + @Throws(RendezvousError::class) + private suspend fun checkCompatibility() { + val incompatible = theirIntent == ourIntent + + Timber.tag(TAG).d("ourIntent: $ourIntent, theirIntent: $theirIntent, incompatible: $incompatible") + + if (incompatible) { + // inform the other side + send(Payload(PayloadType.FINISH, intent = ourIntent)) + if (ourIntent == RendezvousIntent.LOGIN_ON_NEW_DEVICE) { + throw RendezvousError("The other device isn't signed in", RendezvousFailureReason.OtherDeviceNotSignedIn) + } else { + throw RendezvousError("The other device is already signed in", RendezvousFailureReason.OtherDeviceAlreadySignedIn) + } + } + } + + @Throws(RendezvousError::class) + suspend fun startAfterScanningCode(): String { + val checksum = channel.connect() + + Timber.tag(TAG).i("Connected to secure channel with checksum: $checksum") + + checkCompatibility() + + // get protocols + Timber.tag(TAG).i("Waiting for protocols") + val protocolsResponse = receive() + + if (protocolsResponse?.protocols == null || !protocolsResponse.protocols.contains(Protocol.LOGIN_TOKEN)) { + send(Payload(PayloadType.FINISH, outcome = Outcome.UNSUPPORTED)) + throw RendezvousError("Unsupported protocols", RendezvousFailureReason.UnsupportedHomeserver) + } + + send(Payload(PayloadType.PROGRESS, protocol = Protocol.LOGIN_TOKEN)) + + return checksum + } + + @Throws(RendezvousError::class) + suspend fun waitForLoginOnNewDevice(authenticationService: AuthenticationService): Session { + Timber.tag(TAG).i("Waiting for login_token") + + val loginToken = receive() + + if (loginToken?.type == PayloadType.FINISH) { + when (loginToken.outcome) { + Outcome.DECLINED -> { + throw RendezvousError("Login declined by other device", RendezvousFailureReason.UserDeclined) + } + Outcome.UNSUPPORTED -> { + throw RendezvousError("Homeserver lacks support", RendezvousFailureReason.UnsupportedHomeserver) + } + else -> { + throw RendezvousError("Unknown error", RendezvousFailureReason.Unknown) + } + } + } + + val homeserver = loginToken?.homeserver ?: throw RendezvousError("No homeserver returned", RendezvousFailureReason.ProtocolError) + val token = loginToken.loginToken ?: throw RendezvousError("No login token returned", RendezvousFailureReason.ProtocolError) + + Timber.tag(TAG).i("Got login_token now attempting to sign in with $homeserver") + + val hsConfig = HomeServerConnectionConfig(homeServerUri = Uri.parse(homeserver)) + return authenticationService.loginUsingQrLoginToken(hsConfig, token) + } + + @Throws(RendezvousError::class) + suspend fun completeVerificationOnNewDevice(session: Session) { + val userId = session.myUserId + val crypto = session.cryptoService() + val deviceId = crypto.getMyDevice().deviceId + val deviceKey = crypto.getMyDevice().fingerprint() + send(Payload(PayloadType.PROGRESS, outcome = Outcome.SUCCESS, deviceId = deviceId, deviceKey = deviceKey)) + + // await confirmation of verification + val verificationResponse = receive() + if (verificationResponse?.outcome == Outcome.VERIFIED) { + val verifyingDeviceId = verificationResponse.verifyingDeviceId + ?: throw RendezvousError("No verifying device id returned", RendezvousFailureReason.ProtocolError) + val verifyingDeviceFromServer = crypto.getCryptoDeviceInfo(userId, verifyingDeviceId) + if (verifyingDeviceFromServer?.fingerprint() != verificationResponse.verifyingDeviceKey) { + Timber.tag(TAG).w( + "Verifying device $verifyingDeviceId key doesn't match: ${ + verifyingDeviceFromServer?.fingerprint() + } vs ${verificationResponse.verifyingDeviceKey})" + ) + // inform the other side + send(Payload(PayloadType.FINISH, outcome = Outcome.E2EE_SECURITY_ERROR)) + throw RendezvousError("Key from verifying device doesn't match", RendezvousFailureReason.E2EESecurityIssue) + } + + verificationResponse.masterKey?.let { masterKeyFromVerifyingDevice -> + // verifying device provided us with a master key, so use it to check integrity + + // see what the homeserver told us + val localMasterKey = crypto.crossSigningService().getMyCrossSigningKeys()?.masterKey() + + // n.b. if no local master key this is a problem, as well as it not matching + if (localMasterKey?.unpaddedBase64PublicKey != masterKeyFromVerifyingDevice) { + Timber.tag(TAG).w("Master key from verifying device doesn't match: $masterKeyFromVerifyingDevice vs $localMasterKey") + // inform the other side + send(Payload(PayloadType.FINISH, outcome = Outcome.E2EE_SECURITY_ERROR)) + throw RendezvousError("Master key from verifying device doesn't match", RendezvousFailureReason.E2EESecurityIssue) + } + + // set other device as verified + Timber.tag(TAG).i("Setting device $verifyingDeviceId as verified") + crypto.setDeviceVerification(DeviceTrustLevel(locallyVerified = true, crossSigningVerified = false), userId, verifyingDeviceId) + + Timber.tag(TAG).i("Setting master key as trusted") + crypto.crossSigningService().markMyMasterKeyAsTrusted() + } ?: run { + // set other device as verified anyway + Timber.tag(TAG).i("Setting device $verifyingDeviceId as verified") + crypto.setDeviceVerification(DeviceTrustLevel(locallyVerified = true, crossSigningVerified = false), userId, verifyingDeviceId) + + Timber.tag(TAG).i("No master key given by verifying device") + } + + // request secrets from the verifying device + Timber.tag(TAG).i("Requesting secrets from $verifyingDeviceId") + + session.sharedSecretStorageService().let { + it.requestSecret(MASTER_KEY_SSSS_NAME, verifyingDeviceId) + it.requestSecret(SELF_SIGNING_KEY_SSSS_NAME, verifyingDeviceId) + it.requestSecret(USER_SIGNING_KEY_SSSS_NAME, verifyingDeviceId) + it.requestSecret(KEYBACKUP_SECRET_SSSS_NAME, verifyingDeviceId) + } + } else { + Timber.tag(TAG).i("Not doing verification") + } + } + + @Throws(RendezvousError::class) + private suspend fun receive(): Payload? { + val data = channel.receive() ?: return null + val payload = try { + adapter.fromJson(data.toString(Charsets.UTF_8)) + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to parse payload") + throw RendezvousError("Invalid payload received", RendezvousFailureReason.Unknown) + } + + return payload + } + + private suspend fun send(payload: Payload) { + channel.send(adapter.toJson(payload).toByteArray(Charsets.UTF_8)) + } + + suspend fun close() { + channel.close() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousChannel.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousChannel.kt new file mode 100644 index 0000000000..0956a5b0a0 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousChannel.kt @@ -0,0 +1,51 @@ +/* + * 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.rendezvous + +import org.matrix.android.sdk.api.rendezvous.model.RendezvousError + +/** + * Representation of a rendezvous channel such as that described by MSC3903. + */ +interface RendezvousChannel { + val transport: RendezvousTransport + + /** + * @returns the checksum/confirmation digits to be shown to the user + */ + @Throws(RendezvousError::class) + suspend fun connect(): String + + /** + * Send a payload via the channel. + * @param data payload to send + */ + @Throws(RendezvousError::class) + suspend fun send(data: ByteArray) + + /** + * Receive a payload from the channel. + * @returns the received payload + */ + @Throws(RendezvousError::class) + suspend fun receive(): ByteArray? + + /** + * Closes the channel and cleans up. + */ + suspend fun close() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousFailureReason.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousFailureReason.kt new file mode 100644 index 0000000000..18e625d825 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousFailureReason.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.rendezvous + +enum class RendezvousFailureReason(val canRetry: Boolean = true) { + UserDeclined, + OtherDeviceNotSignedIn, + OtherDeviceAlreadySignedIn, + Unknown, + Expired, + UserCancelled, + InvalidCode, + UnsupportedAlgorithm(false), + UnsupportedTransport(false), + UnsupportedHomeserver(false), + ProtocolError, + E2EESecurityIssue(false) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousTransport.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousTransport.kt new file mode 100644 index 0000000000..81632e951a --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/RendezvousTransport.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.rendezvous + +import okhttp3.MediaType +import org.matrix.android.sdk.api.rendezvous.model.RendezvousError +import org.matrix.android.sdk.api.rendezvous.model.RendezvousTransportDetails + +interface RendezvousTransport { + var ready: Boolean + + @Throws(RendezvousError::class) + suspend fun details(): RendezvousTransportDetails + + @Throws(RendezvousError::class) + suspend fun send(contentType: MediaType, data: ByteArray) + + @Throws(RendezvousError::class) + suspend fun receive(): ByteArray? + + suspend fun close() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/channels/ECDHRendezvousChannel.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/channels/ECDHRendezvousChannel.kt new file mode 100644 index 0000000000..c1d6b1b70e --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/channels/ECDHRendezvousChannel.kt @@ -0,0 +1,183 @@ +/* + * 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.rendezvous.channels + +import android.util.Base64 +import com.squareup.moshi.JsonClass +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.MediaType.Companion.toMediaType +import org.matrix.android.sdk.api.logger.LoggerTag +import org.matrix.android.sdk.api.rendezvous.RendezvousChannel +import org.matrix.android.sdk.api.rendezvous.RendezvousFailureReason +import org.matrix.android.sdk.api.rendezvous.RendezvousTransport +import org.matrix.android.sdk.api.rendezvous.model.RendezvousError +import org.matrix.android.sdk.api.rendezvous.model.SecureRendezvousChannelAlgorithm +import org.matrix.android.sdk.api.util.MatrixJsonParser +import org.matrix.android.sdk.internal.crypto.verification.SASDefaultVerificationTransaction +import org.matrix.olm.OlmSAS +import timber.log.Timber +import java.security.SecureRandom +import java.util.LinkedList +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * Implements X25519 ECDH key agreement and AES-256-GCM encryption channel as per MSC3903: + * https://github.com/matrix-org/matrix-spec-proposals/pull/3903 + */ +class ECDHRendezvousChannel(override var transport: RendezvousTransport, theirPublicKeyBase64: String?) : RendezvousChannel { + companion object { + private const val ALGORITHM_SPEC = "AES/GCM/NoPadding" + private const val KEY_SPEC = "AES" + private val TAG = LoggerTag(ECDHRendezvousChannel::class.java.simpleName, LoggerTag.RENDEZVOUS).value + } + + @JsonClass(generateAdapter = true) + internal data class ECDHPayload( + val algorithm: SecureRendezvousChannelAlgorithm? = null, + val key: String? = null, + val ciphertext: String? = null, + val iv: String? = null + ) + + private val olmSASMutex = Mutex() + private var olmSAS: OlmSAS? + private val ourPublicKey: ByteArray + private val ecdhAdapter = MatrixJsonParser.getMoshi().adapter(ECDHPayload::class.java) + private var theirPublicKey: ByteArray? = null + private var aesKey: ByteArray? = null + + init { + theirPublicKeyBase64?.let { + theirPublicKey = Base64.decode(it, Base64.NO_WRAP) + } + olmSAS = OlmSAS() + ourPublicKey = Base64.decode(olmSAS!!.publicKey, Base64.NO_WRAP) + } + + @Throws(RendezvousError::class) + override suspend fun connect(): String { + val sas = olmSAS ?: throw RendezvousError("Channel closed", RendezvousFailureReason.Unknown) + val isInitiator = theirPublicKey == null + + if (isInitiator) { + Timber.tag(TAG).i("Waiting for other device to send their public key") + val res = this.receiveAsPayload() ?: throw RendezvousError("No reply from other device", RendezvousFailureReason.ProtocolError) + + if (res.key == null) { + throw RendezvousError( + "Unsupported algorithm: ${res.algorithm}", + RendezvousFailureReason.UnsupportedAlgorithm, + ) + } + theirPublicKey = Base64.decode(res.key, Base64.NO_WRAP) + } else { + // send our public key unencrypted + Timber.tag(TAG).i("Sending public key") + send( + ECDHPayload( + algorithm = SecureRendezvousChannelAlgorithm.ECDH_V1, + key = Base64.encodeToString(ourPublicKey, Base64.NO_WRAP) + ) + ) + } + + olmSASMutex.withLock { + sas.setTheirPublicKey(Base64.encodeToString(theirPublicKey, Base64.NO_WRAP)) + sas.setTheirPublicKey(Base64.encodeToString(theirPublicKey, Base64.NO_WRAP)) + + val initiatorKey = Base64.encodeToString(if (isInitiator) ourPublicKey else theirPublicKey, Base64.NO_WRAP) + val recipientKey = Base64.encodeToString(if (isInitiator) theirPublicKey else ourPublicKey, Base64.NO_WRAP) + val aesInfo = "${SecureRendezvousChannelAlgorithm.ECDH_V1.value}|$initiatorKey|$recipientKey" + + aesKey = sas.generateShortCode(aesInfo, 32) + + val rawChecksum = sas.generateShortCode(aesInfo, 5) + return SASDefaultVerificationTransaction.getDecimalCodeRepresentation(rawChecksum, separator = "-") + } + } + + private suspend fun send(payload: ECDHPayload) { + transport.send("application/json".toMediaType(), ecdhAdapter.toJson(payload).toByteArray(Charsets.UTF_8)) + } + + override suspend fun send(data: ByteArray) { + if (aesKey == null) { + throw IllegalStateException("Shared secret not established") + } + send(encrypt(data)) + } + + private suspend fun receiveAsPayload(): ECDHPayload? { + transport.receive()?.toString(Charsets.UTF_8)?.let { + return ecdhAdapter.fromJson(it) + } ?: return null + } + + override suspend fun receive(): ByteArray? { + if (aesKey == null) { + throw IllegalStateException("Shared secret not established") + } + val payload = receiveAsPayload() ?: return null + return decrypt(payload) + } + + override suspend fun close() { + val sas = olmSAS ?: throw IllegalStateException("Channel already closed") + olmSASMutex.withLock { + // this does a double release check already so we don't re-check ourselves + sas.releaseSas() + olmSAS = null + } + transport.close() + } + + private fun encrypt(plainText: ByteArray): ECDHPayload { + val iv = ByteArray(16) + SecureRandom().nextBytes(iv) + + val cipherText = LinkedList() + + val encryptCipher = Cipher.getInstance(ALGORITHM_SPEC) + val secretKeySpec = SecretKeySpec(aesKey, KEY_SPEC) + val ivParameterSpec = IvParameterSpec(iv) + encryptCipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec) + cipherText.addAll(encryptCipher.update(plainText).toList()) + cipherText.addAll(encryptCipher.doFinal().toList()) + + return ECDHPayload( + ciphertext = Base64.encodeToString(cipherText.toByteArray(), Base64.NO_WRAP), + iv = Base64.encodeToString(iv, Base64.NO_WRAP) + ) + } + + private fun decrypt(payload: ECDHPayload): ByteArray { + val iv = Base64.decode(payload.iv, Base64.NO_WRAP) + val encryptCipher = Cipher.getInstance(ALGORITHM_SPEC) + val secretKeySpec = SecretKeySpec(aesKey, KEY_SPEC) + val ivParameterSpec = IvParameterSpec(iv) + encryptCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec) + + val plainText = LinkedList() + plainText.addAll(encryptCipher.update(Base64.decode(payload.ciphertext, Base64.NO_WRAP)).toList()) + plainText.addAll(encryptCipher.doFinal().toList()) + + return plainText.toByteArray() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/ECDHRendezvous.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/ECDHRendezvous.kt new file mode 100644 index 0000000000..55bac6397e --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/ECDHRendezvous.kt @@ -0,0 +1,26 @@ +/* + * 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.rendezvous.model + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ECDHRendezvous( + val transport: SimpleHttpRendezvousTransportDetails, + val algorithm: SecureRendezvousChannelAlgorithm, + val key: String +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/ECDHRendezvousCode.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/ECDHRendezvousCode.kt new file mode 100644 index 0000000000..575b5d4bfd --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/ECDHRendezvousCode.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.rendezvous.model + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ECDHRendezvousCode( + val intent: RendezvousIntent, + val rendezvous: ECDHRendezvous +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Outcome.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Outcome.kt new file mode 100644 index 0000000000..0ebd1f88b3 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Outcome.kt @@ -0,0 +1,38 @@ +/* + * 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.rendezvous.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = false) +enum class Outcome(val value: String) { + @Json(name = "success") + SUCCESS("success"), + + @Json(name = "declined") + DECLINED("declined"), + + @Json(name = "unsupported") + UNSUPPORTED("unsupported"), + + @Json(name = "verified") + VERIFIED("verified"), + + @Json(name = "e2ee_security_error") + E2EE_SECURITY_ERROR("e2ee_security_error") +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Payload.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Payload.kt new file mode 100644 index 0000000000..04631ce959 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Payload.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.rendezvous.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class Payload( + val type: PayloadType, + val intent: RendezvousIntent? = null, + val outcome: Outcome? = null, + val protocols: List? = null, + val protocol: Protocol? = null, + val homeserver: String? = null, + @Json(name = "login_token") val loginToken: String? = null, + @Json(name = "device_id") val deviceId: String? = null, + @Json(name = "device_key") val deviceKey: String? = null, + @Json(name = "verifying_device_id") val verifyingDeviceId: String? = null, + @Json(name = "verifying_device_key") val verifyingDeviceKey: String? = null, + @Json(name = "master_key") val masterKey: String? = null +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/PayloadType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/PayloadType.kt new file mode 100644 index 0000000000..33beb1f525 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/PayloadType.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.rendezvous.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = false) +internal enum class PayloadType(val value: String) { + @Json(name = "m.login.start") + START("m.login.start"), + + @Json(name = "m.login.finish") + FINISH("m.login.finish"), + + @Json(name = "m.login.progress") + PROGRESS("m.login.progress") +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Protocol.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Protocol.kt new file mode 100644 index 0000000000..6fce2fa11c --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Protocol.kt @@ -0,0 +1,26 @@ +/* + * 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.rendezvous.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = false) +enum class Protocol(val value: String) { + @Json(name = "org.matrix.msc3906.login_token") + LOGIN_TOKEN("org.matrix.msc3906.login_token") +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousError.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousError.kt new file mode 100644 index 0000000000..c52b11a322 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousError.kt @@ -0,0 +1,21 @@ +/* + * 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.rendezvous.model + +import org.matrix.android.sdk.api.rendezvous.RendezvousFailureReason + +class RendezvousError(val description: String, val reason: RendezvousFailureReason) : Exception(description) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousIntent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousIntent.kt new file mode 100644 index 0000000000..65037e1252 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousIntent.kt @@ -0,0 +1,26 @@ +/* + * 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.rendezvous.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = false) +enum class RendezvousIntent { + @Json(name = "login.start") LOGIN_ON_NEW_DEVICE, + @Json(name = "login.reciprocate") RECIPROCATE_LOGIN_ON_EXISTING_DEVICE +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportDetails.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportDetails.kt new file mode 100644 index 0000000000..1bde43ab7e --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportDetails.kt @@ -0,0 +1,24 @@ +/* + * 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.rendezvous.model + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +open class RendezvousTransportDetails( + val type: RendezvousTransportType +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportType.kt new file mode 100644 index 0000000000..6fca7efa71 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportType.kt @@ -0,0 +1,26 @@ +/* + * 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.rendezvous.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = false) +enum class RendezvousTransportType(val value: String) { + @Json(name = "org.matrix.msc3886.http.v1") + MSC3886_SIMPLE_HTTP_V1("org.matrix.msc3886.http.v1") +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SecureRendezvousChannelAlgorithm.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SecureRendezvousChannelAlgorithm.kt new file mode 100644 index 0000000000..75f0024fda --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SecureRendezvousChannelAlgorithm.kt @@ -0,0 +1,26 @@ +/* + * 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.rendezvous.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = false) +enum class SecureRendezvousChannelAlgorithm(val value: String) { + @Json(name = "org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256") + ECDH_V1("org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256") +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SimpleHttpRendezvousTransportDetails.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SimpleHttpRendezvousTransportDetails.kt new file mode 100644 index 0000000000..049aa8b756 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SimpleHttpRendezvousTransportDetails.kt @@ -0,0 +1,24 @@ +/* + * 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.rendezvous.model + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class SimpleHttpRendezvousTransportDetails( + val uri: String +) : RendezvousTransportDetails(type = RendezvousTransportType.MSC3886_SIMPLE_HTTP_V1) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/transports/SimpleHttpRendezvousTransport.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/transports/SimpleHttpRendezvousTransport.kt new file mode 100644 index 0000000000..620b599e3d --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/transports/SimpleHttpRendezvousTransport.kt @@ -0,0 +1,173 @@ +/* + * 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.rendezvous.transports + +import kotlinx.coroutines.delay +import okhttp3.MediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.matrix.android.sdk.api.logger.LoggerTag +import org.matrix.android.sdk.api.rendezvous.RendezvousFailureReason +import org.matrix.android.sdk.api.rendezvous.RendezvousTransport +import org.matrix.android.sdk.api.rendezvous.model.RendezvousError +import org.matrix.android.sdk.api.rendezvous.model.RendezvousTransportDetails +import org.matrix.android.sdk.api.rendezvous.model.SimpleHttpRendezvousTransportDetails +import timber.log.Timber +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Implementation of the Simple HTTP transport MSC3886: https://github.com/matrix-org/matrix-spec-proposals/pull/3886 + */ +class SimpleHttpRendezvousTransport(rendezvousUri: String?) : RendezvousTransport { + companion object { + private val TAG = LoggerTag(SimpleHttpRendezvousTransport::class.java.simpleName, LoggerTag.RENDEZVOUS).value + } + + override var ready = false + private var cancelled = false + private var uri: String? + private var etag: String? = null + private var expiresAt: Date? = null + + init { + uri = rendezvousUri + } + + override suspend fun details(): RendezvousTransportDetails { + val uri = uri ?: throw IllegalStateException("Rendezvous not set up") + + return SimpleHttpRendezvousTransportDetails(uri) + } + + @Throws(RendezvousError::class) + override suspend fun send(contentType: MediaType, data: ByteArray) { + if (cancelled) { + throw IllegalStateException("Rendezvous cancelled") + } + + val method = if (uri != null) "PUT" else "POST" + val uri = this.uri ?: throw RuntimeException("No rendezvous URI") + + val httpClient = okhttp3.OkHttpClient.Builder().build() + + val request = Request.Builder() + .url(uri) + .method(method, data.toRequestBody()) + .header("content-type", contentType.toString()) + + etag?.let { + request.header("if-match", it) + } + + val response = httpClient.newCall(request.build()).execute() + + if (response.code == 404) { + throw get404Error() + } + etag = response.header("etag") + + Timber.tag(TAG).i("Sent data to $uri new etag $etag") + + if (method == "POST") { + val location = response.header("location") ?: throw RuntimeException("No rendezvous URI found in response") + + response.header("expires")?.let { + val format = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US) + expiresAt = format.parse(it) + } + + // resolve location header which could be relative or absolute + this.uri = response.request.url.toUri().resolve(location).toString() + ready = true + } + } + + @Throws(RendezvousError::class) + override suspend fun receive(): ByteArray? { + if (cancelled) { + throw IllegalStateException("Rendezvous cancelled") + } + val uri = uri ?: throw IllegalStateException("Rendezvous not set up") + val httpClient = okhttp3.OkHttpClient.Builder().build() + while (true) { + Timber.tag(TAG).i("Polling: $uri after etag $etag") + val request = Request.Builder() + .url(uri) + .get() + + etag?.let { + request.header("if-none-match", it) + } + + val response = httpClient.newCall(request.build()).execute() + + try { + // expired + if (response.code == 404) { + throw get404Error() + } + + // rely on server expiring the channel rather than checking ourselves + + if (response.header("content-type") != "application/json") { + response.header("etag")?.let { + etag = it + } + } else if (response.code == 200) { + response.header("etag")?.let { + etag = it + } + return response.body?.bytes() + } + + // sleep for a second before polling again + // we rely on the server expiring the channel rather than checking it ourselves + delay(1000) + } finally { + response.close() + } + } + } + + private fun get404Error(): RendezvousError { + if (expiresAt != null && Date() > expiresAt) { + return RendezvousError("Expired", RendezvousFailureReason.Expired) + } + + return RendezvousError("Received unexpected 404", RendezvousFailureReason.Unknown) + } + + override suspend fun close() { + cancelled = true + ready = false + + uri?.let { + try { + val httpClient = okhttp3.OkHttpClient.Builder().build() + val request = Request.Builder() + .url(it) + .delete() + .build() + httpClient.newCall(request).execute() + } catch (e: Throwable) { + Timber.tag(TAG).w(e, "Failed to delete channel") + } + } + } +} 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 f5d2c0d9a0..1f16041b54 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 @@ -31,6 +31,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent import org.matrix.android.sdk.api.session.room.model.message.MessageType +import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent import org.matrix.android.sdk.api.session.room.model.relation.shouldRenderInThread import org.matrix.android.sdk.api.session.room.send.SendState @@ -357,6 +358,10 @@ fun Event.isAudioMessage(): Boolean { } } +fun Event.isVoiceMessage(): Boolean { + return this.asMessageAudioEvent()?.content?.voiceMessageIndicator != null +} + fun Event.isFileMessage(): Boolean { return when (getMsgType()) { MessageType.MSGTYPE_FILE -> true @@ -396,7 +401,7 @@ fun Event.getRelationContent(): RelationDefaultContent? { when (getClearType()) { EventType.STICKER -> getClearContent().toModel()?.relatesTo in EventType.BEACON_LOCATION_DATA -> getClearContent().toModel()?.relatesTo - else -> null + else -> getClearContent()?.get("m.relates_to")?.toContent().toModel() } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageAudioEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageAudioEvent.kt new file mode 100644 index 0000000000..38ced8f385 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageAudioEvent.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.room.model.message + +import org.matrix.android.sdk.api.extensions.tryOrNull +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.toModel + +/** + * [Event] wrapper for [EventType.MESSAGE] event type. + * Provides additional fields and functions related to this event type. + */ +@JvmInline +value class MessageAudioEvent(val root: Event) { + + /** + * The mapped [MessageAudioContent] model of the event content. + */ + val content: MessageAudioContent + get() = root.getClearContent().toModel() as MessageAudioContent + + init { + require(tryOrNull { content } != null) + } +} + +/** + * Map a [EventType.MESSAGE] event to a [MessageAudioEvent]. + */ +fun Event.asMessageAudioEvent() = if (getClearType() == EventType.MESSAGE) { + tryOrNull { MessageAudioEvent(this) } +} else null diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt index 53b49129c4..6a6fadc95a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt @@ -45,18 +45,30 @@ interface SendService { * @param text the text message to send * @param msgType the message type: MessageType.MSGTYPE_TEXT (default) or MessageType.MSGTYPE_EMOTE * @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present + * @param additionalContent additional content to put in the event content * @return a [Cancelable] */ - fun sendTextMessage(text: CharSequence, msgType: String = MessageType.MSGTYPE_TEXT, autoMarkdown: Boolean = false): Cancelable + fun sendTextMessage( + text: CharSequence, + msgType: String = MessageType.MSGTYPE_TEXT, + autoMarkdown: Boolean = false, + additionalContent: Content? = null, + ): Cancelable /** * Method to send a text message with a formatted body. * @param text the text message to send * @param formattedText The formatted body using MessageType#FORMAT_MATRIX_HTML * @param msgType the message type: MessageType.MSGTYPE_TEXT (default) or MessageType.MSGTYPE_EMOTE + * @param additionalContent additional content to put in the event content * @return a [Cancelable] */ - fun sendFormattedTextMessage(text: String, formattedText: String, msgType: String = MessageType.MSGTYPE_TEXT): Cancelable + fun sendFormattedTextMessage( + text: String, + formattedText: String, + msgType: String = MessageType.MSGTYPE_TEXT, + additionalContent: Content? = null, + ): Cancelable /** * Method to quote an events content. @@ -65,6 +77,7 @@ interface SendService { * @param formattedText the formatted text message to send * @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present * @param rootThreadEventId when this param is not null, the message will be sent in this specific thread + * @param additionalContent additional content to put in the event content * @return a [Cancelable] */ fun sendQuotedTextMessage( @@ -73,6 +86,7 @@ interface SendService { formattedText: String? = null, autoMarkdown: Boolean, rootThreadEventId: String? = null, + additionalContent: Content? = null, ): Cancelable /** @@ -83,6 +97,7 @@ interface SendService { * It can be useful to send media to multiple room. It's safe to include the current roomId in this set * @param rootThreadEventId when this param is not null, the Media will be sent in this specific thread * @param relatesTo add a relation content to the media event + * @param additionalContent additional content to put in the event content * @return a [Cancelable] */ fun sendMedia( @@ -91,6 +106,7 @@ interface SendService { roomIds: Set, rootThreadEventId: String? = null, relatesTo: RelationDefaultContent? = null, + additionalContent: Content? = null, ): Cancelable /** @@ -100,6 +116,7 @@ interface SendService { * @param roomIds set of roomIds to where the media will be sent. The current roomId will be add to this set if not present. * It can be useful to send media to multiple room. It's safe to include the current roomId in this set * @param rootThreadEventId when this param is not null, all the Media will be sent in this specific thread + * @param additionalContent additional content to put in the event content * @return a [Cancelable] */ fun sendMedias( @@ -107,6 +124,7 @@ interface SendService { compressBeforeSending: Boolean, roomIds: Set, rootThreadEventId: String? = null, + additionalContent: Content? = null, ): Cancelable /** @@ -114,31 +132,35 @@ interface SendService { * @param pollType indicates open or closed polls * @param question the question * @param options list of options + * @param additionalContent additional content to put in the event content * @return a [Cancelable] */ - fun sendPoll(pollType: PollType, question: String, options: List): Cancelable + fun sendPoll(pollType: PollType, question: String, options: List, additionalContent: Content? = null): Cancelable /** * Method to send a poll response. * @param pollEventId the poll currently replied to * @param answerId The id of the answer + * @param additionalContent additional content to put in the event content * @return a [Cancelable] */ - fun voteToPoll(pollEventId: String, answerId: String): Cancelable + fun voteToPoll(pollEventId: String, answerId: String, additionalContent: Content? = null): Cancelable /** * End a poll in the room. * @param pollEventId event id of the poll + * @param additionalContent additional content to put in the event content * @return a [Cancelable] */ - fun endPoll(pollEventId: String): Cancelable + fun endPoll(pollEventId: String, additionalContent: Content? = null): Cancelable /** * Redact (delete) the given event. * @param event The event to redact * @param reason Optional reason string + * @param additionalContent additional content to put in the event content */ - fun redactEvent(event: Event, reason: String?): Cancelable + fun redactEvent(event: Event, reason: String?, additionalContent: Content? = null): Cancelable /** * Schedule this message to be resent. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt index 1824d5dc6c..9ac33c0545 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt @@ -106,6 +106,8 @@ interface Timeline { /** * Called when new events come through the sync. + * Note that the corresponding events may not be available yet in the database. + * [onTimelineUpdated] will be called with the event content. */ fun onNewTimelineEvents(eventIds: List) = Unit diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt index 46433f387d..aa9afd5c8c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt @@ -55,4 +55,9 @@ interface TimelineService { * Returns a snapshot list of TimelineEvent with EventType.MESSAGE and MessageType.MSGTYPE_IMAGE or MessageType.MSGTYPE_VIDEO. */ fun getAttachmentMessages(): List + + /** + * Returns a snapshot list of TimelineEvent with a content relation of the given type to the given eventId. + */ + fun getTimelineEventsRelatedTo(relationType: String, eventId: String): List } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/SASDefaultVerificationTransaction.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/SASDefaultVerificationTransaction.kt index 1cbaff059a..29b416bb82 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/SASDefaultVerificationTransaction.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/SASDefaultVerificationTransaction.kt @@ -82,6 +82,33 @@ internal abstract class SASDefaultVerificationTransaction( // older devices have limited support of emoji but SDK offers images for the 64 verification emojis // so always send that we support EMOJI val KNOWN_SHORT_CODES = listOf(SasMode.EMOJI, SasMode.DECIMAL) + + /** + * decimal: generate five bytes by using HKDF. + * Take the first 13 bits and convert it to a decimal number (which will be a number between 0 and 8191 inclusive), + * and add 1000 (resulting in a number between 1000 and 9191 inclusive). + * Do the same with the second 13 bits, and the third 13 bits, giving three 4-digit numbers. + * In other words, if the five bytes are B0, B1, B2, B3, and B4, then the first number is (B0 << 5 | B1 >> 3) + 1000, + * the second number is ((B1 & 0x7) << 10 | B2 << 2 | B3 >> 6) + 1000, and the third number is ((B3 & 0x3f) << 7 | B4 >> 1) + 1000. + * (This method of converting 13 bits at a time is used to avoid requiring 32-bit clients to do big-number arithmetic, + * and adding 1000 to the number avoids having clients to worry about properly zero-padding the number when displaying to the user.) + * The three 4-digit numbers are displayed to the user either with dashes (or another appropriate separator) separating the three numbers, + * or with the three numbers on separate lines. + */ + fun getDecimalCodeRepresentation(byteArray: ByteArray, separator: String = " "): String { + val b0 = byteArray[0].toUnsignedInt() // need unsigned byte + val b1 = byteArray[1].toUnsignedInt() // need unsigned byte + val b2 = byteArray[2].toUnsignedInt() // need unsigned byte + val b3 = byteArray[3].toUnsignedInt() // need unsigned byte + val b4 = byteArray[4].toUnsignedInt() // need unsigned byte + // (B0 << 5 | B1 >> 3) + 1000 + val first = (b0.shl(5) or b1.shr(3)) + 1000 + // ((B1 & 0x7) << 10 | B2 << 2 | B3 >> 6) + 1000 + val second = ((b1 and 0x7).shl(10) or b2.shl(2) or b3.shr(6)) + 1000 + // ((B3 & 0x3f) << 7 | B4 >> 1) + 1000 + val third = ((b3 and 0x3f).shl(7) or b4.shr(1)) + 1000 + return "$first$separator$second$separator$third" + } } override var state: VerificationTxState = VerificationTxState.None @@ -371,33 +398,6 @@ internal abstract class SASDefaultVerificationTransaction( return getDecimalCodeRepresentation(shortCodeBytes!!) } - /** - * decimal: generate five bytes by using HKDF. - * Take the first 13 bits and convert it to a decimal number (which will be a number between 0 and 8191 inclusive), - * and add 1000 (resulting in a number between 1000 and 9191 inclusive). - * Do the same with the second 13 bits, and the third 13 bits, giving three 4-digit numbers. - * In other words, if the five bytes are B0, B1, B2, B3, and B4, then the first number is (B0 << 5 | B1 >> 3) + 1000, - * the second number is ((B1 & 0x7) << 10 | B2 << 2 | B3 >> 6) + 1000, and the third number is ((B3 & 0x3f) << 7 | B4 >> 1) + 1000. - * (This method of converting 13 bits at a time is used to avoid requiring 32-bit clients to do big-number arithmetic, - * and adding 1000 to the number avoids having clients to worry about properly zero-padding the number when displaying to the user.) - * The three 4-digit numbers are displayed to the user either with dashes (or another appropriate separator) separating the three numbers, - * or with the three numbers on separate lines. - */ - fun getDecimalCodeRepresentation(byteArray: ByteArray): String { - val b0 = byteArray[0].toUnsignedInt() // need unsigned byte - val b1 = byteArray[1].toUnsignedInt() // need unsigned byte - val b2 = byteArray[2].toUnsignedInt() // need unsigned byte - val b3 = byteArray[3].toUnsignedInt() // need unsigned byte - val b4 = byteArray[4].toUnsignedInt() // need unsigned byte - // (B0 << 5 | B1 >> 3) + 1000 - val first = (b0.shl(5) or b1.shr(3)) + 1000 - // ((B1 & 0x7) << 10 | B2 << 2 | B3 >> 6) + 1000 - val second = ((b1 and 0x7).shl(10) or b2.shl(2) or b3.shr(6)) + 1000 - // ((B3 & 0x3f) << 7 | B4 >> 1) + 1000 - val third = ((b3 and 0x3f).shl(7) or b4.shr(1)) + 1000 - return "$first $second $third" - } - override fun getEmojiCodeRepresentation(): List { return getEmojiCodeRepresentation(shortCodeBytes!!) } 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 1e62b5d7f5..db1cd1b33b 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 @@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.listeners.ProgressListener import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.crypto.model.EncryptedFileInfo +import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent @@ -407,7 +408,10 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter newAttachmentAttributes: NewAttachmentAttributes ) { localEchoRepository.updateEcho(eventId) { _, event -> - val messageContent: MessageContent? = event.asDomain().content.toModel() + val content: Content? = event.asDomain().content + val messageContent: MessageContent? = content.toModel() + // Retrieve potential additional content from the original event + val additionalContent = content.orEmpty() - messageContent?.toContent().orEmpty().keys val updatedContent = when (messageContent) { is MessageImageContent -> messageContent.update(url, encryptedFileInfo, newAttachmentAttributes) is MessageVideoContent -> messageContent.update(url, encryptedFileInfo, thumbnailUrl, thumbnailEncryptedFileInfo, newAttachmentAttributes) @@ -415,7 +419,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter is MessageAudioContent -> messageContent.update(url, encryptedFileInfo, newAttachmentAttributes.newFileSize) else -> messageContent } - event.content = ContentMapper.map(updatedContent.toContent()) + event.content = ContentMapper.map(updatedContent.toContent().plus(additionalContent)) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/profile/GetProfileInfoTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/profile/GetProfileInfoTask.kt index 40444edcab..22bb3d37b0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/profile/GetProfileInfoTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/profile/GetProfileInfoTask.kt @@ -17,26 +17,40 @@ package org.matrix.android.sdk.internal.session.profile +import com.zhuinden.monarchy.Monarchy +import org.matrix.android.sdk.api.session.user.model.User import org.matrix.android.sdk.api.util.JsonDict +import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.executeRequest +import org.matrix.android.sdk.internal.session.user.UserEntityFactory import org.matrix.android.sdk.internal.task.Task +import org.matrix.android.sdk.internal.util.awaitTransaction import javax.inject.Inject internal abstract class GetProfileInfoTask : Task { data class Params( - val userId: String + val userId: String, + val storeInDatabase: Boolean = true, ) } internal class DefaultGetProfileInfoTask @Inject constructor( private val profileAPI: ProfileAPI, - private val globalErrorReceiver: GlobalErrorReceiver + private val globalErrorReceiver: GlobalErrorReceiver, + @SessionDatabase private val monarchy: Monarchy, ) : GetProfileInfoTask() { override suspend fun execute(params: Params): JsonDict { return executeRequest(globalErrorReceiver) { profileAPI.getProfile(params.userId) + }.also { user -> + if (params.storeInDatabase) { + // Insert into DB + monarchy.awaitTransaction { + it.insertOrUpdate(UserEntityFactory.create(User.fromJson(params.userId, user))) + } + } } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt index aa305e6067..9cdbc7ff46 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt @@ -27,6 +27,7 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.launch import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl import org.matrix.android.sdk.api.session.content.ContentAttachmentData +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.isAttachmentMessage import org.matrix.android.sdk.api.session.events.model.isTextMessage @@ -88,14 +89,14 @@ internal class DefaultSendService @AssistedInject constructor( .let { sendEvent(it) } } - override fun sendTextMessage(text: CharSequence, msgType: String, autoMarkdown: Boolean): Cancelable { - return localEchoEventFactory.createTextEvent(roomId, msgType, text, autoMarkdown) + override fun sendTextMessage(text: CharSequence, msgType: String, autoMarkdown: Boolean, additionalContent: Content?): Cancelable { + return localEchoEventFactory.createTextEvent(roomId, msgType, text, autoMarkdown, additionalContent) .also { createLocalEcho(it) } .let { sendEvent(it) } } - override fun sendFormattedTextMessage(text: String, formattedText: String, msgType: String): Cancelable { - return localEchoEventFactory.createFormattedTextEvent(roomId, TextContent(text, formattedText), msgType) + override fun sendFormattedTextMessage(text: String, formattedText: String, msgType: String, additionalContent: Content?): Cancelable { + return localEchoEventFactory.createFormattedTextEvent(roomId, TextContent(text, formattedText), msgType, additionalContent) .also { createLocalEcho(it) } .let { sendEvent(it) } } @@ -105,7 +106,8 @@ internal class DefaultSendService @AssistedInject constructor( text: String, formattedText: String?, autoMarkdown: Boolean, - rootThreadEventId: String? + rootThreadEventId: String?, + additionalContent: Content?, ): Cancelable { return localEchoEventFactory.createQuotedTextEvent( roomId = roomId, @@ -113,33 +115,34 @@ internal class DefaultSendService @AssistedInject constructor( text = text, formattedText = formattedText, autoMarkdown = autoMarkdown, - rootThreadEventId = rootThreadEventId + rootThreadEventId = rootThreadEventId, + additionalContent = additionalContent, ) .also { createLocalEcho(it) } .let { sendEvent(it) } } - override fun sendPoll(pollType: PollType, question: String, options: List): Cancelable { - return localEchoEventFactory.createPollEvent(roomId, pollType, question, options) + override fun sendPoll(pollType: PollType, question: String, options: List, additionalContent: Content?): Cancelable { + return localEchoEventFactory.createPollEvent(roomId, pollType, question, options, additionalContent) .also { createLocalEcho(it) } .let { sendEvent(it) } } - override fun voteToPoll(pollEventId: String, answerId: String): Cancelable { - return localEchoEventFactory.createPollReplyEvent(roomId, pollEventId, answerId) + override fun voteToPoll(pollEventId: String, answerId: String, additionalContent: Content?): Cancelable { + return localEchoEventFactory.createPollReplyEvent(roomId, pollEventId, answerId, additionalContent) .also { createLocalEcho(it) } .let { sendEvent(it) } } - override fun endPoll(pollEventId: String): Cancelable { - return localEchoEventFactory.createEndPollEvent(roomId, pollEventId) + override fun endPoll(pollEventId: String, additionalContent: Content?): Cancelable { + return localEchoEventFactory.createEndPollEvent(roomId, pollEventId, additionalContent) .also { createLocalEcho(it) } .let { sendEvent(it) } } - override fun redactEvent(event: Event, reason: String?): Cancelable { + override fun redactEvent(event: Event, reason: String?, additionalContent: Content?): Cancelable { // TODO manage media/attachements? - val redactionEcho = localEchoEventFactory.createRedactEvent(roomId, event.eventId!!, reason) + val redactionEcho = localEchoEventFactory.createRedactEvent(roomId, event.eventId!!, reason, additionalContent) .also { createLocalEcho(it) } return eventSenderProcessor.postRedaction(redactionEcho, reason) } @@ -265,7 +268,8 @@ internal class DefaultSendService @AssistedInject constructor( attachments: List, compressBeforeSending: Boolean, roomIds: Set, - rootThreadEventId: String? + rootThreadEventId: String?, + additionalContent: Content?, ): Cancelable { return attachments.mapTo(CancelableBag()) { sendMedia( @@ -283,6 +287,7 @@ internal class DefaultSendService @AssistedInject constructor( roomIds: Set, rootThreadEventId: String?, relatesTo: RelationDefaultContent?, + additionalContent: Content?, ): Cancelable { // Ensure that the event will not be send in a thread if we are a different flow. // Like sending files to multiple rooms @@ -299,6 +304,7 @@ internal class DefaultSendService @AssistedInject constructor( attachment = attachment, rootThreadEventId = rootThreadId, relatesTo, + additionalContent, ).also { event -> createLocalEcho(event) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt index 1d7f624eba..7d8605c2bd 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 @@ -95,12 +95,12 @@ internal class LocalEchoEventFactory @Inject constructor( private val permalinkFactory: PermalinkFactory, private val clock: Clock, ) { - fun createTextEvent(roomId: String, msgType: String, text: CharSequence, autoMarkdown: Boolean): Event { + fun createTextEvent(roomId: String, msgType: String, text: CharSequence, autoMarkdown: Boolean, additionalContent: Content? = null): Event { if (msgType == MessageType.MSGTYPE_TEXT || msgType == MessageType.MSGTYPE_EMOTE) { - return createFormattedTextEvent(roomId, createTextContent(text, autoMarkdown), msgType) + return createFormattedTextEvent(roomId, createTextContent(text, autoMarkdown), msgType, additionalContent) } val content = MessageTextContent(msgType = msgType, body = text.toString()) - return createMessageEvent(roomId, content) + return createMessageEvent(roomId, content, additionalContent) } private fun createTextContent(text: CharSequence, autoMarkdown: Boolean): TextContent { @@ -116,8 +116,8 @@ internal class LocalEchoEventFactory @Inject constructor( return TextContent(text.toString()) } - fun createFormattedTextEvent(roomId: String, textContent: TextContent, msgType: String): Event { - return createMessageEvent(roomId, textContent.toMessageTextContent(msgType)) + fun createFormattedTextEvent(roomId: String, textContent: TextContent, msgType: String, additionalContent: Content? = null): Event { + return createMessageEvent(roomId, textContent.toMessageTextContent(msgType), additionalContent) } fun createReplaceTextEvent( @@ -128,6 +128,7 @@ internal class LocalEchoEventFactory @Inject constructor( newBodyAutoMarkdown: Boolean, msgType: String, compatibilityText: String, + additionalContent: Content? = null, ): Event { val content = if (newBodyFormattedText != null) { TextContent(newBodyText.toString(), newBodyFormattedText.toString()).toMessageTextContent(msgType) @@ -141,7 +142,8 @@ internal class LocalEchoEventFactory @Inject constructor( body = compatibilityText, relatesTo = RelationDefaultContent(RelationType.REPLACE, targetEventId), newContent = content, - ) + ), + additionalContent, ) } @@ -167,6 +169,7 @@ internal class LocalEchoEventFactory @Inject constructor( targetEventId: String, question: String, options: List, + additionalContent: Content? = null, ): Event { val newContent = MessagePollContent( relatesTo = RelationDefaultContent(RelationType.REPLACE, targetEventId), @@ -179,7 +182,7 @@ internal class LocalEchoEventFactory @Inject constructor( senderId = userId, eventId = localId, type = EventType.POLL_START.first(), - content = newContent.toContent() + content = newContent.toContent().plus(additionalContent.orEmpty()) ) } @@ -187,6 +190,7 @@ internal class LocalEchoEventFactory @Inject constructor( roomId: String, pollEventId: String, answerId: String, + additionalContent: Content? = null, ): Event { val content = MessagePollResponseContent( body = answerId, @@ -203,7 +207,7 @@ internal class LocalEchoEventFactory @Inject constructor( senderId = userId, eventId = localId, type = EventType.POLL_RESPONSE.first(), - content = content.toContent(), + content = content.toContent().plus(additionalContent.orEmpty()), unsignedData = UnsignedData(age = null, transactionId = localId) ) } @@ -213,6 +217,7 @@ internal class LocalEchoEventFactory @Inject constructor( pollType: PollType, question: String, options: List, + additionalContent: Content? = null, ): Event { val content = createPollContent(question, options, pollType) val localId = LocalEcho.createLocalEchoId() @@ -222,7 +227,7 @@ internal class LocalEchoEventFactory @Inject constructor( senderId = userId, eventId = localId, type = EventType.POLL_START.first(), - content = content.toContent(), + content = content.toContent().plus(additionalContent.orEmpty()), unsignedData = UnsignedData(age = null, transactionId = localId) ) } @@ -230,6 +235,7 @@ internal class LocalEchoEventFactory @Inject constructor( fun createEndPollEvent( roomId: String, eventId: String, + additionalContent: Content? = null, ): Event { val content = MessageEndPollContent( relatesTo = RelationDefaultContent( @@ -244,7 +250,7 @@ internal class LocalEchoEventFactory @Inject constructor( senderId = userId, eventId = localId, type = EventType.POLL_END.first(), - content = content.toContent(), + content = content.toContent().plus(additionalContent.orEmpty()), unsignedData = UnsignedData(age = null, transactionId = localId) ) } @@ -255,6 +261,7 @@ internal class LocalEchoEventFactory @Inject constructor( longitude: Double, uncertainty: Double?, isUserLocation: Boolean, + additionalContent: Content? = null, ): Event { val geoUri = buildGeoUri(latitude, longitude, uncertainty) val assetType = if (isUserLocation) LocationAssetType.SELF else LocationAssetType.PIN @@ -266,7 +273,7 @@ internal class LocalEchoEventFactory @Inject constructor( unstableTimestampMillis = clock.epochMillis(), unstableText = geoUri ) - return createMessageEvent(roomId, content) + return createMessageEvent(roomId, content, additionalContent) } fun createLiveLocationEvent( @@ -275,6 +282,7 @@ internal class LocalEchoEventFactory @Inject constructor( latitude: Double, longitude: Double, uncertainty: Double?, + additionalContent: Content? = null, ): Event { val geoUri = buildGeoUri(latitude, longitude, uncertainty) val content = MessageBeaconLocationDataContent( @@ -293,7 +301,7 @@ internal class LocalEchoEventFactory @Inject constructor( senderId = userId, eventId = localId, type = EventType.BEACON_LOCATION_DATA.first(), - content = content.toContent(), + content = content.toContent().plus(additionalContent.orEmpty()), unsignedData = UnsignedData(age = null, transactionId = localId) ) } @@ -306,6 +314,7 @@ internal class LocalEchoEventFactory @Inject constructor( autoMarkdown: Boolean, msgType: String, compatibilityText: String, + additionalContent: Content? = null, ): Event { val permalink = permalinkFactory.createPermalink(roomId, originalEvent.root.eventId ?: "", false) val userLink = originalEvent.root.senderId?.let { permalinkFactory.createPermalink(it, false) } ?: "" @@ -340,7 +349,8 @@ internal class LocalEchoEventFactory @Inject constructor( formattedBody = replyFormatted ) .toContent() - ) + ), + additionalContent, ) } @@ -349,23 +359,32 @@ internal class LocalEchoEventFactory @Inject constructor( attachment: ContentAttachmentData, rootThreadEventId: String?, relatesTo: RelationDefaultContent?, + additionalContent: Content? = null, ): Event { return when (attachment.type) { - ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment, rootThreadEventId, relatesTo) - ContentAttachmentData.Type.VIDEO -> createVideoEvent(roomId, attachment, rootThreadEventId, relatesTo) - ContentAttachmentData.Type.AUDIO -> createAudioEvent(roomId, attachment, isVoiceMessage = false, rootThreadEventId = rootThreadEventId, relatesTo) + ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment, rootThreadEventId, relatesTo, additionalContent) + ContentAttachmentData.Type.VIDEO -> createVideoEvent(roomId, attachment, rootThreadEventId, relatesTo, additionalContent) + ContentAttachmentData.Type.AUDIO -> createAudioEvent( + roomId, + attachment, + isVoiceMessage = false, + rootThreadEventId = rootThreadEventId, + relatesTo, + additionalContent + ) ContentAttachmentData.Type.VOICE_MESSAGE -> createAudioEvent( roomId, attachment, isVoiceMessage = true, rootThreadEventId = rootThreadEventId, relatesTo, + additionalContent, ) - ContentAttachmentData.Type.FILE -> createFileEvent(roomId, attachment, rootThreadEventId, relatesTo) + ContentAttachmentData.Type.FILE -> createFileEvent(roomId, attachment, rootThreadEventId, relatesTo, additionalContent) } } - fun createReactionEvent(roomId: String, targetEventId: String, reaction: String): Event { + fun createReactionEvent(roomId: String, targetEventId: String, reaction: String, additionalContent: Content? = null): Event { val content = ReactionContent( ReactionInfo( RelationType.ANNOTATION, @@ -380,7 +399,7 @@ internal class LocalEchoEventFactory @Inject constructor( senderId = userId, eventId = localId, type = EventType.REACTION, - content = content.toContent(), + content = content.toContent().plus(additionalContent.orEmpty()), unsignedData = UnsignedData(age = null, transactionId = localId) ) } @@ -390,6 +409,7 @@ internal class LocalEchoEventFactory @Inject constructor( attachment: ContentAttachmentData, rootThreadEventId: String?, relatesTo: RelationDefaultContent?, + additionalContent: Content?, ): Event { var width = attachment.width var height = attachment.height @@ -417,7 +437,7 @@ internal class LocalEchoEventFactory @Inject constructor( url = attachment.queryUri.toString(), relatesTo = relatesTo ?: rootThreadEventId?.let { generateThreadRelationContent(it) } ) - return createMessageEvent(roomId, content) + return createMessageEvent(roomId, content, additionalContent) } private fun createVideoEvent( @@ -425,6 +445,7 @@ internal class LocalEchoEventFactory @Inject constructor( attachment: ContentAttachmentData, rootThreadEventId: String?, relatesTo: RelationDefaultContent?, + additionalContent: Content?, ): Event { val mediaDataRetriever = MediaMetadataRetriever() mediaDataRetriever.setDataSource(context, attachment.queryUri) @@ -459,7 +480,7 @@ internal class LocalEchoEventFactory @Inject constructor( url = attachment.queryUri.toString(), relatesTo = relatesTo ?: rootThreadEventId?.let { generateThreadRelationContent(it) } ) - return createMessageEvent(roomId, content) + return createMessageEvent(roomId, content, additionalContent) } private fun createAudioEvent( @@ -468,6 +489,7 @@ internal class LocalEchoEventFactory @Inject constructor( isVoiceMessage: Boolean, rootThreadEventId: String?, relatesTo: RelationDefaultContent?, + additionalContent: Content? ): Event { val content = MessageAudioContent( msgType = MessageType.MSGTYPE_AUDIO, @@ -485,7 +507,7 @@ internal class LocalEchoEventFactory @Inject constructor( voiceMessageIndicator = if (!isVoiceMessage) null else emptyMap(), relatesTo = relatesTo ?: rootThreadEventId?.let { generateThreadRelationContent(it) } ) - return createMessageEvent(roomId, content) + return createMessageEvent(roomId, content, additionalContent) } private fun createFileEvent( @@ -493,6 +515,7 @@ internal class LocalEchoEventFactory @Inject constructor( attachment: ContentAttachmentData, rootThreadEventId: String?, relatesTo: RelationDefaultContent?, + additionalContent: Content? ): Event { val content = MessageFileContent( msgType = MessageType.MSGTYPE_FILE, @@ -504,15 +527,16 @@ internal class LocalEchoEventFactory @Inject constructor( url = attachment.queryUri.toString(), relatesTo = relatesTo ?: rootThreadEventId?.let { generateThreadRelationContent(it) } ) - return createMessageEvent(roomId, content) + return createMessageEvent(roomId, content, additionalContent) } - private fun createMessageEvent(roomId: String, content: MessageContent? = null): Event { - return createEvent(roomId, EventType.MESSAGE, content.toContent()) + private fun createMessageEvent(roomId: String, content: MessageContent, additionalContent: Content?): Event { + return createEvent(roomId, EventType.MESSAGE, content.toContent(), additionalContent) } - fun createEvent(roomId: String, type: String, content: Content?): Event { + fun createEvent(roomId: String, type: String, content: Content?, additionalContent: Content? = null): Event { val newContent = enhanceStickerIfNeeded(type, content) ?: content + val updatedNewContent = newContent?.plus(additionalContent.orEmpty()) ?: additionalContent val localId = LocalEcho.createLocalEchoId() return Event( roomId = roomId, @@ -520,7 +544,7 @@ internal class LocalEchoEventFactory @Inject constructor( senderId = userId, eventId = localId, type = type, - content = newContent, + content = updatedNewContent, unsignedData = UnsignedData(age = null, transactionId = localId) ) } @@ -555,6 +579,7 @@ internal class LocalEchoEventFactory @Inject constructor( msgType: String, autoMarkdown: Boolean, formattedText: String?, + additionalContent: Content? = null, ): Event { val content = formattedText?.let { TextContent(text.toString(), it) } ?: createTextContent(text, autoMarkdown) return createEvent( @@ -564,8 +589,7 @@ internal class LocalEchoEventFactory @Inject constructor( rootThreadEventId = rootThreadEventId, latestThreadEventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId), msgType = msgType - ) - .toContent() + ).toContent().plus(additionalContent.orEmpty()) ) } @@ -584,6 +608,7 @@ internal class LocalEchoEventFactory @Inject constructor( autoMarkdown: Boolean, rootThreadEventId: String? = null, showInThread: Boolean, + additionalContent: Content? = null ): Event? { // Fallbacks and event representation // TODO Add error/warning logs when any of this is null @@ -621,7 +646,7 @@ internal class LocalEchoEventFactory @Inject constructor( showInThread = showInThread ) ) - return createMessageEvent(roomId, content) + return createMessageEvent(roomId, content, additionalContent) } private fun generateThreadRelationContent(rootThreadEventId: String) = @@ -750,7 +775,7 @@ internal class LocalEchoEventFactory @Inject constructor( } } */ - fun createRedactEvent(roomId: String, eventId: String, reason: String?): Event { + fun createRedactEvent(roomId: String, eventId: String, reason: String?, additionalContent: Content? = null): Event { val localId = LocalEcho.createLocalEchoId() return Event( roomId = roomId, @@ -759,7 +784,7 @@ internal class LocalEchoEventFactory @Inject constructor( eventId = localId, type = EventType.REDACTION, redacts = eventId, - content = reason?.let { mapOf("reason" to it).toContent() }, + content = reason?.let { mapOf("reason" to it).toContent().plus(additionalContent.orEmpty()) } ?: additionalContent, unsignedData = UnsignedData(age = null, transactionId = localId) ) } @@ -776,9 +801,14 @@ internal class LocalEchoEventFactory @Inject constructor( formattedText: String?, autoMarkdown: Boolean, rootThreadEventId: String?, + additionalContent: Content? = null, ): Event { val messageContent = quotedEvent.getLastMessageContent() - val textMsg = if (messageContent is MessageContentWithFormattedBody) { messageContent.formattedBody } else { messageContent?.body } + val textMsg = if (messageContent is MessageContentWithFormattedBody) { + messageContent.formattedBody + } else { + messageContent?.body + } val quoteText = legacyRiotQuoteText(textMsg, text) val quoteFormattedText = "
$textMsg
$formattedText" @@ -791,13 +821,15 @@ internal class LocalEchoEventFactory @Inject constructor( rootThreadEventId = rootThreadEventId, latestThreadEventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId), msgType = MessageType.MSGTYPE_TEXT - ) + ), + additionalContent, ) } else { createFormattedTextEvent( roomId, markdownParser.parse(quoteText, force = true, advanced = autoMarkdown).copy(formattedText = quoteFormattedText), - MessageType.MSGTYPE_TEXT + MessageType.MSGTYPE_TEXT, + additionalContent, ) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt index 53c0253876..b1a3d51b36 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt @@ -96,4 +96,8 @@ internal class DefaultTimelineService @AssistedInject constructor( override fun getAttachmentMessages(): List { return timelineEventDataSource.getAttachmentMessages(roomId) } + + override fun getTimelineEventsRelatedTo(relationType: String, eventId: String): List { + return timelineEventDataSource.getTimelineEventsRelatedTo(roomId, relationType, eventId) + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDataSource.kt index b1b9e4bb22..2d6082f9b5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDataSource.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDataSource.kt @@ -19,8 +19,11 @@ package org.matrix.android.sdk.internal.session.room.timeline import androidx.lifecycle.LiveData import com.zhuinden.monarchy.Monarchy import io.realm.Sort +import org.matrix.android.sdk.api.session.events.model.getRelationContent import org.matrix.android.sdk.api.session.events.model.isImageMessage import org.matrix.android.sdk.api.session.events.model.isVideoMessage +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.internal.database.RealmSessionProvider @@ -63,4 +66,24 @@ internal class TimelineEventDataSource @Inject constructor( .orEmpty() } } + + fun getTimelineEventsRelatedTo(roomId: String, eventType: String, eventId: String): List { + // TODO Remove this trick and call relations API + // see https://spec.matrix.org/latest/client-server-api/#get_matrixclientv1roomsroomidrelationseventidreltypeeventtype + return realmSessionProvider.withRealm { realm -> + TimelineEventEntity.whereRoomId(realm, roomId) + .sort(TimelineEventEntityFields.ROOT.ORIGIN_SERVER_TS, Sort.ASCENDING) + .distinct(TimelineEventEntityFields.EVENT_ID) + .findAll() + .mapNotNull { + timelineEventMapper.map(it) + .takeIf { + val isEventRelatedTo = it.root.getRelationContent()?.takeIf { it.type == eventType && it.eventId == eventId } != null + val isContentRelatedTo = it.root.getClearContent()?.toModel() + ?.relatesTo?.takeIf { it.type == eventType && it.eventId == eventId } != null + isEventRelatedTo || isContentRelatedTo + } + } + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UpdateUserWorker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UpdateUserWorker.kt index 1f840a82d5..1ee2fc4802 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UpdateUserWorker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UpdateUserWorker.kt @@ -71,10 +71,16 @@ internal class UpdateUserWorker(context: Context, params: WorkerParameters, sess ?.saveLocally() } - private suspend fun fetchUsers(userIdsToFetch: Collection) = userIdsToFetch.mapNotNull { - tryOrNull { - val profileJson = getProfileInfoTask.execute(GetProfileInfoTask.Params(it)) - User.fromJson(it, profileJson) + private suspend fun fetchUsers(userIdsToFetch: Collection): List { + return userIdsToFetch.mapNotNull { userId -> + tryOrNull { + val profileJson = getProfileInfoTask.execute(GetProfileInfoTask.Params( + userId = userId, + // Bulk insert later, so tell the task not to store the User. + storeInDatabase = false, + )) + User.fromJson(userId, profileJson) + } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/UserDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/UserDataSource.kt index f9feb04e97..98108008fe 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/UserDataSource.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/UserDataSource.kt @@ -66,6 +66,8 @@ internal class UserDataSource @Inject constructor( } } + fun getUserOrDefault(userId: String): User = getUser(userId) ?: User(userId) + fun getUserLive(userId: String): LiveData> { val liveData = monarchy.findAllMappedWithChanges( { UserEntity.where(it, userId) }, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/helper/WidgetFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/helper/WidgetFactory.kt index 8bd61a7bdf..a43c59a83b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/helper/WidgetFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/helper/WidgetFactory.kt @@ -20,7 +20,6 @@ import org.matrix.android.sdk.api.session.content.ContentUrlResolver import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.sender.SenderInfo -import org.matrix.android.sdk.api.session.user.model.User import org.matrix.android.sdk.api.session.widgets.model.Widget import org.matrix.android.sdk.api.session.widgets.model.WidgetContent import org.matrix.android.sdk.api.session.widgets.model.WidgetType @@ -74,7 +73,7 @@ internal class WidgetFactory @Inject constructor( // Ref: https://github.com/matrix-org/matrix-widget-api/blob/master/src/templating/url-template.ts#L29-L33 fun computeURL(widget: Widget, isLightTheme: Boolean): String? { var computedUrl = widget.widgetContent.url ?: return null - val myUser = userDataSource.getUser(userId) ?: User(userId) + val myUser = userDataSource.getUserOrDefault(userId) val keyValue = widget.widgetContent.data.mapKeys { "\$${it.key}" }.toMutableMap() diff --git a/tools/danger/dangerfile.js b/tools/danger/dangerfile.js index 1a36474470..2fc55bad0c 100644 --- a/tools/danger/dangerfile.js +++ b/tools/danger/dangerfile.js @@ -81,6 +81,7 @@ const allowList = [ "Florian14", "ganfra", "jmartinesp", + "jonnyandrew", "langleyd", "MadLittleMods", "manuroe", diff --git a/vector-app/build.gradle b/vector-app/build.gradle index ca77e4b86f..1e8a8b3d3f 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 = 4 +ext.versionPatch = 6 static def getGitTimestamp() { def cmd = 'git show -s --format=%ct' diff --git a/vector-app/src/androidTest/java/im/vector/app/espresso/tools/ViewActionsExt.kt b/vector-app/src/androidTest/java/im/vector/app/espresso/tools/ViewActionsExt.kt new file mode 100644 index 0000000000..c1e5c0164b --- /dev/null +++ b/vector-app/src/androidTest/java/im/vector/app/espresso/tools/ViewActionsExt.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.espresso.tools + +import android.view.View +import androidx.test.espresso.PerformException +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import com.google.android.material.tabs.TabLayout +import org.hamcrest.Matchers.allOf + +fun selectTabAtPosition(tabIndex: Int): ViewAction { + return object : ViewAction { + override fun getDescription() = "with tab at index $tabIndex" + + override fun getConstraints() = allOf(isDisplayed(), isAssignableFrom(TabLayout::class.java)) + + override fun perform(uiController: UiController, view: View) { + val tabLayout = view as TabLayout + val tabAtIndex: TabLayout.Tab = tabLayout.getTabAt(tabIndex) + ?: throw PerformException.Builder() + .withCause(Throwable("No tab at index $tabIndex")) + .build() + + tabAtIndex.select() + } + } +} diff --git a/vector-app/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt b/vector-app/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt index d4878b8dcc..52607bd9a1 100644 --- a/vector-app/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt +++ b/vector-app/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt @@ -135,6 +135,14 @@ class UiAllScreensSanityTest { elementRobot.space { selectSpace(spaceName) } + elementRobot.layoutPreferences { + crawl() + } + + elementRobot.roomList { + crawlTabs() + } + elementRobot.withDeveloperMode { settings { advancedSettings { crawlDeveloperOptions() } diff --git a/vector-app/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt b/vector-app/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt index d9dfb0facf..8f1df52863 100644 --- a/vector-app/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt +++ b/vector-app/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt @@ -17,8 +17,10 @@ package im.vector.app.ui.robot import android.view.View +import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso.closeSoftKeyboard import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu import androidx.test.espresso.Espresso.pressBack import androidx.test.espresso.action.ViewActions import androidx.test.espresso.action.ViewActions.click @@ -94,6 +96,18 @@ class ElementRobot( waitUntilViewVisible(withId(R.id.roomListContainer)) } + fun layoutPreferences(block: LayoutPreferencesRobot.() -> Unit) { + openActionBarOverflowOrOptionsMenu( + ApplicationProvider.getApplicationContext() + ) + clickOn(R.string.home_layout_preferences) + waitUntilDialogVisible(withId(R.id.home_layout_settings_recents)) + + block(LayoutPreferencesRobot()) + + pressBack() + } + fun newDirectMessage(block: NewDirectMessageRobot.() -> Unit) { if (labsPreferences.isNewAppLayoutEnabled) { clickOn(R.id.newLayoutCreateChatButton) diff --git a/vector-app/src/androidTest/java/im/vector/app/ui/robot/LayoutPreferencesRobot.kt b/vector-app/src/androidTest/java/im/vector/app/ui/robot/LayoutPreferencesRobot.kt new file mode 100644 index 0000000000..429511e10f --- /dev/null +++ b/vector-app/src/androidTest/java/im/vector/app/ui/robot/LayoutPreferencesRobot.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.ui.robot + +import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn +import im.vector.app.R + +class LayoutPreferencesRobot { + + fun crawl() { + toggleRecents() + toggleFilters() + useAZOrderd() + useActivityOrder() + } + + fun toggleRecents() { + clickOn(R.id.home_layout_settings_recents) + } + + fun toggleFilters() { + clickOn(R.id.home_layout_settings_filters) + } + + fun useAZOrderd() { + clickOn(R.id.home_layout_settings_sort_name) + } + + fun useActivityOrder() { + clickOn(R.id.home_layout_settings_sort_activity) + } +} diff --git a/vector-app/src/androidTest/java/im/vector/app/ui/robot/RoomListRobot.kt b/vector-app/src/androidTest/java/im/vector/app/ui/robot/RoomListRobot.kt index e4984aeed0..cbc46f15e7 100644 --- a/vector-app/src/androidTest/java/im/vector/app/ui/robot/RoomListRobot.kt +++ b/vector-app/src/androidTest/java/im/vector/app/ui/robot/RoomListRobot.kt @@ -21,29 +21,41 @@ import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.pressBack import androidx.test.espresso.action.ViewActions import androidx.test.espresso.contrib.RecyclerViewActions -import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import com.adevinta.android.barista.assertion.BaristaVisibilityAssertions import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn import im.vector.app.R +import im.vector.app.espresso.tools.selectTabAtPosition import im.vector.app.espresso.tools.waitUntilActivityVisible import im.vector.app.espresso.tools.waitUntilDialogVisible +import im.vector.app.espresso.tools.waitUntilViewVisible +import im.vector.app.features.home.HomeActivity +import im.vector.app.features.home.room.list.home.header.HomeRoomFilter import im.vector.app.features.roomdirectory.RoomDirectoryActivity import im.vector.app.ui.robot.settings.labs.LabFeaturesPreferences +import im.vector.app.waitForView class RoomListRobot(private val labsPreferences: LabFeaturesPreferences) { fun openRoom(roomName: String, block: RoomDetailRobot.() -> Unit) { - clickOn(roomName) + onView(withId(R.id.roomListView)) + .perform( + RecyclerViewActions.actionOnItem( + hasDescendant(withText(roomName)), + ViewActions.click() + ) + ) block(RoomDetailRobot()) pressBack() } fun verifyCreatedRoom() { - onView(ViewMatchers.withId(R.id.roomListView)) + onView(withId(R.id.roomListView)) .perform( RecyclerViewActions.actionOnItem( - ViewMatchers.hasDescendant(withText(R.string.room_displayname_empty_room)), + hasDescendant(withText(R.string.room_displayname_empty_room)), ViewActions.longClick() ) ) @@ -53,7 +65,7 @@ class RoomListRobot(private val labsPreferences: LabFeaturesPreferences) { fun newRoom(block: NewRoomRobot.() -> Unit) { if (labsPreferences.isNewAppLayoutEnabled) { clickOn(R.id.newLayoutCreateChatButton) - waitUntilDialogVisible(ViewMatchers.withId(R.id.create_room)) + waitUntilDialogVisible(withId(R.id.create_room)) clickOn(R.id.create_room) } else { clickOn(R.id.createGroupRoomButton) @@ -67,4 +79,19 @@ class RoomListRobot(private val labsPreferences: LabFeaturesPreferences) { pressBack() } } + + fun crawlTabs() { + waitUntilActivityVisible { + waitUntilViewVisible(withId(R.id.roomListContainer)) + } + + selectFilterTab(HomeRoomFilter.UNREADS) + waitForView(withId(R.id.emptyTitleView)) + selectFilterTab(HomeRoomFilter.ALL) + waitForView(withId(R.id.roomNameView)) + } + + fun selectFilterTab(filter: HomeRoomFilter) { + onView(withId(R.id.home_filter_tabs_tabs)).perform(selectTabAtPosition(filter.ordinal)) + } } diff --git a/vector-app/src/debug/java/im/vector/app/flipper/VectorFlipperProxy.kt b/vector-app/src/debug/java/im/vector/app/flipper/VectorFlipperProxy.kt index 2e4336c942..cbf9e4f11f 100644 --- a/vector-app/src/debug/java/im/vector/app/flipper/VectorFlipperProxy.kt +++ b/vector-app/src/debug/java/im/vector/app/flipper/VectorFlipperProxy.kt @@ -17,6 +17,7 @@ package im.vector.app.flipper import android.content.Context +import android.os.Build import com.facebook.flipper.android.AndroidFlipperClient import com.facebook.flipper.android.utils.FlipperUtils import com.facebook.flipper.plugins.crashreporter.CrashReporterPlugin @@ -31,6 +32,7 @@ import com.kgurgul.flipper.RealmDatabaseDriver import com.kgurgul.flipper.RealmDatabaseProvider import im.vector.app.core.debug.FlipperProxy import io.realm.RealmConfiguration +import okhttp3.Interceptor import org.matrix.android.sdk.api.Matrix import javax.inject.Inject import javax.inject.Singleton @@ -41,29 +43,43 @@ class VectorFlipperProxy @Inject constructor( ) : FlipperProxy { private val networkFlipperPlugin = NetworkFlipperPlugin() + private val isEnabled: Boolean + get() { + // https://github.com/facebook/flipper/issues/3572 + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP_MR1) { + return false + } + + return FlipperUtils.shouldEnableFlipper(context) + } + override fun init(matrix: Matrix) { + if (!isEnabled) return + SoLoader.init(context, false) - if (FlipperUtils.shouldEnableFlipper(context)) { - val client = AndroidFlipperClient.getInstance(context) - client.addPlugin(CrashReporterPlugin.getInstance()) - client.addPlugin(SharedPreferencesFlipperPlugin(context)) - client.addPlugin(InspectorFlipperPlugin(context, DescriptorMapping.withDefaults())) - client.addPlugin(networkFlipperPlugin) - client.addPlugin( - DatabasesFlipperPlugin( - RealmDatabaseDriver( - context = context, - realmDatabaseProvider = object : RealmDatabaseProvider { - override fun getRealmConfigurations(): List { - return matrix.debugService().getAllRealmConfigurations() - } - }) - ) - ) - client.start() - } + val client = AndroidFlipperClient.getInstance(context) + client.addPlugin(CrashReporterPlugin.getInstance()) + client.addPlugin(SharedPreferencesFlipperPlugin(context)) + client.addPlugin(InspectorFlipperPlugin(context, DescriptorMapping.withDefaults())) + client.addPlugin(networkFlipperPlugin) + client.addPlugin( + DatabasesFlipperPlugin( + RealmDatabaseDriver( + context = context, + realmDatabaseProvider = object : RealmDatabaseProvider { + override fun getRealmConfigurations(): List { + return matrix.debugService().getAllRealmConfigurations() + } + }) + ) + ) + client.start() } - override fun networkInterceptor() = FlipperOkhttpInterceptor(networkFlipperPlugin) + override fun networkInterceptor(): Interceptor? { + if (!isEnabled) return null + + return FlipperOkhttpInterceptor(networkFlipperPlugin) + } } diff --git a/vector-config/src/main/res/values/config-settings.xml b/vector-config/src/main/res/values/config-settings.xml index 7b7aac8156..504c587b8d 100755 --- a/vector-config/src/main/res/values/config-settings.xml +++ b/vector-config/src/main/res/values/config-settings.xml @@ -49,6 +49,8 @@ false true false + true + false diff --git a/vector/src/main/java/im/vector/app/features/VectorFeatures.kt b/vector/src/main/java/im/vector/app/features/VectorFeatures.kt index 255ac6d188..95cf272abd 100644 --- a/vector/src/main/java/im/vector/app/features/VectorFeatures.kt +++ b/vector/src/main/java/im/vector/app/features/VectorFeatures.kt @@ -62,5 +62,5 @@ class DefaultVectorFeatures : VectorFeatures { override fun isQrCodeLoginEnabled(): Boolean = true override fun isQrCodeLoginForAllServers(): Boolean = false override fun isReciprocateQrCodeLogin(): Boolean = false - override fun isVoiceBroadcastEnabled(): Boolean = false + override fun isVoiceBroadcastEnabled(): Boolean = true } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt index 3e828f62b7..f773671694 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 @@ -121,9 +121,17 @@ sealed class RoomDetailAction : VectorViewModelAction { object OpenElementCallWidget : RoomDetailAction() sealed class VoiceBroadcastAction : RoomDetailAction() { - object Start : VoiceBroadcastAction() - object Pause : VoiceBroadcastAction() - object Resume : VoiceBroadcastAction() - object Stop : VoiceBroadcastAction() + sealed class Recording : VoiceBroadcastAction() { + object Start : Recording() + object Pause : Recording() + object Resume : Recording() + object Stop : Recording() + } + + sealed class Listening : VoiceBroadcastAction() { + data class PlayOrResume(val eventId: String) : Listening() + object Pause : Listening() + object Stop : Listening() + } } } 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 c30d7a648d..a019ab862d 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 @@ -623,10 +623,13 @@ class TimelineViewModel @AssistedInject constructor( if (room == null) return viewModelScope.launch { when (action) { - RoomDetailAction.VoiceBroadcastAction.Start -> voiceBroadcastHelper.startVoiceBroadcast(room.roomId) - RoomDetailAction.VoiceBroadcastAction.Pause -> voiceBroadcastHelper.pauseVoiceBroadcast(room.roomId) - RoomDetailAction.VoiceBroadcastAction.Resume -> voiceBroadcastHelper.resumeVoiceBroadcast(room.roomId) - RoomDetailAction.VoiceBroadcastAction.Stop -> voiceBroadcastHelper.stopVoiceBroadcast(room.roomId) + 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() } } } 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 4721b81571..55ec922a57 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 @@ -234,8 +234,9 @@ 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.Pause) + it.isVoiceBroadcasting && !requireActivity().isChangingConfigurations -> timelineViewModel.handle(VoiceBroadcastAction.Recording.Pause) else -> { + timelineViewModel.handle(VoiceBroadcastAction.Listening.Pause) messageComposerViewModel.handle(MessageComposerAction.OnEntersBackground(composer.text.toString())) } } @@ -309,7 +310,7 @@ class MessageComposerFragment : VectorBaseFragment(), A ) attachmentTypeSelector.setAttachmentVisibility( AttachmentTypeSelectorView.Type.VOICE_BROADCAST, - vectorFeatures.isVoiceBroadcastEnabled(), // TODO check user permission + vectorPreferences.isVoiceBroadcastEnabled(), // TODO check user permission ) } attachmentTypeSelector.show(composer.attachmentButton) @@ -684,7 +685,7 @@ class MessageComposerFragment : VectorBaseFragment(), A locationOwnerId = session.myUserId ) } - AttachmentTypeSelectorView.Type.VOICE_BROADCAST -> timelineViewModel.handle(VoiceBroadcastAction.Start) + AttachmentTypeSelectorView.Type.VOICE_BROADCAST -> timelineViewModel.handle(VoiceBroadcastAction.Recording.Start) } } 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 eef06d11b7..1a9f9e6291 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 @@ -42,6 +42,7 @@ import im.vector.app.features.powerlevel.PowerLevelsFlowFactory import im.vector.app.features.session.coroutineScope import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants +import im.vector.app.features.voicebroadcast.VoiceBroadcastHelper import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine @@ -84,6 +85,7 @@ class MessageComposerViewModel @AssistedInject constructor( private val rainbowGenerator: RainbowGenerator, private val audioMessageHelper: AudioMessageHelper, private val analyticsTracker: AnalyticsTracker, + private val voiceBroadcastHelper: VoiceBroadcastHelper, ) : VectorViewModel(initialState) { private val room = session.getRoom(initialState.roomId)!! @@ -981,6 +983,8 @@ class MessageComposerViewModel @AssistedInject constructor( private fun handleEntersBackground(composerText: String) { // Always stop all voice actions. It may be playing in timeline or active recording val playingAudioContent = audioMessageHelper.stopAllVoiceActions(deleteRecord = false) + // TODO remove this when there will be a listening indicator outside of the timeline + voiceBroadcastHelper.pausePlayback() val isVoiceRecording = com.airbnb.mvrx.withState(this) { it.isVoiceRecording } if (isVoiceRecording) { 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 06da69fc1a..245d92f95b 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 @@ -43,9 +43,7 @@ import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStat import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttributesFactory -import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventsGroup import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider -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.MessageAudioItem import im.vector.app.features.home.room.detail.timeline.item.MessageAudioItem_ @@ -58,8 +56,6 @@ import im.vector.app.features.home.room.detail.timeline.item.MessageLocationItem import im.vector.app.features.home.room.detail.timeline.item.MessageLocationItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem_ -import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastItem -import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceItem import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceItem_ import im.vector.app.features.home.room.detail.timeline.item.PollItem @@ -82,8 +78,8 @@ import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.media.VideoContentRenderer import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.voice.AudioWaveformView +import im.vector.app.features.voicebroadcast.isVoiceBroadcast import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent -import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence import me.gujun.android.span.span import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl @@ -107,6 +103,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageVerificationRequestContent import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent +import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent import org.matrix.android.sdk.api.session.room.model.message.getFileUrl import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl import org.matrix.android.sdk.api.settings.LightweightSettingsStorage @@ -141,6 +138,7 @@ class MessageItemFactory @Inject constructor( private val urlMapProvider: UrlMapProvider, private val liveLocationShareMessageItemFactory: LiveLocationShareMessageItemFactory, private val pollItemViewStateFactory: PollItemViewStateFactory, + private val voiceBroadcastItemFactory: VoiceBroadcastItemFactory, ) { // TODO inject this properly? @@ -203,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 -> buildVoiceBroadcastItem(messageContent, params.eventsGroup, highlight, callback, attributes) + is MessageVoiceBroadcastInfoContent -> voiceBroadcastItemFactory.create(params, messageContent, highlight, callback, attributes) else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes) } return messageItem?.apply { @@ -323,7 +321,10 @@ class MessageItemFactory @Inject constructor( informationData: MessageInformationData, highlight: Boolean, attributes: AbsMessageItem.Attributes - ): MessageVoiceItem { + ): MessageVoiceItem? { + // Do not display voice broadcast messages + if (params.event.root.asMessageAudioEvent().isVoiceBroadcast()) return null + val fileUrl = getAudioFileUrl(messageContent, informationData) val playbackControlButtonClickListener = createOnPlaybackButtonClickListener(messageContent, informationData, params) @@ -713,25 +714,6 @@ class MessageItemFactory @Inject constructor( .highlighted(highlight) } - private fun buildVoiceBroadcastItem( - messageContent: MessageVoiceBroadcastInfoContent, - eventsGroup: TimelineEventsGroup?, - highlight: Boolean, - callback: TimelineEventController.Callback?, - attributes: AbsMessageItem.Attributes, - ): MessageVoiceBroadcastItem? { - if (messageContent.voiceBroadcastState != VoiceBroadcastState.STARTED) return null - val voiceBroadcastEventsGroup = eventsGroup?.let { VoiceBroadcastEventsGroup(it) } ?: return null - val mostRecentEvent = voiceBroadcastEventsGroup.getLastEvent() - val mostRecentMessageContent = (mostRecentEvent.getVectorLastMessageContent() as? MessageVoiceBroadcastInfoContent) ?: return null - return MessageVoiceBroadcastItem_() - .attributes(attributes) - .highlighted(highlight) - .voiceBroadcastState(mostRecentMessageContent.voiceBroadcastState) - .leftGuideline(avatarSizeProvider.leftGuideline) - .callback(callback) - } - private fun List?.toFft(): List? { return this ?.filterNotNull() 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 new file mode 100644 index 0000000000..5dc601a91a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.app.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.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.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.model.MessageVoiceBroadcastInfoContent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +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.util.toMatrixItem +import javax.inject.Inject + +class VoiceBroadcastItemFactory @Inject constructor( + private val session: Session, + private val avatarSizeProvider: AvatarSizeProvider, + private val colorProvider: ColorProvider, + private val drawableProvider: DrawableProvider, + private val voiceBroadcastRecorder: VoiceBroadcastRecorder?, + private val voiceBroadcastPlayer: VoiceBroadcastPlayer, +) { + + fun create( + params: TimelineItemFactoryParams, + messageContent: MessageVoiceBroadcastInfoContent, + highlight: Boolean, + callback: TimelineEventController.Callback?, + attributes: AbsMessageItem.Attributes, + ): VectorEpoxyModel? { + // 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 + return if (isRecording) { + createRecordingItem( + params.event.roomId, + eventsGroup.groupId, + highlight, + callback, + attributes + ) + } else { + createListeningItem( + params.event.roomId, + eventsGroup.groupId, + mostRecentMessageContent.voiceBroadcastState, + recorderName, + highlight, + callback, + attributes + ) + } + } + + private fun createRecordingItem( + roomId: String, + voiceBroadcastId: String, + highlight: Boolean, + callback: TimelineEventController.Callback?, + attributes: AbsMessageItem.Attributes, + ): MessageVoiceBroadcastRecordingItem { + val roomSummary = session.getRoom(roomId)?.roomSummary() + return MessageVoiceBroadcastRecordingItem_() + .id("voice_broadcast_$voiceBroadcastId") + .attributes(attributes) + .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, + ): MessageVoiceBroadcastListeningItem { + val roomSummary = session.getRoom(roomId)?.roomSummary() + return MessageVoiceBroadcastListeningItem_() + .id("voice_broadcast_$voiceBroadcastId") + .attributes(attributes) + .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/LocationPinProvider.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/LocationPinProvider.kt index 8ef910c931..7f276f2f73 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/LocationPinProvider.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/LocationPinProvider.kt @@ -29,7 +29,7 @@ import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.glide.GlideApp import im.vector.app.core.utils.DimensionConverter import im.vector.app.features.home.AvatarRenderer -import org.matrix.android.sdk.api.session.getUser +import org.matrix.android.sdk.api.session.getUserOrDefault import org.matrix.android.sdk.api.util.toMatrixItem import timber.log.Timber import javax.inject.Inject @@ -67,9 +67,9 @@ class LocationPinProvider @Inject constructor( activeSessionHolder .getActiveSession() - .getUser(userId) - ?.toMatrixItem() - ?.let { userItem -> + .getUserOrDefault(userId) + .toMatrixItem() + .let { userItem -> val size = dimensionConverter.dpToPx(44) val bgTintColor = matrixItemColorProvider.getColor(userItem) avatarRenderer.render(glideRequests, userItem, object : CustomTarget(size, size) { 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 13de456e84..d8817c1f44 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,12 +18,15 @@ 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.getVoiceBroadcastEventId +import im.vector.app.features.voicebroadcast.isVoiceBroadcast import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent +import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.widgets.model.WidgetContent import org.threeten.bp.Duration @@ -61,6 +64,10 @@ class TimelineEventsGroups { EventType.isCallEvent(type) -> (content?.get("call_id") as? String) type == VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> root.asVoiceBroadcastEvent()?.reference?.eventId type == EventType.STATE_ROOM_WIDGET || type == EventType.STATE_ROOM_WIDGET_LEGACY -> root.stateKey + type == EventType.MESSAGE && root.asMessageAudioEvent().isVoiceBroadcast() -> { + // Group voice messages with a reference to an eventId + root.asMessageAudioEvent()?.getVoiceBroadcastEventId() + } else -> { null } @@ -134,8 +141,8 @@ class CallSignalingEventsGroup(private val group: TimelineEventsGroup) { } class VoiceBroadcastEventsGroup(private val group: TimelineEventsGroup) { - fun getLastEvent(): TimelineEvent { + fun getLastDisplayableEvent(): TimelineEvent { return group.events.find { it.root.asVoiceBroadcastEvent()?.content?.voiceBroadcastState == VoiceBroadcastState.STOPPED } - ?: group.events.maxBy { it.root.originServerTs ?: 0L } + ?: group.events.filter { it.root.type == VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO }.maxBy { it.root.originServerTs ?: 0L } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastItem.kt deleted file mode 100644 index 14a4fc6b07..0000000000 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastItem.kt +++ /dev/null @@ -1,72 +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.home.room.detail.timeline.item - -import android.annotation.SuppressLint -import android.widget.ImageButton -import android.widget.TextView -import com.airbnb.epoxy.EpoxyAttribute -import com.airbnb.epoxy.EpoxyModelClass -import im.vector.app.R -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.model.VoiceBroadcastState - -@EpoxyModelClass -abstract class MessageVoiceBroadcastItem : AbsMessageItem() { - - @EpoxyAttribute - var callback: TimelineEventController.Callback? = null - - @EpoxyAttribute - var voiceBroadcastState: VoiceBroadcastState? = null - - override fun isCacheable(): Boolean = false - - override fun bind(holder: Holder) { - super.bind(holder) - bindVoiceBroadcastItem(holder) - } - - @SuppressLint("SetTextI18n") // Temporary text - private fun bindVoiceBroadcastItem(holder: Holder) { - with(holder) { - currentStateText.text = "Voice Broadcast state: ${voiceBroadcastState?.value ?: "None"}" - playButton.isEnabled = voiceBroadcastState == VoiceBroadcastState.PAUSED - pauseButton.isEnabled = voiceBroadcastState == VoiceBroadcastState.STARTED || voiceBroadcastState == VoiceBroadcastState.RESUMED - stopButton.isEnabled = voiceBroadcastState == VoiceBroadcastState.STARTED || - voiceBroadcastState == VoiceBroadcastState.RESUMED || - voiceBroadcastState == VoiceBroadcastState.PAUSED - playButton.setOnClickListener { attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Resume) } - pauseButton.setOnClickListener { attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Pause) } - stopButton.setOnClickListener { attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Stop) } - } - } - - override fun getViewStubId() = STUB_ID - - class Holder : AbsMessageLocationItem.Holder(STUB_ID) { - val currentStateText by bind(R.id.currentStateText) - val playButton by bind(R.id.playButton) - val pauseButton by bind(R.id.pauseButton) - val stopButton by bind(R.id.stopButton) - } - - companion object { - private val STUB_ID = R.id.messageVoiceBroadcastStub - } -} 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 new file mode 100644 index 0000000000..5b58dda4e6 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt @@ -0,0 +1,179 @@ +/* + * 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.view.View +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.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.voicebroadcast.model.VoiceBroadcastState +import org.matrix.android.sdk.api.util.MatrixItem + +@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 + + private lateinit var playerListener: VoiceBroadcastPlayer.Listener + + override fun isCacheable(): Boolean = false + + override fun bind(holder: Holder) { + super.bind(holder) + bindVoiceBroadcastItem(holder) + } + + private fun bindVoiceBroadcastItem(holder: Holder) { + playerListener = VoiceBroadcastPlayer.Listener { state -> + renderState(holder, state) + } + voiceBroadcastPlayer?.addListener(playerListener) + renderHeader(holder) + renderLiveIcon(holder) + } + + private fun renderHeader(holder: Holder) { + with(holder) { + roomItem?.let { + attributes.avatarRenderer.render(it, roomAvatarImageView) + titleText.text = it.displayName + } + broadcasterNameText.text = broadcasterName + } + } + + private fun renderLiveIcon(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 + } + } + } + } + + private fun renderState(holder: Holder, state: VoiceBroadcastPlayer.State) { + if (isCurrentMediaActive()) { + renderActiveMedia(holder, state) + } else { + renderInactiveMedia(holder) + } + } + + private fun renderActiveMedia(holder: Holder, state: VoiceBroadcastPlayer.State) { + with(holder) { + bufferingView.isVisible = state == VoiceBroadcastPlayer.State.BUFFERING + playPauseButton.isVisible = state != VoiceBroadcastPlayer.State.BUFFERING + + when (state) { + VoiceBroadcastPlayer.State.PLAYING -> { + 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) } + } + 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)) + } + } + VoiceBroadcastPlayer.State.BUFFERING -> Unit + } + } + } + + private fun renderInactiveMedia(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)) + } + } + } + + private fun isCurrentMediaActive() = voiceBroadcastPlayer?.currentVoiceBroadcastId == voiceBroadcastId + + override fun unbind(holder: Holder) { + super.unbind(holder) + voiceBroadcastPlayer?.removeListener(playerListener) + } + + 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) + val playPauseButton by bind(R.id.playPauseButton) + val bufferingView by bind(R.id.bufferingView) + val broadcasterNameText by bind(R.id.broadcasterNameText) + } + + companion object { + private val STUB_ID = R.id.messageVoiceBroadcastListeningStub + } +} 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 new file mode 100644 index 0000000000..c417053b2a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt @@ -0,0 +1,140 @@ +/* + * 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.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.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 + +@EpoxyModelClass +abstract class MessageVoiceBroadcastRecordingItem : AbsMessageItem() { + + @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 + + override fun bind(holder: Holder) { + super.bind(holder) + bindVoiceBroadcastItem(holder) + } + + private fun bindVoiceBroadcastItem(holder: Holder) { + recorderListener = object : VoiceBroadcastRecorder.Listener { + override fun onStateUpdated(state: VoiceBroadcastRecorder.State) { + renderState(holder, state) + } + } + voiceBroadcastRecorder?.addListener(recorderListener) + renderHeader(holder) + } + + private fun renderHeader(holder: Holder) { + with(holder) { + roomItem?.let { + attributes.avatarRenderer.render(it, roomAvatarImageView) + titleText.text = it.displayName + } + } + } + + 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 + + liveIndicator.isVisible = true + liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.vctr_content_quaternary)) + + recordButton.setImageResource(R.drawable.ic_recording_dot) + recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_resume_voice_broadcast_record) + recordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Resume) } + stopRecordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } + } + VoiceBroadcastRecorder.State.Idle -> { + recordButton.isEnabled = false + stopRecordButton.isEnabled = false + liveIndicator.isVisible = false + } + } + } + } + + override fun unbind(holder: Holder) { + super.unbind(holder) + voiceBroadcastRecorder?.removeListener(recorderListener) + } + + 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) + val recordButton by bind(R.id.recordButton) + val stopRecordButton by bind(R.id.stopRecordButton) + } + + companion object { + private val STUB_ID = R.id.messageVoiceBroadcastRecordingStub + } +} 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 5f20b7278e..85cfb76ff7 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 @@ -27,7 +27,7 @@ import im.vector.app.core.glide.GlideApp import im.vector.app.features.home.AvatarRenderer import io.noties.markwon.core.spans.LinkSpan import org.matrix.android.sdk.api.session.getRoomSummary -import org.matrix.android.sdk.api.session.getUser +import org.matrix.android.sdk.api.session.getUserOrDefault import org.matrix.android.sdk.api.session.permalinks.PermalinkData import org.matrix.android.sdk.api.session.permalinks.PermalinkParser import org.matrix.android.sdk.api.session.room.model.RoomSummary @@ -101,7 +101,7 @@ class PillsPostProcessor @AssistedInject constructor( private fun PermalinkData.UserLink.toMatrixItem(roomId: String?): MatrixItem? = if (roomId == null) { - sessionHolder.getSafeActiveSession()?.getUser(userId)?.toMatrixItem() + sessionHolder.getSafeActiveSession()?.getUserOrDefault(userId)?.toMatrixItem() } else { sessionHolder.getSafeActiveSession()?.roomService()?.getRoomMember(userId, roomId)?.toMatrixItem() } diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt index 28e37a38eb..4c7abd99b8 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt @@ -40,7 +40,7 @@ import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.EventType 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.session.room.powerlevels.PowerLevelsHelper import org.matrix.android.sdk.api.util.toMatrixItem import timber.log.Timber @@ -101,7 +101,7 @@ class LocationSharingViewModel @AssistedInject constructor( } private fun setUserItem() { - setState { copy(userItem = session.getUser(session.myUserId)?.toMatrixItem()) } + setState { copy(userItem = session.getUserOrDefault(session.myUserId).toMatrixItem()) } } private fun updatePin(isUserPin: Boolean? = true) { diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/UserLiveLocationViewStateMapper.kt b/vector/src/main/java/im/vector/app/features/location/live/map/UserLiveLocationViewStateMapper.kt index 77f8c30fd3..f4f4a462ce 100644 --- a/vector/src/main/java/im/vector/app/features/location/live/map/UserLiveLocationViewStateMapper.kt +++ b/vector/src/main/java/im/vector/app/features/location/live/map/UserLiveLocationViewStateMapper.kt @@ -20,7 +20,7 @@ import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider import im.vector.app.features.location.toLocationData import kotlinx.coroutines.suspendCancellableCoroutine -import org.matrix.android.sdk.api.session.getUser +import org.matrix.android.sdk.api.session.getUserOrDefault import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject @@ -45,19 +45,17 @@ class UserLiveLocationViewStateMapper @Inject constructor( else -> { locationPinProvider.create(userId) { pinDrawable -> val session = activeSessionHolder.getActiveSession() - session.getUser(userId)?.toMatrixItem()?.let { matrixItem -> - val locationTimestampMillis = liveLocationShareAggregatedSummary.lastLocationDataContent?.getBestTimestampMillis() - val viewState = UserLiveLocationViewState( - matrixItem = matrixItem, - pinDrawable = pinDrawable, - locationData = locationData, - endOfLiveTimestampMillis = liveLocationShareAggregatedSummary.endOfLiveTimestampMillis, - locationTimestampMillis = locationTimestampMillis, - showStopSharingButton = userId == session.myUserId - ) - continuation.resume(viewState) { - // do nothing on cancellation - } + val locationTimestampMillis = liveLocationShareAggregatedSummary.lastLocationDataContent?.getBestTimestampMillis() + val viewState = UserLiveLocationViewState( + matrixItem = session.getUserOrDefault(userId).toMatrixItem(), + pinDrawable = pinDrawable, + locationData = locationData, + endOfLiveTimestampMillis = liveLocationShareAggregatedSummary.endOfLiveTimestampMillis, + locationTimestampMillis = locationTimestampMillis, + showStopSharingButton = userId == session.myUserId + ) + continuation.resume(viewState) { + // do nothing on cancellation } } } diff --git a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginAction.kt b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginAction.kt index 8854d0720f..5ea46d3dcd 100644 --- a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginAction.kt +++ b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginAction.kt @@ -22,4 +22,5 @@ sealed class QrCodeLoginAction : VectorViewModelAction { data class OnQrCodeScanned(val qrCode: String) : QrCodeLoginAction() object GenerateQrCode : QrCodeLoginAction() object ShowQrCode : QrCodeLoginAction() + object TryAgain : QrCodeLoginAction() } diff --git a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginActivity.kt b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginActivity.kt index f5fd17c0c8..a0c113224d 100644 --- a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginActivity.kt +++ b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginActivity.kt @@ -24,7 +24,9 @@ import com.airbnb.mvrx.Mavericks import com.airbnb.mvrx.viewModel import dagger.hilt.android.AndroidEntryPoint import im.vector.app.core.extensions.addFragment +import im.vector.app.core.extensions.replaceFragment import im.vector.app.core.platform.SimpleFragmentActivity +import im.vector.app.features.home.HomeActivity import im.vector.lib.core.utils.compat.getParcelableCompat import timber.log.Timber @@ -37,32 +39,35 @@ class QrCodeLoginActivity : SimpleFragmentActivity() { super.onCreate(savedInstanceState) views.toolbar.visibility = View.GONE - val qrCodeLoginArgs: QrCodeLoginArgs? = intent?.extras?.getParcelableCompat(Mavericks.KEY_ARG) if (isFirstCreation()) { - when (qrCodeLoginArgs?.loginType) { - QrCodeLoginType.LOGIN -> { - showInstructionsFragment(qrCodeLoginArgs) - } - QrCodeLoginType.LINK_A_DEVICE -> { - if (qrCodeLoginArgs.showQrCodeImmediately) { - handleNavigateToShowQrCodeScreen() - } else { - showInstructionsFragment(qrCodeLoginArgs) - } - } - null -> { - Timber.i("QrCodeLoginArgs is null. This is not expected.") - finish() - return - } - } + navigateToInitialFragment() } observeViewEvents() } + private fun navigateToInitialFragment() { + val qrCodeLoginArgs: QrCodeLoginArgs? = intent?.extras?.getParcelableCompat(Mavericks.KEY_ARG) + when (qrCodeLoginArgs?.loginType) { + QrCodeLoginType.LOGIN -> { + showInstructionsFragment(qrCodeLoginArgs) + } + QrCodeLoginType.LINK_A_DEVICE -> { + if (qrCodeLoginArgs.showQrCodeImmediately) { + handleNavigateToShowQrCodeScreen() + } else { + showInstructionsFragment(qrCodeLoginArgs) + } + } + null -> { + Timber.i("QrCodeLoginArgs is null. This is not expected.") + finish() + } + } + } + private fun showInstructionsFragment(qrCodeLoginArgs: QrCodeLoginArgs) { - addFragment( + replaceFragment( views.container, QrCodeLoginInstructionsFragment::class.java, qrCodeLoginArgs, @@ -75,10 +80,16 @@ class QrCodeLoginActivity : SimpleFragmentActivity() { when (it) { QrCodeLoginViewEvents.NavigateToStatusScreen -> handleNavigateToStatusScreen() QrCodeLoginViewEvents.NavigateToShowQrCodeScreen -> handleNavigateToShowQrCodeScreen() + QrCodeLoginViewEvents.NavigateToHomeScreen -> handleNavigateToHomeScreen() + QrCodeLoginViewEvents.NavigateToInitialScreen -> handleNavigateToInitialScreen() } } } + private fun handleNavigateToInitialScreen() { + navigateToInitialFragment() + } + private fun handleNavigateToShowQrCodeScreen() { addFragment( views.container, @@ -95,6 +106,11 @@ class QrCodeLoginActivity : SimpleFragmentActivity() { ) } + private fun handleNavigateToHomeScreen() { + val intent = HomeActivity.newIntent(this, firstStartMainActivity = false, existingSession = true) + startActivity(intent) + } + companion object { private const val FRAGMENT_QR_CODE_INSTRUCTIONS_TAG = "FRAGMENT_QR_CODE_INSTRUCTIONS_TAG" diff --git a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginConnectionStatus.kt b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginConnectionStatus.kt index 330562b874..4bef41b6c1 100644 --- a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginConnectionStatus.kt +++ b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginConnectionStatus.kt @@ -16,9 +16,11 @@ package im.vector.app.features.login.qr +import org.matrix.android.sdk.api.rendezvous.RendezvousFailureReason + sealed class QrCodeLoginConnectionStatus { object ConnectingToDevice : QrCodeLoginConnectionStatus() data class Connected(val securityCode: String, val canConfirmSecurityCode: Boolean) : QrCodeLoginConnectionStatus() object SigningIn : QrCodeLoginConnectionStatus() - data class Failed(val errorType: QrCodeLoginErrorType, val canTryAgain: Boolean) : QrCodeLoginConnectionStatus() + data class Failed(val errorType: RendezvousFailureReason, val canTryAgain: Boolean) : QrCodeLoginConnectionStatus() } diff --git a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginInstructionsFragment.kt b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginInstructionsFragment.kt index efd23f2530..40fcbbbb85 100644 --- a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginInstructionsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginInstructionsFragment.kt @@ -63,6 +63,7 @@ class QrCodeLoginInstructionsFragment : VectorBaseFragment diff --git a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginStatusFragment.kt b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginStatusFragment.kt index a9c589e469..6ef261e6d9 100644 --- a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginStatusFragment.kt +++ b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginStatusFragment.kt @@ -28,6 +28,7 @@ import im.vector.app.R import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.databinding.FragmentQrCodeLoginStatusBinding import im.vector.app.features.themes.ThemeUtils +import org.matrix.android.sdk.api.rendezvous.RendezvousFailureReason @AndroidEntryPoint class QrCodeLoginStatusFragment : VectorBaseFragment() { @@ -41,6 +42,13 @@ class QrCodeLoginStatusFragment : VectorBaseFragment getString(R.string.qr_code_login_header_failed_device_is_not_supported_description) - QrCodeLoginErrorType.TIMEOUT -> getString(R.string.qr_code_login_header_failed_timeout_description) - QrCodeLoginErrorType.REQUEST_WAS_DENIED -> getString(R.string.qr_code_login_header_failed_denied_description) + private fun getErrorDescription(reason: RendezvousFailureReason): String { + return when (reason) { + RendezvousFailureReason.UnsupportedAlgorithm, + RendezvousFailureReason.UnsupportedTransport -> getString(R.string.qr_code_login_header_failed_device_is_not_supported_description) + RendezvousFailureReason.UnsupportedHomeserver -> getString(R.string.qr_code_login_header_failed_homeserver_is_not_supported_description) + RendezvousFailureReason.Expired -> getString(R.string.qr_code_login_header_failed_timeout_description) + RendezvousFailureReason.UserDeclined -> getString(R.string.qr_code_login_header_failed_denied_description) + RendezvousFailureReason.E2EESecurityIssue -> getString(R.string.qr_code_login_header_failed_e2ee_security_issue_description) + RendezvousFailureReason.OtherDeviceAlreadySignedIn -> getString(R.string.qr_code_login_header_failed_other_device_already_signed_in_description) + RendezvousFailureReason.OtherDeviceNotSignedIn -> getString(R.string.qr_code_login_header_failed_other_device_not_signed_in_description) + RendezvousFailureReason.InvalidCode -> getString(R.string.qr_code_login_header_failed_invalid_qr_code_description) + RendezvousFailureReason.UserCancelled -> getString(R.string.qr_code_login_header_failed_user_cancelled_description) + else -> getString(R.string.qr_code_login_header_failed_other_description) } } diff --git a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginViewEvents.kt b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginViewEvents.kt index dc258408e7..e20ea6b2e8 100644 --- a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginViewEvents.kt @@ -21,4 +21,6 @@ import im.vector.app.core.platform.VectorViewEvents sealed class QrCodeLoginViewEvents : VectorViewEvents { object NavigateToStatusScreen : QrCodeLoginViewEvents() object NavigateToShowQrCodeScreen : QrCodeLoginViewEvents() + object NavigateToHomeScreen : QrCodeLoginViewEvents() + object NavigateToInitialScreen : QrCodeLoginViewEvents() } diff --git a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginViewModel.kt b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginViewModel.kt index e979ffa63c..97cca9d791 100644 --- a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginViewModel.kt @@ -20,13 +20,24 @@ import com.airbnb.mvrx.MavericksViewModelFactory import dagger.assisted.Assisted import dagger.assisted.AssistedFactory 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.core.platform.VectorViewModel +import im.vector.app.core.session.ConfigureAndStartSessionUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.auth.AuthenticationService +import org.matrix.android.sdk.api.rendezvous.Rendezvous +import org.matrix.android.sdk.api.rendezvous.RendezvousFailureReason +import org.matrix.android.sdk.api.rendezvous.model.RendezvousError import timber.log.Timber class QrCodeLoginViewModel @AssistedInject constructor( @Assisted private val initialState: QrCodeLoginViewState, + private val authenticationService: AuthenticationService, + private val activeSessionHolder: ActiveSessionHolder, + private val configureAndStartSessionUseCase: ConfigureAndStartSessionUseCase, ) : VectorViewModel(initialState) { @AssistedFactory @@ -34,16 +45,28 @@ class QrCodeLoginViewModel @AssistedInject constructor( override fun create(initialState: QrCodeLoginViewState): QrCodeLoginViewModel } - companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() { + val TAG: String = QrCodeLoginViewModel::class.java.simpleName + } override fun handle(action: QrCodeLoginAction) { when (action) { is QrCodeLoginAction.OnQrCodeScanned -> handleOnQrCodeScanned(action) QrCodeLoginAction.GenerateQrCode -> handleQrCodeViewStarted() QrCodeLoginAction.ShowQrCode -> handleShowQrCode() + QrCodeLoginAction.TryAgain -> handleTryAgain() } } + private fun handleTryAgain() { + setState { + copy( + connectionStatus = null + ) + } + _viewEvents.post(QrCodeLoginViewEvents.NavigateToInitialScreen) + } + private fun handleShowQrCode() { _viewEvents.post(QrCodeLoginViewEvents.NavigateToShowQrCodeScreen) } @@ -58,20 +81,60 @@ class QrCodeLoginViewModel @AssistedInject constructor( } private fun handleOnQrCodeScanned(action: QrCodeLoginAction.OnQrCodeScanned) { - if (isValidQrCode(action.qrCode)) { - setState { - copy( - connectionStatus = QrCodeLoginConnectionStatus.ConnectingToDevice - ) + Timber.tag(TAG).d("Scanned code of length ${action.qrCode.length}") + + val rendezvous = try { Rendezvous.buildChannelFromCode(action.qrCode) } catch (t: Throwable) { + Timber.tag(TAG).e(t, "Error occurred during sign in") + if (t is RendezvousError) { + onFailed(t.reason) + } else { + onFailed(RendezvousFailureReason.Unknown) + } + return + } + + setState { + copy( + connectionStatus = QrCodeLoginConnectionStatus.ConnectingToDevice + ) + } + + _viewEvents.post(QrCodeLoginViewEvents.NavigateToStatusScreen) + + viewModelScope.launch(Dispatchers.IO) { + try { + val confirmationCode = rendezvous.startAfterScanningCode() + Timber.tag(TAG).i("Established secure channel with checksum: $confirmationCode") + + onConnectionEstablished(confirmationCode) + + val session = rendezvous.waitForLoginOnNewDevice(authenticationService) + onSigningIn() + + activeSessionHolder.setActiveSession(session) + authenticationService.reset() + configureAndStartSessionUseCase.execute(session) + + rendezvous.completeVerificationOnNewDevice(session) + + _viewEvents.post(QrCodeLoginViewEvents.NavigateToHomeScreen) + } catch (t: Throwable) { + Timber.tag(TAG).e(t, "Error occurred during sign in") + if (t is RendezvousError) { + onFailed(t.reason) + } else { + onFailed(RendezvousFailureReason.Unknown) + } } - _viewEvents.post(QrCodeLoginViewEvents.NavigateToStatusScreen) } } - private fun onFailed(errorType: QrCodeLoginErrorType, canTryAgain: Boolean) { + private fun onFailed(reason: RendezvousFailureReason) { + _viewEvents.post(QrCodeLoginViewEvents.NavigateToStatusScreen) + setState { copy( - connectionStatus = QrCodeLoginConnectionStatus.Failed(errorType, canTryAgain) + connectionStatus = QrCodeLoginConnectionStatus.Failed(reason, reason.canRetry) ) } } @@ -93,14 +156,11 @@ class QrCodeLoginViewModel @AssistedInject constructor( } } - // TODO. Implement in the logic related PR. - private fun isValidQrCode(qrCode: String): Boolean { - Timber.d("isValidQrCode: $qrCode") - return false - } - - // TODO. Implement in the logic related PR. + /** + * QR code generation is not currently supported and this is a placeholder for future + * functionality. + */ private fun generateQrCodeData(): String { - return "TODO" + return "NOT SUPPORTED" } } diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt index 4ee7da4b64..ba1d5c7f6f 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt @@ -38,7 +38,7 @@ import org.matrix.android.sdk.api.session.events.model.supportsNotification 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.getRoomSummary -import org.matrix.android.sdk.api.session.getUser +import org.matrix.android.sdk.api.session.getUserOrDefault import org.matrix.android.sdk.api.session.room.getTimelineEvent import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomMemberContent @@ -112,7 +112,7 @@ class NotifiableEventResolver @Inject constructor( val notificationAction = actions.toNotificationAction() return if (notificationAction.shouldNotify) { - val user = session.getUser(event.senderId!!) ?: return null + val user = session.getUserOrDefault(event.senderId!!) val timelineEvent = TimelineEvent( root = event, diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt index 5f43ff6b90..2623045cf3 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt @@ -27,7 +27,7 @@ import im.vector.app.features.displayname.getBestName import im.vector.app.features.settings.VectorPreferences import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.content.ContentUrlResolver -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 timber.log.Timber import javax.inject.Inject @@ -186,11 +186,11 @@ class NotificationDrawerManager @Inject constructor( } private fun renderEvents(session: Session, eventsToRender: List>) { - val user = session.getUser(session.myUserId) + val user = session.getUserOrDefault(session.myUserId) // myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash - val myUserDisplayName = user?.toMatrixItem()?.getBestName() ?: session.myUserId + val myUserDisplayName = user.toMatrixItem().getBestName() val myUserAvatarUrl = session.contentUrlResolver().resolveThumbnail( - contentUrl = user?.avatarUrl, + contentUrl = user.avatarUrl, width = avatarSize, height = avatarSize, method = ContentUrlResolver.ThumbnailMethod.SCALE diff --git a/vector/src/main/java/im/vector/app/features/roommemberprofile/devices/DeviceListBottomSheetViewModel.kt b/vector/src/main/java/im/vector/app/features/roommemberprofile/devices/DeviceListBottomSheetViewModel.kt index 92687e1a37..eb23c5654e 100644 --- a/vector/src/main/java/im/vector/app/features/roommemberprofile/devices/DeviceListBottomSheetViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/roommemberprofile/devices/DeviceListBottomSheetViewModel.kt @@ -29,11 +29,13 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.SingletonEntryPoint import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.platform.VectorViewModel +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.crosssigning.MXCrossSigningInfo import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo import org.matrix.android.sdk.api.session.crypto.verification.VerificationMethod -import org.matrix.android.sdk.api.session.getUser +import org.matrix.android.sdk.api.session.getUserOrDefault import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.flow.flow @@ -42,7 +44,6 @@ data class DeviceListViewState( val userId: String, val allowDeviceAction: Boolean, val userItem: MatrixItem? = null, - val isMine: Boolean = false, val memberCrossSigningKey: MXCrossSigningInfo? = null, val cryptoDevices: Async> = Loading(), val selectedDevice: CryptoDeviceInfo? = null @@ -61,23 +62,19 @@ class DeviceListBottomSheetViewModel @AssistedInject constructor( companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() { - override fun initialState(viewModelContext: ViewModelContext): DeviceListViewState? { + override fun initialState(viewModelContext: ViewModelContext): DeviceListViewState { val args = viewModelContext.args() val userId = args.userId val session = EntryPoints.get(viewModelContext.app(), SingletonEntryPoint::class.java).activeSessionHolder().getActiveSession() - return session.getUser(userId)?.toMatrixItem()?.let { - DeviceListViewState( - userId = userId, - allowDeviceAction = args.allowDeviceAction, - userItem = it, - isMine = userId == session.myUserId - ) - } ?: return super.initialState(viewModelContext) + return DeviceListViewState( + userId = userId, + allowDeviceAction = args.allowDeviceAction, + userItem = session.getUserOrDefault(userId).toMatrixItem(), + ) } } init { - session.flow().liveUserCryptoDevices(initialState.userId) .execute { copy(cryptoDevices = it).also { @@ -89,6 +86,16 @@ class DeviceListBottomSheetViewModel @AssistedInject constructor( .execute { copy(memberCrossSigningKey = it.invoke()?.getOrNull()) } + + updateMatrixItem() + } + + private fun updateMatrixItem() { + viewModelScope.launch { + tryOrNull { session.userService().resolveUser(initialState.userId) } + ?.toMatrixItem() + ?.let { setState { copy(userItem = it) } } + } } override fun handle(action: DeviceListAction) { diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt index 89fcda142a..2dc8b12160 100755 --- a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt @@ -74,6 +74,7 @@ class VectorPreferences @Inject constructor( const val SETTINGS_LABS_RICH_TEXT_EDITOR_KEY = "SETTINGS_LABS_RICH_TEXT_EDITOR_KEY" const val SETTINGS_LABS_NEW_SESSION_MANAGER_KEY = "SETTINGS_LABS_NEW_SESSION_MANAGER_KEY" const val SETTINGS_LABS_CLIENT_INFO_RECORDING_KEY = "SETTINGS_LABS_CLIENT_INFO_RECORDING_KEY" + const val SETTINGS_LABS_VOICE_BROADCAST_KEY = "SETTINGS_LABS_VOICE_BROADCAST_KEY" const val SETTINGS_CRYPTOGRAPHY_PREFERENCE_KEY = "SETTINGS_CRYPTOGRAPHY_PREFERENCE_KEY" const val SETTINGS_CRYPTOGRAPHY_DIVIDER_PREFERENCE_KEY = "SETTINGS_CRYPTOGRAPHY_DIVIDER_PREFERENCE_KEY" const val SETTINGS_CRYPTOGRAPHY_MANAGE_PREFERENCE_KEY = "SETTINGS_CRYPTOGRAPHY_MANAGE_PREFERENCE_KEY" @@ -1203,4 +1204,9 @@ class VectorPreferences @Inject constructor( fun isRichTextEditorEnabled(): Boolean { return defaultPrefs.getBoolean(SETTINGS_LABS_RICH_TEXT_EDITOR_KEY, getDefault(R.bool.settings_labs_rich_text_editor_default)) } + + fun isVoiceBroadcastEnabled(): Boolean { + return vectorFeatures.isVoiceBroadcastEnabled() && + defaultPrefs.getBoolean(SETTINGS_LABS_VOICE_BROADCAST_KEY, getDefault(R.bool.settings_labs_enable_voice_broadcast_default)) + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/labs/VectorSettingsLabsFragment.kt b/vector/src/main/java/im/vector/app/features/settings/labs/VectorSettingsLabsFragment.kt index 6c31e32567..c10411301f 100644 --- a/vector/src/main/java/im/vector/app/features/settings/labs/VectorSettingsLabsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/labs/VectorSettingsLabsFragment.kt @@ -16,6 +16,7 @@ package im.vector.app.features.settings.labs +import android.os.Build import android.os.Bundle import android.text.method.LinkMovementMethod import android.widget.TextView @@ -90,6 +91,11 @@ class VectorSettingsLabsFragment : } } + findPreference(VectorPreferences.SETTINGS_LABS_VOICE_BROADCAST_KEY)?.let { pref -> + // Voice Broadcast recording is not available on Android < 10 + pref.isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && vectorFeatures.isVoiceBroadcastEnabled() + } + configureUnreadNotificationsAsTabPreference() configureEnableClientInfoRecordingPreference() } diff --git a/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutViewModel.kt b/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutViewModel.kt index f3e2f82edc..117c298878 100644 --- a/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutViewModel.kt @@ -32,6 +32,7 @@ import im.vector.app.core.di.SingletonEntryPoint import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.extensions.hasUnsavedKeys import im.vector.app.core.platform.VectorViewModel +import im.vector.app.features.displayname.getBestName import im.vector.app.features.login.LoginMode import im.vector.app.features.login.toSsoState import kotlinx.coroutines.launch @@ -39,7 +40,8 @@ import org.matrix.android.sdk.api.auth.AuthenticationService import org.matrix.android.sdk.api.auth.LoginType import org.matrix.android.sdk.api.auth.data.LoginFlowTypes import org.matrix.android.sdk.api.session.Session -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 timber.log.Timber class SoftLogoutViewModel @AssistedInject constructor( @@ -68,7 +70,7 @@ class SoftLogoutViewModel @AssistedInject constructor( homeServerUrl = session.sessionParams.homeServerUrl, userId = userId, deviceId = session.sessionParams.deviceId.orEmpty(), - userDisplayName = session.getUser(userId)?.displayName ?: userId, + userDisplayName = session.getUserOrDefault(userId).toMatrixItem().getBestName(), hasUnsavedKeys = session.hasUnsavedKeys(), loginType = session.sessionParams.loginType, ) diff --git a/vector/src/main/java/im/vector/app/features/spaces/invite/SpaceInviteBottomSheetViewModel.kt b/vector/src/main/java/im/vector/app/features/spaces/invite/SpaceInviteBottomSheetViewModel.kt index ea36908dd2..27f194e8d2 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/invite/SpaceInviteBottomSheetViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/invite/SpaceInviteBottomSheetViewModel.kt @@ -34,7 +34,7 @@ import kotlinx.coroutines.launch import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.getRoomSummary -import org.matrix.android.sdk.api.session.getUser +import org.matrix.android.sdk.api.session.getUserOrDefault import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.peeking.PeekResult @@ -49,7 +49,7 @@ class SpaceInviteBottomSheetViewModel @AssistedInject constructor( session.getRoomSummary(initialState.spaceId)?.let { roomSummary -> val knownMembers = roomSummary.otherMemberIds.filter { session.roomService().getExistingDirectRoomWithUser(it) != null - }.mapNotNull { session.getUser(it) } + }.map { session.getUserOrDefault(it) } // put one with avatar first, and take 5 val peopleYouKnow = (knownMembers.filter { it.avatarUrl != null } + knownMembers.filter { it.avatarUrl == null }) .take(5) @@ -57,7 +57,7 @@ class SpaceInviteBottomSheetViewModel @AssistedInject constructor( setState { copy( summary = Success(roomSummary), - inviterUser = roomSummary.inviterId?.let { session.getUser(it) }?.let { Success(it) } ?: Uninitialized, + inviterUser = roomSummary.inviterId?.let { session.getUserOrDefault(it) }?.let { Success(it) } ?: Uninitialized, peopleYouKnow = Success(peopleYouKnow) ) } diff --git a/vector/src/main/java/im/vector/app/features/usercode/UserCodeSharedViewModel.kt b/vector/src/main/java/im/vector/app/features/usercode/UserCodeSharedViewModel.kt index 8c377cafd5..e76837f182 100644 --- a/vector/src/main/java/im/vector/app/features/usercode/UserCodeSharedViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/usercode/UserCodeSharedViewModel.kt @@ -30,7 +30,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.session.getUser +import org.matrix.android.sdk.api.session.getUserOrDefault import org.matrix.android.sdk.api.session.permalinks.PermalinkData import org.matrix.android.sdk.api.session.permalinks.PermalinkParser import org.matrix.android.sdk.api.session.user.model.User @@ -46,10 +46,10 @@ class UserCodeSharedViewModel @AssistedInject constructor( companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() init { - val user = session.getUser(initialState.userId) + val user = session.getUserOrDefault(initialState.userId) setState { copy( - matrixItem = user?.toMatrixItem(), + matrixItem = user.toMatrixItem(), shareLink = session.permalinkService().createPermalink(initialState.userId) ) } 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 3a9aac12d5..551eaa4dac 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 @@ -16,11 +16,16 @@ package im.vector.app.features.voicebroadcast +import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent + object VoiceBroadcastConstants { /** Voice Broadcast State Event. */ const val STATE_ROOM_VOICE_BROADCAST_INFO = "io.element.voice_broadcast_info" + /** Custom key passed to the [MessageAudioContent] with Voice Broadcast information. */ + const val VOICE_BROADCAST_CHUNK_KEY = "io.element.voice_broadcast_chunk" + /** Default voice broadcast chunk duration, in seconds. */ - const val DEFAULT_CHUNK_LENGTH_IN_SECONDS = 30 + const val DEFAULT_CHUNK_LENGTH_IN_SECONDS = 120 } 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 new file mode 100644 index 0000000000..f9da2e76b1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastExtensions.kt @@ -0,0 +1,34 @@ +/* + * 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 im.vector.app.features.voicebroadcast.model.VoiceBroadcastChunk +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 +import org.matrix.android.sdk.api.session.room.model.message.MessageAudioEvent + +fun MessageAudioEvent?.isVoiceBroadcast() = this?.root?.getClearContent()?.get(VoiceBroadcastConstants.VOICE_BROADCAST_CHUNK_KEY) != null + +fun MessageAudioEvent.getVoiceBroadcastEventId(): String? = if (isVoiceBroadcast()) root.getRelationContent()?.eventId else null + +fun MessageAudioEvent.getVoiceBroadcastChunk(): VoiceBroadcastChunk? { + @Suppress("UNCHECKED_CAST") + return (root.getClearContent()?.get(VoiceBroadcastConstants.VOICE_BROADCAST_CHUNK_KEY) as? Content).toModel() +} + +val MessageAudioEvent.sequence: Int? get() = getVoiceBroadcastChunk()?.sequence 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 f682cd2f5e..58e7de7f32 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 @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 New Vector Ltd + * Copyright (c) 2022 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,6 +30,7 @@ class VoiceBroadcastHelper @Inject constructor( private val pauseVoiceBroadcastUseCase: PauseVoiceBroadcastUseCase, private val resumeVoiceBroadcastUseCase: ResumeVoiceBroadcastUseCase, private val stopVoiceBroadcastUseCase: StopVoiceBroadcastUseCase, + private val voiceBroadcastPlayer: VoiceBroadcastPlayer, ) { suspend fun startVoiceBroadcast(roomId: String) = startVoiceBroadcastUseCase.execute(roomId) @@ -38,4 +39,10 @@ class VoiceBroadcastHelper @Inject constructor( suspend fun resumeVoiceBroadcast(roomId: String) = resumeVoiceBroadcastUseCase.execute(roomId) suspend fun stopVoiceBroadcast(roomId: String) = stopVoiceBroadcastUseCase.execute(roomId) + + fun playOrResumePlayback(roomId: String, eventId: String) = voiceBroadcastPlayer.playOrResume(roomId, eventId) + + fun pausePlayback() = voiceBroadcastPlayer.pause() + + fun stopPlayback() = voiceBroadcastPlayer.stop() } 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 new file mode 100644 index 0000000000..2c892c8306 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt @@ -0,0 +1,338 @@ +/* + * 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/VoiceBroadcastRecorder.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorder.kt index 2668501a8d..8b69051823 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorder.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorder.kt @@ -16,16 +16,27 @@ package im.vector.app.features.voicebroadcast +import androidx.annotation.IntRange import im.vector.app.features.voice.VoiceRecorder import java.io.File interface VoiceBroadcastRecorder : VoiceRecorder { - var listener: Listener? + val currentSequence: Int + val state: State fun startRecord(roomId: String, chunkLength: Int) + fun addListener(listener: Listener) + fun removeListener(listener: Listener) - fun interface Listener { - fun onVoiceMessageCreated(file: File) + interface Listener { + fun onVoiceMessageCreated(file: File, @IntRange(from = 1) sequence: Int) = Unit + fun onStateUpdated(state: State) = Unit + } + + enum class State { + Recording, + Paused, + Idle, } } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorderQ.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorderQ.kt index 620db721c9..5285dc5e3b 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorderQ.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorderQ.kt @@ -21,7 +21,9 @@ import android.media.MediaRecorder import android.os.Build import androidx.annotation.RequiresApi import im.vector.app.features.voice.AbstractVoiceRecorderQ +import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.content.ContentAttachmentData +import java.util.concurrent.CopyOnWriteArrayList @RequiresApi(Build.VERSION_CODES.Q) class VoiceBroadcastRecorderQ( @@ -29,8 +31,15 @@ class VoiceBroadcastRecorderQ( ) : AbstractVoiceRecorderQ(context), VoiceBroadcastRecorder { private var maxFileSize = 0L // zero or negative for no limit + private var currentRoomId: String? = null + override var currentSequence = 0 + override var state = VoiceBroadcastRecorder.State.Idle + set(value) { + field = value + listeners.forEach { it.onStateUpdated(value) } + } - override var listener: VoiceBroadcastRecorder.Listener? = null + private val listeners = CopyOnWriteArrayList() override val outputFormat = MediaRecorder.OutputFormat.MPEG_4 override val audioEncoder = MediaRecorder.AudioEncoder.HE_AAC @@ -50,14 +59,32 @@ class VoiceBroadcastRecorderQ( } override fun startRecord(roomId: String, chunkLength: Int) { + currentRoomId = roomId maxFileSize = (chunkLength * audioEncodingBitRate / 8).toLong() + currentSequence = 1 startRecord(roomId) + state = VoiceBroadcastRecorder.State.Recording + } + + override fun pauseRecord() { + tryOrNull { mediaRecorder?.stop() } + mediaRecorder?.reset() + notifyOutputFileCreated() + state = VoiceBroadcastRecorder.State.Paused + } + + override fun resumeRecord() { + currentSequence++ + currentRoomId?.let { startRecord(it) } + state = VoiceBroadcastRecorder.State.Recording } override fun stopRecord() { super.stopRecord() notifyOutputFileCreated() - listener = null + listeners.clear() + currentSequence = 0 + state = VoiceBroadcastRecorder.State.Idle } override fun release() { @@ -65,17 +92,27 @@ class VoiceBroadcastRecorderQ( super.release() } + override fun addListener(listener: VoiceBroadcastRecorder.Listener) { + listeners.add(listener) + listener.onStateUpdated(state) + } + + override fun removeListener(listener: VoiceBroadcastRecorder.Listener) { + listeners.remove(listener) + } + private fun onMaxFileSizeApproaching(roomId: String) { setNextOutputFile(roomId) } private fun onNextOutputFileStarted() { notifyOutputFileCreated() + currentSequence++ } private fun notifyOutputFileCreated() { - outputFile?.let { - listener?.onVoiceMessageCreated(it) + outputFile?.let { file -> + listeners.forEach { it.onVoiceMessageCreated(file, currentSequence) } outputFile = nextOutputFile nextOutputFile = null } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/model/MessageVoiceBroadcastInfoContent.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/MessageVoiceBroadcastInfoContent.kt index 5044bb5c34..d882d4049e 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/model/MessageVoiceBroadcastInfoContent.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/MessageVoiceBroadcastInfoContent.kt @@ -38,10 +38,14 @@ data class MessageVoiceBroadcastInfoContent( @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null, @Json(name = "m.new_content") override val newContent: Content? = null, + /** The device from which the broadcast has been started. */ + @Json(name = "device_id") val deviceId: String? = null, /** The [VoiceBroadcastState] value. **/ @Json(name = "state") val voiceBroadcastStateStr: String = "", /** The length of the voice chunks in seconds. **/ @Json(name = "chunk_length") val chunkLength: Int? = null, + /** The sequence of the last sent chunk. **/ + @Json(name = "last_chunk_sequence") val lastChunkSequence: Int? = null, ) : MessageContent { val voiceBroadcastState: VoiceBroadcastState? = VoiceBroadcastState.values() diff --git a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginErrorType.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcastChunk.kt similarity index 70% rename from vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginErrorType.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcastChunk.kt index 9a6cc13de0..e0f6e6e7b1 100644 --- a/vector/src/main/java/im/vector/app/features/login/qr/QrCodeLoginErrorType.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcastChunk.kt @@ -14,10 +14,12 @@ * limitations under the License. */ -package im.vector.app.features.login.qr +package im.vector.app.features.voicebroadcast.model -enum class QrCodeLoginErrorType { - DEVICE_IS_NOT_SUPPORTED, - TIMEOUT, - REQUEST_WAS_DENIED, -} +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class VoiceBroadcastChunk( + @Json(name = "sequence") val sequence: Int? = null +) 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 new file mode 100644 index 0000000000..d08fa14a95 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastUseCase.kt @@ -0,0 +1,40 @@ +/* + * 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/usecase/PauseVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCase.kt index 835a57c102..1430dd8c86 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCase.kt @@ -59,6 +59,7 @@ class PauseVoiceBroadcastUseCase @Inject constructor( body = MessageVoiceBroadcastInfoContent( relatesTo = reference, voiceBroadcastStateStr = VoiceBroadcastState.PAUSED.value, + lastChunkSequence = voiceBroadcastRecorder?.currentSequence, ).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/usecase/StartVoiceBroadcastUseCase.kt index 2a306bcd28..7934d18e36 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt @@ -23,6 +23,7 @@ import im.vector.app.features.attachments.toContentAttachmentData 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.VoiceBroadcastChunk import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import im.vector.lib.multipicker.utils.toMultiPickerAudioType @@ -54,7 +55,7 @@ class StartVoiceBroadcastUseCase @Inject constructor( QueryStringValue.IsNotEmpty ) .mapNotNull { it.asVoiceBroadcastEvent() } - .filter { it.content?.voiceBroadcastState != VoiceBroadcastState.STOPPED } + .filter { it.content?.voiceBroadcastState != null && it.content?.voiceBroadcastState != VoiceBroadcastState.STOPPED } if (onGoingVoiceBroadcastEvents.isEmpty()) { startVoiceBroadcast(room) @@ -70,6 +71,7 @@ class StartVoiceBroadcastUseCase @Inject constructor( eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, stateKey = session.myUserId, body = MessageVoiceBroadcastInfoContent( + deviceId = session.sessionParams.deviceId, voiceBroadcastStateStr = VoiceBroadcastState.STARTED.value, chunkLength = chunkLength, ).toContent() @@ -79,25 +81,30 @@ class StartVoiceBroadcastUseCase @Inject constructor( } private fun startRecording(room: Room, eventId: String, chunkLength: Int) { - voiceBroadcastRecorder?.listener = VoiceBroadcastRecorder.Listener { file -> - sendVoiceFile(room, file, eventId) - } + voiceBroadcastRecorder?.addListener(object : VoiceBroadcastRecorder.Listener { + override fun onVoiceMessageCreated(file: File, sequence: Int) { + sendVoiceFile(room, file, eventId, sequence) + } + }) voiceBroadcastRecorder?.startRecord(room.roomId, chunkLength) } - private fun sendVoiceFile(room: Room, voiceMessageFile: File, referenceEventId: String) { + private fun sendVoiceFile(room: Room, voiceMessageFile: File, referenceEventId: String, sequence: Int) { val outputFileUri = FileProvider.getUriForFile( context, buildMeta.applicationId + ".fileProvider", voiceMessageFile, - "Voice message.${voiceMessageFile.extension}" + "Voice Broadcast Part ($sequence).${voiceMessageFile.extension}" ) val audioType = outputFileUri.toMultiPickerAudioType(context) ?: return room.sendService().sendMedia( attachment = audioType.toContentAttachmentData(isVoiceMessage = true), compressBeforeSending = false, roomIds = emptySet(), - relatesTo = RelationDefaultContent(RelationType.REFERENCE, referenceEventId) + relatesTo = RelationDefaultContent(RelationType.REFERENCE, referenceEventId), + additionalContent = mapOf( + VoiceBroadcastConstants.VOICE_BROADCAST_CHUNK_KEY to VoiceBroadcastChunk(sequence = sequence).toContent() + ) ) } } 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/usecase/StopVoiceBroadcastUseCase.kt index 6eefa06979..bc6a3e7be6 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCase.kt @@ -60,6 +60,7 @@ class StopVoiceBroadcastUseCase @Inject constructor( body = MessageVoiceBroadcastInfoContent( relatesTo = reference, voiceBroadcastStateStr = VoiceBroadcastState.STOPPED.value, + lastChunkSequence = voiceBroadcastRecorder?.currentSequence, ).toContent(), ) diff --git a/vector/src/main/res/drawable/ic_recording_dot.xml b/vector/src/main/res/drawable/ic_recording_dot.xml new file mode 100644 index 0000000000..f5d92f9718 --- /dev/null +++ b/vector/src/main/res/drawable/ic_recording_dot.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_stop.xml b/vector/src/main/res/drawable/ic_stop.xml new file mode 100644 index 0000000000..459a7cfce2 --- /dev/null +++ b/vector/src/main/res/drawable/ic_stop.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_16.xml new file mode 100644 index 0000000000..7d427a56d0 --- /dev/null +++ b/vector/src/main/res/drawable/ic_voice_broadcast_16.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/vector/src/main/res/drawable/rounded_rect_shape_2.xml b/vector/src/main/res/drawable/rounded_rect_shape_2.xml new file mode 100644 index 0000000000..977de2fd09 --- /dev/null +++ b/vector/src/main/res/drawable/rounded_rect_shape_2.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/vector/src/main/res/layout/item_timeline_event_view_stubs_container.xml b/vector/src/main/res/layout/item_timeline_event_view_stubs_container.xml index 6fcf5711f7..643f4a89c8 100644 --- a/vector/src/main/res/layout/item_timeline_event_view_stubs_container.xml +++ b/vector/src/main/res/layout/item_timeline_event_view_stubs_container.xml @@ -48,10 +48,17 @@ tools:visibility="gone" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000000..e3bb85138d --- /dev/null +++ b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_recording_stub.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_stub.xml b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_stub.xml deleted file mode 100644 index e35060f72a..0000000000 --- a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_stub.xml +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - diff --git a/vector/src/main/res/xml/vector_settings_labs.xml b/vector/src/main/res/xml/vector_settings_labs.xml index 5b519bdd91..15a255753a 100644 --- a/vector/src/main/res/xml/vector_settings_labs.xml +++ b/vector/src/main/res/xml/vector_settings_labs.xml @@ -117,4 +117,11 @@ android:title="@string/labs_enable_client_info_recording_title" app:isPreferenceVisible="@bool/settings_labs_client_info_recording_visible" /> + + diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeSendService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeSendService.kt index 04b9b95ce1..425a485561 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeSendService.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeSendService.kt @@ -17,6 +17,7 @@ package im.vector.app.test.fakes import io.mockk.mockk +import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.room.model.message.PollType import org.matrix.android.sdk.api.session.room.send.SendService import org.matrix.android.sdk.api.util.Cancelable @@ -25,5 +26,5 @@ class FakeSendService : SendService by mockk() { private val cancelable = mockk() - override fun sendPoll(pollType: PollType, question: String, options: List): Cancelable = cancelable + override fun sendPoll(pollType: PollType, question: String, options: List, additionalContent: Content?): Cancelable = cancelable }