diff --git a/.gitignore b/.gitignore
index ab162f0755..4752469dc1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
/local.properties
# idea files: exclude everything except dictionnaries
.idea/caches
+.idea/copilot
.idea/libraries
.idea/inspectionProfiles
.idea/sonarlint
diff --git a/CHANGES.md b/CHANGES.md
index df477df4d4..c4cd36e50f 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,17 @@
+Changes in Element v1.6.14 (2024-04-02)
+=======================================
+
+Bugfixes 🐛
+----------
+ - Fix send button blinking once for each character you are typing in RTE. ([#send_button_blinking](https://github.com/element-hq/element-android/issues/send_button_blinking))
+ - Fix infinite loading on secure backup setup ("Re-Authentication needed" bottom sheet). ([#8786](https://github.com/element-hq/element-android/issues/8786))
+
+Other changes
+-------------
+ - Improve UTD reporting by adding additional fields to the report. ([#8780](https://github.com/element-hq/element-android/issues/8780))
+ - Add a report user action in the message bottom sheet and on the user profile page. ([#8796](https://github.com/element-hq/element-android/issues/8796))
+
+
Changes in Element v1.6.12 (2024-02-16)
=======================================
@@ -5,8 +19,8 @@ This update provides important security fixes, please update now.
Security fixes 🔐
-----------------
- - Add a check on incoming intent. ([#1506 internal](https://github.com/matrix-org/internal-config/issues/1506))
- - Store temporary files created for Camera in the media folder. ([#1505 internal](https://github.com/matrix-org/internal-config/issues/1505))
+ - Add a check on incoming intent. [CVE-2024-26131](https://www.cve.org/CVERecord?id=CVE-2024-26131) / [GHSA-j6pr-fpc8-q9vm](https://github.com/element-hq/element-android/security/advisories/GHSA-j6pr-fpc8-q9vm)
+ - Store temporary files created for Camera in a dedicated media folder. [CVE-2024-26132](https://www.cve.org/CVERecord?id=CVE-2024-26132) / [GHSA-8wj9-cx7h-pvm4](https://github.com/element-hq/element-android/security/advisories/GHSA-8wj9-cx7h-pvm4)
Bugfixes 🐛
----------
diff --git a/dependencies.gradle b/dependencies.gradle
index 8b9125d9e8..7c9ca63536 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -101,7 +101,7 @@ ext.libs = [
],
element : [
'opusencoder' : "io.element.android:opusencoder:1.1.0",
- 'wysiwyg' : "io.element.android:wysiwyg:2.29.0"
+ 'wysiwyg' : "io.element.android:wysiwyg:2.35.0"
],
squareup : [
'moshi' : "com.squareup.moshi:moshi:$moshi",
diff --git a/fastlane/metadata/android/en-US/changelogs/40106140.txt b/fastlane/metadata/android/en-US/changelogs/40106140.txt
new file mode 100644
index 0000000000..ec92674960
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40106140.txt
@@ -0,0 +1,2 @@
+Main changes in this version: Bugfixes and improvements.
+Full changelog: https://github.com/element-hq/element-android/releases
diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml
index 577101b0d3..2a98069c2e 100644
--- a/library/ui-strings/src/main/res/values/strings.xml
+++ b/library/ui-strings/src/main/res/values/strings.xml
@@ -1953,8 +1953,11 @@
"This content was reported as spam.\n\nIf you don't want to see any more content from this user, you can ignore them to hide their messages."
"Reported as inappropriate"
"This content was reported as inappropriate.\n\nIf you don't want to see any more content from this user, you can ignore them to hide their messages."
+ "Reported user"
+ "The user has been reported.\n\nIf you don't want to see any more content from this user, you can ignore them to hide their messages."
Ignore user
+ Report user
"All messages (noisy)"
"All messages"
diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle
index f46fe81432..d2d3dd2d29 100644
--- a/matrix-sdk-android/build.gradle
+++ b/matrix-sdk-android/build.gradle
@@ -62,7 +62,7 @@ android {
// that the app's state is completely cleared between tests.
testInstrumentationRunnerArguments clearPackageData: 'true'
- buildConfigField "String", "SDK_VERSION", "\"1.6.12\""
+ buildConfigField "String", "SDK_VERSION", "\"1.6.14\""
buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\""
buildConfigField "String", "GIT_SDK_REVISION_UNIX_DATE", "\"${gitRevisionUnixDate()}\""
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/LiveEventListener.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/LiveEventListener.kt
index b4b283c86a..ce0a01a491 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/LiveEventListener.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/LiveEventListener.kt
@@ -16,6 +16,7 @@
package org.matrix.android.sdk.api.session
+import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.util.JsonDict
@@ -27,7 +28,7 @@ interface LiveEventListener {
fun onEventDecrypted(event: Event, clearEvent: JsonDict)
- fun onEventDecryptionError(event: Event, throwable: Throwable)
+ fun onEventDecryptionError(event: Event, cryptoError: MXCryptoError)
fun onLiveToDeviceEvent(event: Event)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DecryptRoomEventUseCase.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DecryptRoomEventUseCase.kt
index 12255d0783..25a1ffc005 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DecryptRoomEventUseCase.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DecryptRoomEventUseCase.kt
@@ -22,10 +22,12 @@ import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult
import org.matrix.android.sdk.api.session.events.model.Event
import javax.inject.Inject
-internal class DecryptRoomEventUseCase @Inject constructor(private val olmMachine: OlmMachine) {
+internal class DecryptRoomEventUseCase @Inject constructor(
+ private val cryptoService: RustCryptoService
+) {
suspend operator fun invoke(event: Event): MXEventDecryptionResult {
- return olmMachine.decryptRoomEvent(event)
+ return cryptoService.decryptEvent(event, "")
}
suspend fun decryptAndSaveResult(event: Event) {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/Device.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/Device.kt
index 0bd6ed06d1..d2865f0f65 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/Device.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/Device.kt
@@ -172,8 +172,6 @@ internal class Device @AssistedInject constructor(
* This will not fetch out fresh data from the Rust side.
**/
internal fun toCryptoDeviceInfo(): CryptoDeviceInfo {
-// val keys = innerDevice.keys.map { (keyId, key) -> keyId to key }.toMap()
-
return CryptoDeviceInfo(
deviceId = innerDevice.deviceId,
userId = innerDevice.userId,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/OlmMachine.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/OlmMachine.kt
index f90ae4a345..4fe59fb1dd 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/OlmMachine.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/OlmMachine.kt
@@ -189,18 +189,21 @@ internal class OlmMachine @Inject constructor(
is OwnUserIdentity -> ownIdentity.trustsOurOwnDevice()
else -> false
}
+ val ownDevice = inner.getDevice(userId(), deviceId, 0u)!!
+ val creationTime = ownDevice.firstTimeSeenTs.toLong()
return CryptoDeviceInfo(
deviceId(),
userId(),
- // TODO pass the algorithms here.
- listOf(),
+ ownDevice.algorithms,
keys,
mapOf(),
- UnsignedDeviceInfo(),
+ UnsignedDeviceInfo(
+ deviceDisplayName = ownDevice.displayName
+ ),
DeviceTrustLevel(crossSigningVerified, locallyVerified = true),
false,
- null
+ creationTime
)
}
@@ -291,7 +294,7 @@ internal class OlmMachine @Inject constructor(
// checking the returned to devices to check for room keys.
// XXX Anyhow there is now proper signaling we should soon stop parsing them manually
receiveSyncChanges.toDeviceEvents.map {
- outAdapter.fromJson(it) ?: Event()
+ outAdapter.fromJson(it) ?: Event()
}
}
@@ -882,6 +885,7 @@ internal class OlmMachine @Inject constructor(
inner.queryMissingSecretsFromOtherSessions()
}
}
+
@Throws(CryptoStoreException::class)
suspend fun enableBackupV1(key: String, version: String) {
return withContext(coroutineDispatchers.computation) {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/RustCryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/RustCryptoService.kt
index c0407ca4e8..5ba74f705b 100755
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/RustCryptoService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/RustCryptoService.kt
@@ -497,8 +497,11 @@ internal class RustCryptoService @Inject constructor(
@Throws(MXCryptoError::class)
override suspend fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult {
return try {
- olmMachine.decryptRoomEvent(event)
+ olmMachine.decryptRoomEvent(event).also {
+ liveEventManager.get().dispatchLiveEventDecrypted(event, it)
+ }
} catch (mxCryptoError: MXCryptoError) {
+ liveEventManager.get().dispatchLiveEventDecryptionFailed(event, mxCryptoError)
if (mxCryptoError is MXCryptoError.Base && (
mxCryptoError.errorType == MXCryptoError.ErrorType.UNKNOWN_INBOUND_SESSION_ID ||
mxCryptoError.errorType == MXCryptoError.ErrorType.UNKNOWN_MESSAGE_INDEX)) {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/StreamEventsManager.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/StreamEventsManager.kt
index ce34b0430e..3e7beed047 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/StreamEventsManager.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/StreamEventsManager.kt
@@ -22,6 +22,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.LiveEventListener
+import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.crypto.model.MXEventDecryptionResult
import org.matrix.android.sdk.api.session.events.model.Event
import timber.log.Timber
@@ -75,7 +76,7 @@ internal class StreamEventsManager @Inject constructor() {
}
}
- fun dispatchLiveEventDecryptionFailed(event: Event, error: Throwable) {
+ fun dispatchLiveEventDecryptionFailed(event: Event, error: MXCryptoError) {
Timber.v("## dispatchLiveEventDecryptionFailed ${event.eventId}")
coroutineScope.launch {
listeners.forEach {
diff --git a/vector-app/build.gradle b/vector-app/build.gradle
index a5df2ae1d5..eed61f97b7 100644
--- a/vector-app/build.gradle
+++ b/vector-app/build.gradle
@@ -37,7 +37,7 @@ ext.versionMinor = 6
// 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 = 12
+ext.versionPatch = 14
static def getGitTimestamp() {
def cmd = 'git show -s --format=%ct'
diff --git a/vector-app/src/main/java/im/vector/app/VectorApplication.kt b/vector-app/src/main/java/im/vector/app/VectorApplication.kt
index 7b41c12773..fe4cc3311b 100644
--- a/vector-app/src/main/java/im/vector/app/VectorApplication.kt
+++ b/vector-app/src/main/java/im/vector/app/VectorApplication.kt
@@ -51,6 +51,7 @@ import im.vector.app.core.debug.LeakDetector
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.pushers.FcmHelper
import im.vector.app.core.resources.BuildMeta
+import im.vector.app.features.analytics.DecryptionFailureTracker
import im.vector.app.features.analytics.VectorAnalytics
import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.configuration.VectorConfiguration
@@ -100,6 +101,7 @@ class VectorApplication :
@Inject lateinit var callManager: WebRtcCallManager
@Inject lateinit var invitesAcceptor: InvitesAcceptor
@Inject lateinit var autoRageShaker: AutoRageShaker
+ @Inject lateinit var decryptionFailureTracker: DecryptionFailureTracker
@Inject lateinit var vectorFileLogger: VectorFileLogger
@Inject lateinit var vectorAnalytics: VectorAnalytics
@Inject lateinit var flipperProxy: FlipperProxy
@@ -130,6 +132,7 @@ class VectorApplication :
vectorAnalytics.init()
invitesAcceptor.initialize()
autoRageShaker.initialize()
+ decryptionFailureTracker.start()
vectorUncaughtExceptionHandler.activate()
// Remove Log handler statically added by Jitsi
diff --git a/vector/src/main/java/im/vector/app/UISIDetector.kt b/vector/src/main/java/im/vector/app/UISIDetector.kt
index 4a9d8ae266..7188fb0dc9 100644
--- a/vector/src/main/java/im/vector/app/UISIDetector.kt
+++ b/vector/src/main/java/im/vector/app/UISIDetector.kt
@@ -18,6 +18,7 @@ package im.vector.app
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.LiveEventListener
+import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventContent
import org.matrix.android.sdk.api.session.events.model.toModel
@@ -84,7 +85,7 @@ class UISIDetector(private val timeoutMillis: Long = 30_000L) : LiveEventListene
}
}
- override fun onEventDecryptionError(event: Event, throwable: Throwable) {
+ override fun onEventDecryptionError(event: Event, cryptoError: MXCryptoError) {
val eventId = event.eventId
val roomId = event.roomId
if (!enabled || eventId == null || roomId == null) return
diff --git a/vector/src/main/java/im/vector/app/core/di/ActiveSessionHolder.kt b/vector/src/main/java/im/vector/app/core/di/ActiveSessionHolder.kt
index 472d0896f9..5523c84994 100644
--- a/vector/src/main/java/im/vector/app/core/di/ActiveSessionHolder.kt
+++ b/vector/src/main/java/im/vector/app/core/di/ActiveSessionHolder.kt
@@ -23,7 +23,6 @@ import im.vector.app.core.pushers.UnregisterUnifiedPushUseCase
import im.vector.app.core.services.GuardServiceStarter
import im.vector.app.core.session.ConfigureAndStartSessionUseCase
import im.vector.app.features.analytics.DecryptionFailureTracker
-import im.vector.app.features.analytics.plan.Error
import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.crypto.keysrequest.KeyRequestHandler
import im.vector.app.features.crypto.verification.IncomingVerificationRequestHandler
@@ -75,11 +74,6 @@ class ActiveSessionHolder @Inject constructor(
session.callSignalingService().addCallListener(callManager)
imageManager.onSessionStarted(session)
guardServiceStarter.start()
- decryptionFailureTracker.currentModule = if (session.cryptoService().name() == "rust-sdk") {
- Error.CryptoModule.Rust
- } else {
- Error.CryptoModule.Native
- }
}
suspend fun clearActiveSession() {
diff --git a/vector/src/main/java/im/vector/app/features/analytics/DecryptionFailure.kt b/vector/src/main/java/im/vector/app/features/analytics/DecryptionFailure.kt
new file mode 100644
index 0000000000..f7fb177e12
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/analytics/DecryptionFailure.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2024 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
+
+import im.vector.app.features.analytics.plan.Error
+import org.matrix.android.sdk.api.session.crypto.MXCryptoError
+
+data class DecryptionFailure(
+ val timeStamp: Long,
+ val roomId: String,
+ val failedEventId: String,
+ val error: MXCryptoError,
+ val wasVisibleOnScreen: Boolean,
+ val ownIdentityTrustedAtTimeOfDecryptionFailure: Boolean,
+ // If this is set, it means that the event was decrypted but late. Will be -1 if
+ // the event was not decrypted after the maximum wait time.
+ val timeToDecryptMillis: Long? = null,
+ val isMatrixDotOrg: Boolean,
+ val isFederated: Boolean? = null,
+ val eventLocalAgeAtDecryptionFailure: Long? = null
+)
+
+fun DecryptionFailure.toAnalyticsEvent(): Error {
+ val errorMsg = (error as? MXCryptoError.Base)?.technicalMessage ?: error.message
+ return Error(
+ context = "mxc_crypto_error_type|${errorMsg}",
+ domain = Error.Domain.E2EE,
+ name = this.toAnalyticsErrorName(),
+ // this is deprecated keep for backward compatibility
+ cryptoModule = Error.CryptoModule.Rust,
+ cryptoSDK = Error.CryptoSDK.Rust,
+ eventLocalAgeMillis = eventLocalAgeAtDecryptionFailure?.toInt(),
+ isFederated = isFederated,
+ isMatrixDotOrg = isMatrixDotOrg,
+ timeToDecryptMillis = timeToDecryptMillis?.toInt() ?: -1,
+ wasVisibleToUser = wasVisibleOnScreen,
+ userTrustsOwnIdentity = ownIdentityTrustedAtTimeOfDecryptionFailure,
+ )
+}
+
+private fun DecryptionFailure.toAnalyticsErrorName(): Error.Name {
+ val error = this.error
+ val name = if (error is MXCryptoError.Base) {
+ when (error.errorType) {
+ MXCryptoError.ErrorType.UNKNOWN_INBOUND_SESSION_ID,
+ MXCryptoError.ErrorType.KEYS_WITHHELD -> Error.Name.OlmKeysNotSentError
+ MXCryptoError.ErrorType.OLM -> Error.Name.OlmUnspecifiedError
+ MXCryptoError.ErrorType.UNKNOWN_MESSAGE_INDEX -> Error.Name.OlmIndexError
+ else -> Error.Name.UnknownError
+ }
+ } else {
+ Error.Name.UnknownError
+ }
+ // check if it's an expected UTD!
+ val localAge = this.eventLocalAgeAtDecryptionFailure
+ val isHistorical = localAge != null && localAge < 0
+ if (isHistorical && !this.ownIdentityTrustedAtTimeOfDecryptionFailure) {
+ return Error.Name.HistoricalMessage
+ }
+
+ return name
+}
diff --git a/vector/src/main/java/im/vector/app/features/analytics/DecryptionFailureTracker.kt b/vector/src/main/java/im/vector/app/features/analytics/DecryptionFailureTracker.kt
index d596741d53..fcbc67169e 100644
--- a/vector/src/main/java/im/vector/app/features/analytics/DecryptionFailureTracker.kt
+++ b/vector/src/main/java/im/vector/app/features/analytics/DecryptionFailureTracker.kt
@@ -16,149 +16,282 @@
package im.vector.app.features.analytics
-import im.vector.app.features.analytics.plan.Error
-import im.vector.lib.core.utils.compat.removeIfCompat
-import im.vector.lib.core.utils.flow.tickerFlow
+import im.vector.app.ActiveSessionDataSource
import im.vector.lib.core.utils.timer.Clock
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.matrix.android.sdk.api.MatrixPatterns
+import org.matrix.android.sdk.api.session.LiveEventListener
+import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.crypto.MXCryptoError
+import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
+import org.matrix.android.sdk.api.util.JsonDict
+import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
-private data class DecryptionFailure(
- val timeStamp: Long,
- val roomId: String,
- val failedEventId: String,
- val error: MXCryptoError.ErrorType
-)
-private typealias DetailedErrorName = Pair
-
+// If we can decrypt in less than 4s, we don't report
private const val GRACE_PERIOD_MILLIS = 4_000
-private const val CHECK_INTERVAL = 2_000L
+
+// A tick to check when a decryption failure as exceeded the max time
+private const val CHECK_INTERVAL = 10_000L
+
+// If we can't decrypt after 60s, we report failures
+private const val MAX_WAIT_MILLIS = 60_000
/**
- * Tracks decryption errors that are visible to the user.
+ * Tracks decryption errors.
* When an error is reported it is not directly tracked via analytics, there is a grace period
* that gives the app a few seconds to get the key to decrypt.
+ *
+ * Decrypted under 4s => No report
+ * Decrypted before MAX_WAIT_MILLIS => Report with time to decrypt
+ * Not Decrypted after MAX_WAIT_MILLIS => Report with time = -1
*/
@Singleton
class DecryptionFailureTracker @Inject constructor(
private val analyticsTracker: AnalyticsTracker,
+ private val sessionDataSource: ActiveSessionDataSource,
private val clock: Clock
-) {
+) : Session.Listener, LiveEventListener {
- private val scope: CoroutineScope = CoroutineScope(SupervisorJob())
- private val failures = mutableListOf()
+ // The active session (set by the sessionDataSource)
+ private var activeSession: Session? = null
+
+ // The coroutine scope to use for the tracker
+ private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ // Map of eventId to tracked failure
+ // Only accessed on a `post` call, ensuring sequential access
+ private val trackedEventsMap = mutableMapOf()
+
+ // List of eventId that have been reported, to avoid double reporting
private val alreadyReported = mutableListOf()
- var currentModule: Error.CryptoModule? = null
+ // Mutex to ensure sequential access to internal state
+ private val mutex = Mutex()
- init {
- start()
- }
+ // Used to unsubscribe from the active session data source
+ private lateinit var activeSessionSourceDisposable: Job
- fun start() {
- tickerFlow(scope, CHECK_INTERVAL)
- .onEach {
- checkFailures()
- }.launchIn(scope)
+ // The ticker job, to report permanent UTD (not decrypted after MAX_WAIT_MILLIS)
+ private var currentTicker: Job? = null
+
+ /**
+ * Start the tracker.
+ *
+ * @param scope The coroutine scope to use, exposed for tests. If null, it will use the default one
+ */
+ fun start(scope: CoroutineScope? = null) {
+ if (scope != null) {
+ this.scope = scope
+ }
+ observeActiveSession()
}
fun stop() {
- scope.cancel()
+ Timber.v("Stop DecryptionFailureTracker")
+ activeSessionSourceDisposable.cancel(CancellationException("Closing DecryptionFailureTracker"))
+
+ activeSession?.removeListener(this)
+ activeSession?.eventStreamService()?.removeEventStreamListener(this)
+ activeSession = null
}
- fun e2eEventDisplayedInTimeline(event: TimelineEvent) {
- scope.launch(Dispatchers.Default) {
- val mCryptoError = event.root.mCryptoError
- if (mCryptoError != null) {
- addDecryptionFailure(DecryptionFailure(clock.epochMillis(), event.roomId, event.eventId, mCryptoError))
- } else {
- removeFailureForEventId(event.eventId)
+ private fun post(block: suspend () -> Unit) {
+ scope.launch {
+ mutex.withLock {
+ block()
}
}
}
- /**
- * Can be called when the timeline is disposed in order
- * to grace those events as they are not anymore displayed on screen.
- * */
- fun onTimeLineDisposed(roomId: String) {
- scope.launch(Dispatchers.Default) {
- synchronized(failures) {
- failures.removeIfCompat { it.roomId == roomId }
+ private suspend fun rescheduleTicker() {
+ currentTicker = scope.launch {
+ Timber.v("Reschedule ticker")
+ delay(CHECK_INTERVAL)
+ post {
+ checkFailures()
+ currentTicker = null
+ if (trackedEventsMap.isNotEmpty()) {
+ // Reschedule
+ rescheduleTicker()
+ }
+ }
+ }
+ }
+ private fun observeActiveSession() {
+ activeSessionSourceDisposable = sessionDataSource.stream()
+ .distinctUntilChanged()
+ .onEach {
+ Timber.v("Active session changed ${it.getOrNull()?.myUserId}")
+ it.orNull()?.let { session ->
+ post {
+ onSessionActive(session)
+ }
+ }
+ }.launchIn(scope)
+ }
+
+ private fun onSessionActive(session: Session) {
+ Timber.v("onSessionActive ${session.myUserId} previous: ${activeSession?.myUserId}")
+ val sessionId = session.sessionId
+ if (sessionId == activeSession?.sessionId) {
+ return
+ }
+ this.activeSession?.let { previousSession ->
+ previousSession.removeListener(this)
+ previousSession.eventStreamService().removeEventStreamListener(this)
+ // Do we want to clear the tracked events?
+ }
+ this.activeSession = session
+ session.addListener(this)
+ session.eventStreamService().addEventStreamListener(this)
+ }
+
+ override fun onSessionStopped(session: Session) {
+ post {
+ this.activeSession = null
+ session.addListener(this)
+ session.eventStreamService().addEventStreamListener(this)
+ }
+ }
+
+ // LiveEventListener callbacks
+
+ override fun onEventDecrypted(event: Event, clearEvent: JsonDict) {
+ Timber.v("Event decrypted ${event.eventId}")
+ event.eventId?.let {
+ post {
+ handleEventDecrypted(it)
}
}
}
- private fun addDecryptionFailure(failure: DecryptionFailure) {
- // de duplicate
- synchronized(failures) {
- if (failures.none { it.failedEventId == failure.failedEventId }) {
- failures.add(failure)
- }
+ override fun onEventDecryptionError(event: Event, cryptoError: MXCryptoError) {
+ Timber.v("Decryption error for event ${event.eventId} with error $cryptoError")
+ val session = activeSession ?: return
+ // track the event
+ post {
+ trackEvent(session, event, cryptoError)
}
}
- private fun removeFailureForEventId(eventId: String) {
- synchronized(failures) {
- failures.removeIfCompat { it.failedEventId == eventId }
+ override fun onLiveToDeviceEvent(event: Event) {}
+ override fun onLiveEvent(roomId: String, event: Event) {}
+ override fun onPaginatedEvent(roomId: String, event: Event) {}
+
+ private suspend fun trackEvent(session: Session, event: Event, error: MXCryptoError) {
+ Timber.v("Track event ${event.eventId}/${session.myUserId} time: ${clock.epochMillis()}")
+ val eventId = event.eventId
+ val roomId = event.roomId
+ if (eventId == null || roomId == null) return
+ if (trackedEventsMap.containsKey(eventId)) {
+ // already tracked
+ return
}
+ if (alreadyReported.contains(eventId)) {
+ // already reported
+ return
+ }
+ val isOwnIdentityTrusted = session.cryptoService().crossSigningService().isCrossSigningVerified()
+ val userHS = MatrixPatterns.extractServerNameFromId(session.myUserId)
+ val messageSenderHs = event.senderId?.let { MatrixPatterns.extractServerNameFromId(it) }
+ Timber.v("senderHs: $messageSenderHs, userHS: $userHS, isOwnIdentityTrusted: $isOwnIdentityTrusted")
+
+ val deviceCreationTs = session.cryptoService().getMyCryptoDevice().firstTimeSeenLocalTs
+ Timber.v("deviceCreationTs: $deviceCreationTs")
+ val eventRelativeAge = deviceCreationTs?.let { deviceTs ->
+ event.originServerTs?.let {
+ it - deviceTs
+ }
+ }
+ val failure = DecryptionFailure(
+ clock.epochMillis(),
+ roomId,
+ eventId,
+ error,
+ wasVisibleOnScreen = false,
+ ownIdentityTrustedAtTimeOfDecryptionFailure = isOwnIdentityTrusted,
+ isMatrixDotOrg = userHS == "matrix.org",
+ isFederated = messageSenderHs?.let { it != userHS },
+ eventLocalAgeAtDecryptionFailure = eventRelativeAge
+ )
+ Timber.v("Tracked failure: ${failure}")
+ trackedEventsMap[eventId] = failure
+
+ if (currentTicker == null) {
+ rescheduleTicker()
+ }
+ }
+
+ private fun handleEventDecrypted(eventId: String) {
+ Timber.v("Handle event decrypted $eventId time: ${clock.epochMillis()}")
+ // Only consider if it was tracked as a failure
+ val trackedFailure = trackedEventsMap[eventId] ?: return
+
+ // Grace event if decrypted under 4s
+ val now = clock.epochMillis()
+ val timeToDecrypt = now - trackedFailure.timeStamp
+ Timber.v("Handle event decrypted timeToDecrypt: $timeToDecrypt for event $eventId")
+ if (timeToDecrypt < GRACE_PERIOD_MILLIS) {
+ Timber.v("Grace event $eventId")
+ trackedEventsMap.remove(eventId)
+ return
+ }
+ // We still want to report but with the time it took
+ if (trackedFailure.timeToDecryptMillis == null) {
+ val decryptionFailure = trackedFailure.copy(timeToDecryptMillis = timeToDecrypt)
+ trackedEventsMap[eventId] = decryptionFailure
+ reportFailure(decryptionFailure)
+ }
+ }
+
+ fun utdDisplayedInTimeline(event: TimelineEvent) {
+ post {
+ // should be tracked (unless already reported)
+ val eventId = event.root.eventId ?: return@post
+ val trackedEvent = trackedEventsMap[eventId] ?: return@post
+
+ trackedEventsMap[eventId] = trackedEvent.copy(wasVisibleOnScreen = true)
+ }
+ }
+
+ // This will mutate the trackedEventsMap, so don't call it while iterating on it.
+ private fun reportFailure(decryptionFailure: DecryptionFailure) {
+ Timber.v("Report failure for event ${decryptionFailure.failedEventId}")
+ val error = decryptionFailure.toAnalyticsEvent()
+
+ analyticsTracker.capture(error)
+
+ // now remove from tracked
+ trackedEventsMap.remove(decryptionFailure.failedEventId)
+ // mark as already reported
+ alreadyReported.add(decryptionFailure.failedEventId)
}
private fun checkFailures() {
val now = clock.epochMillis()
- val aggregatedErrors: Map>
- synchronized(failures) {
- val toReport = mutableListOf()
- failures.removeAll { failure ->
- (now - failure.timeStamp > GRACE_PERIOD_MILLIS).also {
- if (it) {
- toReport.add(failure)
- }
- }
- }
-
- aggregatedErrors = toReport
- .groupBy { it.error.toAnalyticsErrorName() }
- .mapValues {
- it.value.map { it.failedEventId }
- }
+ Timber.v("Check failures now $now")
+ // report the definitely failed
+ val toReport = trackedEventsMap.values.filter {
+ now - it.timeStamp > MAX_WAIT_MILLIS
}
-
- aggregatedErrors.forEach { aggregation ->
- // there is now way to send the total/sum in posthog, so iterating
- aggregation.value
- // for now we ignore events already reported even if displayed again?
- .filter { alreadyReported.contains(it).not() }
- .forEach { failedEventId ->
- analyticsTracker.capture(Error(
- context = aggregation.key.first,
- domain = Error.Domain.E2EE,
- name = aggregation.key.second,
- cryptoModule = currentModule
- ))
- alreadyReported.add(failedEventId)
- }
+ toReport.forEach {
+ reportFailure(
+ it.copy(timeToDecryptMillis = -1)
+ )
}
}
-
- private fun MXCryptoError.ErrorType.toAnalyticsErrorName(): DetailedErrorName {
- val detailed = "$name | mxc_crypto_error_type"
- val errorName = when (this) {
- MXCryptoError.ErrorType.UNKNOWN_INBOUND_SESSION_ID,
- MXCryptoError.ErrorType.KEYS_WITHHELD -> Error.Name.OlmKeysNotSentError
- MXCryptoError.ErrorType.OLM -> Error.Name.OlmUnspecifiedError
- MXCryptoError.ErrorType.UNKNOWN_MESSAGE_INDEX -> Error.Name.OlmIndexError
- else -> Error.Name.UnknownError
- }
- return DetailedErrorName(detailed, errorName)
- }
}
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 2a7d0ac975..acc6ebf51e 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
@@ -175,7 +175,10 @@ class DefaultVectorAnalytics @Inject constructor(
Timber.tag(analyticsTag.value).d("capture($event)")
posthog
?.takeIf { userConsent == true }
- ?.capture(event.getName(), event.getProperties()?.toPostHogProperties())
+ ?.capture(
+ event.getName(),
+ event.getProperties()?.toPostHogProperties()
+ )
}
override fun screen(screen: VectorAnalyticsScreen) {
diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/Error.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/Error.kt
index 386c090848..98553b9258 100644
--- a/vector/src/main/java/im/vector/app/features/analytics/plan/Error.kt
+++ b/vector/src/main/java/im/vector/app/features/analytics/plan/Error.kt
@@ -30,11 +30,44 @@ data class Error(
*/
val context: String? = null,
/**
- * Which crypto module is the client currently using.
+ * DEPRECATED: Which crypto module is the client currently using.
*/
val cryptoModule: CryptoModule? = null,
+ /**
+ * Which crypto backend is the client currently using.
+ */
+ val cryptoSDK: CryptoSDK? = null,
val domain: Domain,
+ /**
+ * An heuristic based on event origin_server_ts and the current device
+ * creation time (origin_server_ts - device_ts). This would be used to
+ * get the source of the event scroll-back/live/initialSync.
+ */
+ val eventLocalAgeMillis: Int? = null,
+ /**
+ * true if userDomain != senderDomain.
+ */
+ val isFederated: Boolean? = null,
+ /**
+ * true if the current user is using matrix.org.
+ */
+ val isMatrixDotOrg: Boolean? = null,
val name: Name,
+ /**
+ * UTDs can be permanent or temporary. If temporary, this field will
+ * contain the time it took to decrypt the message in milliseconds. If
+ * permanent should be -1.
+ */
+ val timeToDecryptMillis: Int? = null,
+ /**
+ * true if the current user trusts their own identity (verified session)
+ * at time of decryption.
+ */
+ val userTrustsOwnIdentity: Boolean? = null,
+ /**
+ * true if that unable to decrypt error was visible to the user.
+ */
+ val wasVisibleToUser: Boolean? = null,
) : VectorAnalyticsEvent {
enum class Domain {
@@ -44,18 +77,79 @@ data class Error(
}
enum class Name {
+
+ /**
+ * E2EE domain error. Decryption failed for a message sent before the
+ * device logged in, and key backup is not enabled.
+ */
+ HistoricalMessage,
+
+ /**
+ * E2EE domain error. The room key is known but is ratcheted (index >
+ * 0).
+ */
OlmIndexError,
+
+ /**
+ * E2EE domain error. Generic unknown inbound group session error.
+ */
OlmKeysNotSentError,
+
+ /**
+ * E2EE domain error. Any other decryption error (missing field, format
+ * errors...).
+ */
OlmUnspecifiedError,
+
+ /**
+ * TO_DEVICE domain error. The to-device message failed to decrypt.
+ */
ToDeviceFailedToDecrypt,
+
+ /**
+ * E2EE domain error. Decryption failed due to unknown error.
+ */
UnknownError,
+
+ /**
+ * VOIP domain error. ICE negotiation failed.
+ */
VoipIceFailed,
+
+ /**
+ * VOIP domain error. ICE negotiation timed out.
+ */
VoipIceTimeout,
+
+ /**
+ * VOIP domain error. The call invite timed out.
+ */
VoipInviteTimeout,
+
+ /**
+ * VOIP domain error. The user hung up the call.
+ */
VoipUserHangup,
+
+ /**
+ * VOIP domain error. The user's media failed to start.
+ */
VoipUserMediaFailed,
}
+ enum class CryptoSDK {
+
+ /**
+ * Legacy crypto backend specific to each platform.
+ */
+ Legacy,
+
+ /**
+ * Cross-platform crypto backend written in Rust.
+ */
+ Rust,
+ }
+
enum class CryptoModule {
/**
@@ -75,8 +169,15 @@ data class Error(
return mutableMapOf().apply {
context?.let { put("context", it) }
cryptoModule?.let { put("cryptoModule", it.name) }
+ cryptoSDK?.let { put("cryptoSDK", it.name) }
put("domain", domain.name)
+ eventLocalAgeMillis?.let { put("eventLocalAgeMillis", it) }
+ isFederated?.let { put("isFederated", it) }
+ isMatrixDotOrg?.let { put("isMatrixDotOrg", it) }
put("name", name.name)
+ timeToDecryptMillis?.let { put("timeToDecryptMillis", it) }
+ userTrustsOwnIdentity?.let { put("userTrustsOwnIdentity", it) }
+ wasVisibleToUser?.let { put("wasVisibleToUser", it) }
}.takeIf { it.isNotEmpty() }
}
}
diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/Interaction.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/Interaction.kt
index 1df1b35439..4aa84353e5 100644
--- a/vector/src/main/java/im/vector/app/features/analytics/plan/Interaction.kt
+++ b/vector/src/main/java/im/vector/app/features/analytics/plan/Interaction.kt
@@ -85,11 +85,28 @@ data class Interaction(
*/
MobileRoomAddHome,
+ /**
+ * User switched the favourite toggle on Room Details screen.
+ */
+ MobileRoomFavouriteToggle,
+
/**
* User tapped on Leave Room button on Room Details screen.
*/
MobileRoomLeave,
+ /**
+ * User adjusted their favourite rooms using the context menu on a room
+ * in the room list.
+ */
+ MobileRoomListRoomContextMenuFavouriteToggle,
+
+ /**
+ * User adjusted their unread rooms using the context menu on a room in
+ * the room list.
+ */
+ MobileRoomListRoomContextMenuUnreadToggle,
+
/**
* User tapped on Threads button on Room screen.
*/
@@ -306,6 +323,18 @@ data class Interaction(
*/
WebRoomListRoomTileContextMenuLeaveItem,
+ /**
+ * User marked a message as read using the context menu on a room tile
+ * in the room list in Element Web/Desktop.
+ */
+ WebRoomListRoomTileContextMenuMarkRead,
+
+ /**
+ * User marked a room as unread using the context menu on a room tile in
+ * the room list in Element Web/Desktop.
+ */
+ WebRoomListRoomTileContextMenuMarkUnread,
+
/**
* User accessed room settings using the context menu on a room tile in
* the room list in Element Web/Desktop.
@@ -408,6 +437,18 @@ data class Interaction(
*/
WebThreadViewBackButton,
+ /**
+ * User clicked on the Threads Activity Centre button of Element
+ * Web/Desktop.
+ */
+ WebThreadsActivityCentreButton,
+
+ /**
+ * User clicked on a room in the Threads Activity Centre of Element
+ * Web/Desktop.
+ */
+ WebThreadsActivityCentreRoomItem,
+
/**
* User selected a thread in the Threads panel in Element Web/Desktop.
*/
diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/MobileScreen.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/MobileScreen.kt
index 59b53aaa89..d08b0d1921 100644
--- a/vector/src/main/java/im/vector/app/features/analytics/plan/MobileScreen.kt
+++ b/vector/src/main/java/im/vector/app/features/analytics/plan/MobileScreen.kt
@@ -119,6 +119,12 @@ data class MobileScreen(
*/
MyGroups,
+ /**
+ * The screen containing tests to help user to fix issues around
+ * notifications.
+ */
+ NotificationTroubleshoot,
+
/**
* The People tab on mobile that lists all the DM rooms you have joined.
*/
diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/NotificationTroubleshoot.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/NotificationTroubleshoot.kt
new file mode 100644
index 0000000000..9a4e6bd84b
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/analytics/plan/NotificationTroubleshoot.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.analytics.plan
+
+import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
+
+// GENERATED FILE, DO NOT EDIT. FOR MORE INFORMATION VISIT
+// https://github.com/matrix-org/matrix-analytics-events/
+
+/**
+ * Triggered when the user runs the troubleshoot notification test suite.
+ */
+data class NotificationTroubleshoot(
+ /**
+ * Whether one or more tests are in error.
+ */
+ val hasError: Boolean,
+) : VectorAnalyticsEvent {
+
+ override fun getName() = "NotificationTroubleshoot"
+
+ override fun getProperties(): Map? {
+ return mutableMapOf().apply {
+ put("hasError", hasError)
+ }.takeIf { it.isNotEmpty() }
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/PollEnd.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/PollEnd.kt
index f55e39522d..8750d70a5f 100644
--- a/vector/src/main/java/im/vector/app/features/analytics/plan/PollEnd.kt
+++ b/vector/src/main/java/im/vector/app/features/analytics/plan/PollEnd.kt
@@ -27,7 +27,7 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
data class PollEnd(
/**
* Do not use this. Remove this property when the kotlin type generator
- * can properly generate types without proprties other than the event
+ * can properly generate types without properties other than the event
* name.
*/
val doNotUse: Boolean? = null,
diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/PollVote.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/PollVote.kt
index 722f52bdec..9918063ef9 100644
--- a/vector/src/main/java/im/vector/app/features/analytics/plan/PollVote.kt
+++ b/vector/src/main/java/im/vector/app/features/analytics/plan/PollVote.kt
@@ -27,7 +27,7 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
data class PollVote(
/**
* Do not use this. Remove this property when the kotlin type generator
- * can properly generate types without proprties other than the event
+ * can properly generate types without properties other than the event
* name.
*/
val doNotUse: Boolean? = null,
diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/RoomModeration.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/RoomModeration.kt
new file mode 100644
index 0000000000..7dd03caa76
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/analytics/plan/RoomModeration.kt
@@ -0,0 +1,137 @@
+/*
+ * Copyright (c) 2021 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.analytics.plan
+
+import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
+
+// GENERATED FILE, DO NOT EDIT. FOR MORE INFORMATION VISIT
+// https://github.com/matrix-org/matrix-analytics-events/
+
+/**
+ * Triggered when a moderation action is performed within a room.
+ */
+data class RoomModeration(
+ /**
+ * The action that was performed.
+ */
+ val action: Action,
+ /**
+ * When the action sets a particular power level, this is the suggested
+ * role for that the power level.
+ */
+ val role: Role? = null,
+) : VectorAnalyticsEvent {
+
+ enum class Action {
+ /**
+ * Banned a room member.
+ */
+ BanMember,
+
+ /**
+ * Changed a room member's power level.
+ */
+ ChangeMemberRole,
+
+ /**
+ * Changed the power level required to ban room members.
+ */
+ ChangePermissionsBanMembers,
+
+ /**
+ * Changed the power level required to invite users to the room.
+ */
+ ChangePermissionsInviteUsers,
+
+ /**
+ * Changed the power level required to kick room members.
+ */
+ ChangePermissionsKickMembers,
+
+ /**
+ * Changed the power level required to redact messages in the room.
+ */
+ ChangePermissionsRedactMessages,
+
+ /**
+ * Changed the power level required to set the room's avatar.
+ */
+ ChangePermissionsRoomAvatar,
+
+ /**
+ * Changed the power level required to set the room's name.
+ */
+ ChangePermissionsRoomName,
+
+ /**
+ * Changed the power level required to set the room's topic.
+ */
+ ChangePermissionsRoomTopic,
+
+ /**
+ * Changed the power level required to send messages in the room.
+ */
+ ChangePermissionsSendMessages,
+
+ /**
+ * Kicked a room member.
+ */
+ KickMember,
+
+ /**
+ * Reset all of the room permissions back to their default values.
+ */
+ ResetPermissions,
+
+ /**
+ * Unbanned a room member.
+ */
+ UnbanMember,
+ }
+
+ enum class Role {
+
+ /**
+ * A power level of 100.
+ */
+ Administrator,
+
+ /**
+ * A power level of 50.
+ */
+ Moderator,
+
+ /**
+ * Any other power level.
+ */
+ Other,
+
+ /**
+ * A power level of 0.
+ */
+ User,
+ }
+
+ override fun getName() = "RoomModeration"
+
+ override fun getProperties(): Map? {
+ return mutableMapOf().apply {
+ put("action", action.name)
+ role?.let { put("role", it.name) }
+ }.takeIf { it.isNotEmpty() }
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/SuperProperties.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/SuperProperties.kt
new file mode 100644
index 0000000000..b62ae85a41
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/analytics/plan/SuperProperties.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2021 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.analytics.plan
+
+// GENERATED FILE, DO NOT EDIT. FOR MORE INFORMATION VISIT
+// https://github.com/matrix-org/matrix-analytics-events/
+
+/**
+ * Super Properties are properties associated with events that are sent with
+ * every capture call, be it a $pageview, an autocaptured button click, or
+ * anything else.
+ */
+data class SuperProperties(
+ /**
+ * Used by web to identify the platform (Web Platform/Electron Platform).
+ */
+ val appPlatform: String? = null,
+ /**
+ * Which crypto backend is the client currently using.
+ */
+ val cryptoSDK: CryptoSDK? = null,
+ /**
+ * Version of the crypto backend.
+ */
+ val cryptoSDKVersion: String? = null,
+) {
+
+ enum class CryptoSDK {
+ /**
+ * Legacy crypto backend specific to each platform.
+ */
+ Legacy,
+
+ /**
+ * Cross-platform crypto backend written in Rust.
+ */
+ Rust,
+ }
+
+ fun getProperties(): Map? {
+ return mutableMapOf().apply {
+ appPlatform?.let { put("appPlatform", it) }
+ cryptoSDK?.let { put("cryptoSDK", it.name) }
+ cryptoSDKVersion?.let { put("cryptoSDKVersion", it) }
+ }.takeIf { it.isNotEmpty() }
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/ViewRoom.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/ViewRoom.kt
index 366979025a..93c166dc88 100644
--- a/vector/src/main/java/im/vector/app/features/analytics/plan/ViewRoom.kt
+++ b/vector/src/main/java/im/vector/app/features/analytics/plan/ViewRoom.kt
@@ -252,6 +252,12 @@ data class ViewRoom(
*/
WebSpacePanelNotificationBadge,
+ /**
+ * Room accessed via interacting with the Threads Activity Centre in
+ * Element Web/Desktop.
+ */
+ WebThreadsActivityCentre,
+
/**
* Room accessed via Element Web/Desktop's Unified Search modal.
*/
diff --git a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapReAuthFragment.kt b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapReAuthFragment.kt
index f32ba735a1..d259f1f738 100644
--- a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapReAuthFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapReAuthFragment.kt
@@ -21,6 +21,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
+import androidx.lifecycle.ViewModelProvider
import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.withState
import dagger.hilt.android.AndroidEntryPoint
@@ -43,6 +44,12 @@ class BootstrapReAuthFragment :
views.bootstrapRetryButton.debouncedClicks { submit() }
views.bootstrapCancelButton.debouncedClicks { cancel() }
+
+ val viewModel = ViewModelProvider(this).get(BootstrapReAuthViewModel::class.java)
+ if (!viewModel.isFirstSubmitDone) {
+ viewModel.isFirstSubmitDone = true
+ submit()
+ }
}
private fun submit() = withState(sharedViewModel) { state ->
diff --git a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapReAuthViewModel.kt b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapReAuthViewModel.kt
new file mode 100644
index 0000000000..8dedaaa15a
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapReAuthViewModel.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2024 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.crypto.recover
+
+import androidx.lifecycle.ViewModel
+
+class BootstrapReAuthViewModel : ViewModel() {
+ var isFirstSubmitDone = false
+}
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 478ed4a58d..30bcf7f8eb 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
@@ -61,7 +61,8 @@ sealed class RoomDetailAction : VectorViewModelAction {
val senderId: String?,
val reason: String,
val spam: Boolean = false,
- val inappropriate: Boolean = false
+ val inappropriate: Boolean = false,
+ val user: Boolean = false,
) : RoomDetailAction()
data class IgnoreUser(val userId: String?) : RoomDetailAction()
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 feaad386cb..f80855663f 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
@@ -1345,6 +1345,16 @@ class TimelineFragment :
}
.show()
}
+ data.user -> {
+ MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_Vector_MaterialAlertDialog_NegativeDestructive)
+ .setTitle(R.string.user_reported_as_inappropriate_title)
+ .setMessage(R.string.user_reported_as_inappropriate_content)
+ .setPositiveButton(R.string.ok, null)
+ .setNegativeButton(R.string.block_user) { _, _ ->
+ timelineViewModel.handle(RoomDetailAction.IgnoreUser(data.senderId))
+ }
+ .show()
+ }
else -> {
MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_Vector_MaterialAlertDialog_NegativeDestructive)
.setTitle(R.string.content_reported_title)
@@ -1857,6 +1867,13 @@ class TimelineFragment :
is EventSharedAction.IgnoreUser -> {
action.senderId?.let { askConfirmationToIgnoreUser(it) }
}
+ is EventSharedAction.ReportUser -> {
+ timelineViewModel.handle(
+ RoomDetailAction.ReportContent(
+ action.eventId, action.senderId, "Reporting user ${action.senderId}", user = true
+ )
+ )
+ }
is EventSharedAction.OnUrlClicked -> {
onUrlClicked(action.url, action.title)
}
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 3793ed18d2..9e3802101e 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
@@ -1484,7 +1484,6 @@ class TimelineViewModel @AssistedInject constructor(
override fun onCleared() {
timeline?.dispose()
timeline?.removeAllListeners()
- decryptionFailureTracker.onTimeLineDisposed(initialState.roomId)
if (vectorPreferences.sendTypingNotifs()) {
room?.typingService()?.userStopsTyping()
}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt
index b0923885e8..a0d28be365 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt
@@ -45,7 +45,9 @@ import com.google.android.material.shape.MaterialShapeDrawable
import im.vector.app.R
import im.vector.app.core.extensions.setTextIfDifferent
import im.vector.app.core.extensions.showKeyboard
+import im.vector.app.core.utils.Debouncer
import im.vector.app.core.utils.DimensionConverter
+import im.vector.app.core.utils.createUIHandler
import im.vector.app.databinding.ComposerRichTextLayoutBinding
import im.vector.app.databinding.ViewRichTextMenuButtonBinding
import im.vector.app.features.home.room.detail.composer.images.UriContentListener
@@ -195,10 +197,16 @@ internal class RichTextComposerLayout @JvmOverloads constructor(
renderComposerMode(MessageComposerMode.Normal(null))
views.richTextComposerEditText.addTextChangedListener(
- TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder(isFullScreen) })
+ TextChangeListener(
+ onTextChanged = {
+ callback?.onTextChanged(it)
+ },
+ onExpandedChanged = { updateTextFieldBorder(isFullScreen) })
)
views.plainTextComposerEditText.addTextChangedListener(
- TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder(isFullScreen) })
+ TextChangeListener({
+ callback?.onTextChanged(it)
+ }, { updateTextFieldBorder(isFullScreen) })
)
ViewCompat.setOnReceiveContentListener(
views.richTextComposerEditText,
@@ -516,18 +524,21 @@ internal class RichTextComposerLayout @JvmOverloads constructor(
private val onTextChanged: (s: Editable) -> Unit,
private val onExpandedChanged: (isExpanded: Boolean) -> Unit,
) : TextWatcher {
+
+ private val debouncer = Debouncer(createUIHandler())
private var previousTextWasExpanded = false
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable) {
- onTextChanged.invoke(s)
-
- val isExpanded = s.lines().count() > 1
- if (previousTextWasExpanded != isExpanded) {
- onExpandedChanged(isExpanded)
+ debouncer.debounce("afterTextChanged", 50L) {
+ onTextChanged.invoke(s)
+ val isExpanded = s.lines().count() > 1
+ if (previousTextWasExpanded != isExpanded) {
+ onExpandedChanged(isExpanded)
+ }
+ previousTextWasExpanded = isExpanded
}
- previousTextWasExpanded = isExpanded
}
}
}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt
index 7bf9f536f2..18ff638390 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt
@@ -98,6 +98,9 @@ sealed class EventSharedAction(
data class IgnoreUser(val senderId: String?) :
EventSharedAction(R.string.message_ignore_user, R.drawable.ic_alert_triangle, true)
+ data class ReportUser(val eventId: String, val senderId: String?) :
+ EventSharedAction(R.string.message_report_user, R.drawable.ic_flag, true)
+
data class QuickReact(val eventId: String, val clickedOn: String, val add: Boolean) :
EventSharedAction(0, 0)
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 62aed5c3c6..8809c4f0bf 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
@@ -430,6 +430,12 @@ class MessageActionsViewModel @AssistedInject constructor(
add(EventSharedAction.Separator)
add(EventSharedAction.IgnoreUser(timelineEvent.root.senderId))
+ add(
+ EventSharedAction.ReportUser(
+ eventId = eventId,
+ senderId = timelineEvent.root.senderId,
+ )
+ )
}
}
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 84b71ceedf..3482eaf4ad 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
@@ -158,8 +158,8 @@ class TimelineItemFactory @Inject constructor(
defaultItemFactory.create(params)
}
}.also {
- if (it != null && event.isEncrypted()) {
- decryptionFailureTracker.e2eEventDisplayedInTimeline(event)
+ if (it != null && event.isEncrypted() && event.root.mCryptoError != null) {
+ decryptionFailureTracker.utdDisplayedInTimeline(event)
}
}
}
diff --git a/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileAction.kt b/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileAction.kt
index e2298d9b53..874f3c73b8 100644
--- a/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileAction.kt
+++ b/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileAction.kt
@@ -22,6 +22,7 @@ import im.vector.app.core.platform.VectorViewModelAction
sealed class RoomMemberProfileAction : VectorViewModelAction {
object RetryFetchingInfo : RoomMemberProfileAction()
object IgnoreUser : RoomMemberProfileAction()
+ object ReportUser : RoomMemberProfileAction()
data class BanOrUnbanUser(val reason: String?) : RoomMemberProfileAction()
data class KickUser(val reason: String?) : RoomMemberProfileAction()
object InviteUser : RoomMemberProfileAction()
diff --git a/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileController.kt b/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileController.kt
index 9585e6aaa1..e74bad1acb 100644
--- a/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileController.kt
+++ b/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileController.kt
@@ -39,6 +39,7 @@ class RoomMemberProfileController @Inject constructor(
interface Callback {
fun onIgnoreClicked()
+ fun onReportClicked()
fun onTapVerify()
fun onShowDeviceList()
fun onShowDeviceListNoCrossSigning()
@@ -225,7 +226,7 @@ class RoomMemberProfileController @Inject constructor(
title = stringProvider.getString(R.string.room_participants_action_invite),
destructive = false,
editable = false,
- divider = ignoreActionTitle != null,
+ divider = true,
action = { callback?.onInviteClicked() }
)
}
@@ -235,10 +236,18 @@ class RoomMemberProfileController @Inject constructor(
title = ignoreActionTitle,
destructive = true,
editable = false,
- divider = false,
+ divider = true,
action = { callback?.onIgnoreClicked() }
)
}
+ buildProfileAction(
+ id = "report",
+ title = stringProvider.getString(R.string.message_report_user),
+ destructive = true,
+ editable = false,
+ divider = false,
+ action = { callback?.onReportClicked() }
+ )
}
}
@@ -314,9 +323,9 @@ class RoomMemberProfileController @Inject constructor(
private fun RoomMemberProfileViewState.buildIgnoreActionTitle(): String? {
val isIgnored = isIgnored() ?: return null
return if (isIgnored) {
- stringProvider.getString(R.string.unignore)
+ stringProvider.getString(R.string.room_participants_action_unignore_title)
} else {
- stringProvider.getString(R.string.action_ignore)
+ stringProvider.getString(R.string.room_participants_action_ignore_title)
}
}
}
diff --git a/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileFragment.kt b/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileFragment.kt
index 020512af36..7ac5bfea0c 100644
--- a/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileFragment.kt
@@ -140,11 +140,20 @@ class RoomMemberProfileFragment :
is RoomMemberProfileViewEvents.OnIgnoreActionSuccess -> Unit
is RoomMemberProfileViewEvents.OnInviteActionSuccess -> Unit
RoomMemberProfileViewEvents.GoBack -> handleGoBack()
+ RoomMemberProfileViewEvents.OnReportActionSuccess -> handleReportSuccess()
}
}
setupLongClicks()
}
+ private fun handleReportSuccess() {
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.user_reported_as_inappropriate_title)
+ .setMessage(R.string.user_reported_as_inappropriate_content)
+ .setPositiveButton(R.string.ok, null)
+ .show()
+ }
+
private fun setupLongClicks() {
headerViews.memberProfileNameView.copyOnLongClick()
headerViews.memberProfileIdView.copyOnLongClick()
@@ -301,6 +310,10 @@ class RoomMemberProfileFragment :
}
}
+ override fun onReportClicked() {
+ viewModel.handle(RoomMemberProfileAction.ReportUser)
+ }
+
override fun onTapVerify() {
viewModel.handle(RoomMemberProfileAction.VerifyUser)
}
diff --git a/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileViewEvents.kt b/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileViewEvents.kt
index d04de8b936..0bf8ef1b6e 100644
--- a/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileViewEvents.kt
+++ b/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileViewEvents.kt
@@ -26,6 +26,7 @@ sealed class RoomMemberProfileViewEvents : VectorViewEvents {
data class Failure(val throwable: Throwable) : RoomMemberProfileViewEvents()
object OnIgnoreActionSuccess : RoomMemberProfileViewEvents()
+ object OnReportActionSuccess : RoomMemberProfileViewEvents()
object OnSetPowerLevelSuccess : RoomMemberProfileViewEvents()
object OnInviteActionSuccess : RoomMemberProfileViewEvents()
object OnKickActionSuccess : RoomMemberProfileViewEvents()
diff --git a/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileViewModel.kt b/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileViewModel.kt
index d38b2a0a69..f688793f4b 100644
--- a/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileViewModel.kt
@@ -161,6 +161,7 @@ class RoomMemberProfileViewModel @AssistedInject constructor(
when (action) {
is RoomMemberProfileAction.RetryFetchingInfo -> handleRetryFetchProfileInfo()
is RoomMemberProfileAction.IgnoreUser -> handleIgnoreAction()
+ is RoomMemberProfileAction.ReportUser -> handleReportAction()
is RoomMemberProfileAction.VerifyUser -> prepareVerification()
is RoomMemberProfileAction.ShareRoomMemberProfile -> handleShareRoomMemberProfile()
is RoomMemberProfileAction.SetPowerLevel -> handleSetPowerLevel(action)
@@ -172,6 +173,25 @@ class RoomMemberProfileViewModel @AssistedInject constructor(
}
}
+ private fun handleReportAction() {
+ viewModelScope.launch {
+ val event = try {
+ // The API need an Event, use the latest Event.
+ val latestEventId = room?.roomSummary()?.latestPreviewableEvent?.eventId ?: return@launch
+ room.reportingService()
+ .reportContent(
+ eventId = latestEventId,
+ score = -100,
+ reason = "Reporting user ${initialState.userId} (eventId is not relevant)"
+ )
+ RoomMemberProfileViewEvents.OnReportActionSuccess
+ } catch (failure: Throwable) {
+ RoomMemberProfileViewEvents.Failure(failure)
+ }
+ _viewEvents.post(event)
+ }
+ }
+
private fun handleOpenOrCreateDm(action: RoomMemberProfileAction.OpenOrCreateDm) {
viewModelScope.launch {
_viewEvents.post(RoomMemberProfileViewEvents.Loading())
diff --git a/vector/src/main/java/im/vector/app/features/settings/crosssigning/CrossSigningSettingsViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/crosssigning/CrossSigningSettingsViewModel.kt
index 51c7928e1f..3ea66b5dfe 100644
--- a/vector/src/main/java/im/vector/app/features/settings/crosssigning/CrossSigningSettingsViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/settings/crosssigning/CrossSigningSettingsViewModel.kt
@@ -27,6 +27,7 @@ import im.vector.app.core.resources.StringProvider
import im.vector.app.features.auth.PendingAuthHandler
import im.vector.app.features.login.ReAuthHelper
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@@ -52,6 +53,8 @@ class CrossSigningSettingsViewModel @AssistedInject constructor(
private val pendingAuthHandler: PendingAuthHandler,
) : VectorViewModel(initialState) {
+ private var observeCrossSigningJob: Job? = null
+
init {
observeCrossSigning()
}
@@ -90,6 +93,8 @@ class CrossSigningSettingsViewModel @AssistedInject constructor(
}
}
})
+ // Force a fast refresh of the data
+ observeCrossSigning()
} catch (failure: Throwable) {
handleInitializeXSigningError(failure)
} finally {
@@ -114,7 +119,8 @@ class CrossSigningSettingsViewModel @AssistedInject constructor(
// ) { myDevicesInfo, mxCrossSigningInfo ->
// myDevicesInfo to mxCrossSigningInfo
// }
- session.flow().liveCrossSigningInfo(session.myUserId)
+ observeCrossSigningJob?.cancel()
+ observeCrossSigningJob = session.flow().liveCrossSigningInfo(session.myUserId)
.onEach { data ->
val crossSigningKeys = data.getOrNull()
val xSigningIsEnableInAccount = crossSigningKeys != null
@@ -128,7 +134,8 @@ class CrossSigningSettingsViewModel @AssistedInject constructor(
xSigningKeyCanSign = xSigningKeyCanSign
)
}
- }.launchIn(viewModelScope)
+ }
+ .launchIn(viewModelScope)
}
private fun handleInitializeXSigningError(failure: Throwable) {
diff --git a/vector/src/test/java/im/vector/app/features/analytics/DecryptionFailureTrackerTest.kt b/vector/src/test/java/im/vector/app/features/analytics/DecryptionFailureTrackerTest.kt
new file mode 100644
index 0000000000..2f11d4c2eb
--- /dev/null
+++ b/vector/src/test/java/im/vector/app/features/analytics/DecryptionFailureTrackerTest.kt
@@ -0,0 +1,760 @@
+/*
+ * Copyright (c) 2024 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
+
+import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
+import im.vector.app.features.analytics.plan.Error
+import im.vector.app.test.fakes.FakeActiveSessionDataSource
+import im.vector.app.test.fakes.FakeAnalyticsTracker
+import im.vector.app.test.fakes.FakeClock
+import im.vector.app.test.fakes.FakeSession
+import im.vector.app.test.shared.createTimberTestRule
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
+import io.mockk.slot
+import io.mockk.verify
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.amshove.kluent.shouldBeEqualTo
+import org.amshove.kluent.shouldNotBeEqualTo
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.matrix.android.sdk.api.auth.LoginType
+import org.matrix.android.sdk.api.auth.data.Credentials
+import org.matrix.android.sdk.api.auth.data.SessionParams
+import org.matrix.android.sdk.api.session.crypto.MXCryptoError
+import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo
+import org.matrix.android.sdk.api.session.events.model.Event
+import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
+import java.text.SimpleDateFormat
+
+@ExperimentalCoroutinesApi
+class DecryptionFailureTrackerTest {
+
+ @Rule
+ fun timberTestRule() = createTimberTestRule()
+
+ private val fakeAnalyticsTracker = FakeAnalyticsTracker()
+
+ private val fakeActiveSessionDataSource = FakeActiveSessionDataSource()
+
+ private val fakeClock = FakeClock()
+
+ private val decryptionFailureTracker = DecryptionFailureTracker(
+ fakeAnalyticsTracker,
+ fakeActiveSessionDataSource.instance,
+ fakeClock
+ )
+
+ private val aCredential = Credentials(
+ userId = "@alice:matrix.org",
+ deviceId = "ABCDEFGHT",
+ homeServer = "http://matrix.org",
+ accessToken = "qwerty",
+ refreshToken = null,
+ )
+
+ private val fakeMxOrgTestSession = FakeSession().apply {
+ givenSessionParams(
+ SessionParams(
+ credentials = aCredential,
+ homeServerConnectionConfig = mockk(relaxed = true),
+ isTokenValid = true,
+ loginType = LoginType.PASSWORD
+ )
+ )
+ fakeUserId = "@alice:matrix.org"
+ }
+
+ private val aUISIError = MXCryptoError.Base(
+ MXCryptoError.ErrorType.UNKNOWN_INBOUND_SESSION_ID,
+ "",
+ detailedErrorDescription = ""
+ )
+
+ private val aFakeBobMxOrgEvent = Event(
+ originServerTs = 90_000,
+ eventId = "$000",
+ senderId = "@bob:matrix.org",
+ roomId = "!roomA"
+ )
+
+ @Before
+ fun setupTest() {
+ fakeMxOrgTestSession.fakeCryptoService.fakeCrossSigningService.givenIsCrossSigningVerifiedReturns(false)
+ }
+
+ @Test
+ fun `should report late decryption to analytics tracker`() = runTest {
+ val fakeSession = fakeMxOrgTestSession
+
+ every {
+ fakeAnalyticsTracker.capture(any())
+ } just runs
+
+ fakeClock.givenEpoch(100_000)
+ fakeActiveSessionDataSource.setActiveSession(fakeSession)
+ decryptionFailureTracker.start(CoroutineScope(coroutineContext))
+ runCurrent()
+
+ fakeSession.fakeCryptoService.fakeCrossSigningService.givenIsCrossSigningVerifiedReturns(true)
+
+ val event = aFakeBobMxOrgEvent
+
+ decryptionFailureTracker.onEventDecryptionError(event, aUISIError)
+ runCurrent()
+ // advance time by 5 seconds
+ fakeClock.givenEpoch(105_000)
+
+ // Now simulate it's decrypted
+ decryptionFailureTracker.onEventDecrypted(event, emptyMap())
+ runCurrent()
+
+ // it should report
+ verify(exactly = 1) { fakeAnalyticsTracker.capture(any()) }
+
+ verify {
+ fakeAnalyticsTracker.capture(
+ im.vector.app.features.analytics.plan.Error(
+ "mxc_crypto_error_type|",
+ cryptoModule = Error.CryptoModule.Rust,
+ domain = Error.Domain.E2EE,
+ name = Error.Name.OlmKeysNotSentError,
+ cryptoSDK = Error.CryptoSDK.Rust,
+ timeToDecryptMillis = 5000,
+ isFederated = false,
+ isMatrixDotOrg = true,
+ userTrustsOwnIdentity = true,
+ wasVisibleToUser = false
+ ),
+ )
+ }
+
+ // Can't do that in @Before function, it wont work as test will fail with:
+ // "the test coroutine is not completing, there were active child jobs"
+ // as the decryptionFailureTracker is setup to use the current test coroutine scope (?)
+ decryptionFailureTracker.stop()
+ }
+
+ @Test
+ fun `should not report graced late decryption to analytics tracker`() = runTest {
+ val fakeSession = fakeMxOrgTestSession
+
+ var currentFakeTime = 100_000L
+ fakeClock.givenEpoch(currentFakeTime)
+ fakeActiveSessionDataSource.setActiveSession(fakeSession)
+ decryptionFailureTracker.start(CoroutineScope(coroutineContext))
+ runCurrent()
+
+ val event = aFakeBobMxOrgEvent
+
+ decryptionFailureTracker.onEventDecryptionError(event, aUISIError)
+
+ runCurrent()
+ // advance time by 3 seconds
+ currentFakeTime += 3_000
+ fakeClock.givenEpoch(currentFakeTime)
+
+ // Now simulate it's decrypted
+ decryptionFailureTracker.onEventDecrypted(
+ event,
+ emptyMap()
+ )
+
+ runCurrent()
+
+ // it should not have reported it
+ verify(exactly = 0) { fakeAnalyticsTracker.capture(any()) }
+
+ decryptionFailureTracker.stop()
+ }
+
+ @Test
+ fun `should report time to decrypt for late decryption`() = runTest {
+ val fakeSession = fakeMxOrgTestSession
+
+ val eventSlot = slot()
+
+ every {
+ fakeAnalyticsTracker.capture(event = capture(eventSlot))
+ } just runs
+
+ var currentFakeTime = 100_000L
+ fakeClock.givenEpoch(currentFakeTime)
+ fakeActiveSessionDataSource.setActiveSession(fakeSession)
+ decryptionFailureTracker.start(CoroutineScope(coroutineContext))
+ runCurrent()
+
+ fakeSession.fakeCryptoService.fakeCrossSigningService.givenIsCrossSigningVerifiedReturns(true)
+
+ val event = aFakeBobMxOrgEvent
+ decryptionFailureTracker.onEventDecryptionError(event, aUISIError)
+
+ runCurrent()
+ // advance time by 7 seconds, to be ahead of the 3 seconds grace period
+ currentFakeTime += 7_000
+ fakeClock.givenEpoch(currentFakeTime)
+
+ // Now simulate it's decrypted
+ decryptionFailureTracker.onEventDecrypted(
+ event,
+ emptyMap()
+ )
+
+ runCurrent()
+
+ // it should report
+ verify(exactly = 1) { fakeAnalyticsTracker.capture(any()) }
+
+ val error = eventSlot.captured as Error
+ error.timeToDecryptMillis shouldBeEqualTo 7000
+
+ decryptionFailureTracker.stop()
+ }
+
+ @Test
+ fun `should report isMatrixDotOrg`() = runTest {
+ val fakeSession = fakeMxOrgTestSession
+
+ val eventSlot = slot()
+
+ every {
+ fakeAnalyticsTracker.capture(event = capture(eventSlot))
+ } just runs
+
+ var currentFakeTime = 100_000L
+ fakeClock.givenEpoch(currentFakeTime)
+ fakeActiveSessionDataSource.setActiveSession(fakeSession)
+ decryptionFailureTracker.start(CoroutineScope(coroutineContext))
+ runCurrent()
+
+ val event = aFakeBobMxOrgEvent
+
+ decryptionFailureTracker.onEventDecryptionError(event, aUISIError)
+ runCurrent()
+
+ // advance time by 7 seconds, to be ahead of the grace period
+ currentFakeTime += 7_000
+ fakeClock.givenEpoch(currentFakeTime)
+
+ // Now simulate it's decrypted
+ decryptionFailureTracker.onEventDecrypted(event, emptyMap())
+ runCurrent()
+
+ val error = eventSlot.captured as Error
+ error.isMatrixDotOrg shouldBeEqualTo true
+
+ val otherSession = FakeSession().apply {
+ givenSessionParams(
+ SessionParams(
+ credentials = aCredential.copy(userId = "@alice:another.org"),
+ homeServerConnectionConfig = mockk(relaxed = true),
+ isTokenValid = true,
+ loginType = LoginType.PASSWORD
+ )
+ )
+ every { sessionId } returns "WWEERE"
+ fakeUserId = "@alice:another.org"
+ this.fakeCryptoService.fakeCrossSigningService.givenIsCrossSigningVerifiedReturns(true)
+ }
+ fakeActiveSessionDataSource.setActiveSession(otherSession)
+ runCurrent()
+
+ val event2 = aFakeBobMxOrgEvent.copy(eventId = "$001")
+
+ decryptionFailureTracker.onEventDecryptionError(event2, aUISIError)
+ runCurrent()
+
+ // advance time by 7 seconds, to be ahead of the grace period
+ currentFakeTime += 7_000
+ fakeClock.givenEpoch(currentFakeTime)
+
+ // Now simulate it's decrypted
+ decryptionFailureTracker.onEventDecrypted(event2, emptyMap())
+ runCurrent()
+
+ (eventSlot.captured as Error).isMatrixDotOrg shouldBeEqualTo false
+
+ decryptionFailureTracker.stop()
+ }
+
+ @Test
+ fun `should report if user trusted it's identity at time of decryption`() = runTest {
+ val fakeSession = fakeMxOrgTestSession
+
+ val eventSlot = slot()
+
+ every {
+ fakeAnalyticsTracker.capture(event = capture(eventSlot))
+ } just runs
+
+ var currentFakeTime = 100_000L
+ fakeClock.givenEpoch(currentFakeTime)
+ fakeActiveSessionDataSource.setActiveSession(fakeSession)
+ decryptionFailureTracker.start(CoroutineScope(coroutineContext))
+ runCurrent()
+
+ fakeSession.fakeCryptoService.fakeCrossSigningService.givenIsCrossSigningVerifiedReturns(false)
+ val event = aFakeBobMxOrgEvent
+
+ decryptionFailureTracker.onEventDecryptionError(event, aUISIError)
+ runCurrent()
+
+ fakeSession.fakeCryptoService.fakeCrossSigningService.givenIsCrossSigningVerifiedReturns(true)
+ val event2 = aFakeBobMxOrgEvent.copy(eventId = "$001")
+ decryptionFailureTracker.onEventDecryptionError(event2, aUISIError)
+ runCurrent()
+
+ // advance time by 7 seconds, to be ahead of the grace period
+ currentFakeTime += 7_000
+ fakeClock.givenEpoch(currentFakeTime)
+
+ // Now simulate it's decrypted
+ decryptionFailureTracker.onEventDecrypted(event, emptyMap())
+ runCurrent()
+
+ (eventSlot.captured as Error).userTrustsOwnIdentity shouldBeEqualTo false
+
+ decryptionFailureTracker.onEventDecrypted(event2, emptyMap())
+ runCurrent()
+
+ (eventSlot.captured as Error).userTrustsOwnIdentity shouldBeEqualTo true
+
+ verify(exactly = 2) { fakeAnalyticsTracker.capture(any()) }
+
+ decryptionFailureTracker.stop()
+ }
+
+ @Test
+ fun `should not report same event twice`() = runTest {
+ val fakeSession = fakeMxOrgTestSession
+
+ every {
+ fakeAnalyticsTracker.capture(any())
+ } just runs
+
+ var currentFakeTime = 100_000L
+ fakeClock.givenEpoch(currentFakeTime)
+ fakeActiveSessionDataSource.setActiveSession(fakeSession)
+ decryptionFailureTracker.start(CoroutineScope(coroutineContext))
+ runCurrent()
+
+ val event = aFakeBobMxOrgEvent
+
+ decryptionFailureTracker.onEventDecryptionError(event, aUISIError)
+
+ runCurrent()
+
+ // advance time by 7 seconds, to be ahead of the grace period
+ currentFakeTime += 7_000
+ fakeClock.givenEpoch(currentFakeTime)
+
+ // Now simulate it's decrypted
+ decryptionFailureTracker.onEventDecrypted(event, emptyMap())
+ runCurrent()
+
+ verify(exactly = 1) { fakeAnalyticsTracker.capture(any()) }
+
+ decryptionFailureTracker.onEventDecryptionError(event, aUISIError)
+ runCurrent()
+
+ // advance time by 7 seconds, to be ahead of the grace period
+ currentFakeTime += 7_000
+ fakeClock.givenEpoch(currentFakeTime)
+
+ decryptionFailureTracker.onEventDecrypted(event, emptyMap())
+ runCurrent()
+
+ verify(exactly = 1) { fakeAnalyticsTracker.capture(any()) }
+
+ decryptionFailureTracker.stop()
+ }
+
+ @Test
+ fun `should report if isFedrated`() = runTest {
+ val fakeSession = fakeMxOrgTestSession
+
+ val eventSlot = slot()
+
+ every {
+ fakeAnalyticsTracker.capture(event = capture(eventSlot))
+ } just runs
+
+ var currentFakeTime = 100_000L
+ fakeClock.givenEpoch(currentFakeTime)
+ fakeActiveSessionDataSource.setActiveSession(fakeSession)
+ decryptionFailureTracker.start(CoroutineScope(coroutineContext))
+ runCurrent()
+
+ val event = aFakeBobMxOrgEvent
+
+ decryptionFailureTracker.onEventDecryptionError(event, aUISIError)
+ runCurrent()
+
+ val event2 = aFakeBobMxOrgEvent.copy(
+ eventId = "$001",
+ senderId = "@bob:another.org",
+ )
+ decryptionFailureTracker.onEventDecryptionError(event2, aUISIError)
+ runCurrent()
+
+ // advance time by 7 seconds, to be ahead of the grace period
+ currentFakeTime += 7_000
+ fakeClock.givenEpoch(currentFakeTime)
+
+ // Now simulate it's decrypted
+ decryptionFailureTracker.onEventDecrypted(event, emptyMap())
+ runCurrent()
+
+ (eventSlot.captured as Error).isFederated shouldBeEqualTo false
+
+ decryptionFailureTracker.onEventDecrypted(event2, emptyMap())
+ runCurrent()
+
+ (eventSlot.captured as Error).isFederated shouldBeEqualTo true
+
+ verify(exactly = 2) { fakeAnalyticsTracker.capture(any()) }
+
+ decryptionFailureTracker.stop()
+ }
+
+ @Test
+ fun `should report if wasVisibleToUser`() = runTest {
+ val fakeSession = fakeMxOrgTestSession
+ val eventSlot = slot()
+
+ every {
+ fakeAnalyticsTracker.capture(event = capture(eventSlot))
+ } just runs
+ var currentFakeTime = 100_000L
+ fakeClock.givenEpoch(currentFakeTime)
+ fakeActiveSessionDataSource.setActiveSession(fakeSession)
+ decryptionFailureTracker.start(CoroutineScope(coroutineContext))
+ runCurrent()
+
+ val event = aFakeBobMxOrgEvent
+
+ decryptionFailureTracker.onEventDecryptionError(event, aUISIError)
+ runCurrent()
+
+ val event2 = aFakeBobMxOrgEvent.copy(
+ eventId = "$001",
+ senderId = "@bob:another.org",
+ )
+ decryptionFailureTracker.onEventDecryptionError(event2, aUISIError)
+ runCurrent()
+
+ decryptionFailureTracker.utdDisplayedInTimeline(
+ mockk(relaxed = true).apply {
+ every { root } returns event2
+ every { eventId } returns event2.eventId.orEmpty()
+ }
+ )
+
+ // advance time by 7 seconds, to be ahead of the grace period
+ currentFakeTime += 7_000
+ fakeClock.givenEpoch(currentFakeTime)
+
+ // Now simulate it's decrypted
+ decryptionFailureTracker.onEventDecrypted(event, emptyMap())
+ runCurrent()
+
+ (eventSlot.captured as Error).wasVisibleToUser shouldBeEqualTo false
+
+ decryptionFailureTracker.onEventDecrypted(event2, emptyMap())
+ runCurrent()
+
+ (eventSlot.captured as Error).wasVisibleToUser shouldBeEqualTo true
+
+ verify(exactly = 2) { fakeAnalyticsTracker.capture(any()) }
+
+ decryptionFailureTracker.stop()
+ }
+
+ @Test
+ fun `should report if event relative age to session`() = runTest {
+ val fakeSession = fakeMxOrgTestSession
+
+ val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
+ val historicalEventTimestamp = formatter.parse("2024-03-08 09:24:11")!!.time
+ val sessionCreationTime = formatter.parse("2024-03-09 10:00:00")!!.time
+ // 1mn after creation
+ val liveEventTimestamp = formatter.parse("2024-03-09 10:01:00")!!.time
+
+ val eventSlot = slot()
+
+ every {
+ fakeAnalyticsTracker.capture(event = capture(eventSlot))
+ } just runs
+
+ fakeSession.fakeCryptoService.cryptoDeviceInfo = CryptoDeviceInfo(
+ deviceId = "ABCDEFGHT",
+ userId = "@alice:matrix.org",
+ firstTimeSeenLocalTs = sessionCreationTime
+ )
+
+ var currentFakeTime = 100_000L
+ fakeClock.givenEpoch(currentFakeTime)
+ fakeActiveSessionDataSource.setActiveSession(fakeSession)
+ decryptionFailureTracker.start(CoroutineScope(coroutineContext))
+ runCurrent()
+
+ val event = aFakeBobMxOrgEvent.copy(
+ originServerTs = historicalEventTimestamp
+ )
+
+ decryptionFailureTracker.onEventDecryptionError(event, aUISIError)
+ runCurrent()
+
+ val liveEvent = aFakeBobMxOrgEvent.copy(
+ eventId = "$001",
+ originServerTs = liveEventTimestamp
+ )
+ decryptionFailureTracker.onEventDecryptionError(liveEvent, aUISIError)
+ runCurrent()
+
+ // advance time by 7 seconds, to be ahead of the grace period
+ currentFakeTime += 7_000
+ fakeClock.givenEpoch(currentFakeTime)
+
+ // Now simulate historical event late decrypt
+ decryptionFailureTracker.onEventDecrypted(event, emptyMap())
+ runCurrent()
+
+ (eventSlot.captured as Error).eventLocalAgeMillis shouldBeEqualTo (historicalEventTimestamp - sessionCreationTime).toInt()
+
+ decryptionFailureTracker.onEventDecrypted(liveEvent, emptyMap())
+ runCurrent()
+
+ (eventSlot.captured as Error).eventLocalAgeMillis shouldBeEqualTo (liveEventTimestamp - sessionCreationTime).toInt()
+ (eventSlot.captured as Error).eventLocalAgeMillis shouldBeEqualTo 60 * 1000
+
+ verify(exactly = 2) { fakeAnalyticsTracker.capture(any()) }
+
+ decryptionFailureTracker.stop()
+ }
+
+ @Test
+ fun `should report historical UTDs as an expected UTD if not verified`() = runTest {
+ val fakeSession = fakeMxOrgTestSession
+
+ val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
+ val historicalEventTimestamp = formatter.parse("2024-03-08 09:24:11")!!.time
+ val sessionCreationTime = formatter.parse("2024-03-09 10:00:00")!!.time
+
+ val eventSlot = slot()
+
+ every {
+ fakeAnalyticsTracker.capture(event = capture(eventSlot))
+ } just runs
+
+ fakeSession.fakeCryptoService.cryptoDeviceInfo = CryptoDeviceInfo(
+ deviceId = "ABCDEFGHT",
+ userId = "@alice:matrix.org",
+ firstTimeSeenLocalTs = sessionCreationTime
+ )
+
+ var currentFakeTime = 100_000L
+ fakeClock.givenEpoch(currentFakeTime)
+ fakeActiveSessionDataSource.setActiveSession(fakeSession)
+ decryptionFailureTracker.start(CoroutineScope(coroutineContext))
+ runCurrent()
+
+ // historical event and session not verified
+ fakeSession.fakeCryptoService.fakeCrossSigningService.givenIsCrossSigningVerifiedReturns(false)
+ val event = aFakeBobMxOrgEvent.copy(
+ originServerTs = historicalEventTimestamp
+ )
+ decryptionFailureTracker.onEventDecryptionError(event, aUISIError)
+ runCurrent()
+
+ // advance time to be ahead of the permanent UTD period
+ currentFakeTime += 70_000
+ fakeClock.givenEpoch(currentFakeTime)
+ advanceTimeBy(70_000)
+ runCurrent()
+
+ (eventSlot.captured as Error).name shouldBeEqualTo Error.Name.HistoricalMessage
+
+ decryptionFailureTracker.stop()
+ }
+
+ @Test
+ fun `should not report historical UTDs as an expected UTD if verified`() = runTest {
+ val fakeSession = fakeMxOrgTestSession
+
+ val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
+ val historicalEventTimestamp = formatter.parse("2024-03-08 09:24:11")!!.time
+ val sessionCreationTime = formatter.parse("2024-03-09 10:00:00")!!.time
+
+ val eventSlot = slot()
+
+ every {
+ fakeAnalyticsTracker.capture(event = capture(eventSlot))
+ } just runs
+
+ fakeSession.fakeCryptoService.cryptoDeviceInfo = CryptoDeviceInfo(
+ deviceId = "ABCDEFGHT",
+ userId = "@alice:matrix.org",
+ firstTimeSeenLocalTs = sessionCreationTime
+ )
+
+ var currentFakeTime = 100_000L
+ fakeClock.givenEpoch(currentFakeTime)
+ fakeActiveSessionDataSource.setActiveSession(fakeSession)
+ decryptionFailureTracker.start(CoroutineScope(coroutineContext))
+ runCurrent()
+
+ // historical event and session not verified
+ fakeSession.fakeCryptoService.fakeCrossSigningService.givenIsCrossSigningVerifiedReturns(true)
+ val event = aFakeBobMxOrgEvent.copy(
+ originServerTs = historicalEventTimestamp
+ )
+ decryptionFailureTracker.onEventDecryptionError(event, aUISIError)
+ runCurrent()
+
+ // advance time to be ahead of the permanent UTD period
+ currentFakeTime += 70_000
+ fakeClock.givenEpoch(currentFakeTime)
+ advanceTimeBy(70_000)
+ runCurrent()
+
+ (eventSlot.captured as Error).name shouldNotBeEqualTo Error.Name.HistoricalMessage
+
+ decryptionFailureTracker.stop()
+ }
+
+ @Test
+ fun `should not report live UTDs as an expected UTD even if not verified`() = runTest {
+ val fakeSession = fakeMxOrgTestSession
+
+ val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
+ val sessionCreationTime = formatter.parse("2024-03-09 10:00:00")!!.time
+ // 1mn after creation
+ val liveEventTimestamp = formatter.parse("2024-03-09 10:01:00")!!.time
+
+ val eventSlot = slot()
+
+ every {
+ fakeAnalyticsTracker.capture(event = capture(eventSlot))
+ } just runs
+
+ fakeSession.fakeCryptoService.cryptoDeviceInfo = CryptoDeviceInfo(
+ deviceId = "ABCDEFGHT",
+ userId = "@alice:matrix.org",
+ firstTimeSeenLocalTs = sessionCreationTime
+ )
+
+ var currentFakeTime = 100_000L
+ fakeClock.givenEpoch(currentFakeTime)
+ fakeActiveSessionDataSource.setActiveSession(fakeSession)
+ decryptionFailureTracker.start(CoroutineScope(coroutineContext))
+ runCurrent()
+
+ // historical event and session not verified
+ fakeSession.fakeCryptoService.fakeCrossSigningService.givenIsCrossSigningVerifiedReturns(false)
+ val event = aFakeBobMxOrgEvent.copy(
+ originServerTs = liveEventTimestamp
+ )
+ decryptionFailureTracker.onEventDecryptionError(event, aUISIError)
+ runCurrent()
+
+ // advance time to be ahead of the permanent UTD period
+ currentFakeTime += 70_000
+ fakeClock.givenEpoch(currentFakeTime)
+ advanceTimeBy(70_000)
+ runCurrent()
+
+ (eventSlot.captured as Error).name shouldNotBeEqualTo Error.Name.HistoricalMessage
+
+ decryptionFailureTracker.stop()
+ }
+
+ @Test
+ fun `should report if permanent UTD`() = runTest {
+ val fakeSession = fakeMxOrgTestSession
+
+ val eventSlot = slot()
+
+ every {
+ fakeAnalyticsTracker.capture(event = capture(eventSlot))
+ } just runs
+
+ var currentFakeTime = 100_000L
+ fakeClock.givenEpoch(currentFakeTime)
+ fakeActiveSessionDataSource.setActiveSession(fakeSession)
+ decryptionFailureTracker.start(CoroutineScope(coroutineContext))
+ runCurrent()
+
+ val event = aFakeBobMxOrgEvent
+
+ decryptionFailureTracker.onEventDecryptionError(event, aUISIError)
+ runCurrent()
+
+ currentFakeTime += 70_000
+ fakeClock.givenEpoch(currentFakeTime)
+ advanceTimeBy(70_000)
+ runCurrent()
+
+ verify(exactly = 1) { fakeAnalyticsTracker.capture(any()) }
+
+ (eventSlot.captured as Error).timeToDecryptMillis shouldBeEqualTo -1
+ decryptionFailureTracker.stop()
+ }
+
+ @Test
+ fun `with multiple UTD`() = runTest {
+ val fakeSession = fakeMxOrgTestSession
+
+ every {
+ fakeAnalyticsTracker.capture(any())
+ } just runs
+
+ var currentFakeTime = 100_000L
+ fakeClock.givenEpoch(currentFakeTime)
+ fakeActiveSessionDataSource.setActiveSession(fakeSession)
+ decryptionFailureTracker.start(CoroutineScope(coroutineContext))
+ runCurrent()
+
+ val events = (0..10).map {
+ aFakeBobMxOrgEvent.copy(
+ eventId = "000$it",
+ originServerTs = 50_000 + it * 1000L
+ )
+ }
+
+ events.forEach {
+ decryptionFailureTracker.onEventDecryptionError(it, aUISIError)
+ }
+ runCurrent()
+
+ currentFakeTime += 70_000
+ fakeClock.givenEpoch(currentFakeTime)
+ advanceTimeBy(70_000)
+ runCurrent()
+
+ verify(exactly = 11) { fakeAnalyticsTracker.capture(any()) }
+
+ decryptionFailureTracker.stop()
+ }
+}
diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt
index 12da88d286..9c791305b6 100644
--- a/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt
+++ b/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt
@@ -53,7 +53,10 @@ class FakeSession(
mockkStatic("im.vector.app.core.extensions.SessionKt")
}
- override val myUserId: String = "@fake:server.fake"
+ var fakeUserId = "@fake:server.fake"
+
+ override val myUserId: String
+ get() = fakeUserId
override val coroutineDispatchers = testCoroutineDispatchers
diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeUri.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeUri.kt
index 08bfac8db1..e24f14294e 100644
--- a/vector/src/test/java/im/vector/app/test/fakes/FakeUri.kt
+++ b/vector/src/test/java/im/vector/app/test/fakes/FakeUri.kt
@@ -28,6 +28,7 @@ class FakeUri(contentEquals: String? = null) {
contentEquals?.let {
givenEquals(it)
every { instance.toString() } returns it
+ every { instance.scheme } returns contentEquals.substring(0, contentEquals.indexOf(':'))
}
}