diff --git a/.gitignore b/.gitignore index a53a3eba9e9..650ab930a61 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 df477df4d4b..c4cd36e50fb 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 8b9125d9e8a..7c9ca635365 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 00000000000..ec926749603 --- /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-de/strings.xml b/library/ui-strings/src/main/res/values-de/strings.xml index 9de02cbbaac..7775283afaa 100644 --- a/library/ui-strings/src/main/res/values-de/strings.xml +++ b/library/ui-strings/src/main/res/values-de/strings.xml @@ -2562,7 +2562,7 @@ E-Mail nicht bestätigt, prüfe deinen Posteingang Willkommen zurück! Passwort vergessen - Nutzername / E-Mail-Adresse / Telefonnummer + Nutzername / E-Mail-Adresse Erstelle dein Konto Server-URL Wie lautet die Adresse deines Servers\? Dies ist eine Art Zuhause für all deine Daten diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index cf669b3d8ef..d00931b22a1 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -1973,8 +1973,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 f46fe81432f..d2d3dd2d29b 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 b4b283c86a2..ce0a01a4918 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 12255d07830..25a1ffc0053 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 0bd6ed06d1c..d2865f0f65f 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 f90ae4a3452..4fe59fb1dde 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 c0407ca4e8d..5ba74f705b2 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 ce34b0430ec..3e7beed0472 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 f713d53392a..043a0544dd6 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 7b41c127736..fe4cc3311b0 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 4a9d8ae266c..7188fb0dc93 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 472d0896f9a..5523c849944 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 00000000000..f7fb177e12c --- /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 d596741d532..fcbc67169eb 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 { + + // 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() - private val scope: CoroutineScope = CoroutineScope(SupervisorJob()) - private val failures = mutableListOf() + // 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) + } + } - private fun addDecryptionFailure(failure: DecryptionFailure) { - // de duplicate - synchronized(failures) { - if (failures.none { it.failedEventId == failure.failedEventId }) { - failures.add(failure) + // LiveEventListener callbacks + + override fun onEventDecrypted(event: Event, clearEvent: JsonDict) { + Timber.v("Event decrypted ${event.eventId}") + event.eventId?.let { + post { + handleEventDecrypted(it) } } } - private fun removeFailureForEventId(eventId: String) { - synchronized(failures) { - failures.removeIfCompat { it.failedEventId == eventId } + 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 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) - } - } + 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 - aggregatedErrors = toReport - .groupBy { it.error.toAnalyticsErrorName() } - .mapValues { - it.value.map { it.failedEventId } - } + if (currentTicker == null) { + rescheduleTicker() } + } - 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) - } + 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) } } - 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 + 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() + Timber.v("Check failures now $now") + // report the definitely failed + val toReport = trackedEventsMap.values.filter { + now - it.timeStamp > MAX_WAIT_MILLIS + } + toReport.forEach { + reportFailure( + it.copy(timeToDecryptMillis = -1) + ) } - 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 2a7d0ac975f..acc6ebf51e8 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 386c0908488..98553b92586 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 1df1b35439b..4aa84353e58 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 59b53aaa89d..d08b0d1921a 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 00000000000..9a4e6bd84b0 --- /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 f55e39522d3..8750d70a5f9 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 722f52bdec8..9918063ef9b 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 00000000000..7dd03caa766 --- /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 00000000000..b62ae85a41a --- /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 366979025a5..93c166dc88b 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 f32ba735a18..d259f1f7381 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 00000000000..8dedaaa15a6 --- /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 478ed4a58d8..30bcf7f8eb9 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 feaad386cb5..f80855663fd 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 3793ed18d2e..9e3802101eb 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 b0923885e8d..a0d28be3652 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 7bf9f536f20..18ff638390b 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 62aed5c3c61..8809c4f0bf1 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 84b71ceedfc..3482eaf4ad2 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 e2298d9b53d..874f3c73b8a 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 9585e6aaa15..e74bad1acb1 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 020512af360..7ac5bfea0cc 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 d04de8b9369..0bf8ef1b6e4 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 d38b2a0a692..f688793f4b4 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 51c7928e1f3..3ea66b5dfe1 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 00000000000..2f11d4c2eb1 --- /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 12da88d286c..9c791305b6a 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 08bfac8db15..e24f14294ec 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(':')) } }