diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 8a892b9b15..5698a696b6 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -11,7 +11,7 @@ jobs: - run: | npm install --save-dev @babel/plugin-transform-flow-strip-types - name: Danger - uses: danger/danger-js@11.1.2 + uses: danger/danger-js@11.1.3 with: args: "--dangerfile tools/danger/dangerfile.js" env: diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index da70d13a86..1692e2e281 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -66,7 +66,7 @@ jobs: yarn add danger-plugin-lint-report --dev - name: Danger lint if: always() - uses: danger/danger-js@11.1.2 + uses: danger/danger-js@11.1.3 with: args: "--dangerfile tools/danger/dangerfile-lint.js" env: diff --git a/CHANGES.md b/CHANGES.md index 009c2b2af5..d1e4834988 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,59 @@ +Changes in Element v1.5.2 (2022-10-05) +====================================== + +Features ✨ +---------- + - New App Layout is now enabled by default! Go to the Settings > Labs to toggle this ([#7166](https://github.com/vector-im/element-android/issues/7166)) + - Render inline images in the timeline ([#351](https://github.com/vector-im/element-android/issues/351)) + - Add privacy setting to disable personalized learning by the keyboard ([#6633](https://github.com/vector-im/element-android/issues/6633)) + +Bugfixes 🐛 +---------- + - Disable emoji keyboard not applies in reply ([#5029](https://github.com/vector-im/element-android/issues/5029)) + - Fix animated images not autoplaying sometimes if only a thumbnail was fetched from the server ([#6215](https://github.com/vector-im/element-android/issues/6215)) + - Add Warning shield when a user previously verified rotated their cross signing keys ([#6702](https://github.com/vector-im/element-android/issues/6702)) + - Can't verify user when option to send keys to verified devices only is selected ([#6723](https://github.com/vector-im/element-android/issues/6723)) + - Add option to only send to verified devices per room (web parity) ([#6725](https://github.com/vector-im/element-android/issues/6725)) + - Delete pin code key and the key used for biometrics authentication on logout ([#6906](https://github.com/vector-im/element-android/issues/6906)) + - Fix crash on previewing images to upload on Android Pie. ([#7184](https://github.com/vector-im/element-android/issues/7184)) + - Fix app restarts in loop on Android 13 on the first run of the app. ([#7224](https://github.com/vector-im/element-android/issues/7224)) + +In development 🚧 +---------------- + - [Device Management] Learn more bottom sheets ([#7100](https://github.com/vector-im/element-android/issues/7100)) + - [Device management] Verify current session ([#7114](https://github.com/vector-im/element-android/issues/7114)) + - [Device management] Verify another session ([#7143](https://github.com/vector-im/element-android/issues/7143)) + - [Device management] Rename a session ([#7158](https://github.com/vector-im/element-android/issues/7158)) + - [Device Manager] Unverified and inactive sessions list ([#7170](https://github.com/vector-im/element-android/issues/7170)) + - [Device management] Sign out a session ([#7190](https://github.com/vector-im/element-android/issues/7190)) + - [Device Manager] Parse user agents ([#7247](https://github.com/vector-im/element-android/issues/7247)) + - [Voice Broadcast] Add a feature flag with the composer action ([#7258](https://github.com/vector-im/element-android/issues/7258)) + +Improved Documentation 📚 +------------------------ + - Draft onboarding documentation of the project at `./docs/_developer_onboarding.md` ([#7126](https://github.com/vector-im/element-android/issues/7126)) + +SDK API changes ⚠️ +------------------ + - Allow the sync timeout to be configured (mainly useful for testing) ([#7198](https://github.com/vector-im/element-android/issues/7198)) + - Ports SDK instrumentation tests to use suspending functions instead of countdown latches ([#7207](https://github.com/vector-im/element-android/issues/7207)) + - [Device Manager] Extend user agent to include device information ([#7209](https://github.com/vector-im/element-android/issues/7209)) + +Other changes +------------- + - Add support for `/tableflip` command ([#12](https://github.com/vector-im/element-android/issues/12)) + - Decreases the size of rounded corners and increases the maximum width of message bubbles to help avoid unnecessary unused space on screen ([#5712](https://github.com/vector-im/element-android/issues/5712)) + - Adds screenshot testing tooling ([#5798](https://github.com/vector-im/element-android/issues/5798)) + - [AppLayout]: added tracking of new analytics events ([#6508](https://github.com/vector-im/element-android/issues/6508)) + - Target API 12 and compile with Android SDK 32. ([#6929](https://github.com/vector-im/element-android/issues/6929)) + - Add basic integration of Sentry to capture errors and crashes if user has given consent. ([#7076](https://github.com/vector-im/element-android/issues/7076)) + - Add support to `/devtools` command. ([#7126](https://github.com/vector-im/element-android/issues/7126)) + - Fix lint warning, and cleanup the code ([#7159](https://github.com/vector-im/element-android/issues/7159)) + - Mutualize the pending auth handling ([#7193](https://github.com/vector-im/element-android/issues/7193)) + - CI: Prevent modification of translations by developer. ([#7211](https://github.com/vector-im/element-android/issues/7211)) + - Fix typo in strings.xml and make sure this is American English. ([#7287](https://github.com/vector-im/element-android/issues/7287)) + + Changes in Element v1.5.1 (2022-09-28) ====================================== diff --git a/build.gradle b/build.gradle index e7f7d00159..d38d430b25 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.2" - classpath 'org.owasp:dependency-check-gradle:7.2.0' + classpath 'org.owasp:dependency-check-gradle:7.2.1' classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.7.10" classpath "org.jetbrains.kotlinx:kotlinx-knit:0.4.0" classpath 'com.jakewharton:butterknife-gradle-plugin:10.2.3' @@ -44,6 +44,8 @@ plugins { id "org.jlleitschuh.gradle.ktlint" version "11.0.0" // Detekt id "io.gitlab.arturbosch.detekt" version "1.21.0" + // Ksp + id "com.google.devtools.ksp" version "1.7.20-1.0.6" // Dependency Analysis id 'com.autonomousapps.dependency-analysis' version "1.13.1" @@ -146,6 +148,9 @@ allprojects { // To have XML report for Danger reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE) } + filter { + exclude { element -> element.file.path.contains("$buildDir/generated/") } + } disabledRules = [ // TODO Re-enable these 4 rules after reformatting project "indent", @@ -327,3 +332,31 @@ ext.initScreenshotTests = { project -> } } } + +// Workaround to have KSP generated Kotlin code available in the IDE (for code completion) +// Ref: https://github.com/airbnb/epoxy/releases/tag/5.0.0beta02 +subprojects { project -> + afterEvaluate { + if (project.hasProperty("android")) { + android { + if (it instanceof com.android.build.gradle.LibraryExtension) { + libraryVariants.all { variant -> + def outputFolder = new File("build/generated/ksp/${variant.name}/kotlin") + variant.addJavaSourceFoldersToModel(outputFolder) + android.sourceSets.getAt(variant.name).java { + srcDir(outputFolder) + } + } + } else if (it instanceof com.android.build.gradle.AppExtension) { + applicationVariants.all { variant -> + def outputFolder = new File("build/generated/ksp/${variant.name}/kotlin") + variant.addJavaSourceFoldersToModel(outputFolder) + android.sourceSets.getAt(variant.name).java { + srcDir(outputFolder) + } + } + } + } + } + } +} diff --git a/changelog.d/12.misc b/changelog.d/12.misc deleted file mode 100644 index 392d7b1122..0000000000 --- a/changelog.d/12.misc +++ /dev/null @@ -1 +0,0 @@ -Add support for `/tableflip` command \ No newline at end of file diff --git a/changelog.d/5798.misc b/changelog.d/5798.misc deleted file mode 100644 index 40185eac0d..0000000000 --- a/changelog.d/5798.misc +++ /dev/null @@ -1 +0,0 @@ -Adds screenshot testing tooling diff --git a/changelog.d/6702.bugfix b/changelog.d/6702.bugfix deleted file mode 100644 index a1d646cf71..0000000000 --- a/changelog.d/6702.bugfix +++ /dev/null @@ -1 +0,0 @@ -Add Warning shield when a user previously verified rotated their cross signing keys diff --git a/changelog.d/6929.misc b/changelog.d/6929.misc deleted file mode 100644 index d12167cfea..0000000000 --- a/changelog.d/6929.misc +++ /dev/null @@ -1 +0,0 @@ -Target API 12 and compile with Android SDK 32. diff --git a/changelog.d/7114.wip b/changelog.d/7114.wip deleted file mode 100644 index 79ad705132..0000000000 --- a/changelog.d/7114.wip +++ /dev/null @@ -1 +0,0 @@ -[Device management] Verify current session diff --git a/changelog.d/7126.doc b/changelog.d/7126.doc deleted file mode 100644 index 9c69350a11..0000000000 --- a/changelog.d/7126.doc +++ /dev/null @@ -1 +0,0 @@ -Draft onboarding documentation of the project at `./docs/_developer_onboarding.md` diff --git a/changelog.d/7126.misc b/changelog.d/7126.misc deleted file mode 100644 index a79d61f819..0000000000 --- a/changelog.d/7126.misc +++ /dev/null @@ -1 +0,0 @@ -Add support to `/devtools` command. diff --git a/changelog.d/7143.wip b/changelog.d/7143.wip deleted file mode 100644 index 588f7fb255..0000000000 --- a/changelog.d/7143.wip +++ /dev/null @@ -1 +0,0 @@ -[Device management] Verify another session diff --git a/changelog.d/7158.wip b/changelog.d/7158.wip deleted file mode 100644 index 6c303281d8..0000000000 --- a/changelog.d/7158.wip +++ /dev/null @@ -1 +0,0 @@ -[Device management] Rename a session diff --git a/changelog.d/7159.misc b/changelog.d/7159.misc deleted file mode 100644 index 76f5f45c40..0000000000 --- a/changelog.d/7159.misc +++ /dev/null @@ -1 +0,0 @@ -Fix lint warning, and cleanup the code diff --git a/changelog.d/7166.misc b/changelog.d/7166.misc deleted file mode 100644 index d223208853..0000000000 --- a/changelog.d/7166.misc +++ /dev/null @@ -1 +0,0 @@ -New App Layout is now enabled by default! Go to the Settings > Labs to toggle this diff --git a/changelog.d/7170.wip b/changelog.d/7170.wip deleted file mode 100644 index f5b71a14f8..0000000000 --- a/changelog.d/7170.wip +++ /dev/null @@ -1 +0,0 @@ -[Device Manager] Unverified and inactive sessions list diff --git a/changelog.d/7190.wip b/changelog.d/7190.wip deleted file mode 100644 index 3c70666d91..0000000000 --- a/changelog.d/7190.wip +++ /dev/null @@ -1 +0,0 @@ -[Device management] Sign out a session diff --git a/changelog.d/7193.misc b/changelog.d/7193.misc deleted file mode 100644 index efa0f594ae..0000000000 --- a/changelog.d/7193.misc +++ /dev/null @@ -1 +0,0 @@ -Mutualize the pending auth handling diff --git a/changelog.d/7198.sdk b/changelog.d/7198.sdk deleted file mode 100644 index 115b8d6113..0000000000 --- a/changelog.d/7198.sdk +++ /dev/null @@ -1 +0,0 @@ -Allow the sync timeout to be configured (mainly useful for testing) diff --git a/changelog.d/7207.sdk b/changelog.d/7207.sdk deleted file mode 100644 index 0bc221e9f7..0000000000 --- a/changelog.d/7207.sdk +++ /dev/null @@ -1 +0,0 @@ -Ports SDK instrumentation tests to use suspending functions instead of countdown latches diff --git a/changelog.d/7209.sdk b/changelog.d/7209.sdk deleted file mode 100644 index 6375f5e495..0000000000 --- a/changelog.d/7209.sdk +++ /dev/null @@ -1 +0,0 @@ -[Device Manager] Extend user agent to include device information diff --git a/changelog.d/7211.misc b/changelog.d/7211.misc deleted file mode 100644 index 44abd3d59d..0000000000 --- a/changelog.d/7211.misc +++ /dev/null @@ -1 +0,0 @@ - CI: Prevent modification of translations by developer. diff --git a/changelog.d/7217.wip b/changelog.d/7217.wip new file mode 100644 index 0000000000..a8cc2a3ef3 --- /dev/null +++ b/changelog.d/7217.wip @@ -0,0 +1 @@ +Implements MSC3881: Parses `enabled` and `device_id` fields from updated Pusher API diff --git a/changelog.d/7247.wip b/changelog.d/7247.wip deleted file mode 100644 index 8f2a447742..0000000000 --- a/changelog.d/7247.wip +++ /dev/null @@ -1 +0,0 @@ -[Device Manager] Parse user agents diff --git a/changelog.d/7261.wip b/changelog.d/7261.wip new file mode 100644 index 0000000000..f7063fcc1b --- /dev/null +++ b/changelog.d/7261.wip @@ -0,0 +1 @@ +Adds pusher toggle setting to device manager v2 diff --git a/changelog.d/7273.wip b/changelog.d/7273.wip new file mode 100644 index 0000000000..c480a79a43 --- /dev/null +++ b/changelog.d/7273.wip @@ -0,0 +1 @@ +[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 new file mode 100644 index 0000000000..168d10b809 --- /dev/null +++ b/changelog.d/7277.wip @@ -0,0 +1 @@ +[Device Management] Show correct device type icons diff --git a/changelog.d/7283.wip b/changelog.d/7283.wip new file mode 100644 index 0000000000..f7cbd323f1 --- /dev/null +++ b/changelog.d/7283.wip @@ -0,0 +1 @@ +[Voice Broadcast] Aggregate state events in the timeline diff --git a/changelog.d/7285.misc b/changelog.d/7285.misc new file mode 100644 index 0000000000..ce94383146 --- /dev/null +++ b/changelog.d/7285.misc @@ -0,0 +1 @@ +Refactor TimelineFragment, split it into MessageComposerFragment and VoiceRecorderFragment. diff --git a/changelog.d/7288.feature b/changelog.d/7288.feature new file mode 100644 index 0000000000..be00e26179 --- /dev/null +++ b/changelog.d/7288.feature @@ -0,0 +1 @@ +Add WYSIWYG editor. diff --git a/changelog.d/7288.sdk b/changelog.d/7288.sdk new file mode 100644 index 0000000000..9c4a33ad22 --- /dev/null +++ b/changelog.d/7288.sdk @@ -0,0 +1,10 @@ +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/7310.bugfix b/changelog.d/7310.bugfix new file mode 100644 index 0000000000..3570b2d3ad --- /dev/null +++ b/changelog.d/7310.bugfix @@ -0,0 +1 @@ +[Device Management] Long session names not handled well diff --git a/dependencies.gradle b/dependencies.gradle index f5d64a78d1..59e64ee4dc 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -9,9 +9,9 @@ ext.versions = [ def gradle = "7.2.2" // Ref: https://kotlinlang.org/releases.html -def kotlin = "1.7.10" +def kotlin = "1.7.20" def kotlinCoroutines = "1.6.4" -def dagger = "2.43.2" +def dagger = "2.44" def appDistribution = "16.0.0-beta04" def retrofit = "2.9.0" def arrow = "0.8.2" @@ -19,16 +19,18 @@ def markwon = "4.6.2" def moshi = "1.14.0" def lifecycle = "2.5.1" def flowBinding = "1.2.0" -def flipper = "0.164.0" -def epoxy = "4.6.2" -def mavericks = "2.7.0" -def glide = "4.13.2" +def flipper = "0.169.0" +def epoxy = "5.0.0" +def mavericks = "3.0.1" +def glide = "4.14.2" def bigImageViewer = "1.8.1" def jjwt = "0.11.5" // Temporary version to unblock #6929. Once 0.16.0 is released we should use it, and revert // the whole commit which set version 0.16.0-SNAPSHOT def vanniktechEmoji = "0.16.0-SNAPSHOT" +def sentry = "6.4.3" + def fragment = "1.5.3" // Testing @@ -54,7 +56,7 @@ ext.libs = [ 'biometric' : "androidx.biometric:biometric:1.1.0", 'core' : "androidx.core:core-ktx:1.8.0", 'recyclerview' : "androidx.recyclerview:recyclerview:1.2.1", - 'exifinterface' : "androidx.exifinterface:exifinterface:1.3.3", + 'exifinterface' : "androidx.exifinterface:exifinterface:1.3.4", 'fragmentKtx' : "androidx.fragment:fragment-ktx:$fragment", 'fragmentTesting' : "androidx.fragment:fragment-testing:$fragment", 'constraintLayout' : "androidx.constraintlayout:constraintlayout:2.1.4", @@ -100,6 +102,7 @@ ext.libs = [ ], element : [ 'opusencoder' : "io.element.android:opusencoder:1.1.0", + 'wysiwyg' : "io.element.android:wysiwyg:0.1.0" ], squareup : [ 'moshi' : "com.squareup.moshi:moshi:$moshi", @@ -119,6 +122,7 @@ ext.libs = [ markwon : [ 'core' : "io.noties.markwon:core:$markwon", 'extLatex' : "io.noties.markwon:ext-latex:$markwon", + 'imageGlide' : "io.noties.markwon:image-glide:$markwon", 'inlineParser' : "io.noties.markwon:inline-parser:$markwon", 'html' : "io.noties.markwon:html:$markwon" ], @@ -164,11 +168,13 @@ ext.libs = [ apache : [ 'commonsImaging' : "org.apache.sanselan:sanselan:0.97-incubator" ], + sentry: [ + 'sentryAndroid' : "io.sentry:sentry-android:$sentry" + ], tests : [ 'kluent' : "org.amshove.kluent:kluent-android:1.68", 'timberJunitRule' : "net.lachlanmckee:timber-junit-rule:1.0.1", 'junit' : "junit:junit:4.13.2", - 'robolectric' : "org.robolectric:robolectric:4.8", ] ] diff --git a/dependencies_groups.gradle b/dependencies_groups.gradle index a97d80bc7f..e614bf1329 100644 --- a/dependencies_groups.gradle +++ b/dependencies_groups.gradle @@ -84,6 +84,7 @@ ext.groups = [ 'com.google', 'com.google.android', 'com.google.api.grpc', + 'com.google.auto', 'com.google.auto.service', 'com.google.auto.value', 'com.google.code.findbugs', @@ -101,6 +102,7 @@ ext.groups = [ 'com.googlecode.json-simple', 'com.googlecode.libphonenumber', 'com.ibm.icu', + 'com.intellij', 'com.jakewharton.android.repackaged', 'com.jakewharton.timber', 'com.kgurgul.flipper', @@ -148,6 +150,7 @@ ext.groups = [ 'io.opencensus', 'io.reactivex.rxjava2', 'io.realm', + 'io.sentry', 'it.unimi.dsi', 'jakarta.activation', 'jakarta.xml.bind', @@ -175,6 +178,7 @@ ext.groups = [ 'org.apache.httpcomponents', 'org.apache.sanselan', 'org.bouncycastle', + 'org.ccil.cowan.tagsoup', 'org.checkerframework', 'org.codehaus', 'org.codehaus.groovy', @@ -210,7 +214,6 @@ ext.groups = [ 'org.ow2.asm', 'org.ow2.asm', 'org.reactivestreams', - 'org.robolectric', 'org.slf4j', 'org.sonatype.oss', 'org.testng', diff --git a/docs/unit_testing.md b/docs/unit_testing.md index f786c9a160..95b78c7f5f 100644 --- a/docs/unit_testing.md +++ b/docs/unit_testing.md @@ -314,7 +314,7 @@ class ViewModelTest { private var initialState = ViewState.Empty @get:Rule - val mvrxTestRule = MvRxTestRule(testDispatcher = UnconfinedTestDispatcher()) + val mavericksTestRule = MavericksTestRule(testDispatcher = UnconfinedTestDispatcher()) @Test fun `when handling MyAction, then emits Loading and Content states`() { diff --git a/fastlane/metadata/android/cs-CZ/changelogs/40104360.txt b/fastlane/metadata/android/cs-CZ/changelogs/40104360.txt new file mode 100644 index 0000000000..fcadf9898c --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/changelogs/40104360.txt @@ -0,0 +1,3 @@ +Nový vzhled aplikace lze povolit v Experimentálních funkcích. Prosíme, vyzkoušejte ho! +Oprava problémů s chybějícími oznámeními a dlouhou přírůstkovou synchronizací. +Úplný seznam změn: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/de-DE/changelogs/40104360.txt b/fastlane/metadata/android/de-DE/changelogs/40104360.txt new file mode 100644 index 0000000000..3c47fa7eb6 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/40104360.txt @@ -0,0 +1,3 @@ +Das neue App-Layout kann in den experimentellen Einstellungen aktiviert werden. Probier es gerne aus! +Fehler bzgl. ausbleibender Benachrichtigungen und langwierigem inkrementellem Synchronisieren behoben. +Vollständiges Änderungsprotokoll: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/de-DE/short_description.txt b/fastlane/metadata/android/de-DE/short_description.txt index d27bd3ef12..de571645ee 100644 --- a/fastlane/metadata/android/de-DE/short_description.txt +++ b/fastlane/metadata/android/de-DE/short_description.txt @@ -1 +1 @@ -Gruppen-Messenger - verschlüsselte Kommunikation, Gruppenchat und Videoanrufe +Gruppen-Messenger – verschlüsselte Kommunikation, Gruppen und Videoanrufe diff --git a/fastlane/metadata/android/de-DE/title.txt b/fastlane/metadata/android/de-DE/title.txt index 6304f37925..edee751d06 100644 --- a/fastlane/metadata/android/de-DE/title.txt +++ b/fastlane/metadata/android/de-DE/title.txt @@ -1 +1 @@ -Element - Sicherer Messenger +Element – Sicher kommunizieren diff --git a/fastlane/metadata/android/en-US/changelogs/40105020.txt b/fastlane/metadata/android/en-US/changelogs/40105020.txt new file mode 100644 index 0000000000..41795c468c --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40105020.txt @@ -0,0 +1,2 @@ +Main changes in this version: New app layout enabled by default! +Full changelog: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/et/changelogs/40104360.txt b/fastlane/metadata/android/et/changelogs/40104360.txt new file mode 100644 index 0000000000..1c2733683d --- /dev/null +++ b/fastlane/metadata/android/et/changelogs/40104360.txt @@ -0,0 +1,3 @@ +Testide alt saad sisse lülitada uue kujunduse - palun proovi seda! +Parandasime teavitustega seotud vigu ning andmete sünkroniseerimist pika viitega. +Kogu ingliskeelne muudatuste logi: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/fa/changelogs/40104360.txt b/fastlane/metadata/android/fa/changelogs/40104360.txt new file mode 100644 index 0000000000..be14e1b9e2 --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/40104360.txt @@ -0,0 +1,3 @@ +چینش کارهٔ جدید می‌تواند در تنظیمات آزمایشگاه‌ها به کار بیفتند. لطفاً بیازماییدش! +رفع مشکلات مربوط به آگاهی غایب و همگام‌سازی تجمعّی طولانی. +گزارش دگرگونی کامل: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/fr-FR/changelogs/40104360.txt b/fastlane/metadata/android/fr-FR/changelogs/40104360.txt new file mode 100644 index 0000000000..80f59952d1 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/40104360.txt @@ -0,0 +1,3 @@ +La nouvelle présentation de l’application est disponibles dans les paramètres expérimentaux. Essayez-là ! +Correction de problèmes sur les notifications manquantes, et la synchronisation incrémentale lente. +Intégralité des changements : https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/hu-HU/changelogs/40104360.txt b/fastlane/metadata/android/hu-HU/changelogs/40104360.txt new file mode 100644 index 0000000000..a63a8d1a83 --- /dev/null +++ b/fastlane/metadata/android/hu-HU/changelogs/40104360.txt @@ -0,0 +1,3 @@ +Az új alkalmazás megjelenés a Laborokban bekapcsolható. Próbáld ki! +Hiányzó értesítések és hosszú inkrementális szinkronizáció javítása. +Teljes változásnapló: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/id/changelogs/40104360.txt b/fastlane/metadata/android/id/changelogs/40104360.txt new file mode 100644 index 0000000000..be626f6350 --- /dev/null +++ b/fastlane/metadata/android/id/changelogs/40104360.txt @@ -0,0 +1,3 @@ +Tata Letak Aplikasi Baru dapat diaktifkan di pengaturan Uji Coba. Cobalah! +Perbariki masalah tentang notifikasi hilang, dan penyinkronan inkremental panjang. +Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/it-IT/changelogs/40104360.txt b/fastlane/metadata/android/it-IT/changelogs/40104360.txt new file mode 100644 index 0000000000..c6749d3ff7 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/40104360.txt @@ -0,0 +1,3 @@ +Nuova disposizione dell'app attivabile nelle impostazioni Laboratori. Provala! +Corretti problemi su notifiche mancanti e sincronizzazioni incrementali lunghe. +Cronologia completa: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/pt-BR/changelogs/40104360.txt b/fastlane/metadata/android/pt-BR/changelogs/40104360.txt new file mode 100644 index 0000000000..78a879ccb7 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/40104360.txt @@ -0,0 +1,3 @@ +Novo Layout de App poder ser habilitado nas configurações de Labs. Por favor dê uma chance! +Consertar problemas sobre notificação faltando, e sinc incremental longo. +Changelog completo: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sk/changelogs/40104360.txt b/fastlane/metadata/android/sk/changelogs/40104360.txt new file mode 100644 index 0000000000..af4154b5cf --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/40104360.txt @@ -0,0 +1,3 @@ +Nové usporiadanie aplikácie môžete povoliť v nastaveniach laboratórií. Vyskúšajte to! +Oprava problémov týkajúcich sa chýbajúcich oznámení a dlhej inkrementálnej synchronizácie. +Úplný zoznam zmien: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/uk/changelogs/40104360.txt b/fastlane/metadata/android/uk/changelogs/40104360.txt new file mode 100644 index 0000000000..a2c9bcc4b5 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/40104360.txt @@ -0,0 +1,3 @@ +Новий макет програми можна увімкнути в налаштуваннях лабораторії. Спробуйте! +Виправлено проблеми з відсутністю сповіщень та тривалою інкрементною синхронізацією. +Список усіх змін: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/uk/full_description.txt b/fastlane/metadata/android/uk/full_description.txt index c046d8a40a..330ddde4ae 100644 --- a/fastlane/metadata/android/uk/full_description.txt +++ b/fastlane/metadata/android/uk/full_description.txt @@ -5,7 +5,7 @@ Element — це і безпечний месенджер, і застосуно - Повністю зашифровані повідомлення для надання можливості безпечнішого корпоративного спілкування, навіть для віддалених працівників - Децентралізований чат на основі відкритого коду Matrix - Безпечний обмін файлами із зашифрованими даними для керування проєктами -- Відеочати з передачею голосу через IP та показом екрану іншим +- Відеочати з передачею голосу через IP та показом екрана іншим - Проста інтеграція з вашими улюбленими інструментами для онлайн-співпраці, інструментами керування проєктами, послугами VoIP та іншими застосунками обміну повідомленнями для команд Element цілковито відрізняється від інших застосунків обміну повідомленнями та спільної роботи. Він працює на Matrix, відкритій мережі для безпечного обміну повідомленнями та децентралізованого зв'язку. Це дозволяє самостійне розгортання, щоб надати користувачам якнайбільше володіння та контролю над їх даними та повідомленнями. diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt index 9b60098c34..03fdb6e34d 100644 --- a/fastlane/metadata/android/zh-CN/full_description.txt +++ b/fastlane/metadata/android/zh-CN/full_description.txt @@ -30,7 +30,7 @@ Element 透过不同的方式让你掌控一切: 你可以与 Matrix 网络上的任何人聊天,不论他们是使用 Element、其他 Matrix 应用或其他通讯应用。 超级安全 -真正的端到端加密(仅有那些在对话中的可以解密讯息)以及交叉签章装置验证。 +真正的端到端加密(仅有那些在对话中的人可以解密讯息)以及交叉签章装置验证。 完整的通讯与整合 信息传递、语音与视频通话、文件分享、画面分享与超多的整合、机器人与挂件。建构房间、社群、保持联络并完成工作。 diff --git a/fastlane/metadata/android/zh-CN/short_description.txt b/fastlane/metadata/android/zh-CN/short_description.txt index e271e7f9a4..8cfea85b90 100644 --- a/fastlane/metadata/android/zh-CN/short_description.txt +++ b/fastlane/metadata/android/zh-CN/short_description.txt @@ -1 +1 @@ -群组消息应用-加密的消息传递、群组聊天和视频通话 +群组消息应用——加密的消息传递、群组聊天和视频通话 diff --git a/fastlane/metadata/android/zh-TW/changelogs/40104360.txt b/fastlane/metadata/android/zh-TW/changelogs/40104360.txt new file mode 100644 index 0000000000..be36b60840 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/40104360.txt @@ -0,0 +1,3 @@ +新的應用程式佈局可在「實驗室」設定中啟用。請試試看! +修復關於遺失通知的問題,以及增量同步需要長時間的問題。 +完整的變更紀錄:https://github.com/vector-im/element-android/releases diff --git a/library/external/jsonviewer/build.gradle b/library/external/jsonviewer/build.gradle index 4e8dc99654..50bb635e8e 100644 --- a/library/external/jsonviewer/build.gradle +++ b/library/external/jsonviewer/build.gradle @@ -2,6 +2,7 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply plugin: 'kotlin-parcelize' apply plugin: 'kotlin-kapt' +apply plugin: 'com.google.devtools.ksp' buildscript { repositories { @@ -51,7 +52,7 @@ dependencies { implementation libs.androidx.recyclerview implementation libs.airbnb.epoxy - kapt libs.airbnb.epoxyProcessor + ksp libs.airbnb.epoxyProcessor implementation libs.airbnb.mavericks // Span utils diff --git a/library/external/jsonviewer/src/main/java/org/billcarsonfr/jsonviewer/JSonViewerFragment.kt b/library/external/jsonviewer/src/main/java/org/billcarsonfr/jsonviewer/JSonViewerFragment.kt index fbf6f88bc3..719ce29045 100644 --- a/library/external/jsonviewer/src/main/java/org/billcarsonfr/jsonviewer/JSonViewerFragment.kt +++ b/library/external/jsonviewer/src/main/java/org/billcarsonfr/jsonviewer/JSonViewerFragment.kt @@ -62,7 +62,7 @@ class JSonViewerFragment : Fragment(), MavericksView { } recyclerView = inflate.findViewById(R.id.jvRecyclerView) recyclerView.layoutManager = - LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false) + LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false) recyclerView.setController(epoxyController) epoxyController.setStyle(args?.styleProvider) registerForContextMenu(recyclerView) diff --git a/library/multipicker/src/main/java/im/vector/lib/multipicker/utils/ImageUtils.kt b/library/multipicker/src/main/java/im/vector/lib/multipicker/utils/ImageUtils.kt index a3d69ae8cf..705223c55e 100644 --- a/library/multipicker/src/main/java/im/vector/lib/multipicker/utils/ImageUtils.kt +++ b/library/multipicker/src/main/java/im/vector/lib/multipicker/utils/ImageUtils.kt @@ -30,7 +30,15 @@ object ImageUtils { fun getBitmap(context: Context, uri: Uri): Bitmap? { return try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, uri)) + val source = ImageDecoder.createSource(context.contentResolver, uri) + val listener = ImageDecoder.OnHeaderDecodedListener { decoder, _, _ -> + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) { + // Allocating hardware bitmap may cause a crash on framework versions prior to Android Q + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + } + } + + ImageDecoder.decodeBitmap(source, listener) } else { context.contentResolver.openInputStream(uri)?.use { inputStream -> BitmapFactory.decodeStream(inputStream) 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 863fa13fbb..25c490807e 100644 --- a/library/ui-strings/src/main/res/values-ca/strings.xml +++ b/library/ui-strings/src/main/res/values-ca/strings.xml @@ -2674,4 +2674,40 @@ Aquesta sessió està llesta per a missatges segurs. La teva sessió actual està llesta per a missatges segurs. Verifica la teva sessió actual obtenir missatges segurs millorats. + Crea missatge directe només al primer missatge + Activa missatges directes programats + Verifica o tanca aquesta sessió per estar més segur. + Per estar més segur, tanca qualsevol sessió que no reconeguis o ja no utilitzis. + No s\'han trobat sessions inactives. + No s\'han trobat sessions no verificades. + No s\'han trobat sessions verificades. + Detalls de sessió + Esborra filtre + Última activitat + Nom de la sessió + Informació d\'aplicació, dispositiu i activitat. + Adreça IP + + Pensa en tancar sessió de les sessions antigues (%1$d dia o més) que ja no utilitzis. + Pensa en tancar sessió de les sessions antigues (%1$d dies o més) que ja no utilitzis. + + Inactiu + No verificat + Verificat + Filtra + + Inactiu durant %1$d dia o més + Inactiu durant %1$d dies o més + + Inactiu + No verificat + Verificat + Totes les sessions + Filtre + Última activitat %1$s + Dispositiu + Sessió + Sessió actual + Element simplificat amb pestanyes opcionals + Activa la nova visualització \ 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 79f8311159..1983036271 100644 --- a/library/ui-strings/src/main/res/values-cs/strings.xml +++ b/library/ui-strings/src/main/res/values-cs/strings.xml @@ -2720,4 +2720,48 @@ Sbalit podprostory %s Rozbalit podprostory %s Změnit prostor - + IP adresa + Poslední aktivita + Název relace + Informace o aplikacích, zařízeních a aktivitách. + Podrobnosti o relaci + Vyčistit filtr + Nebyly nalezeny žádné neaktivní relace. + Nebyly nalezeny žádné neověřené relace. + Nebyly nalezeny žádné ověřené relace. + + Zvažte odhlášení ze starých relací (%1$d den nebo více), které již nepoužíváte. + Zvažte odhlášení ze starých relací (%1$d dny nebo více), které již nepoužíváte. + Zvažte odhlášení ze starých relací (%1$d dnů nebo více), které již nepoužíváte. + + Neaktivní + Ověřte své relace pro vylepšené bezpečné zasílání zpráv nebo se odhlaste z těch, které již nepoznáváte nebo nepoužíváte. + Neověřeno + Pro nejlepší zabezpečení se odhlaste z každé relace, kterou již nepoznáváte nebo nepoužíváte. + Ověřeno + Filtr + + Neaktivní po dobu %1$d dne nebo déle + Neaktivní po dobu %1$d dnů nebo déle + Neaktivní po dobu %1$d dnů nebo déle + + Neaktivní + Není připraveno na bezpečné zasílání zpráv + Neověřeno + Připraveno na bezpečné zasílání zpráv + Ověřeno + Všechny relace + Filtr + Poslední aktivita %1$s + Zařízení + Relace + Aktuální relace + Pro nejlepší zabezpečení a spolehlivost tuto relaci ověřte nebo se z ní odhlaste. + Ověřte svou aktuální relaci pro vylepšené bezpečené zasílání zpráv. + Tato relace je připravena pro bezpečné zasílání zpráv. + Vaše aktuální relace je připravena pro bezpečné zasílání zpráv. + Vytvořit přímou zprávu pouze při první zprávě + Povolit odložené přímé zprávy + Zjednodušený Element s volitelnými kartami + Povolit nový vzhled + \ 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 e01fc898a3..27f46160bc 100644 --- a/library/ui-strings/src/main/res/values-de/strings.xml +++ b/library/ui-strings/src/main/res/values-de/strings.xml @@ -103,7 +103,7 @@ Du hast das Bild des Raumes geändert Du hast den Raumnamen zu %1$s geändert Du hast einen Videoanruf gestartet. - Du hast einen Audioanruf gestartet. + Du hast einen Sprachanruf gestartet. Du hast den Anruf angenommen. Du hast den Anruf beendet. Du hast den zukünftigen Nachrichtenverlauf für %1$s sichtbar gemacht @@ -269,7 +269,7 @@ Problem melden Bitte beschreibe das Problem. Was hast du genau gemacht\? Was sollte passieren\? Was ist tatsächlich passiert\? Problembeschreibung - Um Probleme diagnostizieren zu können, werden Protokolle des Clients zusammen mit dem Fehlerbericht übermittelt. Dieser Fehlerbericht wird, wie die Protokolle und das Bildschirmfoto, nicht öffentlich sichtbar sein. Wenn du nur den oben eingegebenen Text senden möchtest, die nachfolgenden Haken entsprechend entfernen: + Um Probleme diagnostizieren zu können, werden Protokolle der Anwendung zusammen mit dem Fehlerbericht übermittelt. Dieser Fehlerbericht wird, wie die Protokolle und das Bildschirmfoto, nicht öffentlich sichtbar sein. Wenn du nur den oben eingegebenen Text senden möchtest, die nachfolgenden Haken entsprechend entfernen: Du scheinst dein Telefon frustriert zu schütteln. Möchtest du das Fenster zum Senden eines Fehlerberichts öffnen\? Dein Fehlerbericht wurde erfolgreich übermittelt Der Fehlerbericht konnte nicht übermittelt werden (%s) @@ -278,7 +278,7 @@ Raum betreten Benutzername Abmelden - Heimserver-Adresse + Heim-Server-Adresse Suchen Sprachanruf starten Videoanruf starten @@ -308,7 +308,7 @@ Die Gegenseite hat den Anruf nicht angenommen. Information ${app_name} benötigt die Berechtigung, auf dein Mikrofon zugreifen zu können, um (Sprach-)Anrufe tätigen zu können. - ${app_name} benötigt die Berechtigung, auf Kamera und Mikrofon zu zugreifen, um Video-Anrufe durchzuführen. + ${app_name} benötigt die Berechtigung, auf Kamera und Mikrofon zuzugreifen, um Videoanrufe durchzuführen. \n \nBitte erlaube den Zugriff im nächsten Dialog, um den Anruf durchzuführen. Ja @@ -351,12 +351,12 @@ Anzeigename E-Mail-Adresse hinzufügen Telefonnummer hinzufügen - Appinfo in den Systemeinstellungen öffnen. - App-Info + Anwendungsinformationen in den Systemeinstellungen anzeigen. + Anwendungsinformationen Benachrichtigungen für diesen Account Benachrichtigungen für diese Sitzung Direktnachrichten - Gruppenchats + Gruppenunterhaltungen Einladungen Anrufe Nachrichten von Bots @@ -366,7 +366,7 @@ Version OLM-Version Nutzungsbedingungen - Nutzungshinweise von Drittanbietern + Drittanbieter-Lizenzen Urheberrechtserklärung Datenschutzerklärung Cache leeren @@ -390,8 +390,8 @@ %1$s @ %2$s Authentifizierung Angemeldet als - Heimserver - Identitätsserver + Heim-Server + Identitäts-Server Bitte prüfe deinen E-Mail-Posteingang und klicke auf den in der E-Mail enthaltenen Link. Klicke anschließend auf Fortsetzen. Diese E-Mail-Adresse wird bereits verwendet. Diese Telefonnummer wird bereits verwendet. @@ -403,8 +403,8 @@ Alle Nachrichten von %s anzeigen\? Wähle ein Land Thema - Lesbarkeit des Chatverlaufs - Wer kann den Chatverlauf lesen? + Lesbarkeit des Verlaufs + Wer kann den Verlauf lesen\? Alle Nur Mitglieder Nur Mitglieder (ab Einladung) @@ -412,8 +412,8 @@ Verbannte Benutzer Erweitert Interne ID dieses Raumes - Experimentelle Einstellungen - Dies sind experimentelle Funktionen, die in unerwarteter Weise Fehler verursachen können. Mit Vorsicht zu verwenden. + Labor + Dies sind experimentelle Funktionen, die in unerwarteter Weise Fehler verursachen können. Verwende sie mit Vorsicht. Als Hauptadresse setzen Als Hauptadresse aufheben Entschlüsselungsfehler @@ -447,7 +447,7 @@ Starte beim Systemstart Medien-Cache leeren Medien behalten - Für alle Nachrichten Zeitstempel anzeigen + Zeitstempel für alle Nachrichten 3 Tage 1 Woche 1 Monat @@ -500,7 +500,7 @@ Sicher, dass du einen Videoanruf starten möchtest\? Die Verbannung einer Person entfernt sie aus diesem Raum und hindert sie am erneuten Beitritt. Alle Nachrichten - URL-Vorschau im Chat + URL-Vorschau Vibriere beim Erwähnen eines Nutzers Erstellen Startseite @@ -551,9 +551,9 @@ Um %1$s weiter zu verwenden, musst die Geschäftsbedingungen begutachten und ihnen zustimmen. Jetzt prüfen Konto deaktivieren - Dies wird dein Konto permanent unbenutzbar machen. Du wirst dich nicht anmelden können und keiner wird denselben Nutzernamen erneut registrieren können. Du verlässt automatisch alle Räume, in denen du bist, und deine Kontoangaben werden vom Identitätsserver gelöscht. Diese Aktion ist unumkehrbar. + Dies wird dein Konto permanent unbenutzbar machen. Du wirst dich nicht anmelden können und keiner wird denselben Nutzernamen erneut registrieren können. Du verlässt automatisch alle Räume, in denen du bist, und deine Kontoangaben werden vom Identitäts-Server gelöscht. Diese Aktion ist unumkehrbar. \n -\nDie Deaktivierung deines Konto wird standardmäßig keine deiner gesendeten Nachrichten löschen. Wenn du möchtest, dass auch deine Nachrichten gelöscht werden, wähle zusätzlich die Option unten. +\nDie Deaktivierung deines Kontos wird standardmäßig keine deiner gesendeten Nachrichten löschen. Wenn du möchtest, dass auch deine Nachrichten gelöscht werden, wähle zusätzlich die Option unten. \n \nDie Sichtbarkeit deiner Nachrichten ist ähnlich wie bei E-Mails: Wenn deine Nachrichten gelöscht werden, bedeutet dies, dass von dir verschickte Nachrichten nicht mit neuen oder unregistrierten Nutzer geteilt werden. Aber registrierte Nutzer, die bereits Zugang zu diesen Nachrichten haben, behalten weiterhin Zugriff auf ihre Kopie. Bitte alle Nachrichten, die ich gesendet habe, löschen, wenn mein Konto deaktiviert wird (Warnung: Unterhaltungen werden für zukünftige Nutzer unvollständig erscheinen) @@ -605,7 +605,7 @@ %1$s: %2$s +%d Aus Unterhaltung entfernen - Linkvorschau im Chat aktivieren, falls dein Homeserver diese Funktion unterstützt. + Link-Vorschau im Chat aktivieren, falls dein Heim-Server diese Funktion unterstützt. Schreibbenachrichtigungen senden Lasse andere Benutzer wissen, dass du tippst. Markdown-Formatierung @@ -614,7 +614,7 @@ Klicke auf die Lesebestätigungen für eine detailliertere Liste. Einladungen, Entfernungen und Verbannungen bleiben sichtbar. Passwort - Starte die System-Kamera anstelle der angepassten Kamera. + Starte die Kamera des Systems anstelle der selbstdefinierten. Das Kommando \"%s\" braucht mehr Parameter oder einige Parameter sind inkorrekt. Markdown wurde aktiviert. Markdown wurde deaktiviert. @@ -729,10 +729,10 @@ Wiederherstellungsschlüssel aus Passphrase generieren. Dies kann mehrere Sekunden brauchen. Du verlierst möglicherweise den Zugang zu deinen Nachrichten, wenn du dich abmeldest oder das Gerät verlierst. Rufe Backup-Version ab… - Nutze deine Wiederherstellungspassphrase, um deinen verschlüsselten Chatverlauf lesen zu können + Nutze deine Wiederherstellungs-Passphrase, um deinen verschlüsselten Nachrichtenverlauf lesen zu können nutze deinen Wiederherstellungsschlüssel Wenn du deine Wiederherstellungspassphrase nicht weist, kannst du %s. - Nutze deinen Wiederherstellungsschlüssel, um deinen verschlüsselten Chatverlauf lesen zu können + Nutze deinen Wiederherstellungsschlüssel, um deinen verschlüsselten Nachrichtenverlauf lesen zu können Hast du deinen Wiederherstellungsschlüssel verloren\? Du kannst einen neuen in den Einstellungen einrichten. Sicherung konnte mit dieser Passphrase nicht entschlüsselt werden. Bitte stelle sicher, dass du die korrekte Wiederherstellungspassphrase eingegeben hast. Gib deinen Wiederherstellungsschlüssel ein @@ -757,7 +757,7 @@ Die Sicherung hat eine ungültige Signatur von der verifizierten Sitzung %s Die Sicherung hat eine ungültige Signatur von der nicht verifizierten Sitzung %s Um die Schlüsselsicherung für diese Sitzung zu verwenden, stelle sie jetzt mit deiner Passphrase oder deinem Wiederherstellungsschlüssel wieder her. - Deine gesicherten Schlüssel vom Server löschen\? Du wirst deinen Wiederherstellungsschlüssel nicht mehr nutzen können, um deinen verschlüsselten Chatverlauf zu lesen. + Deine gesicherten Schlüssel vom Server löschen\? Du wirst deinen Wiederherstellungsschlüssel nicht mehr nutzen können, um deinen verschlüsselten Nachrichtenverlauf zu lesen. Beim Abmelden gehen deine verschlüsselten Nachrichten verloren Schlüssel-Sicherung wird durchgeführt. Wenn du dich jetzt abmeldest, gehen deine verschlüsselten Nachrichten verloren. Schlüsselsicherung sollte bei allen Sitzungen aktiviert sein, um den Verlust verschlüsselter Nachrichten zu verhindern. @@ -889,7 +889,7 @@ Sonstige Hinweise Dritter Du siehst diesen Raum bereits! Allgemein - Einstellungen + Optionen Sicherheit und Privatsphäre Push-Regeln Keine Push-Regeln definiert @@ -934,7 +934,7 @@ Keine Hintergrundsynchronisation Auffindbarkeit Um fortzufahren, musst du die Nutzungsbedingungen akzeptieren. - Du verwendest keinen Identitätsserver + Du verwendest keinen Identitäts-Server Du versuchst anscheinend, eine Verbindung zu einem anderen Homeserver herzustellen. Möchtest du dich abmelden\? Push-Key: App-Anzeigename: @@ -942,13 +942,13 @@ Nutzungsbedingungen Für andere auffindbar sein Verwende Bots, Bridges, Widgets und Sticker-Pakete - Identitätsserver - Verbindung zum Identitätsserver trennen - Identitätsserver konfigurieren - Identitätsserver ändern + Identitäts-Server + Verbindung zum Identitäts-Server trennen + Identitäts-Server konfigurieren + Identitäts-Server ändern Auffindbare E-Mail-Adressen Erkennungsoptionen werden angezeigt, sobald du eine E-Mail hinzugefügt hast. - Gib einen neuen Identitätsserver ein + Gib eine Identitäts-Server-Adresse ein Konnte keine Verbindung zum Homeserver herstellen Dies ist keine Adresse eines Matrixservers Kann Homeserver nicht unter dieser URL erreichen. Bitte überprüfen @@ -986,15 +986,15 @@ Sitzungsname: Format: Du nutzt aktuell %1$s um vorhandene Kontakte zu finden und um von dir bekannten Kontakten gefunden zu werden. - Du benutzt aktuell keinen Identitätsserver. Um zu entdecken und um von dir bekannten Kontakten entdeckt zu werden, richte unten einen ein. + Aktuell nutzt du keinen Identitäts-Server. Richte einen ein, um andere zu finden und selbst auffindbar zu sein. Auffindbare Telefonnummern - Bitte gib die Adresse des Identitätsservers ein - Identitätsserver hat keine Nutzungsbedingungen - Der Identitätsserver den du ausgewählt hast, hat keine Nutzungsbedingungen. Fahre nur fort, wenn du dem Besitzer des Dienstes vertraust + Bitte gib die Adresse des Identitäts-Servers ein + Identitäts-Server hat keine Nutzungsbedingungen + Der Identitäts-Server, den du ausgewählt hast, hat keine Nutzungsbedingungen. Fahre nur fort, wenn du den Betreibenden des Dienstes vertraust Eine Textnachricht wurde an %s gesendet. Bitte gib den Verifizierungscode ein, den sie enthält. Aktiviere ausführliche Logs. - Ausführliche Logs werden der Entwicklung der App dadurch helfen, dass mehr Informationen übertragen werden, wenn du einen Fehlerbericht sendest. Auch wenn dies aktiviert ist, werden keine Nachrichteninhalte oder andere privaten Daten aufgezeichnet. - Bitte erneut versuchen, nachdem du die Nutzungsbedingungen deines Heimservers akzeptiert hast. + Ausführliche Protokolle werden bei der Entwicklung der App helfen. Auch wenn dies aktiviert ist, werden keine Nachrichteninhalte oder andere privaten Daten aufgezeichnet. + Bitte erneut versuchen, nachdem du die Nutzungsbedingungen deines Heim-Servers akzeptiert hast. Bei Benutzung könnten Cookies gesetzt werden und es könnten Daten mit %s geteilt werden: Bei Benutzung könnten Daten mit %s geteilt werden: Optionen zum Finden werden erscheinen, sobald du eine Telefonnummer hinzugefügt hast. @@ -1004,7 +1004,7 @@ Navigationsmenü öffnen Raumerstellungsmenü öffnen Schließe das Raumerstellungsmenü… - Starte einen neuen Privatchat + Erstelle eine neue Direktnachricht Erstelle einen neuen Raum Schließe Key-Backup-Einblendung Zum Ende springen @@ -1052,7 +1052,7 @@ Halte auf einem Raum um mehr Optionen anzuzeigen %1$s hat den Raum für jeden, der den Link hat, öffentlich gemacht. Ungelesene Nachrichten - Privat oder in Gruppen mit Leuten chatten + Schreibe privat oder in Gruppen Halte Gespräche mittels Verschlüsselung privat Los geht\'s Wähle einen Server @@ -1063,9 +1063,9 @@ Andere Benutzerdefinierte und erweiterte Einstellungen Fortfahren - Eine Trennung von deinem Identitätsserver würde bedeuten, dass du weder von anderen Nutzern gefunden werden, noch diese per E-Mail oder Telefonnummer einladen kannst. - Du teilst deine E-Mail-Adressen oder Telefonnummern momentan auf dem Identitätsserver %1$s. Du wirst dich erneut mit %2$s verbinden müssen, um mit dem Teilen aufzuhören. - Stimme den Nutzungsbedingungen des Identitätsservers (%s) zu, um zu erlauben per E-Mail oder Telefonnummer gefunden zu werden. + Eine Trennung von deinem Identitäts-Server würde bedeuten, dass du weder von anderen gefunden werden, noch diese per E-Mail oder Telefonnummer einladen kannst. + Du teilst deine E-Mail-Adressen oder Telefonnummern momentan auf dem Identitäts-Server %1$s. Du wirst dich erneut mit %2$s verbinden müssen, um mit dem Teilen aufzuhören. + Stimme den Nutzungsbedingungen des Identitäts-Servers (%s) zu, um per E-Mail-Adresse oder Telefonnummer auffindbar zu sein zu können. Zu teilende Daten nicht verarbeitbar Erweitere und individualisiere dein Benutzererlebnis Mit %1$s verbinden @@ -1081,13 +1081,13 @@ Es tut uns leid. Dieser Server akzeptiert keine neuen Benutzerkonten. Die Anwendung kann kein neues Benutzerkonto auf diesem Server erstellen. \n -\nMöchtest du dich über eine Web-Anwendung anmelden\? +\nMöchtest du dich mit einer Web-Anwendung anmelden\? Diese E-Mail-Adresse ist mit keinem Benutzerkonto verknüpft. Passwort auf %1$s zurücksetzen E-Mail Neues Passwort Achtung! - Eine Änderung deines Passworts wird alle Ende-zu-Ende-Schlüssel zurücksetzen. Dein verschlüsselter Chatverlauf wird dadurch unlesbar. Richte die Schlüsselsicherung ein oder exportiere deine Raumschlüssel aus einer anderen Sitzung, bevor du dein Passwort zurücksetzt. + Eine Änderung deines Passworts wird alle Ende-zu-Ende-Schlüssel zurücksetzen. Dein verschlüsselter Verlauf wird dadurch unlesbar. Richte die Schlüsselsicherung ein oder exportiere deine Raumschlüssel aus einer anderen Sitzung, bevor du dein Passwort zurücksetzt. Fortfahren Diese E-Mail-Adresse ist mit keinem Benutzerkonto verknüpft Prüfe deinen Posteingang @@ -1126,9 +1126,9 @@ Es ist deine Konversation. Mache sie dir zu eigen. Premium-Hosting für Organisationen Gib die Adresse des Modular Element oder Servers ein, den du verwenden möchtest - Die Anwendung kann sich nicht bei diesem Homeserver anmelden. Der Homeserver unterstützt die folgenden Anmeldemöglichkeiten: %1$s. + Die Anwendung kann sich nicht bei diesem Heim-Server anmelden. Der Heim-Server unterstützt die folgenden Anmeldemöglichkeiten: %1$s. \n -\nMöchtest du dich mit einem Webclient anmelden\? +\nMöchtest du dich mit einer Web-Anwendung anmelden\? Dir wird eine Bestätigungsmail gesendet, um dein neues Passwort zu bestätigen. Weiter Du wurdest von allen Sitzungen abgemeldet und erhältst keine Push-Benachrichtigungen mehr. Um Benachrichtigungen wieder zu aktivieren, melde dich auf jedem Gerät erneut an. @@ -1272,21 +1272,21 @@ Vergleiche den Code mit dem Code auf dem Bildschirm deines Gegenübers. Nachrichten mit diesem Gegenüber sind Ende-zu-Ende-verschlüsselt und können nicht von Dritten gelesen werden. Deine neue Sitzung ist jetzt verifiziert. Sie hat Zugriff auf deine verschlüsselten Nachrichten, und andere Benutzer sehen sie als vertrauenswürdig an. - Cross-Signing - Cross-Signing ist aktiviert -\nPrivate Schlüssel auf dem Gerät. - Cross-Signing ist aktiviert + Quersignierung + Quersignierung ist aktiviert, +\nprivate Schlüssel auf dem Gerät. + Quersignierung ist aktiviert, \nSchlüssel sind vertrauenswürdig. \nPrivate Schlüssel sind nicht bekannt - Cross-Signing ist aktiviert + Quersignierung ist aktiviert, \nSchlüssel sind nicht vertrauenswürdig - Cross-Signing ist nicht aktiviert + Quersignierung ist nicht aktiviert Aktive Sitzungen Alle Sitzungen anzeigen Sitzungen verwalten Diese Sitzung abmelden Keine kryptografischen Informationen verfügbar - Diese Sitzung ist für sichere Nachrichtenübertragung vertrauenswürdig, da du sie überprüft hast: + Diese Sitzung ist für sichere Kommunikation vertrauenswürdig, da du sie überprüft hast: Verifiziere diese Sitzung, um sie als vertrauenswürdig zu markieren, und gewähren ihr Zugriff auf verschlüsselte Nachrichten. Wenn du dich nicht bei dieser Sitzung angemeldet hast, ist dein Konto möglicherweise gefährdet: Eine aktive Sitzung @@ -1301,10 +1301,10 @@ Sitzungen Vertraut Nicht vertraut - Diese Sitzung ist für sichere Nachrichtenübertragung vertrauenswürdig, weil %1$s (%2$s) sie verifiziert hat: + Diese Sitzung ist für sichere Kommunikation vertrauenswürdig, weil %1$s (%2$s) sie verifiziert hat: %1$s (%2$s) hat sich in einer neuen Sitzung angemeldet: Bis dieser Benutzer dieser Sitzung vertraut, werden an und von ihm gesendete Nachrichten mit Warnungen gekennzeichnet. Alternativ kannst du dies manuell überprüfen. - Initialisiere Cross-Signing + Quersignierung initialisieren Schlüssel zurücksetzen QR-Code Fast geschafft! Zeigt %s ein Häkchen\? @@ -1373,7 +1373,7 @@ Import der Schlüssel fehlgeschlagen Benachrichtigungskonfiguration Nachrichten mit \"@room\" - Verschlüsselte Gruppenchats + Verschlüsselte Gruppenunterhaltungen Sendet eine Nachricht als einfachen Text, ohne sie als Markdown zu interpretieren Inkorrekter Benutzername und/oder Passwort. Das eingegebene Passwort beginnt oder endet mit Leerzeichen, bitte kontrolliere es. Nachrichtenschlüssel @@ -1381,7 +1381,7 @@ Druck es aus und speichere es an einem sicheren Ort Kopier es in deinen persönlichen Cloud-Speicher Verschlüsselung ist nicht aktiviert - Raumupgrades + Raumaktualisierung Verschlüsselung aktiviert Nachrichten in diesem Raum sind Ende-zu-Ende-verschlüsselt. Erfahre mehr und verifiziere Benutzer in deren Profil. Die Verschlüsselung in diesem Raum wird nicht unterstützt @@ -1392,7 +1392,7 @@ Fast geschafft! Warte auf Bestätigung… Verschlüsselte Direktnachrichten Nachricht… - Verifiziere dich und andere, um eure Chats zu schützen + Verifiziere dich und andere, um eure Unterhaltungen zu schützen Gib zum Fortfahren deinen %s ein Datei benutzen Dies ist kein gültiger Wiederherstellungsschlüssel @@ -1412,12 +1412,12 @@ Bildschirmfotos der Anwendung verhindern Das Aktivieren dieser Einstellung setzt FLAG_SECURE in allen Aktivitäten. Starte die Anwendung neu, damit die Änderung wirksam wird. Neues Benutzerpasswort festlegen… - Nutze die neueste Version von ${app_name} auf deinen anderen Geräten, ${app_name} Web, ${app_name} Desktop, ${app_name} iOS, ${app_name} für Android oder einen anderen cross-signing-fähigen Matrix-Client + Nutze die neueste Version von ${app_name} auf deinen anderen Geräten, ${app_name} Web, ${app_name} Desktop, ${app_name} iOS, ${app_name} für Android oder eine andere Matrix-Anwendung, die Quersignierung unterstützt ${app_name} Web \n${app_name} Desktop ${app_name} iOS \n${app_name} Android - oder einen anderen cross-signing-fähigen Matrix Client + oder eine andere Matrix-Anwendung, die Quersignierung unterstützt Nutze die neueste Version von ${app_name} auf deinen anderen Geräten: Erzwingt das Verwerfen der aktuell ausgehende Gruppensitzung in einem verschlüsseltem Raum Wird nur in verschlüsselten Räumen unterstützt @@ -1461,7 +1461,7 @@ Ablehnen Erfolg Echtzeitverbindung konnte nicht hergestellt werden. -\nBitte den Administrator deines Heimservers, einen TURN-Server zu konfigurieren, damit Anrufe zuverlässig funktionieren. +\nBitte den Administrator deines Heim-Servers, einen TURN-Server zu konfigurieren, damit Anrufe zuverlässig funktionieren. Audiogerät auswählen Telefon Lautsprecher @@ -1555,22 +1555,22 @@ Andere verfügbare Sprachen Lade verfügbare Sprachen… Öffne AGBs von %s - Trenne Verbindung zu Identitätsserver %s\? - Dieser Identitätsserver ist veraltet. ${app_name} unterstützt nur API V2. + Verbindung zu Identitäts-Server %s trennen\? + Dieser Identitäts-Server ist veraltet. ${app_name} unterstützt nur API V2. Diese Operation ist nicht möglich. Der Homeserver ist veraltet. - Bitte konfiguriere zuerst einen Identitätsserver. - Bitte akzeptiere zuerst die AGB des Identitätsservers in den Einstellungen. + Bitte konfiguriere zuerst einen Identitäts-Server. + Bitte akzeptiere zuerst die AGB des Identitäts-Servers in den Einstellungen. Deiner Privatsphäre wegen unterstützt ${app_name} nur das Senden gehashter E-Mail-Adressen und Telefonnummern. Die Assoziierung ist fehlgeschlagen. Für diese Kennung gibt es aktuell keine Zuordnung. - Dein Homeserver (%1$s) schlägt %2$s als Identitätsserver vor + Dein Heim-Server (%1$s) schlägt %2$s als Identitäts-Server vor Benutze %1$s - Alternativ kannst du die URL eines beliebigen anderen Identitätsservers angeben - Gib die URL von einem Identitätsserver ein - Bestätigen - Lege Rolle fest + Alternativ kannst du die URL eines beliebigen anderen Identitäts-Servers angeben + Gib die Adresse eines Identitäts-Servers ein + Absenden + Rolle festlegen Rolle - Öffne Chat + Unterhaltung öffnen Stelle Mikrophon stumm Aktiviere Mikrophon Stoppe Kamera @@ -1704,7 +1704,7 @@ Alle Wiederherstellungsoptionen vergessen oder verloren\? Alles zurücksetzen Du bist beigetreten. %s ist beigetreten. - Nachrichten in diesem Raum sind Ende-zu-Ende-verschlüsselt. + Nachrichten in dieser Unterhaltung sind Ende-zu-Ende-verschlüsselt. Verlassen Einstellungen Nachrichten hier sind Ende-zu-Ende-verschlüsselt. @@ -1744,10 +1744,10 @@ Direktnachricht Verlauf der Anfragen von Schlüsselfreigaben senden Keine weiteren Ergebnisse - Beginne ein Gespräch + Beginne eine Unterhaltung Autorisieren Meine Zustimmung widerrufen - Du hast zugestimmt E-Mails und Telefonnummern an diesen Identitätsserver zu senden, um von anderen Nutzern entdeckt zu werden. + Du hast zugestimmt, E-Mail-Adressen und Telefonnummern an diesen Identitäts-Server zu übermitteln, um für andere auffindbar zu sein. E-Mails und Telefonnummern senden Vorschläge Bekannte Personen @@ -1774,7 +1774,7 @@ Suche nach Kontakten auf Matrix Raumbild einrichten Einverständnis wurde nicht abgegeben. - Teile diesen Code mit Leuten, damit sie ihn scannen und mit dir chatten können. + Teile diesen Code, damit andere ihn einlesen und mit dir schreiben können. Meinen Code teilen Mein Code Scanne einen QR-Code @@ -1794,7 +1794,7 @@ Manche Zeichen sind nicht zulässig Bitte gib eine Raumadresse an Diese Adresse ist bereits vergeben - Aktivieren, wenn der Raum nur von Mitgliedern deines Heimservers zur internen Kommunikation verwendet wird. Das kann später nicht mehr geändert werden. + Aktivieren, wenn der Raum nur von Mitgliedern deines Heim-Servers zur internen Kommunikation verwendet wird. Das kann später nicht mehr geändert werden. Begrenze Zugang zu diesem Raum (für immer!) auf Mitglieder von %s %1$d von %2$d Keine Vorschau für diesen Raum verfügbar. Willst du direkt beitreten\? @@ -1849,7 +1849,7 @@ Knopf zum Nachrichteneditor hinzufügen, der die Emoji-Tastatur öffnet Emoji-Tastatur anzeigen Nutze /confetti oder sende Nachrichten mit ❄️ oder 🎉 - Chateffekte + Effekte im Verlauf Thema ändern Raum aktualisieren Rollen, die zum Ändern verschiedener Teile des Raums erforderlich sind, auswählen @@ -1859,7 +1859,7 @@ Authentifizierung fehlgeschlagen Deine Anmeldeinformationen müssen für ${app_name} eingegeben werden, um diese Aktion auszuführen. Erneute Authentifizierung erforderlich - Cross-Signing konnte nicht eingerichtet werden + Quersignierung konnte nicht eingerichtet werden Nicht autorisierte, fehlende gültige Authentifizierungsdaten Nutzer Beim Weiterleiten des Anrufs ist ein Fehler aufgetreten @@ -1917,7 +1917,7 @@ %d Einträge Die Obergrenze ist nicht bekannt. - Dein Homeserver akzeptiert Anhänge (wie Dateien, Medien, etc.) mit einer Größe bis zu %s. + Dein Heim-Server akzeptiert Anhänge (wie Dateien, Medien, etc.) mit einer Größe bis zu %s. Datei-Upload-Obergrenze des Servers Serverversion Servername @@ -1960,7 +1960,7 @@ Diese werden in der Lage sein, %s zu durchsuchen Diese werden kein Teil von %s sein Tritt meinem Space %1$s %2$s bei - Mit Spaces kannst du Personen und Räume gruppieren. + Spaces sind eine neue Möglichkeit, Räume und Personen zu gruppieren. Räume oder Spaces hinzufügen Vorübergehend überspringen Über welche Themen möchtest du dich in %s unterhalten\? @@ -1994,8 +1994,8 @@ Dein öffentlicher Space Betrete einen Space mit der angegebenen ID Beschreibung - Erzeuge Space… - Irgendetwas + Erzeuge Space … + Ohne Thema Allgemein Einen Space erstellen Nur für mich @@ -2051,7 +2051,7 @@ Privater Space Öffentlicher Space Unbekannte Person - Feedback geben + Rückmeldung geben Fehler beim Senden vom Feedback (%s) Dein Feedback wurde erfolgreich versandt. Danke! Mich bei Fragen kontaktieren @@ -2086,7 +2086,7 @@ Sprachnachricht pausieren Sprachnachricht abspielen Sprachnachricht aufnehmen - Dieser Raum verwendet die Raumversion %s, die von diesem Heimserver als instabil markiert ist. + Dieser Raum verwendet die Raumversion %s, die von diesem Heim-Server als instabil markiert ist. Du benötigst die Berechtigung, um einen Raum upzugraden Übergeordneten Space automatisch updaten Benutzer automatisch einladen @@ -2105,14 +2105,14 @@ Sprachnachricht Lege fest, wer diesen Raum finden und betreten kann. Klicke, um die Spaces zu bearbeiten - Spaces auswählen + Spaces wählen Mitglieder von %s können Räume finden, betrachten und betreten. Privat (Zutritt nur mit Einladung) Raumupgrades Nachrichten von Bots Raumeinladungen - Verschlüsselten Gruppenchats - Gruppenchats + Verschlüsselte Gruppennachrichten + Gruppennachrichten Verschlüsselten Direktnachrichten Direktnachrichten Mein Benutzername @@ -2129,7 +2129,7 @@ Verpasster Sprachanruf %d verpasste Sprachanrufe - Heimserver API URL + Heim-Server API URL Um Sprachnachrichten zu senden, erlaube bitte Zugriff aufs Mikrofon. Um fortzufahren, erlaube bitte in den Systemeinstellungen Zugriff auf die Kamera. Für diese Aktion fehlen einige Berechtigungen, bitte erlaube diese in den Systemeinstellungen. @@ -2243,8 +2243,8 @@ Auffindungseinstellungen öffnen Sitzung abgemeldet! Raum verlassen! - Heimserver auswählen - Es konnte kein Heimserver mit der Adresse %s gefunden werden. Bitte überprüfe die Adresse oder wähle den Heimserver manuell. + Heim-Server auswählen + Es konnte kein Heim-Server mit der Adresse %s gefunden werden. Bitte überprüfe die Adresse oder wähle den Heim-Server manuell. Untergeordneten Space hinzufügen. Bist du dir wirklich sicher, dass du diese Informationen senden willst\? E-Mail-Adressen und Telefonnummern an %s senden @@ -2259,16 +2259,16 @@ \n%s kannst du alle unsere Bedingungen lesen. Stelle sicher, dass die richtigen Personen Zugriff auf %s haben. Du kannst jederzeit weitere Personen einladen. Wer ist Mitglied deines Teams\? - Der Identitätsserver gibt keine Bedingungen an - Bedingungen des Identitätsservers ausblenden - Bedingungen des Identitätsservers anzeigen + Der Identitäts-Server gibt keine Bedingungen an + Richtlinie des Identitäts-Servers ausblenden + Bedingungen des Identitäts-Servers anzeigen Systemeinstellungen Versionen Erhalte Hilfe bei der Bedienung von ${app_name} Hilfe und Unterstützung Hilfe Rechtliches - Entscheide, welche Spaces Zugriff auf den Raum haben sollen. Die Mitglieder der Spaces können diesen Räumen beitreten. + Entscheide, welche Spaces Zugriff auf den Raum haben sollen. Die Mitglieder der Spaces können diesen Räumen betreten. hier Hilf mit, ${app_name} zu verbessern Aktivieren @@ -2296,15 +2296,15 @@ Auffindbarkeit (%s) Per E-Mail einladen, finde deine Kontakte und mehr… Schließe die Konfiguration des Auffindbarkeitsdienstes ab. - Du verwendest derzeit keinen Identitätsserver. Um Teammitglieder einzuladen und für sie auffindbar zu sein, müssen du einen solchen Server konfigurieren. - Ich habe schon ein Konto - Sichere Nachrichtenübertragung. - Besitze deine Konversationen. - Um bestehende Kontakte ermitteln zu können, müsst du Kontaktinformationen (E-Mails und Telefonnummern) an Ihren Identitätsserver senden. Wir verschlüsseln deine Daten vor dem Senden, um den Datenschutz zu gewährleisten. - Deine Kontakte sind privat. Um in deinen Kontakten Benutzer erkennen zu können, benötigen wir deine Erlaubnis, Kontaktinformationen an deinen Identitätsserver zu senden. + Du verwendest derzeit keinen Identitäts-Server. Um Team-Mitglieder einzuladen und für sie auffindbar zu sein, konfiguriere zunächst einen. + Ich habe bereits ein Konto + Sichere Kommunikation. + Besitze deine Unterhaltungen. + Um bestehende Kontakte ermitteln zu können, musst du Kontaktinformationen (E-Mail-Adressen und Telefonnummern) an deinen Identitäts-Server übermitteln. Wir verschlüsseln deine Daten vor der Übermittlung, um den Datenschutz gewährleisten zu können. + Deine Kontakte sind privat. Um unter deinen Kontakten Matrix-Nutzer finden zu können, benötigen wir deine Erlaubnis, Kontaktinformationen an deinen Identitäts-Server zu übermitteln. Dieser Server stellt keine Richtlinie bereit. - Deine Identitätsserver-Richtlinie - Deine Heimserver Richtlinie + Richtlinie deines Identitäts-Servers + Richtlinie deines Heim-Servers ${app_name} Richtlinie Abstimmung erstellen Kontakte öffnen @@ -2340,10 +2340,10 @@ Umfrage bearbeiten Keine Stimmen abgegeben Konto erstellen - Nachrichtenaustausch für dein Team. + Kommunikation für dein Team. Ende-zu-Ende-verschlüsselt und ohne Telefonnummer nutzbar. Keine Werbung oder Datenerfassung. - Wähle wo deine Gespräche liegen, für Kontrolle und Unabhängigkeit. Verbunden mit Matrix. - Sichere und unabhängige Kommunikation, die für die gleiche Vertraulichkeit sorgt, wie ein Gespräch von Angesicht zu Angesicht in deinem eigenen Zuhause. + Wähle, wo deine Unterhaltungen gespeichert werden, um Kontrolle und Unabhängigkeit zu erhalten. Verbunden via Matrix. + Sichere und unabhängige Kommunikation, die für eine Vertraulichkeit sorgt, wie ein Gespräch von Angesicht zu Angesicht in deinen eigenen vier Wänden. Standort Die Verschlüsselung ist fehlerhaft konfiguriert Bitte kontaktiere einen Admin, um die Verschlüsselung zurückzusetzen. @@ -2363,10 +2363,10 @@ Communities Teams Wir helfen dir, in Verbindung zu kommen - Mit wem wirst du am meisten chatten\? + Mit wem wirst du am meisten schreiben\? Link zu Thread kopieren Threads anzeigen - Nachrichtenblasen anzeigen + Nachrichtenblasen Laden der Karte fehlgeschlagen Karte Hinweis: App wird neugestartet @@ -2401,7 +2401,7 @@ Beenden Live-Standort aktiviert Standort teilen - Standort teilen + Diesen Standort teilen Meinen Standort teilen Meinen Standort teilen Live-Standort teilen @@ -2409,19 +2409,19 @@ Threads nähern sich der Beta 🎉 Deaktivieren BETA - Feedback geben + Rückmeldung geben BETA - Threads Beta + Threads-Beta Threads Beta Bildschirm teilen - Ausprobieren + Probiere es aus Live bis %1$s Wähle Deine Benachrichtigungsmethode Vorläufige Implementierung: Standorte bleiben im Nachrichtenverlauf von Räumen erhalten Profil-Tag: h Standortfreigabe aktivieren - Bitte beachten: Dies ist eine Testfunktion mit einer vorübergehenden Implementierung. Das bedeutet, dass Du Deinen Standortverlauf nicht löschen kannst und dass fortgeschrittene Nutzer Deinen Standortverlauf auch dann noch sehen können, wenn Du Deinen Live-Standort nicht mehr mit diesem Raum teilst. + Bitte beachte: Dies ist eine experimentelle Funktion, die eine temporäre Implementierung nutzt. Das bedeutet, dass du deinen Standortverlauf nicht löschen kannst und erfahrene Nutzer ihn sehen können, selbst wenn du deinen Live-Standort nicht mehr mit diesem Raum teilst. Live-Standortfreigabe Aktuelles Gateway: %s Gateway @@ -2464,7 +2464,7 @@ %1$d Minuten %2$d Sekunden %1$s, %2$s, %3$s Die neuesten Profilinformationen (Avatar und Anzeigename) für alle Nachrichten anzeigen. - Aktuelle Benutzerinformationen anzeigen + Aktuelle Profilinformationen Sieht gut aus! einen Anzeigenamen wählen Zurück zum Home-Screen @@ -2480,11 +2480,11 @@ Präsenz Animierte Bilder in der Zeitleiste abspielen, sobald sie sichtbar sind Animierte Bilder automatisch abspielen - Das Endpunkt-Token konnte nicht auf dem Heimserver registriert werden: + Das Endpunkt-Token konnte nicht auf dem Heim-Server registriert werden: \n%1$s - Endpunkt erfolgreich beim Heimserver registriert. + Endpunkt erfolgreich beim Heim-Server registriert. Endpunkt-Registrierung - Dein Heimserver unterstützt derzeit keine Threads, daher kann diese Funktion evtl. nicht richtig funktionieren. Einige Nachrichten mit Threads sind möglicherweise nicht zuverlässig verfügbar. %sMöchtest Du Threads trotzdem aktivieren\? + Dein Heim-Server unterstützt derzeit keine Threads, daher könnte diese Funktion evtl. nicht richtig funktionieren. Einige Nachrichten mit Threads sind möglicherweise nicht zuverlässig verfügbar. %sMöchtest Du Threads trotzdem aktivieren\? Threads helfen dabei, Unterhaltungen beim Thema zu halten und leichter zu verfolgen. %sDie Aktivierung von Threads aktualisiert die App. Dies kann bei einigen Konten länger dauern. Wir nähern uns der Veröffentlichung einer öffentlichen Beta für Threads. \n @@ -2506,7 +2506,7 @@ Beschäftigt Die biometrische Authentifizierung konnte nicht aktiviert werden. Die biometrische Authentifizierung wurde deaktiviert, weil kürzlich eine neue biometrische Authentifizierungsmethode hinzugefügt wurde. Du kannst sie in den Einstellungen wieder aktivieren. - Der Heimserver akzeptiert keine Benutzernamen, die nur aus Ziffern bestehen. + Der Heim-Server akzeptiert keine Benutzernamen, die nur aus Ziffern bestehen. teilten ihren Live-Standort Schritt überspringen Speichern und fortfahren @@ -2521,13 +2521,13 @@ Profil personalisieren ${app_name} ist auch für den Arbeitsplatz geeignet. Die sichersten Organisationen der Welt vertrauen darauf. Threads sind noch in Arbeit, und es stehen neue, aufregende Funktionen an, wie z. B. verbesserte Benachrichtigungen. Wir würden uns sehr über Dein Feedback freuen! - Nachrichten in diesem Chat werden Ende-zu-Ende-verschlüsselt. + Nachrichten in dieser Unterhaltung werden Ende-zu-Ende-verschlüsselt. Bist du ein Mensch\? Bitte lies dir %ss Bedingungen und Richtlinien durch Server-Richtlinien Folge den Anweisungen, die an %s gesendet wurden E-Mail bestätigen - Ergebnisse sind nach Beenden der Abstimmung sichtbar + Ergebnisse werden nach Abschluss der Abstimmung sichtbar sein Prüfe deine E-Mails. Passwort zurücksetzen Gib mindestens 8 Zeichen ein. @@ -2550,12 +2550,12 @@ %d Nachricht gelöscht %d Nachrichten gelöscht - Keine Element Call-Berechtigungsabfragen - Bestätige automatisch Element Call-Widgets und erlaube Kamera- und Mikrofonzugriff + Keine Element-Call-Berechtigungsabfragen + Bestätige automatisch Element-Call-Widgets und erlaube Kamera- und Mikrofonzugriff Los ändern oder - Das Zuhause deiner Gespräche + Der Ort, an dem deine Gespräche stattfinden Das zukünftige Zuhause für deine Gespräche Systemstandard nutzen Automatisch festlegen @@ -2565,9 +2565,9 @@ E-Mail nicht bestätigt, prüfe deinen Posteingang Willkommen zurück! Passwort vergessen - Benutzername / E-Mail / Telefon + Nutzername / E-Mail-Adresse / Telefonnummer Erstelle dein Konto - Serveradresse + Server-URL Wie lautet die Adresse deines Servers\? Das wird eine Art Zuhause für deine Daten Wie lautet die Adresse deines Servers\? Muss 8 oder mehr Zeichen umfassen @@ -2585,7 +2585,7 @@ Raum erstellen Ungelesene Personen - Schreibe deine erste Nachricht, um %s zur Konversation einzuladen + Schreibe deine erste Nachricht, um %s zur Unterhaltung einzuladen Alle Sitzungen anzeigen (V2, in Arbeit) Für bestmögliche Sicherheit verifiziere deine Sitzungen und melde dich von allen ab, die du nicht erkennst oder nutzt. Andere Sitzungen @@ -2595,7 +2595,7 @@ Favoriten Alle Karte laden nicht möglich -\nDieser Heimserver könnte für die Kartendarstellung nicht konfiguriert sein. +\nDieser Heim-Server könnte für die Kartendarstellung nicht konfiguriert sein. Einstellungen öffnen Dieser QR-Code ist fehlerhaft. Bitte versuche es mit einer anderen Methode. Du wirst deinen verschlüsselten Nachrichtenverlauf nicht abrufen können. Um neu zu beginnen, setze deine Sicherung und Verifizierungsschlüssel zurück. @@ -2619,24 +2619,94 @@ Entschuldigung, dieser Raum wurde nicht gefunden. \nBitte versuche es später erneut.%s Einladungen - Nicht verifiziert · Letzte Aktivität %1$s + Nicht verifiziert · Neueste Aktivität %1$s Nicht verifizierte Sitzung - Nicht verifizierte Sitzung + Nicht verifizierte Sitzungen Verbessere deine Kontosicherheit, indem du diese Empfehlungen beherzigst. Sicherheitsempfehlungen Inaktiv seit %1$d+ Tag (%2$s) Inaktiv seit %1$d+ Tagen (%2$s) - Verifiziert · Letzte Aktivität %1$s + Verifiziert · Neueste Aktivität %1$s Verifizierte Sitzung Unbekannter Gerätetyp Nichts Neues. - Spaces sind eine neue Art, Räume und Personen zu organisieren. Erstelle einen Space, um zu beginnen. + Spaces sind eine neue Möglichkeit, Räume und Personen zu gruppieren. Erstelle einen Space, um zu beginnen. Noch keine Spaces. Hier werden deine ungelesenen Nachrichten erscheinen, wenn du welche hast. Es gibt nichts Neues. Alle Unterhaltungen Space wechseln Unterhaltung beginnen - + Filter + Filtern + Subspaces von %s schließen + Subspaces von %s erweitern + Andere können dich als %s finden + Erstelle Unterhaltungen mit der ersten Nachricht + Verzögerte Direktnachrichten + Historie anzeigen + Probiere es aus + Tippe oben rechts, um eine Rückmeldung zu senden. + Rückmeldung geben + Greife auf deine Spaces (unten rechts) schneller und einfacher denn je zu. + Auf Spaces zugreifen + Um dein ${app_name} zu vereinfachen, sind Tabs nun optional. Verwalte sie mit dem Menü oben rechts. + Willkommen in einer neuen Übersicht! + Die Komplettlösung für sichere Kommunikation unter Freunden, in Gruppen oder in Organisationen. Erstelle eine Unterhaltung oder trete einem bestehenden Raum bei, um loszulegen. + Willkommen bei ${app_name}, +\n%s. + Spaces sind eine neue Möglichkeit, Räume und Personen zu gruppieren. Füge einen bestehenden Raum hinzu oder erstelle einen neuen mit der Schaltfläche unten rechts. + %s +\nsieht ein bisschen leer aus. + IP-Adresse + Sitzungsname + Anwendung, Gerät und Aktivitätsinformationen. + Sitzungsdetails + Filter zurücksetzen + Keine inaktiven Sitzungen gefunden. + Keine nicht verifizierten Sitzungen gefunden. + Keine verifizierten Sitzungen gefunden. + + Erwäge, dich aus alten (ein Tag oder mehr), nicht mehr verwendeten Sitzungen abzumelden. + Erwäge, dich aus alten (%1$d Tage oder mehr), nicht mehr verwendeten Sitzungen abzumelden. + + Inaktiv + Für besonders sichere Kommunikation verifiziere deine Sitzungen oder melde dich von ihnen ab, falls du sie nicht mehr identifizieren kannst. + Nicht verifiziert + Verifiziert + + Inaktiv seit %1$d Tag oder länger + Inaktiv seit %1$d Tagen oder länger + + Inaktiv + Nicht bereit für sichere Kommunikation + Nicht verifiziert + Für sichere Kommunikation bereit + Verifiziert + Alle Sitzungen + Gerät + Sitzung + Aktuelle Sitzung + + Erwäge, dich aus alten (ein Tag oder mehr), nicht mehr verwendeten Sitzungen abzumelden. + Erwäge, dich aus alten (%1$d Tage oder mehr), nicht mehr verwendeten Sitzungen abzumelden. + + Inaktive Sitzungen + Nicht verifizierte Sitzungen verifizieren oder abmelden. + Alle anzeigen (%1$d) + Sitzung verifizieren + Diese Sitzung ist für sichere Kommunikation bereit. + Desktop + Hier erscheinen deine neuen Anfragen und Einladungen. + Ein vereinfachtes Element mit optionalen Tabs + Neues Layout aktivieren + Neueste Aktivität + Neueste Aktivität %1$s + Verifiziere deine aktuelle Sitzung für besonders sichere Kommunikation. + Deine aktuelle Sitzung ist für sichere Kommunikation bereit. + Details anzeigen + Für bestmögliche Sicherheit und Zuverlässigkeit verifiziere diese Sitzungen oder melde dich von ihr ab. + Für die bestmögliche Sicherheit, melde dich von allen Sitzungen ab, die du nicht erkennst oder nicht mehr benutzt. + \ 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 9bd1dd23b7..dbdbbdbb00 100644 --- a/library/ui-strings/src/main/res/values-et/strings.xml +++ b/library/ui-strings/src/main/res/values-et/strings.xml @@ -2594,7 +2594,7 @@ Näita kõiki sessioone (V2, WIP) Parima turvalisuse nimel verifitseeri kõik oma sessioonid ning logi välja neist, mida sa enam ei kasuta. Muud sessioonid - Sessionid + Sessioonid Ava kogukondade loend Alusta uut vestlust või loo uus jututuba Inimesed @@ -2659,4 +2659,46 @@ Siin saavad olema sinu tulevased päringud ja kutsed. Ahenda %s alamkogukonnad Näita %s alamkogukondi - + IP-aadress + Viimati kasutusel + Sessiooni nimi + Rakendus, seade ja kasutamise teave. + Sessiooni teave + Eemalda filter + Ei leidu sessioone, mis pole aktiivses kasutuses. + Verifitseerimata sessioone ei leidu. + Verifitseeritud sessioone ei leidu. + + Kaalu vanadest ja kasutamata sessioonidest väljalogimist (vanemad kui %1$d või enam päeva). + Kaalu vanadest ja kasutamata sessioonidest väljalogimist (vanemad kui %1$d või enam päeva). + + Pole pidevas kasutuses + Turvalise sõnumvahetuse nimel verifitseeri kõik oma sessioonid ning logi neist välja, mida sa enam ei kasuta või ei tunne enam ära. + Verifitseerimata + Parima turvalisuse nimel logi välja neist sessioonidest, mida sa enam ei kasuta või ei tunne ära. + Verifitseeritud + Filtreeri + + Pole olnud kasutusel %1$d või enam päeva + Pole olnud kasutusel %1$d või enam päeva + + Pole pidevas kasutuses + Pole valmis turvaliseks sõnumivahetuseks + Verifitseerimata + Valmis turvaliseks sõnumivahetuseks + Verifitseeritud + Kõik sessioonid + Filtreeri + Viimati kasutusel %1$s + Seade + Sessioonid + Praegune sessioon + Parima turvalisuse ja töökindluse nimel verifitseeri see sessioon või logi ta võrgust välja. + Turvalise sõnumivahetuse nimel palun verifitseeri oma praegune sessioon. + See sessioon on valmis turvaliseks sõnumivahetuseks. + Sinu praegune sessioon on valmis turvaliseks sõnumivahetuseks. + Alusta otsevestlust esimese sõnumiga + Võta kasutusele viivitusega otsevestlused + Lihtsustatud Element valikuliste kaartidega + Võta kasutusele rakenduse uus välimus + \ 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 400a8121f9..9012bc2ebe 100644 --- a/library/ui-strings/src/main/res/values-fa/strings.xml +++ b/library/ui-strings/src/main/res/values-fa/strings.xml @@ -2700,4 +2700,9 @@ ایجاد پیام خصوصی فقط در نخستین پیام المنتی ساده شده با زبانه‌های انتخابی به کار انداختن چینش جدید + تأیید نشست‌هایتان برای پیام‌رسانی امن بهبود یافته یا خروج از آن‌هایی که تشخیصشان نداده یا دیگر استفاده نمی‌کنید. + + غیرفعّال برای ۱ روز یا بیش‌تر + غیرفعّال برای %1$d روز یا بیش‌تر + \ 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 5a19ccf2da..c7100e3a1e 100644 --- a/library/ui-strings/src/main/res/values-fr/strings.xml +++ b/library/ui-strings/src/main/res/values-fr/strings.xml @@ -2668,4 +2668,46 @@ Réduire %s enfants Développer %s enfants Changer d’espace - + Adresse IP + Dernière activité + Nom de la session + Application, appareil et information sur l’activité. + Détails de session + Supprimer les filtres + Aucune session inactive n’a été trouvée. + Aucune session non vérifiée n’a été trouvée. + Aucune session vérifiée n’a été trouvée. + + Pensez à vous déconnecter des anciennes sessions (%1$d jour ou plus) que vous n’utilisez plus. + Pensez à vous déconnecter des anciennes sessions (%1$d jours ou plus) que vous n’utilisez plus. + + Inactif + Vérifiez vos sessions pour améliorer la sécurité de votre messagerie, ou déconnectez celles que vous ne connaissez pas ou n’utilisez plus. + Non vérifié + Pour une meilleure sécurité, déconnectez toutes les sessions que vous ne connaissez pas ou que vous n’utilisez plus. + Vérifié + Filtrer + + Inactif depuis %1$d jour ou plus + Inactif depuis %1$d jours ou plus + + Inactif + Pas prêt pour une messagerie sécurisée + Non vérifié + Prêt pour une messagerie sécurisée + Vérifié + Toutes les sessions + Filtrer + Dernière activité %1$s + Appareil + Session + Cette session + Vérifiez ou déconnectez cette session pour une meilleure sécurité et fiabilité. + Vérifiez votre session pour une sécurité accrue de votre messagerie. + Cette session est prête pour l’envoi de messages sécurisés. + Votre session est prête pour l’envoi de messages sécurisés. + Créer la conversation seulement lors du premier message + Activer les conversations privées différées + Un Element simplifié avec des onglets optionnels + Activer la nouvelle présentation + \ 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 3068556fe4..cac0a2eb5d 100644 --- a/library/ui-strings/src/main/res/values-hu/strings.xml +++ b/library/ui-strings/src/main/res/values-hu/strings.xml @@ -2668,4 +2668,46 @@ A Visszaállítási Kulcsot tartsd biztonságos helyen, mint pl. egy jelszókeze %1$d+ napja inaktív (%2$s) Itt láthatók a meghívók és elvégzendő műveletek. - + IP cím + Utolsó tevékenység + Munkamenet neve + Alkalmazás, eszköz és aktivitás információ. + Munkamenet információk + Szűrő törlése + Nincs inaktív munkamenet. + Nincs ellenőrizetlen munkamenet. + Nincs ellenőrzött munkamenet. + + Fontold meg, hogy kijelentkezel a régi munkamenetekből (%1$d napja vagy régebben használtál) amit már nem használsz. + Fontold meg, hogy kijelentkezel a régi munkamenetekből (%1$d napja vagy régebben használtál) amit már nem használsz. + + Inaktív + Erősítse meg a munkameneteit a még biztonságosabb csevegéshez vagy jelentkezzen ki ezekből, ha nem ismeri fel vagy már nem használja őket. + Ellenőrizetlen + A legjobb biztonság érdekében jelentkezz ki minden olyan munkamenetből amit nem ismersz fel vagy régen használtál már. + Hitelesített + Szűrés + + %1$d napja inaktív + %1$d napja inaktív + + Inaktív + Nem áll készen a biztonságos üzenetküldésre + Ellenőrizetlen + Felkészülve a biztonságos üzenetküldésre + Hitelesített + Minden munkamenet + Szűrés + Utolsó aktivitás %1$s + Eszköz + Munkamenet + Jelenlegi munkamenet + A jobb biztonság vagy megbízhatóság érdekében ellenőrizze vagy jelentkezzen ki ebből a munkamenetből. + Az aktuális munkamenet készen áll a biztonságos üzenetküldésre. + Ez a munkamenet beállítva a biztonságos üzenetküldéshez. + Az aktuális munkamenet készen áll a biztonságos üzenetküldésre. + Közvetlen beszélgetés indítása csak az első üzenettel + Késleltetett közvetlen üzenetek engedélyezése + Egyszerűsített Element opcionálisan lapokkal + Új kinézet engedélyezése + \ 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 3b30950bd1..7b103a9131 100644 --- a/library/ui-strings/src/main/res/values-in/strings.xml +++ b/library/ui-strings/src/main/res/values-in/strings.xml @@ -2617,5 +2617,45 @@ Di masa mendatang proses verifikasi ini akan dimutakhirkan. Belum ada space. Tutup %s anak Buka %s anak - Buat Space - + Ubah Space + Alamat IP + Aktivitas terakhir + Nama sesi + Informasi aplikasi, perangkat, dan aktivitas. + Detail sesi + Hapus Saringan + Tidak ditemukan sesi yang tidak aktif. + Tidak ditemukan sesi yang belum diverifikasi. + Tidak ditemukan sesi yang terverifikasi. + + Pertimbangkan untuk mengeluarkan sesi lawas (%1$d hari atau lebih) yang Anda tidak gunakan lagi. + + Tidak aktif + Verifikasi sesi Anda untuk perpesanan aman yang terbaik atau keluarkan sesi yang Anda tidak kenal atau gunakan lagi. + Belum diverifikasi + Untuk keamanan yang terbaik, keluarkan sesi yang Anda tidak kenal atau gunakan lagi. + Terverifikasi + Saring + + Tidak aktif selama %1$d hari atau lebih + + Tidak aktif + Belum siap untuk perpesanan aman + Belum diverifikasi + Siap untuk perpesanan aman + Terverifikasi + Semua sesi + Saring + Aktivitas terakhir %1$s + Perangkat + Sesi + Sesi Saat Ini + Verifikasi atau keluarkan sesi ini untuk keamanan dan keandalan yang terbaik. + Verifikasi sesi Anda saat ini untuk perpesanan aman yang baik. + Sesi ini siap untuk perpesanan aman. + Sesi Anda saat ini siap untuk perpesanan aman. + Buat pesan langsung hanya pada pesan pertama + Aktifkan pesan langsung tangguhan + Sebuah Element yang sederhana dengan fitur tab opsional + Aktifkan tata letak baru + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-is/strings.xml b/library/ui-strings/src/main/res/values-is/strings.xml index d25d66bfba..69191e1741 100644 --- a/library/ui-strings/src/main/res/values-is/strings.xml +++ b/library/ui-strings/src/main/res/values-is/strings.xml @@ -1536,7 +1536,7 @@ Yfirfarðu þennan tengil Ef þú frumstillir allt Næstum því búið! Bíð eftir staðfestingu… - Bæta við umræðuefni + Bættu við umræðuefni Sannprófa þessa innskráningu Skrá út úr þessari setu Skilaboð við þennan notanda eru enda-í-enda dulrituð þannig að enginn annar getur lesið þau. @@ -1751,7 +1751,7 @@ Forritarahamur Hreinsa persónuleg gögn Taktu þátt ókeypis ásamt milljónum annarra á stærsta almenningsþjóninum - sleppt þessari spurningu + Sleppa þessari spurningu Örugg skilaboð. Gat ekki tengst við auðkennisþjón Dulritunarlyklarnir þínir eru ekki öryggisafritaðir úr þessari setu. @@ -1990,10 +1990,10 @@ Séð af Sleppa þessu skrefi Vista og halda áfram - Kjörstillingarnar þínar hafa verið vistaðar. + Farðu hvenær sem er í stillingarnar til að breyta notandasniðinu þínu. Nú ertu tilbúin(n)! Hefjumst handa - Þú getur breytt þessu hvenær sem er. + Þú getur breytt þessu hvenær sem er Bættu við auðkennismynd Þú getur breytt þessu síðar Birtingarnafn @@ -2012,4 +2012,206 @@ Prófaðu það Gera óvirkt Upphafleg samstillingarbeiðni - + Velkomin í nýja sýn! + Skoða staðsetningu í rauntíma + Sumar niðurstöður gætu verið faldar þar sem þær eru einkamál, þá þarftu boð til að geta séð þær. + Þú ert eini stjórnandi þessa svæðis. Ef þú yfirgefur það verður enginn annar sem er með stjórn yfir því. + Þú munt ekki geta tekið þátt aftur nema þér verði boðið aftur. + Yfirgefa ekkert + Yfirgefa allt + Efni á þessu svæði + Þetta samnefni er ekki aðgengilegt í augnablikinu. +\nPrófaðu aftur síðar, eða spurðu einhvern stjórnanda hvort þú hafir aðgang. + Fara af spjallrás með uppgefið auðkenni (eða fyrirliggjandi spjallrás ef þetta er núll) + Taka þátt í svæði með uppgefið auðkenni + Gat ekki virkjað auðkenningu með lífkennum. + Annars geturðu sett inn slóð á hvaða auðkennisþjón sem er + Heimaþjónninn þinn (%1$s) stingur upp á að nota %2$s sem auðkenningarþjón fyrir þig + Samþykki notandans hefur ekki verið gefið. + Stilltu fyrst auðkennisþjón. + Þessi aðgerð er ekki möguleg. Heimaþjónninn er úreltur. + Deildu þessum kóða með fólki svo viðkomandi geti skannað hann, bætt þér við og byrjað að spjalla. + Heimaþjónn notandans samþykkir ekki notendanöfn einungis með tölustöfum. + Hindra skjámyndatöku af forritinu + Uppsetning tilkynninga + Mistókst að flytja inn lykla + Næstum því búið! Sýnir hitt tækið gátmerki\? + %s svo fólk viti að um hvað málin snúist. + Sendu fyrstu skilaboðin þín til að bjóða %s að spjalla + Þetta er upphafið á þessu samtali. + Þetta er upphafið á %s. + %s bjó til og stillti spjallrásina. + Dulritunin sem notuð er í þessari spjallrás er ekki studd + Dulritun er rangt stillt + Skilaboð í þessu spjalli verða enda-í-enda dulrituð. + Skilaboð í þessari spjallrás eru enda-í-enda dulrituð. Lærðu meira um þetta og yfirfarðu notendur í notandasniðum þeirra. + Ef þú hættir við núna, geturðu tapað dulrituðum skilaboðum og gögnum ef þú missir aðgang að innskráningum þínum. +\n +\nÞú getur víka sett upp örugga afritun og sýslað með dulritunarlyklana þína í stillingunum. + Gef út útbúna auðkennislykla + Set upp endurheimtu. + Ekki nota lykilorðið fyrir aðganginn þinn. + Lykill skilaboða + Þetta var ekki ég + Beiðnir um lykla + ${app_name} fyrir Android + Næstum því búið! Sýnir %s gátmerki\? + Mistókst að ná í setur + + %d virk seta + %d virkar setur + + Engar dulkóðunarupplýsingar tiltækar + Þú hefur ekki heimild til að virkja dulritun á þessari spjallrás. + Kóði var sendur til: %s + Staðfestu símanúmerið þitt + Staðfestingarkóði + Viltu hýsa þinn eigin netþjón\? + Hvert er vistfang netþjónsins þíns\? + Hvert er vistfang netþjónsins þíns\? Þetta er staður sem geymir öll gögnin þín + Veldu netþjón fyrir þig + Þar sem samtölin þín eru + Þar sem samtölin þín verða + Verður að vera að minnsta kosti 8 stafir + Aðrir geta fundið þig %s + %s aðgangur þinn hefur verið útbúinn + Fara á forsíðuna + Persónugera notandasnið + Ætlarðu að ganga til liðs við fyrirliggjandi netþjón\? + Ekki ennþá viss\? %s + Við hverja muntu helst spjalla\? + ${app_name} er líka frábært fyrir vinnustaðinn. Heimsins öruggustu samtök treysta því. + Enda-í-enda dulritað og ekkert símanúmer nauðsynlegt. Engar auglýsingar eða gagnasöfnun. + Veldu hvar á að geyma samtölin þín, sem gefur þér stjórnina og algert sjálfstæði. Tengt í gegnum Matrix. + Örugg og óháð samskipti sem gefa þér færi á að ræða málin í friði rétt eins og þetta sé maður á mann í heimahúsi. + Skilaboð fyrir teymið þitt. + Skrifaðu stikkorð til að finna viðbrögð. + Opna svæðalista + Ekki er hægt að forskoða þessa spjallrás. Viltu taka þátt í henni\? + Þessi spjallrás er ekki aðgengileg í augnablikinu. +\nPrófaðu aftur síðar, eða spurðu einhvern stjórnanda hvort þú hafir aðgang. + Rangt sniðinn atburður, get ekki birt hann + Atburði eytt af notanda + Nýjir lyklar fyrir örugg skilaboð + Hjálpaðu okkur við að greina vandamál og bæta ${app_name} með því að deila nafnlausum gögnum varðandi notkun. Til að skilja hvernig fólk notar saman mörg tæki, munum við útbúa tilviljanakennt auðkenni, sem tækin þín deila. +\n +\nÞú getur lesið alla skilmála okkar %s. + Spila hreyfimyndir sjálfvirkt + Mistókst að skrá endapunkt á heimaþjóninn: +\n%1$s + Það tókst að skrá endapunkt á heimaþjóninn. + Skráning endapunkts + + %1$s og %2$d í viðbót + %1$s og %2$d í viðbót + + Skoða og uppfæra hlutverk sem krafist er til að breyta ýmsum þáttum svæðisins. + Skoða og uppfæra hlutverk sem krafist er til að breyta ýmsum þáttum spjallrásarinnar. + Tölvupóstfang ekki staðfest, athugaðu pósthólfið þitt + Ekkert nýtt. + Engin svæði ennþá. + Einfaldað Element með valkvæðum flipum + Virkja nýja framsetningu + Kjörstillingar framsetningar + Skipta um svæði + Allar spjallrásir + Prófaðu það + Gefðu umsögn + IP-vistfang + Síðasta virkni + Nafn á setu + Nánar um setuna + Hreinsa síu + Engar óvirkar setur fundust. + Engar óstaðfestar setur fundust. + Engar staðfestar setur fundust. + Óvirkt + Óstaðfest + Staðfest + Sía + Óvirkt + Óstaðfest + Staðfest + Allar setur + Sía + Síðasta virkni %1$s + Tæki + Seta + Núverandi seta + Óstaðfestar setur + Skoða allt (%1$d) + Skoða nánar + Sannprófa setu + Óstaðfest seta + Staðfest seta + Óþekkt tegund tækis + Skjáborð + Vefur + Farsími + Virkja deilingu staðsetninga + Netgátt + Aðferð + Samstilling í bakgrunni + Google þjónustur + Deila staðsetningu + %1$s hætti + Niðurstöður birtast einungis eftir að könnuninni hefur lokið + Engar niðurstöður fundust + Opna stillingar + Afritaðu hann á einkageymslu sem þú átt í tölvuskýi + Vistaðu hann á USB-lykil eða öryggisdisk + Prentaðu hann og geymdu á öruggum stað + Settu inn öryggisfrasa sem aðeins þú þekkir, þetta er notað til að verja leyndarmálin sem þú geymir á netþjóninum þínum. + Settu inn %s til að halda áfram. + Tókst ekki að sannreyna þetta tæki + Aðrar setur + Setur + Notandanafn / tölvupóstfang / símanúmer + Ertu mannvera\? + Endurstilling lykilorðs + Gleymt lykilorð + Senda tölvupóst aftur + Skoðaðu tölvupóstinn þinn + Endursenda kóða + Skrá út öll tæki + Endurstilla lykilorð + Veldu nýtt lykilorð + Nýtt lykilorð + Athugaðu tölvupóstinn þinn. + Símanúmer + Settu inn símanúmerið þitt + Tölvupóstur + Settu inn tölvupóstfangið þitt + Hafðu samband + Slóð netþjóns + Velkomin(n) aftur! + Breyta + Eða + Búa til aðganginn þinn + Við munum hjálpa þér að tengjast + Fara + Þessa spjallrás er ekki hægt að forskoða + Uppfæri gögnin þín… + Fólk + Eftirlæti + Ólesið + Allt + Nota sjálfgefnar kerfisstillingar + Velja handvirkt + Setja sjálfvirkt + Veldu leturstærð + %1$s og %2$s + Boðsgestir + A-Ö + Virkni + Raða eftir + Birta nýlegt + Sýna síur + Næsta + sek + mín + klst + Kanna spjallrásir + Búa til spjallrás + Hefja spjall + \ 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 b7b0fe91af..b2f9fa9238 100644 --- a/library/ui-strings/src/main/res/values-it/strings.xml +++ b/library/ui-strings/src/main/res/values-it/strings.xml @@ -2659,4 +2659,46 @@ Riduci contenuto di %s Espandi contenuto di %s Cambia spazio - + Indirizzo IP + Ultima attività + Nome sessione + Applicazione, dispositivo e informazioni di attività. + Dettagli sessione + Annulla filtro + Nessuna sessione inattiva trovata. + Nessuna sessione non verificata trovata. + Nessuna sessione verificata trovata. + + Considera di disconnettere le sessioni vecchie (%1$d giorno o più) che non usi più. + Considera di disconnettere le sessioni vecchie (%1$d giorni o più) che non usi più. + + Inattivo + Verifica le tue sessioni per avere conversazioni più sicure o disconnetti quelle che non riconosci o che non usi più. + Non verificato + Per una maggiore sicurezza, disconnetti tutte le sessioni che non riconosci o che non usi più. + Verificato + Filtra + + Inattivo da %1$d giorno o più + Inattivo da %1$d giorni o più + + Inattivo + Non pronto per messaggi sicuri + Non verificato + Pronto per messaggi sicuri + Verificato + Tutte le sessioni + Filtra + Ultima attività %1$s + Dispositivo + Sessione + Sessione attuale + Verifica o disconnetti questa sessione per una migliore sicurezza e affidabilità. + Verifica la tua sessione attuale per messaggi più sicuri. + Questa sessione è pronta per i messaggi sicuri. + La tua sessione attuale è pronta per i messaggi sicuri. + Attiva messaggi diretti differiti + Crea messaggio diretto solo al primo messaggio + Un Element semplificato con schede opzionali + Attiva nuova disposizione + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-lt/strings.xml b/library/ui-strings/src/main/res/values-lt/strings.xml index adfc70c36e..aeba3d53e6 100644 --- a/library/ui-strings/src/main/res/values-lt/strings.xml +++ b/library/ui-strings/src/main/res/values-lt/strings.xml @@ -1406,4 +1406,781 @@ Keisti kambario pavadinimą Keisti istorijos matomumą %s atnaujino čia. - + Atlikite captcha iššūkį + Pasirinkti pasirinktinį namų serverį + Pasirinkti Element Matrix Services + Pasirinkti matrix.org + Jūsų paskyra dar nesukurta. Sustabdyti registracijos procesą\? + Perspėjimas + Šis vartotojo vardas yra užimtas + Toliau + Slaptažodis + Naudotojo vardas + Naudotojo vardas arba el. pašto adresas + Toliau + Siųsti vėl + Įvesti kodą + Ką tik išsiuntėme kodą į %1$s. Įveskite jį toliau, kad patvirtintumėte, kad tai jūs. + Nustatyti telefono numerį + Neatrodo kaip tinkamas el. pašto adresas + Toliau + Patvirtinkite telefono numerį + El. pašto adresas (nebūtinas) + El. pašto adresas + Toliau + Telefono numeris (nebūtinas) + Jūsų slaptažodis buvo nustatytas iš naujo. + Sėkmė! + Patvirtinau savo el. pašto adresą + Bakstelėkite nuorodą ir patvirtinkite naująjį slaptažodį. Paspaudę joje esančią nuorodą, spustelėkite žemiau. + Patvirtinimo el. laiškas buvo išsiųstas į %1$s. + Patikrinkite savo pašto dėžutę + Šis el. paštas nėra susietas su jokia paskyra + Tęsti + Pakeitus slaptažodį bus iš naujo nustatyti visų jūsų sesijų visapusiško šifravimo raktai, todėl užšifruotų pokalbių istorijos nebus galima perskaityti. Prieš iš naujo nustatydami slaptažodį, sukurkite raktų atsarginę kopiją arba eksportuokite kambario raktus iš kitos sesijos. + Perspėjimas! + Naujas slaptažodis + El. paštas + Toliau + Į jūsų pašto dėžutę bus išsiųstas patvirtinimo el. laiškas, naujo slaptažodžio nustatymo patvirtinimui. + Iš naujo nustatyti slaptažodį %1$s + Šis el. paštas nesusijęs su jokia paskyra. + Programa negali sukurti paskyros šiame namų serveryje. +\n +\nAr norite užsiregistruoti naudodami žiniatinklio klientą\? + Atsiprašome, šis serveris nepriima naujų paskyrų. + Programa negali prisijungti prie šio namų serverio. Namų serveris palaiko šiuos prisijungimo tipus: %1$s. +\n +\nAr norite prisijungti naudodami žiniatinklio klientą\? + Įkeliant puslapį įvyko klaida: %1$s (%2$d) + Įveskite norimo naudoti serverio adresą + Įveskite adresą Modular Element arba serverio kurį norite naudoti + Aukščiausios kokybės talpinimas organizacijoms + Adresas + Element Matrix Services Adresas + Išvalyti istoriją + Tęsti su vienkartiniu prisijungimu + Prisijungti + Registruotis + Prisijungti prie %1$s + Prisijungti prie pasirinktinio serverio + Prisijungti prie Element Matrix Services + Prisijungti prie %1$s + Tęsti + vienkartinis prisijungimas + Prisijungti su %s + Užsiregistruoti su %s + Tęsti su %s + Arba + Pasirinktiniai & išplėstiniai nustatymai + Kitas + Sužinoti daugiau + Aukščiausios kokybės talpinimas organizacijoms + Nemokamai prisijunkite prie milijonų žmonių didžiausiame viešajame serveryje + Kaip ir el. paštas, paskyros turi vienus namus, nors galite bendrauti su bet kuo + Pasirinkti serverį + Aš jau turiu paskyrą + Sukurti paskyrą + Pradėkite + Išplėskite ir pritaikykite savo patirtį + Saugokite pokalbių privatumą naudodami šifravimą + Bendraukite su žmonėmis tiesiogiai arba grupėse + Tai jūsų pokalbis. Priklauso jums. + Praleisti šį žingsnį + Išsaugoti ir tęsti + Bet kada eikite į nustatymus norint atnaujinti savo profilį + Atrodo gerai! + Pirmyn + Laikas prie vardo pridėti veidą + Pridėti profilio nuotrauką + Jūs tai galite pakeisti vėliau + Rodomas vardas + Pasirinkite rodomą vardą + Vartotojo vardas / el. paštas / telefonas + Ar esate žmogus\? + Vykdykite nurodymus, išsiųstus adresu %s + Pamiršau slaptažodį + Slaptažodžio nustatymas iš naujo + Iš naujo siųsti el. laišką + Negavote el. laiško\? + Vykdykite nurodymus, išsiųstus adresu %s + Patvirtinkite savo el. pašto adresą + Iš naujo siųsti kodą + Kodas buvo išsiųstas į %s + Patvirtinkite savo telefono numerį + Atjungti visus prietaisus + Iš naujo nustatyti slaptažodį + Draugai ir šeima + Padėsime jums užmegzti ryšį + Su kuo daugiausiai bendrausite\? + ${app_name} taip pat puikiai tinka darbo vietoje. Ja pasitiki saugiausios pasaulio organizacijos. + Visapusiškai užšifruota ir nereikia telefono numerio. Jokių reklamų ar duomenų rinkimo. + Pasirinkite, kur bus saugomi jūsų pokalbiai, taip suteikdami jums galimybę kontroliuoti ir būti nepriklausomiems. Sujungta naudojant Matrix. + Saugus ir nepriklausomas bendravimas, suteikiantis tiek pat privatumo, kiek ir pokalbis akis į akį jūsų namuose. + Bandykite dar kartą, kai sutiksite su savo namų serverio nuostatomis ir sąlygomis. + Išsamūs žurnalai padės kūrėjams, nes siųsdami piktą purtymą pateiksite daugiau žurnalų. Net ir įjungus šią funkciją, programa nerenka žinučių turinio ar kitų privačių duomenų. + Įjungti išsamius žurnalus. + Sutikite su tapatybės serverio (%s) paslaugų teikimo sąlygomis, kad galėtumėte būti atrandami pagal el. pašto adresą arba telefono numerį. + Šiuo metu bendrinate el. pašto adresus arba telefono numerius tapatybės serveryje %1$s. Norėdami nustoti juos bendrinti, turėsite iš naujo prisijungti prie %2$s. + Tekstinė žinutė buvo išsiųsta adresu %s. Įveskite joje esantį patvirtinimo kodą. + Pasirinktame tapatybės serveryje nėra jokių paslaugų teikimo sąlygų. Tęskite tik tuo atveju, jei pasitikite paslaugos savininku + Tapatybės serveris neturi paslaugų teikimo sąlygų + Įveskite tapatybės serverio url + Nepavyko prisijungti prie tapatybės serverio + Įveskite tapatybės serverio URL + Ar sutinkate siųsti šią informaciją\? + Jei norite atrasti esamus kontaktus, į tapatybės serverį reikia nusiųsti kontaktinę informaciją (el. paštus ir telefono numerius). Prieš išsiunčiant duomenis, siekiant užtikrinti privatumą, juos sutriname. + Pateikti atsiliepimą + Pateikti atsiliepimą + Atsiliepimo nepavyko išsiųsti (%s) + Ačiū, jūsų atsiliepimas sėkmingai išsiųstas + Jei turite papildomų klausimų, galite susisiekti su manimi + Atsiliepimas + BETA + Pasiūlymo nepavyko išsiųsti (%s) + Ačiū, pasiūlymas sėkmingai išsiųstas + Aprašykite savo pasiūlymą čia + Žemiau parašykite savo pasiūlymą. + Pateikti pasiūlymą + Versijos + Gaukite pagalbos naudojant ${app_name} + Pagalba ir parama + Pagalba + Teisės aktai + Pagalba & Apie + Balsas & Vaizdas + Profilio žyma: + Formatas: + Url: + session_name: + app_display_name: + push_key: + app_id: + Jūs jau žiūrite šią temą! + Jūs jau žiūrite šį kambarį! + Importuoti šifravimo raktus iš failo \"%1$s\". + Įvyko klaida gaunant raktų atsarginės kopijos duomenis + Įvyko klaida gaunant pasitikėjimo informaciją + Kambarys sukurtas, tačiau kai kurie kvietimai nebuvo išsiųsti dėl šios priežasties: +\n +\n%s + Kiekvienas galės prisijungti prie šio kambario + Viešas + Tema + Kambario tema (nebūtina) + Pavadinimas + Kambario pavadinimas + Eiti + SUKURTI + Tiesioginės žinutės + Kambariai + Šio kambario negalima peržiūrėti. Ar norite prie jo prisijungti\? + Šiuo metu į šį kambarį patekti negalima. +\nPabandykite vėliau arba paprašykite kambario admino patikrinti, ar turite prieigą. + Šio kambario negalima peržiūrėti + Atnaujinami jūsų duomenys… + Prašome palaukti… + Keisti tinklą + Tinklo nėra. Patikrinkite interneto ryšį. + Sukurti naują kambarį + Neteisingai suformuotas įvykis, negalima rodyti + Įvykis moderuotas kambario admino + Naudotojo ištrintas įvykis + Žinutė pašalinta + Reakcijos + Peržiūrėti reakcijas + Pridėti reakciją + Reakcijos + Žmonės + Parankiniai + Neperskaityti + Visi + Čia bus rodomi jūsų kambariai. Bakstelėkite \"+\" apačioje dešinėje, kad rastumėte esamus kambarius arba pradėtumėte kurti savo. + Kambariai + Jūsų tiesioginių žinučių pokalbiai bus rodomi čia. Bakstelėkite \"+\" apačioje dešinėje, kad pradėtumėte keletą. + Pokalbiai + Neturite daugiau neperskaitytų žinučių + Jūs viską pasivijote! + Pakvietė %s + Išsiuntė jums kvietimą + Pakartoti + Peržiūrėti kambaryje + Atsakyti temoje + Atsakyti + Redaguoti + Atrodo, kad bandote prisijungti prie kito namų serverio. Ar norite atsijungti\? + Jūs nenaudojate jokio tapatybės serverio + Nežinoma klaida + %s nori patvirtinti jūsų sesiją + Patvirtinimo užklausa + Supratau + Patvirtinta! + Parašas + Algoritmas + Versija + + Kuriama atsarginė %d rakto kopija… + Kuriama atsarginė %d raktų kopija… + Kuriama atsarginė %d raktų kopija… + + Visų raktų atsarginė kopija sukurta + Nustatyti saugią atsarginę kopiją + Kuriama raktų atsarginė kopija. Tai gali užtrukti kelias minutes… + Valdyti raktų atsarginėje kopijoje + Nauji saugių žinučių raktai + Naudoti raktų atsarginę kopiją + Niekada nepraraskite užšifruotų žinučių + Apsisaugokite nuo užšifruotų žinučių ir duomenų praradimo + Saugi atsarginė kopija + Išjungta + Kad ištaisyti Matrix programėlių valdymą + Įj./Išj. markdown + Prašymas dalytis raktais + Atsiprašome, šis kambarys nerastas. +\nPrašome bandyti vėliau.%s + Jei norite tęsti, turite sutikti su šios paslaugos sąlygomis. + Nėra aktyvių valdiklių + Užklausoje trūksta user_id. + Užklausoje trūksta room_id. + Galios lygis turi būti teigiamas sveikasis skaičius. + Nepavyko išsiųsti užklausos. + Nepavyko sukurti valdiklio. + Skaityti DRM apsaugotą mediją + Šis valdiklis nori naudoti šiuos išteklius: + Palikti dabartinę konferenciją ir pereiti į kitą\? + Atsiprašome, bandant prisijungti prie konferencijos įvyko klaida + Atsiprašome, konferenciniai skambučiai su Jitsi nepalaikomi senuose įrenginiuose (įrenginiuose su žemesne nei 6.0 Android OS) + Valdiklio ID + Jūsų tema + Jūsų naudotojo ID + Jūsų avataro URL + Jūsų rodomas vardas + Atšaukti prieigą man + Atidaryti naršyklėje + Iš naujo įkelti valdiklį + Nepavyko įkelti valdiklio. +\n%s + Naudojant jį duomenys gali būti bendrinami su %s: + Naudojant jį gali būti nustatyti slapukai ir bendrinami duomenys su %s: + Šį valdiklį pridėjo: + Įkelti valdiklį + Valdiklis + Aktyvūs valdikliai + PERŽIŪRĖTI + + %d aktyvus valdiklis + %d aktyvūs valdikliai + %d aktyvių valdiklių + + Ar tikrai norite ištrinti valdiklį iš šio kambario\? + Milžiniškas + Didžiausias + Didesnis + Didelis + Vidutinis + Mažas + Mažytis + Šrifto dydis + Naudoti sistemos numatytąjį + Pasirinkti rankiniu būdu + Nustatyti automatiškai + Pasirinkti šrifto dydį + %1$s: %2$s %3$s + %1$s: %2$s + ** Nepavyko išsiųsti - atidarykite kambarį + + Naujas pakvietimas + Naujos žinutės + Kambarys + Naujas įvykis + %1$s ir %2$s + %1$s esantys %2$s ir %3$s + %1$s esantys %2$s + + %d pranešimas + %d pranešimai + %d pranešimų + + + %1$s: %2$d žinutė + %1$s: %2$d žinutės + %1$s: %2$d žinučių + + + %d pakvietimas + %d pakvietimai + %d pakvietimų + + + %d kambarys + %d kambariai + %d kambarių + + + %d neperskaityta pranešta žinutė + %d neperskaitytos praneštos žinutės + %d neperskaitytų praneštų žinučių + + Šis serveris jau yra sąraše + Negalima rasti šio serverio arba jo kambarių sąrašo + Įveskite naujo serverio, kurį norite patyrinėti, pavadinimą. + Pridėti naują serverį + Jūsų serveris + Visi vietiniai %s kambariai + Visi kambariai %s serveryje + Serverio pavadinimas + Pasirinkti kambarių katalogą + Jei jie nesutampa, gali kilti pavojus jūsų komunikacijos saugumui. + Patvirtinti + nežinomas ip + Patvirtinta + Nepatvirtinta + + %1$d/%2$d raktas importuotas sėkmingai. + %1$d/%2$d raktai importuoti sėkmingai. + %1$d/%2$d raktų importuota sėkmingai. + + Niekada nesiųsti užšifruotų žinučių į nepatvirtintas sesijas iš šios sesijos. + Šifruoti tik į patvirtintas sesijas + Importuoti + Importuoti raktus iš vietinio failo + Importuoti kambarių raktus + Importuoti šifruotų kambarių raktus + Užšifruotų žinučių atkūrimas + Raktai sėkmingai eksportuoti + Sukurkite slaptafrazę eksportuojamiems raktams užšifruoti. Norėdami importuoti raktus, turėsite įvesti tą pačią slaptafrazę. + Eksportuoti + Eksportuoti raktus į vietinį failą + Eksportuoti kambarių raktus + Eksportuoti šifruotų kambarių raktus + Sesijos raktas + Viešas pavadinimas + Iššifravimo klaida + Nuspręskite, kas gali rasti ir prisijungti prie šio kambario. + Nepavyko gauti dabartinio kambarių katalogo matomumo (%1$s). + Paskelbti šį kambarį viešai %1$s kambarių kataloge\? + Panaikinti šio adreso skelbimą + Paskelbti šį adresą + Pridėti vietinį adresą + Šis kambarys neturi vietinių adresų + Nustatykite šio kambario adresus, kad naudotojai galėtų rasti šį kambarį per jūsų namų serverį (%1$s) + Vietiniai adresai + Naujas skelbiamas adresas (pvz., #pseudonimas:serveris) + Kitų paskelbtų adresų dar nėra. + Kitų paskelbtų adresų dar nėra, pridėkite juos žemiau. + Ištrinti adresą \"%1$s\"\? + Panaikinti adreso \"%1$s\" skelbimą\? + Paskelbti + Paskelbti naują adresą rankiniu būdu + Kiti paskelbti adresai: + Tai yra pagrindinis adresas + Paskelbtus adresus gali naudoti bet kas bet kuriame serveryje, prisijungimui prie jūsų kambario. Norint paskelbti adresą, pirmiausia nustatykite jį kaip vietinį adresą. + Paskelbti adresai + Žetono registracija + Pridėti paskyrą + [%1$s] +\nŠi klaida yra nekontroliuojama ${app_name}. Telefone nėra Google paskyros. Atidarykite paskyrų tvarkytuvę ir pridėkite Google paskyrą. + Šifravimas neteisingai sukonfigūruotas + Šifravimas nėra įjungtas + Šiame pokalbyje žinutės bus visapusiškai užšifruojamos. + Šiame pokalbyje žinutės yra visapusiškai užšifruotos. + Šiame kambaryje žinutės yra visapusiškai užšifruotos. Sužinokite daugiau ir patvirtinkite naudotojus jų profilyje. + Šifravimas įjungtas + Šiame kambaryje naudojamas šifravimas nepalaikomas + Jau beveik! Laukiama patvirtinimo… + Jau beveik! Ar kitas prietaisas rodo varnelę\? + "Tema: " + Pridėkite temą + Siųskite pirmąją žinutę kad pakviestumėte %s į pokalbį + Tai yra jūsų tiesioginių žinučių su %s istorijos pradžia. + Tai šio pokalbio pradžia. + Tai yra %s pradžia. + Jūs prisijungėte. + %s prisijungė. + Sukūrėte ir sukonfigūravote kambarį. + %s sukūrė ir sukonfigūravo kambarį. + Nepavyko importuoti raktų + Laukiama %s… + Ši paskyra buvo deaktyvuota. + Žinutė… + Tikrinamas atsarginės kopijos raktas + Įveskite atkūrimo raktą + Tai netinkamas atkūrimo raktas + Naudoti failą + Norėdami tęsti, įveskite savo %s + Patvirtinkite save ir kitus, kad pokalbiai būtų saugūs + Galimas šifravimo patobulinimas + Tikrinamas atsarginės kopijos raktas (%s) + FCM žetonas sėkmingai užregistruotas namų serveryje. + Naudoti botus, tiltus, valdiklius ir lipdukų paketus + Keisti tapatybės serverį + Siųsti el. paštus ir telefono numerius + Konfigūruoti tapatybės serverį + Atjungti tapatybės serverį + Tapatybės serveris + Patvirtinimo kodas neteisingas. + Kodas + Atrodo, kad serveris neatsako per ilgai, tai gali būti dėl prasto ryšio arba serverio klaidos. Pabandykite dar kartą po kurio laiko. + %s perskaitė + %1$s ir %2$s perskaitė + %1$s, %2$s ir %3$s perskaitė + + %1$s, %2$s ir %3$d kitas perskaitė + %1$s, %2$s ir %3$d kiti perskaitė + %1$s, %2$s ir %3$d kitų perskaitė + + Peršokti į apačią + Uždaryti raktų atsarginės kopijos antraštę + Sukurti naują kambarį + Sukurti naują pokalbį arba kambarį + Sukurti naują tiesioginį pokalbį + Uždaryti kambario kūrimo meniu… + Atidaryti kambario kūrimo meniu + Atidaryti navigacijos stalčių + Siųsti priedą + + %d naudotojas perskaitė + %d naudotojai perskaitė + %d naudotojų perskaitė + + Failas yra per didelis, kad jį būtų galima įkelti. + Pridėti paveikslėlį iš + Šis turinys buvo praneštas kaip nepadorus. +\n +\nJei nenorite matyti daugiau šio naudotojo turinio, galite jį ignoruoti kad paslėpti jo žinutes. + Pranešta kaip nepadorus turinys + Apie šį turinį buvo pranešta kaip apie šlamštą. +\n +\nJei nenorite matyti daugiau šio naudotojo turinio, galite jį ignoruoti kad paslėpti jo žinutes. + Pranešta kaip šlamštas + Buvo pranešta apie šį turinį. +\n +\nJei nenorite matyti daugiau šio naudotojo turinio, galite jį ignoruoti kad paslėpti jo žinutes. + Turinys praneštas + IGNORUOTI NAUDOTOJĄ + PRANEŠTI + Pranešimo apie šį turinį priežastis + Pranešti apie šį turinį + Pasirinktinis pranešimas… + Tai nepadoru + Tai šlamštas + Šiame kambaryje nėra failų + %1$s %2$s + FAILAI + Šiame kambaryje nėra medijos + MEDIJA + %1$d iš %2$d + Nepavyko tvarkyti bendrinimo duomenų + Pasukti ir apkarpyti + Vietovė + Apklausa + Lipdukas + Galerija + Kamera + Kontaktas + Failas + Įveskite raktažodžius, reakcijos radimui. + Spoileris + Siunčia duotą žinutę kaip spoilerį + Nepadarėte jokių pakeitimų + %1$s nepadarė jokių pakeitimų + %1$s padarė šį kambarį tik pakviestiems. + Paviešinote kambarį visiems, kurie žino nuorodą. + %1$s paviešino kambarį visiems, kurie žino nuorodą. + Ilgai spauskite ant kambario, kad pamatytumėte daugiau parinkčių + Jūs neignoruojate jokių naudotojų + Pašalinti iš žemo prioriteto + Pridėti prie žemo prioriteto + Pašalinti iš parankinių + Pridėti prie parankinių + Ignoruoti naudotoją + Visos žinutės (triukšmingas) + Nutildyti + Tik paminėjimai + Visos žinutės + Nustatymai + Kambario nustatymai + Išeiti iš kambario + Padarėte šitai tik pakviestiems. + %1$s padarė šitai tik pakviestiems. + Padarėte šį kambarį tik pakviestiems. + Žinučių siuntimas jūsų komandai. + Saugus žinučių siuntimas. + Jūs viską kontroliuojate. + Turėkite savo pokalbius. + Neperskaitytos žinutės + Dar nesate tikri\? %s + Bendruomenės + Komandos + Redaguoti + Arba + Kur laikomi jūsų pokalbiai + Kur bus laikomi jūsų pokalbiai + Turi būti ne mažiau kaip 8 simboliai + Kiti gali jus atrasti %s + Sukurti savo paskyrą + Jūsų paskyra %s buvo sukurta + Sveikiname! + Pasiimkite mane namo + Suasmeninti profilį + Prisijungti prie serverio + Norite prisijungti prie esamo serverio\? + Praleisti šį klausimą + Sveiki sugrįžę! + Perskaitykite %s sąlygas ir taisykles + Serverio politikos + Patikrinkite savo el. paštą. + Susisiekite su mumis + Element Matrix Services (EMS) yra tvirta ir patikima talpinimo paslauga, skirta greitam ir saugiam bendravimui realiuoju laiku. Sužinokite, kaip <a href=\"${ftue_ems_url}\">element.io/ems</a> + Norite turėti savo serverį\? + %s atsiųs jums patvirtinimo nuorodą + Serverio URL + Patvirtinimo kodas + Koks yra jūsų serverio adresas\? + Koks yra jūsų serverio adresas\? Tai tarsi visų jūsų duomenų namai + Pasirinkti savo serverį + Telefono numeris + %s turi patvirtinti jūsų paskyrą + Įveskite savo telefono numerį + El. paštas + %s turi patvirtinti jūsų paskyrą + Įveskite savo el. paštą + Įsitikinkite, kad jis yra 8 ar daugiau simbolių. + Pasirinkite naują slaptažodį + Naujas slaptažodis + Pranešimų tikslai + olm versija + Naudokite integracijų tvarkyklę botams, tiltams, valdikliams ir lipdukų paketams tvarkyti. +\nIntegracijų valdytojai gauna konfigūracijos duomenis ir gali keisti valdiklius, siųsti kvietimus į kambarius ir nustatyti galios lygius jūsų vardu. + Telefonų knygos šalis + Vietiniai kontaktai + Prisegti kambarius su praleistais pranešimais + Pradžios ekranas + Nuorodų peržiūra pokalbyje, kai jūsų namų serveris palaiko šią funkciją. + Įterptinė URL peržiūra + Prisegti kambarius su neperskaitytomis žinutėmis + Integracijos + Kriptografijos raktų valdymas + Kriptografija + Padėkite mums nustatyti problemas ir tobulinti ${app_name} dalydamiesi anoniminiais naudojimo duomenimis. Kad suprastume, kaip žmonės naudojasi keliais įrenginiais, sugeneruosime atsitiktinį identifikatorių, kuriuo dalijasi jūsų įrenginiai. +\n +\nGalite perskaityti visas mūsų sąlygas %s. + Jei įjungta, kitiems naudotojams visada atrodysite neprisijungę, net jei naudosite programą. + Neprisijungęs režimas + Esamumas + Amžinai + 1 mėnuo + 1 savaitė + 3 dienos + Groti užrakto garsą + Pasirinkti + Numatytasis medijos šaltinis + Pasirinkti + Numatytasis glaudinimas + Medija + Pasirinkti šalį + Sutikote siųsti el. paštus ir telefono numerius į šį tapatybės serverį, kad būtų galima atrasti kitus naudotojus iš jūsų kontaktų. + Siųsti el. paštus ir telefono numerius į %s + Duoti sutikimą + Atšaukti mano sutikimą + Jūsų kontaktai yra privatūs. Kad galėtume rasti naudotojus iš jūsų kontaktų, mums reikia jūsų leidimo siųsti kontaktinę informaciją į jūsų tapatybės serverį. + Išsiuntėme jums patvirtinimo el. laišką į %s, pirmiausia patikrinkite savo el. paštą ir spustelėkite patvirtinimo nuorodą + Išsiuntėme jums patvirtinimo el. laišką į %s, patikrinkite savo el. paštą ir spustelėkite patvirtinimo nuorodą + Atrandami telefono numeriai + Atsijungimas nuo tapatybės serverio reiškia, kad jūsų negalės rasti kiti naudotojai ir negalėsite pakviesti kitų el. paštu ar telefonu. + Pridėjus telefono numerį bus rodomos atradimo parinktys. + Pridėjus el. pašto adresą, bus rodomos atradimo parinktys. + Atrandami el. pašto adresai + Šiuo metu nenaudojate tapatybės serverio. Norėdami atrasti esamus žinomus kontaktus ir būti jų atrandami, sukonfigūruokite jį žemiau. + Šiuo metu naudojate %1$s, esamų kontaktų atradimui, kuriuos pažįstate, ir kad būtumėte jų atrandami. + Tapatybės serveris nepateikė jokios politikos + BETA + Temos yra nebaigtas darbas, kuriame bus naujų, įdomių būsimų funkcijų, pvz., patobulinti pranešimai. Norėtume išgirsti jūsų atsiliepimus! + Temų Beta atsiliepimai + Tvarkyti el. paštus ir telefono numerius susietus su jūsų Matrix paskyra + El. paštai ir telefono numeriai + Rodyti visas žinutes nuo %s\? + Jūsų slaptažodis buvo atnaujintas + Slaptažodis nėra tinkamas + Nepavyko atnaujinti slaptažodžio + Naujas slaptažodis + Dabartinis slaptažodis + Keisti slaptažodį + Slaptažodis + Šis telefono numeris jau naudojamas. + Šis el. pašto adresas jau naudojamas. + Patikrinkite savo el. paštą ir spustelėkite jame esančią nuorodą. Kai tai padarysite, spauskite tęsti. + Padėkite tobulinti ${app_name} + ${app_name} renka anoniminę analizę, kad galėtume tobulinti programą. + Pasirinkti kalbą + Kalba + Siųsti analitikos duomenis + Analitika + Tvarkyti atradimo nustatymus. + Atradimas + Deaktyvuoti mano paskyrą + Tai pakeis dabartinį raktą arba frazę. + Generuoti naują saugumo raktą arba nustatyti naują esamos atsarginės kopijos saugumo frazę. + Apsisaugokite nuo užšifruotų žinučių ir duomenų praradimo, darydami šifravimo raktų atsargines kopijas serveryje. + Nustatyti šiame įrenginyje + Nustatyti saugią atsarginę kopiją iš naujo + Nustatyti saugią atsarginę kopiją + Saugi atsarginė kopija + Pridėti žinutės kompozitoriuje mygtuką jaustukų klaviatūros atidarymui + Rodyti jaustukų klaviatūrą + Programinės klaviatūros mygtukas Enter išsiųs žinutę, o ne pridės eilutės pertrauką + Siųsti žinutę su enter + Medijos peržiūra prieš siunčiant + Vibruoti paminėjus naudotoją + Įtraukiami avataro ir rodomojo vardo keitimai. + Rodyti paskyrų įvykius + Kvietimai, pašalinimai ir užblokavimai nėra įtakojami. + Rodyti prisijungimo ir išėjimo įvykius + Paleisti animuotus paveikslėlius laiko juostoje, kai tik jie tampa matomi + Automatinis animuotų vaizdų paleidimas + Naudokite /confetti komandą arba siųskite žinutę, kurioje yra ❄️ arba 🎉 + Rodyti pokalbio efektus + Spustelėkite ant skaitymo kvitų, kad pamatytumėte išsamų sąrašą. + Rodyti skaitymo kvitus + Rodyti laiko žymas 12 valandų formatu + Leidimas naudotis kontaktais + Rodyti laiko žymas visoms žinutėms + Prieš siunčiant žinutes, suformatuoti jas naudojant Markdown sintakse. Tai leidžia atlikti išplėstinį formatavimą, pavyzdžiui, naudoti žvaigždutes tekstui kursyvu rodyti. + Markdown formatavimas + Naudotojo sąsaja + Leisti kitiems naudotojams žinoti, kad rašote. + Norėdami tai daryti, Įjunkite \'Leisti integracijas\' nustatymuose. + Siųsti pranešimus apie rašymą + Trečiųjų šalių bibliotekos + Jūsų tapatybės serverio politika + Jūsų namų serverio politika + ${app_name} politika + Integracijų tvarkyklė + Leisti integracijas + Tapatybės serveris + Namų serveris + Prisijungta kaip + Autentifikacija + %1$s @ %2$s + Paskutinį kartą matytas + Atnaujinti viešą pavadinimą + Viešas pavadinimas + Deaktyvuoti paskyrą + ID + Tai galite bet kada išjungti nustatymuose + Mes <b>nesidalijame</b> informacija su trečiosiomis šalimis + Mes <b>neįrašome ir neprofiliuojame</b> jokių paskyros duomenų + čia + Integracijos yra išjungtos + Šis serveris nepateikia jokios politikos. + Išsiuntėte duomenis skambučiui nustatyti. + Slėpti tapatybės serverio politiką + Rodyti tapatybės serverio politiką + Failas %1$s buvo atsiųstas! + Suglaudinamas vaizdo įrašas %d%% + Suglaudinamas paveikslėlis… + Siunčiamas failas (%1$s / %2$s) + Siunčiama miniatiūra (%1$s / %2$s) + Užšifruojamas failas… + Užšifruojama miniatiūra… + Nerandate to, ko ieškote\? + Laukiama… + Filtruoti pokalbius… + Redagavimų nerasta + Žinutės redagavimai + (redaguota) + Pagrindiniame ekrane pridėti specialų skirtuką neperskaitytiems pranešimams. + Įjungti perbraukimą, kad atsakytumėte laiko juostoje + Ieškoti pavadinimo + Ieškoti pagal vardą, ID arba paštą + Pavadinimas arba ID (#pavyzdys:matrix.org) + Peržiūrėti kambarių katalogą + Siųsti naują tiesioginę žinutę + Tiesioginės žinutės + Sukurti naują kambarį + Pasiūlymai + Žinomi naudotojai + Kuriamas kambarys… + QR kodas + Pridėti pagal QR kodą + Būkite atrandami kitų + Paslaugų teikimo sąlygos + Peržiūrėti redagavimo istoriją + Nuoroda nukopijuota į iškarpinę + Atidaryti atradimo nustatymus + Rodyti pilną istoriją užšifruotuose kambariuose + Rodyti paslėptus įvykius laiko juostoje + Iš naujo nustatyti pranešimų metodą + Registruoti žetoną + Sistemos nustatymai + Nėra registruotų tiesioginių pranešimų vartų + Nėra nustatytų tiesioginų pranešimų taisyklių + Tiesioginių pranešimų taisyklės + Saugumas & Privatumas + Nuostatos + Bendrieji + Kiti trečiųjų šalių pranešimai + Matrix SDK versija + Kambario nustatymai + Rodyti pašalintų žinučių vietoje užrašą + Rodyti pašalintas žinutes + ištrinti iš serverio atsarginę šifravimo raktų kopiją\? Atkūrimo rakto nebegalėsite naudoti užšifruotai žinučių istorijai skaityti. + Ištrinti atsarginę kopiją + Tikrinama atsarginės kopijos būsena + Atsarginė kopija ištrinama… + Jei norite naudoti atsarginę raktų kopiją šioje sesijoje, dabar atkurkite naudodami slaptažodį arba atkūrimo raktą. + Atsarginė kopija turi netinkamą parašą iš nepatvirtintos sesijos %s + Atsarginė kopija turi netinkamą parašą iš patvirtintos sesijos %s + Įjungti sistemos kamerą, vietoj pritaikytos kameros ekrano. + Naudoti vietinę kamerą + Patvirtinkite palygindami šiuos duomenis su naudotojo nustatymais kitoje sesijoje: + Tvarkyti raktų atsarginę kopiją + Tema + Atšaukti nustatymą pagrindiniu adresu + Nustatyti kaip pagrindinį adresą + Tai eksperimentinės funkcijos, kurios gali netikėtai sugesti. Naudokite atsargiai. + Laboratorijos + Kambario versija + Šio kambario vidinis ID + Išplėstiniai + + %d užblokuotas naudotojas + %d užblokuoti naudotojai + %d užblokuotų naudotojų + + Užblokuoti naudotojai + Bet kas gali rasti kambarį ir prisijungti + Viešas + Tik pakviesti žmonės gali rasti ir prisijungti + Privatus (tik su kvietimais) + Privatus + Nežinomas prieigos nustatymas (%s) + Bet kas gali pasibelsti į kambarį, o nariai gali priimti arba atmesti + Tik nariai (nuo jų prisijungimo) + Tik nariai (nuo jų pakvietimo) + Tik nariai (nuo šios parinkties pasirinkimo momento) + Bet kas + Leisti svečiams prisijungti + Pranešti man apie + Peržiūrėti ir tvarkyti šio kambario adresus bei jo matomumą kambarių kataloge. + Kas gali prieiti\? + Pakeitimai, kas gali skaityti istoriją, bus taikomi tik būsimoms šio kambario žinutėms. Esamos istorijos matomumas išliks nepakitęs. + Kas gali skaityti istoriją\? + Kambario istorijos skaitomumas + Paskyros nustatymai + Tema + Kambario adresai + Kambario prieiga + Pranešimus galite tvarkyti %1$s. + Atkreipkite dėmesį, kad pranešimai apie paminėjimus ir raktinius žodžius užšifruotuose kambariuose, nėra prieinami mobiliuosiuose įrenginiuose. + Pranešimų konfigūracija + Įjungus šį nustatymą, prie visų veiksmų pridedamas žymuo FLAG_SECURE. Iš naujo paleiskite programą, kad pakeitimas įsigaliotų. + Neleisti programos ekrano nuotraukų + Biometrinis autentifikavimas buvo išjungtas, nes neseniai buvo pridėtas naujas biometrinis autentifikavimo metodas. Jį vėl galite įjungti nustatymuose. + Nepavyko įjungti biometrinio autentifikavimo. + Atidaryti nustatymus + Sukurti AŽ tik po pirmos žinutės + Įjungti atidėtas AŽ + Supaprastintas Element su nebūtinais skirtukais + Įjungti naują išdėstymą + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-pl/strings.xml b/library/ui-strings/src/main/res/values-pl/strings.xml index b7b73eb9e6..c9bac8977b 100644 --- a/library/ui-strings/src/main/res/values-pl/strings.xml +++ b/library/ui-strings/src/main/res/values-pl/strings.xml @@ -732,7 +732,7 @@ Wysyłaj wiadomości za pomocą klawisza enter Przycisk enter na klawiaturze programowej wyśle wiadomość zamiast wprowadzania łamanania linii Ustawienia wyszukiwania - Ustal jak inni mogą odnaleść twoje konto. + Ustal jak inni mogą odnaleźć twoje konto. Media Domyślne źródło mediów Odzyskiwanie zaszyfrowanych wiadomości @@ -2732,4 +2732,16 @@ Niestety, ten pokój nie został znaleziony. \nSpróbuj ponownie później.%s Zaproszenia - + Tutaj pojawią się rozmowy które nie zostały jeszcze odczytane. + Brak nowych wiadomości. + Zmień przestrzeń + Stwórz prywatny chat dopiero po wysłaniu pierwszej wiadomości + Włącz odroczone prywatne chaty + Odświeżony wygląd Element z opcjonalnymi kartami + Włącz nowy układ + Przestrzenie to nowa metoda na grupowanie razem wielu pokoi i osób. Dodaj tu już istniejący pokój lub stwórz nowy używając przycisku w prawym-dolnym rogu. + Jest to nowa metoda na grupowanie razem wielu pokoi i osób. + %s +\nwygląda nieco pusto. + Brak przestrzeni. + \ 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 817c7646df..108ecc7e38 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 @@ -473,7 +473,7 @@ Você tem certeza que você quer começar uma chamada de vídeo\? Tirar foto Tirar vídeo - Chamar + Chamada Banir usuária(o) vai removê-la(o) desta sala e preveni-la(o) de se juntar de novo. Todas as mensagens Adicionar a tela de Início @@ -2460,7 +2460,7 @@ Threads ajudam manThreads ajudam manter suas conversas em-tópico e fáceis de rastrear. %sHabilitar threads vai refrescar o app. Isto pode tomar mais tempo para algumas contas. Threads Beta Saber mais - Teste aí + Experimentar Compartilhamento de tela está em progresso ${app_name} Compartilhamento de Tela Parar compartilhamento de tela @@ -2633,15 +2633,15 @@ Desculpe, esta sala não tem sido encontrada. \nPor favor retente mais tarde.%s Convites - Teste aí + Experimentar Toque na direita topo para ver a opção para feedback. - Dar Feedback - Acessar seus Espaços (direito fundo) mais rápido e fácio que jamais antes. - Acessar Espaços + Dê Feedback + Acesse seus Espaços (direita fundo) mais rápido e fácil que jamais antes. + Acesse Espaços Para simplificar seu ${app_name}, abas são agora opcionais. Gerencie-as usando o menu direito topo. Boas-vindas a uma nova visão! Isto é onde suas mensagens não-lidas vão aparecer, quando você tiver algumas. - Nada a reportar. + Nada para reportar. O app de chat seguro tudo-em-um para equipes, amigas(os) e organizações. Crie um chat, ou junte-se a uma sala existe, para começar. Boas-vindas a ${app_name}, \n%s. @@ -2658,8 +2658,8 @@ Melhore a segurança de sua conta ao seguir estas recomendações. Recomendações de segurança - Inativa(o) por %1$d+ dia (%2$s) - Inativa(o) por %1$d+ dias (%2$s) + Inativa por %1$d+ dia (%2$s) + Inativa por %1$d+ dias (%2$s) Isto é onde suas novas requisições e convites vão estar. Nada novo. @@ -2668,4 +2668,46 @@ Colapsar filhos de %s Expandir filhos de %s Mudar Espaço + Não-verificadas + Verificadas + Não-verificadas + Verificadas + Inativas + + Inativas por %1$d dia ou mais longo + Inativas por %1$d dias ou mais longo + + Inativas + Endereço de IP + Última atividade + Nome de sessão + Informação de aplicativo, dispositivo, e atividade. + Detalhes de sessão + Limpar Filtro + Nenhuma sessão inativa encontrada. + Nenhuma sessão não-verificada encontrada. + Nenhuma sessão verificada encontrada. + + Considere fazer signout de sessões antigas (%1$d dia ou mais) que você não usa mais. + Considere fazer signout de sessões antigas (%1$d dias ou mais) que você não usa mais. + + Verifique suas sessões para mensageria segura melhorada ou faça signout daquelas que você não reconhece ou usa mais. + Para melhor segurança, faça signout de qualquer sessão que você não reconhece ou usa mais. + Filtrar + Pronta para mensageria segura + Não pronta para mensageria segura + Todas as sessões + Filtrar + Última atividade %1$s + Dispositivo + Sessão + Sessão Atual + Verifique ou faça signout desta sessão para melhor segurança e fiabilidade. + Verifique sua sessão atual para mensageria segura melhorada. + Esta sessão está pronta para mensageria segura. + Sua sessão atual está pronta para mensageria segura. + Criar DM somente em primeira mensagem + Habilitar DMs diferidas + Um Element simplificado com abas opcionais + Habilitar novo layout 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 7c9d073035..c8eee49d96 100644 --- a/library/ui-strings/src/main/res/values-ru/strings.xml +++ b/library/ui-strings/src/main/res/values-ru/strings.xml @@ -273,7 +273,7 @@ Фильтр названий комнат Приглашения Маловажные - Беседы + Личные сообщения Только Matrix контакты Нет результатов Комнаты @@ -452,7 +452,7 @@ Чтобы убедиться, что этой сессии можно доверять, обратитесь к ее владельцу, используя другие способы (например, лично или по телефону), и спросите, соответствует ли ключ, который он видит в настройках для этой сессии: Если они не совпадают, безопасность вашего общения может быть поставлена под угрозу. Выбор каталога комнат - Имя сервера + Название сервера Все комнаты на сервере %s Все местные комнаты %s Пользовательский интерфейс @@ -907,16 +907,16 @@ Событие удалено пользователем Событие модерируется администратором комнаты Некорректное событие, не могу отобразить - Создать новую комнату + Создать комнату Нет сети. Пожалуйста, проверьте подключение к Интернету. Изменить - Изменить сеть + Изменить сервер Пожалуйста, подождите… Эту комнату нельзя предварительно просмотреть Комнаты Личные сообщения СОЗДАТЬ - Имя + Название Публичная Каждый сможет присоединиться к этой комнате Произошла ошибка при получении информации о доверии @@ -927,7 +927,7 @@ Вы уже просмотрели эту комнату! Общее Предпочтения - Безопасность и конфиденциальность + Безопасность Правила push-уведомлений app_id: push_key: @@ -956,11 +956,11 @@ Изменения не найдены Отфильтровать беседы… Не можете найти нужное\? - Создать новую комнату - Отправить новое личное сообщение - Просмотр каталога комнат - Имя или ID (#example:matrix.org) - Включить жест смахивания для ответа в ленте сообщений + Создать комнату + Отправить личное сообщение + Каталог комнат + Название или ID (#example:matrix.org) + Жест смахивания для ответа в ленте сообщений Ссылка скопирована в буфер обмена Создаем комнату… История изменений @@ -1039,7 +1039,7 @@ Использовать камеру Использовать микрофон Получать доступ к медиа, защищённым DRM - Создать новую комнату + Создать комнату Файл Камера Галерея @@ -1390,7 +1390,7 @@ Вы приняли Подтверждение отправлено Запрос на подтверждение - Подтвердите эту сессию + Заверьте эту сессию Сканируйте код с помощью устройства другого пользователя, чтобы безопасно проверить друг друга Сканировать их код Невозможно сканировать @@ -1450,7 +1450,7 @@ %d сессии активны %d сессий активно - Подтвердите это устройство + Заверьте эту сессию Используйте существующую сессию для подтверждения этой, предоставив ей доступ к зашифрованным сообщениям. Инструменты для разработчиков Данные учётной записи @@ -1473,13 +1473,13 @@ Безопасное резервное копирование Эта сессия является надежной для безопасного обмена сообщениями, поскольку вы подтвердили ее: Подтвердите эту сессию, чтобы пометить её доверенной и предоставить ей доступ к зашифрованным сообщениям. Если вы не входили в эту сессию, ваша учетная запись может быть скомпрометирована: - Проверить - Проверено + Заверить + Заверено Предупреждение Не удалось получить список сессий Сессии - Доверенные - Недоверенные + Заверенная + Незаверенная Эта сессия является доверенной для безопасного обмена сообщениями, так как %1$s (%2$s) проверил(а) его: %1$s (%2$s) вошел(ла), используя новую сессию: Пока этот пользователь не доверяет этой сессии, сообщения, отправленные в обе стороны, помечаются предупреждениями. Кроме того, вы можете подтвердить сессию вручную. @@ -2037,7 +2037,7 @@ Вы здесь единственный человек. Если вы уйдёте, никто не сможет присоединиться в будущем, включая вас. Покинуть Добавить комнаты - Исследуйте комнаты + Обзор комнат %d человек, которого вы знаете, уже присоединился %d людей, которых вы знаете, уже присоединились @@ -2116,7 +2116,7 @@ Сканируйте код с помощью другого устройства или переключитесь и сканируйте с помощью этого устройства Адрес пространства Файл слишком большой для загрузки. - Поиск по имени + Поиск по названию Сжатие видео %d%% Сжатие изображения… Оставить отзыв @@ -2374,11 +2374,11 @@ Опрос Создать опрос Перезапустите приложение, чтобы изменения вступили в силу. - Включить математику LaTeX + Математика LaTeX Ваша система будет автоматически отправлять журналы при возникновении ошибки невозможности расшифровки Автоматически сообщать об ошибках расшифровки. Шифрование неправильно настроено - Изменить цвет отображаемого имени + Изменить цвет имени Восстановить шифрование Обратитесь к администратору, чтобы восстановить шифрование до рабочего состояния. Шифрование настроено неправильно. @@ -2435,7 +2435,7 @@ Не удалось загрузить карту Карта Примечание: приложение будет перезапущено - Включить обсуждения сообщений + Обсуждения сообщений Подключиться к серверу Хотите присоединиться к существующему серверу\? Пропустить вопрос @@ -2507,7 +2507,7 @@ Идёт отправка местоположения Осталось %1$s Обновлено %1$s назад - Включить функцию \"Поделиться трансляцией местоположения\" + Функция \"Поделиться трансляцией местоположения\" ${app_name} Трансляция местоположения Транслировать до %1$s Трансляция завершена @@ -2665,8 +2665,8 @@ Сессии Создать беседу или комнату Показать все сессии (V2, в разработке) - Люди - Настройки макета + ЛС + Настройки вида Фильтры Недавние Избранные @@ -2676,7 +2676,7 @@ Активности Сортировать по Обзор комнат - Начать беседу + Отправить ЛС Создать комнату Посмотреть все (%1$d) Повысьте безопасность учётной записи, следуя этим рекомендациям. @@ -2693,4 +2693,53 @@ Добро пожаловать в ${app_name}, \n%s. Оставить отзыв - + Название сессии + Неактивные + IP-адрес + Последняя активность + Сведения о сессии + Для лучшей безопасности выйдите из всех сессий, которые более не признаёте или не используете. + Заверенные + Все сессии + Последняя активность %1$s + Устройство + Сессия + Текущая сессия + Заверить сессию + Подробности + Эта сессия готова к безопасному обмену сообщениями. + Текущая сессия готова к безопасному обмену сообщениями. + Веб-браузер + Пространства — это новый способ организации комнат и людей. Создайте пространство, чтобы начать. + Новый вид + Нечего отображать. + Здесь будут отображаться непрочитанные сообщения, когда таковые будут. + Присущий системе + Смена пространства + Упрощённый Element с дополнительными вкладками + Добро пожаловать в новый вид! + %s +\nвыглядит слегка пустовато. + Попробовать + Сведения о приложении, устройстве и активности. + Подтвердите текущую сессию для более безопасного обмена сообщениями. + Пока нет пространств. + Подтвердите свои сессии для более безопасного обмена сообщениями или выйдите из тех, которые более не признаёте или не используете. + Подтвердите или выйдите из незаверенных сессий. + Подтвердите или выйдите из этой сессии для лучшей безопасности и надёжности. + Ничего нового. + Заверенных сессий не обнаружено. + Незаверенных сессий не обнаружено. + Неактивных сессий не обнаружено. + Очистить фильтр + Не готовы к безопасному обмену сообщениями + Готовы к безопасному обмену сообщениями + + Неактивны %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 9eb22e3ae3..f37af1a654 100644 --- a/library/ui-strings/src/main/res/values-sk/strings.xml +++ b/library/ui-strings/src/main/res/values-sk/strings.xml @@ -2720,4 +2720,48 @@ Zbaliť %s podpriestory Rozbaliť %s podpriestory Zmeniť priestor - + IP adresa + Posledná aktivita + Názov relácie + Informácie o aplikácii, zariadení a činnosti. + Podrobnosti o relácii + Zrušiť filter + Nenašli sa žiadne neaktívne relácie. + Nenašli sa žiadne neoverené relácie. + Nenašli sa žiadne overené relácie. + + Zvážte odhlásenie zo starých relácií (%1$d deň alebo viac), ktoré už nepoužívate. + Zvážte odhlásenie zo starých relácií (%1$d dni alebo viac), ktoré už nepoužívate. + Zvážte odhlásenie zo starých relácií (%1$d dní alebo viac), ktoré už nepoužívate. + + Neaktívne + Overte si relácie pre vylepšené bezpečné zasielanie správ alebo sa odhláste z tých, ktoré už nepoznáte alebo nepoužívate. + Neoverené + V záujme čo najlepšieho zabezpečenia sa odhláste z každej relácie, ktorú už nepoznáte alebo nepoužívate. + Overené + Filter + + Neaktívny už %1$d deň alebo dlhšie + Neaktívny už %1$d dni alebo dlhšie + Neaktívny už %1$d dní alebo dlhšie + + Neaktívne + Nie je pripravené na bezpečné zasielanie správ + Neoverené + Pripravené na bezpečné zasielanie správ + Overené + Všetky relácie + Filter + Posledná aktivita %1$s + Zariadenie + Relácia + Aktuálna relácia + V záujme čo najvyššej bezpečnosti a spoľahlivosti túto reláciu overte alebo sa z nej odhláste. + Overte svoju aktuálnu reláciu pre vylepšené bezpečné zasielanie správ. + Táto relácia je pripravená na bezpečné zasielanie správ. + Vaša aktuálna relácia je pripravená na bezpečné zasielanie správ. + Vytvoriť priamu správu len pri prvej správe + Povoliť odložené priame správy + Zjednodušený Element s voliteľnými kartami + Zapnúť nové usporiadanie + \ 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 3e511f8459..c4f1658f6b 100644 --- a/library/ui-strings/src/main/res/values-uk/strings.xml +++ b/library/ui-strings/src/main/res/values-uk/strings.xml @@ -818,7 +818,7 @@ URL-адреса аватара Ваше показуване ім\'я Скасувати доступ для мене - Відкрити в переглядачі + Відкрити у браузері Перезавантажити віджет Не вдалося завантажити віджет. \n%s @@ -1196,7 +1196,7 @@ Використати файл Скористатись парольною фразою відновлення або ключем Скористатись відновлювальними парольною фразою або ключем - Використовуйте найостаннішій ${app_name} на ваших інших пристроях, ${app_name} Web, ${app_name} для комп\'ютерів, ${app_name} iOS, ${app_name} для Android, або будь-який інший, здатний до перехресного підписування, Matrix-клієнт + Використовуйте найостаннішій ${app_name} на ваших інших пристроях, ${app_name} браузері, ${app_name} комп\'ютерах, ${app_name} iOS, ${app_name} Android, або будь-який інший, здатний до перехресного підписування, Matrix-клієнт Використовуйте найостаннішій ${app_name} на ваших інших пристроях: Якщо ви не можете доступитись до чинного сеансу Використайте чинний сеанс, щоб звірити цей сеанс, таким чином надавши йому доступ до зашифрованих повідомлень. @@ -2021,7 +2021,7 @@ Не вдалося отримати доступ до безпечного сховища ${app_name} iOS \n${app_name} Android - ${app_name} для переглядача + ${app_name} для браузера \n${app_name} для ПК Не вдалося зберегти медіафайл Це не дійсний ключ відновлення @@ -2701,7 +2701,7 @@ Відкрити налаштування Усі бесіди Показати всі сеанси (V2, WIP) - Для найкращої безпеки перевірте свої сеанси та вийдіть з усіх сеансів, які ви більше не розпізнаєте або не використовуєте. + Звірте свої сеанси та вийдіть з усіх сеансів, які ви більше не розпізнаєте або не використовуєте для кращої безпеки. Інші сеанси Сеанси Відкрити список кімнат @@ -2772,4 +2772,50 @@ Згорнути дочірні елементи %s Розгорнути дочірні елементи %s Змінити простір - + IP-адреса + Остання активність + Назва сеансу + Відомості про застосунок, пристрій та діяльність. + Подробиці сеансу + Очистити фільтр + Неактивних сеансів не знайдено. + Не знайдено не звірених сеансів. + Знайдені не звірені сеанси. + + Подумайте про те, щоб вийти зі старих сеансів (%1$d день або довше), якими ви більше не користуєтесь. + Подумайте про те, щоб вийти зі старих сеансів (%1$d дні або довше), якими ви більше не користуєтесь. + Подумайте про те, щоб вийти зі старих сеансів (%1$d днів або довше), якими ви більше не користуєтесь. + Подумайте про те, щоб вийти зі старих сеансів (%1$d днів або довше), якими ви більше не користуєтесь. + + Неактивний + Звірте свої сеанси для посилення безпеки обміну повідомленнями або вийдіть з тих, які ви більше не впізнаєте або не використовуєте. + Не звірений + Для кращої безпеки виходьте з будь-якого сеансу, який ви більше не впізнаєте або не використовуєте. + Звірений + Фільтрувати + + Неактивний %1$d день або довше + Неактивний %1$d дні або довше + Неактивний %1$d днів або довше + Неактивний %1$d днів або довше + + Неактивний + Не готовий до безпечного обміну повідомленнями + Не звірений + Звірений + Готовий до безпечного обміну повідомленнями + Усі сеанси + Фільтрувати + Остання активність %1$s + Пристрій + Сеанс + Поточний сеанс + Звірте або вийдіть з цього сеансу для кращої безпеки та надійності. + Звірте свій поточний сеанс для посилення безпеки обміну повідомленнями. + Цей сеанс готовий до безпечного обміну повідомленнями. + Ваш поточний сеанс готовий до безпечного обміну повідомленнями. + Створюйте приватні повідомлення лише за надсилання першого повідомлення + Увімкнути відкладені приватні повідомлення + Спрощений Element з опціональними вкладками + Увімкнути новий вигляд + \ 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 db1dab92e2..eba96e82c3 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 @@ -2604,4 +2604,24 @@ 提供反馈 点击右上角查看反馈选项。 试用 - + 空间是对房间和人进行分组的新方式。创建一个空间来开始吧。 + 启用新布局 + IP地址 + 验证你的会话以增强消息传输的安全性,或从那些你不认识或不再使用的会话登出。 + 尚未准备好安全收发消息 + 准备好安全收发消息 + 已验证 + 全部会话 + 筛选 + 上次活跃%1$s + 设备 + 会话 + 当前会话 + 验证你的会话以增强消息传输的安全性。 + 访问你的空间(右下角)比以前更快、更容易。 + 此会话已准备好安全地收发消息。 + 你当前的会话已准备好安全地收发消息。 + 仅在首条消息创建私聊消息 + 启用延迟的私聊消息 + 简化的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 78caa2cc2e..876084d566 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 @@ -2616,4 +2616,44 @@ 折疊 %s 個子空間 展開 %s 個子空間 變更空間 - + IP 位置 + 最後活動 + 工作階段名稱 + 應用程式、裝置與活動資訊。 + 工作階段詳細資訊 + 清除過濾條件 + 找不到不活躍的工作階段。 + 找不到未驗證的工作階段。 + 找不到已驗證的工作階段。 + + 閒置%1$d天或更久 + + + 考慮登出您不再使用的舊工作階段(%1$d天或更久)。 + + 不活躍 + 驗證您的工作階段以強化安全通訊或從您無法識別或不再使用的工作階段登出。 + 未驗證 + 為取得最佳安全性,請從任何您無法識別或不再使用的工作階段登出。 + 已驗證 + 過濾 + 不活躍 + 尚未準備好安全通訊 + 未驗證 + 準備好安全通訊 + 已驗證 + 所有工作階段 + 過濾 + 最後活動 %1$s + 裝置 + 工作階段 + 目前的工作階段 + 驗證或從此工作階段登出以取得最佳安全性與可靠性。 + 驗證您目前的工作階段以強化安全通訊。 + 此工作階段已準備好安全通訊。 + 您目前的工作階段已準備好安全通訊。 + 僅在第一則訊息上建立直接訊息 + 啟用延期直接訊息 + 包含選擇性分頁的簡潔 Element + 啟用新佈局 + \ 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 68e8b495f8..108fe7db7c 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -284,8 +284,8 @@ %1$s turned on end-to-end encryption. You turned on end-to-end encryption. - %1$s turned on end-to-end encryption (unrecognised algorithm %2$s). - You turned on end-to-end encryption (unrecognised algorithm %1$s). + %1$s turned on end-to-end encryption (unrecognized algorithm %2$s). + You turned on end-to-end encryption (unrecognized algorithm %1$s). System Default @@ -406,6 +406,7 @@ Reset Learn more Next + Got it Copied to clipboard @@ -423,7 +424,7 @@ Notifications - Favourites + Favorites People Rooms @@ -445,6 +446,9 @@ Enable deferred DMs Create DM only on first message + Enable rich text editor + Use a rich text editor to send formatted messages + Invites Low priority @@ -773,7 +777,7 @@ Shows all threads from current room My Threads Shows all threads you’ve participated in - Keep discussions organised with threads + Keep discussions organized with threads Threads help keep your conversations on-topic and easy to track. Tip: Long tap a message and use “%s”. @@ -818,7 +822,7 @@ Show the application info in the system settings. Email addresses - No email has been added to your account + No email address has been added to your account Phone numbers Remove %s? Ensure that you have clicked on the link in the email we have sent to you. @@ -827,7 +831,7 @@ Notification importance by event Email notification - To receive email with notification, please associate an email to your Matrix account + To receive email with notification, please associate an email address to your Matrix account Enable email notifications for %s @@ -1093,7 +1097,7 @@ Show all messages from %s? Emails and phone numbers - Manage emails and phone numbers linked to your Matrix account + Manage email addresses and phone numbers linked to your Matrix account Choose a country @@ -1233,6 +1237,9 @@ Import Encrypt to verified sessions only Never send encrypted messages to unverified sessions from this session. + Never send encrypted messages to unverified sessions in this room. + ⚠ There are unverified devices in this room, they won’t be able to decrypt messages you send. + 🔒 You have enabled encrypt to verified sessions only for all rooms in Security Settings. %1$d/%2$d key imported with success. %1$d/%2$d keys imported with success. @@ -1641,7 +1648,7 @@ All Unreads - Favourites + Favorites People Reactions @@ -1698,13 +1705,15 @@ No push rules defined No registered push gateways - app_id: - push_key: - app_display_name: - session_name: + App ID: + Push Key: + App Display Name: + Session Display Name: + Session ID: Url: Format: Profile tag: + Enabled: Voice & Video Help & About @@ -1800,20 +1809,20 @@ You are currently using %1$s to discover and be discoverable by existing contacts you know. You are not currently using an identity server. To discover and be discoverable by existing contacts you know, configure one below. Discoverable email addresses - Discovery options will appear once you have added an email. + Discovery options will appear once you have added an email address. Discovery options will appear once you have added a phone number. Disconnecting from your identity server will mean you won’t be discoverable by other users and you won’t be able to invite others by email or phone. Discoverable phone numbers - We sent you a confirm email to %s, check your email and click on the confirmation link - We sent you a confirm email to %s, please first check your email and click on the confirmation link + We sent an email to %s, check your email and click on the confirmation link + We sent an email to %s, please first check your email and click on the confirmation link Send emails and phone numbers - You have given your consent to send emails and phone numbers to this identity server to discover other users from your contacts. + You have given your consent to send email addresses and phone numbers to this identity server to discover other users from your contacts. Your contacts are private. To discover users from your contacts, we need your permission to send contact info to your identity server. Revoke my consent Give consent - Send emails and phone numbers to %s - To discover existing contacts, you need to send contact info (emails and phone numbers) to your identity server. We hash your data before sending for privacy. + Send email addresses and phone numbers to %s + To discover existing contacts, you need to send contact info (email addresses and phone numbers) to your identity server. We hash your data before sending for privacy. Do you agree to send this info? Enter an identity server URL @@ -1843,7 +1852,7 @@ Close the create room menu… Create a new direct conversation Create a new conversation or room - Create a new room + Create a new room Open spaces list Close keys backup banner Jump to bottom @@ -1871,6 +1880,7 @@ "Sticker" Poll Location + Voice Broadcast Rotate and crop Couldn\'t handle share data @@ -2037,7 +2047,7 @@ It\'s your conversation. Own it. Chat with people directly or in groups Keep conversations private with encryption - Extend & customise your experience + Extend & customize your experience Get started Create account I already have an account @@ -2080,7 +2090,7 @@ Sorry, this server isn’t accepting new accounts. The application is not able to create an account on this homeserver.\n\nDo you want to signup using a web client? - This email is not associated to any account. + This email address is not associated to any account. Reset password on %1$s @@ -2093,7 +2103,7 @@ Changing your password will reset any end-to-end encryption keys on all of your sessions, making encrypted chat history unreadable. Set up Key Backup or export your room keys from another session before resetting your password. Continue - This email is not linked to any account + This email address is not linked to any account Check your inbox @@ -2110,7 +2120,7 @@ Your password is not yet changed.\n\nStop the password change process? Set email address - Set an email to recover your account. Later, you can optionally allow people you know to discover you by your email. + Set an email address to recover your account. Later, you can optionally allow people you know to discover you by this address. Email Email (optional) Next @@ -2262,8 +2272,8 @@ Shared their live location Waiting… - %s cancelled - You cancelled + %s canceled + You canceled %s accepted You accepted Verification Sent @@ -2367,9 +2377,6 @@ Manage Sessions Sign out of this session Sessions - Other sessions - For best security, verify your sessions and sign out from any session that you don’t recognize or use anymore. - Server name Server version Server file upload limit @@ -2408,7 +2415,7 @@ This session is trusted for secure messaging because %1$s (%2$s) verified it: %1$s (%2$s) signed in using a new session: - Until this user trusts this session, messages sent to and from it are labelled with warnings. Alternatively, you can manually verify it. + Until this user trusts this session, messages sent to and from it are labeled with warnings. Alternatively, you can manually verify it. Initialize CrossSigning @@ -2477,9 +2484,9 @@ One of the following may be compromised:\n\n- Your password\n- Your homeserver\n- This device, or the other device\n- The internet connection either device is using\n\nWe recommend you change your password & recovery key in Settings immediately. - Verification has been cancelled. You can start verification again. + Verification has been canceled. You can start verification again. This QR code looks malformed. Please try to verify with another method. - Verification Cancelled + Verification Canceled Recovery Passphrase Message Key @@ -2580,6 +2587,9 @@ Prevent screenshots of the application Enabling this setting adds the FLAG_SECURE to all Activities. Restart the application for the change to take effect. + Incognito keyboard + "Request that the keyboard should not update any personalized data such as typing history and dictionary based on what you've typed in conversations. Notice that some keyboards may not respect this setting." + Could not save media file Set a new account password… @@ -2676,7 +2686,7 @@ Please first configure an identity server. Please first accepts the terms of the identity server in the settings. - For your privacy, ${app_name} only supports sending hashed user emails and phone number. + For your privacy, ${app_name} only supports sending hashed user email addresses and phone numbers. The association has failed. There is no current association with this identifier. The user consent has not been provided. @@ -2915,7 +2925,7 @@ Who are you working with? Make sure the right people have access to %s. Just me - A private space to organise your rooms + A private space to organize your rooms Me and teammates A private space for you & your teammates Public @@ -3073,7 +3083,7 @@ This invite to this space was sent to %s which is not associated with your account - Link this email with your account + Link this email address with your account %s in Settings to receive invites directly in ${app_name}. @@ -3180,6 +3190,7 @@ Open contacts Create poll Share location + Start a voice broadcast Show less @@ -3230,6 +3241,8 @@ Show All Sessions (V2, WIP) + Other sessions + For best security, verify your sessions and sign out from any session that you don’t recognize or use anymore. Mobile Web Desktop @@ -3295,6 +3308,8 @@ Sign out of this session Session details Application, device, and activity information. + Push notifications + Receive push notifications on this session. Session name Session ID Last activity @@ -3306,6 +3321,15 @@ SIGN IN WITH QR CODE You can use this device to sign in a mobile or web device with a QR code. There are two ways to do this: + Inactive sessions + Inactive sessions are sessions you have not used in some time, but they continue to receive encryption keys.\n\nRemoving inactive sessions improves security and performance, and makes it easier for you to identify if a new session is suspicious. + Unverified sessions + Unverified sessions are sessions that have logged in with your credentials but not been cross-verified.\n\nYou should make especially certain that you recognise these sessions as they could represent an unauthorised use of your account. + Verified sessions + Verified sessions have logged in with your credentials and then been verified, either using your secure passphrase or by cross-verifying.\n\nThis means they hold encryption keys for your previous messages, and confirm to other users you are communicating with that these sessions are really you. + Renaming sessions + Other users in direct messages and rooms that you join are able to view a full list of your sessions.\n\nThis provides them with confidence that they are really speaking to you, but it also means they can see the session name you enter here. + %s\nis looking a little empty. diff --git a/library/ui-styles/src/main/res/values/dimens.xml b/library/ui-styles/src/main/res/values/dimens.xml index 758dd6e978..0fb03f0ea3 100644 --- a/library/ui-styles/src/main/res/values/dimens.xml +++ b/library/ui-styles/src/main/res/values/dimens.xml @@ -50,9 +50,9 @@ 28dp - 62dp - 300dp - 12dp + 6dp + 350sp + 8dp 0.05 diff --git a/library/ui-styles/src/main/res/values/stylable_session_overview_entry_view.xml b/library/ui-styles/src/main/res/values/stylable_session_overview_entry_view.xml index d3884f247d..6428cd6eac 100644 --- a/library/ui-styles/src/main/res/values/stylable_session_overview_entry_view.xml +++ b/library/ui-styles/src/main/res/values/stylable_session_overview_entry_view.xml @@ -6,4 +6,10 @@ + + + + + + diff --git a/library/ui-styles/src/main/res/values/stylable_sessions_list_header_view.xml b/library/ui-styles/src/main/res/values/stylable_sessions_list_header_view.xml index f0907c8087..098ec263fc 100644 --- a/library/ui-styles/src/main/res/values/stylable_sessions_list_header_view.xml +++ b/library/ui-styles/src/main/res/values/stylable_sessions_list_header_view.xml @@ -4,7 +4,7 @@ - + diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index 1ed3aff057..ea2b5d6c47 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -60,7 +60,7 @@ android { // that the app's state is completely cleared between tests. testInstrumentationRunnerArguments clearPackageData: 'true' - buildConfigField "String", "SDK_VERSION", "\"1.5.2\"" + buildConfigField "String", "SDK_VERSION", "\"1.5.4\"" buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\"" buildConfigField "String", "GIT_SDK_REVISION_UNIX_DATE", "\"${gitRevisionUnixDate()}\"" diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CryptoTestHelper.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CryptoTestHelper.kt index cbaa3153df..74292daf15 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CryptoTestHelper.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CryptoTestHelper.kt @@ -313,7 +313,7 @@ class CryptoTestHelper(val testHelper: CommonTestHelper) { val incomingRequest = bobVerificationService.getExistingVerificationRequests(alice.myUserId).first { it.requestInfo?.fromDevice == alice.sessionParams.deviceId } - bobVerificationService.readyPendingVerification(listOf(VerificationMethod.SAS), alice.myUserId, incomingRequest.transactionId!!) + bobVerificationService.readyPendingVerificationInDMs(listOf(VerificationMethod.SAS), alice.myUserId, roomId, incomingRequest.transactionId!!) var requestID: String? = null // wait for it to be readied diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeConfigTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeConfigTest.kt new file mode 100644 index 0000000000..8b12092b79 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeConfigTest.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.crypto + +import androidx.test.filters.LargeTest +import org.amshove.kluent.shouldBe +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.junit.runners.MethodSorters +import org.matrix.android.sdk.InstrumentedTest +import org.matrix.android.sdk.api.session.crypto.MXCryptoError +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent +import org.matrix.android.sdk.common.CommonTestHelper.Companion.runCryptoTest + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +@LargeTest +class E2eeConfigTest : InstrumentedTest { + + @Test + fun testBlacklistUnverifiedDefault() = runCryptoTest(context()) { cryptoTestHelper, _ -> + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true) + + cryptoTestData.firstSession.cryptoService().getGlobalBlacklistUnverifiedDevices() shouldBe false + cryptoTestData.firstSession.cryptoService().isRoomBlacklistUnverifiedDevices(cryptoTestData.roomId) shouldBe false + cryptoTestData.secondSession!!.cryptoService().getGlobalBlacklistUnverifiedDevices() shouldBe false + cryptoTestData.secondSession!!.cryptoService().isRoomBlacklistUnverifiedDevices(cryptoTestData.roomId) shouldBe false + } + + @Test + fun testCantDecryptIfGlobalUnverified() = runCryptoTest(context()) { cryptoTestHelper, testHelper -> + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true) + + cryptoTestData.firstSession.cryptoService().setGlobalBlacklistUnverifiedDevices(true) + + val roomAlicePOV = cryptoTestData.firstSession.roomService().getRoom(cryptoTestData.roomId)!! + + val sentMessage = testHelper.sendTextMessage(roomAlicePOV, "you are blocked", 1).first() + + val roomBobPOV = cryptoTestData.secondSession!!.roomService().getRoom(cryptoTestData.roomId)!! + // ensure other received + testHelper.retryPeriodically { + roomBobPOV.timelineService().getTimelineEvent(sentMessage.eventId) != null + } + + cryptoTestHelper.ensureCannotDecrypt(listOf(sentMessage.eventId), cryptoTestData.secondSession!!, cryptoTestData.roomId) + } + + @Test + fun testCanDecryptIfGlobalUnverifiedAndUserTrusted() = runCryptoTest(context()) { cryptoTestHelper, testHelper -> + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true) + + cryptoTestHelper.initializeCrossSigning(cryptoTestData.firstSession) + cryptoTestHelper.initializeCrossSigning(cryptoTestData.secondSession!!) + + cryptoTestHelper.verifySASCrossSign(cryptoTestData.firstSession, cryptoTestData.secondSession!!, cryptoTestData.roomId) + + cryptoTestData.firstSession.cryptoService().setGlobalBlacklistUnverifiedDevices(true) + + val roomAlicePOV = cryptoTestData.firstSession.roomService().getRoom(cryptoTestData.roomId)!! + + val sentMessage = testHelper.sendTextMessage(roomAlicePOV, "you can read", 1).first() + + val roomBobPOV = cryptoTestData.secondSession!!.roomService().getRoom(cryptoTestData.roomId)!! + // ensure other received + testHelper.retryPeriodically { + roomBobPOV.timelineService().getTimelineEvent(sentMessage.eventId) != null + } + + cryptoTestHelper.ensureCanDecrypt( + listOf(sentMessage.eventId), + cryptoTestData.secondSession!!, + cryptoTestData.roomId, + listOf(sentMessage.getLastMessageContent()!!.body) + ) + } + + @Test + fun testCantDecryptIfPerRoomUnverified() = runCryptoTest(context()) { cryptoTestHelper, testHelper -> + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true) + + val roomAlicePOV = cryptoTestData.firstSession.roomService().getRoom(cryptoTestData.roomId)!! + + val beforeMessage = testHelper.sendTextMessage(roomAlicePOV, "you can read", 1).first() + + val roomBobPOV = cryptoTestData.secondSession!!.roomService().getRoom(cryptoTestData.roomId)!! + // ensure other received + testHelper.retryPeriodically { + roomBobPOV.timelineService().getTimelineEvent(beforeMessage.eventId) != null + } + + cryptoTestHelper.ensureCanDecrypt( + listOf(beforeMessage.eventId), + cryptoTestData.secondSession!!, + cryptoTestData.roomId, + listOf(beforeMessage.getLastMessageContent()!!.body) + ) + + cryptoTestData.firstSession.cryptoService().setRoomBlockUnverifiedDevices(cryptoTestData.roomId, true) + + val afterMessage = testHelper.sendTextMessage(roomAlicePOV, "you are blocked", 1).first() + + // ensure received + testHelper.retryPeriodically { + cryptoTestData.secondSession?.getRoom(cryptoTestData.roomId)?.timelineService()?.getTimelineEvent(afterMessage.eventId)?.root != null + } + + cryptoTestHelper.ensureCannotDecrypt( + listOf(afterMessage.eventId), + cryptoTestData.secondSession!!, + cryptoTestData.roomId, + MXCryptoError.ErrorType.KEYS_WITHHELD + ) + } +} diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeSanityTests.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeSanityTests.kt index 544fe90a73..a36ba8ac02 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeSanityTests.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeSanityTests.kt @@ -33,6 +33,10 @@ import org.junit.runner.RunWith import org.junit.runners.JUnit4 import org.junit.runners.MethodSorters import org.matrix.android.sdk.InstrumentedTest +import org.matrix.android.sdk.api.auth.UIABaseAuth +import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor +import org.matrix.android.sdk.api.auth.UserPasswordAuth +import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse import org.matrix.android.sdk.api.crypto.MXCryptoConfig import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.MXCryptoError @@ -61,7 +65,10 @@ import org.matrix.android.sdk.common.CommonTestHelper import org.matrix.android.sdk.common.CommonTestHelper.Companion.runCryptoTest import org.matrix.android.sdk.common.CommonTestHelper.Companion.runSessionTest import org.matrix.android.sdk.common.SessionTestParams +import org.matrix.android.sdk.common.TestConstants import org.matrix.android.sdk.mustFail +import timber.log.Timber +import kotlin.coroutines.Continuation import kotlin.coroutines.resume // @Ignore("This test fails with an unhandled exception thrown from a coroutine which terminates the entire test run.") @@ -607,6 +614,85 @@ class E2eeSanityTests : InstrumentedTest { ) } + @Test + fun test_EncryptionDoesNotHinderVerification() = runCryptoTest(context()) { cryptoTestHelper, testHelper -> + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom() + + val aliceSession = cryptoTestData.firstSession + val bobSession = cryptoTestData.secondSession + + val aliceAuthParams = UserPasswordAuth( + user = aliceSession.myUserId, + password = TestConstants.PASSWORD + ) + val bobAuthParams = UserPasswordAuth( + user = bobSession!!.myUserId, + password = TestConstants.PASSWORD + ) + + testHelper.waitForCallback { + aliceSession.cryptoService().crossSigningService().initializeCrossSigning(object : UserInteractiveAuthInterceptor { + override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation) { + promise.resume(aliceAuthParams) + } + }, it) + } + + testHelper.waitForCallback { + bobSession.cryptoService().crossSigningService().initializeCrossSigning(object : UserInteractiveAuthInterceptor { + override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation) { + promise.resume(bobAuthParams) + } + }, it) + } + + // add a second session for bob but not cross signed + + val secondBobSession = testHelper.logIntoAccount(bobSession.myUserId, SessionTestParams(true)) + + aliceSession.cryptoService().setGlobalBlacklistUnverifiedDevices(true) + + // The two bob session should not be able to decrypt any message + + val roomFromAlicePOV = aliceSession.getRoom(cryptoTestData.roomId)!! + Timber.v("#TEST: Send a first message that should be withheld") + val sentEvent = sendMessageInRoom(testHelper, roomFromAlicePOV, "Hello")!! + + // wait for it to be synced back the other side + Timber.v("#TEST: Wait for message to be synced back") + testHelper.retryPeriodically { + bobSession.roomService().getRoom(cryptoTestData.roomId)?.timelineService()?.getTimelineEvent(sentEvent) != null + } + + testHelper.retryPeriodically { + secondBobSession.roomService().getRoom(cryptoTestData.roomId)?.timelineService()?.getTimelineEvent(sentEvent) != null + } + + // bob should not be able to decrypt + Timber.v("#TEST: Ensure cannot be decrytped") + cryptoTestHelper.ensureCannotDecrypt(listOf(sentEvent), bobSession, cryptoTestData.roomId) + cryptoTestHelper.ensureCannotDecrypt(listOf(sentEvent), secondBobSession, cryptoTestData.roomId) + + // let's try to verify, it should work even if bob devices are untrusted + Timber.v("#TEST: Do the verification") + cryptoTestHelper.verifySASCrossSign(aliceSession, bobSession, cryptoTestData.roomId) + + Timber.v("#TEST: Send a second message, outbound session should have rotated and only bob 1rst session should decrypt") + + val secondEvent = sendMessageInRoom(testHelper, roomFromAlicePOV, "World")!! + Timber.v("#TEST: Wait for message to be synced back") + testHelper.retryPeriodically { + bobSession.roomService().getRoom(cryptoTestData.roomId)?.timelineService()?.getTimelineEvent(secondEvent) != null + } + + testHelper.retryPeriodically { + secondBobSession.roomService().getRoom(cryptoTestData.roomId)?.timelineService()?.getTimelineEvent(secondEvent) != null + } + + cryptoTestHelper.ensureCanDecrypt(listOf(secondEvent), bobSession, cryptoTestData.roomId, listOf("World")) + cryptoTestHelper.ensureCannotDecrypt(listOf(secondEvent), secondBobSession, cryptoTestData.roomId) + } + private suspend fun VerificationService.readOldVerificationCodeAsync(scope: CoroutineScope, userId: String): Deferred { return scope.async { suspendCancellableCoroutine { continuation -> diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt index e0e662c789..d2aa8020e8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt @@ -61,6 +61,8 @@ interface CryptoService { fun isRoomBlacklistUnverifiedDevices(roomId: String?): Boolean + fun getLiveBlockUnverifiedDevices(roomId: String): LiveData + fun setWarnOnUnknownDevices(warn: Boolean) fun setDeviceVerification(trustLevel: DeviceTrustLevel, userId: String, deviceId: String) @@ -77,6 +79,8 @@ interface CryptoService { fun setGlobalBlacklistUnverifiedDevices(block: Boolean) + fun getLiveGlobalCryptoConfig(): LiveData + /** * Enable or disable key gossiping. * Default is true. @@ -100,7 +104,7 @@ interface CryptoService { */ fun isShareKeysOnInviteEnabled(): Boolean - fun setRoomUnBlacklistUnverifiedDevices(roomId: String) + fun setRoomUnBlockUnverifiedDevices(roomId: String) fun getDeviceTrackingStatus(userId: String): Int @@ -112,7 +116,7 @@ interface CryptoService { suspend fun exportRoomKeys(password: String): ByteArray - fun setRoomBlacklistUnverifiedDevices(roomId: String) + fun setRoomBlockUnverifiedDevices(roomId: String, block: Boolean) fun getCryptoDeviceInfo(userId: String, deviceId: String?): CryptoDeviceInfo? diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/GlobalCryptoConfig.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/GlobalCryptoConfig.kt new file mode 100644 index 0000000000..6405652a68 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/GlobalCryptoConfig.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.crypto + +data class GlobalCryptoConfig( + val globalBlockUnverifiedDevices: Boolean, + val globalEnableKeyGossiping: Boolean, + val enableKeyForwardingOnInvite: Boolean, +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt index 84c25776e7..3ad4f3a87f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt @@ -128,4 +128,17 @@ object EventType { type == CALL_REJECT || type == CALL_REPLACES } + + fun isVerificationEvent(type: String): Boolean { + return when (type) { + KEY_VERIFICATION_START, + KEY_VERIFICATION_ACCEPT, + KEY_VERIFICATION_KEY, + KEY_VERIFICATION_MAC, + KEY_VERIFICATION_CANCEL, + KEY_VERIFICATION_DONE, + KEY_VERIFICATION_READY -> true + else -> false + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/HttpPusher.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/HttpPusher.kt index 1ae23e2b70..1258c5c02f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/HttpPusher.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/HttpPusher.kt @@ -58,6 +58,16 @@ data class HttpPusher( */ val url: String, + /** + * Whether the pusher should actively create push notifications. + */ + val enabled: Boolean, + + /** + * The device ID of the session that registered the pusher. + */ + val deviceId: String, + /** * If true, the homeserver should add another pusher with the given pushkey and App ID in addition * to any others with different user IDs. Otherwise, the homeserver must remove any other pushers diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/Pusher.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/Pusher.kt index b85ab32b21..92ac6c483b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/Pusher.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/Pusher.kt @@ -24,8 +24,9 @@ data class Pusher( val profileTag: String? = null, val lang: String?, val data: PusherData, - - val state: PusherState + val enabled: Boolean, + val deviceId: String?, + val state: PusherState, ) { companion object { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/PushersService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/PushersService.kt index d7958ea3cd..6a27f7af61 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/PushersService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/PushersService.kt @@ -67,6 +67,14 @@ interface PushersService { append: Boolean = true ) + /** + * Enables or disables a registered pusher. + * + * @param pusher The pusher being toggled + * @param enable Whether the pusher should be enabled or disabled + */ + suspend fun togglePusher(pusher: Pusher, enable: Boolean) + /** * Directly ask the push gateway to send a push to this device. * If successful, the push gateway has accepted the request. In this case, the app should receive a Push with the provided eventId. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt index b12d9ed6c8..e97a5be303 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt @@ -43,4 +43,7 @@ object MessageType { // Fake message types for live location events to be able to inherit them from MessageContent const val MSGTYPE_BEACON_INFO = "org.matrix.android.sdk.beacon.info" const val MSGTYPE_BEACON_LOCATION_DATA = "org.matrix.android.sdk.beacon.location.data" + + // Fake message types for voice broadcast events to be able to inherit them from MessageContent + const val MSGTYPE_VOICE_BROADCAST_INFO = "io.element.voicebroadcast.info" } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt index d34ea3c7d3..e7fcabf386 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt @@ -91,7 +91,8 @@ interface RelationService { * Edit a text message body. Limited to "m.text" contentType. * @param targetEvent The event to edit * @param msgType the message type - * @param newBodyText The edited body + * @param newBodyText The edited body in plain text + * @param newFormattedBodyText The edited body with format * @param newBodyAutoMarkdown true to parse markdown on the new body * @param compatibilityBodyText The text that will appear on clients that don't support yet edition */ @@ -99,6 +100,7 @@ interface RelationService { targetEvent: TimelineEvent, msgType: String, newBodyText: CharSequence, + newFormattedBodyText: CharSequence? = null, newBodyAutoMarkdown: Boolean, compatibilityBodyText: String = "* $newBodyText" ): Cancelable @@ -108,13 +110,15 @@ interface RelationService { * This method will take the new body (stripped from fallbacks) and re-add them before sending. * @param replyToEdit The event to edit * @param originalTimelineEvent the message that this reply (being edited) is relating to - * @param newBodyText The edited body (stripped from in reply to content) + * @param newBodyText The plain text edited body (stripped from in reply to content) + * @param newFormattedBodyText The formatted edited body (stripped from in reply to content) * @param compatibilityBodyText The text that will appear on clients that don't support yet edition */ fun editReply( replyToEdit: TimelineEvent, originalTimelineEvent: TimelineEvent, newBodyText: String, + newFormattedBodyText: String? = null, compatibilityBodyText: String = "* $newBodyText" ): Cancelable @@ -133,6 +137,7 @@ interface RelationService { * by the sdk into pills. * @param eventReplied the event referenced by the reply * @param replyText the reply text + * @param replyFormattedText the reply text, formatted * @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present * @param showInThread If true, relation will be added to the reply in order to be visible from within threads * @param rootThreadEventId If show in thread is true then we need the rootThreadEventId to generate the relation @@ -140,6 +145,7 @@ interface RelationService { fun replyToMessage( eventReplied: TimelineEvent, replyText: CharSequence, + replyFormattedText: CharSequence? = null, autoMarkdown: Boolean = false, showInThread: Boolean = false, rootThreadEventId: String? = 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 9cf062356f..de9bcfbf0d 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 @@ -60,12 +60,19 @@ interface SendService { /** * Method to quote an events content. * @param quotedEvent The event to which we will quote it's content. - * @param text the text message to send + * @param text the plain text message to send + * @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 * @return a [Cancelable] */ - fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, rootThreadEventId: String? = null): Cancelable + fun sendQuotedTextMessage( + quotedEvent: TimelineEvent, + text: String, + formattedText: String? = null, + autoMarkdown: Boolean, + rootThreadEventId: String? = null + ): Cancelable /** * Method to send a media asynchronously. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt index d391abf1e6..7341fd922e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt @@ -33,6 +33,7 @@ import org.matrix.android.sdk.api.session.room.model.ReadReceipt import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.model.message.MessageContentWithFormattedBody import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent @@ -181,7 +182,8 @@ fun TimelineEvent.isRootThread(): Boolean { * Get the latest message body, after a possible edition, stripping the reply prefix if necessary. */ fun TimelineEvent.getTextEditableContent(): String { - val lastContentBody = getLastMessageContent()?.body ?: return "" + val lastMessageContent = getLastMessageContent() + val lastContentBody = lastMessageContent.getFormattedBody() ?: return "" return if (isReply()) { extractUsefulTextFromReply(lastContentBody) } else { @@ -199,3 +201,11 @@ fun MessageContent.getTextDisplayableContent(): String { ?: (this as MessageTextContent?)?.matrixFormattedBody?.let { ContentUtils.formatSpoilerTextFromHtml(it) } ?: body } + +fun MessageContent?.getFormattedBody(): String? { + return if (this is MessageContentWithFormattedBody) { + formattedBody + } else { + this?.body + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt index 901700cac6..9c3e0ba1c5 100755 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt @@ -40,6 +40,7 @@ import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.listeners.ProgressListener import org.matrix.android.sdk.api.logger.LoggerTag import org.matrix.android.sdk.api.session.crypto.CryptoService +import org.matrix.android.sdk.api.session.crypto.GlobalCryptoConfig import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.crypto.NewSessionListener import org.matrix.android.sdk.api.session.crypto.OutgoingKeyRequest @@ -1163,6 +1164,10 @@ internal class DefaultCryptoService @Inject constructor( return cryptoStore.getGlobalBlacklistUnverifiedDevices() } + override fun getLiveGlobalCryptoConfig(): LiveData { + return cryptoStore.getLiveGlobalCryptoConfig() + } + /** * Tells whether the client should encrypt messages only for the verified devices * in this room. @@ -1171,39 +1176,28 @@ internal class DefaultCryptoService @Inject constructor( * @param roomId the room id * @return true if the client should encrypt messages only for the verified devices. */ -// TODO add this info in CryptoRoomEntity? override fun isRoomBlacklistUnverifiedDevices(roomId: String?): Boolean { - return roomId?.let { cryptoStore.getRoomsListBlacklistUnverifiedDevices().contains(it) } + return roomId?.let { cryptoStore.getBlockUnverifiedDevices(roomId) } ?: false } /** - * Manages the room black-listing for unverified devices. + * A live status regarding sharing keys for unverified devices in this room. * - * @param roomId the room id - * @param add true to add the room id to the list, false to remove it. + * @return Live status */ - private fun setRoomBlacklistUnverifiedDevices(roomId: String, add: Boolean) { - val roomIds = cryptoStore.getRoomsListBlacklistUnverifiedDevices().toMutableList() - - if (add) { - if (roomId !in roomIds) { - roomIds.add(roomId) - } - } else { - roomIds.remove(roomId) - } - - cryptoStore.setRoomsListBlacklistUnverifiedDevices(roomIds) + override fun getLiveBlockUnverifiedDevices(roomId: String): LiveData { + return cryptoStore.getLiveBlockUnverifiedDevices(roomId) } /** * Add this room to the ones which don't encrypt messages to unverified devices. * * @param roomId the room id + * @param block if true will block sending keys to unverified devices */ - override fun setRoomBlacklistUnverifiedDevices(roomId: String) { - setRoomBlacklistUnverifiedDevices(roomId, true) + override fun setRoomBlockUnverifiedDevices(roomId: String, block: Boolean) { + cryptoStore.blockUnverifiedDevicesInRoom(roomId, block) } /** @@ -1211,8 +1205,8 @@ internal class DefaultCryptoService @Inject constructor( * * @param roomId the room id */ - override fun setRoomUnBlacklistUnverifiedDevices(roomId: String) { - setRoomBlacklistUnverifiedDevices(roomId, false) + override fun setRoomUnBlockUnverifiedDevices(roomId: String) { + setRoomBlockUnverifiedDevices(roomId, false) } /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmEncryption.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmEncryption.kt index fca6fab66c..0b7af9f4d7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmEncryption.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmEncryption.kt @@ -31,6 +31,8 @@ import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.content.RoomKeyWithHeldContent import org.matrix.android.sdk.api.session.events.model.content.WithHeldCode +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.internal.crypto.DeviceListManager import org.matrix.android.sdk.internal.crypto.InboundGroupSessionHolder import org.matrix.android.sdk.internal.crypto.MXOlmDevice @@ -92,7 +94,18 @@ internal class MXMegolmEncryption( ): Content { val ts = clock.epochMillis() Timber.tag(loggerTag.value).v("encryptEventContent : getDevicesInRoom") - val devices = getDevicesInRoom(userIds) + + /** + * When using in-room messages and the room has encryption enabled, + * clients should ensure that encryption does not hinder the verification. + * For example, if the verification messages are encrypted, clients must ensure that all the recipient’s + * unverified devices receive the keys necessary to decrypt the messages, + * even if they would normally not be given the keys to decrypt messages in the room. + */ + val shouldSendToUnverified = isVerificationEvent(eventType, eventContent) + + val devices = getDevicesInRoom(userIds, forceDistributeToUnverified = shouldSendToUnverified) + Timber.tag(loggerTag.value).d("encrypt event in room=$roomId - devices count in room ${devices.allowedDevices.toDebugCount()}") Timber.tag(loggerTag.value).v("encryptEventContent ${clock.epochMillis() - ts}: getDevicesInRoom ${devices.allowedDevices.toDebugString()}") val outboundSession = ensureOutboundSession(devices.allowedDevices) @@ -107,6 +120,11 @@ internal class MXMegolmEncryption( } } + private fun isVerificationEvent(eventType: String, eventContent: Content) = + EventType.isVerificationEvent(eventType) || + (eventType == EventType.MESSAGE && + eventContent.get(MessageContent.MSG_TYPE_JSON_KEY) == MessageType.MSGTYPE_VERIFICATION_REQUEST) + private fun notifyWithheldForSession(devices: MXUsersDevicesMap, outboundSession: MXOutboundSessionInfo) { // offload to computation thread cryptoCoroutineScope.launch(coroutineDispatchers.computation) { @@ -416,15 +434,17 @@ internal class MXMegolmEncryption( * This method must be called in getDecryptingThreadHandler() thread. * * @param userIds the user ids whose devices must be checked. + * @param forceDistributeToUnverified If true the unverified devices will be included in valid recipients even if + * such devices are blocked in crypto settings */ - private suspend fun getDevicesInRoom(userIds: List): DeviceInRoomInfo { + private suspend fun getDevicesInRoom(userIds: List, forceDistributeToUnverified: Boolean = false): DeviceInRoomInfo { // We are happy to use a cached version here: we assume that if we already // have a list of the user's devices, then we already share an e2e room // with them, which means that they will have announced any new devices via // an m.new_device. val keys = deviceListManager.downloadKeys(userIds, false) val encryptToVerifiedDevicesOnly = cryptoStore.getGlobalBlacklistUnverifiedDevices() || - cryptoStore.getRoomsListBlacklistUnverifiedDevices().contains(roomId) + cryptoStore.getBlockUnverifiedDevices(roomId) val devicesInRoom = DeviceInRoomInfo() val unknownDevices = MXUsersDevicesMap() @@ -444,7 +464,7 @@ internal class MXMegolmEncryption( continue } - if (!deviceInfo.isVerified && encryptToVerifiedDevicesOnly) { + if (!deviceInfo.isVerified && encryptToVerifiedDevicesOnly && !forceDistributeToUnverified) { devicesInRoom.withHeldDevices.setObject(userId, deviceId, WithHeldCode.UNVERIFIED) continue } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt index 56eba25249..21e3342365 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.crypto.store import androidx.lifecycle.LiveData import androidx.paging.PagedList +import org.matrix.android.sdk.api.session.crypto.GlobalCryptoConfig import org.matrix.android.sdk.api.session.crypto.NewSessionListener import org.matrix.android.sdk.api.session.crypto.OutgoingKeyRequest import org.matrix.android.sdk.api.session.crypto.OutgoingRoomKeyRequestState @@ -120,11 +121,26 @@ internal interface IMXCryptoStore { fun getRoomsListBlacklistUnverifiedDevices(): List /** - * Updates the rooms ids list in which the messages are not encrypted for the unverified devices. + * A live status regarding sharing keys for unverified devices in this room. * - * @param roomIds the room ids list + * @return Live status */ - fun setRoomsListBlacklistUnverifiedDevices(roomIds: List) + fun getLiveBlockUnverifiedDevices(roomId: String): LiveData + + /** + * Tell if unverified devices should be blacklisted when sending keys. + * + * @return true if should not send keys to unverified devices + */ + fun getBlockUnverifiedDevices(roomId: String): Boolean + + /** + * Define if encryption keys should be sent to unverified devices in this room. + * + * @param roomId the roomId + * @param block if true will not send keys to unverified devices + */ + fun blockUnverifiedDevicesInRoom(roomId: String, block: Boolean) /** * Get the current keys backup version. @@ -516,6 +532,9 @@ internal interface IMXCryptoStore { fun getCrossSigningPrivateKeys(): PrivateKeysInfo? fun getLiveCrossSigningPrivateKeys(): LiveData> + fun getGlobalCryptoConfig(): GlobalCryptoConfig + fun getLiveGlobalCryptoConfig(): LiveData + fun saveBackupRecoveryKey(recoveryKey: String?, version: String?) fun getKeyBackupRecoveryKeyInfo(): SavedKeyBackupKeyInfo? diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt index 6a2ef3bde1..1b52b79746 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt @@ -29,6 +29,7 @@ import io.realm.kotlin.where import org.matrix.android.sdk.api.crypto.MXCRYPTO_ALGORITHM_MEGOLM import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.logger.LoggerTag +import org.matrix.android.sdk.api.session.crypto.GlobalCryptoConfig import org.matrix.android.sdk.api.session.crypto.NewSessionListener import org.matrix.android.sdk.api.session.crypto.OutgoingKeyRequest import org.matrix.android.sdk.api.session.crypto.OutgoingRoomKeyRequestState @@ -445,6 +446,38 @@ internal class RealmCryptoStore @Inject constructor( } } + override fun getGlobalCryptoConfig(): GlobalCryptoConfig { + return doWithRealm(realmConfiguration) { realm -> + realm.where().findFirst() + ?.let { + GlobalCryptoConfig( + globalBlockUnverifiedDevices = it.globalBlacklistUnverifiedDevices, + globalEnableKeyGossiping = it.globalEnableKeyGossiping, + enableKeyForwardingOnInvite = it.enableKeyForwardingOnInvite + ) + } ?: GlobalCryptoConfig(false, false, false) + } + } + + override fun getLiveGlobalCryptoConfig(): LiveData { + val liveData = monarchy.findAllMappedWithChanges( + { realm: Realm -> + realm + .where() + }, + { + GlobalCryptoConfig( + globalBlockUnverifiedDevices = it.globalBlacklistUnverifiedDevices, + globalEnableKeyGossiping = it.globalEnableKeyGossiping, + enableKeyForwardingOnInvite = it.enableKeyForwardingOnInvite + ) + } + ) + return Transformations.map(liveData) { + it.firstOrNull() ?: GlobalCryptoConfig(false, false, false) + } + } + override fun storePrivateKeysInfo(msk: String?, usk: String?, ssk: String?) { Timber.v("## CRYPTO | *** storePrivateKeysInfo ${msk != null}, ${usk != null}, ${ssk != null}") doRealmTransaction(realmConfiguration) { realm -> @@ -624,14 +657,7 @@ internal class RealmCryptoStore @Inject constructor( } override fun saveMyDevicesInfo(info: List) { - val entities = info.map { - MyDeviceLastSeenInfoEntity( - lastSeenTs = it.lastSeenTs, - lastSeenIp = it.lastSeenIp, - displayName = it.displayName, - deviceId = it.deviceId - ) - } + val entities = info.map { myDeviceLastSeenInfoEntityMapper.map(it) } doRealmTransactionAsync(realmConfiguration) { realm -> realm.where().findAll().deleteAllFromRealm() entities.forEach { @@ -1053,25 +1079,6 @@ internal class RealmCryptoStore @Inject constructor( } ?: false } - override fun setRoomsListBlacklistUnverifiedDevices(roomIds: List) { - doRealmTransaction(realmConfiguration) { - // Reset all - it.where() - .findAll() - .forEach { room -> - room.blacklistUnverifiedDevices = false - } - - // Enable those in the list - it.where() - .`in`(CryptoRoomEntityFields.ROOM_ID, roomIds.toTypedArray()) - .findAll() - .forEach { room -> - room.blacklistUnverifiedDevices = true - } - } - } - override fun getRoomsListBlacklistUnverifiedDevices(): List { return doWithRealm(realmConfiguration) { it.where() @@ -1083,6 +1090,37 @@ internal class RealmCryptoStore @Inject constructor( } } + override fun getLiveBlockUnverifiedDevices(roomId: String): LiveData { + val liveData = monarchy.findAllMappedWithChanges( + { realm: Realm -> + realm.where() + .equalTo(CryptoRoomEntityFields.ROOM_ID, roomId) + }, + { + it.blacklistUnverifiedDevices + } + ) + return Transformations.map(liveData) { + it.firstOrNull() ?: false + } + } + + override fun getBlockUnverifiedDevices(roomId: String): Boolean { + return doWithRealm(realmConfiguration) { realm -> + realm.where() + .equalTo(CryptoRoomEntityFields.ROOM_ID, roomId) + .findFirst() + ?.blacklistUnverifiedDevices ?: false + } + } + + override fun blockUnverifiedDevicesInRoom(roomId: String, block: Boolean) { + doRealmTransaction(realmConfiguration) { realm -> + CryptoRoomEntity.getById(realm, roomId) + ?.blacklistUnverifiedDevices = block + } + } + override fun getDeviceTrackingStatuses(): Map { return doWithRealm(realmConfiguration) { it.where() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt index de2b74308d..9129453c8a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt @@ -36,6 +36,7 @@ import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo017 import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo018 import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo019 +import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo020 import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration import org.matrix.android.sdk.internal.util.time.Clock import javax.inject.Inject @@ -50,7 +51,7 @@ internal class RealmCryptoStoreMigration @Inject constructor( private val clock: Clock, ) : MatrixRealmMigration( dbName = "Crypto", - schemaVersion = 19L, + schemaVersion = 20L, ) { /** * Forces all RealmCryptoStoreMigration instances to be equal. @@ -79,5 +80,6 @@ internal class RealmCryptoStoreMigration @Inject constructor( if (oldVersion < 17) MigrateCryptoTo017(realm).perform() if (oldVersion < 18) MigrateCryptoTo018(realm).perform() if (oldVersion < 19) MigrateCryptoTo019(realm).perform() + if (oldVersion < 20) MigrateCryptoTo020(realm).perform() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/mapper/MyDeviceLastSeenInfoEntityMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/mapper/MyDeviceLastSeenInfoEntityMapper.kt index 38a7569aab..b81883fb38 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/mapper/MyDeviceLastSeenInfoEntityMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/mapper/MyDeviceLastSeenInfoEntityMapper.kt @@ -27,7 +27,18 @@ internal class MyDeviceLastSeenInfoEntityMapper @Inject constructor() { deviceId = entity.deviceId, lastSeenIp = entity.lastSeenIp, lastSeenTs = entity.lastSeenTs, - displayName = entity.displayName + displayName = entity.displayName, + unstableLastSeenUserAgent = entity.lastSeenUserAgent, + ) + } + + fun map(deviceInfo: DeviceInfo): MyDeviceLastSeenInfoEntity { + return MyDeviceLastSeenInfoEntity( + deviceId = deviceInfo.deviceId, + lastSeenIp = deviceInfo.lastSeenIp, + lastSeenTs = deviceInfo.lastSeenTs, + displayName = deviceInfo.displayName, + lastSeenUserAgent = deviceInfo.getBestLastSeenUserAgent(), ) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo019.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo019.kt index 9d2eb60a60..65280300ab 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo019.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo019.kt @@ -30,7 +30,7 @@ import org.matrix.android.sdk.internal.util.database.RealmMigrator * mark existing keys as safe. * This migration can take long depending on the account */ -internal class MigrateCryptoTo019(realm: DynamicRealm) : RealmMigrator(realm, 18) { +internal class MigrateCryptoTo019(realm: DynamicRealm) : RealmMigrator(realm, 19) { override fun doMigrate(realm: DynamicRealm) { realm.schema.get("CrossSigningInfoEntity") diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo020.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo020.kt new file mode 100644 index 0000000000..44d07ab538 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo020.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.crypto.store.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.crypto.store.db.model.MyDeviceLastSeenInfoEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +/** + * This migration adds a new field into MyDeviceLastSeenInfoEntity corresponding to the last seen user agent. + */ +internal class MigrateCryptoTo020(realm: DynamicRealm) : RealmMigrator(realm, 20) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("MyDeviceLastSeenInfoEntity") + ?.addField(MyDeviceLastSeenInfoEntityFields.LAST_SEEN_USER_AGENT, String::class.java) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/MyDeviceLastSeenInfoEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/MyDeviceLastSeenInfoEntity.kt index 74a81d5b01..3e6dc2de16 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/MyDeviceLastSeenInfoEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/MyDeviceLastSeenInfoEntity.kt @@ -27,7 +27,9 @@ internal open class MyDeviceLastSeenInfoEntity( /** The last time this device has been seen. */ var lastSeenTs: Long? = null, /** The last ip address. */ - var lastSeenIp: String? = null + var lastSeenIp: String? = null, + /** The last user agent. */ + var lastSeenUserAgent: String? = null, ) : RealmObject() { companion object diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/PushersMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/PushersMapper.kt index 2dba2c228b..c3a37f5b95 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/PushersMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/PushersMapper.kt @@ -33,7 +33,9 @@ internal object PushersMapper { profileTag = pushEntity.profileTag, lang = pushEntity.lang, data = PusherData(pushEntity.data?.url, pushEntity.data?.format), - state = pushEntity.state + enabled = pushEntity.enabled, + deviceId = pushEntity.deviceId, + state = pushEntity.state, ) } @@ -46,7 +48,9 @@ internal object PushersMapper { deviceDisplayName = pusher.deviceDisplayName, profileTag = pusher.profileTag, lang = pusher.lang, - data = PusherDataEntity(pusher.data?.url, pusher.data?.format) + data = PusherDataEntity(pusher.data?.url, pusher.data?.format), + enabled = pusher.enabled, + deviceId = pusher.deviceId, ) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PusherEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PusherEntity.kt index af8e4f2d37..c08f695168 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PusherEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PusherEntity.kt @@ -18,15 +18,6 @@ package org.matrix.android.sdk.internal.database.model import io.realm.RealmObject import org.matrix.android.sdk.api.session.pushers.PusherState -// TODO -// at java.lang.Thread.run(Thread.java:764) -// Caused by: java.lang.IllegalArgumentException: 'value' is not a valid managed object. -// at io.realm.ProxyState.checkValidObject(ProxyState.java:213) -// at io.realm.im_vector_matrix_android_internal_database_model_PusherEntityRealmProxy -// .realmSet$data(im_vector_matrix_android_internal_database_model_PusherEntityRealmProxy.java:413) -// at org.matrix.android.sdk.internal.database.model.PusherEntity.setData(PusherEntity.kt:16) -// at org.matrix.android.sdk.internal.session.pushers.AddHttpPusherWorker$doWork$$inlined$fold$lambda$2.execute(AddHttpPusherWorker.kt:70) -// at io.realm.Realm.executeTransaction(Realm.java:1493) internal open class PusherEntity( var pushKey: String = "", var kind: String? = null, @@ -35,7 +26,9 @@ internal open class PusherEntity( var deviceDisplayName: String? = null, var profileTag: String? = null, var lang: String? = null, - var data: PusherDataEntity? = null + var data: PusherDataEntity? = null, + var enabled: Boolean = true, + var deviceId: String? = null, ) : RealmObject() { private var stateStr: String = PusherState.UNREGISTERED.name diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/AddPusherTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/AddPusherTask.kt index 7d81e19265..3e145dc668 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/AddPusherTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/AddPusherTask.kt @@ -38,6 +38,7 @@ internal class DefaultAddPusherTask @Inject constructor( private val requestExecutor: RequestExecutor, private val globalErrorReceiver: GlobalErrorReceiver ) : AddPusherTask { + override suspend fun execute(params: AddPusherTask.Params) { val pusher = params.pusher try { @@ -71,6 +72,8 @@ internal class DefaultAddPusherTask @Inject constructor( echo.profileTag = pusher.profileTag echo.data?.format = pusher.data?.format echo.data?.url = pusher.data?.url + echo.enabled = pusher.enabled + echo.deviceId = pusher.deviceId echo.state = PusherState.REGISTERED } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/DefaultPushersService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/DefaultPushersService.kt index e912d9ccf8..e89cfa508c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/DefaultPushersService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/DefaultPushersService.kt @@ -42,6 +42,7 @@ internal class DefaultPushersService @Inject constructor( private val getPusherTask: GetPushersTask, private val pushGatewayNotifyTask: PushGatewayNotifyTask, private val addPusherTask: AddPusherTask, + private val togglePusherTask: TogglePusherTask, private val removePusherTask: RemovePusherTask, private val taskExecutor: TaskExecutor ) : PushersService { @@ -78,7 +79,9 @@ internal class DefaultPushersService @Inject constructor( appDisplayName = appDisplayName, deviceDisplayName = deviceDisplayName, data = JsonPusherData(url, EVENT_ID_ONLY.takeIf { withEventIdOnly }), - append = append + append = append, + enabled = enabled, + deviceId = deviceId, ) override suspend fun addEmailPusher( @@ -106,6 +109,24 @@ internal class DefaultPushersService @Inject constructor( ) } + override suspend fun togglePusher(pusher: Pusher, enable: Boolean) { + togglePusherTask.execute(TogglePusherTask.Params(pusher.toJsonPusher(), enable)) + } + + private fun Pusher.toJsonPusher() = JsonPusher( + pushKey = pushKey, + kind = kind, + appId = appId, + appDisplayName = appDisplayName, + deviceDisplayName = deviceDisplayName, + profileTag = profileTag, + lang = lang, + data = JsonPusherData(data.url, data.format), + append = false, + enabled = enabled, + deviceId = deviceId, + ) + private fun enqueueAddPusher(pusher: JsonPusher): UUID { val params = AddPusherWorker.Params(sessionId, pusher) val request = workManagerProvider.matrixOneTimeWorkRequestBuilder() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/JsonPusher.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/JsonPusher.kt index 71a1ea8c66..c1cf3eb276 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/JsonPusher.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/JsonPusher.kt @@ -33,6 +33,8 @@ import java.security.InvalidParameterException * "device_display_name": "Alice's Phone", * "profile_tag": "xyz", * "lang": "en-US", + * "enabled": true, + * "device_id": "abc123", * "data": { * "url": "https://example.com/_matrix/push/v1/notify" * } @@ -112,7 +114,19 @@ internal data class JsonPusher( * The default is false. */ @Json(name = "append") - val append: Boolean? = false + val append: Boolean? = false, + + /** + * Whether the pusher should actively create push notifications. + */ + @Json(name = "org.matrix.msc3881.enabled") + val enabled: Boolean = true, + + /** + * The device_id of the session that registered the pusher. + */ + @Json(name = "org.matrix.msc3881.device_id") + val deviceId: String? = null, ) { init { // Do some parameter checks. It's ok to throw Exception, to inform developer of the problem diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/PushersModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/PushersModule.kt index 4528c95e69..37c1c0c3ad 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/PushersModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/PushersModule.kt @@ -68,6 +68,9 @@ internal abstract class PushersModule { @Binds abstract fun bindAddPusherTask(task: DefaultAddPusherTask): AddPusherTask + @Binds + abstract fun bindTogglePusherTask(task: DefaultTogglePusherTask): TogglePusherTask + @Binds abstract fun bindRemovePusherTask(task: DefaultRemovePusherTask): RemovePusherTask diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/TogglePusherTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/TogglePusherTask.kt new file mode 100644 index 0000000000..87836e1c76 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/TogglePusherTask.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.pushers + +import com.zhuinden.monarchy.Monarchy +import org.matrix.android.sdk.internal.database.model.PusherEntity +import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.di.SessionDatabase +import org.matrix.android.sdk.internal.network.GlobalErrorReceiver +import org.matrix.android.sdk.internal.network.RequestExecutor +import org.matrix.android.sdk.internal.task.Task +import org.matrix.android.sdk.internal.util.awaitTransaction +import javax.inject.Inject + +internal interface TogglePusherTask : Task { + data class Params(val pusher: JsonPusher, val enable: Boolean) +} + +internal class DefaultTogglePusherTask @Inject constructor( + private val pushersAPI: PushersAPI, + @SessionDatabase private val monarchy: Monarchy, + private val requestExecutor: RequestExecutor, + private val globalErrorReceiver: GlobalErrorReceiver +) : TogglePusherTask { + + override suspend fun execute(params: TogglePusherTask.Params) { + val pusher = params.pusher.copy(enabled = params.enable) + + requestExecutor.executeRequest(globalErrorReceiver) { + pushersAPI.setPusher(pusher) + } + + monarchy.awaitTransaction { realm -> + val entity = PusherEntity.where(realm, params.pusher.pushKey).findFirst() + entity?.apply { enabled = params.enable }?.let { realm.insertOrUpdate(it) } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt index 9839a44427..ddf3e41dff 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt @@ -105,19 +105,21 @@ internal class DefaultRelationService @AssistedInject constructor( targetEvent: TimelineEvent, msgType: String, newBodyText: CharSequence, + newFormattedBodyText: CharSequence?, newBodyAutoMarkdown: Boolean, compatibilityBodyText: String ): Cancelable { - return eventEditor.editTextMessage(targetEvent, msgType, newBodyText, newBodyAutoMarkdown, compatibilityBodyText) + return eventEditor.editTextMessage(targetEvent, msgType, newBodyText, newFormattedBodyText, newBodyAutoMarkdown, compatibilityBodyText) } override fun editReply( replyToEdit: TimelineEvent, originalTimelineEvent: TimelineEvent, newBodyText: String, + newFormattedBodyText: String?, compatibilityBodyText: String ): Cancelable { - return eventEditor.editReply(replyToEdit, originalTimelineEvent, newBodyText, compatibilityBodyText) + return eventEditor.editReply(replyToEdit, originalTimelineEvent, newBodyText, newFormattedBodyText, compatibilityBodyText) } override suspend fun fetchEditHistory(eventId: String): List { @@ -127,6 +129,7 @@ internal class DefaultRelationService @AssistedInject constructor( override fun replyToMessage( eventReplied: TimelineEvent, replyText: CharSequence, + replyFormattedText: CharSequence?, autoMarkdown: Boolean, showInThread: Boolean, rootThreadEventId: String? @@ -135,6 +138,7 @@ internal class DefaultRelationService @AssistedInject constructor( roomId = roomId, eventReplied = eventReplied, replyText = replyText, + replyTextFormatted = replyFormattedText, autoMarkdown = autoMarkdown, rootThreadEventId = rootThreadEventId, showInThread = showInThread @@ -178,6 +182,7 @@ internal class DefaultRelationService @AssistedInject constructor( roomId = roomId, eventReplied = eventReplied, replyText = replyInThreadText, + replyTextFormatted = formattedText, autoMarkdown = autoMarkdown, rootThreadEventId = rootThreadEventId, showInThread = false diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt index 795e9003ce..c83539c8fd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt @@ -23,6 +23,7 @@ import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.api.util.NoOpCancellable +import org.matrix.android.sdk.api.util.TextContent import org.matrix.android.sdk.internal.database.mapper.toEntity import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository @@ -42,19 +43,25 @@ internal class EventEditor @Inject constructor( targetEvent: TimelineEvent, msgType: String, newBodyText: CharSequence, + newBodyFormattedText: CharSequence?, newBodyAutoMarkdown: Boolean, compatibilityBodyText: String ): Cancelable { val roomId = targetEvent.roomId if (targetEvent.root.sendState.hasFailed()) { // We create a new in memory event for the EventSenderProcessor but we keep the eventId of the failed event. - val editedEvent = eventFactory.createTextEvent(roomId, msgType, newBodyText, newBodyAutoMarkdown).copy( + val editedEvent = if (newBodyFormattedText != null) { + val content = TextContent(newBodyText.toString(), newBodyFormattedText.toString()) + eventFactory.createFormattedTextEvent(roomId, content, msgType) + } else { + eventFactory.createTextEvent(roomId, msgType, newBodyText, newBodyAutoMarkdown) + }.copy( eventId = targetEvent.eventId ) return sendFailedEvent(targetEvent, editedEvent) } else if (targetEvent.root.sendState.isSent()) { val event = eventFactory - .createReplaceTextEvent(roomId, targetEvent.eventId, newBodyText, newBodyAutoMarkdown, msgType, compatibilityBodyText) + .createReplaceTextEvent(roomId, targetEvent.eventId, newBodyText, newBodyFormattedText, newBodyAutoMarkdown, msgType, compatibilityBodyText) return sendReplaceEvent(event) } else { // Should we throw? @@ -100,6 +107,7 @@ internal class EventEditor @Inject constructor( replyToEdit: TimelineEvent, originalTimelineEvent: TimelineEvent, newBodyText: String, + newBodyFormattedText: String?, compatibilityBodyText: String ): Cancelable { val roomId = replyToEdit.roomId @@ -109,6 +117,7 @@ internal class EventEditor @Inject constructor( roomId = roomId, eventReplied = originalTimelineEvent, replyText = newBodyText, + replyTextFormatted = newBodyFormattedText, autoMarkdown = false, showInThread = false )?.copy( 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 418000abed..a3f2825a0c 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 @@ -99,11 +99,18 @@ internal class DefaultSendService @AssistedInject constructor( .let { sendEvent(it) } } - override fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, rootThreadEventId: String?): Cancelable { + override fun sendQuotedTextMessage( + quotedEvent: TimelineEvent, + text: String, + formattedText: String?, + autoMarkdown: Boolean, + rootThreadEventId: String? + ): Cancelable { return localEchoEventFactory.createQuotedTextEvent( roomId = roomId, quotedEvent = quotedEvent, text = text, + formattedText = formattedText, autoMarkdown = autoMarkdown, rootThreadEventId = rootThreadEventId ) 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 4fbc91e9ec..4d5e574592 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 @@ -124,19 +124,23 @@ internal class LocalEchoEventFactory @Inject constructor( roomId: String, targetEventId: String, newBodyText: CharSequence, + newBodyFormattedText: CharSequence?, newBodyAutoMarkdown: Boolean, msgType: String, compatibilityText: String ): Event { + val content = if (newBodyFormattedText != null) { + TextContent(newBodyText.toString(), newBodyFormattedText.toString()).toMessageTextContent(msgType) + } else { + createTextContent(newBodyText, newBodyAutoMarkdown).toMessageTextContent(msgType) + }.toContent() return createMessageEvent( roomId, MessageTextContent( msgType = msgType, body = compatibilityText, relatesTo = RelationDefaultContent(RelationType.REPLACE, targetEventId), - newContent = createTextContent(newBodyText, newBodyAutoMarkdown) - .toMessageTextContent(msgType) - .toContent() + newContent = content, ) ) } @@ -581,6 +585,7 @@ internal class LocalEchoEventFactory @Inject constructor( roomId: String, eventReplied: TimelineEvent, replyText: CharSequence, + replyTextFormatted: CharSequence?, autoMarkdown: Boolean, rootThreadEventId: String? = null, showInThread: Boolean @@ -594,7 +599,7 @@ internal class LocalEchoEventFactory @Inject constructor( val body = bodyForReply(eventReplied.getLastMessageContent(), eventReplied.isReply()) // As we always supply formatted body for replies we should force the MarkdownParser to produce html. - val replyTextFormatted = markdownParser.parse(replyText, force = true, advanced = autoMarkdown).takeFormatted() + val finalReplyTextFormatted = replyTextFormatted?.toString() ?: markdownParser.parse(replyText, force = true, advanced = autoMarkdown).takeFormatted() // Body of the original message may not have formatted version, so may also have to convert to html. val bodyFormatted = body.formattedText ?: markdownParser.parse(body.text, force = true, advanced = autoMarkdown).takeFormatted() val replyFormatted = buildFormattedReply( @@ -602,7 +607,7 @@ internal class LocalEchoEventFactory @Inject constructor( userLink, userId, bodyFormatted, - replyTextFormatted + finalReplyTextFormatted ) // // > <@alice:example.org> This is the original body @@ -765,18 +770,20 @@ internal class LocalEchoEventFactory @Inject constructor( roomId: String, quotedEvent: TimelineEvent, text: String, + formattedText: String?, autoMarkdown: Boolean, rootThreadEventId: String? ): Event { val messageContent = quotedEvent.getLastMessageContent() - val textMsg = messageContent?.body + val textMsg = if (messageContent is MessageContentWithFormattedBody) { messageContent.formattedBody } else { messageContent?.body } val quoteText = legacyRiotQuoteText(textMsg, text) + val quoteFormattedText = "
$textMsg
$formattedText" return if (rootThreadEventId != null) { createMessageEvent( roomId, markdownParser - .parse(quoteText, force = true, advanced = autoMarkdown) + .parse(quoteText, force = true, advanced = autoMarkdown).copy(formattedText = quoteFormattedText) .toThreadTextContent( rootThreadEventId = rootThreadEventId, latestThreadEventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId), @@ -786,7 +793,7 @@ internal class LocalEchoEventFactory @Inject constructor( } else { createFormattedTextEvent( roomId, - markdownParser.parse(quoteText, force = true, advanced = autoMarkdown), + markdownParser.parse(quoteText, force = true, advanced = autoMarkdown).copy(formattedText = quoteFormattedText), MessageType.MSGTYPE_TEXT ) } diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/store/db/mapper/MyDeviceLastSeenInfoEntityMapperTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/store/db/mapper/MyDeviceLastSeenInfoEntityMapperTest.kt index a27f430edc..8515427e8e 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/store/db/mapper/MyDeviceLastSeenInfoEntityMapperTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/store/db/mapper/MyDeviceLastSeenInfoEntityMapperTest.kt @@ -25,6 +25,7 @@ private const val A_DEVICE_ID = "device-id" private const val AN_IP_ADDRESS = "ip-address" private const val A_TIMESTAMP = 123L private const val A_DISPLAY_NAME = "display-name" +private const val A_USER_AGENT = "user-agent" class MyDeviceLastSeenInfoEntityMapperTest { @@ -32,21 +33,55 @@ class MyDeviceLastSeenInfoEntityMapperTest { @Test fun `given an entity when mapping to model then all fields are correctly mapped`() { + // Given val entity = MyDeviceLastSeenInfoEntity( deviceId = A_DEVICE_ID, lastSeenIp = AN_IP_ADDRESS, lastSeenTs = A_TIMESTAMP, - displayName = A_DISPLAY_NAME + displayName = A_DISPLAY_NAME, + lastSeenUserAgent = A_USER_AGENT, ) val expectedDeviceInfo = DeviceInfo( deviceId = A_DEVICE_ID, lastSeenIp = AN_IP_ADDRESS, lastSeenTs = A_TIMESTAMP, - displayName = A_DISPLAY_NAME + displayName = A_DISPLAY_NAME, + unstableLastSeenUserAgent = A_USER_AGENT, ) + // When val deviceInfo = myDeviceLastSeenInfoEntityMapper.map(entity) + // Then deviceInfo shouldBeEqualTo expectedDeviceInfo } + + @Test + fun `given a device info when mapping to entity then all fields are correctly mapped`() { + // Given + val deviceInfo = DeviceInfo( + deviceId = A_DEVICE_ID, + lastSeenIp = AN_IP_ADDRESS, + lastSeenTs = A_TIMESTAMP, + displayName = A_DISPLAY_NAME, + unstableLastSeenUserAgent = A_USER_AGENT, + ) + val expectedEntity = MyDeviceLastSeenInfoEntity( + deviceId = A_DEVICE_ID, + lastSeenIp = AN_IP_ADDRESS, + lastSeenTs = A_TIMESTAMP, + displayName = A_DISPLAY_NAME, + lastSeenUserAgent = A_USER_AGENT + ) + + // When + val entity = myDeviceLastSeenInfoEntityMapper.map(deviceInfo) + + // Then + entity.deviceId shouldBeEqualTo expectedEntity.deviceId + entity.lastSeenIp shouldBeEqualTo expectedEntity.lastSeenIp + entity.lastSeenTs shouldBeEqualTo expectedEntity.lastSeenTs + entity.displayName shouldBeEqualTo expectedEntity.displayName + entity.lastSeenUserAgent shouldBeEqualTo expectedEntity.lastSeenUserAgent + } } diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/PushersMapperTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/PushersMapperTest.kt new file mode 100644 index 0000000000..08ed20a766 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/PushersMapperTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.mapper + +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import org.matrix.android.sdk.test.fixtures.JsonPusherFixture.aJsonPusher +import org.matrix.android.sdk.test.fixtures.PusherEntityFixture.aPusherEntity + +class PushersMapperTest { + + @Test + fun `when mapping PusherEntity, then it is mapped into Pusher successfully`() { + val pusherEntity = aPusherEntity() + + val mappedPusher = PushersMapper.map(pusherEntity) + + mappedPusher.pushKey shouldBeEqualTo pusherEntity.pushKey + mappedPusher.kind shouldBeEqualTo pusherEntity.kind.orEmpty() + mappedPusher.appId shouldBeEqualTo pusherEntity.appId + mappedPusher.appDisplayName shouldBeEqualTo pusherEntity.appDisplayName + mappedPusher.deviceDisplayName shouldBeEqualTo pusherEntity.deviceDisplayName + mappedPusher.profileTag shouldBeEqualTo pusherEntity.profileTag + mappedPusher.lang shouldBeEqualTo pusherEntity.lang + mappedPusher.data.url shouldBeEqualTo pusherEntity.data?.url + mappedPusher.data.format shouldBeEqualTo pusherEntity.data?.format + mappedPusher.enabled shouldBeEqualTo pusherEntity.enabled + mappedPusher.deviceId shouldBeEqualTo pusherEntity.deviceId + mappedPusher.state shouldBeEqualTo pusherEntity.state + } + + @Test + fun `when mapping JsonPusher, then it is mapped into Pusher successfully`() { + val jsonPusher = aJsonPusher() + + val mappedPusherEntity = PushersMapper.map(jsonPusher) + + mappedPusherEntity.pushKey shouldBeEqualTo jsonPusher.pushKey + mappedPusherEntity.kind shouldBeEqualTo jsonPusher.kind + mappedPusherEntity.appId shouldBeEqualTo jsonPusher.appId + mappedPusherEntity.appDisplayName shouldBeEqualTo jsonPusher.appDisplayName + mappedPusherEntity.deviceDisplayName shouldBeEqualTo jsonPusher.deviceDisplayName + mappedPusherEntity.profileTag shouldBeEqualTo jsonPusher.profileTag + mappedPusherEntity.lang shouldBeEqualTo jsonPusher.lang + mappedPusherEntity.data?.url shouldBeEqualTo jsonPusher.data?.url + mappedPusherEntity.data?.format shouldBeEqualTo jsonPusher.data?.format + mappedPusherEntity.enabled shouldBeEqualTo jsonPusher.enabled + mappedPusherEntity.deviceId shouldBeEqualTo jsonPusher.deviceId + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultAddPusherTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultAddPusherTaskTest.kt index dac33069f3..a971973f56 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultAddPusherTaskTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultAddPusherTaskTest.kt @@ -71,7 +71,7 @@ class DefaultAddPusherTaskTest { } @Test - fun `given a persisted pusher when adding Pusher then updates api and mutates persisted result with Registered state`() { + fun `given a persisted pusher, when adding Pusher, then updates api and mutates persisted result with Registered state`() { val realmResult = PusherEntity(appDisplayName = null) monarchy.givenWhereReturns(result = realmResult) .givenEqualTo(PusherEntityFields.PUSH_KEY, A_JSON_PUSHER.pushKey) @@ -85,7 +85,7 @@ class DefaultAddPusherTaskTest { } @Test - fun `given a persisted push entity and SetPush API fails when adding Pusher then mutates persisted result with Failed registration state and rethrows`() { + fun `given a persisted push entity and SetPush API fails, when adding Pusher, then mutates persisted result with Failed registration state and rethrows`() { val realmResult = PusherEntity() monarchy.givenWhereReturns(result = realmResult) .givenEqualTo(PusherEntityFields.PUSH_KEY, A_JSON_PUSHER.pushKey) @@ -99,7 +99,7 @@ class DefaultAddPusherTaskTest { } @Test - fun `given no persisted push entity and SetPush API fails when adding Pusher then rethrows error`() { + fun `given no persisted push entity and SetPush API fails, when adding Pusher, then rethrows error`() { monarchy.givenWhereReturns(result = null) .givenEqualTo(PusherEntityFields.PUSH_KEY, A_JSON_PUSHER.pushKey) pushersAPI.givenSetPusherErrors(SocketException()) diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultPushersServiceTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultPushersServiceTest.kt new file mode 100644 index 0000000000..a00ac3a17d --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultPushersServiceTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.pushers + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.matrix.android.sdk.test.fakes.FakeAddPusherTask +import org.matrix.android.sdk.test.fakes.FakeGetPushersTask +import org.matrix.android.sdk.test.fakes.FakeMonarchy +import org.matrix.android.sdk.test.fakes.FakeRemovePusherTask +import org.matrix.android.sdk.test.fakes.FakeTaskExecutor +import org.matrix.android.sdk.test.fakes.FakeTogglePusherTask +import org.matrix.android.sdk.test.fakes.FakeWorkManagerProvider +import org.matrix.android.sdk.test.fakes.internal.FakePushGatewayNotifyTask +import org.matrix.android.sdk.test.fixtures.PusherFixture + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultPushersServiceTest { + + private val workManagerProvider = FakeWorkManagerProvider() + private val monarchy = FakeMonarchy() + private val sessionId = "" + private val getPushersTask = FakeGetPushersTask() + private val pushGatewayNotifyTask = FakePushGatewayNotifyTask() + private val addPusherTask = FakeAddPusherTask() + private val togglePusherTask = FakeTogglePusherTask() + private val removePusherTask = FakeRemovePusherTask() + private val taskExecutor = FakeTaskExecutor() + + private val pushersService = DefaultPushersService( + workManagerProvider.instance, + monarchy.instance, + sessionId, + getPushersTask, + pushGatewayNotifyTask, + addPusherTask, + togglePusherTask, + removePusherTask, + taskExecutor.instance, + ) + + @Test + fun `when togglePusher, then execute task`() = runTest { + val pusher = PusherFixture.aPusher() + val enable = true + + pushersService.togglePusher(pusher, enable) + + togglePusherTask.verifyExecution(pusher, enable) + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultTogglePusherTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultTogglePusherTaskTest.kt new file mode 100644 index 0000000000..3c54f6f1e1 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultTogglePusherTaskTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.pushers + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import org.matrix.android.sdk.internal.database.model.PusherEntity +import org.matrix.android.sdk.internal.database.model.PusherEntityFields +import org.matrix.android.sdk.test.fakes.FakeGlobalErrorReceiver +import org.matrix.android.sdk.test.fakes.FakeMonarchy +import org.matrix.android.sdk.test.fakes.FakePushersAPI +import org.matrix.android.sdk.test.fakes.FakeRequestExecutor +import org.matrix.android.sdk.test.fakes.givenEqualTo +import org.matrix.android.sdk.test.fakes.givenFindFirst +import org.matrix.android.sdk.test.fixtures.JsonPusherFixture.aJsonPusher +import org.matrix.android.sdk.test.fixtures.PusherEntityFixture.aPusherEntity + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultTogglePusherTaskTest { + + private val pushersAPI = FakePushersAPI() + private val monarchy = FakeMonarchy() + private val requestExecutor = FakeRequestExecutor() + private val globalErrorReceiver = FakeGlobalErrorReceiver() + + private val togglePusherTask = DefaultTogglePusherTask(pushersAPI, monarchy.instance, requestExecutor, globalErrorReceiver) + + @Test + fun `execution toggles enable on both local and remote`() = runTest { + val jsonPusher = aJsonPusher(enabled = false) + val params = TogglePusherTask.Params(aJsonPusher(), true) + + val pusherEntity = aPusherEntity(enabled = false) + monarchy.givenWhere() + .givenEqualTo(PusherEntityFields.PUSH_KEY, jsonPusher.pushKey) + .givenFindFirst(pusherEntity) + + togglePusherTask.execute(params) + + val expectedPayload = jsonPusher.copy(enabled = true) + pushersAPI.verifySetPusher(expectedPayload) + monarchy.verifyInsertOrUpdate { + withArg { actual -> + actual.enabled shouldBeEqualTo true + } + } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeAddPusherTask.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeAddPusherTask.kt new file mode 100644 index 0000000000..16cdd7a626 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeAddPusherTask.kt @@ -0,0 +1,22 @@ +/* + * 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.test.fakes + +import io.mockk.mockk +import org.matrix.android.sdk.internal.session.pushers.AddPusherTask + +class FakeAddPusherTask : AddPusherTask by mockk() diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeGetPushersTask.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeGetPushersTask.kt new file mode 100644 index 0000000000..d5a41bb0e0 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeGetPushersTask.kt @@ -0,0 +1,22 @@ +/* + * 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.test.fakes + +import io.mockk.mockk +import org.matrix.android.sdk.internal.session.pushers.GetPushersTask + +class FakeGetPushersTask : GetPushersTask by mockk() diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRemovePusherTask.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRemovePusherTask.kt new file mode 100644 index 0000000000..55a7607a03 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRemovePusherTask.kt @@ -0,0 +1,22 @@ +/* + * 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.test.fakes + +import io.mockk.mockk +import org.matrix.android.sdk.internal.session.pushers.RemovePusherTask + +class FakeRemovePusherTask : RemovePusherTask by mockk() diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeTaskExecutor.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeTaskExecutor.kt new file mode 100644 index 0000000000..543dda8a4f --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeTaskExecutor.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.test.fakes + +import io.mockk.mockk +import org.matrix.android.sdk.internal.task.TaskExecutor + +internal class FakeTaskExecutor { + + val instance: TaskExecutor = mockk() +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeTogglePusherTask.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeTogglePusherTask.kt new file mode 100644 index 0000000000..b1e059a40e --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeTogglePusherTask.kt @@ -0,0 +1,35 @@ +/* + * 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.test.fakes + +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import org.amshove.kluent.shouldBeEqualTo +import org.matrix.android.sdk.api.session.pushers.Pusher +import org.matrix.android.sdk.internal.session.pushers.TogglePusherTask + +class FakeTogglePusherTask : TogglePusherTask by mockk(relaxed = true) { + + fun verifyExecution(pusher: Pusher, enable: Boolean) { + val slot = slot() + coVerify { execute(capture(slot)) } + val params = slot.captured + params.pusher.pushKey shouldBeEqualTo pusher.pushKey + params.enable shouldBeEqualTo enable + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/FakePushGatewayNotifyTask.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/FakePushGatewayNotifyTask.kt new file mode 100644 index 0000000000..46a106dcb2 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/FakePushGatewayNotifyTask.kt @@ -0,0 +1,22 @@ +/* + * 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.test.fakes.internal + +import io.mockk.mockk +import org.matrix.android.sdk.internal.session.pushers.gateway.PushGatewayNotifyTask + +class FakePushGatewayNotifyTask : PushGatewayNotifyTask by mockk() diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/JsonPusherFixture.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/JsonPusherFixture.kt new file mode 100644 index 0000000000..8e679ff91c --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/JsonPusherFixture.kt @@ -0,0 +1,49 @@ +/* + * 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.test.fixtures + +import org.matrix.android.sdk.internal.session.pushers.JsonPusher +import org.matrix.android.sdk.internal.session.pushers.JsonPusherData + +internal object JsonPusherFixture { + + fun aJsonPusher( + pushKey: String = "", + kind: String? = null, + appId: String = "", + appDisplayName: String? = null, + deviceDisplayName: String? = null, + profileTag: String? = null, + lang: String? = null, + data: JsonPusherData? = null, + append: Boolean? = false, + enabled: Boolean = true, + deviceId: String? = null, + ) = JsonPusher( + pushKey, + kind, + appId, + appDisplayName, + deviceDisplayName, + profileTag, + lang, + data, + append, + enabled, + deviceId, + ) +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/PusherEntityFixture.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/PusherEntityFixture.kt new file mode 100644 index 0000000000..8d048d4c9a --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/PusherEntityFixture.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.test.fixtures + +import org.matrix.android.sdk.internal.database.model.PusherDataEntity +import org.matrix.android.sdk.internal.database.model.PusherEntity + +internal object PusherEntityFixture { + + fun aPusherEntity( + pushKey: String = "", + kind: String? = null, + appId: String = "", + appDisplayName: String? = null, + deviceDisplayName: String? = null, + profileTag: String? = null, + lang: String? = null, + data: PusherDataEntity? = null, + enabled: Boolean = true, + deviceId: String? = null, + ) = PusherEntity( + pushKey, + kind, + appId, + appDisplayName, + deviceDisplayName, + profileTag, + lang, + data, + enabled, + deviceId, + ) +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/PusherFixture.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/PusherFixture.kt new file mode 100644 index 0000000000..0ac7885062 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/PusherFixture.kt @@ -0,0 +1,50 @@ +/* + * 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.test.fixtures + +import org.matrix.android.sdk.api.session.pushers.Pusher +import org.matrix.android.sdk.api.session.pushers.PusherData +import org.matrix.android.sdk.api.session.pushers.PusherState + +object PusherFixture { + + fun aPusher( + pushKey: String = "", + kind: String = "", + appId: String = "", + appDisplayName: String? = "", + deviceDisplayName: String? = "", + profileTag: String? = null, + lang: String? = "", + data: PusherData = PusherData("f.o/_matrix/push/v1/notify", ""), + enabled: Boolean = true, + deviceId: String? = "", + state: PusherState = PusherState.REGISTERED, + ) = Pusher( + pushKey, + kind, + appId, + appDisplayName, + deviceDisplayName, + profileTag, + lang, + data, + enabled, + deviceId, + state, + ) +} diff --git a/tools/danger/dangerfile.js b/tools/danger/dangerfile.js index 6314ec8f68..1a36474470 100644 --- a/tools/danger/dangerfile.js +++ b/tools/danger/dangerfile.js @@ -70,6 +70,7 @@ const signOff = "Signed-off-by:" // Please add new names following the alphabetical order. const allowList = [ + "amitkma", "aringenbach", "BillCarsonFr", "bmarty", diff --git a/vector-app/build.gradle b/vector-app/build.gradle index a4bc105a1d..eb19027880 100644 --- a/vector-app/build.gradle +++ b/vector-app/build.gradle @@ -6,6 +6,7 @@ apply plugin: 'com.google.android.gms.oss-licenses-plugin' apply plugin: 'kotlin-android' apply plugin: 'kotlin-parcelize' apply plugin: 'kotlin-kapt' +apply plugin: 'com.google.devtools.ksp' apply plugin: 'dagger.hilt.android.plugin' apply plugin: 'kotlinx-knit' apply plugin: 'com.likethesalad.stem' @@ -36,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 = 2 +ext.versionPatch = 4 static def getGitTimestamp() { def cmd = 'git show -s --format=%ct' @@ -358,7 +359,7 @@ dependencies { debugImplementation project(':library:ui-styles') implementation libs.dagger.hilt implementation 'androidx.multidex:multidex:2.0.1' - implementation "androidx.sharetarget:sharetarget:1.1.0" + implementation "androidx.sharetarget:sharetarget:1.2.0" // Flipper, debug builds only debugImplementation(libs.flipper.flipper) { @@ -387,7 +388,7 @@ dependencies { // OSS License, gplay flavor only gplayImplementation 'com.google.android.gms:play-services-oss-licenses:17.0.0' kapt libs.dagger.hiltCompiler - kapt libs.airbnb.epoxyProcessor + ksp libs.airbnb.epoxyProcessor androidTestImplementation libs.androidx.testCore androidTestImplementation libs.androidx.testRunner @@ -410,7 +411,7 @@ dependencies { androidTestImplementation libs.mockk.mockkAndroid androidTestUtil libs.androidx.orchestrator androidTestImplementation libs.androidx.fragmentTesting - androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.7.10" + androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.7.20" debugImplementation libs.androidx.fragmentTesting debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1' } diff --git a/vector-app/src/androidTest/java/im/vector/app/ui/robot/RoomSettingsRobot.kt b/vector-app/src/androidTest/java/im/vector/app/ui/robot/RoomSettingsRobot.kt index 2c57dd058d..62c34e1b66 100644 --- a/vector-app/src/androidTest/java/im/vector/app/ui/robot/RoomSettingsRobot.kt +++ b/vector-app/src/androidTest/java/im/vector/app/ui/robot/RoomSettingsRobot.kt @@ -34,18 +34,18 @@ class RoomSettingsRobot { fun crawl() { // Room settings - clickListItem(R.id.matrixProfileRecyclerView, 3) + clickListItem(R.id.matrixProfileRecyclerView, 4) navigateToRoomParameters() pressBack() // Notifications - clickListItem(R.id.matrixProfileRecyclerView, 5) + clickListItem(R.id.matrixProfileRecyclerView, 6) pressBack() assertDisplayed(R.id.roomProfileAvatarView) // People - clickListItem(R.id.matrixProfileRecyclerView, 7) + clickListItem(R.id.matrixProfileRecyclerView, 8) assertDisplayed(R.id.inviteUsersButton) navigateToRoomPeople() // Fab @@ -56,7 +56,7 @@ class RoomSettingsRobot { assertDisplayed(R.id.roomProfileAvatarView) // Uploads - clickListItem(R.id.matrixProfileRecyclerView, 9) + clickListItem(R.id.matrixProfileRecyclerView, 10) // File tab clickOn(R.string.uploads_files_title) waitUntilViewVisible(withText(R.string.uploads_media_title)) @@ -73,12 +73,12 @@ class RoomSettingsRobot { // Advanced // Room addresses - clickListItem(R.id.matrixProfileRecyclerView, 15) + clickListItem(R.id.matrixProfileRecyclerView, 16) waitUntilViewVisible(withText(R.string.room_alias_published_alias_title)) pressBack() // Room permissions - clickListItem(R.id.matrixProfileRecyclerView, 17) + clickListItem(R.id.matrixProfileRecyclerView, 18) waitUntilViewVisible(withText(R.string.room_permissions_change_room_avatar)) clickOn(R.string.room_permissions_change_room_avatar) waitUntilDialogVisible(withId(android.R.id.button2)) @@ -95,7 +95,7 @@ class RoomSettingsRobot { } private fun leaveRoom(block: DialogRobot.() -> Unit) { - clickListItem(R.id.matrixProfileRecyclerView, 13) + clickListItem(R.id.matrixProfileRecyclerView, 14) waitUntilDialogVisible(withId(android.R.id.button2)) val dialogRobot = DialogRobot() block(dialogRobot) diff --git a/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugFeaturesStateFactory.kt b/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugFeaturesStateFactory.kt index be25ffe0af..3a302feba0 100644 --- a/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugFeaturesStateFactory.kt +++ b/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugFeaturesStateFactory.kt @@ -95,6 +95,11 @@ class DebugFeaturesStateFactory @Inject constructor( key = DebugFeatureKeys.qrCodeLoginEnabled, factory = VectorFeatures::isQrCodeLoginEnabled ), + createBooleanFeature( + label = "Enable Voice Broadcast", + key = DebugFeatureKeys.voiceBroadcastEnabled, + factory = VectorFeatures::isVoiceBroadcastEnabled + ), ) ) } diff --git a/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt b/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt index e4b151329d..b5ffa28db7 100644 --- a/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt +++ b/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt @@ -82,6 +82,9 @@ class DebugVectorFeatures( override fun isQrCodeLoginEnabled() = read(DebugFeatureKeys.qrCodeLoginEnabled) ?: vectorFeatures.isQrCodeLoginEnabled() + override fun isVoiceBroadcastEnabled(): Boolean = read(DebugFeatureKeys.voiceBroadcastEnabled) + ?: vectorFeatures.isVoiceBroadcastEnabled() + fun override(value: T?, key: Preferences.Key) = updatePreferences { if (value == null) { it.remove(key) @@ -144,4 +147,5 @@ object DebugFeatureKeys { val newAppLayoutEnabled = booleanPreferencesKey("new-app-layout-enabled") val newDeviceManagementEnabled = booleanPreferencesKey("new-device-management-enabled") val qrCodeLoginEnabled = booleanPreferencesKey("qr-code-login-enabled") + val voiceBroadcastEnabled = booleanPreferencesKey("voice-broadcast-enabled") } diff --git a/vector-app/src/fdroid/java/im/vector/app/di/FlavorModule.kt b/vector-app/src/fdroid/java/im/vector/app/di/FlavorModule.kt index 5355aa3cd9..63b4c2a3cd 100644 --- a/vector-app/src/fdroid/java/im/vector/app/di/FlavorModule.kt +++ b/vector-app/src/fdroid/java/im/vector/app/di/FlavorModule.kt @@ -23,6 +23,10 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import im.vector.app.core.pushers.FcmHelper +import im.vector.app.core.resources.AppNameProvider +import im.vector.app.core.resources.DefaultAppNameProvider +import im.vector.app.core.resources.DefaultLocaleProvider +import im.vector.app.core.resources.LocaleProvider import im.vector.app.core.services.GuardServiceStarter import im.vector.app.fdroid.service.FDroidGuardServiceStarter import im.vector.app.features.home.NightlyProxy @@ -59,4 +63,10 @@ abstract class FlavorModule { @Binds abstract fun bindsFcmHelper(fcmHelper: FdroidFcmHelper): FcmHelper + + @Binds + abstract fun bindsLocaleProvider(localeProvider: DefaultLocaleProvider): LocaleProvider + + @Binds + abstract fun bindsAppNameProvider(appNameProvider: DefaultAppNameProvider): AppNameProvider } diff --git a/vector-app/src/gplay/java/im/vector/app/di/FlavorModule.kt b/vector-app/src/gplay/java/im/vector/app/di/FlavorModule.kt index 2fe72313ea..d70a55afb8 100644 --- a/vector-app/src/gplay/java/im/vector/app/di/FlavorModule.kt +++ b/vector-app/src/gplay/java/im/vector/app/di/FlavorModule.kt @@ -23,6 +23,10 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import im.vector.app.GoogleFlavorLegals import im.vector.app.core.pushers.FcmHelper +import im.vector.app.core.resources.AppNameProvider +import im.vector.app.core.resources.DefaultAppNameProvider +import im.vector.app.core.resources.DefaultLocaleProvider +import im.vector.app.core.resources.LocaleProvider import im.vector.app.core.services.GuardServiceStarter import im.vector.app.features.home.NightlyProxy import im.vector.app.features.settings.legals.FlavorLegals @@ -46,6 +50,12 @@ abstract class FlavorModule { @Binds abstract fun bindsFcmHelper(fcmHelper: GoogleFcmHelper): FcmHelper + @Binds + abstract fun bindsLocaleProvider(localeProvider: DefaultLocaleProvider): LocaleProvider + + @Binds + abstract fun bindsAppNameProvider(appNameProvider: DefaultAppNameProvider): AppNameProvider + @Binds abstract fun bindsFlavorLegals(legals: GoogleFlavorLegals): FlavorLegals } diff --git a/vector-app/src/main/AndroidManifest.xml b/vector-app/src/main/AndroidManifest.xml index 2767b20404..7a515449b4 100644 --- a/vector-app/src/main/AndroidManifest.xml +++ b/vector-app/src/main/AndroidManifest.xml @@ -66,14 +66,6 @@ android:resource="@xml/sdk_provider_paths" /> - - - - diff --git a/vector-app/src/main/java/im/vector/app/core/di/SingletonModule.kt b/vector-app/src/main/java/im/vector/app/core/di/SingletonModule.kt index 384c584e0c..dbfda024d8 100644 --- a/vector-app/src/main/java/im/vector/app/core/di/SingletonModule.kt +++ b/vector-app/src/main/java/im/vector/app/core/di/SingletonModule.kt @@ -34,6 +34,8 @@ import im.vector.app.SpaceStateHandler import im.vector.app.SpaceStateHandlerImpl import im.vector.app.config.Config import im.vector.app.core.debug.FlipperProxy +import im.vector.app.core.device.DefaultGetDeviceInfoUseCase +import im.vector.app.core.device.GetDeviceInfoUseCase import im.vector.app.core.dispatchers.CoroutineDispatchers import im.vector.app.core.error.DefaultErrorFormatter import im.vector.app.core.error.ErrorFormatter @@ -112,6 +114,9 @@ abstract class VectorBindModule { @Binds abstract fun bindSpaceStateHandler(spaceStateHandlerImpl: SpaceStateHandlerImpl): SpaceStateHandler + + @Binds + abstract fun bindGetDeviceInfoUseCase(getDeviceInfoUseCase: DefaultGetDeviceInfoUseCase): GetDeviceInfoUseCase } @InstallIn(SingletonComponent::class) diff --git a/vector-config/src/main/java/im/vector/app/config/Analytics.kt b/vector-config/src/main/java/im/vector/app/config/Analytics.kt index 7fdc78dc8a..d944a84f94 100644 --- a/vector-config/src/main/java/im/vector/app/config/Analytics.kt +++ b/vector-config/src/main/java/im/vector/app/config/Analytics.kt @@ -27,9 +27,9 @@ sealed interface Analytics { object Disabled : Analytics /** - * Analytics integration via PostHog. + * Analytics integration via PostHog and Sentry. */ - data class PostHog( + data class Enabled( /** * The PostHog instance url. */ @@ -44,5 +44,15 @@ sealed interface Analytics { * A URL to more information about the analytics collection. */ val policyLink: String, + + /** + * The Sentry DSN url. + */ + val sentryDSN: String, + + /** + * Environment for Sentry. + */ + val sentryEnvironment: String ) : Analytics } diff --git a/vector-config/src/main/java/im/vector/app/config/Config.kt b/vector-config/src/main/java/im/vector/app/config/Config.kt index f660799d06..c91987dbfd 100644 --- a/vector-config/src/main/java/im/vector/app/config/Config.kt +++ b/vector-config/src/main/java/im/vector/app/config/Config.kt @@ -68,25 +68,29 @@ object Config { * The analytics configuration to use for the Debug build type. * Can be disabled by providing Analytics.Disabled */ - val DEBUG_ANALYTICS_CONFIG = Analytics.PostHog( + val DEBUG_ANALYTICS_CONFIG = Analytics.Enabled( postHogHost = "https://posthog.element.dev", postHogApiKey = "phc_VtA1L35nw3aeAtHIx1ayrGdzGkss7k1xINeXcoIQzXN", policyLink = "https://element.io/cookie-policy", + sentryDSN = "https://f6acc9cfc2024641b28c87ad95e73e66@sentry.tools.element.io/49", + sentryEnvironment = "DEBUG" ) /** * The analytics configuration to use for the Release build type. * Can be disabled by providing Analytics.Disabled */ - val RELEASE_ANALYTICS_CONFIG = Analytics.PostHog( + val RELEASE_ANALYTICS_CONFIG = Analytics.Enabled( postHogHost = "https://posthog.hss.element.io", postHogApiKey = "phc_Jzsm6DTm6V2705zeU5dcNvQDlonOR68XvX2sh1sEOHO", policyLink = "https://element.io/cookie-policy", + sentryDSN = "https://f6acc9cfc2024641b28c87ad95e73e66@sentry.tools.element.io/49", + sentryEnvironment = "RELEASE" ) /** * The analytics configuration to use for the Nightly build type. * Can be disabled by providing Analytics.Disabled */ - val NIGHTLY_ANALYTICS_CONFIG = RELEASE_ANALYTICS_CONFIG + val NIGHTLY_ANALYTICS_CONFIG = RELEASE_ANALYTICS_CONFIG.copy(sentryEnvironment = "NIGHTLY") } diff --git a/vector-config/src/main/res/values/config-settings.xml b/vector-config/src/main/res/values/config-settings.xml index c69452e3d0..11fbb2f147 100755 --- a/vector-config/src/main/res/values/config-settings.xml +++ b/vector-config/src/main/res/values/config-settings.xml @@ -43,6 +43,8 @@ true true false + true + false diff --git a/vector/build.gradle b/vector/build.gradle index 7bc97b1a57..833d06f6d6 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -2,6 +2,7 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply plugin: 'kotlin-parcelize' apply plugin: 'kotlin-kapt' +apply plugin: 'com.google.devtools.ksp' apply plugin: 'dagger.hilt.android.plugin' if (project.hasProperty("coverage")) { @@ -103,6 +104,7 @@ android { } } dependencies { + implementation project(":vector-config") api project(":matrix-sdk-android") implementation project(":matrix-sdk-android-flow") @@ -142,6 +144,9 @@ dependencies { // Opus Encoder implementation libs.element.opusencoder + // WYSIWYG Editor + implementation libs.element.wysiwyg + // Log api libs.jakewharton.timber @@ -156,7 +161,7 @@ dependencies { api libs.airbnb.epoxy implementation libs.airbnb.epoxyGlide - kapt libs.airbnb.epoxyProcessor + ksp libs.airbnb.epoxyProcessor implementation libs.airbnb.epoxyPaging api libs.airbnb.mavericks @@ -183,6 +188,7 @@ dependencies { } implementation libs.markwon.core implementation libs.markwon.extLatex + implementation libs.markwon.imageGlide implementation libs.markwon.inlineParser implementation libs.markwon.html implementation 'com.googlecode.htmlcompressor:htmlcompressor:1.5.2' @@ -230,6 +236,7 @@ dependencies { implementation('com.posthog.android:posthog:1.1.2') { exclude group: 'com.android.support', module: 'support-annotations' } + implementation libs.sentry.sentryAndroid // UnifiedPush implementation 'com.github.UnifiedPush:android-connector:2.1.0' @@ -290,10 +297,10 @@ dependencies { testImplementation libs.tests.kluent testImplementation libs.mockk.mockk testImplementation libs.androidx.coreTesting - testImplementation libs.tests.robolectric // Plant Timber tree for test testImplementation libs.tests.timberJunitRule testImplementation libs.airbnb.mavericksTesting + testImplementation libs.androidx.coreTesting testImplementation(libs.jetbrains.coroutinesTest) { exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug" } @@ -319,5 +326,5 @@ dependencies { androidTestImplementation libs.mockk.mockkAndroid androidTestUtil libs.androidx.orchestrator debugImplementation libs.androidx.fragmentTesting - androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.7.10" + androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.7.20" } diff --git a/vector/src/test/java/im/vector/app/features/RoomMemberListControllerTest.kt b/vector/src/androidTest/java/im/vector/app/features/RoomMemberListControllerTest.kt similarity index 91% rename from vector/src/test/java/im/vector/app/features/RoomMemberListControllerTest.kt rename to vector/src/androidTest/java/im/vector/app/features/RoomMemberListControllerTest.kt index d32c3b5532..73174e4b34 100644 --- a/vector/src/test/java/im/vector/app/features/RoomMemberListControllerTest.kt +++ b/vector/src/androidTest/java/im/vector/app/features/RoomMemberListControllerTest.kt @@ -16,36 +16,27 @@ package im.vector.app.features -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.airbnb.mvrx.Success -import com.airbnb.mvrx.test.MvRxTestRule import im.vector.app.core.epoxy.profiles.ProfileMatrixItemWithPowerLevelWithPresence import im.vector.app.features.roomprofile.members.RoomMemberListCategories import im.vector.app.features.roomprofile.members.RoomMemberListController import im.vector.app.features.roomprofile.members.RoomMemberListViewState import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo -import org.junit.Rule import org.junit.Test -import org.junit.runner.RunWith import org.matrix.android.sdk.api.session.crypto.model.UserVerificationLevel import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary -import org.robolectric.RobolectricTestRunner +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine -@RunWith(RobolectricTestRunner::class) class RoomMemberListControllerTest { - @get:Rule - val mvrxTestRule = MvRxTestRule() - - @get:Rule - val instantExecutorRule = InstantTaskExecutorRule() - @Test - fun testControllerUserVerificationLevel() { + fun testControllerUserVerificationLevel() = runTest { val roomListController = RoomMemberListController( avatarRenderer = mockk { }, @@ -107,7 +98,12 @@ class RoomMemberListControllerTest { ) ) - roomListController.setData(state) + suspendCoroutine { continuation -> + roomListController.setData(state) + roomListController.addModelBuildListener { + continuation.resume(it) + } + } val models = roomListController.adapter.copyOfModels diff --git a/vector/src/androidTest/java/im/vector/app/features/html/EventHtmlRendererTest.kt b/vector/src/androidTest/java/im/vector/app/features/html/EventHtmlRendererTest.kt index 41c0f51322..a2e489dd70 100644 --- a/vector/src/androidTest/java/im/vector/app/features/html/EventHtmlRendererTest.kt +++ b/vector/src/androidTest/java/im/vector/app/features/html/EventHtmlRendererTest.kt @@ -18,6 +18,7 @@ package im.vector.app.features.html import androidx.core.text.toSpannable import androidx.test.platform.app.InstrumentationRegistry +import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.resources.ColorProvider import im.vector.app.core.utils.toTestSpan import im.vector.app.features.settings.VectorPreferences @@ -36,11 +37,13 @@ class EventHtmlRendererTest { private val fakeVectorPreferences = mockk().also { every { it.latexMathsIsEnabled() } returns false } + private val fakeSessionHolder = mockk() private val renderer = EventHtmlRenderer( MatrixHtmlPluginConfigure(ColorProvider(context), context.resources), context, - fakeVectorPreferences + fakeVectorPreferences, + fakeSessionHolder, ) @Test diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 1b26f814e6..572c3ab385 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -69,6 +69,9 @@ + + + throw IllegalStateException("Unhandled build type: ${BuildConfig.BUILD_TYPE}") } return when (config) { - Analytics.Disabled -> AnalyticsConfig(isEnabled = false, "", "", "") - is Analytics.PostHog -> AnalyticsConfig( + Analytics.Disabled -> AnalyticsConfig(isEnabled = false, "", "", "", "", "") + is Analytics.Enabled -> AnalyticsConfig( isEnabled = true, postHogHost = config.postHogHost, postHogApiKey = config.postHogApiKey, - policyLink = config.policyLink + policyLink = config.policyLink, + sentryDSN = config.sentryDSN, + sentryEnvironment = config.sentryEnvironment ) } } diff --git a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt index 76e15cd5cf..78cef2b858 100644 --- a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt @@ -90,6 +90,7 @@ import im.vector.app.features.settings.crosssigning.CrossSigningSettingsViewMode import im.vector.app.features.settings.devices.DeviceVerificationInfoBottomSheetViewModel import im.vector.app.features.settings.devices.DevicesViewModel import im.vector.app.features.settings.devices.v2.details.SessionDetailsViewModel +import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreViewModel import im.vector.app.features.settings.devices.v2.othersessions.OtherSessionsViewModel import im.vector.app.features.settings.devices.v2.overview.SessionOverviewViewModel import im.vector.app.features.settings.devices.v2.rename.RenameSessionViewModel @@ -665,4 +666,9 @@ interface MavericksViewModelModule { @IntoMap @MavericksViewModelKey(QrCodeLoginViewModel::class) fun qrCodeLoginViewModelFactory(factory: QrCodeLoginViewModel.Factory): MavericksAssistedViewModelFactory<*, *> + + @Binds + @IntoMap + @MavericksViewModelKey(SessionLearnMoreViewModel::class) + fun sessionLearnMoreViewModelFactory(factory: SessionLearnMoreViewModel.Factory): MavericksAssistedViewModelFactory<*, *> } diff --git a/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt b/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt index 28c1587b1a..cdb84387ce 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt @@ -16,9 +16,14 @@ package im.vector.app.core.extensions +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent 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.message.MessageContent import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent fun TimelineEvent.canReact(): Boolean { // Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START are supported for the moment @@ -26,3 +31,15 @@ fun TimelineEvent.canReact(): Boolean { root.sendState == SendState.SYNCED && !root.isRedacted() } + +/** + * Get last MessageContent, after a possible edition. + * This method iterate on the vector event types and fallback to [getLastMessageContent] from the matrix sdk for the other types. + */ +fun TimelineEvent.getVectorLastMessageContent(): MessageContent? { + // Iterate on event types which are not part of the matrix sdk, otherwise fallback to the sdk method + return when (root.getClearType()) { + STATE_ROOM_VOICE_BROADCAST_INFO -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel() + else -> getLastMessageContent() + } +} diff --git a/vector/src/main/java/im/vector/app/core/pushers/PushersManager.kt b/vector/src/main/java/im/vector/app/core/pushers/PushersManager.kt index c77f454ab0..44cccbd3f5 100644 --- a/vector/src/main/java/im/vector/app/core/pushers/PushersManager.kt +++ b/vector/src/main/java/im/vector/app/core/pushers/PushersManager.kt @@ -17,6 +17,7 @@ package im.vector.app.core.pushers import im.vector.app.R +import im.vector.app.core.device.GetDeviceInfoUseCase import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.resources.AppNameProvider import im.vector.app.core.resources.LocaleProvider @@ -26,7 +27,7 @@ import java.util.UUID import javax.inject.Inject import kotlin.math.abs -private const val DEFAULT_PUSHER_FILE_TAG = "mobile" +internal const val DEFAULT_PUSHER_FILE_TAG = "mobile" class PushersManager @Inject constructor( private val unifiedPushHelper: UnifiedPushHelper, @@ -34,6 +35,7 @@ class PushersManager @Inject constructor( private val localeProvider: LocaleProvider, private val stringProvider: StringProvider, private val appNameProvider: AppNameProvider, + private val getDeviceInfoUseCase: GetDeviceInfoUseCase, ) { suspend fun testPush() { val currentSession = activeSessionHolder.getActiveSession() @@ -63,15 +65,17 @@ class PushersManager @Inject constructor( pushKey: String, gateway: String ) = HttpPusher( - pushKey, - stringProvider.getString(R.string.pusher_app_id), + pushkey = pushKey, + appId = stringProvider.getString(R.string.pusher_app_id), profileTag = DEFAULT_PUSHER_FILE_TAG + "_" + abs(activeSessionHolder.getActiveSession().myUserId.hashCode()), - localeProvider.current().language, - appNameProvider.getAppName(), - activeSessionHolder.getActiveSession().sessionParams.deviceId ?: "MOBILE", - gateway, + lang = localeProvider.current().language, + appDisplayName = appNameProvider.getAppName(), + deviceDisplayName = getDeviceInfoUseCase.execute().displayName().orEmpty(), + url = gateway, + enabled = true, + deviceId = activeSessionHolder.getActiveSession().sessionParams.deviceId ?: "MOBILE", append = false, - withEventIdOnly = true + withEventIdOnly = true, ) suspend fun registerEmailForPush(email: String) { diff --git a/vector/src/main/java/im/vector/app/core/resources/AppNameProvider.kt b/vector/src/main/java/im/vector/app/core/resources/AppNameProvider.kt index 3b6a8b595c..a25862f3a8 100644 --- a/vector/src/main/java/im/vector/app/core/resources/AppNameProvider.kt +++ b/vector/src/main/java/im/vector/app/core/resources/AppNameProvider.kt @@ -21,9 +21,14 @@ import im.vector.app.core.utils.getApplicationLabel import timber.log.Timber import javax.inject.Inject -class AppNameProvider @Inject constructor(private val context: Context) { +interface AppNameProvider { - fun getAppName(): String { + fun getAppName(): String +} + +class DefaultAppNameProvider @Inject constructor(private val context: Context) : AppNameProvider { + + override fun getAppName(): String { return try { val appPackageName = context.applicationContext.packageName var appName = context.getApplicationLabel(appPackageName) diff --git a/vector/src/main/java/im/vector/app/core/resources/LocaleProvider.kt b/vector/src/main/java/im/vector/app/core/resources/LocaleProvider.kt index d91a09e6df..3bdbc64eb4 100644 --- a/vector/src/main/java/im/vector/app/core/resources/LocaleProvider.kt +++ b/vector/src/main/java/im/vector/app/core/resources/LocaleProvider.kt @@ -23,9 +23,14 @@ import androidx.core.os.ConfigurationCompat import java.util.Locale import javax.inject.Inject -class LocaleProvider @Inject constructor(private val resources: Resources) { +interface LocaleProvider { - fun current(): Locale { + fun current(): Locale +} + +class DefaultLocaleProvider @Inject constructor(private val resources: Resources) : LocaleProvider { + + override fun current(): Locale { return ConfigurationCompat.getLocales(resources.configuration).get(0) ?: Locale.getDefault() } } diff --git a/vector/src/main/java/im/vector/app/core/utils/PermissionsTools.kt b/vector/src/main/java/im/vector/app/core/utils/PermissionsTools.kt index 9ad95d3c55..a287626671 100644 --- a/vector/src/main/java/im/vector/app/core/utils/PermissionsTools.kt +++ b/vector/src/main/java/im/vector/app/core/utils/PermissionsTools.kt @@ -42,6 +42,7 @@ val PERMISSIONS_FOR_ROOM_AVATAR = listOf(Manifest.permission.CAMERA) val PERMISSIONS_FOR_WRITING_FILES = listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE) val PERMISSIONS_FOR_PICKING_CONTACT = listOf(Manifest.permission.READ_CONTACTS) val PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING = listOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION) +val PERMISSIONS_FOR_VOICE_BROADCAST = listOf(Manifest.permission.RECORD_AUDIO) // This is not ideal to store the value like that, but it works private var permissionDialogDisplayed = false diff --git a/vector/src/main/java/im/vector/app/features/MainActivity.kt b/vector/src/main/java/im/vector/app/features/MainActivity.kt index e1e7764f19..1b3a9eb142 100644 --- a/vector/src/main/java/im/vector/app/features/MainActivity.kt +++ b/vector/src/main/java/im/vector/app/features/MainActivity.kt @@ -42,9 +42,10 @@ import im.vector.app.features.analytics.plan.ViewRoom import im.vector.app.features.home.HomeActivity import im.vector.app.features.home.ShortcutsHandler import im.vector.app.features.notifications.NotificationDrawerManager -import im.vector.app.features.pin.PinCodeStore import im.vector.app.features.pin.PinLocker import im.vector.app.features.pin.UnlockedActivity +import im.vector.app.features.pin.lockscreen.crypto.LockScreenKeyRepository +import im.vector.app.features.pin.lockscreen.pincode.PinCodeHelper import im.vector.app.features.popup.PopupAlertManager import im.vector.app.features.session.VectorSessionStore import im.vector.app.features.settings.VectorPreferences @@ -134,10 +135,11 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity @Inject lateinit var vectorPreferences: VectorPreferences @Inject lateinit var uiStateRepository: UiStateRepository @Inject lateinit var shortcutsHandler: ShortcutsHandler - @Inject lateinit var pinCodeStore: PinCodeStore + @Inject lateinit var pinCodeHelper: PinCodeHelper @Inject lateinit var pinLocker: PinLocker @Inject lateinit var popupAlertManager: PopupAlertManager @Inject lateinit var vectorAnalytics: VectorAnalytics + @Inject lateinit var lockScreenKeyRepository: LockScreenKeyRepository override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -284,9 +286,10 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity vectorPreferences.clearPreferences() uiStateRepository.reset() pinLocker.unlock() - pinCodeStore.deletePinCode() + pinCodeHelper.deletePinCode() vectorAnalytics.onSignOut() vectorSessionStore.clear() + lockScreenKeyRepository.deleteSystemKey() } withContext(Dispatchers.IO) { // On BG thread diff --git a/vector/src/main/java/im/vector/app/features/VectorFeatures.kt b/vector/src/main/java/im/vector/app/features/VectorFeatures.kt index df952d8592..62eb0523b0 100644 --- a/vector/src/main/java/im/vector/app/features/VectorFeatures.kt +++ b/vector/src/main/java/im/vector/app/features/VectorFeatures.kt @@ -42,6 +42,7 @@ interface VectorFeatures { fun isNewAppLayoutFeatureEnabled(): Boolean fun isNewDeviceManagementEnabled(): Boolean fun isQrCodeLoginEnabled(): Boolean + fun isVoiceBroadcastEnabled(): Boolean } class DefaultVectorFeatures : VectorFeatures { @@ -59,4 +60,5 @@ class DefaultVectorFeatures : VectorFeatures { override fun isNewAppLayoutFeatureEnabled(): Boolean = true override fun isNewDeviceManagementEnabled(): Boolean = false override fun isQrCodeLoginEnabled(): Boolean = false + override fun isVoiceBroadcastEnabled(): Boolean = false } diff --git a/vector/src/main/java/im/vector/app/features/analytics/AnalyticsConfig.kt b/vector/src/main/java/im/vector/app/features/analytics/AnalyticsConfig.kt index bffba6fa9c..cc3eed306d 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/AnalyticsConfig.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/AnalyticsConfig.kt @@ -21,4 +21,6 @@ data class AnalyticsConfig( val postHogHost: String, val postHogApiKey: String, val policyLink: String, + val sentryDSN: String, + val sentryEnvironment: String ) diff --git a/vector/src/main/java/im/vector/app/features/analytics/extensions/UserPropertiesExt.kt b/vector/src/main/java/im/vector/app/features/analytics/extensions/UserPropertiesExt.kt index e5446f438b..0ff04f0854 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/extensions/UserPropertiesExt.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/extensions/UserPropertiesExt.kt @@ -17,6 +17,7 @@ package im.vector.app.features.analytics.extensions import im.vector.app.features.analytics.plan.UserProperties +import im.vector.app.features.home.room.list.home.header.HomeRoomFilter import im.vector.app.features.onboarding.FtueUseCase fun FtueUseCase.toTrackingValue(): UserProperties.FtueUseCaseSelection { @@ -27,3 +28,12 @@ fun FtueUseCase.toTrackingValue(): UserProperties.FtueUseCaseSelection { FtueUseCase.SKIP -> UserProperties.FtueUseCaseSelection.Skip } } + +fun HomeRoomFilter.toTrackingValue(): UserProperties.AllChatsActiveFilter { + return when (this) { + HomeRoomFilter.ALL -> UserProperties.AllChatsActiveFilter.All + HomeRoomFilter.UNREADS -> UserProperties.AllChatsActiveFilter.Unreads + HomeRoomFilter.FAVOURITES -> UserProperties.AllChatsActiveFilter.Favourites + HomeRoomFilter.PEOPlE -> UserProperties.AllChatsActiveFilter.People + } +} diff --git a/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt b/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt index be847dcb7f..553d699d86 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt @@ -41,6 +41,7 @@ private val IGNORED_OPTIONS: Options? = null @Singleton class DefaultVectorAnalytics @Inject constructor( postHogFactory: PostHogFactory, + private val sentryFactory: SentryFactory, analyticsConfig: AnalyticsConfig, private val analyticsStore: AnalyticsStore, private val lateInitUserPropertiesFactory: LateInitUserPropertiesFactory, @@ -94,6 +95,9 @@ class DefaultVectorAnalytics @Inject constructor( override suspend fun onSignOut() { // reset the analyticsId setAnalyticsId("") + + // Close Sentry SDK. + sentryFactory.stopSentry() } private fun observeAnalyticsId() { @@ -123,10 +127,20 @@ class DefaultVectorAnalytics @Inject constructor( Timber.tag(analyticsTag.value).d("User consent updated to $consent") userConsent = consent optOutPostHog() + initOrStopSentry() } .launchIn(globalScope) } + private fun initOrStopSentry() { + userConsent?.let { + when (it) { + true -> sentryFactory.initSentry() + false -> sentryFactory.stopSentry() + } + } + } + private fun optOutPostHog() { userConsent?.let { posthog?.optOut(!it) } } diff --git a/vector/src/main/java/im/vector/app/features/analytics/impl/SentryFactory.kt b/vector/src/main/java/im/vector/app/features/analytics/impl/SentryFactory.kt new file mode 100644 index 0000000000..a000f2a77a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/analytics/impl/SentryFactory.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.analytics.impl + +import android.content.Context +import im.vector.app.features.analytics.AnalyticsConfig +import im.vector.app.features.analytics.log.analyticsTag +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.android.core.SentryAndroid +import timber.log.Timber +import javax.inject.Inject + +class SentryFactory @Inject constructor( + private val context: Context, + private val analyticsConfig: AnalyticsConfig, +) { + + fun initSentry() { + Timber.tag(analyticsTag.value).d("Initializing Sentry") + if (Sentry.isEnabled()) return + SentryAndroid.init(context) { options -> + options.dsn = analyticsConfig.sentryDSN + options.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> event } + options.tracesSampleRate = 1.0 + options.isEnableUserInteractionTracing = true + options.environment = analyticsConfig.sentryEnvironment + options.diagnosticLevel + } + } + + fun stopSentry() { + Timber.tag(analyticsTag.value).d("Stopping Sentry") + Sentry.close() + } +} diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt index c85c3aa6b5..8536b765d4 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt @@ -40,6 +40,7 @@ import im.vector.app.core.utils.PERMISSIONS_EMPTY import im.vector.app.core.utils.PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING import im.vector.app.core.utils.PERMISSIONS_FOR_PICKING_CONTACT import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO +import im.vector.app.core.utils.PERMISSIONS_FOR_VOICE_BROADCAST import im.vector.app.databinding.ViewAttachmentTypeSelectorBinding import im.vector.app.features.attachments.AttachmentTypeSelectorView.Callback import kotlin.math.max @@ -75,6 +76,7 @@ class AttachmentTypeSelectorView( views.attachmentContactButton.configure(Type.CONTACT) views.attachmentPollButton.configure(Type.POLL) views.attachmentLocationButton.configure(Type.LOCATION) + views.attachmentVoiceBroadcast.configure(Type.VOICE_BROADCAST) width = LinearLayout.LayoutParams.MATCH_PARENT height = LinearLayout.LayoutParams.WRAP_CONTENT animationStyle = 0 @@ -134,6 +136,7 @@ class AttachmentTypeSelectorView( Type.CONTACT -> views.attachmentContactButton Type.POLL -> views.attachmentPollButton Type.LOCATION -> views.attachmentLocationButton + Type.VOICE_BROADCAST -> views.attachmentVoiceBroadcast }.let { it.isVisible = isVisible } @@ -221,6 +224,7 @@ class AttachmentTypeSelectorView( STICKER(PERMISSIONS_EMPTY, R.string.tooltip_attachment_sticker), CONTACT(PERMISSIONS_FOR_PICKING_CONTACT, R.string.tooltip_attachment_contact), POLL(PERMISSIONS_EMPTY, R.string.tooltip_attachment_poll), - LOCATION(PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING, R.string.tooltip_attachment_location) + LOCATION(PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING, R.string.tooltip_attachment_location), + VOICE_BROADCAST(PERMISSIONS_FOR_VOICE_BROADCAST, R.string.tooltip_attachment_voice_broadcast), } } diff --git a/vector/src/main/java/im/vector/app/features/attachments/preview/AttachmentsPreviewFragment.kt b/vector/src/main/java/im/vector/app/features/attachments/preview/AttachmentsPreviewFragment.kt index 20b155d11e..e7ab8c9804 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/preview/AttachmentsPreviewFragment.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/preview/AttachmentsPreviewFragment.kt @@ -207,13 +207,13 @@ class AttachmentsPreviewFragment : attachmentMiniaturePreviewController.callback = this views.attachmentPreviewerMiniatureList.let { - it.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + it.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false) it.setHasFixedSize(true) it.adapter = attachmentMiniaturePreviewController.adapter } views.attachmentPreviewerBigList.let { - it.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + it.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false) it.attachSnapHelperWithListener( PagerSnapHelper(), SnapOnScrollListener.Behavior.NOTIFY_ON_SCROLL_STATE_IDLE, diff --git a/vector/src/main/java/im/vector/app/features/command/CommandParser.kt b/vector/src/main/java/im/vector/app/features/command/CommandParser.kt index 81950fe86c..e08bc9fb64 100644 --- a/vector/src/main/java/im/vector/app/features/command/CommandParser.kt +++ b/vector/src/main/java/im/vector/app/features/command/CommandParser.kt @@ -18,6 +18,7 @@ package im.vector.app.features.command import im.vector.app.core.extensions.isEmail import im.vector.app.core.extensions.isMsisdn +import im.vector.app.core.extensions.orEmpty import im.vector.app.features.home.room.detail.ChatEffect import org.matrix.android.sdk.api.MatrixPatterns import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl @@ -30,39 +31,30 @@ class CommandParser @Inject constructor() { /** * Convert the text message into a Slash command. * - * @param textMessage the text message + * @param textMessage the text message in plain text + * @param formattedMessage the text messaged in HTML format * @param isInThreadTimeline true if the user is currently typing in a thread * @return a parsed slash command (ok or error) */ - fun parseSlashCommand(textMessage: CharSequence, isInThreadTimeline: Boolean): ParsedCommand { + @Suppress("NAME_SHADOWING") + fun parseSlashCommand(textMessage: CharSequence, formattedMessage: String?, isInThreadTimeline: Boolean): ParsedCommand { // check if it has the Slash marker - return if (!textMessage.startsWith("/")) { + val message = formattedMessage ?: textMessage + return if (!message.startsWith("/")) { ParsedCommand.ErrorNotACommand } else { // "/" only - if (textMessage.length == 1) { + if (message.length == 1) { return ParsedCommand.ErrorEmptySlashCommand } // Exclude "//" - if ("/" == textMessage.substring(1, 2)) { + if ("/" == message.substring(1, 2)) { return ParsedCommand.ErrorNotACommand } - val messageParts = try { - textMessage.split("\\s+".toRegex()).dropLastWhile { it.isEmpty() } - } catch (e: Exception) { - Timber.e(e, "## parseSlashCommand() : split failed") - null - } - - // test if the string cut fails - if (messageParts.isNullOrEmpty()) { - return ParsedCommand.ErrorEmptySlashCommand - } - + val (messageParts, message) = extractMessage(message.toString()) ?: return ParsedCommand.ErrorEmptySlashCommand val slashCommand = messageParts.first() - val message = textMessage.substring(slashCommand.length).trim() getNotSupportedByThreads(isInThreadTimeline, slashCommand)?.let { return ParsedCommand.ErrorCommandNotSupportedInThreads(it) @@ -71,7 +63,12 @@ class CommandParser @Inject constructor() { when { Command.PLAIN.matches(slashCommand) -> { if (message.isNotEmpty()) { - ParsedCommand.SendPlainText(message = message) + if (formattedMessage != null) { + val trimmedPlainTextMessage = extractMessage(textMessage.toString())?.second.orEmpty() + ParsedCommand.SendFormattedText(message = trimmedPlainTextMessage, formattedMessage = message) + } else { + ParsedCommand.SendPlainText(message = message) + } } else { ParsedCommand.ErrorSyntax(Command.PLAIN) } @@ -415,6 +412,25 @@ class CommandParser @Inject constructor() { } } + private fun extractMessage(message: String): Pair, String>? { + val messageParts = try { + message.split("\\s+".toRegex()).dropLastWhile { it.isEmpty() } + } catch (e: Exception) { + Timber.e(e, "## parseSlashCommand() : split failed") + null + } + + // test if the string cut fails + if (messageParts.isNullOrEmpty()) { + return null + } + + val slashCommand = messageParts.first() + val trimmedMessage = message.substring(slashCommand.length).trim() + + return messageParts to trimmedMessage + } + private val notSupportedThreadsCommands: List by lazy { Command.values().filter { !it.isThreadCommand diff --git a/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt b/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt index eee786253b..670eddefda 100644 --- a/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt +++ b/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt @@ -39,6 +39,7 @@ sealed interface ParsedCommand { // Valid commands: data class SendPlainText(val message: CharSequence) : ParsedCommand + data class SendFormattedText(val message: CharSequence, val formattedMessage: String) : ParsedCommand data class SendEmote(val message: CharSequence) : ParsedCommand data class SendRainbow(val message: CharSequence) : ParsedCommand data class SendRainbowEmote(val message: CharSequence) : ParsedCommand 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 c1e3b58a80..3e828f62b7 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 @@ -119,4 +119,11 @@ sealed class RoomDetailAction : VectorViewModelAction { object StopLiveLocationSharing : RoomDetailAction() object OpenElementCallWidget : RoomDetailAction() + + sealed class VoiceBroadcastAction : RoomDetailAction() { + object Start : VoiceBroadcastAction() + object Pause : VoiceBroadcastAction() + object Resume : VoiceBroadcastAction() + object Stop : VoiceBroadcastAction() + } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt index 7aa7d5a877..897594ffad 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt @@ -20,6 +20,7 @@ import com.airbnb.mvrx.Async import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.Uninitialized import im.vector.app.features.home.room.detail.arguments.TimelineArgs +import im.vector.app.features.share.SharedData import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState @@ -77,6 +78,8 @@ data class RoomDetailViewState( val threadNotificationBadgeState: ThreadNotificationBadgeState = ThreadNotificationBadgeState(), val typingUsers: List? = null, val isSharingLiveLocation: Boolean = false, + val showKeyboardWhenPresented: Boolean = false, + val sharedData: SharedData? = null, ) : MavericksState { constructor(args: TimelineArgs) : this( @@ -86,7 +89,9 @@ data class RoomDetailViewState( // Also highlight the target event, if any highlightedEventId = args.eventId, switchToParentSpace = args.switchToParentSpace, - rootThreadEventId = args.threadTimelineArgs?.rootThreadEventId + rootThreadEventId = args.threadTimelineArgs?.rootThreadEventId, + showKeyboardWhenPresented = args.threadTimelineArgs?.showKeyboard.orFalse(), + sharedData = args.sharedData, ) fun isCallOptionAvailable(): Boolean { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index bba607eeb4..c34d60de71 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -19,32 +19,23 @@ package im.vector.app.features.home.room.detail import android.annotation.SuppressLint import android.app.Activity import android.content.Intent -import android.content.res.Configuration import android.net.Uri import android.os.Build import android.os.Bundle -import android.text.Spannable -import android.text.format.DateUtils import android.text.method.LinkMovementMethod import android.view.HapticFeedbackConstants -import android.view.KeyEvent import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.view.inputmethod.EditorInfo import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView -import android.widget.Toast -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes import androidx.appcompat.view.menu.MenuBuilder import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat import androidx.core.net.toUri -import androidx.core.text.buildSpannedString import androidx.core.text.toSpannable import androidx.core.util.Pair import androidx.core.view.ViewCompat @@ -52,7 +43,6 @@ import androidx.core.view.forEach import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.fragment.app.setFragmentResultListener -import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager @@ -67,7 +57,6 @@ import com.airbnb.mvrx.args import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.vanniktech.emoji.EmojiPopup import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.animations.play @@ -75,26 +64,22 @@ import im.vector.app.core.dialogs.ConfirmationDialogBuilder import im.vector.app.core.dialogs.GalleryOrCameraDialogHelper import im.vector.app.core.dialogs.GalleryOrCameraDialogHelperFactory import im.vector.app.core.epoxy.LayoutManagerStateRestorer -import im.vector.app.core.error.fatalError import im.vector.app.core.extensions.cleanup +import im.vector.app.core.extensions.commitTransaction import im.vector.app.core.extensions.containsRtLOverride import im.vector.app.core.extensions.ensureEndsLeftToRight import im.vector.app.core.extensions.filterDirectionOverrides import im.vector.app.core.extensions.hideKeyboard import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.extensions.setTextOrHide -import im.vector.app.core.extensions.showKeyboard import im.vector.app.core.extensions.trackItemsVisibilityChange import im.vector.app.core.glide.GlideApp import im.vector.app.core.glide.GlideRequests -import im.vector.app.core.hardware.vibrate import im.vector.app.core.intent.getFilenameFromUri import im.vector.app.core.intent.getMimeTypeFromUri import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorMenuProvider -import im.vector.app.core.platform.lifecycleAwareLazy import im.vector.app.core.platform.showOptimizedSnackbar -import im.vector.app.core.resources.BuildMeta import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.UserPreferencesProvider import im.vector.app.core.time.Clock @@ -106,7 +91,6 @@ import im.vector.app.core.ui.views.NotificationAreaView import im.vector.app.core.utils.Debouncer import im.vector.app.core.utils.DimensionConverter import im.vector.app.core.utils.KeyboardStateUtils -import im.vector.app.core.utils.PERMISSIONS_FOR_VOICE_MESSAGE import im.vector.app.core.utils.PERMISSIONS_FOR_WRITING_FILES import im.vector.app.core.utils.checkPermissions import im.vector.app.core.utils.colorizeMatchingText @@ -116,7 +100,6 @@ import im.vector.app.core.utils.createUIHandler import im.vector.app.core.utils.isAnimationEnabled import im.vector.app.core.utils.isValidUrl import im.vector.app.core.utils.onPermissionDeniedDialog -import im.vector.app.core.utils.onPermissionDeniedSnackbar import im.vector.app.core.utils.openLocation import im.vector.app.core.utils.openUrlInExternalBrowser import im.vector.app.core.utils.registerForPermissionsResult @@ -132,13 +115,7 @@ import im.vector.app.features.VectorFeatures import im.vector.app.features.analytics.extensions.toAnalyticsInteraction import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.analytics.plan.MobileScreen -import im.vector.app.features.attachments.AttachmentTypeSelectorView -import im.vector.app.features.attachments.AttachmentsHelper -import im.vector.app.features.attachments.ContactAttachment import im.vector.app.features.attachments.ShareIntentHandler -import im.vector.app.features.attachments.preview.AttachmentsPreviewActivity -import im.vector.app.features.attachments.preview.AttachmentsPreviewArgs -import im.vector.app.features.attachments.toGroupedContentAttachmentData import im.vector.app.features.call.SharedKnownCallsViewModel import im.vector.app.features.call.VectorCallActivity import im.vector.app.features.call.conference.ConferenceEvent @@ -146,22 +123,16 @@ import im.vector.app.features.call.conference.ConferenceEventEmitter import im.vector.app.features.call.conference.ConferenceEventObserver import im.vector.app.features.call.conference.JitsiCallViewModel import im.vector.app.features.call.webrtc.WebRtcCallManager -import im.vector.app.features.command.Command -import im.vector.app.features.command.ParsedCommand import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreActivity import im.vector.app.features.crypto.verification.VerificationBottomSheet import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.home.room.detail.composer.CanSendStatus import im.vector.app.features.home.room.detail.composer.MessageComposerAction -import im.vector.app.features.home.room.detail.composer.MessageComposerView -import im.vector.app.features.home.room.detail.composer.MessageComposerViewEvents +import im.vector.app.features.home.room.detail.composer.MessageComposerFragment import im.vector.app.features.home.room.detail.composer.MessageComposerViewModel -import im.vector.app.features.home.room.detail.composer.MessageComposerViewState -import im.vector.app.features.home.room.detail.composer.SendMode import im.vector.app.features.home.room.detail.composer.boolean -import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView -import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState +import im.vector.app.features.home.room.detail.composer.voice.VoiceRecorderFragment import im.vector.app.features.home.room.detail.error.RoomNotFound import im.vector.app.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet import im.vector.app.features.home.room.detail.timeline.TimelineEventController @@ -171,7 +142,6 @@ import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActi import im.vector.app.features.home.room.detail.timeline.edithistory.ViewEditHistoryBottomSheet import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider -import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData 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.MessageFileItem @@ -188,7 +158,6 @@ import im.vector.app.features.home.room.detail.widget.RoomWidgetsBottomSheet import im.vector.app.features.home.room.threads.ThreadsManager import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.html.EventHtmlRenderer -import im.vector.app.features.html.PillImageSpan import im.vector.app.features.html.PillsPostProcessor import im.vector.app.features.invite.VectorInviteView import im.vector.app.features.location.LocationSharingMode @@ -206,25 +175,19 @@ import im.vector.app.features.roomprofile.RoomProfileActivity import im.vector.app.features.session.coroutineScope import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorSettingsActivity -import im.vector.app.features.share.SharedData import im.vector.app.features.spaces.share.ShareSpaceBottomSheet import im.vector.app.features.themes.ThemeUtils -import im.vector.app.features.voice.VoiceFailure import im.vector.app.features.widgets.WidgetActivity import im.vector.app.features.widgets.WidgetArgs import im.vector.app.features.widgets.WidgetKind import im.vector.app.features.widgets.permissions.RoomWidgetPermissionBottomSheet import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.billcarsonfr.jsonviewer.JSonViewerDialog -import org.commonmark.parser.Parser import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventContent import org.matrix.android.sdk.api.session.events.model.content.WithHeldCode @@ -233,11 +196,8 @@ import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent -import org.matrix.android.sdk.api.session.room.model.message.MessageContent -import org.matrix.android.sdk.api.session.room.model.message.MessageFormat import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent import org.matrix.android.sdk.api.session.room.model.message.MessageLocationContent -import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageVerificationRequestContent @@ -246,14 +206,11 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachme import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent -import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.widgets.model.Widget import org.matrix.android.sdk.api.session.widgets.model.WidgetType import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.MimeTypes import org.matrix.android.sdk.api.util.toMatrixItem -import reactivecircus.flowbinding.android.view.focusChanges -import reactivecircus.flowbinding.android.widget.textChanges import timber.log.Timber import java.net.URL import java.util.UUID @@ -264,8 +221,6 @@ class TimelineFragment : VectorBaseFragment(), TimelineEventController.Callback, VectorInviteView.Callback, - AttachmentTypeSelectorView.Callback, - AttachmentsHelper.Callback, GalleryOrCameraDialogHelper.Listener, CurrentCallsView.Callback, VectorMenuProvider { @@ -273,7 +228,6 @@ class TimelineFragment : @Inject lateinit var session: Session @Inject lateinit var avatarRenderer: AvatarRenderer @Inject lateinit var timelineEventController: TimelineEventController - @Inject lateinit var autoCompleterFactory: AutoCompleter.Factory @Inject lateinit var permalinkHandler: PermalinkHandler @Inject lateinit var notificationDrawerManager: NotificationDrawerManager @Inject lateinit var eventHtmlRenderer: EventHtmlRenderer @@ -292,42 +246,15 @@ class TimelineFragment : @Inject lateinit var shareIntentHandler: ShareIntentHandler @Inject lateinit var clock: Clock @Inject lateinit var vectorFeatures: VectorFeatures - @Inject lateinit var buildMeta: BuildMeta @Inject lateinit var galleryOrCameraDialogHelperFactory: GalleryOrCameraDialogHelperFactory companion object { - - /** - * Sanitize the display name. - * - * @param displayName the display name to sanitize - * @return the sanitized display name - */ - private fun sanitizeDisplayName(displayName: String): String { - if (displayName.endsWith(ircPattern)) { - return displayName.substring(0, displayName.length - ircPattern.length) - } - - return displayName - } - const val MAX_TYPING_MESSAGE_USERS_COUNT = 4 - private const val ircPattern = " (IRC)" } private lateinit var galleryOrCameraDialogHelper: GalleryOrCameraDialogHelper private val timelineArgs: TimelineArgs by args() - private val glideRequests by lazy { - GlideApp.with(this) - } - private val pillsPostProcessor by lazy { - pillsPostProcessorFactory.create(timelineArgs.roomId) - } - - private val autoCompleter: AutoCompleter by lazy { - autoCompleterFactory.create(timelineArgs.roomId, isThreadTimeLine()) - } private val timelineViewModel: TimelineViewModel by fragmentViewModel() private val messageComposerViewModel: MessageComposerViewModel by fragmentViewModel() @@ -351,19 +278,12 @@ class TimelineFragment : private lateinit var jumpToBottomViewVisibilityManager: JumpToBottomViewVisibilityManager private var modelBuildListener: OnModelBuildFinishedListener? = null - private lateinit var attachmentsHelper: AttachmentsHelper private lateinit var keyboardStateUtils: KeyboardStateUtils private lateinit var callActionsHandler: StartCallActionsHandler - private lateinit var attachmentTypeSelector: AttachmentTypeSelectorView - - private var lockSendButton = false private val currentCallsViewPresenter = CurrentCallsViewPresenter() private val lazyLoadedViews = RoomDetailLazyLoadedViews() - private val emojiPopup: EmojiPopup by lifecycleAwareLazy { - createEmojiPopup() - } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -374,6 +294,18 @@ class TimelineFragment : timelineViewModel.handle(RoomDetailAction.RoomUpgradeSuccess(replacementRoomId)) } } + + if (childFragmentManager.findFragmentById(R.id.composerContainer) == null) { + childFragmentManager.commitTransaction { + replace(R.id.composerContainer, MessageComposerFragment()) + } + } + + if (childFragmentManager.findFragmentById(R.id.voiceMessageRecorderContainer) == null) { + childFragmentManager.commitTransaction { + replace(R.id.voiceMessageRecorderContainer, VoiceRecorderFragment()) + } + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -382,7 +314,6 @@ class TimelineFragment : sharedActionViewModel = activityViewModelProvider.get(MessageSharedActionViewModel::class.java) sharedActivityActionViewModel = activityViewModelProvider.get(RoomDetailSharedActionViewModel::class.java) knownCallsViewModel = activityViewModelProvider.get(SharedKnownCallsViewModel::class.java) - attachmentsHelper = AttachmentsHelper(requireContext(), this, buildMeta).register() callActionsHandler = StartCallActionsHandler( roomId = timelineArgs.roomId, fragment = this, @@ -398,14 +329,11 @@ class TimelineFragment : setupToolbar(views.roomToolbar) .allowBack() setupRecyclerView() - setupComposer() setupNotificationView() setupJumpToReadMarkerView() setupActiveCallView() setupJumpToBottomView() - setupEmojiButton() setupRemoveJitsiWidgetView() - setupVoiceMessageView() setupLiveLocationIndicator() views.includeRoomToolbar.roomToolbarContentView.debouncedClicks { @@ -430,19 +358,6 @@ class TimelineFragment : updateJumpToReadMarkerViewVisibility() } - messageComposerViewModel.onEach(MessageComposerViewState::sendMode, MessageComposerViewState::canSendMessage) { mode, canSend -> - if (!canSend.boolean()) { - return@onEach - } - when (mode) { - is SendMode.Regular -> renderRegularMode(mode.text) - is SendMode.Edit -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, R.string.edit, mode.text) - is SendMode.Quote -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, R.string.action_quote, mode.text) - is SendMode.Reply -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, R.string.reply, mode.text) - is SendMode.Voice -> renderVoiceMessageMode(mode.text) - } - } - timelineViewModel.onEach( RoomDetailViewState::syncState, RoomDetailViewState::incrementalSyncRequestState, @@ -456,24 +371,6 @@ class TimelineFragment : ) } - messageComposerViewModel.observeViewEvents { - when (it) { - is MessageComposerViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it) - is MessageComposerViewEvents.SlashCommandConfirmationRequest -> handleSlashCommandConfirmationRequest(it) - is MessageComposerViewEvents.SendMessageResult -> renderSendMessageResult(it) - is MessageComposerViewEvents.ShowMessage -> showSnackWithMessage(it.message) - is MessageComposerViewEvents.ShowRoomUpgradeDialog -> handleShowRoomUpgradeDialog(it) - is MessageComposerViewEvents.AnimateSendButtonVisibility -> handleSendButtonVisibilityChanged(it) - is MessageComposerViewEvents.OpenRoomMemberProfile -> openRoomMemberProfile(it.userId) - is MessageComposerViewEvents.VoicePlaybackOrRecordingFailure -> { - if (it.throwable is VoiceFailure.UnableToRecord) { - onCannotRecord() - } - showErrorInSnackbar(it.throwable) - } - } - } - timelineViewModel.observeViewEvents { when (it) { is RoomDetailViewEvents.Failure -> displayErrorMessage(it) @@ -513,51 +410,10 @@ class TimelineFragment : } if (savedInstanceState == null) { - handleShareData() handleSpaceShare() } } - private fun handleSlashCommandConfirmationRequest(action: MessageComposerViewEvents.SlashCommandConfirmationRequest) { - when (action.parsedCommand) { - is ParsedCommand.UnignoreUser -> promptUnignoreUser(action.parsedCommand) - else -> TODO("Add case for ${action.parsedCommand.javaClass.simpleName}") - } - lockSendButton = false - } - - private fun promptUnignoreUser(command: ParsedCommand.UnignoreUser) { - MaterialAlertDialogBuilder(requireActivity()) - .setTitle(R.string.room_participants_action_unignore_title) - .setMessage(getString(R.string.settings_unignore_user, command.userId)) - .setPositiveButton(R.string.unignore) { _, _ -> - messageComposerViewModel.handle(MessageComposerAction.SlashCommandConfirmed(command)) - } - .setNegativeButton(R.string.action_cancel, null) - .show() - } - - private fun renderVoiceMessageMode(content: String) { - ContentAttachmentData.fromJsonString(content)?.let { audioAttachmentData -> - views.voiceMessageRecorderView.isVisible = true - messageComposerViewModel.handle(MessageComposerAction.InitializeVoiceRecorder(audioAttachmentData)) - } - } - - private fun handleSendButtonVisibilityChanged(event: MessageComposerViewEvents.AnimateSendButtonVisibility) { - if (event.isVisible) { - views.voiceMessageRecorderView.isVisible = false - views.composerLayout.views.sendButton.alpha = 0f - views.composerLayout.views.sendButton.isVisible = true - views.composerLayout.views.sendButton.animate().alpha(1f).setDuration(150).start() - } else { - views.composerLayout.views.sendButton.isInvisible = true - views.voiceMessageRecorderView.alpha = 0f - views.voiceMessageRecorderView.isVisible = true - views.voiceMessageRecorderView.animate().alpha(1f).setDuration(150).start() - } - } - private fun setupRemoveJitsiWidgetView() { views.removeJitsiWidgetView.onCompleteSliding = { withState(timelineViewModel) { @@ -578,11 +434,6 @@ class TimelineFragment : timelineViewModel.handle(RoomDetailAction.UpdateJoinJitsiCallStatus(conferenceEvent)) } - private fun onCannotRecord() { - // Update the UI, cancel the animation - messageComposerViewModel.handle(MessageComposerAction.OnVoiceRecordingUiStateChanged(RecordingUiState.Idle)) - } - private fun acceptIncomingCall(event: RoomDetailViewEvents.DisplayAndAcceptCall) { val intent = VectorCallActivity.newIntent( context = vectorBaseActivity, @@ -599,12 +450,6 @@ class TimelineFragment : JoinReplacementRoomBottomSheet().show(childFragmentManager, tag) } - private fun handleShowRoomUpgradeDialog(roomDetailViewEvents: MessageComposerViewEvents.ShowRoomUpgradeDialog) { - val tag = MigrateRoomBottomSheet::javaClass.name - MigrateRoomBottomSheet.newInstance(timelineArgs.roomId, roomDetailViewEvents.newVersion) - .show(parentFragmentManager, tag) - } - private fun handleChatEffect(chatEffect: ChatEffect) { if (!requireContext().isAnimationEnabled()) { Timber.d("Do not perform chat effect, animations are disabled.") @@ -721,52 +566,6 @@ class TimelineFragment : ) } - private fun setupEmojiButton() { - views.composerLayout.views.composerEmojiButton.debouncedClicks { - emojiPopup.toggle() - } - } - - private fun createEmojiPopup(): EmojiPopup { - return EmojiPopup( - rootView = views.rootConstraintLayout, - keyboardAnimationStyle = R.style.emoji_fade_animation_style, - onEmojiPopupShownListener = { - views.composerLayout.views.composerEmojiButton.apply { - contentDescription = getString(R.string.a11y_close_emoji_picker) - setImageResource(R.drawable.ic_keyboard) - } - }, - onEmojiPopupDismissListener = lifecycleAwareDismissAction { - views.composerLayout.views.composerEmojiButton.apply { - contentDescription = getString(R.string.a11y_open_emoji_picker) - setImageResource(R.drawable.ic_insert_emoji) - } - }, - editText = views.composerLayout.views.composerEditText - ) - } - - /** - * Ensure dismiss actions only trigger when the fragment is in the started state. - * EmojiPopup by default dismisses onViewDetachedFromWindow, this can cause race conditions with onDestroyView. - */ - private fun lifecycleAwareDismissAction(action: () -> Unit): () -> Unit { - return { - if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { - action() - } - } - } - - private val permissionVoiceMessageLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> - if (allGranted) { - // In this case, let the user start again the gesture - } else if (deniedPermanently) { - vectorBaseActivity.onPermissionDeniedSnackbar(R.string.denied_permission_voice_message) - } - } - private fun createFailedMessagesWarningCallback(): FailedMessagesWarningView.Callback { return object : FailedMessagesWarningView.Callback { override fun onDeleteAllClicked() { @@ -786,86 +585,6 @@ class TimelineFragment : } } - private fun setupVoiceMessageView() { - audioMessagePlaybackTracker.track(AudioMessagePlaybackTracker.RECORDING_ID, views.voiceMessageRecorderView) - views.voiceMessageRecorderView.callback = object : VoiceMessageRecorderView.Callback { - - override fun onVoiceRecordingStarted() { - if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) { - messageComposerViewModel.handle(MessageComposerAction.StartRecordingVoiceMessage) - vibrate(requireContext()) - updateRecordingUiState(RecordingUiState.Recording(clock.epochMillis())) - } - } - - override fun onVoicePlaybackButtonClicked() { - messageComposerViewModel.handle(MessageComposerAction.PlayOrPauseRecordingPlayback) - } - - override fun onVoiceRecordingCancelled() { - messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true, rootThreadEventId = getRootThreadEventId())) - vibrate(requireContext()) - updateRecordingUiState(RecordingUiState.Idle) - } - - override fun onVoiceRecordingLocked() { - val startedState = withState(messageComposerViewModel) { it.voiceRecordingUiState as? RecordingUiState.Recording } - val startTime = startedState?.recordingStartTimestamp ?: clock.epochMillis() - updateRecordingUiState(RecordingUiState.Locked(startTime)) - } - - override fun onVoiceRecordingEnded() { - onSendVoiceMessage() - } - - override fun onSendVoiceMessage() { - messageComposerViewModel.handle( - MessageComposerAction.EndRecordingVoiceMessage(isCancelled = false, rootThreadEventId = getRootThreadEventId()) - ) - updateRecordingUiState(RecordingUiState.Idle) - } - - override fun onDeleteVoiceMessage() { - messageComposerViewModel.handle( - MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true, rootThreadEventId = getRootThreadEventId()) - ) - updateRecordingUiState(RecordingUiState.Idle) - } - - override fun onRecordingLimitReached() { - messageComposerViewModel.handle( - MessageComposerAction.PauseRecordingVoiceMessage - ) - updateRecordingUiState(RecordingUiState.Draft) - } - - override fun onRecordingWaveformClicked() { - messageComposerViewModel.handle( - MessageComposerAction.PauseRecordingVoiceMessage - ) - updateRecordingUiState(RecordingUiState.Draft) - } - - override fun onVoiceWaveformTouchedUp(percentage: Float, duration: Int) { - messageComposerViewModel.handle( - MessageComposerAction.VoiceWaveformTouchedUp(AudioMessagePlaybackTracker.RECORDING_ID, duration, percentage) - ) - } - - override fun onVoiceWaveformMoved(percentage: Float, duration: Int) { - messageComposerViewModel.handle( - MessageComposerAction.VoiceWaveformTouchedUp(AudioMessagePlaybackTracker.RECORDING_ID, duration, percentage) - ) - } - - private fun updateRecordingUiState(state: RecordingUiState) { - messageComposerViewModel.handle( - MessageComposerAction.OnVoiceRecordingUiStateChanged(state) - ) - } - } - } - private fun setupLiveLocationIndicator() { views.liveLocationStatusIndicator.stopButton.debouncedClicks { timelineViewModel.handle(RoomDetailAction.StopLiveLocationSharing) @@ -943,25 +662,6 @@ class TimelineFragment : .show() } - private fun handleJoinedToAnotherRoom(action: MessageComposerViewEvents.JoinRoomCommandSuccess) { - views.composerLayout.setTextIfDifferent("") - lockSendButton = false - navigator.openRoom(vectorBaseActivity, action.roomId) - } - - private fun handleShareData() { - when (val sharedData = timelineArgs.sharedData) { - is SharedData.Text -> { - messageComposerViewModel.handle(MessageComposerAction.EnterRegularMode(sharedData.text, fromSharing = true)) - } - is SharedData.Attachments -> { - // open share edition - onContentAttachmentsReady(sharedData.attachmentData) - } - null -> Timber.v("No share data to process") - } - } - private fun handleSpaceShare() { timelineArgs.openShareSpaceForId?.let { spaceId -> ShareSpaceBottomSheet.show(childFragmentManager, spaceId, true) @@ -972,13 +672,11 @@ class TimelineFragment : } override fun onDestroyView() { - messageComposerViewModel.endAllVoiceActions() lazyLoadedViews.unBind() timelineEventController.callback = null timelineEventController.removeModelBuildListener(modelBuildListener) currentCallsViewPresenter.unBind() modelBuildListener = null - autoCompleter.clear() debouncer.cancelAll() views.timelineRecyclerView.cleanup() super.onDestroyView() @@ -1247,87 +945,11 @@ class TimelineFragment : .show() } - private fun renderRegularMode(content: String) { - autoCompleter.exitSpecialMode() - views.composerLayout.collapse() - views.composerLayout.setTextIfDifferent(content) - views.composerLayout.views.sendButton.contentDescription = getString(R.string.action_send) - } - - private fun renderSpecialMode( - event: TimelineEvent, - @DrawableRes iconRes: Int, - @StringRes descriptionRes: Int, - defaultContent: String - ) { - autoCompleter.enterSpecialMode() - // switch to expanded bar - views.composerLayout.views.composerRelatedMessageTitle.apply { - text = event.senderInfo.disambiguatedDisplayName - setTextColor(matrixItemColorProvider.getColor(MatrixItem.UserItem(event.root.senderId ?: "@"))) - } - - val messageContent: MessageContent? = event.getLastMessageContent() - val nonFormattedBody = when (messageContent) { - is MessageAudioContent -> getAudioContentBodyText(messageContent) - is MessagePollContent -> messageContent.getBestPollCreationInfo()?.question?.getBestQuestion() - is MessageBeaconInfoContent -> getString(R.string.live_location_description) - else -> messageContent?.body.orEmpty() - } - var formattedBody: CharSequence? = null - if (messageContent is MessageTextContent && messageContent.format == MessageFormat.FORMAT_MATRIX_HTML) { - val parser = Parser.builder().build() - val document = parser.parse(messageContent.formattedBody ?: messageContent.body) - formattedBody = eventHtmlRenderer.render(document, pillsPostProcessor) - } - views.composerLayout.views.composerRelatedMessageContent.text = (formattedBody ?: nonFormattedBody) - - // Image Event - val data = event.buildImageContentRendererData(dimensionConverter.dpToPx(66)) - val isImageVisible = if (data != null) { - imageContentRenderer.render(data, ImageContentRenderer.Mode.THUMBNAIL, views.composerLayout.views.composerRelatedMessageImage) - true - } else { - imageContentRenderer.clear(views.composerLayout.views.composerRelatedMessageImage) - false - } - - views.composerLayout.views.composerRelatedMessageImage.isVisible = isImageVisible - - views.composerLayout.setTextIfDifferent(defaultContent) - - views.composerLayout.views.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) - views.composerLayout.views.sendButton.contentDescription = getString(descriptionRes) - - avatarRenderer.render(event.senderInfo.toMatrixItem(), views.composerLayout.views.composerRelatedMessageAvatar) - - views.composerLayout.expand { - if (isAdded) { - // need to do it here also when not using quick reply - focusComposerAndShowKeyboard() - views.composerLayout.views.composerRelatedMessageImage.isVisible = isImageVisible - } - } - focusComposerAndShowKeyboard() - } - - private fun getAudioContentBodyText(messageContent: MessageAudioContent): String { - val formattedDuration = DateUtils.formatElapsedTime(((messageContent.audioInfo?.duration ?: 0) / 1000).toLong()) - return if (messageContent.voiceMessageIndicator != null) { - getString(R.string.voice_message_reply_content, formattedDuration) - } else { - getString(R.string.audio_message_reply_content, messageContent.body, formattedDuration) - } - } - override fun onResume() { super.onResume() notificationDrawerManager.setCurrentRoom(timelineArgs.roomId) roomDetailPendingActionStore.data?.let { handlePendingAction(it) } roomDetailPendingActionStore.data = null - - // Removed listeners should be set again - setupVoiceMessageView() } private fun handlePendingAction(roomDetailPendingAction: RoomDetailPendingAction) { @@ -1336,7 +958,7 @@ class TimelineFragment : is RoomDetailPendingAction.JumpToReadReceipt -> timelineViewModel.handle(RoomDetailAction.JumpToReadReceipt(roomDetailPendingAction.userId)) is RoomDetailPendingAction.MentionUser -> - insertUserDisplayNameInTextEditor(roomDetailPendingAction.userId) + messageComposerViewModel.handle(MessageComposerAction.InsertUserDisplayName(roomDetailPendingAction.userId)) is RoomDetailPendingAction.OpenRoom -> handleOpenRoom(RoomDetailViewEvents.OpenRoom(roomDetailPendingAction.roomId, roomDetailPendingAction.closeCurrentRoom)) } @@ -1345,52 +967,6 @@ class TimelineFragment : override fun onPause() { super.onPause() notificationDrawerManager.setCurrentRoom(null) - audioMessagePlaybackTracker.pauseAllPlaybacks() - - if (withState(messageComposerViewModel) { it.isVoiceRecording } && requireActivity().isChangingConfigurations) { - // we're rotating, maintain any active recordings - } else { - messageComposerViewModel.handle(MessageComposerAction.OnEntersBackground(views.composerLayout.text.toString())) - } - } - - private val attachmentFileActivityResultLauncher = registerStartForActivityResult { - if (it.resultCode == Activity.RESULT_OK) { - attachmentsHelper.onFileResult(it.data) - } - } - - private val attachmentContactActivityResultLauncher = registerStartForActivityResult { - if (it.resultCode == Activity.RESULT_OK) { - attachmentsHelper.onContactResult(it.data) - } - } - - private val attachmentMediaActivityResultLauncher = registerStartForActivityResult { - if (it.resultCode == Activity.RESULT_OK) { - attachmentsHelper.onMediaResult(it.data) - } - } - - private val attachmentCameraActivityResultLauncher = registerStartForActivityResult { - if (it.resultCode == Activity.RESULT_OK) { - attachmentsHelper.onCameraResult() - } - } - - private val attachmentCameraVideoActivityResultLauncher = registerStartForActivityResult { - if (it.resultCode == Activity.RESULT_OK) { - attachmentsHelper.onCameraVideoResult() - } - } - - private val contentAttachmentActivityResultLauncher = registerStartForActivityResult { activityResult -> - val data = activityResult.data ?: return@registerStartForActivityResult - if (activityResult.resultCode == Activity.RESULT_OK) { - val sendData = AttachmentsPreviewActivity.getOutput(data) - val keepOriginalSize = AttachmentsPreviewActivity.getKeepOriginalSize(data) - timelineViewModel.handle(RoomDetailAction.SendMedia(sendData, !keepOriginalSize)) - } } private val emojiActivityResultLauncher = registerStartForActivityResult { activityResult -> @@ -1434,8 +1010,8 @@ class TimelineFragment : timelineEventController.timeline = timelineViewModel.timeline views.timelineRecyclerView.trackItemsVisibilityChange() - layoutManager = object : LinearLayoutManager(context, RecyclerView.VERTICAL, true) { - override fun onLayoutCompleted(state: RecyclerView.State?) { + layoutManager = object : LinearLayoutManager(requireContext(), RecyclerView.VERTICAL, true) { + override fun onLayoutCompleted(state: RecyclerView.State) { super.onLayoutCompleted(state) updateJumpToReadMarkerViewVisibility() jumpToBottomViewVisibilityManager.maybeShowJumpToBottomViewVisibilityWithDelay() @@ -1463,7 +1039,7 @@ class TimelineFragment : override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { (model as? AbsMessageItem)?.attributes?.informationData?.let { val eventId = it.eventId - messageComposerViewModel.handle(MessageComposerAction.EnterReplyMode(eventId, views.composerLayout.text.toString())) + messageComposerViewModel.handle(MessageComposerAction.EnterReplyMode(eventId)) } } @@ -1530,117 +1106,6 @@ class TimelineFragment : } } - private fun setupComposer() { - val composerEditText = views.composerLayout.views.composerEditText - autoCompleter.setup(composerEditText) - - observerUserTyping() - - if (vectorPreferences.sendMessageWithEnter()) { - // imeOptions="actionSend" only works with single line, so we remove multiline inputType - composerEditText.inputType = composerEditText.inputType and EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE.inv() - composerEditText.imeOptions = EditorInfo.IME_ACTION_SEND - } - - composerEditText.setOnEditorActionListener { v, actionId, keyEvent -> - val imeActionId = actionId and EditorInfo.IME_MASK_ACTION - if (EditorInfo.IME_ACTION_DONE == imeActionId || EditorInfo.IME_ACTION_SEND == imeActionId) { - sendTextMessage(v.text) - true - } - // Add external keyboard functionality (to send messages) - else if (null != keyEvent && - !keyEvent.isShiftPressed && - keyEvent.keyCode == KeyEvent.KEYCODE_ENTER && - resources.configuration.keyboard != Configuration.KEYBOARD_NOKEYS) { - sendTextMessage(v.text) - true - } else false - } - - views.composerLayout.views.composerEmojiButton.isVisible = vectorPreferences.showEmojiKeyboard() - - if (isThreadTimeLine() && timelineArgs.threadTimelineArgs?.showKeyboard == true) { - // Show keyboard when the user started a thread - views.composerLayout.views.composerEditText.showKeyboard(andRequestFocus = true) - } - views.composerLayout.callback = object : MessageComposerView.Callback { - override fun onAddAttachment() { - if (!::attachmentTypeSelector.isInitialized) { - attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@TimelineFragment) - attachmentTypeSelector.setAttachmentVisibility( - AttachmentTypeSelectorView.Type.LOCATION, - vectorFeatures.isLocationSharingEnabled(), - ) - attachmentTypeSelector.setAttachmentVisibility( - AttachmentTypeSelectorView.Type.POLL, !isThreadTimeLine() - ) - } - attachmentTypeSelector.show(views.composerLayout.views.attachmentButton) - } - - override fun onSendMessage(text: CharSequence) { - sendTextMessage(text) - } - - override fun onCloseRelatedMessage() { - messageComposerViewModel.handle(MessageComposerAction.EnterRegularMode(views.composerLayout.text.toString(), false)) - } - - override fun onRichContentSelected(contentUri: Uri): Boolean { - return sendUri(contentUri) - } - - override fun onTextChanged(text: CharSequence) { - messageComposerViewModel.handle(MessageComposerAction.OnTextChanged(text)) - } - } - } - - private fun sendTextMessage(text: CharSequence) { - if (lockSendButton) { - Timber.w("Send button is locked") - return - } - if (text.isNotBlank()) { - // We collapse ASAP, if not there will be a slight annoying delay - views.composerLayout.collapse(true) - lockSendButton = true - messageComposerViewModel.handle(MessageComposerAction.SendMessage(text, vectorPreferences.isMarkdownEnabled())) - emojiPopup.dismiss() - } - } - - private fun observerUserTyping() { - if (isThreadTimeLine()) return - views.composerLayout.views.composerEditText.textChanges() - .skipInitialValue() - .debounce(300) - .map { it.isNotEmpty() } - .onEach { - Timber.d("Typing: User is typing: $it") - messageComposerViewModel.handle(MessageComposerAction.UserIsTyping(it)) - } - .launchIn(viewLifecycleOwner.lifecycleScope) - - views.composerLayout.views.composerEditText.focusChanges() - .onEach { - timelineViewModel.handle(RoomDetailAction.ComposerFocusChange(it)) - } - .launchIn(viewLifecycleOwner.lifecycleScope) - } - - private fun sendUri(uri: Uri): Boolean { - val shareIntent = Intent(Intent.ACTION_SEND, uri) - val isHandled = shareIntentHandler.handleIncomingShareIntent(shareIntent, ::onContentAttachmentsReady, onPlainText = { - fatalError("Should not happen as we're generating a File based share Intent", vectorPreferences.failFast()) - }) - if (!isHandled) { - Toast.makeText(requireContext(), R.string.error_handling_incoming_share, Toast.LENGTH_SHORT).show() - } - return isHandled - } - override fun invalidate() = withState(timelineViewModel, messageComposerViewModel) { mainState, messageComposerState -> invalidateOptionsMenu() if (mainState.asyncRoomSummary is Fail) { @@ -1664,12 +1129,6 @@ class TimelineFragment : lazyLoadedViews.inviteView(false)?.isVisible = false if (mainState.tombstoneEvent == null) { - views.composerLayout.isInvisible = !messageComposerState.isComposerVisible - views.voiceMessageRecorderView.isVisible = messageComposerState.isVoiceMessageRecorderVisible - views.composerLayout.views.sendButton.isInvisible = !messageComposerState.isSendButtonVisible - views.voiceMessageRecorderView.render(messageComposerState.voiceRecordingUiState) - views.composerLayout.setRoomEncrypted(summary.isEncrypted) - // views.composerLayout.alwaysShowSendButton = false when (messageComposerState.canSendMessage) { CanSendStatus.Allowed -> { NotificationAreaView.State.Hidden @@ -1724,8 +1183,7 @@ class TimelineFragment : } private fun FragmentTimelineBinding.hideComposerViews() { - composerLayout.isVisible = false - voiceMessageRecorderView.isVisible = false + composerContainer.isVisible = false } private fun renderTypingMessageNotification(roomSummary: RoomSummary?, state: RoomDetailViewState) { @@ -1778,57 +1236,6 @@ class TimelineFragment : } } - private fun renderSendMessageResult(sendMessageResult: MessageComposerViewEvents.SendMessageResult) { - when (sendMessageResult) { - is MessageComposerViewEvents.SlashCommandLoading -> { - showLoading(null) - } - is MessageComposerViewEvents.SlashCommandError -> { - displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command)) - } - is MessageComposerViewEvents.SlashCommandUnknown -> { - displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command)) - } - is MessageComposerViewEvents.SlashCommandResultOk -> { - handleSlashCommandResultOk(sendMessageResult.parsedCommand) - } - is MessageComposerViewEvents.SlashCommandResultError -> { - dismissLoadingDialog() - displayCommandError(errorFormatter.toHumanReadable(sendMessageResult.throwable)) - } - is MessageComposerViewEvents.SlashCommandNotImplemented -> { - displayCommandError(getString(R.string.not_implemented)) - } - is MessageComposerViewEvents.SlashCommandNotSupportedInThreads -> { - displayCommandError(getString(R.string.command_not_supported_in_threads, sendMessageResult.command.command)) - } - } - - lockSendButton = false - } - - private fun handleSlashCommandResultOk(parsedCommand: ParsedCommand) { - dismissLoadingDialog() - views.composerLayout.setTextIfDifferent("") - when (parsedCommand) { - is ParsedCommand.DevTools -> { - navigator.openDevTools(requireContext(), timelineArgs.roomId) - } - is ParsedCommand.SetMarkdown -> { - showSnackWithMessage(getString(if (parsedCommand.enable) R.string.markdown_has_been_enabled else R.string.markdown_has_been_disabled)) - } - else -> Unit - } - } - - private fun displayCommandError(message: String) { - MaterialAlertDialogBuilder(requireActivity()) - .setTitle(R.string.command_error) - .setMessage(message) - .setPositiveButton(R.string.ok, null) - .show() - } - private fun displayE2eError(withHeldCode: WithHeldCode?) { val msgId = when (withHeldCode) { WithHeldCode.BLACKLISTED -> R.string.crypto_error_withheld_blacklisted @@ -2057,7 +1464,7 @@ class TimelineFragment : inMemory = inMemory ) { pairs -> pairs.add(Pair(views.roomToolbar, ViewCompat.getTransitionName(views.roomToolbar) ?: "")) - pairs.add(Pair(views.composerLayout, ViewCompat.getTransitionName(views.composerLayout) ?: "")) + pairs.add(Pair(views.composerContainer, ViewCompat.getTransitionName(views.composerContainer) ?: "")) } } @@ -2069,16 +1476,10 @@ class TimelineFragment : view = view ) { pairs -> pairs.add(Pair(views.roomToolbar, ViewCompat.getTransitionName(views.roomToolbar) ?: "")) - pairs.add(Pair(views.composerLayout, ViewCompat.getTransitionName(views.composerLayout) ?: "")) + pairs.add(Pair(views.composerContainer, ViewCompat.getTransitionName(views.composerContainer) ?: "")) } } - private fun cleanUpAfterPermissionNotGranted() { - // Reset all pending data - timelineViewModel.pendingAction = null - attachmentsHelper.pendingType = null - } - override fun onLoadMore(direction: Timeline.Direction) { timelineViewModel.handle(RoomDetailAction.LoadMoreTimelineEvents(direction)) } @@ -2160,7 +1561,7 @@ class TimelineFragment : } override fun onMemberNameClicked(informationData: MessageInformationData) { - insertUserDisplayNameInTextEditor(informationData.senderId) + messageComposerViewModel.handle(MessageComposerAction.InsertUserDisplayName(informationData.senderId)) } override fun onClickOnReactionPill(informationData: MessageInformationData, reaction: String, on: Boolean) { @@ -2275,6 +1676,11 @@ class TimelineFragment : } } + private fun cleanUpAfterPermissionNotGranted() { + // Reset all pending data + timelineViewModel.pendingAction = null + } + private fun onSaveActionClicked(action: EventSharedAction.Save) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && !checkPermissions(PERMISSIONS_FOR_WRITING_FILES, requireActivity(), saveActionActivityResultLauncher)) { @@ -2352,17 +1758,17 @@ class TimelineFragment : if (action.eventType in EventType.POLL_START) { navigator.openCreatePoll(requireContext(), timelineArgs.roomId, action.eventId, PollMode.EDIT) } else if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) { - messageComposerViewModel.handle(MessageComposerAction.EnterEditMode(action.eventId, views.composerLayout.text.toString())) + messageComposerViewModel.handle(MessageComposerAction.EnterEditMode(action.eventId)) } else { requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) } } is EventSharedAction.Quote -> { - messageComposerViewModel.handle(MessageComposerAction.EnterQuoteMode(action.eventId, views.composerLayout.text.toString())) + messageComposerViewModel.handle(MessageComposerAction.EnterQuoteMode(action.eventId)) } is EventSharedAction.Reply -> { if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) { - messageComposerViewModel.handle(MessageComposerAction.EnterReplyMode(action.eventId, views.composerLayout.text.toString())) + messageComposerViewModel.handle(MessageComposerAction.EnterReplyMode(action.eventId)) } else { requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) } @@ -2463,63 +1869,6 @@ class TimelineFragment : .show() } - /** - * Insert a user displayName in the message editor. - * - * @param userId the userId. - */ - @SuppressLint("SetTextI18n") - private fun insertUserDisplayNameInTextEditor(userId: String) { - val startToCompose = views.composerLayout.text.isNullOrBlank() - - if (startToCompose && - userId == session.myUserId) { - // Empty composer, current user: start an emote - views.composerLayout.views.composerEditText.setText(Command.EMOTE.command + " ") - views.composerLayout.views.composerEditText.setSelection(Command.EMOTE.command.length + 1) - } else { - val roomMember = timelineViewModel.getMember(userId) - // TODO move logic outside of fragment - (roomMember?.displayName ?: userId) - .let { sanitizeDisplayName(it) } - .let { displayName -> - buildSpannedString { - append(displayName) - setSpan( - PillImageSpan( - glideRequests, - avatarRenderer, - requireContext(), - MatrixItem.UserItem(userId, displayName, roomMember?.avatarUrl) - ) - .also { it.bind(views.composerLayout.views.composerEditText) }, - 0, - displayName.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - append(if (startToCompose) ": " else " ") - }.let { pill -> - if (startToCompose) { - if (displayName.startsWith("/")) { - // Ensure displayName will not be interpreted as a Slash command - views.composerLayout.views.composerEditText.append("\\") - } - views.composerLayout.views.composerEditText.append(pill) - } else { - views.composerLayout.views.composerEditText.text?.insert(views.composerLayout.views.composerEditText.selectionStart, pill) - } - } - } - } - focusComposerAndShowKeyboard() - } - - private fun focusComposerAndShowKeyboard() { - if (views.composerLayout.isVisible) { - views.composerLayout.views.composerEditText.showKeyboard(andRequestFocus = true) - } - } - private fun showSnackWithMessage(message: String) { view?.showOptimizedSnackbar(message) } @@ -2621,78 +1970,6 @@ class TimelineFragment : } } - // AttachmentTypeSelectorView.Callback - private val typeSelectedActivityResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> - if (allGranted) { - val pendingType = attachmentsHelper.pendingType - if (pendingType != null) { - attachmentsHelper.pendingType = null - launchAttachmentProcess(pendingType) - } - } else { - if (deniedPermanently) { - activity?.onPermissionDeniedDialog(R.string.denied_permission_generic) - } - cleanUpAfterPermissionNotGranted() - } - } - - override fun onTypeSelected(type: AttachmentTypeSelectorView.Type) { - if (checkPermissions(type.permissions, requireActivity(), typeSelectedActivityResultLauncher)) { - launchAttachmentProcess(type) - } else { - attachmentsHelper.pendingType = type - } - } - - private fun launchAttachmentProcess(type: AttachmentTypeSelectorView.Type) { - when (type) { - AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera( - activity = requireActivity(), - vectorPreferences = vectorPreferences, - cameraActivityResultLauncher = attachmentCameraActivityResultLauncher, - cameraVideoActivityResultLauncher = attachmentCameraVideoActivityResultLauncher - ) - AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher) - AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher) - AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher) - AttachmentTypeSelectorView.Type.STICKER -> timelineViewModel.handle(RoomDetailAction.SelectStickerAttachment) - AttachmentTypeSelectorView.Type.POLL -> navigator.openCreatePoll(requireContext(), timelineArgs.roomId, null, PollMode.CREATE) - AttachmentTypeSelectorView.Type.LOCATION -> { - navigator - .openLocationSharing( - context = requireContext(), - roomId = timelineArgs.roomId, - mode = LocationSharingMode.STATIC_SHARING, - initialLocationData = null, - locationOwnerId = session.myUserId - ) - } - } - } - - // AttachmentsHelper.Callback - override fun onContentAttachmentsReady(attachments: List) { - val grouped = attachments.toGroupedContentAttachmentData() - if (grouped.notPreviewables.isNotEmpty()) { - // Send the not previewable attachments right now (?) - timelineViewModel.handle(RoomDetailAction.SendMedia(grouped.notPreviewables, false)) - } - if (grouped.previewables.isNotEmpty()) { - val intent = AttachmentsPreviewActivity.newIntent(requireContext(), AttachmentsPreviewArgs(grouped.previewables)) - contentAttachmentActivityResultLauncher.launch(intent) - } - } - - override fun onContactAttachmentReady(contactAttachment: ContactAttachment) { - val formattedContact = contactAttachment.toHumanReadable() - messageComposerViewModel.handle(MessageComposerAction.SendMessage(formattedContact, false)) - } - - override fun onAttachmentError(throwable: Throwable) { - showFailure(throwable) - } - private fun onViewWidgetsClicked() { RoomWidgetsBottomSheet.newInstance() .show(childFragmentManager, "ROOM_WIDGETS_BOTTOM_SHEET") @@ -2726,7 +2003,7 @@ class TimelineFragment : /** * Returns true if the current room is a Thread room, false otherwise. */ - private fun isThreadTimeLine(): Boolean = timelineArgs.threadTimelineArgs?.rootThreadEventId != null + private fun isThreadTimeLine(): Boolean = withState(timelineViewModel) { it.isThreadTimeline() } /** * Returns true if the current room is a local room, false otherwise. @@ -2736,5 +2013,5 @@ class TimelineFragment : /** * Returns the root thread event if we are in a thread room, otherwise returns null. */ - fun getRootThreadEventId(): String? = timelineArgs.threadTimelineArgs?.rootThreadEventId + fun getRootThreadEventId(): String? = withState(timelineViewModel) { it.rootThreadEventId } } 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 02dd2604e1..511fd597fe 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 @@ -65,6 +65,7 @@ import im.vector.app.features.raw.wellknown.withElementWellKnown import im.vector.app.features.session.coroutineScope import im.vector.app.features.settings.VectorDataStore import im.vector.app.features.settings.VectorPreferences +import im.vector.app.features.voicebroadcast.VoiceBroadcastHelper import im.vector.lib.core.utils.flow.chunk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow @@ -149,6 +150,7 @@ class TimelineViewModel @AssistedInject constructor( buildMeta: BuildMeta, timelineFactory: TimelineFactory, private val spaceStateHandler: SpaceStateHandler, + private val voiceBroadcastHelper: VoiceBroadcastHelper, ) : VectorViewModel(initialState), Timeline.Listener, ChatEffectManager.Delegate, CallProtocolsChecker.Listener, LocationSharingServiceConnection.Callback { @@ -456,6 +458,7 @@ class TimelineViewModel @AssistedInject constructor( is RoomDetailAction.ReRequestKeys -> handleReRequestKeys(action) is RoomDetailAction.TapOnFailedToDecrypt -> handleTapOnFailedToDecrypt(action) is RoomDetailAction.SelectStickerAttachment -> handleSelectStickerAttachment() + is RoomDetailAction.VoiceBroadcastAction -> handleVoiceBroadcastAction(action) is RoomDetailAction.OpenIntegrationManager -> handleOpenIntegrationManager() is RoomDetailAction.StartCall -> handleStartCall(action) is RoomDetailAction.AcceptCall -> handleAcceptCall(action) @@ -597,6 +600,18 @@ class TimelineViewModel @AssistedInject constructor( } } + private fun handleVoiceBroadcastAction(action: RoomDetailAction.VoiceBroadcastAction) { + 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) + } + } + } + private fun handleOpenIntegrationManager() { viewModelScope.launch { val viewEvent = withContext(Dispatchers.Default) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/ComposerEditText.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/ComposerEditText.kt index c751053cdf..9e88882866 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/ComposerEditText.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/ComposerEditText.kt @@ -20,10 +20,12 @@ package im.vector.app.features.home.room.detail.composer import android.content.ClipData import android.content.Context import android.net.Uri +import android.os.Build import android.text.Editable import android.util.AttributeSet import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection +import androidx.annotation.RequiresApi import androidx.appcompat.widget.AppCompatEditText import androidx.core.view.OnReceiveContentListener import androidx.core.view.ViewCompat @@ -79,6 +81,27 @@ class ComposerEditText @JvmOverloads constructor( return ic } + /** Set whether the keyboard should disable personalized learning. */ + @RequiresApi(Build.VERSION_CODES.O) + fun setUseIncognitoKeyboard(useIncognitoKeyboard: Boolean) { + imeOptions = if (useIncognitoKeyboard) { + imeOptions or EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING + } else { + imeOptions and EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING.inv() + } + } + + /** Set whether enter should send the message or add a new line. */ + fun setSendMessageWithEnter(sendMessageWithEnter: Boolean) { + if (sendMessageWithEnter) { + inputType = inputType and EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE.inv() + imeOptions = imeOptions or EditorInfo.IME_ACTION_SEND + } else { + inputType = inputType or EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE + imeOptions = imeOptions and EditorInfo.IME_ACTION_SEND.inv() + } + } + init { addTextChangedListener( object : SimpleTextWatcher() { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt index 527f42a67a..82adcd014a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt @@ -23,15 +23,16 @@ import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent sealed class MessageComposerAction : VectorViewModelAction { - data class SendMessage(val text: CharSequence, val autoMarkdown: Boolean) : MessageComposerAction() - data class EnterEditMode(val eventId: String, val text: String) : MessageComposerAction() - data class EnterQuoteMode(val eventId: String, val text: String) : MessageComposerAction() - data class EnterReplyMode(val eventId: String, val text: String) : MessageComposerAction() - data class EnterRegularMode(val text: String, val fromSharing: Boolean) : MessageComposerAction() + data class SendMessage(val text: CharSequence, val formattedText: String?, val autoMarkdown: Boolean) : MessageComposerAction() + data class EnterEditMode(val eventId: String) : MessageComposerAction() + data class EnterQuoteMode(val eventId: String) : MessageComposerAction() + data class EnterReplyMode(val eventId: String) : MessageComposerAction() + data class EnterRegularMode(val fromSharing: Boolean) : MessageComposerAction() data class UserIsTyping(val isTyping: Boolean) : MessageComposerAction() data class OnTextChanged(val text: CharSequence) : MessageComposerAction() data class OnEntersBackground(val composerText: String) : MessageComposerAction() data class SlashCommandConfirmed(val parsedCommand: ParsedCommand) : MessageComposerAction() + data class InsertUserDisplayName(val userId: String) : MessageComposerAction() // Voice Message data class InitializeVoiceRecorder(val attachmentData: ContentAttachmentData) : MessageComposerAction() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt new file mode 100644 index 0000000000..b3abfa480e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -0,0 +1,826 @@ +/* + * 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.composer + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.content.res.Configuration +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.text.Spannable +import android.text.format.DateUtils +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import android.widget.Toast +import androidx.annotation.DrawableRes +import androidx.annotation.RequiresApi +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.core.text.buildSpannedString +import androidx.core.view.isGone +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import com.airbnb.mvrx.parentFragmentViewModel +import com.airbnb.mvrx.withState +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.vanniktech.emoji.EmojiPopup +import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.R +import im.vector.app.core.error.fatalError +import im.vector.app.core.extensions.getVectorLastMessageContent +import im.vector.app.core.extensions.registerStartForActivityResult +import im.vector.app.core.extensions.showKeyboard +import im.vector.app.core.glide.GlideApp +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.lifecycleAwareLazy +import im.vector.app.core.platform.showOptimizedSnackbar +import im.vector.app.core.resources.BuildMeta +import im.vector.app.core.utils.DimensionConverter +import im.vector.app.core.utils.checkPermissions +import im.vector.app.core.utils.onPermissionDeniedDialog +import im.vector.app.core.utils.registerForPermissionsResult +import im.vector.app.databinding.FragmentComposerBinding +import im.vector.app.features.VectorFeatures +import im.vector.app.features.attachments.AttachmentTypeSelectorView +import im.vector.app.features.attachments.AttachmentsHelper +import im.vector.app.features.attachments.ContactAttachment +import im.vector.app.features.attachments.ShareIntentHandler +import im.vector.app.features.attachments.preview.AttachmentsPreviewActivity +import im.vector.app.features.attachments.preview.AttachmentsPreviewArgs +import im.vector.app.features.attachments.toGroupedContentAttachmentData +import im.vector.app.features.command.Command +import im.vector.app.features.command.ParsedCommand +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.AutoCompleter +import im.vector.app.features.home.room.detail.RoomDetailAction +import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction +import im.vector.app.features.home.room.detail.TimelineViewModel +import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView +import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActionViewModel +import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider +import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData +import im.vector.app.features.home.room.detail.upgrade.MigrateRoomBottomSheet +import im.vector.app.features.html.EventHtmlRenderer +import im.vector.app.features.html.PillImageSpan +import im.vector.app.features.html.PillsPostProcessor +import im.vector.app.features.location.LocationSharingMode +import im.vector.app.features.media.ImageContentRenderer +import im.vector.app.features.poll.PollMode +import im.vector.app.features.settings.VectorPreferences +import im.vector.app.features.share.SharedData +import im.vector.app.features.voice.VoiceFailure +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import org.commonmark.parser.Parser +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.content.ContentAttachmentData +import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.model.message.MessageFormat +import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent +import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.util.MatrixItem +import org.matrix.android.sdk.api.util.toMatrixItem +import reactivecircus.flowbinding.android.view.focusChanges +import reactivecircus.flowbinding.android.widget.textChanges +import timber.log.Timber +import javax.inject.Inject + +@AndroidEntryPoint +class MessageComposerFragment : VectorBaseFragment(), AttachmentsHelper.Callback, AttachmentTypeSelectorView.Callback { + + companion object { + private const val ircPattern = " (IRC)" + } + + @Inject lateinit var autoCompleterFactory: AutoCompleter.Factory + @Inject lateinit var avatarRenderer: AvatarRenderer + @Inject lateinit var matrixItemColorProvider: MatrixItemColorProvider + @Inject lateinit var eventHtmlRenderer: EventHtmlRenderer + @Inject lateinit var dimensionConverter: DimensionConverter + @Inject lateinit var imageContentRenderer: ImageContentRenderer + @Inject lateinit var shareIntentHandler: ShareIntentHandler + @Inject lateinit var pillsPostProcessorFactory: PillsPostProcessor.Factory + @Inject lateinit var vectorPreferences: VectorPreferences + @Inject lateinit var vectorFeatures: VectorFeatures + @Inject lateinit var buildMeta: BuildMeta + @Inject lateinit var session: Session + + private val roomId: String get() = withState(timelineViewModel) { it.roomId } + + private val autoCompleter: AutoCompleter by lazy { + autoCompleterFactory.create(roomId, isThreadTimeLine()) + } + + private val pillsPostProcessor by lazy { + pillsPostProcessorFactory.create(roomId) + } + + private val emojiPopup: EmojiPopup by lifecycleAwareLazy { + createEmojiPopup() + } + + private val glideRequests by lazy { + GlideApp.with(this) + } + + private val isEmojiKeyboardVisible: Boolean + get() = vectorPreferences.showEmojiKeyboard() + + private var lockSendButton = false + + private lateinit var attachmentsHelper: AttachmentsHelper + private lateinit var attachmentTypeSelector: AttachmentTypeSelectorView + + private val timelineViewModel: TimelineViewModel by parentFragmentViewModel() + private val messageComposerViewModel: MessageComposerViewModel by parentFragmentViewModel() + private lateinit var sharedActionViewModel: MessageSharedActionViewModel + + private val composer: MessageComposerView get() { + return if (vectorPreferences.isRichTextEditorEnabled()) { + views.richTextComposerLayout + } else { + views.composerLayout + } + } + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentComposerBinding { + return FragmentComposerBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + sharedActionViewModel = activityViewModelProvider.get(MessageSharedActionViewModel::class.java) + + attachmentsHelper = AttachmentsHelper(requireContext(), this, buildMeta).register() + + setupComposer() + setupEmojiButton() + + views.composerLayout.isGone = vectorPreferences.isRichTextEditorEnabled() + views.richTextComposerLayout.isVisible = vectorPreferences.isRichTextEditorEnabled() + + messageComposerViewModel.observeViewEvents { + when (it) { + is MessageComposerViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it) + is MessageComposerViewEvents.SlashCommandConfirmationRequest -> handleSlashCommandConfirmationRequest(it) + is MessageComposerViewEvents.SendMessageResult -> renderSendMessageResult(it) + is MessageComposerViewEvents.ShowMessage -> showSnackWithMessage(it.message) + is MessageComposerViewEvents.ShowRoomUpgradeDialog -> handleShowRoomUpgradeDialog(it) + is MessageComposerViewEvents.AnimateSendButtonVisibility -> handleSendButtonVisibilityChanged(it) + is MessageComposerViewEvents.OpenRoomMemberProfile -> openRoomMemberProfile(it.userId) + is MessageComposerViewEvents.VoicePlaybackOrRecordingFailure -> { + if (it.throwable is VoiceFailure.UnableToRecord) { + onCannotRecord() + } + showErrorInSnackbar(it.throwable) + } + is MessageComposerViewEvents.InsertUserDisplayName -> insertUserDisplayNameInTextEditor(it.userId) + } + } + + messageComposerViewModel.onEach(MessageComposerViewState::sendMode, MessageComposerViewState::canSendMessage) { mode, canSend -> + if (!canSend.boolean()) { + return@onEach + } + when (mode) { + is SendMode.Regular -> renderRegularMode(mode.text.toString()) + is SendMode.Edit -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, R.string.edit, mode.text.toString()) + is SendMode.Quote -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, R.string.action_quote, mode.text.toString()) + is SendMode.Reply -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, R.string.reply, mode.text.toString()) + is SendMode.Voice -> renderVoiceMessageMode(mode.text) + } + } + + if (savedInstanceState != null) { + handleShareData() + } + } + + override fun onPause() { + super.onPause() + + if (withState(messageComposerViewModel) { it.isVoiceRecording } && requireActivity().isChangingConfigurations) { + // we're rotating, maintain any active recordings + } else { + messageComposerViewModel.handle(MessageComposerAction.OnEntersBackground(composer.text.toString())) + } + } + + override fun onDestroyView() { + super.onDestroyView() + + if (!vectorPreferences.isRichTextEditorEnabled()) { + autoCompleter.clear() + } + messageComposerViewModel.endAllVoiceActions() + } + + override fun invalidate() = withState(timelineViewModel, messageComposerViewModel) { mainState, messageComposerState -> + if (mainState.tombstoneEvent != null) return@withState + + composer.setInvisible(!messageComposerState.isComposerVisible) + composer.sendButton.isInvisible = !messageComposerState.isSendButtonVisible + } + + private fun setupComposer() { + val composerEditText = composer.editText + composerEditText.setHint(R.string.room_message_placeholder) + + if (!vectorPreferences.isRichTextEditorEnabled()) { + autoCompleter.setup(composerEditText) + } + + observerUserTyping() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + composerEditText.setUseIncognitoKeyboard(vectorPreferences.useIncognitoKeyboard()) + } + composerEditText.setSendMessageWithEnter(vectorPreferences.sendMessageWithEnter()) + + composerEditText.setOnEditorActionListener { v, actionId, keyEvent -> + val imeActionId = actionId and EditorInfo.IME_MASK_ACTION + val isSendAction = EditorInfo.IME_ACTION_DONE == imeActionId || EditorInfo.IME_ACTION_SEND == imeActionId + // Add external keyboard functionality (to send messages) + val externalKeyboardPressedEnter = null != keyEvent && + !keyEvent.isShiftPressed && + keyEvent.keyCode == KeyEvent.KEYCODE_ENTER && + resources.configuration.keyboard != Configuration.KEYBOARD_NOKEYS + val result = if (isSendAction || externalKeyboardPressedEnter) { + sendTextMessage(v.text) + true + } else false + composer.setTextIfDifferent(null) + result + } + + composer.emojiButton?.isVisible = vectorPreferences.showEmojiKeyboard() + + val showKeyboard = withState(timelineViewModel) { it.showKeyboardWhenPresented } + if (isThreadTimeLine() && showKeyboard) { + // Show keyboard when the user started a thread + composerEditText.showKeyboard(andRequestFocus = true) + } + composer.callback = object : PlainTextComposerLayout.Callback { + override fun onAddAttachment() { + if (!::attachmentTypeSelector.isInitialized) { + attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@MessageComposerFragment) + attachmentTypeSelector.setAttachmentVisibility( + AttachmentTypeSelectorView.Type.LOCATION, + vectorFeatures.isLocationSharingEnabled(), + ) + attachmentTypeSelector.setAttachmentVisibility( + AttachmentTypeSelectorView.Type.POLL, !isThreadTimeLine() + ) + attachmentTypeSelector.setAttachmentVisibility( + AttachmentTypeSelectorView.Type.VOICE_BROADCAST, + vectorFeatures.isVoiceBroadcastEnabled(), // TODO check user permission + ) + } + attachmentTypeSelector.show(composer.attachmentButton) + } + + override fun onExpandOrCompactChange() { + composer.emojiButton?.isVisible = isEmojiKeyboardVisible + } + + override fun onSendMessage(text: CharSequence) { + sendTextMessage(text, composer.formattedText) + } + + override fun onCloseRelatedMessage() { + messageComposerViewModel.handle(MessageComposerAction.EnterRegularMode(false)) + } + + override fun onRichContentSelected(contentUri: Uri): Boolean { + return sendUri(contentUri) + } + + override fun onTextChanged(text: CharSequence) { + messageComposerViewModel.handle(MessageComposerAction.OnTextChanged(text)) + } + } + } + + private fun sendTextMessage(text: CharSequence, formattedText: String? = null) { + if (lockSendButton) { + Timber.w("Send button is locked") + return + } + if (text.isNotBlank()) { + // We collapse ASAP, if not there will be a slight annoying delay + composer.collapse(true) + lockSendButton = true + if (formattedText != null) { + messageComposerViewModel.handle(MessageComposerAction.SendMessage(text, formattedText, false)) + } else { + messageComposerViewModel.handle(MessageComposerAction.SendMessage(text, null, vectorPreferences.isMarkdownEnabled())) + } + emojiPopup.dismiss() + } + } + + private fun sendUri(uri: Uri): Boolean { + val shareIntent = Intent(Intent.ACTION_SEND, uri) + val isHandled = shareIntentHandler.handleIncomingShareIntent(shareIntent, ::onContentAttachmentsReady, onPlainText = { + fatalError("Should not happen as we're generating a File based share Intent", vectorPreferences.failFast()) + }) + if (!isHandled) { + Toast.makeText(requireContext(), R.string.error_handling_incoming_share, Toast.LENGTH_SHORT).show() + } + return isHandled + } + + private fun renderRegularMode(content: CharSequence) { + autoCompleter.exitSpecialMode() + composer.collapse() + composer.setTextIfDifferent(content) + composer.sendButton.contentDescription = getString(R.string.action_send) + } + + private fun renderSpecialMode( + event: TimelineEvent, + @DrawableRes iconRes: Int, + @StringRes descriptionRes: Int, + defaultContent: CharSequence, + ) { + autoCompleter.enterSpecialMode() + // switch to expanded bar + composer.composerRelatedMessageTitle.apply { + text = event.senderInfo.disambiguatedDisplayName + setTextColor(matrixItemColorProvider.getColor(MatrixItem.UserItem(event.root.senderId ?: "@"))) + } + + val messageContent: MessageContent? = event.getVectorLastMessageContent() + val nonFormattedBody = when (messageContent) { + is MessageAudioContent -> getAudioContentBodyText(messageContent) + is MessagePollContent -> messageContent.getBestPollCreationInfo()?.question?.getBestQuestion() + is MessageBeaconInfoContent -> getString(R.string.live_location_description) + else -> messageContent?.body.orEmpty() + } + var formattedBody: CharSequence? = null + if (messageContent is MessageTextContent && messageContent.format == MessageFormat.FORMAT_MATRIX_HTML) { + val parser = Parser.builder().build() + val document = parser.parse(messageContent.formattedBody ?: messageContent.body) + formattedBody = eventHtmlRenderer.render(document, pillsPostProcessor) + } + composer.composerRelatedMessageContent.text = (formattedBody ?: nonFormattedBody) + + // Image Event + val data = event.buildImageContentRendererData(dimensionConverter.dpToPx(66)) + val isImageVisible = if (data != null) { + imageContentRenderer.render(data, ImageContentRenderer.Mode.THUMBNAIL, composer.composerRelatedMessageImage) + true + } else { + imageContentRenderer.clear(composer.composerRelatedMessageImage) + false + } + + composer.composerRelatedMessageImage.isVisible = isImageVisible + + composer.replaceFormattedContent(defaultContent) + + composer.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) + composer.sendButton.contentDescription = getString(descriptionRes) + + avatarRenderer.render(event.senderInfo.toMatrixItem(), composer.composerRelatedMessageAvatar) + + composer.expand { + if (isAdded) { + // need to do it here also when not using quick reply + focusComposerAndShowKeyboard() + composer.composerRelatedMessageImage.isVisible = isImageVisible + } + } + focusComposerAndShowKeyboard() + } + + private fun observerUserTyping() { + if (isThreadTimeLine()) return + composer.editText.textChanges() + .skipInitialValue() + .debounce(300) + .map { it.isNotEmpty() } + .onEach { + Timber.d("Typing: User is typing: $it") + messageComposerViewModel.handle(MessageComposerAction.UserIsTyping(it)) + } + .launchIn(viewLifecycleOwner.lifecycleScope) + + composer.editText.focusChanges() + .onEach { + timelineViewModel.handle(RoomDetailAction.ComposerFocusChange(it)) + } + .launchIn(viewLifecycleOwner.lifecycleScope) + } + + private fun focusComposerAndShowKeyboard() { + if (composer.isVisible) { + composer.editText.showKeyboard(andRequestFocus = true) + } + } + + private fun handleSendButtonVisibilityChanged(event: MessageComposerViewEvents.AnimateSendButtonVisibility) { + if (event.isVisible) { + composer.sendButton.alpha = 0f + composer.sendButton.isVisible = true + composer.sendButton.animate().alpha(1f).setDuration(150).start() + } else { + composer.sendButton.isInvisible = true + } + } + + private fun renderVoiceMessageMode(content: String) { + ContentAttachmentData.fromJsonString(content)?.let { audioAttachmentData -> + messageComposerViewModel.handle(MessageComposerAction.InitializeVoiceRecorder(audioAttachmentData)) + } + } + + private fun getAudioContentBodyText(messageContent: MessageAudioContent): String { + val formattedDuration = DateUtils.formatElapsedTime(((messageContent.audioInfo?.duration ?: 0) / 1000).toLong()) + return if (messageContent.voiceMessageIndicator != null) { + getString(R.string.voice_message_reply_content, formattedDuration) + } else { + getString(R.string.audio_message_reply_content, messageContent.body, formattedDuration) + } + } + + private fun createEmojiPopup(): EmojiPopup { + return EmojiPopup( + rootView = views.root, + keyboardAnimationStyle = R.style.emoji_fade_animation_style, + onEmojiPopupShownListener = { + composer.emojiButton?.apply { + contentDescription = getString(R.string.a11y_close_emoji_picker) + setImageResource(R.drawable.ic_keyboard) + } + }, + onEmojiPopupDismissListener = lifecycleAwareDismissAction { + composer.emojiButton?.apply { + contentDescription = getString(R.string.a11y_open_emoji_picker) + setImageResource(R.drawable.ic_insert_emoji) + } + }, + editText = composer.editText + ) + } + + /** + * Ensure dismiss actions only trigger when the fragment is in the started state. + * EmojiPopup by default dismisses onViewDetachedFromWindow, this can cause race conditions with onDestroyView. + */ + private fun lifecycleAwareDismissAction(action: () -> Unit): () -> Unit { + return { + if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + action() + } + } + } + + private fun setupEmojiButton() { + composer.emojiButton?.debouncedClicks { + emojiPopup.toggle() + } + } + + private fun onCannotRecord() { + // Update the UI, cancel the animation + messageComposerViewModel.handle(MessageComposerAction.OnVoiceRecordingUiStateChanged(VoiceMessageRecorderView.RecordingUiState.Idle)) + } + + private fun handleJoinedToAnotherRoom(action: MessageComposerViewEvents.JoinRoomCommandSuccess) { + composer.setTextIfDifferent("") + lockSendButton = false + navigator.openRoom(vectorBaseActivity, action.roomId) + } + + private fun handleSlashCommandConfirmationRequest(action: MessageComposerViewEvents.SlashCommandConfirmationRequest) { + when (action.parsedCommand) { + is ParsedCommand.UnignoreUser -> promptUnignoreUser(action.parsedCommand) + else -> TODO("Add case for ${action.parsedCommand.javaClass.simpleName}") + } + lockSendButton = false + } + + private fun promptUnignoreUser(command: ParsedCommand.UnignoreUser) { + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.room_participants_action_unignore_title) + .setMessage(getString(R.string.settings_unignore_user, command.userId)) + .setPositiveButton(R.string.unignore) { _, _ -> + messageComposerViewModel.handle(MessageComposerAction.SlashCommandConfirmed(command)) + } + .setNegativeButton(R.string.action_cancel, null) + .show() + } + + private fun renderSendMessageResult(sendMessageResult: MessageComposerViewEvents.SendMessageResult) { + when (sendMessageResult) { + is MessageComposerViewEvents.SlashCommandLoading -> { + showLoading(null) + } + is MessageComposerViewEvents.SlashCommandError -> { + displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command)) + } + is MessageComposerViewEvents.SlashCommandUnknown -> { + displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command)) + } + is MessageComposerViewEvents.SlashCommandResultOk -> { + handleSlashCommandResultOk(sendMessageResult.parsedCommand) + } + is MessageComposerViewEvents.SlashCommandResultError -> { + dismissLoadingDialog() + displayCommandError(errorFormatter.toHumanReadable(sendMessageResult.throwable)) + } + is MessageComposerViewEvents.SlashCommandNotImplemented -> { + displayCommandError(getString(R.string.not_implemented)) + } + is MessageComposerViewEvents.SlashCommandNotSupportedInThreads -> { + displayCommandError(getString(R.string.command_not_supported_in_threads, sendMessageResult.command.command)) + } + } + + lockSendButton = false + } + + private fun handleSlashCommandResultOk(parsedCommand: ParsedCommand) { + dismissLoadingDialog() + composer.setTextIfDifferent("") + when (parsedCommand) { + is ParsedCommand.DevTools -> { + navigator.openDevTools(requireContext(), roomId) + } + is ParsedCommand.SetMarkdown -> { + showSnackWithMessage(getString(if (parsedCommand.enable) R.string.markdown_has_been_enabled else R.string.markdown_has_been_disabled)) + } + else -> Unit + } + } + + private fun displayCommandError(message: String) { + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.command_error) + .setMessage(message) + .setPositiveButton(R.string.ok, null) + .show() + } + + private fun showSnackWithMessage(message: String) { + view?.showOptimizedSnackbar(message) + } + + private fun handleShowRoomUpgradeDialog(roomDetailViewEvents: MessageComposerViewEvents.ShowRoomUpgradeDialog) { + val tag = MigrateRoomBottomSheet::javaClass.name + val roomId = withState(timelineViewModel) { it.roomId } + MigrateRoomBottomSheet.newInstance(roomId, roomDetailViewEvents.newVersion) + .show(parentFragmentManager, tag) + } + + private fun openRoomMemberProfile(userId: String) { + navigator.openRoomMemberProfile(userId = userId, roomId = roomId, context = requireActivity()) + } + + private val contentAttachmentActivityResultLauncher = registerStartForActivityResult { activityResult -> + val data = activityResult.data ?: return@registerStartForActivityResult + if (activityResult.resultCode == Activity.RESULT_OK) { + val sendData = AttachmentsPreviewActivity.getOutput(data) + val keepOriginalSize = AttachmentsPreviewActivity.getKeepOriginalSize(data) + timelineViewModel.handle(RoomDetailAction.SendMedia(sendData, !keepOriginalSize)) + } + } + + // AttachmentsHelper.Callback + override fun onContentAttachmentsReady(attachments: List) { + val grouped = attachments.toGroupedContentAttachmentData() + if (grouped.notPreviewables.isNotEmpty()) { + // Send the not previewable attachments right now (?) + timelineViewModel.handle(RoomDetailAction.SendMedia(grouped.notPreviewables, false)) + } + if (grouped.previewables.isNotEmpty()) { + val intent = AttachmentsPreviewActivity.newIntent(requireContext(), AttachmentsPreviewArgs(grouped.previewables)) + contentAttachmentActivityResultLauncher.launch(intent) + } + } + + override fun onContactAttachmentReady(contactAttachment: ContactAttachment) { + val formattedContact = contactAttachment.toHumanReadable() + messageComposerViewModel.handle(MessageComposerAction.SendMessage(formattedContact, null, false)) + } + + override fun onAttachmentError(throwable: Throwable) { + showFailure(throwable) + } + + // AttachmentTypeSelectorView.Callback + private val typeSelectedActivityResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> + if (allGranted) { + val pendingType = attachmentsHelper.pendingType + if (pendingType != null) { + attachmentsHelper.pendingType = null + launchAttachmentProcess(pendingType) + } + } else { + if (deniedPermanently) { + activity?.onPermissionDeniedDialog(R.string.denied_permission_generic) + } + cleanUpAfterPermissionNotGranted() + } + } + + private fun launchAttachmentProcess(type: AttachmentTypeSelectorView.Type) { + when (type) { + AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera( + activity = requireActivity(), + vectorPreferences = vectorPreferences, + cameraActivityResultLauncher = attachmentCameraActivityResultLauncher, + cameraVideoActivityResultLauncher = attachmentCameraVideoActivityResultLauncher + ) + AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher) + AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher) + AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher) + AttachmentTypeSelectorView.Type.STICKER -> timelineViewModel.handle(RoomDetailAction.SelectStickerAttachment) + AttachmentTypeSelectorView.Type.POLL -> navigator.openCreatePoll(requireContext(), roomId, null, PollMode.CREATE) + AttachmentTypeSelectorView.Type.LOCATION -> { + navigator + .openLocationSharing( + context = requireContext(), + roomId = roomId, + mode = LocationSharingMode.STATIC_SHARING, + initialLocationData = null, + locationOwnerId = session.myUserId + ) + } + AttachmentTypeSelectorView.Type.VOICE_BROADCAST -> timelineViewModel.handle(VoiceBroadcastAction.Start) + } + } + + override fun onTypeSelected(type: AttachmentTypeSelectorView.Type) { + if (checkPermissions(type.permissions, requireActivity(), typeSelectedActivityResultLauncher)) { + launchAttachmentProcess(type) + } else { + attachmentsHelper.pendingType = type + } + } + + private val attachmentFileActivityResultLauncher = registerStartForActivityResult { + if (it.resultCode == Activity.RESULT_OK) { + attachmentsHelper.onFileResult(it.data) + } + } + + private val attachmentContactActivityResultLauncher = registerStartForActivityResult { + if (it.resultCode == Activity.RESULT_OK) { + attachmentsHelper.onContactResult(it.data) + } + } + + private val attachmentMediaActivityResultLauncher = registerStartForActivityResult { + if (it.resultCode == Activity.RESULT_OK) { + attachmentsHelper.onMediaResult(it.data) + } + } + + private val attachmentCameraActivityResultLauncher = registerStartForActivityResult { + if (it.resultCode == Activity.RESULT_OK) { + attachmentsHelper.onCameraResult() + } + } + + private val attachmentCameraVideoActivityResultLauncher = registerStartForActivityResult { + if (it.resultCode == Activity.RESULT_OK) { + attachmentsHelper.onCameraVideoResult() + } + } + + private fun cleanUpAfterPermissionNotGranted() { + // Reset all pending data + timelineViewModel.pendingAction = null + attachmentsHelper.pendingType = null + } + + private fun handleShareData() { + when (val sharedData = withState(timelineViewModel) { it.sharedData }) { + is SharedData.Text -> { + messageComposerViewModel.handle(MessageComposerAction.OnTextChanged(sharedData.text)) + messageComposerViewModel.handle(MessageComposerAction.EnterRegularMode(fromSharing = true)) + } + is SharedData.Attachments -> { + // open share edition + onContentAttachmentsReady(sharedData.attachmentData) + } + null -> Timber.v("No share data to process") + } + } + + @SuppressLint("SetTextI18n") + private fun insertUserDisplayNameInTextEditor(userId: String) { + val startToCompose = composer.text.isNullOrBlank() + + if (startToCompose && + userId == session.myUserId) { + // Empty composer, current user: start an emote + composer.editText.setText("${Command.EMOTE.command} ") + composer.editText.setSelection(Command.EMOTE.command.length + 1) + } else { + val roomMember = timelineViewModel.getMember(userId) + val displayName = sanitizeDisplayName(roomMember?.displayName ?: userId) + val pill = buildSpannedString { + append(displayName) + setSpan( + PillImageSpan( + glideRequests, + avatarRenderer, + requireContext(), + MatrixItem.UserItem(userId, displayName, roomMember?.avatarUrl) + ) + .also { it.bind(composer.editText) }, + 0, + displayName.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + append(if (startToCompose) ": " else " ") + } + if (startToCompose) { + if (displayName.startsWith("/")) { + // Ensure displayName will not be interpreted as a Slash command + composer.editText.append("\\") + } + composer.editText.append(pill) + } else { + composer.editText.text?.insert(composer.editText.selectionStart, pill) + } + } + focusComposerAndShowKeyboard() + } + + /** + * Sanitize the display name. + * + * @param displayName the display name to sanitize + * @return the sanitized display name + */ + private fun sanitizeDisplayName(displayName: String): String { + if (displayName.endsWith(ircPattern)) { + return displayName.substring(0, displayName.length - ircPattern.length) + } + + return displayName + } + + /** + * Returns the root thread event if we are in a thread room, otherwise returns null. + */ + fun getRootThreadEventId(): String? = withState(timelineViewModel) { it.rootThreadEventId } + + /** + * Returns true if the current room is a Thread room, false otherwise. + */ + private fun isThreadTimeLine(): Boolean = withState(timelineViewModel) { it.isThreadTimeline() } + + /** Set whether the keyboard should disable personalized learning. */ + @RequiresApi(Build.VERSION_CODES.O) + private fun EditText.setUseIncognitoKeyboard(useIncognitoKeyboard: Boolean) { + imeOptions = if (useIncognitoKeyboard) { + imeOptions or EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING + } else { + imeOptions and EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING.inv() + } + } + + /** Set whether enter should send the message or add a new line. */ + private fun EditText.setSendMessageWithEnter(sendMessageWithEnter: Boolean) { + if (sendMessageWithEnter) { + inputType = inputType and EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE.inv() + imeOptions = imeOptions or EditorInfo.IME_ACTION_SEND + } else { + inputType = inputType or EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE + imeOptions = imeOptions and EditorInfo.IME_ACTION_SEND.inv() + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt index 1522960cc9..09357191b4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt @@ -1,11 +1,11 @@ /* - * Copyright 2019 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. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -16,142 +16,34 @@ package im.vector.app.features.home.room.detail.composer -import android.content.Context -import android.net.Uri import android.text.Editable -import android.util.AttributeSet -import android.view.ViewGroup -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.constraintlayout.widget.ConstraintSet -import androidx.core.text.toSpannable -import androidx.transition.ChangeBounds -import androidx.transition.Fade -import androidx.transition.Transition -import androidx.transition.TransitionManager -import androidx.transition.TransitionSet -import im.vector.app.R -import im.vector.app.core.extensions.setTextIfDifferent -import im.vector.app.databinding.ComposerLayoutBinding +import android.widget.EditText +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView -/** - * Encapsulate the timeline composer UX. - */ -class MessageComposerView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : ConstraintLayout(context, attrs, defStyleAttr) { - - interface Callback : ComposerEditText.Callback { - fun onCloseRelatedMessage() - fun onSendMessage(text: CharSequence) - fun onAddAttachment() - } - - val views: ComposerLayoutBinding - - var callback: Callback? = null - - private var currentConstraintSetId: Int = -1 - - private val animationDuration = 100L +interface MessageComposerView { val text: Editable? - get() = views.composerEditText.text + val formattedText: String? + val editText: EditText + val emojiButton: ImageButton? + val sendButton: ImageButton + val attachmentButton: ImageButton + val composerRelatedMessageTitle: TextView + val composerRelatedMessageContent: TextView + val composerRelatedMessageImage: ImageView + val composerRelatedMessageActionIcon: ImageView + val composerRelatedMessageAvatar: ImageView - init { - inflate(context, R.layout.composer_layout, this) - views = ComposerLayoutBinding.bind(this) + var callback: PlainTextComposerLayout.Callback? - collapse(false) + var isVisible: Boolean - views.composerEditText.callback = object : ComposerEditText.Callback { - override fun onRichContentSelected(contentUri: Uri): Boolean { - return callback?.onRichContentSelected(contentUri) ?: false - } + fun collapse(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) + fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) + fun setTextIfDifferent(text: CharSequence?): Boolean + fun replaceFormattedContent(text: CharSequence) - override fun onTextChanged(text: CharSequence) { - callback?.onTextChanged(text) - } - } - views.composerRelatedMessageCloseButton.setOnClickListener { - collapse() - callback?.onCloseRelatedMessage() - } - - views.sendButton.setOnClickListener { - val textMessage = text?.toSpannable() ?: "" - callback?.onSendMessage(textMessage) - } - - views.attachmentButton.setOnClickListener { - callback?.onAddAttachment() - } - } - - fun collapse(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) { - if (currentConstraintSetId == R.layout.composer_layout_constraint_set_compact) { - // ignore we good - return - } - currentConstraintSetId = R.layout.composer_layout_constraint_set_compact - applyNewConstraintSet(animate, transitionComplete) - } - - fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) { - if (currentConstraintSetId == R.layout.composer_layout_constraint_set_expanded) { - // ignore we good - return - } - currentConstraintSetId = R.layout.composer_layout_constraint_set_expanded - applyNewConstraintSet(animate, transitionComplete) - } - - fun setTextIfDifferent(text: CharSequence?): Boolean { - return views.composerEditText.setTextIfDifferent(text) - } - - private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { - // val wasSendButtonInvisible = views.sendButton.isInvisible - if (animate) { - configureAndBeginTransition(transitionComplete) - } - ConstraintSet().also { - it.clone(context, currentConstraintSetId) - it.applyTo(this) - } - // Might be updated by view state just after, but avoid blinks - // views.sendButton.isInvisible = wasSendButtonInvisible - } - - private fun configureAndBeginTransition(transitionComplete: (() -> Unit)? = null) { - val transition = TransitionSet().apply { - ordering = TransitionSet.ORDERING_SEQUENTIAL - addTransition(ChangeBounds()) - addTransition(Fade(Fade.IN)) - duration = animationDuration - addListener(object : Transition.TransitionListener { - override fun onTransitionEnd(transition: Transition) { - transitionComplete?.invoke() - } - - override fun onTransitionResume(transition: Transition) {} - - override fun onTransitionPause(transition: Transition) {} - - override fun onTransitionCancel(transition: Transition) {} - - override fun onTransitionStart(transition: Transition) {} - }) - } - TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition) - } - - fun setRoomEncrypted(isEncrypted: Boolean) { - if (isEncrypted) { - views.composerEditText.setHint(R.string.room_message_placeholder) - } else { - views.composerEditText.setHint(R.string.room_message_placeholder) - } - } + fun setInvisible(isInvisible: Boolean) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewEvents.kt index e1f6923d21..3a949acb07 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewEvents.kt @@ -47,4 +47,6 @@ sealed class MessageComposerViewEvents : VectorViewEvents { data class ShowRoomUpgradeDialog(val newVersion: String, val isPublic: Boolean) : MessageComposerViewEvents() data class VoicePlaybackOrRecordingFailure(val throwable: Throwable) : MessageComposerViewEvents() + + data class InsertUserDisplayName(val userId: String) : MessageComposerViewEvents() } 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 f9bf244eb1..b877c2979b 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 @@ -23,6 +23,7 @@ import dagger.assisted.AssistedInject import im.vector.app.R import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.resources.StringProvider import im.vector.app.features.analytics.AnalyticsTracker @@ -58,11 +59,11 @@ import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent import org.matrix.android.sdk.api.session.room.model.RoomAvatarContent import org.matrix.android.sdk.api.session.room.model.RoomEncryptionAlgorithm import org.matrix.android.sdk.api.session.room.model.RoomMemberContent +import org.matrix.android.sdk.api.session.room.model.message.MessageContentWithFormattedBody import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.relation.shouldRenderInThread import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper import org.matrix.android.sdk.api.session.room.send.UserDraft -import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.getRelationContent import org.matrix.android.sdk.api.session.room.timeline.getTextEditableContent import org.matrix.android.sdk.api.session.space.CreateSpaceParams @@ -113,6 +114,7 @@ class MessageComposerViewModel @AssistedInject constructor( is MessageComposerAction.VoiceWaveformMovedTo -> handleVoiceWaveformMovedTo(action) is MessageComposerAction.AudioSeekBarMovedTo -> handleAudioSeekBarMovedTo(action) is MessageComposerAction.SlashCommandConfirmed -> handleSlashCommandConfirmed(action) + is MessageComposerAction.InsertUserDisplayName -> handleInsertUserDisplayName(action) } } @@ -144,7 +146,7 @@ class MessageComposerViewModel @AssistedInject constructor( } private fun handleEnterRegularMode(action: MessageComposerAction.EnterRegularMode) = setState { - copy(sendMode = SendMode.Regular(action.text, action.fromSharing)) + copy(sendMode = SendMode.Regular(currentComposerText, action.fromSharing)) } private fun handleEnterEditMode(action: MessageComposerAction.EnterEditMode) { @@ -181,13 +183,13 @@ class MessageComposerViewModel @AssistedInject constructor( private fun handleEnterQuoteMode(action: MessageComposerAction.EnterQuoteMode) { room.getTimelineEvent(action.eventId)?.let { timelineEvent -> - setState { copy(sendMode = SendMode.Quote(timelineEvent, action.text)) } + setState { copy(sendMode = SendMode.Quote(timelineEvent, currentComposerText)) } } } private fun handleEnterReplyMode(action: MessageComposerAction.EnterReplyMode) { room.getTimelineEvent(action.eventId)?.let { timelineEvent -> - setState { copy(sendMode = SendMode.Reply(timelineEvent, action.text)) } + setState { copy(sendMode = SendMode.Reply(timelineEvent, currentComposerText)) } } } @@ -200,6 +202,7 @@ class MessageComposerViewModel @AssistedInject constructor( is SendMode.Regular -> { when (val parsedCommand = commandParser.parseSlashCommand( textMessage = action.text, + formattedMessage = action.formattedText, isInThreadTimeline = state.isInThreadTimeline() )) { is ParsedCommand.ErrorNotACommand -> { @@ -208,10 +211,15 @@ class MessageComposerViewModel @AssistedInject constructor( room.relationService().replyInThread( rootThreadEventId = state.rootThreadEventId, replyInThreadText = action.text, + formattedText = action.formattedText, autoMarkdown = action.autoMarkdown ) } else { - room.sendService().sendTextMessage(action.text, autoMarkdown = action.autoMarkdown) + if (action.formattedText != null) { + room.sendService().sendFormattedTextMessage(action.text.toString(), action.formattedText) + } else { + room.sendService().sendTextMessage(action.text, autoMarkdown = action.autoMarkdown) + } } _viewEvents.post(MessageComposerViewEvents.MessageSent) @@ -243,6 +251,24 @@ class MessageComposerViewModel @AssistedInject constructor( _viewEvents.post(MessageComposerViewEvents.MessageSent) popDraft() } + is ParsedCommand.SendFormattedText -> { + // Send the text message to the room, without markdown + if (state.rootThreadEventId != null) { + room.relationService().replyInThread( + rootThreadEventId = state.rootThreadEventId, + replyInThreadText = parsedCommand.message, + formattedText = parsedCommand.formattedMessage, + autoMarkdown = false + ) + } else { + room.sendService().sendFormattedTextMessage( + text = parsedCommand.message.toString(), + formattedText = parsedCommand.formattedMessage + ) + } + _viewEvents.post(MessageComposerViewEvents.MessageSent) + popDraft() + } is ParsedCommand.ChangeRoomName -> { handleChangeRoomNameSlashCommand(parsedCommand) } @@ -509,16 +535,24 @@ class MessageComposerViewModel @AssistedInject constructor( if (inReplyTo != null) { // TODO check if same content? room.getTimelineEvent(inReplyTo)?.let { - room.relationService().editReply(state.sendMode.timelineEvent, it, action.text.toString()) + room.relationService().editReply(state.sendMode.timelineEvent, it, action.text.toString(), action.formattedText) } } else { - val messageContent = state.sendMode.timelineEvent.getLastMessageContent() - val existingBody = messageContent?.body ?: "" - if (existingBody != action.text) { + val messageContent = state.sendMode.timelineEvent.getVectorLastMessageContent() + val existingBody: String + val needsEdit = if (messageContent is MessageContentWithFormattedBody) { + existingBody = messageContent.formattedBody ?: "" + existingBody != action.formattedText + } else { + existingBody = messageContent?.body ?: "" + existingBody != action.text + } + if (needsEdit) { room.relationService().editTextMessage( state.sendMode.timelineEvent, messageContent?.msgType ?: MessageType.MSGTYPE_TEXT, action.text, + (messageContent as? MessageContentWithFormattedBody)?.formattedBody, action.autoMarkdown ) } else { @@ -532,6 +566,7 @@ class MessageComposerViewModel @AssistedInject constructor( room.sendService().sendQuotedTextMessage( quotedEvent = state.sendMode.timelineEvent, text = action.text.toString(), + formattedText = action.formattedText, autoMarkdown = action.autoMarkdown, rootThreadEventId = state.rootThreadEventId ) @@ -548,11 +583,13 @@ class MessageComposerViewModel @AssistedInject constructor( rootThreadEventId = it, replyInThreadText = action.text.toString(), autoMarkdown = action.autoMarkdown, + formattedText = action.formattedText, eventReplied = timelineEvent ) } ?: room.relationService().replyToMessage( eventReplied = timelineEvent, replyText = action.text.toString(), + replyFormattedText = action.formattedText, autoMarkdown = action.autoMarkdown, showInThread = showInThread, rootThreadEventId = rootThreadEventId @@ -875,7 +912,7 @@ class MessageComposerViewModel @AssistedInject constructor( } } } - handleEnterRegularMode(MessageComposerAction.EnterRegularMode(text = "", fromSharing = false)) + handleEnterRegularMode(MessageComposerAction.EnterRegularMode(fromSharing = false)) } private fun handlePlayOrPauseVoicePlayback(action: MessageComposerAction.PlayOrPauseVoicePlayback) { @@ -943,6 +980,10 @@ class MessageComposerViewModel @AssistedInject constructor( } } + private fun handleInsertUserDisplayName(action: MessageComposerAction.InsertUserDisplayName) { + _viewEvents.post(MessageComposerViewEvents.InsertUserDisplayName(action.userId)) + } + private fun launchSlashCommandFlowSuspendable(parsedCommand: ParsedCommand, block: suspend () -> Unit) { _viewEvents.post(MessageComposerViewEvents.SlashCommandLoading) viewModelScope.launch { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt index 5698414ab4..47a7122584 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt @@ -33,15 +33,15 @@ import kotlin.random.Random */ sealed interface SendMode { data class Regular( - val text: String, + val text: CharSequence, val fromSharing: Boolean, // This is necessary for forcing refresh on selectSubscribe private val random: Int = Random.nextInt() ) : SendMode - data class Quote(val timelineEvent: TimelineEvent, val text: String) : SendMode - data class Edit(val timelineEvent: TimelineEvent, val text: String) : SendMode - data class Reply(val timelineEvent: TimelineEvent, val text: String) : SendMode + data class Quote(val timelineEvent: TimelineEvent, val text: CharSequence) : SendMode + data class Edit(val timelineEvent: TimelineEvent, val text: CharSequence) : SendMode + data class Reply(val timelineEvent: TimelineEvent, val text: CharSequence) : SendMode data class Voice(val text: String) : SendMode } @@ -66,7 +66,8 @@ data class MessageComposerViewState( val rootThreadEventId: String? = null, val startsThread: Boolean = false, val sendMode: SendMode = SendMode.Regular("", false), - val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle + val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle, + val text: CharSequence? = null, ) : MavericksState { val isVoiceRecording = when (voiceRecordingUiState) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt new file mode 100644 index 0000000000..acb5a1b42a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt @@ -0,0 +1,185 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.composer + +import android.content.Context +import android.net.Uri +import android.text.Editable +import android.util.AttributeSet +import android.view.ViewGroup +import android.widget.EditText +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.text.toSpannable +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.transition.ChangeBounds +import androidx.transition.Fade +import androidx.transition.Transition +import androidx.transition.TransitionManager +import androidx.transition.TransitionSet +import im.vector.app.R +import im.vector.app.core.animations.SimpleTransitionListener +import im.vector.app.core.extensions.setTextIfDifferent +import im.vector.app.databinding.ComposerLayoutBinding + +/** + * Encapsulate the timeline composer UX. + */ +class PlainTextComposerLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), MessageComposerView { + + interface Callback : ComposerEditText.Callback { + fun onCloseRelatedMessage() + fun onSendMessage(text: CharSequence) + fun onAddAttachment() + fun onExpandOrCompactChange() + } + + private val views: ComposerLayoutBinding + + override var callback: Callback? = null + + private var currentConstraintSetId: Int = -1 + + private val animationDuration = 100L + + override val text: Editable? + get() = views.composerEditText.text + + override val formattedText: String? = null + + override val editText: EditText + get() = views.composerEditText + + override val emojiButton: ImageButton? + get() = views.composerEmojiButton + + override val sendButton: ImageButton + get() = views.sendButton + + override fun setInvisible(isInvisible: Boolean) { + this.isInvisible = isInvisible + } + override val attachmentButton: ImageButton + get() = views.attachmentButton + override val composerRelatedMessageActionIcon: ImageView + get() = views.composerRelatedMessageActionIcon + override val composerRelatedMessageAvatar: ImageView + get() = views.composerRelatedMessageAvatar + override val composerRelatedMessageContent: TextView + get() = views.composerRelatedMessageContent + override val composerRelatedMessageImage: ImageView + get() = views.composerRelatedMessageImage + override val composerRelatedMessageTitle: TextView + get() = views.composerRelatedMessageTitle + override var isVisible: Boolean + get() = views.root.isVisible + set(value) { views.root.isVisible = value } + + init { + inflate(context, R.layout.composer_layout, this) + views = ComposerLayoutBinding.bind(this) + + collapse(false) + + views.composerEditText.callback = object : ComposerEditText.Callback { + override fun onRichContentSelected(contentUri: Uri): Boolean { + return callback?.onRichContentSelected(contentUri) ?: false + } + + override fun onTextChanged(text: CharSequence) { + callback?.onTextChanged(text) + } + } + views.composerRelatedMessageCloseButton.setOnClickListener { + collapse() + callback?.onCloseRelatedMessage() + } + + views.sendButton.setOnClickListener { + val textMessage = text?.toSpannable() ?: "" + callback?.onSendMessage(textMessage) + } + + views.attachmentButton.setOnClickListener { + callback?.onAddAttachment() + } + } + + override fun replaceFormattedContent(text: CharSequence) { + setTextIfDifferent(text) + } + + override fun collapse(animate: Boolean, transitionComplete: (() -> Unit)?) { + if (currentConstraintSetId == R.layout.composer_layout_constraint_set_compact) { + // ignore we good + return + } + currentConstraintSetId = R.layout.composer_layout_constraint_set_compact + applyNewConstraintSet(animate, transitionComplete) + callback?.onExpandOrCompactChange() + } + + override fun expand(animate: Boolean, transitionComplete: (() -> Unit)?) { + if (currentConstraintSetId == R.layout.composer_layout_constraint_set_expanded) { + // ignore we good + return + } + currentConstraintSetId = R.layout.composer_layout_constraint_set_expanded + applyNewConstraintSet(animate, transitionComplete) + callback?.onExpandOrCompactChange() + } + + override fun setTextIfDifferent(text: CharSequence?): Boolean { + return views.composerEditText.setTextIfDifferent(text) + } + + private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { + // val wasSendButtonInvisible = views.sendButton.isInvisible + if (animate) { + configureAndBeginTransition(transitionComplete) + } + ConstraintSet().also { + it.clone(context, currentConstraintSetId) + it.applyTo(this) + } + // Might be updated by view state just after, but avoid blinks + // views.sendButton.isInvisible = wasSendButtonInvisible + } + + private fun configureAndBeginTransition(transitionComplete: (() -> Unit)? = null) { + val transition = TransitionSet().apply { + ordering = TransitionSet.ORDERING_SEQUENTIAL + addTransition(ChangeBounds()) + addTransition(Fade(Fade.IN)) + duration = animationDuration + addListener(object : SimpleTransitionListener() { + override fun onTransitionEnd(transition: Transition) { + transitionComplete?.invoke() + } + }) + } + TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt new file mode 100644 index 0000000000..76bdcfc9a8 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt @@ -0,0 +1,202 @@ +/* + * 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.composer + +import android.content.Context +import android.text.Editable +import android.text.TextWatcher +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.EditText +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.text.toSpannable +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.transition.ChangeBounds +import androidx.transition.Fade +import androidx.transition.Transition +import androidx.transition.TransitionManager +import androidx.transition.TransitionSet +import im.vector.app.R +import im.vector.app.core.animations.SimpleTransitionListener +import im.vector.app.core.extensions.setTextIfDifferent +import im.vector.app.databinding.ComposerRichTextLayoutBinding +import im.vector.app.databinding.ViewRichTextMenuButtonBinding +import io.element.android.wysiwyg.InlineFormat + +class RichTextComposerLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), MessageComposerView { + + private val views: ComposerRichTextLayoutBinding + + override var callback: PlainTextComposerLayout.Callback? = null + + private var currentConstraintSetId: Int = -1 + + private val animationDuration = 100L + + override val text: Editable? + get() = views.composerEditText.text + override val formattedText: String? + get() = views.composerEditText.getHtmlOutput() + override val editText: EditText + get() = views.composerEditText + override val emojiButton: ImageButton? + get() = null + override val sendButton: ImageButton + get() = views.sendButton + override val attachmentButton: ImageButton + get() = views.attachmentButton + override val composerRelatedMessageActionIcon: ImageView + get() = views.composerRelatedMessageActionIcon + override val composerRelatedMessageAvatar: ImageView + get() = views.composerRelatedMessageAvatar + override val composerRelatedMessageContent: TextView + get() = views.composerRelatedMessageContent + override val composerRelatedMessageImage: ImageView + get() = views.composerRelatedMessageImage + override val composerRelatedMessageTitle: TextView + get() = views.composerRelatedMessageTitle + override var isVisible: Boolean + get() = views.root.isVisible + set(value) { views.root.isVisible = value } + + init { + inflate(context, R.layout.composer_rich_text_layout, this) + views = ComposerRichTextLayoutBinding.bind(this) + + collapse(false) + + views.composerEditText.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable) { + callback?.onTextChanged(s) + } + }) + + views.composerRelatedMessageCloseButton.setOnClickListener { + collapse() + callback?.onCloseRelatedMessage() + } + + views.sendButton.setOnClickListener { + val textMessage = text?.toSpannable() ?: "" + callback?.onSendMessage(textMessage) + } + + views.attachmentButton.setOnClickListener { + callback?.onAddAttachment() + } + + setupRichTextMenu() + } + + private fun setupRichTextMenu() { + addRichTextMenuItem(R.drawable.ic_composer_bold, "Bold") { + views.composerEditText.toggleInlineFormat(InlineFormat.Bold) + } + addRichTextMenuItem(R.drawable.ic_composer_italic, "Italic") { + views.composerEditText.toggleInlineFormat(InlineFormat.Italic) + } + addRichTextMenuItem(R.drawable.ic_composer_underlined, "Underline") { + views.composerEditText.toggleInlineFormat(InlineFormat.Underline) + } + addRichTextMenuItem(R.drawable.ic_composer_strikethrough, "Strikethrough") { + views.composerEditText.toggleInlineFormat(InlineFormat.StrikeThrough) + } + } + + private fun addRichTextMenuItem(@DrawableRes iconId: Int, description: String, action: () -> Unit) { + val inflater = LayoutInflater.from(context) + val button = ViewRichTextMenuButtonBinding.inflate(inflater, views.richTextMenu, true) + with(button.root) { + contentDescription = description + setImageResource(iconId) + setOnClickListener { + action() + } + } + } + + override fun replaceFormattedContent(text: CharSequence) { + views.composerEditText.setHtml(text.toString()) + } + + override fun collapse(animate: Boolean, transitionComplete: (() -> Unit)?) { + if (currentConstraintSetId == R.layout.composer_rich_text_layout_constraint_set_compact) { + // ignore we good + return + } + currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_compact + applyNewConstraintSet(animate, transitionComplete) + } + + override fun expand(animate: Boolean, transitionComplete: (() -> Unit)?) { + if (currentConstraintSetId == R.layout.composer_rich_text_layout_constraint_set_expanded) { + // ignore we good + return + } + currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_expanded + applyNewConstraintSet(animate, transitionComplete) + } + + override fun setTextIfDifferent(text: CharSequence?): Boolean { + return views.composerEditText.setTextIfDifferent(text) + } + + private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { + // val wasSendButtonInvisible = views.sendButton.isInvisible + if (animate) { + configureAndBeginTransition(transitionComplete) + } + ConstraintSet().also { + it.clone(context, currentConstraintSetId) + it.applyTo(this) + } + // Might be updated by view state just after, but avoid blinks + // views.sendButton.isInvisible = wasSendButtonInvisible + } + + private fun configureAndBeginTransition(transitionComplete: (() -> Unit)? = null) { + val transition = TransitionSet().apply { + ordering = TransitionSet.ORDERING_SEQUENTIAL + addTransition(ChangeBounds()) + addTransition(Fade(Fade.IN)) + duration = animationDuration + addListener(object : SimpleTransitionListener() { + override fun onTransitionEnd(transition: Transition) { + transitionComplete?.invoke() + } + }) + } + TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition) + } + + override fun setInvisible(isInvisible: Boolean) { + this.isInvisible = isInvisible + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt new file mode 100644 index 0000000000..25764f3654 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt @@ -0,0 +1,201 @@ +/* + * 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.composer.voice + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import com.airbnb.mvrx.parentFragmentViewModel +import com.airbnb.mvrx.withState +import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.R +import im.vector.app.core.hardware.vibrate +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.time.Clock +import im.vector.app.core.utils.PERMISSIONS_FOR_VOICE_MESSAGE +import im.vector.app.core.utils.checkPermissions +import im.vector.app.core.utils.onPermissionDeniedSnackbar +import im.vector.app.core.utils.registerForPermissionsResult +import im.vector.app.databinding.FragmentVoiceRecorderBinding +import im.vector.app.features.home.room.detail.TimelineViewModel +import im.vector.app.features.home.room.detail.composer.MessageComposerAction +import im.vector.app.features.home.room.detail.composer.MessageComposerViewEvents +import im.vector.app.features.home.room.detail.composer.MessageComposerViewModel +import im.vector.app.features.home.room.detail.composer.MessageComposerViewState +import im.vector.app.features.home.room.detail.composer.SendMode +import im.vector.app.features.home.room.detail.composer.boolean +import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker +import javax.inject.Inject + +@AndroidEntryPoint +class VoiceRecorderFragment : VectorBaseFragment() { + + @Inject lateinit var audioMessagePlaybackTracker: AudioMessagePlaybackTracker + @Inject lateinit var clock: Clock + + private val timelineViewModel: TimelineViewModel by parentFragmentViewModel() + private val messageComposerViewModel: MessageComposerViewModel by parentFragmentViewModel() + + private val permissionVoiceMessageLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> + if (allGranted) { + // In this case, let the user start again the gesture + } else if (deniedPermanently) { + vectorBaseActivity.onPermissionDeniedSnackbar(R.string.denied_permission_voice_message) + } + } + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentVoiceRecorderBinding { + return FragmentVoiceRecorderBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + messageComposerViewModel.observeViewEvents { + when (it) { + is MessageComposerViewEvents.AnimateSendButtonVisibility -> handleSendButtonVisibilityChanged(it.isVisible) + else -> Unit + } + } + + messageComposerViewModel.onEach(MessageComposerViewState::sendMode, MessageComposerViewState::canSendMessage) { mode, canSend -> + if (!canSend.boolean()) { + return@onEach + } + if (mode is SendMode.Voice) { + views.voiceMessageRecorderView.isVisible = true + } + } + } + + override fun onResume() { + super.onResume() + + // Removed listeners should be set again + setupVoiceMessageView() + } + + override fun onPause() { + super.onPause() + + audioMessagePlaybackTracker.pauseAllPlaybacks() + } + + override fun invalidate() = withState(timelineViewModel, messageComposerViewModel) { mainState, messageComposerState -> + if (mainState.tombstoneEvent != null) return@withState + + val hasVoiceDraft = messageComposerState.voiceRecordingUiState is VoiceMessageRecorderView.RecordingUiState.Draft + with(views.root) { + isVisible = messageComposerState.isVoiceMessageRecorderVisible || hasVoiceDraft + render(messageComposerState.voiceRecordingUiState) + } + } + + private fun handleSendButtonVisibilityChanged(isSendButtonVisible: Boolean) { + if (isSendButtonVisible) { + views.root.isVisible = false + } else { + views.root.alpha = 0f + views.root.isVisible = true + views.root.animate().alpha(1f).setDuration(150).start() + } + } + + private fun setupVoiceMessageView() { + audioMessagePlaybackTracker.track(AudioMessagePlaybackTracker.RECORDING_ID, views.voiceMessageRecorderView) + views.voiceMessageRecorderView.callback = object : VoiceMessageRecorderView.Callback { + + override fun onVoiceRecordingStarted() { + if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) { + messageComposerViewModel.handle(MessageComposerAction.StartRecordingVoiceMessage) + vibrate(requireContext()) + updateRecordingUiState(VoiceMessageRecorderView.RecordingUiState.Recording(clock.epochMillis())) + } + } + + override fun onVoicePlaybackButtonClicked() { + messageComposerViewModel.handle(MessageComposerAction.PlayOrPauseRecordingPlayback) + } + + override fun onVoiceRecordingCancelled() { + messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true, rootThreadEventId = getRootThreadEventId())) + vibrate(requireContext()) + updateRecordingUiState(VoiceMessageRecorderView.RecordingUiState.Idle) + } + + override fun onVoiceRecordingLocked() { + val startedState = withState(messageComposerViewModel) { it.voiceRecordingUiState as? VoiceMessageRecorderView.RecordingUiState.Recording } + val startTime = startedState?.recordingStartTimestamp ?: clock.epochMillis() + updateRecordingUiState(VoiceMessageRecorderView.RecordingUiState.Locked(startTime)) + } + + override fun onVoiceRecordingEnded() { + onSendVoiceMessage() + } + + override fun onSendVoiceMessage() { + messageComposerViewModel.handle( + MessageComposerAction.EndRecordingVoiceMessage(isCancelled = false, rootThreadEventId = getRootThreadEventId()) + ) + updateRecordingUiState(VoiceMessageRecorderView.RecordingUiState.Idle) + } + + override fun onDeleteVoiceMessage() { + messageComposerViewModel.handle( + MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true, rootThreadEventId = getRootThreadEventId()) + ) + updateRecordingUiState(VoiceMessageRecorderView.RecordingUiState.Idle) + } + + override fun onRecordingLimitReached() = pauseRecording() + + override fun onRecordingWaveformClicked() = pauseRecording() + + override fun onVoiceWaveformTouchedUp(percentage: Float, duration: Int) { + messageComposerViewModel.handle( + MessageComposerAction.VoiceWaveformTouchedUp(AudioMessagePlaybackTracker.RECORDING_ID, duration, percentage) + ) + } + + override fun onVoiceWaveformMoved(percentage: Float, duration: Int) { + messageComposerViewModel.handle( + MessageComposerAction.VoiceWaveformTouchedUp(AudioMessagePlaybackTracker.RECORDING_ID, duration, percentage) + ) + } + + private fun updateRecordingUiState(state: VoiceMessageRecorderView.RecordingUiState) { + messageComposerViewModel.handle( + MessageComposerAction.OnVoiceRecordingUiStateChanged(state) + ) + } + + private fun pauseRecording() { + messageComposerViewModel.handle( + MessageComposerAction.PauseRecordingVoiceMessage + ) + updateRecordingUiState(VoiceMessageRecorderView.RecordingUiState.Draft) + } + } + } + + /** + * Returns the root thread event if we are in a thread room, otherwise returns null. + */ + fun getRootThreadEventId(): String? = withState(timelineViewModel) { it.rootThreadEventId } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCase.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCase.kt index 3bc3a5e351..eda1929133 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCase.kt @@ -17,6 +17,7 @@ package im.vector.app.features.home.room.detail.timeline.action import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import javax.inject.Inject @@ -27,8 +28,13 @@ class CheckIfCanRedactEventUseCase @Inject constructor( fun execute(event: TimelineEvent, actionPermissions: ActionPermissions): Boolean { // Only some event types are supported for the moment - val canRedactEventTypes = listOf(EventType.MESSAGE, EventType.STICKER) + - EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO + val canRedactEventTypes: List = listOf( + EventType.MESSAGE, + EventType.STICKER, + STATE_ROOM_VOICE_BROADCAST_INFO, + ) + + EventType.POLL_START + + EventType.STATE_ROOM_BEACON_INFO return event.root.getClearType() in canRedactEventTypes && // Message sent by the current user can always be redacted, else check permission for messages sent by other users diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt index 3dfb6744e0..0c44ee386d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt @@ -25,6 +25,7 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.error.ErrorFormatter import im.vector.app.core.extensions.canReact +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.platform.EmptyViewEvents import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.resources.StringProvider @@ -60,7 +61,6 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachme import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent -import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.hasBeenEdited import org.matrix.android.sdk.api.session.room.timeline.isPoll import org.matrix.android.sdk.api.session.room.timeline.isRootThread @@ -187,7 +187,7 @@ class MessageActionsViewModel @AssistedInject constructor( when (timelineEvent.root.getClearType()) { EventType.MESSAGE, EventType.STICKER -> { - val messageContent: MessageContent? = timelineEvent.getLastMessageContent() + val messageContent: MessageContent? = timelineEvent.getVectorLastMessageContent() if (messageContent is MessageTextContent && messageContent.format == MessageFormat.FORMAT_MATRIX_HTML) { val html = messageContent.formattedBody ?.takeIf { it.isNotBlank() } @@ -253,7 +253,7 @@ class MessageActionsViewModel @AssistedInject constructor( } private fun actionsForEvent(timelineEvent: TimelineEvent, actionPermissions: ActionPermissions): List { - val messageContent = timelineEvent.getLastMessageContent() + val messageContent = timelineEvent.getVectorLastMessageContent() val msgType = messageContent?.msgType return arrayListOf().apply { 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 28e256c064..06da69fc1a 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 @@ -28,6 +28,7 @@ import dagger.Lazy import im.vector.app.R import im.vector.app.core.epoxy.ClickListener import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.files.LocalFilesHelper import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.StringProvider @@ -42,7 +43,9 @@ 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_ @@ -55,6 +58,8 @@ 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 @@ -77,6 +82,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.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 @@ -102,7 +109,6 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageVerification import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent 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.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.settings.LightweightSettingsStorage import org.matrix.android.sdk.api.util.MimeTypes import javax.inject.Inject @@ -163,7 +169,7 @@ class MessageItemFactory @Inject constructor( return buildRedactedItem(attributes, highlight) } - val messageContent = event.getLastMessageContent() + val messageContent = event.getVectorLastMessageContent() if (messageContent == null) { val malformedText = stringProvider.getString(R.string.malformed_message) return defaultItemFactory.create(malformedText, informationData, highlight, callback) @@ -197,6 +203,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) else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes) } return messageItem?.apply { @@ -245,7 +252,7 @@ class MessageItemFactory @Inject constructor( .pollQuestion(createPollQuestion(informationData, pollViewState.question, callback)) .canVote(pollViewState.canVote) .votesStatus(pollViewState.votesStatus) - .optionViewStates(pollViewState.optionViewStates) + .optionViewStates(pollViewState.optionViewStates.orEmpty()) .edited(informationData.hasBeenEdited) .highlighted(highlight) .leftGuideline(avatarSizeProvider.leftGuideline) @@ -279,7 +286,7 @@ class MessageItemFactory @Inject constructor( .duration(messageContent.audioInfo?.duration ?: 0) .playbackControlButtonClickListener(playbackControlButtonClickListener) .audioMessagePlaybackTracker(audioMessagePlaybackTracker) - .isLocalFile(localFilesHelper.isLocalFile(fileUrl)) + .izLocalFile(localFilesHelper.isLocalFile(fileUrl)) .fileSize(messageContent.audioInfo?.size ?: 0L) .onSeek { params.callback?.onAudioSeekBarMovedTo(informationData.eventId, duration, it) } .mxcUrl(fileUrl) @@ -339,7 +346,7 @@ class MessageItemFactory @Inject constructor( .playbackControlButtonClickListener(playbackControlButtonClickListener) .waveformTouchListener(waveformTouchListener) .audioMessagePlaybackTracker(audioMessagePlaybackTracker) - .isLocalFile(localFilesHelper.isLocalFile(fileUrl)) + .izLocalFile(localFilesHelper.isLocalFile(fileUrl)) .mxcUrl(fileUrl) .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) .contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder) @@ -399,8 +406,8 @@ class MessageItemFactory @Inject constructor( return MessageFileItem_() .attributes(attributes) .leftGuideline(avatarSizeProvider.leftGuideline) - .isLocalFile(localFilesHelper.isLocalFile(messageContent.getFileUrl())) - .isDownloaded(session.fileService().isFileInCache(messageContent)) + .izLocalFile(localFilesHelper.isLocalFile(messageContent.getFileUrl())) + .izDownloaded(session.fileService().isFileInCache(messageContent)) .mxcUrl(mxcUrl) .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) .contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder) @@ -453,12 +460,15 @@ class MessageItemFactory @Inject constructor( maxWidth = maxWidth, allowNonMxcUrls = informationData.sendState.isSending() ) + + val playable = messageContent.mimeType == MimeTypes.Gif + return MessageImageVideoItem_() .attributes(attributes) .leftGuideline(avatarSizeProvider.leftGuideline) .imageContentRenderer(imageContentRenderer) .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) - .playable(messageContent.mimeType == MimeTypes.Gif) + .playable(playable) .highlighted(highlight) .mediaData(data) .apply { @@ -472,6 +482,10 @@ class MessageItemFactory @Inject constructor( callback?.onImageMessageClicked(messageContent, data, view, emptyList()) } } + }.apply { + if (playable && vectorPreferences.autoplayAnimatedImages()) { + mode(ImageContentRenderer.Mode.ANIMATED_THUMBNAIL) + } } } @@ -699,6 +713,25 @@ 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/TimelineItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index 6c5a66d39d..0b8f95b4a1 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -21,6 +21,7 @@ import im.vector.app.core.epoxy.TimelineEmptyItem_ import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.features.analytics.DecryptionFailureTracker import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityHelper +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import timber.log.Timber @@ -88,6 +89,7 @@ class TimelineItemFactory @Inject constructor( // State room create EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(params) in EventType.STATE_ROOM_BEACON_INFO -> messageItemFactory.create(params) + STATE_ROOM_VOICE_BROADCAST_INFO -> messageItemFactory.create(params) // Unhandled state event types else -> { // Should only happen when shouldShowHiddenEvents() settings is ON diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt index 7b9bd4530b..eb531b6f1b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt @@ -19,6 +19,7 @@ package im.vector.app.features.home.room.detail.timeline.format import dagger.Lazy import im.vector.app.EmojiSpanify import im.vector.app.R +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.StringProvider import im.vector.app.features.html.EventHtmlRenderer @@ -34,7 +35,6 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.relation.ReactionContent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent -import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.getTextDisplayableContent import javax.inject.Inject @@ -60,7 +60,7 @@ class DisplayableEventFormatter @Inject constructor( return when (timelineEvent.root.getClearType()) { EventType.MESSAGE -> { - timelineEvent.getLastMessageContent()?.let { messageContent -> + timelineEvent.getVectorLastMessageContent()?.let { messageContent -> when (messageContent.msgType) { MessageType.MSGTYPE_TEXT -> { val body = messageContent.getTextDisplayableContent() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt index ddb98c42c6..50b4366e98 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt @@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.helper import im.vector.app.core.date.DateFormatKind import im.vector.app.core.date.VectorDateFormatter +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.extensions.localDateTime import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFactoryParams import im.vector.app.features.home.room.detail.timeline.item.E2EDecoration @@ -41,7 +42,6 @@ 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.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent -import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.hasBeenEdited import javax.inject.Inject @@ -123,7 +123,11 @@ class MessageInformationDataFactory @Inject constructor( isLastFromThisSender = isLastFromThisSender, e2eDecoration = e2eDecoration, sendStateDecoration = sendStateDecoration, - messageType = if (event.root.isSticker()) { MessageType.MSGTYPE_STICKER_LOCAL } else { event.root.getMsgType() } + messageType = if (event.root.isSticker()) { + MessageType.MSGTYPE_STICKER_LOCAL + } else { + event.root.getMsgType() + } ) } @@ -230,7 +234,7 @@ class MessageInformationDataFactory @Inject constructor( EventType.KEY_VERIFICATION_DONE, EventType.KEY_VERIFICATION_CANCEL -> true EventType.MESSAGE -> { - event.getLastMessageContent() is MessageVerificationRequestContent + event.getVectorLastMessageContent() is MessageVerificationRequestContent } else -> false } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt index 23db2a721c..87844aba8e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt @@ -16,6 +16,7 @@ package im.vector.app.features.home.room.detail.timeline.helper +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent @@ -24,7 +25,7 @@ object TimelineDisplayableEvents { /** * All types we have an item to build with. Every type not defined here will be shown as DefaultItem if forced to be shown, otherwise will be hidden. */ - val DISPLAYABLE_TYPES = listOf( + val DISPLAYABLE_TYPES: List = listOf( EventType.MESSAGE, EventType.STATE_ROOM_WIDGET_LEGACY, EventType.STATE_ROOM_WIDGET, @@ -51,7 +52,11 @@ object TimelineDisplayableEvents { EventType.STATE_ROOM_JOIN_RULES, EventType.KEY_VERIFICATION_DONE, EventType.KEY_VERIFICATION_CANCEL, - ) + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO + EventType.BEACON_LOCATION_DATA + STATE_ROOM_VOICE_BROADCAST_INFO, + ) + + EventType.POLL_START + + EventType.STATE_ROOM_BEACON_INFO + + EventType.BEACON_LOCATION_DATA } fun TimelineEvent.isRoomConfiguration(roomCreatorUserId: String?): Boolean { 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 4ff8a9fa43..bd211a4513 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 @@ -17,6 +17,9 @@ package im.vector.app.features.home.room.detail.timeline.helper import im.vector.app.core.utils.TextUtils +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +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 @@ -54,12 +57,13 @@ class TimelineEventsGroups { private fun TimelineEvent.getGroupIdOrNull(): String? { val type = root.getClearType() val content = root.getClearContent() - return if (EventType.isCallEvent(type)) { - (content?.get("call_id") as? String) - } else if (type == EventType.STATE_ROOM_WIDGET || type == EventType.STATE_ROOM_WIDGET_LEGACY) { - root.stateKey - } else { - null + return when { + EventType.isCallEvent(type) -> (content?.get("call_id") as? String) + type == STATE_ROOM_VOICE_BROADCAST_INFO -> root.asVoiceBroadcastEvent()?.reference?.eventId + type == EventType.STATE_ROOM_WIDGET || type == EventType.STATE_ROOM_WIDGET_LEGACY -> root.stateKey + else -> { + null + } } } @@ -128,3 +132,10 @@ class CallSignalingEventsGroup(private val group: TimelineEventsGroup) { return group.events.firstOrNull { it.root.getClearType() == EventType.CALL_REJECT } } } + +class VoiceBroadcastEventsGroup(private val group: TimelineEventsGroup) { + fun getLastEvent(): TimelineEvent { + return group.events.find { it.root.asVoiceBroadcastEvent()?.content?.voiceBroadcastState == VoiceBroadcastState.STOPPED } + ?: group.events.maxBy { it.root.originServerTs ?: 0L } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt index 8dba0117b5..869b7d17e2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -63,12 +63,6 @@ abstract class AbsMessageItem( } } - private val _memberNameClickListener = object : ClickListener { - override fun invoke(p1: View) { - attributes.avatarCallback?.onMemberNameClicked(attributes.informationData) - } - } - private val _threadClickListener = object : ClickListener { override fun invoke(p1: View) { attributes.threadCallback?.onThreadSummaryClicked(attributes.informationData.eventId, attributes.threadDetails?.isRootThread ?: false) @@ -95,7 +89,7 @@ abstract class AbsMessageItem( holder.memberNameView.isVisible = true holder.memberNameView.text = attributes.informationData.memberName holder.memberNameView.setTextColor(attributes.getMemberNameColor()) - holder.memberNameView.onClick(_memberNameClickListener) + holder.memberNameView.onClick(attributes.memberClickListener) holder.memberNameView.setOnLongClickListener(attributes.itemLongClickListener) } else { holder.memberNameView.setOnClickListener(null) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageAudioItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageAudioItem.kt index 256019a2cb..fda9a1465f 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageAudioItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageAudioItem.kt @@ -53,8 +53,7 @@ abstract class MessageAudioItem : AbsMessageItem() { var fileSize: Long = 0 @EpoxyAttribute - @JvmField - var isLocalFile = false + var izLocalFile = false @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var onSeek: ((percentage: Float) -> Unit)? = null @@ -91,7 +90,7 @@ abstract class MessageAudioItem : AbsMessageItem() { holder.view.context.getString(R.string.error_audio_message_unable_to_play, filename) holder.progressLayout.isVisible = false } else { - contentUploadStateTrackerBinder.bind(attributes.informationData.eventId, isLocalFile, holder.progressLayout) + contentUploadStateTrackerBinder.bind(attributes.informationData.eventId, izLocalFile, holder.progressLayout) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt index b11d8fbb52..bf16c8959e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt @@ -48,12 +48,10 @@ abstract class MessageFileItem : AbsMessageItem() { var iconRes: Int = 0 @EpoxyAttribute - @JvmField - var isLocalFile = false + var izLocalFile = false @EpoxyAttribute - @JvmField - var isDownloaded = false + var izDownloaded = false @EpoxyAttribute lateinit var contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder @@ -66,7 +64,7 @@ abstract class MessageFileItem : AbsMessageItem() { renderSendState(holder.fileLayout, holder.filenameView) if (!attributes.informationData.sendState.hasFailed()) { - contentUploadStateTrackerBinder.bind(attributes.informationData.eventId, isLocalFile, holder.progressLayout) + contentUploadStateTrackerBinder.bind(attributes.informationData.eventId, izLocalFile, holder.progressLayout) } else { holder.fileImageView.setImageResource(R.drawable.ic_cross) holder.progressLayout.isVisible = false @@ -77,7 +75,7 @@ abstract class MessageFileItem : AbsMessageItem() { if (attributes.informationData.sendState.isSending()) { holder.fileImageView.setImageResource(iconRes) } else { - if (isDownloaded) { + if (izDownloaded) { holder.fileImageView.setImageResource(iconRes) holder.fileDownloadProgress.progress = 0 } else { 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 new file mode 100644 index 0000000000..14a4fc6b07 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastItem.kt @@ -0,0 +1,72 @@ +/* + * 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/MessageVoiceItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt index 93e95dd4a5..e057950790 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt @@ -55,8 +55,7 @@ abstract class MessageVoiceItem : AbsMessageItem() { var waveform: List = emptyList() @EpoxyAttribute - @JvmField - var isLocalFile = false + var izLocalFile = false @EpoxyAttribute lateinit var contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder @@ -77,7 +76,7 @@ abstract class MessageVoiceItem : AbsMessageItem() { super.bind(holder) renderSendState(holder.voiceLayout, null) if (!attributes.informationData.sendState.hasFailed()) { - contentUploadStateTrackerBinder.bind(attributes.informationData.eventId, isLocalFile, holder.progressLayout) + contentUploadStateTrackerBinder.bind(attributes.informationData.eventId, izLocalFile, holder.progressLayout) } else { holder.voicePlaybackControlButton.setImageResource(R.drawable.ic_cross) holder.voicePlaybackControlButton.contentDescription = holder.view.context.getString(R.string.error_voice_message_unable_to_play) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt index 14a02c7172..379e5b3b91 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt @@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.style import android.content.res.Resources import im.vector.app.R +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.extensions.localDateTime import im.vector.app.core.resources.LocaleProvider import im.vector.app.core.resources.isRTL @@ -29,7 +30,6 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageVerificationRequestContent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent -import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.isEdition import org.matrix.android.sdk.api.session.room.timeline.isRootThread import javax.inject.Inject @@ -126,7 +126,7 @@ class TimelineMessageLayoutFactory @Inject constructor( isLastFromThisSender = isLastFromThisSender ) - val messageContent = event.getLastMessageContent() + val messageContent = event.getVectorLastMessageContent() TimelineMessageLayout.Bubble( showAvatar = showInformation && !isSentByMe, showDisplayName = showInformation && !isSentByMe, @@ -167,7 +167,7 @@ class TimelineMessageLayoutFactory @Inject constructor( private fun TimelineEvent.shouldBuildBubbleLayout(): Boolean { val type = root.getClearType() if (type in EVENT_TYPES_WITH_BUBBLE_LAYOUT) { - val messageContent = getLastMessageContent() + val messageContent = getVectorLastMessageContent() return messageContent?.msgType !in MSG_TYPES_WITHOUT_BUBBLE_LAYOUT } return false @@ -212,7 +212,7 @@ class TimelineMessageLayoutFactory @Inject constructor( EventType.KEY_VERIFICATION_DONE, EventType.KEY_VERIFICATION_CANCEL -> true EventType.MESSAGE -> { - event.getLastMessageContent() is MessageVerificationRequestContent + event.getVectorLastMessageContent() is MessageVerificationRequestContent } else -> false } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/MessageBubbleContentLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/MessageBubbleContentLayout.kt index f11b1c6951..6d8fd87a52 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/MessageBubbleContentLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/MessageBubbleContentLayout.kt @@ -27,7 +27,7 @@ import androidx.core.view.marginEnd import androidx.core.view.marginStart import androidx.core.view.marginTop import im.vector.app.R -import im.vector.app.core.resources.LocaleProvider +import im.vector.app.core.resources.DefaultLocaleProvider import im.vector.app.core.resources.getLayoutDirectionFromCurrentLocale class MessageBubbleContentLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : @@ -53,7 +53,7 @@ class MessageBubbleContentLayout @JvmOverloads constructor(context: Context, att textViewStub.setOnInflateListener(null) messageTextView = inflated.findViewById(R.id.messageTextView) } - localeLayoutDirection = LocaleProvider(resources).getLayoutDirectionFromCurrentLocale() + localeLayoutDirection = DefaultLocaleProvider(resources).getLayoutDirectionFromCurrentLocale() } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/MessageBubbleView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/MessageBubbleView.kt index c9665a9125..6ac787b719 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/MessageBubbleView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/view/MessageBubbleView.kt @@ -33,7 +33,7 @@ import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import com.google.android.material.shape.MaterialShapeDrawable import im.vector.app.R -import im.vector.app.core.resources.LocaleProvider +import im.vector.app.core.resources.DefaultLocaleProvider import im.vector.app.core.resources.getLayoutDirectionFromCurrentLocale import im.vector.app.core.utils.DimensionConverter import im.vector.app.databinding.ViewMessageBubbleBinding @@ -67,7 +67,7 @@ class MessageBubbleView @JvmOverloads constructor( override fun onFinishInflate() { super.onFinishInflate() views = ViewMessageBubbleBinding.bind(this) - val currentLayoutDirection = LocaleProvider(resources).getLayoutDirectionFromCurrentLocale() + val currentLayoutDirection = DefaultLocaleProvider(resources).getLayoutDirectionFromCurrentLocale() val layoutDirectionToSet = if (isIncoming) { currentLayoutDirection } else { diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt index 9591048725..970b1c34e1 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt @@ -285,7 +285,7 @@ class RoomListFragment : } private fun setupRecyclerView() { - val layoutManager = LinearLayoutManager(context) + val layoutManager = LinearLayoutManager(requireContext()) stateRestorer = LayoutManagerStateRestorer(layoutManager).register() views.roomListView.layoutManager = layoutManager views.roomListView.itemAnimator = RoomListAnimator() diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemCentered.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryCenteredItem.kt similarity index 96% rename from vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemCentered.kt rename to vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryCenteredItem.kt index 440df0952c..764f50456c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemCentered.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryCenteredItem.kt @@ -41,7 +41,7 @@ import org.matrix.android.sdk.api.session.presence.model.UserPresence import org.matrix.android.sdk.api.util.MatrixItem @EpoxyModelClass -abstract class RoomSummaryItemCentered : VectorEpoxyModel(R.layout.item_room_centered) { +abstract class RoomSummaryCenteredItem : VectorEpoxyModel(R.layout.item_room_centered) { @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer @@ -61,8 +61,8 @@ abstract class RoomSummaryItemCentered : VectorEpoxyModel(R.layo @EpoxyAttribute var showPresence: Boolean = false - @EpoxyAttribute @JvmField - var isPublic: Boolean = false + @EpoxyAttribute + var izPublic: Boolean = false @EpoxyAttribute var unreadNotificationCount: Int = 0 @@ -121,7 +121,7 @@ abstract class RoomSummaryItem : VectorEpoxyModel(R.layo holder.draftView.isVisible = hasDraft avatarRenderer.render(matrixItem, holder.avatarImageView) holder.roomAvatarDecorationImageView.render(encryptionTrustLevel) - holder.roomAvatarPublicDecorationImageView.isVisible = isPublic + holder.roomAvatarPublicDecorationImageView.isVisible = izPublic holder.roomAvatarFailSendingImageView.isVisible = hasFailedSending renderSelection(holder, showSelected) holder.roomAvatarPresenceImageView.render(showPresence, userPresence) diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt index 290b66e576..638e3c185d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt @@ -167,7 +167,7 @@ class RoomSummaryItemFactory @Inject constructor( // .encryptionTrustLevel(roomSummary.roomEncryptionTrustLevel) .displayMode(displayMode) .subtitle(subtitle) - .isPublic(roomSummary.isPublic) + .izPublic(roomSummary.isPublic) .showPresence(roomSummary.isDirect) .userPresence(roomSummary.directUserPresence) .matrixItem(roomSummary.toMatrixItem()) @@ -191,13 +191,13 @@ class RoomSummaryItemFactory @Inject constructor( unreadCount: Int, onClick: ((RoomSummary) -> Unit)?, onLongClick: ((RoomSummary) -> Boolean)? - ) = RoomSummaryItemCentered_() + ) = RoomSummaryCenteredItem_() .id(roomSummary.roomId) .avatarRenderer(avatarRenderer) // We do not display shield in the room list anymore // .encryptionTrustLevel(roomSummary.roomEncryptionTrustLevel) .displayMode(displayMode) - .isPublic(roomSummary.isPublic) + .izPublic(roomSummary.isPublic) .showPresence(roomSummary.isDirect) .userPresence(roomSummary.directUserPresence) .matrixItem(roomSummary.toMatrixItem()) diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPagedController.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPagedController.kt index 10d7ef425c..43b20296af 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPagedController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPagedController.kt @@ -67,7 +67,7 @@ class RoomSummaryPagedController( override fun buildItemModel(currentPosition: Int, item: RoomSummary?): EpoxyModel<*> { return if (item == null) { val host = this - RoomSummaryItemPlaceHolder_().apply { + RoomSummaryPlaceHolderItem_().apply { id(currentPosition) useSingleLineForLastEvent(host.shouldUseSingleLine) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemPlaceHolder.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPlaceHolderItem.kt similarity index 90% rename from vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemPlaceHolder.kt rename to vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPlaceHolderItem.kt index df191bc2ec..75156ad5d9 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemPlaceHolder.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPlaceHolderItem.kt @@ -24,7 +24,7 @@ import im.vector.app.core.epoxy.VectorEpoxyHolder import im.vector.app.core.epoxy.VectorEpoxyModel @EpoxyModelClass -abstract class RoomSummaryItemPlaceHolder : VectorEpoxyModel(R.layout.item_room_placeholder) { +abstract class RoomSummaryPlaceHolderItem : VectorEpoxyModel(R.layout.item_room_placeholder) { @EpoxyAttribute var useSingleLineForLastEvent: Boolean = false diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/SpaceDirectoryFilterNoResults.kt b/vector/src/main/java/im/vector/app/features/home/room/list/SpaceDirectoryFilterNoResultsItem.kt similarity index 84% rename from vector/src/main/java/im/vector/app/features/home/room/list/SpaceDirectoryFilterNoResults.kt rename to vector/src/main/java/im/vector/app/features/home/room/list/SpaceDirectoryFilterNoResultsItem.kt index 6899b59f38..1efbf53214 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/SpaceDirectoryFilterNoResults.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/SpaceDirectoryFilterNoResultsItem.kt @@ -22,6 +22,6 @@ import im.vector.app.core.epoxy.VectorEpoxyHolder import im.vector.app.core.epoxy.VectorEpoxyModel @EpoxyModelClass -abstract class SpaceDirectoryFilterNoResults : VectorEpoxyModel(R.layout.item_space_directory_filter_no_results) { +abstract class SpaceDirectoryFilterNoResultsItem : VectorEpoxyModel(R.layout.item_space_directory_filter_no_results) { class Holder : VectorEpoxyHolder() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeFilteredRoomsController.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeFilteredRoomsController.kt index cd245af0fc..500039e3eb 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeFilteredRoomsController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeFilteredRoomsController.kt @@ -24,7 +24,7 @@ import im.vector.app.core.utils.createUIHandler import im.vector.app.features.home.RoomListDisplayMode import im.vector.app.features.home.room.list.RoomListListener import im.vector.app.features.home.room.list.RoomSummaryItemFactory -import im.vector.app.features.home.room.list.RoomSummaryItemPlaceHolder_ +import im.vector.app.features.home.room.list.RoomSummaryPlaceHolderItem_ import im.vector.app.features.settings.FontScalePreferences import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState import org.matrix.android.sdk.api.session.room.model.RoomSummary @@ -83,7 +83,7 @@ class HomeFilteredRoomsController @Inject constructor( override fun buildItemModel(currentPosition: Int, item: RoomSummary?): EpoxyModel<*> { return if (item == null) { val host = this - RoomSummaryItemPlaceHolder_().apply { + RoomSummaryPlaceHolderItem_().apply { id(currentPosition) useSingleLineForLastEvent(host.shouldUseSingleLine) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListFragment.kt index 5677f3e4a8..d8c71e3e17 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListFragment.kt @@ -137,7 +137,7 @@ class HomeRoomListFragment : private fun setupRecyclerView() { views.stateView.state = StateView.State.Content - val layoutManager = LinearLayoutManager(context) + val layoutManager = LinearLayoutManager(requireContext()) firstItemObserver = FirstItemUpdatedObserver(layoutManager) { layoutManager.scrollToPosition(0) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListViewModel.kt index 7f62c68850..33b293497e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListViewModel.kt @@ -18,7 +18,7 @@ package im.vector.app.features.home.room.list.home import android.widget.ImageView import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.paging.PagedList import arrow.core.toOption @@ -34,6 +34,9 @@ import im.vector.app.core.platform.StateView import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.resources.StringProvider +import im.vector.app.features.analytics.AnalyticsTracker +import im.vector.app.features.analytics.extensions.toTrackingValue +import im.vector.app.features.analytics.plan.UserProperties import im.vector.app.features.displayname.getBestName import im.vector.app.features.home.room.list.home.header.HomeRoomFilter import kotlinx.coroutines.flow.Flow @@ -74,6 +77,7 @@ class HomeRoomListViewModel @AssistedInject constructor( private val preferencesStore: HomeLayoutPreferencesStore, private val stringProvider: StringProvider, private val drawableProvider: DrawableProvider, + private val analyticsTracker: AnalyticsTracker, ) : VectorViewModel(initialState) { @AssistedFactory @@ -89,7 +93,7 @@ class HomeRoomListViewModel @AssistedInject constructor( .setEnablePlaceholders(true) .build() - private val _roomsLivePagedList = MediatorLiveData>() + private val _roomsLivePagedList = MutableLiveData>() val roomsLivePagedList: LiveData> = _roomsLivePagedList private val internalPagedListObserver = Observer> { @@ -236,9 +240,7 @@ class HomeRoomListViewModel @AssistedInject constructor( } private fun observeRooms(currentFilter: HomeRoomFilter, isAZOrdering: Boolean) { - filteredPagedRoomSummariesLive?.livePagedList?.let { livePagedList -> - _roomsLivePagedList.removeSource(livePagedList) - } + filteredPagedRoomSummariesLive?.livePagedList?.removeObserver(internalPagedListObserver) val builder = RoomSummaryQueryParams.Builder().also { it.memberships = listOf(Membership.JOIN) it.spaceFilter = spaceStateHandler.getCurrentSpace()?.roomId.toActiveSpaceOrNoFilter() @@ -256,7 +258,7 @@ class HomeRoomListViewModel @AssistedInject constructor( ).also { filteredPagedRoomSummariesLive = it } - _roomsLivePagedList.addSource(liveResults.livePagedList, internalPagedListObserver) + liveResults.livePagedList.observeForever(internalPagedListObserver) } private fun observeOrderPreferences() { @@ -339,9 +341,7 @@ class HomeRoomListViewModel @AssistedInject constructor( } override fun onCleared() { - filteredPagedRoomSummariesLive?.livePagedList?.let { livePagedList -> - _roomsLivePagedList.removeSource(livePagedList) - } + filteredPagedRoomSummariesLive?.livePagedList?.removeObserver(internalPagedListObserver) super.onCleared() } @@ -358,6 +358,7 @@ class HomeRoomListViewModel @AssistedInject constructor( } setState { copy(headersData = headersData.copy(currentFilter = newFilter)) } updateEmptyState() + analyticsTracker.updateUserProperties(UserProperties(allChatsActiveFilter = newFilter.toTrackingValue())) filteredPagedRoomSummariesLive?.let { liveResults -> liveResults.queryParams = getFilteredQueryParams(newFilter, liveResults.queryParams) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/header/HomeRoomsHeadersController.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/header/HomeRoomsHeadersController.kt index 56cccd9c36..3cc058985a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/home/header/HomeRoomsHeadersController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/header/HomeRoomsHeadersController.kt @@ -28,6 +28,7 @@ import com.google.android.material.color.MaterialColors import im.vector.app.R import im.vector.app.core.resources.StringProvider import im.vector.app.core.utils.FirstItemUpdatedObserver +import im.vector.app.features.analytics.AnalyticsTracker import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.list.RoomListListener import org.matrix.android.sdk.api.session.room.model.RoomSummary @@ -38,6 +39,7 @@ class HomeRoomsHeadersController @Inject constructor( val stringProvider: StringProvider, private val avatarRenderer: AvatarRenderer, resources: Resources, + private val analyticsTracker: AnalyticsTracker, ) : EpoxyController() { private var data: RoomsHeadersData = RoomsHeadersData() @@ -73,7 +75,11 @@ class HomeRoomsHeadersController @Inject constructor( } host.data.filtersList?.let { - addRoomFilterHeaderItem(host.onFilterChangedListener, it, host.data.currentFilter) + addRoomFilterHeaderItem( + filterChangedListener = host.onFilterChangedListener, + filtersList = it, + currentFilter = host.data.currentFilter, + analyticsTracker = analyticsTracker) } } @@ -158,12 +164,14 @@ class HomeRoomsHeadersController @Inject constructor( filterChangedListener: ((HomeRoomFilter) -> Unit)?, filtersList: List, currentFilter: HomeRoomFilter?, + analyticsTracker: AnalyticsTracker, ) { roomFilterHeaderItem { id("filter_header") filtersData(filtersList) selectedFilter(currentFilter) onFilterChangedListener(filterChangedListener) + analyticsTracker(analyticsTracker) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/header/RoomFilterHeaderItem.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/header/RoomFilterHeaderItem.kt index ed99b51681..fd4333b722 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/home/header/RoomFilterHeaderItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/header/RoomFilterHeaderItem.kt @@ -22,6 +22,8 @@ import com.google.android.material.tabs.TabLayout import im.vector.app.R import im.vector.app.core.epoxy.VectorEpoxyHolder import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.features.analytics.AnalyticsTracker +import im.vector.app.features.analytics.plan.Interaction @EpoxyModelClass abstract class RoomFilterHeaderItem : VectorEpoxyModel(R.layout.item_home_filter_tabs) { @@ -35,6 +37,9 @@ abstract class RoomFilterHeaderItem : VectorEpoxyModel + trackFilterChangeEvent(filter) onFilterChangedListener?.invoke(filter) } } @@ -61,6 +67,23 @@ abstract class RoomFilterHeaderItem : VectorEpoxyModel Interaction.Name.MobileAllChatsFilterAll + HomeRoomFilter.UNREADS -> Interaction.Name.MobileAllChatsFilterUnreads + HomeRoomFilter.FAVOURITES -> Interaction.Name.MobileAllChatsFilterFavourites + HomeRoomFilter.PEOPlE -> Interaction.Name.MobileAllChatsFilterPeople + } + + analyticsTracker?.capture( + Interaction( + index = null, + interactionType = null, + name = interactionName + ) + ) + } + override fun unbind(holder: Holder) { holder.tabLayout.clearOnTabSelectedListeners() super.unbind(holder) diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesController.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesController.kt index 1511b97c3c..b59dfbdd43 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesController.kt @@ -22,7 +22,7 @@ import im.vector.app.core.utils.createUIHandler import im.vector.app.features.home.RoomListDisplayMode import im.vector.app.features.home.room.list.RoomListListener import im.vector.app.features.home.room.list.RoomSummaryItemFactory -import im.vector.app.features.home.room.list.RoomSummaryItemPlaceHolder_ +import im.vector.app.features.home.room.list.RoomSummaryPlaceHolderItem_ import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState import org.matrix.android.sdk.api.session.room.model.RoomSummary import javax.inject.Inject @@ -43,7 +43,7 @@ class InvitesController @Inject constructor( var listener: RoomListListener? = null override fun buildItemModel(currentPosition: Int, item: RoomSummary?): EpoxyModel<*> { - item ?: return RoomSummaryItemPlaceHolder_().apply { id(currentPosition) } + item ?: return RoomSummaryPlaceHolderItem_().apply { id(currentPosition) } return roomSummaryItemFactory.create(item, roomChangeMembershipStates.orEmpty(), emptySet(), RoomListDisplayMode.ROOMS, listener) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesFragment.kt index 0dbc1b8f34..ac39d7d567 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesFragment.kt @@ -27,6 +27,7 @@ import im.vector.app.core.extensions.configureWith import im.vector.app.core.platform.StateView import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.databinding.FragmentInvitesBinding +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.ViewRoom import im.vector.app.features.home.room.list.RoomListListener import im.vector.app.features.notifications.NotificationDrawerManager @@ -48,6 +49,11 @@ class InvitesFragment : VectorBaseFragment(), RoomListLi return FragmentInvitesBinding.inflate(inflater, container, false) } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + analyticsScreenName = MobileScreen.ScreenName.Invites + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/layout/HomeLayoutSettingBottomDialogFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/layout/HomeLayoutSettingBottomDialogFragment.kt index 0c4d64a1cc..63b7f557e3 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/home/layout/HomeLayoutSettingBottomDialogFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/layout/HomeLayoutSettingBottomDialogFragment.kt @@ -25,6 +25,7 @@ import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment import im.vector.app.databinding.BottomSheetHomeLayoutSettingsBinding +import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.home.room.list.home.HomeLayoutPreferencesStore import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -54,9 +55,11 @@ class HomeLayoutSettingBottomDialogFragment : VectorBaseBottomSheetDialogFragmen } views.homeLayoutSettingsRecents.setOnCheckedChangeListener { _, isChecked -> + trackRecentsStateEvent(isChecked) setRecentsEnabled(isChecked) } views.homeLayoutSettingsFilters.setOnCheckedChangeListener { _, isChecked -> + trackFiltersStateEvent(isChecked) setFiltersEnabled(isChecked) } views.homeLayoutSettingsSortGroup.setOnCheckedChangeListener { _, checkedId -> @@ -64,10 +67,40 @@ class HomeLayoutSettingBottomDialogFragment : VectorBaseBottomSheetDialogFragmen } } + private fun trackRecentsStateEvent(areEnabled: Boolean) { + val interactionName = if (areEnabled) { + Interaction.Name.MobileAllChatsRecentsEnabled + } else { + Interaction.Name.MobileAllChatsRecentsDisabled + } + analyticsTracker.capture( + Interaction( + index = null, + interactionType = null, + name = interactionName + ) + ) + } + private fun setRecentsEnabled(isEnabled: Boolean) = lifecycleScope.launch { preferencesStore.setRecentsEnabled(isEnabled) } + private fun trackFiltersStateEvent(areEnabled: Boolean) { + val interactionName = if (areEnabled) { + Interaction.Name.MobileAllChatsFiltersEnabled + } else { + Interaction.Name.MobileAllChatsFiltersDisabled + } + analyticsTracker.capture( + Interaction( + index = null, + interactionType = null, + name = interactionName + ) + ) + } + private fun setFiltersEnabled(isEnabled: Boolean) = lifecycleScope.launch { preferencesStore.setFiltersEnabled(isEnabled) } diff --git a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt index 725f23cddd..9e869ecde1 100644 --- a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt @@ -27,8 +27,13 @@ package im.vector.app.features.html import android.content.Context import android.content.res.Resources +import android.graphics.drawable.Drawable import android.text.Spannable import androidx.core.text.toSpannable +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.request.target.Target +import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.resources.ColorProvider import im.vector.app.core.utils.DimensionConverter import im.vector.app.features.settings.VectorPreferences @@ -39,12 +44,15 @@ import io.noties.markwon.PrecomputedFutureTextSetterCompat import io.noties.markwon.ext.latex.JLatexMathPlugin import io.noties.markwon.ext.latex.JLatexMathTheme import io.noties.markwon.html.HtmlPlugin +import io.noties.markwon.image.AsyncDrawable +import io.noties.markwon.image.glide.GlideImagesPlugin import io.noties.markwon.inlineparser.EntityInlineProcessor import io.noties.markwon.inlineparser.HtmlInlineProcessor import io.noties.markwon.inlineparser.MarkwonInlineParser import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin import org.commonmark.node.Node import org.commonmark.parser.Parser +import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -53,7 +61,8 @@ import javax.inject.Singleton class EventHtmlRenderer @Inject constructor( htmlConfigure: MatrixHtmlPluginConfigure, context: Context, - vectorPreferences: VectorPreferences + vectorPreferences: VectorPreferences, + private val activeSessionHolder: ActiveSessionHolder ) { interface PostProcessor { @@ -62,6 +71,23 @@ class EventHtmlRenderer @Inject constructor( private val builder = Markwon.builder(context) .usePlugin(HtmlPlugin.create(htmlConfigure)) + .usePlugin(GlideImagesPlugin.create(object : GlideImagesPlugin.GlideStore { + override fun load(drawable: AsyncDrawable): RequestBuilder { + val url = drawable.destination + if (url.isMxcUrl()) { + val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver() + val imageUrl = contentUrlResolver.resolveFullSize(url) + // Override size to avoid crashes for huge pictures + return Glide.with(context).load(imageUrl).override(500) + } + // We don't want to support other url schemes here, so just return a request for null + return Glide.with(context).load(null as String?) + } + + override fun cancel(target: Target<*>) { + Glide.with(context).clear(target) + } + })) private val markwon = if (vectorPreferences.latexMathsIsEnabled()) { // If latex maths is enabled in app preferences, refomat it so Markwon recognises it diff --git a/vector/src/main/java/im/vector/app/features/lifecycle/VectorActivityLifecycleCallbacks.kt b/vector/src/main/java/im/vector/app/features/lifecycle/VectorActivityLifecycleCallbacks.kt index c884843f5c..5bdd92dcf4 100644 --- a/vector/src/main/java/im/vector/app/features/lifecycle/VectorActivityLifecycleCallbacks.kt +++ b/vector/src/main/java/im/vector/app/features/lifecycle/VectorActivityLifecycleCallbacks.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.matrix.android.sdk.api.extensions.tryOrNull import timber.log.Timber class VectorActivityLifecycleCallbacks constructor(private val popupAlertManager: PopupAlertManager) : Application.ActivityLifecycleCallbacks { @@ -58,6 +59,26 @@ class VectorActivityLifecycleCallbacks constructor(private val popupAlertManager override fun onActivityStopped(activity: Activity) {} override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + if (activitiesInfo.isEmpty()) { + val context = activity.applicationContext + val packageManager: PackageManager = context.packageManager + + // Get all activities from element android + activitiesInfo = packageManager.getPackageInfo(context.packageName, PackageManager.GET_ACTIVITIES).activities + + // Get all activities from PermissionController module + // See https://source.android.com/docs/core/architecture/modular-system/permissioncontroller#package-format + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.S_V2) { + activitiesInfo += tryOrNull { + packageManager.getPackageInfo("com.google.android.permissioncontroller", PackageManager.GET_ACTIVITIES).activities + } ?: tryOrNull { + packageManager.getModuleInfo("com.google.android.permission", 1).packageName?.let { + packageManager.getPackageInfo(it, PackageManager.GET_ACTIVITIES or PackageManager.MATCH_APEX).activities + } + }.orEmpty() + } + } + // restart the app if the task contains an unknown activity coroutineScope.launch { val isTaskCorrupted = try { @@ -92,12 +113,6 @@ class VectorActivityLifecycleCallbacks constructor(private val popupAlertManager */ private suspend fun isTaskCorrupted(activity: Activity): Boolean = withContext(Dispatchers.Default) { val context = activity.applicationContext - val packageManager: PackageManager = context.packageManager - - // Get all activities from app manifest - if (activitiesInfo.isEmpty()) { - activitiesInfo = packageManager.getPackageInfo(context.packageName, PackageManager.GET_ACTIVITIES).activities - } // Get all running activities on app task // and compare to activities declared in manifest @@ -122,7 +137,7 @@ class VectorActivityLifecycleCallbacks constructor(private val popupAlertManager runningTaskInfo.topActivity?.let { // Check whether the activity task affinity matches with app task affinity. // The activity is considered safe when its task affinity doesn't correspond to app task affinity. - if (packageManager.getActivityInfo(it, 0).taskAffinity == context.applicationInfo.taskAffinity) { + if (context.packageManager.getActivityInfo(it, 0).taskAffinity == context.applicationInfo.taskAffinity) { isPotentialMaliciousActivity(it) } else false } ?: false diff --git a/vector/src/main/java/im/vector/app/features/media/ImageContentRenderer.kt b/vector/src/main/java/im/vector/app/features/media/ImageContentRenderer.kt index daaf3a19ec..baad815df2 100644 --- a/vector/src/main/java/im/vector/app/features/media/ImageContentRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/media/ImageContentRenderer.kt @@ -38,7 +38,6 @@ import im.vector.app.core.glide.GlideRequest import im.vector.app.core.glide.GlideRequests import im.vector.app.core.ui.model.Size import im.vector.app.core.utils.DimensionConverter -import im.vector.app.features.settings.VectorPreferences import kotlinx.parcelize.Parcelize import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.content.ContentUrlResolver @@ -65,7 +64,6 @@ class ImageContentRenderer @Inject constructor( private val localFilesHelper: LocalFilesHelper, private val activeSessionHolder: ActiveSessionHolder, private val dimensionConverter: DimensionConverter, - private val vectorPreferences: VectorPreferences ) { @Parcelize @@ -85,6 +83,7 @@ class ImageContentRenderer @Inject constructor( enum class Mode { FULL_SIZE, + ANIMATED_THUMBNAIL, THUMBNAIL, STICKER } @@ -133,7 +132,7 @@ class ImageContentRenderer @Inject constructor( createGlideRequest(data, mode, imageView, size) .let { - if (vectorPreferences.autoplayAnimatedImages()) it + if (mode == Mode.ANIMATED_THUMBNAIL) it else it.dontAnimate() } .transform(cornerTransformation) @@ -231,6 +230,7 @@ class ImageContentRenderer @Inject constructor( val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver() val resolvedUrl = when (mode) { Mode.FULL_SIZE, + Mode.ANIMATED_THUMBNAIL, Mode.STICKER -> resolveUrl(data) Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, size.width, size.height, ContentUrlResolver.ThumbnailMethod.SCALE) } @@ -269,6 +269,7 @@ class ImageContentRenderer @Inject constructor( finalHeight = height finalWidth = width } + Mode.ANIMATED_THUMBNAIL, Mode.THUMBNAIL -> { finalHeight = min(maxImageWidth * height / width, maxImageHeight) finalWidth = finalHeight * width / height 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 90138fd495..4ee7da4b64 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 @@ -17,6 +17,7 @@ package im.vector.app.features.notifications import android.net.Uri import im.vector.app.R +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.extensions.takeAs import im.vector.app.core.resources.BuildMeta import im.vector.app.core.resources.StringProvider @@ -45,7 +46,6 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachme import org.matrix.android.sdk.api.session.room.sender.SenderInfo import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.getEditedEventId -import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.util.toMatrixItem import timber.log.Timber import java.util.UUID @@ -231,7 +231,7 @@ class NotifiableEventResolver @Inject constructor( private suspend fun TimelineEvent.downloadAndExportImage(session: Session): Uri? { return kotlin.runCatching { - getLastMessageContent()?.takeAs()?.let { imageMessage -> + getVectorLastMessageContent()?.takeAs()?.let { imageMessage -> val fileService = session.fileService() fileService.downloadFile(imageMessage) fileService.getTemporarySharableURI(imageMessage) diff --git a/vector/src/main/java/im/vector/app/features/pin/lockscreen/biometrics/BiometricHelper.kt b/vector/src/main/java/im/vector/app/features/pin/lockscreen/biometrics/BiometricHelper.kt index 026ee159ed..bf2075d3a8 100644 --- a/vector/src/main/java/im/vector/app/features/pin/lockscreen/biometrics/BiometricHelper.kt +++ b/vector/src/main/java/im/vector/app/features/pin/lockscreen/biometrics/BiometricHelper.kt @@ -20,7 +20,7 @@ import android.content.Context import android.os.Build import androidx.annotation.MainThread import androidx.annotation.VisibleForTesting -import androidx.annotation.VisibleForTesting.PRIVATE +import androidx.annotation.VisibleForTesting.Companion.PRIVATE import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK diff --git a/vector/src/main/java/im/vector/app/features/poll/create/CreatePollViewModel.kt b/vector/src/main/java/im/vector/app/features/poll/create/CreatePollViewModel.kt index ec064877a9..1b2f0d7d08 100644 --- a/vector/src/main/java/im/vector/app/features/poll/create/CreatePollViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/poll/create/CreatePollViewModel.kt @@ -22,6 +22,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.platform.VectorViewModel import im.vector.app.features.poll.PollMode import org.matrix.android.sdk.api.session.Session @@ -29,7 +30,6 @@ import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.room.getTimelineEvent import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.PollType -import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent class CreatePollViewModel @AssistedInject constructor( @Assisted private val initialState: CreatePollViewState, @@ -72,7 +72,7 @@ class CreatePollViewModel @AssistedInject constructor( private fun initializeEditedPoll(eventId: String) { val event = room.getTimelineEvent(eventId) ?: return - val content = event.getLastMessageContent() as? MessagePollContent ?: return + val content = event.getVectorLastMessageContent() as? MessagePollContent ?: return val pollCreationInfo = content.getBestPollCreationInfo() val pollType = pollCreationInfo?.kind ?: PollType.DISCLOSED_UNSTABLE diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileAction.kt b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileAction.kt index 22b040b4c0..44bac1c8a0 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileAction.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileAction.kt @@ -27,4 +27,5 @@ sealed class RoomProfileAction : VectorViewModelAction { object ShareRoomProfile : RoomProfileAction() object CreateShortcut : RoomProfileAction() object RestoreEncryptionState : RoomProfileAction() + data class SetEncryptToVerifiedDeviceOnly(val enabled: Boolean) : RoomProfileAction() } diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileController.kt b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileController.kt index 06f56bff89..eb43a345f2 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileController.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileController.kt @@ -27,6 +27,7 @@ import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.resources.StringProvider import im.vector.app.core.ui.list.genericFooterItem import im.vector.app.core.ui.list.genericPositiveButtonItem +import im.vector.app.features.form.formSwitchItem import im.vector.app.features.home.ShortcutCreator import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovementMethod @@ -66,6 +67,8 @@ class RoomProfileController @Inject constructor( fun onUrlInTopicLongClicked(url: String) fun doMigrateToVersion(newVersion: String) fun restoreEncryptionState() + fun setEncryptedToVerifiedDevicesOnly(enabled: Boolean) + fun openGlobalBlockSettings() } override fun buildModels(data: RoomProfileViewState?) { @@ -175,6 +178,53 @@ class RoomProfileController @Inject constructor( } buildEncryptionAction(data.actionPermissions, roomSummary) + if (roomSummary.isEncrypted && !encryptionMisconfigured) { + data.globalCryptoConfig.invoke()?.let { globalConfig -> + if (globalConfig.globalBlockUnverifiedDevices) { + genericFooterItem { + id("globalConfig") + centered(false) + text( + span { + +host.stringProvider.getString(R.string.room_settings_global_block_unverified_info_text) + apply { + if (data.unverifiedDevicesInTheRoom.invoke() == true) { + +"\n" + +host.stringProvider.getString(R.string.some_devices_will_not_be_able_to_decrypt) + } + } + }.toEpoxyCharSequence() + ) + itemClickAction { + host.callback?.openGlobalBlockSettings() + } + } + } else { + // per room setting is available + val shouldBlockUnverified = data.encryptToVerifiedDeviceOnly.invoke() + formSwitchItem { + id("send_to_unverified") + enabled(shouldBlockUnverified != null) + title(host.stringProvider.getString(R.string.encryption_never_send_to_unverified_devices_in_room)) + + switchChecked(shouldBlockUnverified ?: false) + + apply { + if (shouldBlockUnverified == true && data.unverifiedDevicesInTheRoom.invoke() == true) { + summary( + host.stringProvider.getString(R.string.some_devices_will_not_be_able_to_decrypt) + ) + } else { + summary(null) + } + } + listener { value -> + host.callback?.setEncryptedToVerifiedDevicesOnly(value) + } + } + } + } + } // More buildProfileSection(stringProvider.getString(R.string.room_profile_section_more)) buildProfileAction( diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt index 4135ab3d1c..f4394111ab 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt @@ -53,6 +53,7 @@ import im.vector.app.features.home.room.detail.RoomDetailPendingActionStore import im.vector.app.features.home.room.detail.upgrade.MigrateRoomBottomSheet import im.vector.app.features.home.room.list.actions.RoomListQuickActionsSharedAction import im.vector.app.features.home.room.list.actions.RoomListQuickActionsSharedActionViewModel +import im.vector.app.features.navigation.SettingsActivityPayload import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.parcelize.Parcelize @@ -346,6 +347,14 @@ class RoomProfileFragment : ) } + override fun setEncryptedToVerifiedDevicesOnly(enabled: Boolean) { + roomProfileViewModel.handle(RoomProfileAction.SetEncryptToVerifiedDeviceOnly(enabled)) + } + + override fun openGlobalBlockSettings() { + navigator.openSettings(requireContext(), SettingsActivityPayload.SecurityPrivacy) + } + private fun onAvatarClicked(view: View) = withState(roomProfileViewModel) { state -> state.roomSummary()?.toMatrixItem()?.let { matrixItem -> navigator.openBigImageViewer(requireActivity(), view, matrixItem) diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileViewModel.kt b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileViewModel.kt index 30664c5618..215a1e1e9c 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileViewModel.kt @@ -17,6 +17,7 @@ package im.vector.app.features.roomprofile +import androidx.lifecycle.asFlow import com.airbnb.mvrx.MavericksViewModelFactory import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -32,7 +33,11 @@ import im.vector.app.features.home.ShortcutCreator import im.vector.app.features.powerlevel.PowerLevelsFlowFactory import im.vector.app.features.session.coroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.matrix.android.sdk.api.query.QueryStringValue @@ -76,6 +81,45 @@ class RoomProfileViewModel @AssistedInject constructor( observeBannedRoomMembers(flowRoom) observePermissions() observePowerLevels() + observeCryptoSettings(flowRoom) + } + + private fun observeCryptoSettings(flowRoom: FlowRoom) { + val perRoomBlockStatus = session.cryptoService().getLiveBlockUnverifiedDevices(initialState.roomId) + .asFlow() + + perRoomBlockStatus + .execute { + copy(encryptToVerifiedDeviceOnly = it) + } + + val globalBlockStatus = session.cryptoService().getLiveGlobalCryptoConfig() + .asFlow() + + globalBlockStatus + .execute { + copy(globalCryptoConfig = it) + } + + perRoomBlockStatus.combine(globalBlockStatus) { perRoom, global -> + perRoom || global.globalBlockUnverifiedDevices + }.flatMapLatest { + if (it) { + flowRoom.liveRoomMembers(roomMemberQueryParams { memberships = Membership.activeMemberships() }) + .map { it.map { it.userId } } + .flatMapLatest { + session.cryptoService().getLiveCryptoDeviceInfo(it).asFlow() + } + } else { + flowOf(emptyList()) + } + }.map { + it.isNotEmpty() + }.execute { + copy( + unverifiedDevicesInTheRoom = it + ) + } } private fun observePowerLevels() { @@ -141,6 +185,7 @@ class RoomProfileViewModel @AssistedInject constructor( is RoomProfileAction.ShareRoomProfile -> handleShareRoomProfile() RoomProfileAction.CreateShortcut -> handleCreateShortcut() RoomProfileAction.RestoreEncryptionState -> restoreEncryptionState() + is RoomProfileAction.SetEncryptToVerifiedDeviceOnly -> setEncryptToVerifiedDeviceOnly(action.enabled) } } @@ -212,6 +257,12 @@ class RoomProfileViewModel @AssistedInject constructor( } } + private fun setEncryptToVerifiedDeviceOnly(enabled: Boolean) { + session.coroutineScope.launch { + session.cryptoService().setRoomBlockUnverifiedDevices(room.roomId, enabled) + } + } + private fun restoreEncryptionState() { _viewEvents.post(RoomProfileViewEvents.Loading()) session.coroutineScope.launch { diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileViewState.kt b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileViewState.kt index 87db15ea3b..5393ceb152 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileViewState.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileViewState.kt @@ -20,6 +20,7 @@ package im.vector.app.features.roomprofile import com.airbnb.mvrx.Async import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.Uninitialized +import org.matrix.android.sdk.api.session.crypto.GlobalCryptoConfig import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent @@ -35,7 +36,10 @@ data class RoomProfileViewState( val recommendedRoomVersion: String? = null, val canUpgradeRoom: Boolean = false, val isTombstoned: Boolean = false, - val canUpdateRoomState: Boolean = false + val canUpdateRoomState: Boolean = false, + val encryptToVerifiedDeviceOnly: Async = Uninitialized, + val globalCryptoConfig: Async = Uninitialized, + val unverifiedDevicesInTheRoom: Async = Uninitialized, ) : MavericksState { constructor(args: RoomProfileArgs) : this(roomId = args.roomId) diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/settings/RoomSettingsViewState.kt b/vector/src/main/java/im/vector/app/features/roomprofile/settings/RoomSettingsViewState.kt index 81e98335c0..10465b03ea 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/settings/RoomSettingsViewState.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/settings/RoomSettingsViewState.kt @@ -45,7 +45,7 @@ data class RoomSettingsViewState( val showSaveAction: Boolean = false, val actionPermissions: ActionPermissions = ActionPermissions(), val supportsRestricted: Boolean = false, - val canUpgradeToRestricted: Boolean = false + val canUpgradeToRestricted: Boolean = false, ) : MavericksState { constructor(args: RoomProfileArgs) : this(roomId = args.roomId) 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 16d3210b45..1cbb8509df 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 @@ -71,6 +71,7 @@ class VectorPreferences @Inject constructor( const val SETTINGS_LABS_PREFERENCE_KEY = "SETTINGS_LABS_PREFERENCE_KEY" const val SETTINGS_LABS_NEW_APP_LAYOUT_KEY = "SETTINGS_LABS_NEW_APP_LAYOUT_KEY" const val SETTINGS_LABS_DEFERRED_DM_KEY = "SETTINGS_LABS_DEFERRED_DM_KEY" + const val SETTINGS_LABS_RICH_TEXT_EDITOR_KEY = "SETTINGS_LABS_RICH_TEXT_EDITOR_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" @@ -87,6 +88,7 @@ class VectorPreferences @Inject constructor( const val SETTINGS_INTEGRATION_MANAGER_UI_URL_KEY = "SETTINGS_INTEGRATION_MANAGER_UI_URL_KEY" const val SETTINGS_SECURE_MESSAGE_RECOVERY_PREFERENCE_KEY = "SETTINGS_SECURE_MESSAGE_RECOVERY_PREFERENCE_KEY" const val SETTINGS_PERSISTED_SPACE_BACKSTACK = "SETTINGS_PERSISTED_SPACE_BACKSTACK" + const val SETTINGS_SECURITY_INCOGNITO_KEYBOARD_PREFERENCE_KEY = "SETTINGS_SECURITY_INCOGNITO_KEYBOARD_PREFERENCE_KEY" const val SETTINGS_CRYPTOGRAPHY_HS_ADMIN_DISABLED_E2E_DEFAULT = "SETTINGS_CRYPTOGRAPHY_HS_ADMIN_DISABLED_E2E_DEFAULT" // const val SETTINGS_SECURE_BACKUP_RESET_PREFERENCE_KEY = "SETTINGS_SECURE_BACKUP_RESET_PREFERENCE_KEY" @@ -288,6 +290,7 @@ class VectorPreferences @Inject constructor( SETTINGS_USE_RAGE_SHAKE_KEY, SETTINGS_SECURITY_USE_FLAG_SECURE, + SETTINGS_SECURITY_INCOGNITO_KEYBOARD_PREFERENCE_KEY, ShortcutsHandler.SHARED_PREF_KEY, ) @@ -969,6 +972,11 @@ class VectorPreferences @Inject constructor( return defaultPrefs.getBoolean(SETTINGS_SECURITY_USE_FLAG_SECURE, false) } + /** Whether the keyboard should disable personalized learning. */ + fun useIncognitoKeyboard(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_SECURITY_INCOGNITO_KEYBOARD_PREFERENCE_KEY, false) + } + /** * The user enable protecting app access with pin code. * Currently we use the pin code store to know if the pin is enabled, so this is not used @@ -1175,4 +1183,8 @@ class VectorPreferences @Inject constructor( fun showLiveSenderInfo(): Boolean { return defaultPrefs.getBoolean(SETTINGS_TIMELINE_SHOW_LIVE_SENDER_INFO, getDefault(R.bool.settings_timeline_show_live_sender_info_default)) } + + fun isRichTextEditorEnabled(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_LABS_RICH_TEXT_EDITOR_KEY, getDefault(R.bool.settings_labs_rich_text_editor_default)) + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsSecurityPrivacyFragment.kt b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsSecurityPrivacyFragment.kt index 5cbdf114a5..87f5af67eb 100644 --- a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsSecurityPrivacyFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsSecurityPrivacyFragment.kt @@ -20,6 +20,7 @@ package im.vector.app.features.settings import android.app.Activity import android.content.Intent import android.net.Uri +import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -160,6 +161,10 @@ class VectorSettingsSecurityPrivacyFragment : findPreference("SETTINGS_USER_ANALYTICS_CONSENT_KEY")!! } + private val incognitoKeyboardPref by lazy { + findPreference(VectorPreferences.SETTINGS_SECURITY_INCOGNITO_KEYBOARD_PREFERENCE_KEY)!! + } + override fun onCreateRecyclerView(inflater: LayoutInflater, parent: ViewGroup, savedInstanceState: Bundle?): RecyclerView { return super.onCreateRecyclerView(inflater, parent, savedInstanceState).also { // Insert animation are really annoying the first time the list is shown @@ -275,6 +280,9 @@ class VectorSettingsSecurityPrivacyFragment : // Analytics setUpAnalytics() + // Incognito Keyboard + setUpIncognitoKeyboard() + // Pin code openPinCodeSettingsPref.setOnPreferenceClickListener { openPinCodePreferenceScreen() @@ -337,6 +345,10 @@ class VectorSettingsSecurityPrivacyFragment : } } + private fun setUpIncognitoKeyboard() { + incognitoKeyboardPref.isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + } + // Todo this should be refactored and use same state as 4S section private fun refreshXSigningStatus() { val crossSigningKeys = session.cryptoService().crossSigningService().getMyCrossSigningKeys() diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt index 937d41e091..48f66cfc75 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt @@ -21,7 +21,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isVisible import com.airbnb.mvrx.Success @@ -34,6 +33,7 @@ import im.vector.app.core.dialogs.ManuallyVerifyDialog import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.DrawableProvider +import im.vector.app.core.resources.StringProvider import im.vector.app.databinding.FragmentSettingsDevicesBinding import im.vector.app.features.VectorFeatures import im.vector.app.features.crypto.recover.SetupMode @@ -67,6 +67,8 @@ class VectorSettingsDevicesFragment : @Inject lateinit var vectorFeatures: VectorFeatures + @Inject lateinit var stringProvider: StringProvider + private val viewModel: DevicesViewModel by fragmentViewModel() override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSettingsDevicesBinding { @@ -87,7 +89,6 @@ class VectorSettingsDevicesFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - initLearnMoreButtons() initWaitingView() initOtherSessionsView() initSecurityRecommendationsView() @@ -196,12 +197,6 @@ class VectorSettingsDevicesFragment : super.onDestroyView() } - private fun initLearnMoreButtons() { - views.deviceListHeaderOtherSessions.onLearnMoreClickListener = { - Toast.makeText(context, "Learn more other", Toast.LENGTH_LONG).show() - } - } - private fun cleanUpLearnMoreButtonsListeners() { views.deviceListHeaderOtherSessions.onLearnMoreClickListener = null } @@ -286,7 +281,7 @@ class VectorSettingsDevicesFragment : isCurrentSession = true, deviceFullInfo = it ) - views.deviceListCurrentSession.render(viewState, dateFormatter, drawableProvider, colorProvider) + views.deviceListCurrentSession.render(viewState, dateFormatter, drawableProvider, colorProvider, stringProvider) views.deviceListCurrentSession.debouncedClicks { currentDeviceInfo.deviceInfo.deviceId?.let { deviceId -> navigateToSessionOverview(deviceId) } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt index 283e64fffe..f83f069a9f 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt @@ -59,6 +59,8 @@ abstract class OtherSessionItem : VectorEpoxyModel(R.la @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var clickListener: ClickListener? = null + private val setDeviceTypeIconUseCase = SetDeviceTypeIconUseCase() + override fun bind(holder: Holder) { super.bind(holder) holder.view.onClick(clickListener) @@ -66,24 +68,7 @@ abstract class OtherSessionItem : VectorEpoxyModel(R.la holder.view.isClickable = false } - when (deviceType) { - DeviceType.MOBILE -> { - holder.otherSessionDeviceTypeImageView.setImageResource(R.drawable.ic_device_type_mobile) - holder.otherSessionDeviceTypeImageView.contentDescription = stringProvider.getString(R.string.a11y_device_manager_device_type_mobile) - } - DeviceType.WEB -> { - holder.otherSessionDeviceTypeImageView.setImageResource(R.drawable.ic_device_type_web) - holder.otherSessionDeviceTypeImageView.contentDescription = stringProvider.getString(R.string.a11y_device_manager_device_type_web) - } - DeviceType.DESKTOP -> { - holder.otherSessionDeviceTypeImageView.setImageResource(R.drawable.ic_device_type_desktop) - holder.otherSessionDeviceTypeImageView.contentDescription = stringProvider.getString(R.string.a11y_device_manager_device_type_desktop) - } - DeviceType.UNKNOWN -> { - holder.otherSessionDeviceTypeImageView.setImageResource(R.drawable.ic_device_type_unknown) - holder.otherSessionDeviceTypeImageView.contentDescription = stringProvider.getString(R.string.a11y_device_manager_device_type_unknown) - } - } + setDeviceTypeIconUseCase.execute(deviceType, holder.otherSessionDeviceTypeImageView, stringProvider) holder.otherSessionVerificationStatusImageView.render(roomEncryptionTrustLevel) holder.otherSessionNameTextView.text = sessionName holder.otherSessionDescriptionTextView.text = sessionDescription diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt index afa640fb9a..b0ba8baa1a 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt @@ -64,7 +64,7 @@ class OtherSessionsController @Inject constructor( otherSessionItem { id(device.deviceInfo.deviceId) - deviceType(DeviceType.UNKNOWN) // TODO. We don't have this info yet. Update accordingly. + deviceType(device.deviceExtendedInfo.deviceType) roomEncryptionTrustLevel(device.roomEncryptionTrustLevel) sessionName(device.deviceInfo.displayName) sessionDescription(description) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoView.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoView.kt index 340a4f3c3a..6f6c5b24e2 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoView.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoView.kt @@ -28,6 +28,7 @@ import im.vector.app.core.extensions.setTextOrHide import im.vector.app.core.extensions.setTextWithColoredPart import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.DrawableProvider +import im.vector.app.core.resources.StringProvider import im.vector.app.databinding.ViewSessionInfoBinding import im.vector.app.features.themes.ThemeUtils import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo @@ -51,13 +52,20 @@ class SessionInfoView @JvmOverloads constructor( val viewDetailsButton = views.sessionInfoViewDetailsButton val viewVerifyButton = views.sessionInfoVerifySessionButton + private val setDeviceTypeIconUseCase = SetDeviceTypeIconUseCase() + fun render( sessionInfoViewState: SessionInfoViewState, dateFormatter: VectorDateFormatter, drawableProvider: DrawableProvider, colorProvider: ColorProvider, + stringProvider: StringProvider, ) { - renderDeviceInfo(sessionInfoViewState.deviceFullInfo.deviceInfo.displayName.orEmpty()) + renderDeviceInfo( + sessionInfoViewState.deviceFullInfo.deviceInfo.displayName.orEmpty(), + sessionInfoViewState.deviceFullInfo.deviceExtendedInfo.deviceType, + stringProvider, + ) renderVerificationStatus( sessionInfoViewState.deviceFullInfo.roomEncryptionTrustLevel, sessionInfoViewState.isCurrentSession, @@ -134,10 +142,8 @@ class SessionInfoView @JvmOverloads constructor( views.sessionInfoVerifySessionButton.isVisible = isVerifyButtonVisible } - // TODO. We don't have this info yet. Update later accordingly. - private fun renderDeviceInfo(sessionName: String) { - views.sessionInfoDeviceTypeImageView.setImageResource(R.drawable.ic_device_type_mobile) - views.sessionInfoDeviceTypeImageView.contentDescription = context.getString(R.string.a11y_device_manager_device_type_mobile) + private fun renderDeviceInfo(sessionName: String, deviceType: DeviceType, stringProvider: StringProvider) { + setDeviceTypeIconUseCase.execute(deviceType, views.sessionInfoDeviceTypeImageView, stringProvider) views.sessionInfoNameTextView.text = sessionName } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionsListHeaderView.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionsListHeaderView.kt index c94dd469da..0660e7d642 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionsListHeaderView.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionsListHeaderView.kt @@ -65,29 +65,29 @@ class SessionsListHeaderView @JvmOverloads constructor( return } - val showLearnMore = typedArray.getBoolean(R.styleable.SessionsListHeaderView_sessionListHeaderShowLearnMore, true) - val learnMore = context.getString(R.string.action_learn_more) - val fullDescription = if (showLearnMore) { - buildString { - append(description) - append(" ") - append(learnMore) - } + val hasLearnMoreLink = typedArray.getBoolean(R.styleable.SessionsListHeaderView_sessionsListHeaderHasLearnMoreLink, true) + if (hasLearnMoreLink) { + setDescriptionWithLearnMore(description) } else { - description + binding.sessionsListHeaderDescription.text = description } binding.sessionsListHeaderDescription.isVisible = true - if (showLearnMore) { - binding.sessionsListHeaderDescription.setTextWithColoredPart( - fullText = fullDescription, - coloredPart = learnMore, - underline = false - ) { - onLearnMoreClickListener?.invoke() - } - } else { - binding.sessionsListHeaderDescription.text = fullDescription + } + + private fun setDescriptionWithLearnMore(description: String) { + val learnMore = context.getString(R.string.action_learn_more) + val fullDescription = buildString { + append(description) + append(" ") + append(learnMore) + } + binding.sessionsListHeaderDescription.setTextWithColoredPart( + fullText = fullDescription, + coloredPart = learnMore, + underline = false + ) { + onLearnMoreClickListener?.invoke() } } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SetDeviceTypeIconUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SetDeviceTypeIconUseCase.kt new file mode 100644 index 0000000000..49ff46779e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SetDeviceTypeIconUseCase.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2.list + +import android.widget.ImageView +import im.vector.app.R +import im.vector.app.core.resources.StringProvider + +class SetDeviceTypeIconUseCase { + + fun execute(deviceType: DeviceType, imageView: ImageView, stringProvider: StringProvider) { + when (deviceType) { + DeviceType.MOBILE -> { + imageView.setImageResource(R.drawable.ic_device_type_mobile) + imageView.contentDescription = stringProvider.getString(R.string.a11y_device_manager_device_type_mobile) + } + DeviceType.WEB -> { + imageView.setImageResource(R.drawable.ic_device_type_web) + imageView.contentDescription = stringProvider.getString(R.string.a11y_device_manager_device_type_web) + } + DeviceType.DESKTOP -> { + imageView.setImageResource(R.drawable.ic_device_type_desktop) + imageView.contentDescription = stringProvider.getString(R.string.a11y_device_manager_device_type_desktop) + } + DeviceType.UNKNOWN -> { + imageView.setImageResource(R.drawable.ic_device_type_unknown) + imageView.contentDescription = stringProvider.getString(R.string.a11y_device_manager_device_type_unknown) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/more/SessionLearnMoreBottomSheet.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/more/SessionLearnMoreBottomSheet.kt new file mode 100644 index 0000000000..22ca06eb1e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/more/SessionLearnMoreBottomSheet.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2.more + +import android.os.Bundle +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.FragmentManager +import com.airbnb.mvrx.fragmentViewModel +import com.airbnb.mvrx.withState +import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment +import im.vector.app.databinding.BottomSheetSessionLearnMoreBinding +import kotlinx.parcelize.Parcelize + +@AndroidEntryPoint +class SessionLearnMoreBottomSheet : VectorBaseBottomSheetDialogFragment() { + + @Parcelize + data class Args( + val title: String, + val description: String, + ) : Parcelable + + private val viewModel: SessionLearnMoreViewModel by fragmentViewModel() + + override val showExpanded = true + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): BottomSheetSessionLearnMoreBinding { + return BottomSheetSessionLearnMoreBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initCloseButton() + } + + private fun initCloseButton() { + views.bottomSheetSessionLearnMoreCloseButton.debouncedClicks { + dismiss() + } + } + + override fun invalidate() = withState(viewModel) { viewState -> + super.invalidate() + views.bottomSheetSessionLearnMoreTitle.text = viewState.title + views.bottomSheetSessionLearnMoreDescription.text = viewState.description + } + + companion object { + + fun show(fragmentManager: FragmentManager, args: Args) { + val bottomSheet = SessionLearnMoreBottomSheet() + bottomSheet.isCancelable = true + bottomSheet.setArguments(args) + bottomSheet.show(fragmentManager, "SessionLearnMoreBottomSheet") + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/more/SessionLearnMoreViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/more/SessionLearnMoreViewModel.kt new file mode 100644 index 0000000000..09ca2df15d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/more/SessionLearnMoreViewModel.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2.more + +import com.airbnb.mvrx.MavericksViewModelFactory +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.core.di.MavericksAssistedViewModelFactory +import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.core.platform.EmptyAction +import im.vector.app.core.platform.EmptyViewEvents +import im.vector.app.core.platform.VectorViewModel + +class SessionLearnMoreViewModel @AssistedInject constructor( + @Assisted initialState: SessionLearnMoreViewState, +) : VectorViewModel(initialState) { + + @AssistedFactory + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: SessionLearnMoreViewState): SessionLearnMoreViewModel + } + + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() + + override fun handle(action: EmptyAction) { + // do nothing + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/more/SessionLearnMoreViewState.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/more/SessionLearnMoreViewState.kt new file mode 100644 index 0000000000..cade2ce861 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/more/SessionLearnMoreViewState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2.more + +import com.airbnb.mvrx.MavericksState + +data class SessionLearnMoreViewState( + val title: String, + val description: String, +) : MavericksState { + constructor(args: SessionLearnMoreBottomSheet.Args) : this( + title = args.title, + description = args.description, + ) +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt index 5734b04089..610776e22e 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt @@ -20,6 +20,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.annotation.StringRes import androidx.core.view.isVisible import com.airbnb.mvrx.Success import com.airbnb.mvrx.args @@ -37,6 +38,7 @@ import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterBott import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType import im.vector.app.features.settings.devices.v2.list.OtherSessionsView import im.vector.app.features.settings.devices.v2.list.SESSION_IS_MARKED_AS_INACTIVE_AFTER_DAYS +import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreBottomSheet import im.vector.app.features.themes.ThemeUtils import javax.inject.Inject @@ -121,6 +123,7 @@ class OtherSessionsFragment : ) ) views.otherSessionsNotFoundTextView.text = getString(R.string.device_manager_other_sessions_no_verified_sessions_found) + updateSecurityLearnMoreButton(R.string.device_manager_learn_more_sessions_verified_title, R.string.device_manager_learn_more_sessions_verified) } DeviceManagerFilterType.UNVERIFIED -> { views.otherSessionsSecurityRecommendationView.render( @@ -132,6 +135,10 @@ class OtherSessionsFragment : ) ) views.otherSessionsNotFoundTextView.text = getString(R.string.device_manager_other_sessions_no_unverified_sessions_found) + updateSecurityLearnMoreButton( + R.string.device_manager_learn_more_sessions_unverified_title, + R.string.device_manager_learn_more_sessions_unverified + ) } DeviceManagerFilterType.INACTIVE -> { views.otherSessionsSecurityRecommendationView.render( @@ -147,8 +154,10 @@ class OtherSessionsFragment : ) ) views.otherSessionsNotFoundTextView.text = getString(R.string.device_manager_other_sessions_no_inactive_sessions_found) + updateSecurityLearnMoreButton(R.string.device_manager_learn_more_sessions_inactive_title, R.string.device_manager_learn_more_sessions_inactive) + } + DeviceManagerFilterType.ALL_SESSIONS -> { /* NOOP. View is not visible */ } - DeviceManagerFilterType.ALL_SESSIONS -> { /* NOOP. View is not visible */ } } if (devices.isNullOrEmpty()) { @@ -161,6 +170,26 @@ class OtherSessionsFragment : } } + private fun updateSecurityLearnMoreButton( + @StringRes titleResId: Int, + @StringRes descriptionResId: Int, + ) { + views.otherSessionsSecurityRecommendationView.onLearnMoreClickListener = { + showLearnMoreInfo(titleResId, getString(descriptionResId)) + } + } + + private fun showLearnMoreInfo( + @StringRes titleResId: Int, + description: String, + ) { + val args = SessionLearnMoreBottomSheet.Args( + title = getString(titleResId), + description = description, + ) + SessionLearnMoreBottomSheet.show(childFragmentManager, args) + } + override fun onOtherSessionClicked(deviceId: String) { viewNavigator.navigateToSessionOverview( context = requireActivity(), diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewAction.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewAction.kt index 42e79ac89f..9a92d5b629 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewAction.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewAction.kt @@ -19,9 +19,14 @@ package im.vector.app.features.settings.devices.v2.overview import im.vector.app.core.platform.VectorViewModelAction sealed class SessionOverviewAction : VectorViewModelAction { + object VerifySession : SessionOverviewAction() object SignoutOtherSession : SessionOverviewAction() object SsoAuthDone : SessionOverviewAction() data class PasswordAuthDone(val password: String) : SessionOverviewAction() object ReAuthCancelled : SessionOverviewAction() + data class TogglePushNotifications( + val deviceId: String, + val enabled: Boolean, + ) : SessionOverviewAction() } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewEntrySwitchView.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewEntrySwitchView.kt new file mode 100644 index 0000000000..bbefd31dfe --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewEntrySwitchView.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2.overview + +import android.content.Context +import android.content.res.TypedArray +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.CompoundButton +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.res.use +import im.vector.app.R +import im.vector.app.core.extensions.setAttributeBackground +import im.vector.app.databinding.ViewSessionOverviewEntrySwitchBinding + +class SessionOverviewEntrySwitchView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private val binding = ViewSessionOverviewEntrySwitchBinding.inflate( + LayoutInflater.from(context), + this + ) + + init { + initBackground() + context.obtainStyledAttributes( + attrs, + R.styleable.SessionOverviewEntrySwitchView, + 0, + 0 + ).use { + setTitle(it) + setDescription(it) + setSwitchedEnabled(it) + } + } + + private fun initBackground() { + binding.root.setAttributeBackground(android.R.attr.selectableItemBackground) + } + + private fun setTitle(typedArray: TypedArray) { + val title = typedArray.getString(R.styleable.SessionOverviewEntrySwitchView_sessionOverviewEntrySwitchTitle) + binding.sessionsOverviewEntryTitle.text = title + } + + private fun setDescription(typedArray: TypedArray) { + val description = typedArray.getString(R.styleable.SessionOverviewEntrySwitchView_sessionOverviewEntrySwitchDescription) + binding.sessionsOverviewEntryDescription.text = description + } + + private fun setSwitchedEnabled(typedArray: TypedArray) { + val enabled = typedArray.getBoolean(R.styleable.SessionOverviewEntrySwitchView_sessionOverviewEntrySwitchEnabled, true) + binding.sessionsOverviewEntrySwitch.isChecked = enabled + } + + fun setChecked(checked: Boolean) { + binding.sessionsOverviewEntrySwitch.isChecked = checked + } + + fun setOnCheckedChangeListener(listener: CompoundButton.OnCheckedChangeListener?) { + binding.sessionsOverviewEntrySwitch.setOnCheckedChangeListener(listener) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + binding.sessionsOverviewEntrySwitch.setOnCheckedChangeListener(null) + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt index 4af4913183..7510880087 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt @@ -24,6 +24,7 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.isGone import androidx.core.view.isVisible import com.airbnb.mvrx.Success import com.airbnb.mvrx.fragmentViewModel @@ -37,13 +38,18 @@ import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.DrawableProvider +import im.vector.app.core.resources.StringProvider import im.vector.app.databinding.FragmentSessionOverviewBinding import im.vector.app.features.auth.ReAuthActivity import im.vector.app.features.crypto.recover.SetupMode +import im.vector.app.features.settings.devices.v2.DeviceFullInfo import im.vector.app.features.settings.devices.v2.list.SessionInfoViewState +import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreBottomSheet import im.vector.app.features.workers.signout.SignOutUiWorker import org.matrix.android.sdk.api.auth.data.LoginFlowTypes import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel +import org.matrix.android.sdk.api.session.pushers.Pusher import javax.inject.Inject /** @@ -62,6 +68,8 @@ class SessionOverviewFragment : @Inject lateinit var colorProvider: ColorProvider + @Inject lateinit var stringProvider: StringProvider + private val viewModel: SessionOverviewViewModel by fragmentViewModel() override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSessionOverviewBinding { @@ -169,9 +177,14 @@ class SessionOverviewFragment : override fun invalidate() = withState(viewModel) { state -> updateToolbar(state) - updateEntryDetails(state.deviceId) updateSessionInfo(state) updateLoading(state.isLoading) + updatePushNotificationToggle(state.deviceId, state.pushers.invoke().orEmpty()) + if (state.deviceInfo is Success) { + renderSessionInfo(state.isCurrentSessionTrusted, state.deviceInfo.invoke()) + } else { + hideSessionInfo() + } } private fun updateToolbar(viewState: SessionOverviewViewState) { @@ -184,12 +197,6 @@ class SessionOverviewFragment : } } - private fun updateEntryDetails(deviceId: String) { - views.sessionOverviewEntryDetails.setOnClickListener { - viewNavigator.goToSessionDetails(requireContext(), deviceId) - } - } - private fun updateSessionInfo(viewState: SessionOverviewViewState) { if (viewState.deviceInfo is Success) { views.sessionOverviewInfo.isVisible = true @@ -203,12 +210,44 @@ class SessionOverviewFragment : isLearnMoreLinkVisible = true, isLastSeenDetailsVisible = true, ) - views.sessionOverviewInfo.render(infoViewState, dateFormatter, drawableProvider, colorProvider) + views.sessionOverviewInfo.render(infoViewState, dateFormatter, drawableProvider, colorProvider, stringProvider) + views.sessionOverviewInfo.onLearnMoreClickListener = { + showLearnMoreInfoVerificationStatus(deviceInfo.roomEncryptionTrustLevel == RoomEncryptionTrustLevel.Trusted) + } } else { views.sessionOverviewInfo.isVisible = false } } + private fun updatePushNotificationToggle(deviceId: String, pushers: List) { + views.sessionOverviewPushNotifications.apply { + if (pushers.isEmpty()) { + isVisible = false + } else { + val allPushersAreEnabled = pushers.all { it.enabled } + setOnCheckedChangeListener(null) + setChecked(allPushersAreEnabled) + post { + setOnCheckedChangeListener { _, isChecked -> + viewModel.handle(SessionOverviewAction.TogglePushNotifications(deviceId, isChecked)) + } + } + } + } + } + + private fun renderSessionInfo(isCurrentSession: Boolean, deviceFullInfo: DeviceFullInfo) { + views.sessionOverviewInfo.isVisible = true + val viewState = SessionInfoViewState( + isCurrentSession = isCurrentSession, + deviceFullInfo = deviceFullInfo, + isDetailsButtonVisible = false, + isLearnMoreLinkVisible = true, + isLastSeenDetailsVisible = true, + ) + views.sessionOverviewInfo.render(viewState, dateFormatter, drawableProvider, colorProvider, stringProvider) + } + private fun updateLoading(isLoading: Boolean) { if (isLoading) { showLoading(null) @@ -249,4 +288,26 @@ class SessionOverviewFragment : reAuthActivityResultLauncher.launch(intent) } } + + private fun showLearnMoreInfoVerificationStatus(isVerified: Boolean) { + val titleResId = if (isVerified) { + R.string.device_manager_verification_status_verified + } else { + R.string.device_manager_verification_status_unverified + } + val descriptionResId = if (isVerified) { + R.string.device_manager_learn_more_sessions_verified + } else { + R.string.device_manager_learn_more_sessions_unverified + } + val args = SessionLearnMoreBottomSheet.Args( + title = getString(titleResId), + description = getString(descriptionResId), + ) + SessionLearnMoreBottomSheet.show(childFragmentManager, args) + } + + private fun hideSessionInfo() { + views.sessionOverviewInfo.isGone = true + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt index bd5c7725eb..a7b0435e29 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt @@ -43,14 +43,17 @@ import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.failure.Failure +import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth +import org.matrix.android.sdk.flow.flow import timber.log.Timber import javax.net.ssl.HttpsURLConnection import kotlin.coroutines.Continuation class SessionOverviewViewModel @AssistedInject constructor( @Assisted val initialState: SessionOverviewViewState, + private val session: Session, private val stringProvider: StringProvider, private val getDeviceFullInfoUseCase: GetDeviceFullInfoUseCase, private val checkIfCurrentSessionCanBeVerifiedUseCase: CheckIfCurrentSessionCanBeVerifiedUseCase, @@ -73,6 +76,7 @@ class SessionOverviewViewModel @AssistedInject constructor( init { observeSessionInfo(initialState.deviceId) observeCurrentSessionInfo() + observePushers(initialState.deviceId) } private fun observeSessionInfo(deviceId: String) { @@ -94,6 +98,13 @@ class SessionOverviewViewModel @AssistedInject constructor( } } + private fun observePushers(deviceId: String) { + session.flow() + .livePushers() + .map { it.filter { pusher -> pusher.deviceId == deviceId } } + .execute { copy(pushers = it) } + } + override fun handle(action: SessionOverviewAction) { when (action) { is SessionOverviewAction.VerifySession -> handleVerifySessionAction() @@ -101,6 +112,7 @@ class SessionOverviewViewModel @AssistedInject constructor( SessionOverviewAction.SsoAuthDone -> handleSsoAuthDone() is SessionOverviewAction.PasswordAuthDone -> handlePasswordAuthDone(action) SessionOverviewAction.ReAuthCancelled -> handleReAuthCancelled() + is SessionOverviewAction.TogglePushNotifications -> handleTogglePusherAction(action) } } @@ -198,4 +210,13 @@ class SessionOverviewViewModel @AssistedInject constructor( private fun handleReAuthCancelled() { pendingAuthHandler.reAuthCancelled() } + + private fun handleTogglePusherAction(action: SessionOverviewAction.TogglePushNotifications) { + viewModelScope.launch { + val devicePushers = awaitState().pushers.invoke()?.filter { it.deviceId == action.deviceId } + devicePushers?.forEach { pusher -> + session.pushersService().togglePusher(pusher, action.enabled) + } + } + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewState.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewState.kt index 07423888b5..c2d4a858b3 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewState.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewState.kt @@ -20,12 +20,14 @@ import com.airbnb.mvrx.Async import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.Uninitialized import im.vector.app.features.settings.devices.v2.DeviceFullInfo +import org.matrix.android.sdk.api.session.pushers.Pusher data class SessionOverviewViewState( val deviceId: String, val isCurrentSessionTrusted: Boolean = false, val deviceInfo: Async = Uninitialized, val isLoading: Boolean = false, + val pushers: Async> = Uninitialized, ) : MavericksState { constructor(args: SessionOverviewArgs) : this( deviceId = args.deviceId diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/rename/RenameSessionFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/rename/RenameSessionFragment.kt index df92bee100..2f671492e3 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/rename/RenameSessionFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/rename/RenameSessionFragment.kt @@ -24,9 +24,11 @@ import androidx.core.widget.doOnTextChanged import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.R import im.vector.app.core.extensions.showKeyboard import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.databinding.FragmentSessionRenameBinding +import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreBottomSheet import javax.inject.Inject /** @@ -51,6 +53,7 @@ class RenameSessionFragment : initEditText() initSaveButton() initWithLastEditedName() + initInfoView() } private fun initToolbar() { @@ -75,6 +78,20 @@ class RenameSessionFragment : viewModel.handle(RenameSessionAction.InitWithLastEditedName) } + private fun initInfoView() { + views.renameSessionInfo.onLearnMoreClickListener = { + showLearnMoreInfo() + } + } + + private fun showLearnMoreInfo() { + val args = SessionLearnMoreBottomSheet.Args( + title = getString(R.string.device_manager_learn_more_session_rename_title), + description = getString(R.string.device_manager_learn_more_session_rename), + ) + SessionLearnMoreBottomSheet.show(childFragmentManager, args) + } + private fun observeViewEvents() { viewModel.observeViewEvents { when (it) { diff --git a/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationsTroubleshootFragment.kt b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationsTroubleshootFragment.kt index 137f1c8722..9fc55d14aa 100644 --- a/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationsTroubleshootFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationsTroubleshootFragment.kt @@ -65,7 +65,7 @@ class VectorSettingsNotificationsTroubleshootFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val layoutManager = LinearLayoutManager(context) + val layoutManager = LinearLayoutManager(requireContext()) views.troubleshootTestRecyclerView.layoutManager = layoutManager val dividerItemDecoration = DividerItemDecoration(view.context, layoutManager.orientation) diff --git a/vector/src/main/java/im/vector/app/features/settings/push/PushGatewayItem.kt b/vector/src/main/java/im/vector/app/features/settings/push/PushGatewayItem.kt index 5ef6e02330..4fa1e422a1 100644 --- a/vector/src/main/java/im/vector/app/features/settings/push/PushGatewayItem.kt +++ b/vector/src/main/java/im/vector/app/features/settings/push/PushGatewayItem.kt @@ -50,6 +50,8 @@ abstract class PushGatewayItem : VectorEpoxyModel(R.layo holder.format.setTextOrHide(pusher.data.format, hideWhenBlank = true, holder.formatTitle) holder.profileTag.setTextOrHide(pusher.profileTag, hideWhenBlank = true, holder.profileTagTitle) holder.deviceName.text = pusher.deviceDisplayName + holder.deviceId.text = pusher.deviceId ?: "null" + holder.enabled.text = pusher.enabled.toString() holder.removeButton.setOnClickListener { interactions.onRemovePushTapped(pusher) } @@ -59,10 +61,12 @@ abstract class PushGatewayItem : VectorEpoxyModel(R.layo val kind by bind(R.id.pushGatewayKind) val pushKey by bind(R.id.pushGatewayKeyValue) val deviceName by bind(R.id.pushGatewayDeviceNameValue) + val deviceId by bind(R.id.pushGatewayDeviceIdValue) val formatTitle by bind(R.id.pushGatewayFormat) val format by bind(R.id.pushGatewayFormatValue) val profileTagTitle by bind(R.id.pushGatewayProfileTag) val profileTag by bind(R.id.pushGatewayProfileTagValue) + val enabled by bind(R.id.pushGatewayEnabledValue) val urlTitle by bind(R.id.pushGatewayURL) val url by bind(R.id.pushGatewayURLValue) val appName by bind(R.id.pushGatewayAppNameValue) diff --git a/vector/src/main/java/im/vector/app/features/spaces/NewSpaceSummaryController.kt b/vector/src/main/java/im/vector/app/features/spaces/NewSpaceSummaryController.kt index 5061eb4036..199169484c 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/NewSpaceSummaryController.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/NewSpaceSummaryController.kt @@ -72,7 +72,7 @@ class NewSpaceSummaryController @Inject constructor( text(host.stringProvider.getString(R.string.all_chats)) selected(selected) countState(UnreadCounterBadgeView.State.Count(homeCount.totalCount, homeCount.isHighlight)) - listener { host.callback?.onSpaceSelected(null) } + listener { host.callback?.onSpaceSelected(null, isSubSpace = false) } } } @@ -99,7 +99,7 @@ class NewSpaceSummaryController @Inject constructor( hasChildren(hasChildren) matrixItem(spaceSummary.toMatrixItem()) onLongClickListener { host.callback?.onSpaceSettings(spaceSummary) } - onSpaceSelectedListener { host.callback?.onSpaceSelected(spaceSummary) } + onSpaceSelectedListener { host.callback?.onSpaceSelected(spaceSummary, isSubSpace = false) } onToggleExpandListener { host.callback?.onToggleExpand(spaceSummary) } selected(isSelected) } @@ -140,7 +140,7 @@ class NewSpaceSummaryController @Inject constructor( indent(depth) matrixItem(childSummary.toMatrixItem()) onLongClickListener { host.callback?.onSpaceSettings(childSummary) } - onSubSpaceSelectedListener { host.callback?.onSpaceSelected(childSummary) } + onSubSpaceSelectedListener { host.callback?.onSpaceSelected(childSummary, isSubSpace = true) } onToggleExpandListener { host.callback?.onToggleExpand(childSummary) } selected(isSelected) } @@ -184,8 +184,10 @@ class NewSpaceSummaryController @Inject constructor( } } + /** + * This is a full duplicate of [SpaceSummaryController.Callback]. We need to merge them ASAP*/ interface Callback { - fun onSpaceSelected(spaceSummary: RoomSummary?) + fun onSpaceSelected(spaceSummary: RoomSummary?, isSubSpace: Boolean) fun onSpaceInviteSelected(spaceSummary: RoomSummary) fun onSpaceSettings(spaceSummary: RoomSummary) fun onToggleExpand(spaceSummary: RoomSummary) diff --git a/vector/src/main/java/im/vector/app/features/spaces/SpaceListAction.kt b/vector/src/main/java/im/vector/app/features/spaces/SpaceListAction.kt index fd2e68e172..1ef755e684 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/SpaceListAction.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/SpaceListAction.kt @@ -20,7 +20,7 @@ import im.vector.app.core.platform.VectorViewModelAction import org.matrix.android.sdk.api.session.room.model.RoomSummary sealed class SpaceListAction : VectorViewModelAction { - data class SelectSpace(val spaceSummary: RoomSummary?) : SpaceListAction() + data class SelectSpace(val spaceSummary: RoomSummary?, val isSubSpace: Boolean) : SpaceListAction() data class OpenSpaceInvite(val spaceSummary: RoomSummary) : SpaceListAction() data class LeaveSpace(val spaceSummary: RoomSummary) : SpaceListAction() data class ToggleExpand(val spaceSummary: RoomSummary) : SpaceListAction() diff --git a/vector/src/main/java/im/vector/app/features/spaces/SpaceListBottomSheet.kt b/vector/src/main/java/im/vector/app/features/spaces/SpaceListBottomSheet.kt index 4787aed8ae..9991384643 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/SpaceListBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/SpaceListBottomSheet.kt @@ -25,6 +25,7 @@ import im.vector.app.R import im.vector.app.core.extensions.replaceChildFragment import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment import im.vector.app.databinding.FragmentSpacesBottomSheetBinding +import im.vector.app.features.analytics.plan.MobileScreen class SpaceListBottomSheet : VectorBaseBottomSheetDialogFragment() { @@ -32,6 +33,11 @@ class SpaceListBottomSheet : VectorBaseBottomSheetDialogFragment if (state.selectedSpace?.roomId != action.spaceSummary?.roomId) { - analyticsTracker.capture(Interaction(null, null, Interaction.Name.SpacePanelSwitchSpace)) + val interactionName = if (action.isSubSpace) { + Interaction.Name.SpacePanelSwitchSubSpace + } else { + Interaction.Name.SpacePanelSwitchSpace + } + analyticsTracker.capture( + Interaction( + index = null, + interactionType = null, + name = interactionName + ) + ) setState { copy(selectedSpace = action.spaceSummary) } spaceStateHandler.setCurrentSpace(action.spaceSummary?.roomId) _viewEvents.post(SpaceListViewEvents.CloseDrawer) diff --git a/vector/src/main/java/im/vector/app/features/spaces/SpaceSummaryController.kt b/vector/src/main/java/im/vector/app/features/spaces/SpaceSummaryController.kt index ff8f5c38f7..acc1df5405 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/SpaceSummaryController.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/SpaceSummaryController.kt @@ -88,7 +88,7 @@ class SpaceSummaryController @Inject constructor( id("space_home") selected(selectedSpace == null) countState(UnreadCounterBadgeView.State.Count(homeCount.totalCount, homeCount.isHighlight)) - listener { host.callback?.onSpaceSelected(null) } + listener { host.callback?.onSpaceSelected(null, isSubSpace = false) } } rootSpaces @@ -114,7 +114,7 @@ class SpaceSummaryController @Inject constructor( selected(isSelected) canDrag(true) onMore { host.callback?.onSpaceSettings(roomSummary) } - listener { host.callback?.onSpaceSelected(roomSummary) } + listener { host.callback?.onSpaceSelected(roomSummary, isSubSpace = false) } toggleExpand { host.callback?.onToggleExpand(roomSummary) } countState( UnreadCounterBadgeView.State.Count( @@ -165,7 +165,7 @@ class SpaceSummaryController @Inject constructor( expanded(expanded) onMore { host.callback?.onSpaceSettings(childSummary) } matrixItem(childSummary.toMatrixItem()) - listener { host.callback?.onSpaceSelected(childSummary) } + listener { host.callback?.onSpaceSelected(childSummary, isSubSpace = true) } toggleExpand { host.callback?.onToggleExpand(childSummary) } indent(currentDepth) countState( @@ -184,7 +184,7 @@ class SpaceSummaryController @Inject constructor( } interface Callback { - fun onSpaceSelected(spaceSummary: RoomSummary?) + fun onSpaceSelected(spaceSummary: RoomSummary?, isSubSpace: Boolean) fun onSpaceInviteSelected(spaceSummary: RoomSummary) fun onSpaceSettings(spaceSummary: RoomSummary) fun onToggleExpand(spaceSummary: RoomSummary) diff --git a/vector/src/main/java/im/vector/app/features/spaces/create/ChooseSpaceTypeFragment.kt b/vector/src/main/java/im/vector/app/features/spaces/create/ChooseSpaceTypeFragment.kt index 4c44bfc7a8..6c31b9e856 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/create/ChooseSpaceTypeFragment.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/create/ChooseSpaceTypeFragment.kt @@ -25,6 +25,7 @@ import dagger.hilt.android.AndroidEntryPoint import im.vector.app.core.epoxy.onClick import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.databinding.FragmentSpaceCreateChooseTypeBinding +import im.vector.app.features.analytics.plan.MobileScreen @AndroidEntryPoint class ChooseSpaceTypeFragment : @@ -35,6 +36,11 @@ class ChooseSpaceTypeFragment : override fun getBinding(inflater: LayoutInflater, container: ViewGroup?) = FragmentSpaceCreateChooseTypeBinding.inflate(layoutInflater, container, false) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + analyticsScreenName = MobileScreen.ScreenName.CreateSpace + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) diff --git a/vector/src/main/java/im/vector/app/features/spaces/create/CreateSpaceViewModel.kt b/vector/src/main/java/im/vector/app/features/spaces/create/CreateSpaceViewModel.kt index b680f77df2..1cfac4a5fe 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/create/CreateSpaceViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/create/CreateSpaceViewModel.kt @@ -32,6 +32,8 @@ import im.vector.app.core.error.ErrorFormatter import im.vector.app.core.extensions.isEmail import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.resources.StringProvider +import im.vector.app.features.analytics.AnalyticsTracker +import im.vector.app.features.analytics.plan.Interaction import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.matrix.android.sdk.api.MatrixPatterns @@ -46,7 +48,8 @@ class CreateSpaceViewModel @AssistedInject constructor( private val session: Session, private val stringProvider: StringProvider, private val createSpaceViewModelTask: CreateSpaceViewModelTask, - private val errorFormatter: ErrorFormatter + private val errorFormatter: ErrorFormatter, + private val analyticsTracker: AnalyticsTracker, ) : VectorViewModel(initialState) { private val identityService = session.identityService() @@ -350,6 +353,13 @@ class CreateSpaceViewModel @AssistedInject constructor( } viewModelScope.launch(Dispatchers.IO) { try { + analyticsTracker.capture( + Interaction( + index = null, + interactionType = null, + name = Interaction.Name.MobileSpaceCreationValidated + ) + ) val alias = if (state.spaceType == SpaceType.Public) { state.aliasLocalPart } else null diff --git a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryController.kt b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryController.kt index 5b362690fa..e2fde4d45b 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryController.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryController.kt @@ -34,7 +34,7 @@ import im.vector.app.core.ui.list.genericEmptyWithActionItem import im.vector.app.core.ui.list.genericPillItem import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.list.spaceChildInfoItem -import im.vector.app.features.home.room.list.spaceDirectoryFilterNoResults +import im.vector.app.features.home.room.list.spaceDirectoryFilterNoResultsItem import im.vector.app.features.spaces.manage.SpaceChildInfoMatchFilter import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence import me.gujun.android.span.span @@ -141,7 +141,7 @@ class SpaceDirectoryController @Inject constructor( val filteredChildInfo = flattenChildInfo.filter { matchFilter.test(it) } if (filteredChildInfo.isEmpty()) { - spaceDirectoryFilterNoResults { + spaceDirectoryFilterNoResultsItem { id("no_results") } } else { diff --git a/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceAddRoomFragment.kt b/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceAddRoomFragment.kt index d0115d561a..edc18a8816 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceAddRoomFragment.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceAddRoomFragment.kt @@ -214,7 +214,7 @@ class SpaceAddRoomFragment : roomEpoxyController.submitList(it) } listenItemCount(viewModel.roomCountFlow) { roomEpoxyController.totalSize = it } - views.roomList.layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false) + views.roomList.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false) views.roomList.setHasFixedSize(true) } diff --git a/vector/src/main/java/im/vector/app/features/spaces/preview/SpacePreviewController.kt b/vector/src/main/java/im/vector/app/features/spaces/preview/SpacePreviewController.kt index 9632087191..d149a3521d 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/preview/SpacePreviewController.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/preview/SpacePreviewController.kt @@ -65,7 +65,7 @@ class SpacePreviewController @Inject constructor( subSpaceItem { id(child.roomId) roomId(child.roomId) - title(child.name) + title(child.name ?: "") depth(depth) avatarUrl(child.avatarUrl) avatarRenderer(host.avatarRenderer) 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 new file mode 100644 index 0000000000..d7d74b08e9 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastConstants.kt @@ -0,0 +1,20 @@ +/* + * 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 + +/** Voice Broadcast State Event. */ +const val STATE_ROOM_VOICE_BROADCAST_INFO = "io.element.voice_broadcast_info" 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 new file mode 100644 index 0000000000..f682cd2f5e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast + +import im.vector.app.features.voicebroadcast.usecase.PauseVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.usecase.ResumeVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.usecase.StartVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.usecase.StopVoiceBroadcastUseCase +import javax.inject.Inject + +/** + * Helper class to record voice broadcast. + */ +class VoiceBroadcastHelper @Inject constructor( + private val startVoiceBroadcastUseCase: StartVoiceBroadcastUseCase, + private val pauseVoiceBroadcastUseCase: PauseVoiceBroadcastUseCase, + private val resumeVoiceBroadcastUseCase: ResumeVoiceBroadcastUseCase, + private val stopVoiceBroadcastUseCase: StopVoiceBroadcastUseCase, +) { + suspend fun startVoiceBroadcast(roomId: String) = startVoiceBroadcastUseCase.execute(roomId) + + suspend fun pauseVoiceBroadcast(roomId: String) = pauseVoiceBroadcastUseCase.execute(roomId) + + suspend fun resumeVoiceBroadcast(roomId: String) = resumeVoiceBroadcastUseCase.execute(roomId) + + suspend fun stopVoiceBroadcast(roomId: String) = stopVoiceBroadcastUseCase.execute(roomId) +} 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 new file mode 100644 index 0000000000..b33d6cc4da --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/MessageVoiceBroadcastInfoContent.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +import org.matrix.android.sdk.api.session.events.model.Content +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.model.message.MessageType.MSGTYPE_VOICE_BROADCAST_INFO +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent +import timber.log.Timber + +/** + * Content of the state event of type [STATE_ROOM_VOICE_BROADCAST_INFO]. + * + * It contains general info related to a voice broadcast. + */ +@JsonClass(generateAdapter = true) +data class MessageVoiceBroadcastInfoContent( + /** Local message type, not from server. */ + @Transient override val msgType: String = MSGTYPE_VOICE_BROADCAST_INFO, + @Json(name = "body") override val body: String = "", + @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null, + @Json(name = "m.new_content") override val newContent: Content? = null, + + /** The [VoiceBroadcastState] value. **/ + @Json(name = "state") val voiceBroadcastStateStr: String = "", + /** The length of the voice chunks in seconds. **/ + @Json(name = "chunk_length") val chunkLength: Long? = null, +) : MessageContent { + + val voiceBroadcastState: VoiceBroadcastState? = VoiceBroadcastState.values() + .find { it.value == voiceBroadcastStateStr } + ?: run { + Timber.w("Invalid value for state: `$voiceBroadcastStateStr`") + null + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcastEvent.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcastEvent.kt new file mode 100644 index 0000000000..c09a5712a8 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcastEvent.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.model + +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent + +/** + * [Event] wrapper for [STATE_ROOM_VOICE_BROADCAST_INFO] event type. + * Provides additional fields and functions related to voice broadcast. + */ +@JvmInline +value class VoiceBroadcastEvent(val root: Event) { + + /** + * Reference on the initial voice broadcast state event (ie. with [MessageVoiceBroadcastInfoContent.voiceBroadcastState]=[VoiceBroadcastState.STARTED]). + */ + val reference: RelationDefaultContent? + get() { + val voiceBroadcastInfoContent = root.content.toModel() + return if (voiceBroadcastInfoContent?.voiceBroadcastState == VoiceBroadcastState.STARTED) { + RelationDefaultContent(RelationType.REFERENCE, root.eventId) + } else { + voiceBroadcastInfoContent?.relatesTo + } + } + + /** + * The mapped [MessageVoiceBroadcastInfoContent] model of the event content. + */ + val content: MessageVoiceBroadcastInfoContent? + get() = root.content.toModel() +} + +/** + * Map a [STATE_ROOM_VOICE_BROADCAST_INFO] state event to a [VoiceBroadcastEvent]. + */ +fun Event.asVoiceBroadcastEvent() = if (type == STATE_ROOM_VOICE_BROADCAST_INFO) VoiceBroadcastEvent(this) else null diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcastState.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcastState.kt new file mode 100644 index 0000000000..02e1b2decc --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcastState.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2020 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 im.vector.app.features.voicebroadcast.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Ref: https://github.com/vector-im/element-meta/discussions/632 + */ +@JsonClass(generateAdapter = false) +enum class VoiceBroadcastState(val value: String) { + /** + * The voice broadcast had been started and is currently being live. + */ + @Json(name = "started") STARTED("started"), + + /** + * The voice broadcast has been paused and may be resumed at any time by the recorder. + */ + @Json(name = "paused") PAUSED("paused"), + + /** + * The voice broadcast is currently being live again. + */ + @Json(name = "resumed") RESUMED("resumed"), + + /** + * The voice broadcast has ended. + */ + @Json(name = "stopped") STOPPED("stopped"), +} 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 new file mode 100644 index 0000000000..8f61284423 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCase.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.usecase + +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +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.query.QueryStringValue +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.toContent +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.relation.RelationDefaultContent +import timber.log.Timber +import javax.inject.Inject + +class PauseVoiceBroadcastUseCase @Inject constructor( + private val session: Session, +) { + + suspend fun execute(roomId: String): Result = runCatching { + val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") + + Timber.d("## PauseVoiceBroadcastUseCase: Pause voice broadcast requested") + + val lastVoiceBroadcastEvent = room.stateService().getStateEvent( + STATE_ROOM_VOICE_BROADCAST_INFO, + QueryStringValue.Equals(session.myUserId) + )?.asVoiceBroadcastEvent() + when (val voiceBroadcastState = lastVoiceBroadcastEvent?.content?.voiceBroadcastState) { + VoiceBroadcastState.STARTED, + VoiceBroadcastState.RESUMED -> pauseVoiceBroadcast(room, lastVoiceBroadcastEvent.reference) + else -> Timber.d("## PauseVoiceBroadcastUseCase: Cannot pause voice broadcast: currentState=$voiceBroadcastState") + } + } + + private suspend fun pauseVoiceBroadcast(room: Room, reference: RelationDefaultContent?) { + Timber.d("## PauseVoiceBroadcastUseCase: Send new voice broadcast info state event") + room.stateService().sendStateEvent( + eventType = STATE_ROOM_VOICE_BROADCAST_INFO, + stateKey = session.myUserId, + body = MessageVoiceBroadcastInfoContent( + relatesTo = reference, + voiceBroadcastStateStr = VoiceBroadcastState.PAUSED.value, + ).toContent(), + ) + + // TODO pause recording audio files + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCase.kt new file mode 100644 index 0000000000..d0d82b42c3 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCase.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.usecase + +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +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.query.QueryStringValue +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.toContent +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.relation.RelationDefaultContent +import timber.log.Timber +import javax.inject.Inject + +class ResumeVoiceBroadcastUseCase @Inject constructor( + private val session: Session, +) { + + suspend fun execute(roomId: String): Result = runCatching { + val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") + + Timber.d("## ResumeVoiceBroadcastUseCase: Resume voice broadcast requested") + + val lastVoiceBroadcastEvent = room.stateService().getStateEvent( + STATE_ROOM_VOICE_BROADCAST_INFO, + QueryStringValue.Equals(session.myUserId) + )?.asVoiceBroadcastEvent() + when (val voiceBroadcastState = lastVoiceBroadcastEvent?.content?.voiceBroadcastState) { + VoiceBroadcastState.PAUSED -> resumeVoiceBroadcast(room, lastVoiceBroadcastEvent.reference) + else -> Timber.d("## ResumeVoiceBroadcastUseCase: Cannot resume voice broadcast: currentState=$voiceBroadcastState") + } + } + + /** + * Resume a paused voice broadcast in the given room. + * + * @param room the room related to the voice broadcast + * @param reference reference on the initial voice broadcast state event (ie. state=STARTED) + */ + private suspend fun resumeVoiceBroadcast(room: Room, reference: RelationDefaultContent?) { + Timber.d("## ResumeVoiceBroadcastUseCase: Send new voice broadcast info state event") + room.stateService().sendStateEvent( + eventType = STATE_ROOM_VOICE_BROADCAST_INFO, + stateKey = session.myUserId, + body = MessageVoiceBroadcastInfoContent( + relatesTo = reference, + voiceBroadcastStateStr = VoiceBroadcastState.RESUMED.value, + ).toContent(), + ) + + // TODO resume recording audio files + } +} 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 new file mode 100644 index 0000000000..0b8328cd4b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.usecase + +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +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.query.QueryStringValue +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.Room +import timber.log.Timber +import javax.inject.Inject + +class StartVoiceBroadcastUseCase @Inject constructor( + private val session: Session, +) { + + suspend fun execute(roomId: String): Result = runCatching { + val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") + + Timber.d("## StartVoiceBroadcastUseCase: Start voice broadcast requested") + + val onGoingVoiceBroadcastEvents = room.stateService().getStateEvents( + setOf(STATE_ROOM_VOICE_BROADCAST_INFO), + QueryStringValue.IsNotEmpty + ) + .mapNotNull { it.asVoiceBroadcastEvent() } + .filter { it.content?.voiceBroadcastState != VoiceBroadcastState.STOPPED } + + if (onGoingVoiceBroadcastEvents.isEmpty()) { + startVoiceBroadcast(room) + } else { + Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: currentVoiceBroadcastEvents=$onGoingVoiceBroadcastEvents") + } + } + + private suspend fun startVoiceBroadcast(room: Room) { + Timber.d("## StartVoiceBroadcastUseCase: Send new voice broadcast info state event") + room.stateService().sendStateEvent( + eventType = STATE_ROOM_VOICE_BROADCAST_INFO, + stateKey = session.myUserId, + body = MessageVoiceBroadcastInfoContent( + voiceBroadcastStateStr = VoiceBroadcastState.STARTED.value, + chunkLength = 5L, // TODO Get length from voice broadcast settings + ).toContent() + ) + + // TODO start recording audio files + } +} 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 new file mode 100644 index 0000000000..8b22193770 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCase.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.usecase + +import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO +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.query.QueryStringValue +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.toContent +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.relation.RelationDefaultContent +import timber.log.Timber +import javax.inject.Inject + +class StopVoiceBroadcastUseCase @Inject constructor( + private val session: Session, +) { + + suspend fun execute(roomId: String): Result = runCatching { + val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") + + Timber.d("## StopVoiceBroadcastUseCase: Stop voice broadcast requested") + + val lastVoiceBroadcastEvent = room.stateService().getStateEvent( + STATE_ROOM_VOICE_BROADCAST_INFO, + QueryStringValue.Equals(session.myUserId) + )?.asVoiceBroadcastEvent() + when (val voiceBroadcastState = lastVoiceBroadcastEvent?.content?.voiceBroadcastState) { + VoiceBroadcastState.STARTED, + VoiceBroadcastState.PAUSED, + VoiceBroadcastState.RESUMED -> stopVoiceBroadcast(room, lastVoiceBroadcastEvent.reference) + else -> Timber.d("## StopVoiceBroadcastUseCase: Cannot stop voice broadcast: currentState=$voiceBroadcastState") + } + } + + private suspend fun stopVoiceBroadcast(room: Room, reference: RelationDefaultContent?) { + Timber.d("## StopVoiceBroadcastUseCase: Send new voice broadcast info state event") + room.stateService().sendStateEvent( + eventType = STATE_ROOM_VOICE_BROADCAST_INFO, + stateKey = session.myUserId, + body = MessageVoiceBroadcastInfoContent( + relatesTo = reference, + voiceBroadcastStateStr = VoiceBroadcastState.STOPPED.value, + ).toContent(), + ) + + // TODO stop recording audio files + } +} diff --git a/vector/src/main/res/drawable/ic_attachment_voice_broadcast.xml b/vector/src/main/res/drawable/ic_attachment_voice_broadcast.xml new file mode 100644 index 0000000000..3e93522b18 --- /dev/null +++ b/vector/src/main/res/drawable/ic_attachment_voice_broadcast.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/vector/src/main/res/drawable/ic_composer_bold.xml b/vector/src/main/res/drawable/ic_composer_bold.xml new file mode 100644 index 0000000000..3d9a10d16b --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_bold.xml @@ -0,0 +1,10 @@ + + + diff --git a/vector/src/main/res/drawable/ic_composer_italic.xml b/vector/src/main/res/drawable/ic_composer_italic.xml new file mode 100644 index 0000000000..faa4f89cd4 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_italic.xml @@ -0,0 +1,10 @@ + + + diff --git a/vector/src/main/res/drawable/ic_composer_strikethrough.xml b/vector/src/main/res/drawable/ic_composer_strikethrough.xml new file mode 100644 index 0000000000..3970c95381 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_strikethrough.xml @@ -0,0 +1,12 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_composer_underlined.xml b/vector/src/main/res/drawable/ic_composer_underlined.xml new file mode 100644 index 0000000000..fe18d60185 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_underlined.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/vector/src/main/res/drawable/poll_option_checked.xml b/vector/src/main/res/drawable/poll_option_checked.xml index 28ab94a421..2324792eac 100644 --- a/vector/src/main/res/drawable/poll_option_checked.xml +++ b/vector/src/main/res/drawable/poll_option_checked.xml @@ -10,5 +10,9 @@ - \ No newline at end of file + android:top="2dp" + android:bottom="2dp" + android:left="2dp" + android:right="2dp" + /> + diff --git a/vector/src/main/res/layout/bottom_sheet_session_learn_more.xml b/vector/src/main/res/layout/bottom_sheet_session_learn_more.xml new file mode 100644 index 0000000000..466ab5af49 --- /dev/null +++ b/vector/src/main/res/layout/bottom_sheet_session_learn_more.xml @@ -0,0 +1,59 @@ + + + + + + + + + +