Merge branch 'release/1.6.14' into main

This commit is contained in:
Benoit Marty 2024-04-02 18:15:34 +02:00
commit 310cecf5cb
46 changed files with 1655 additions and 141 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@
/local.properties /local.properties
# idea files: exclude everything except dictionnaries # idea files: exclude everything except dictionnaries
.idea/caches .idea/caches
.idea/copilot
.idea/libraries .idea/libraries
.idea/inspectionProfiles .idea/inspectionProfiles
.idea/sonarlint .idea/sonarlint

View File

@ -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) Changes in Element v1.6.12 (2024-02-16)
======================================= =======================================
@ -5,8 +19,8 @@ This update provides important security fixes, please update now.
Security fixes 🔐 Security fixes 🔐
----------------- -----------------
- Add a check on incoming intent. ([#1506 internal](https://github.com/matrix-org/internal-config/issues/1506)) - 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 the media folder. ([#1505 internal](https://github.com/matrix-org/internal-config/issues/1505)) - 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 🐛 Bugfixes 🐛
---------- ----------

View File

@ -101,7 +101,7 @@ ext.libs = [
], ],
element : [ element : [
'opusencoder' : "io.element.android:opusencoder:1.1.0", '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 : [ squareup : [
'moshi' : "com.squareup.moshi:moshi:$moshi", 'moshi' : "com.squareup.moshi:moshi:$moshi",

View File

@ -0,0 +1,2 @@
Main changes in this version: Bugfixes and improvements.
Full changelog: https://github.com/element-hq/element-android/releases

View File

@ -1953,8 +1953,11 @@
<string name="content_reported_as_spam_content">"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."</string> <string name="content_reported_as_spam_content">"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."</string>
<string name="content_reported_as_inappropriate_title">"Reported as inappropriate"</string> <string name="content_reported_as_inappropriate_title">"Reported as inappropriate"</string>
<string name="content_reported_as_inappropriate_content">"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."</string> <string name="content_reported_as_inappropriate_content">"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."</string>
<string name="user_reported_as_inappropriate_title">"Reported user"</string>
<string name="user_reported_as_inappropriate_content">"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."</string>
<string name="message_ignore_user">Ignore user</string> <string name="message_ignore_user">Ignore user</string>
<string name="message_report_user">Report user</string>
<string name="room_list_quick_actions_notifications_all_noisy">"All messages (noisy)"</string> <string name="room_list_quick_actions_notifications_all_noisy">"All messages (noisy)"</string>
<string name="room_list_quick_actions_notifications_all">"All messages"</string> <string name="room_list_quick_actions_notifications_all">"All messages"</string>

View File

@ -62,7 +62,7 @@ android {
// that the app's state is completely cleared between tests. // that the app's state is completely cleared between tests.
testInstrumentationRunnerArguments clearPackageData: 'true' 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", "\"${gitRevision()}\""
buildConfigField "String", "GIT_SDK_REVISION_UNIX_DATE", "\"${gitRevisionUnixDate()}\"" buildConfigField "String", "GIT_SDK_REVISION_UNIX_DATE", "\"${gitRevisionUnixDate()}\""

View File

@ -16,6 +16,7 @@
package org.matrix.android.sdk.api.session 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.session.events.model.Event
import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.api.util.JsonDict
@ -27,7 +28,7 @@ interface LiveEventListener {
fun onEventDecrypted(event: Event, clearEvent: JsonDict) fun onEventDecrypted(event: Event, clearEvent: JsonDict)
fun onEventDecryptionError(event: Event, throwable: Throwable) fun onEventDecryptionError(event: Event, cryptoError: MXCryptoError)
fun onLiveToDeviceEvent(event: Event) fun onLiveToDeviceEvent(event: Event)

View File

@ -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 org.matrix.android.sdk.api.session.events.model.Event
import javax.inject.Inject 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 { suspend operator fun invoke(event: Event): MXEventDecryptionResult {
return olmMachine.decryptRoomEvent(event) return cryptoService.decryptEvent(event, "")
} }
suspend fun decryptAndSaveResult(event: Event) { suspend fun decryptAndSaveResult(event: Event) {

View File

@ -172,8 +172,6 @@ internal class Device @AssistedInject constructor(
* This will not fetch out fresh data from the Rust side. * This will not fetch out fresh data from the Rust side.
**/ **/
internal fun toCryptoDeviceInfo(): CryptoDeviceInfo { internal fun toCryptoDeviceInfo(): CryptoDeviceInfo {
// val keys = innerDevice.keys.map { (keyId, key) -> keyId to key }.toMap()
return CryptoDeviceInfo( return CryptoDeviceInfo(
deviceId = innerDevice.deviceId, deviceId = innerDevice.deviceId,
userId = innerDevice.userId, userId = innerDevice.userId,

View File

@ -189,18 +189,21 @@ internal class OlmMachine @Inject constructor(
is OwnUserIdentity -> ownIdentity.trustsOurOwnDevice() is OwnUserIdentity -> ownIdentity.trustsOurOwnDevice()
else -> false else -> false
} }
val ownDevice = inner.getDevice(userId(), deviceId, 0u)!!
val creationTime = ownDevice.firstTimeSeenTs.toLong()
return CryptoDeviceInfo( return CryptoDeviceInfo(
deviceId(), deviceId(),
userId(), userId(),
// TODO pass the algorithms here. ownDevice.algorithms,
listOf(),
keys, keys,
mapOf(), mapOf(),
UnsignedDeviceInfo(), UnsignedDeviceInfo(
deviceDisplayName = ownDevice.displayName
),
DeviceTrustLevel(crossSigningVerified, locallyVerified = true), DeviceTrustLevel(crossSigningVerified, locallyVerified = true),
false, false,
null creationTime
) )
} }
@ -882,6 +885,7 @@ internal class OlmMachine @Inject constructor(
inner.queryMissingSecretsFromOtherSessions() inner.queryMissingSecretsFromOtherSessions()
} }
} }
@Throws(CryptoStoreException::class) @Throws(CryptoStoreException::class)
suspend fun enableBackupV1(key: String, version: String) { suspend fun enableBackupV1(key: String, version: String) {
return withContext(coroutineDispatchers.computation) { return withContext(coroutineDispatchers.computation) {

View File

@ -497,8 +497,11 @@ internal class RustCryptoService @Inject constructor(
@Throws(MXCryptoError::class) @Throws(MXCryptoError::class)
override suspend fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult { override suspend fun decryptEvent(event: Event, timeline: String): MXEventDecryptionResult {
return try { return try {
olmMachine.decryptRoomEvent(event) olmMachine.decryptRoomEvent(event).also {
liveEventManager.get().dispatchLiveEventDecrypted(event, it)
}
} catch (mxCryptoError: MXCryptoError) { } catch (mxCryptoError: MXCryptoError) {
liveEventManager.get().dispatchLiveEventDecryptionFailed(event, mxCryptoError)
if (mxCryptoError is MXCryptoError.Base && ( if (mxCryptoError is MXCryptoError.Base && (
mxCryptoError.errorType == MXCryptoError.ErrorType.UNKNOWN_INBOUND_SESSION_ID || mxCryptoError.errorType == MXCryptoError.ErrorType.UNKNOWN_INBOUND_SESSION_ID ||
mxCryptoError.errorType == MXCryptoError.ErrorType.UNKNOWN_MESSAGE_INDEX)) { mxCryptoError.errorType == MXCryptoError.ErrorType.UNKNOWN_MESSAGE_INDEX)) {

View File

@ -22,6 +22,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.LiveEventListener 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.crypto.model.MXEventDecryptionResult
import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.Event
import timber.log.Timber 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}") Timber.v("## dispatchLiveEventDecryptionFailed ${event.eventId}")
coroutineScope.launch { coroutineScope.launch {
listeners.forEach { listeners.forEach {

View File

@ -37,7 +37,7 @@ ext.versionMinor = 6
// Note: even values are reserved for regular release, odd values for hotfix release. // 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 // When creating a hotfix, you should decrease the value, since the current value
// is the value for the next regular release. // is the value for the next regular release.
ext.versionPatch = 12 ext.versionPatch = 14
static def getGitTimestamp() { static def getGitTimestamp() {
def cmd = 'git show -s --format=%ct' def cmd = 'git show -s --format=%ct'

View File

@ -51,6 +51,7 @@ import im.vector.app.core.debug.LeakDetector
import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.pushers.FcmHelper import im.vector.app.core.pushers.FcmHelper
import im.vector.app.core.resources.BuildMeta 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.analytics.VectorAnalytics
import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.configuration.VectorConfiguration import im.vector.app.features.configuration.VectorConfiguration
@ -100,6 +101,7 @@ class VectorApplication :
@Inject lateinit var callManager: WebRtcCallManager @Inject lateinit var callManager: WebRtcCallManager
@Inject lateinit var invitesAcceptor: InvitesAcceptor @Inject lateinit var invitesAcceptor: InvitesAcceptor
@Inject lateinit var autoRageShaker: AutoRageShaker @Inject lateinit var autoRageShaker: AutoRageShaker
@Inject lateinit var decryptionFailureTracker: DecryptionFailureTracker
@Inject lateinit var vectorFileLogger: VectorFileLogger @Inject lateinit var vectorFileLogger: VectorFileLogger
@Inject lateinit var vectorAnalytics: VectorAnalytics @Inject lateinit var vectorAnalytics: VectorAnalytics
@Inject lateinit var flipperProxy: FlipperProxy @Inject lateinit var flipperProxy: FlipperProxy
@ -130,6 +132,7 @@ class VectorApplication :
vectorAnalytics.init() vectorAnalytics.init()
invitesAcceptor.initialize() invitesAcceptor.initialize()
autoRageShaker.initialize() autoRageShaker.initialize()
decryptionFailureTracker.start()
vectorUncaughtExceptionHandler.activate() vectorUncaughtExceptionHandler.activate()
// Remove Log handler statically added by Jitsi // Remove Log handler statically added by Jitsi

View File

@ -18,6 +18,7 @@ package im.vector.app
import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.LiveEventListener 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.Event
import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventContent import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventContent
import org.matrix.android.sdk.api.session.events.model.toModel 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 eventId = event.eventId
val roomId = event.roomId val roomId = event.roomId
if (!enabled || eventId == null || roomId == null) return if (!enabled || eventId == null || roomId == null) return

View File

@ -23,7 +23,6 @@ import im.vector.app.core.pushers.UnregisterUnifiedPushUseCase
import im.vector.app.core.services.GuardServiceStarter import im.vector.app.core.services.GuardServiceStarter
import im.vector.app.core.session.ConfigureAndStartSessionUseCase import im.vector.app.core.session.ConfigureAndStartSessionUseCase
import im.vector.app.features.analytics.DecryptionFailureTracker 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.call.webrtc.WebRtcCallManager
import im.vector.app.features.crypto.keysrequest.KeyRequestHandler import im.vector.app.features.crypto.keysrequest.KeyRequestHandler
import im.vector.app.features.crypto.verification.IncomingVerificationRequestHandler import im.vector.app.features.crypto.verification.IncomingVerificationRequestHandler
@ -75,11 +74,6 @@ class ActiveSessionHolder @Inject constructor(
session.callSignalingService().addCallListener(callManager) session.callSignalingService().addCallListener(callManager)
imageManager.onSessionStarted(session) imageManager.onSessionStarted(session)
guardServiceStarter.start() guardServiceStarter.start()
decryptionFailureTracker.currentModule = if (session.cryptoService().name() == "rust-sdk") {
Error.CryptoModule.Rust
} else {
Error.CryptoModule.Native
}
} }
suspend fun clearActiveSession() { suspend fun clearActiveSession() {

View File

@ -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
}

View File

@ -16,149 +16,282 @@
package im.vector.app.features.analytics package im.vector.app.features.analytics
import im.vector.app.features.analytics.plan.Error import im.vector.app.ActiveSessionDataSource
import im.vector.lib.core.utils.compat.removeIfCompat
import im.vector.lib.core.utils.flow.tickerFlow
import im.vector.lib.core.utils.timer.Clock import im.vector.lib.core.utils.timer.Clock
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob 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.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch 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.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.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.JsonDict
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private data class DecryptionFailure( // If we can decrypt in less than 4s, we don't report
val timeStamp: Long,
val roomId: String,
val failedEventId: String,
val error: MXCryptoError.ErrorType
)
private typealias DetailedErrorName = Pair<String, Error.Name>
private const val GRACE_PERIOD_MILLIS = 4_000 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 * 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. * 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 @Singleton
class DecryptionFailureTracker @Inject constructor( class DecryptionFailureTracker @Inject constructor(
private val analyticsTracker: AnalyticsTracker, private val analyticsTracker: AnalyticsTracker,
private val sessionDataSource: ActiveSessionDataSource,
private val clock: Clock private val clock: Clock
) { ) : Session.Listener, LiveEventListener {
private val scope: CoroutineScope = CoroutineScope(SupervisorJob()) // The active session (set by the sessionDataSource)
private val failures = mutableListOf<DecryptionFailure>() 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<String, DecryptionFailure>()
// List of eventId that have been reported, to avoid double reporting
private val alreadyReported = mutableListOf<String>() private val alreadyReported = mutableListOf<String>()
var currentModule: Error.CryptoModule? = null // Mutex to ensure sequential access to internal state
private val mutex = Mutex()
init { // Used to unsubscribe from the active session data source
start() private lateinit var activeSessionSourceDisposable: Job
// 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 start() {
tickerFlow(scope, CHECK_INTERVAL)
.onEach {
checkFailures()
}.launchIn(scope)
} }
fun stop() { 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) { private fun post(block: suspend () -> Unit) {
scope.launch(Dispatchers.Default) { scope.launch {
val mCryptoError = event.root.mCryptoError mutex.withLock {
if (mCryptoError != null) { block()
addDecryptionFailure(DecryptionFailure(clock.epochMillis(), event.roomId, event.eventId, mCryptoError))
} else {
removeFailureForEventId(event.eventId)
} }
} }
} }
/** private suspend fun rescheduleTicker() {
* Can be called when the timeline is disposed in order currentTicker = scope.launch {
* to grace those events as they are not anymore displayed on screen. Timber.v("Reschedule ticker")
* */ delay(CHECK_INTERVAL)
fun onTimeLineDisposed(roomId: String) { post {
scope.launch(Dispatchers.Default) { checkFailures()
synchronized(failures) { currentTicker = null
failures.removeIfCompat { it.roomId == roomId } 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) { override fun onEventDecryptionError(event: Event, cryptoError: MXCryptoError) {
// de duplicate Timber.v("Decryption error for event ${event.eventId} with error $cryptoError")
synchronized(failures) { val session = activeSession ?: return
if (failures.none { it.failedEventId == failure.failedEventId }) { // track the event
failures.add(failure) post {
} trackEvent(session, event, cryptoError)
} }
} }
private fun removeFailureForEventId(eventId: String) { override fun onLiveToDeviceEvent(event: Event) {}
synchronized(failures) { override fun onLiveEvent(roomId: String, event: Event) {}
failures.removeIfCompat { it.failedEventId == eventId } 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() { private fun checkFailures() {
val now = clock.epochMillis() val now = clock.epochMillis()
val aggregatedErrors: Map<DetailedErrorName, List<String>> Timber.v("Check failures now $now")
synchronized(failures) { // report the definitely failed
val toReport = mutableListOf<DecryptionFailure>() val toReport = trackedEventsMap.values.filter {
failures.removeAll { failure -> now - it.timeStamp > MAX_WAIT_MILLIS
(now - failure.timeStamp > GRACE_PERIOD_MILLIS).also {
if (it) {
toReport.add(failure)
} }
toReport.forEach {
reportFailure(
it.copy(timeToDecryptMillis = -1)
)
} }
} }
aggregatedErrors = toReport
.groupBy { it.error.toAnalyticsErrorName() }
.mapValues {
it.value.map { it.failedEventId }
}
}
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 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)
}
} }

View File

@ -175,7 +175,10 @@ class DefaultVectorAnalytics @Inject constructor(
Timber.tag(analyticsTag.value).d("capture($event)") Timber.tag(analyticsTag.value).d("capture($event)")
posthog posthog
?.takeIf { userConsent == true } ?.takeIf { userConsent == true }
?.capture(event.getName(), event.getProperties()?.toPostHogProperties()) ?.capture(
event.getName(),
event.getProperties()?.toPostHogProperties()
)
} }
override fun screen(screen: VectorAnalyticsScreen) { override fun screen(screen: VectorAnalyticsScreen) {

View File

@ -30,11 +30,44 @@ data class Error(
*/ */
val context: String? = null, 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, val cryptoModule: CryptoModule? = null,
/**
* Which crypto backend is the client currently using.
*/
val cryptoSDK: CryptoSDK? = null,
val domain: Domain, 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, 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 { ) : VectorAnalyticsEvent {
enum class Domain { enum class Domain {
@ -44,18 +77,79 @@ data class Error(
} }
enum class Name { 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, OlmIndexError,
/**
* E2EE domain error. Generic unknown inbound group session error.
*/
OlmKeysNotSentError, OlmKeysNotSentError,
/**
* E2EE domain error. Any other decryption error (missing field, format
* errors...).
*/
OlmUnspecifiedError, OlmUnspecifiedError,
/**
* TO_DEVICE domain error. The to-device message failed to decrypt.
*/
ToDeviceFailedToDecrypt, ToDeviceFailedToDecrypt,
/**
* E2EE domain error. Decryption failed due to unknown error.
*/
UnknownError, UnknownError,
/**
* VOIP domain error. ICE negotiation failed.
*/
VoipIceFailed, VoipIceFailed,
/**
* VOIP domain error. ICE negotiation timed out.
*/
VoipIceTimeout, VoipIceTimeout,
/**
* VOIP domain error. The call invite timed out.
*/
VoipInviteTimeout, VoipInviteTimeout,
/**
* VOIP domain error. The user hung up the call.
*/
VoipUserHangup, VoipUserHangup,
/**
* VOIP domain error. The user's media failed to start.
*/
VoipUserMediaFailed, VoipUserMediaFailed,
} }
enum class CryptoSDK {
/**
* Legacy crypto backend specific to each platform.
*/
Legacy,
/**
* Cross-platform crypto backend written in Rust.
*/
Rust,
}
enum class CryptoModule { enum class CryptoModule {
/** /**
@ -75,8 +169,15 @@ data class Error(
return mutableMapOf<String, Any>().apply { return mutableMapOf<String, Any>().apply {
context?.let { put("context", it) } context?.let { put("context", it) }
cryptoModule?.let { put("cryptoModule", it.name) } cryptoModule?.let { put("cryptoModule", it.name) }
cryptoSDK?.let { put("cryptoSDK", it.name) }
put("domain", domain.name) put("domain", domain.name)
eventLocalAgeMillis?.let { put("eventLocalAgeMillis", it) }
isFederated?.let { put("isFederated", it) }
isMatrixDotOrg?.let { put("isMatrixDotOrg", it) }
put("name", name.name) put("name", name.name)
timeToDecryptMillis?.let { put("timeToDecryptMillis", it) }
userTrustsOwnIdentity?.let { put("userTrustsOwnIdentity", it) }
wasVisibleToUser?.let { put("wasVisibleToUser", it) }
}.takeIf { it.isNotEmpty() } }.takeIf { it.isNotEmpty() }
} }
} }

View File

@ -85,11 +85,28 @@ data class Interaction(
*/ */
MobileRoomAddHome, MobileRoomAddHome,
/**
* User switched the favourite toggle on Room Details screen.
*/
MobileRoomFavouriteToggle,
/** /**
* User tapped on Leave Room button on Room Details screen. * User tapped on Leave Room button on Room Details screen.
*/ */
MobileRoomLeave, 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. * User tapped on Threads button on Room screen.
*/ */
@ -306,6 +323,18 @@ data class Interaction(
*/ */
WebRoomListRoomTileContextMenuLeaveItem, 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 * User accessed room settings using the context menu on a room tile in
* the room list in Element Web/Desktop. * the room list in Element Web/Desktop.
@ -408,6 +437,18 @@ data class Interaction(
*/ */
WebThreadViewBackButton, 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. * User selected a thread in the Threads panel in Element Web/Desktop.
*/ */

View File

@ -119,6 +119,12 @@ data class MobileScreen(
*/ */
MyGroups, 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. * The People tab on mobile that lists all the DM rooms you have joined.
*/ */

View File

@ -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<String, Any>? {
return mutableMapOf<String, Any>().apply {
put("hasError", hasError)
}.takeIf { it.isNotEmpty() }
}
}

View File

@ -27,7 +27,7 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
data class PollEnd( data class PollEnd(
/** /**
* Do not use this. Remove this property when the kotlin type generator * 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. * name.
*/ */
val doNotUse: Boolean? = null, val doNotUse: Boolean? = null,

View File

@ -27,7 +27,7 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
data class PollVote( data class PollVote(
/** /**
* Do not use this. Remove this property when the kotlin type generator * 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. * name.
*/ */
val doNotUse: Boolean? = null, val doNotUse: Boolean? = null,

View File

@ -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<String, Any>? {
return mutableMapOf<String, Any>().apply {
put("action", action.name)
role?.let { put("role", it.name) }
}.takeIf { it.isNotEmpty() }
}
}

View File

@ -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<String, Any>? {
return mutableMapOf<String, Any>().apply {
appPlatform?.let { put("appPlatform", it) }
cryptoSDK?.let { put("cryptoSDK", it.name) }
cryptoSDKVersion?.let { put("cryptoSDKVersion", it) }
}.takeIf { it.isNotEmpty() }
}
}

View File

@ -252,6 +252,12 @@ data class ViewRoom(
*/ */
WebSpacePanelNotificationBadge, 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. * Room accessed via Element Web/Desktop's Unified Search modal.
*/ */

View File

@ -21,6 +21,7 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.ViewModelProvider
import com.airbnb.mvrx.parentFragmentViewModel import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.withState import com.airbnb.mvrx.withState
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -43,6 +44,12 @@ class BootstrapReAuthFragment :
views.bootstrapRetryButton.debouncedClicks { submit() } views.bootstrapRetryButton.debouncedClicks { submit() }
views.bootstrapCancelButton.debouncedClicks { cancel() } 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 -> private fun submit() = withState(sharedViewModel) { state ->

View File

@ -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
}

View File

@ -61,7 +61,8 @@ sealed class RoomDetailAction : VectorViewModelAction {
val senderId: String?, val senderId: String?,
val reason: String, val reason: String,
val spam: Boolean = false, val spam: Boolean = false,
val inappropriate: Boolean = false val inappropriate: Boolean = false,
val user: Boolean = false,
) : RoomDetailAction() ) : RoomDetailAction()
data class IgnoreUser(val userId: String?) : RoomDetailAction() data class IgnoreUser(val userId: String?) : RoomDetailAction()

View File

@ -1345,6 +1345,16 @@ class TimelineFragment :
} }
.show() .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 -> { else -> {
MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_Vector_MaterialAlertDialog_NegativeDestructive) MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_Vector_MaterialAlertDialog_NegativeDestructive)
.setTitle(R.string.content_reported_title) .setTitle(R.string.content_reported_title)
@ -1857,6 +1867,13 @@ class TimelineFragment :
is EventSharedAction.IgnoreUser -> { is EventSharedAction.IgnoreUser -> {
action.senderId?.let { askConfirmationToIgnoreUser(it) } 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 -> { is EventSharedAction.OnUrlClicked -> {
onUrlClicked(action.url, action.title) onUrlClicked(action.url, action.title)
} }

View File

@ -1484,7 +1484,6 @@ class TimelineViewModel @AssistedInject constructor(
override fun onCleared() { override fun onCleared() {
timeline?.dispose() timeline?.dispose()
timeline?.removeAllListeners() timeline?.removeAllListeners()
decryptionFailureTracker.onTimeLineDisposed(initialState.roomId)
if (vectorPreferences.sendTypingNotifs()) { if (vectorPreferences.sendTypingNotifs()) {
room?.typingService()?.userStopsTyping() room?.typingService()?.userStopsTyping()
} }

View File

@ -45,7 +45,9 @@ import com.google.android.material.shape.MaterialShapeDrawable
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.setTextIfDifferent import im.vector.app.core.extensions.setTextIfDifferent
import im.vector.app.core.extensions.showKeyboard 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.DimensionConverter
import im.vector.app.core.utils.createUIHandler
import im.vector.app.databinding.ComposerRichTextLayoutBinding import im.vector.app.databinding.ComposerRichTextLayoutBinding
import im.vector.app.databinding.ViewRichTextMenuButtonBinding import im.vector.app.databinding.ViewRichTextMenuButtonBinding
import im.vector.app.features.home.room.detail.composer.images.UriContentListener import im.vector.app.features.home.room.detail.composer.images.UriContentListener
@ -195,10 +197,16 @@ internal class RichTextComposerLayout @JvmOverloads constructor(
renderComposerMode(MessageComposerMode.Normal(null)) renderComposerMode(MessageComposerMode.Normal(null))
views.richTextComposerEditText.addTextChangedListener( views.richTextComposerEditText.addTextChangedListener(
TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder(isFullScreen) }) TextChangeListener(
onTextChanged = {
callback?.onTextChanged(it)
},
onExpandedChanged = { updateTextFieldBorder(isFullScreen) })
) )
views.plainTextComposerEditText.addTextChangedListener( views.plainTextComposerEditText.addTextChangedListener(
TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder(isFullScreen) }) TextChangeListener({
callback?.onTextChanged(it)
}, { updateTextFieldBorder(isFullScreen) })
) )
ViewCompat.setOnReceiveContentListener( ViewCompat.setOnReceiveContentListener(
views.richTextComposerEditText, views.richTextComposerEditText,
@ -516,13 +524,15 @@ internal class RichTextComposerLayout @JvmOverloads constructor(
private val onTextChanged: (s: Editable) -> Unit, private val onTextChanged: (s: Editable) -> Unit,
private val onExpandedChanged: (isExpanded: Boolean) -> Unit, private val onExpandedChanged: (isExpanded: Boolean) -> Unit,
) : TextWatcher { ) : TextWatcher {
private val debouncer = Debouncer(createUIHandler())
private var previousTextWasExpanded = false private var previousTextWasExpanded = false
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable) { override fun afterTextChanged(s: Editable) {
debouncer.debounce("afterTextChanged", 50L) {
onTextChanged.invoke(s) onTextChanged.invoke(s)
val isExpanded = s.lines().count() > 1 val isExpanded = s.lines().count() > 1
if (previousTextWasExpanded != isExpanded) { if (previousTextWasExpanded != isExpanded) {
onExpandedChanged(isExpanded) onExpandedChanged(isExpanded)
@ -530,4 +540,5 @@ internal class RichTextComposerLayout @JvmOverloads constructor(
previousTextWasExpanded = isExpanded previousTextWasExpanded = isExpanded
} }
} }
}
} }

View File

@ -98,6 +98,9 @@ sealed class EventSharedAction(
data class IgnoreUser(val senderId: String?) : data class IgnoreUser(val senderId: String?) :
EventSharedAction(R.string.message_ignore_user, R.drawable.ic_alert_triangle, true) 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) : data class QuickReact(val eventId: String, val clickedOn: String, val add: Boolean) :
EventSharedAction(0, 0) EventSharedAction(0, 0)

View File

@ -430,6 +430,12 @@ class MessageActionsViewModel @AssistedInject constructor(
add(EventSharedAction.Separator) add(EventSharedAction.Separator)
add(EventSharedAction.IgnoreUser(timelineEvent.root.senderId)) add(EventSharedAction.IgnoreUser(timelineEvent.root.senderId))
add(
EventSharedAction.ReportUser(
eventId = eventId,
senderId = timelineEvent.root.senderId,
)
)
} }
} }

View File

@ -158,8 +158,8 @@ class TimelineItemFactory @Inject constructor(
defaultItemFactory.create(params) defaultItemFactory.create(params)
} }
}.also { }.also {
if (it != null && event.isEncrypted()) { if (it != null && event.isEncrypted() && event.root.mCryptoError != null) {
decryptionFailureTracker.e2eEventDisplayedInTimeline(event) decryptionFailureTracker.utdDisplayedInTimeline(event)
} }
} }
} }

View File

@ -22,6 +22,7 @@ import im.vector.app.core.platform.VectorViewModelAction
sealed class RoomMemberProfileAction : VectorViewModelAction { sealed class RoomMemberProfileAction : VectorViewModelAction {
object RetryFetchingInfo : RoomMemberProfileAction() object RetryFetchingInfo : RoomMemberProfileAction()
object IgnoreUser : RoomMemberProfileAction() object IgnoreUser : RoomMemberProfileAction()
object ReportUser : RoomMemberProfileAction()
data class BanOrUnbanUser(val reason: String?) : RoomMemberProfileAction() data class BanOrUnbanUser(val reason: String?) : RoomMemberProfileAction()
data class KickUser(val reason: String?) : RoomMemberProfileAction() data class KickUser(val reason: String?) : RoomMemberProfileAction()
object InviteUser : RoomMemberProfileAction() object InviteUser : RoomMemberProfileAction()

View File

@ -39,6 +39,7 @@ class RoomMemberProfileController @Inject constructor(
interface Callback { interface Callback {
fun onIgnoreClicked() fun onIgnoreClicked()
fun onReportClicked()
fun onTapVerify() fun onTapVerify()
fun onShowDeviceList() fun onShowDeviceList()
fun onShowDeviceListNoCrossSigning() fun onShowDeviceListNoCrossSigning()
@ -225,7 +226,7 @@ class RoomMemberProfileController @Inject constructor(
title = stringProvider.getString(R.string.room_participants_action_invite), title = stringProvider.getString(R.string.room_participants_action_invite),
destructive = false, destructive = false,
editable = false, editable = false,
divider = ignoreActionTitle != null, divider = true,
action = { callback?.onInviteClicked() } action = { callback?.onInviteClicked() }
) )
} }
@ -235,10 +236,18 @@ class RoomMemberProfileController @Inject constructor(
title = ignoreActionTitle, title = ignoreActionTitle,
destructive = true, destructive = true,
editable = false, editable = false,
divider = false, divider = true,
action = { callback?.onIgnoreClicked() } 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? { private fun RoomMemberProfileViewState.buildIgnoreActionTitle(): String? {
val isIgnored = isIgnored() ?: return null val isIgnored = isIgnored() ?: return null
return if (isIgnored) { return if (isIgnored) {
stringProvider.getString(R.string.unignore) stringProvider.getString(R.string.room_participants_action_unignore_title)
} else { } else {
stringProvider.getString(R.string.action_ignore) stringProvider.getString(R.string.room_participants_action_ignore_title)
} }
} }
} }

View File

@ -140,11 +140,20 @@ class RoomMemberProfileFragment :
is RoomMemberProfileViewEvents.OnIgnoreActionSuccess -> Unit is RoomMemberProfileViewEvents.OnIgnoreActionSuccess -> Unit
is RoomMemberProfileViewEvents.OnInviteActionSuccess -> Unit is RoomMemberProfileViewEvents.OnInviteActionSuccess -> Unit
RoomMemberProfileViewEvents.GoBack -> handleGoBack() RoomMemberProfileViewEvents.GoBack -> handleGoBack()
RoomMemberProfileViewEvents.OnReportActionSuccess -> handleReportSuccess()
} }
} }
setupLongClicks() 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() { private fun setupLongClicks() {
headerViews.memberProfileNameView.copyOnLongClick() headerViews.memberProfileNameView.copyOnLongClick()
headerViews.memberProfileIdView.copyOnLongClick() headerViews.memberProfileIdView.copyOnLongClick()
@ -301,6 +310,10 @@ class RoomMemberProfileFragment :
} }
} }
override fun onReportClicked() {
viewModel.handle(RoomMemberProfileAction.ReportUser)
}
override fun onTapVerify() { override fun onTapVerify() {
viewModel.handle(RoomMemberProfileAction.VerifyUser) viewModel.handle(RoomMemberProfileAction.VerifyUser)
} }

View File

@ -26,6 +26,7 @@ sealed class RoomMemberProfileViewEvents : VectorViewEvents {
data class Failure(val throwable: Throwable) : RoomMemberProfileViewEvents() data class Failure(val throwable: Throwable) : RoomMemberProfileViewEvents()
object OnIgnoreActionSuccess : RoomMemberProfileViewEvents() object OnIgnoreActionSuccess : RoomMemberProfileViewEvents()
object OnReportActionSuccess : RoomMemberProfileViewEvents()
object OnSetPowerLevelSuccess : RoomMemberProfileViewEvents() object OnSetPowerLevelSuccess : RoomMemberProfileViewEvents()
object OnInviteActionSuccess : RoomMemberProfileViewEvents() object OnInviteActionSuccess : RoomMemberProfileViewEvents()
object OnKickActionSuccess : RoomMemberProfileViewEvents() object OnKickActionSuccess : RoomMemberProfileViewEvents()

View File

@ -161,6 +161,7 @@ class RoomMemberProfileViewModel @AssistedInject constructor(
when (action) { when (action) {
is RoomMemberProfileAction.RetryFetchingInfo -> handleRetryFetchProfileInfo() is RoomMemberProfileAction.RetryFetchingInfo -> handleRetryFetchProfileInfo()
is RoomMemberProfileAction.IgnoreUser -> handleIgnoreAction() is RoomMemberProfileAction.IgnoreUser -> handleIgnoreAction()
is RoomMemberProfileAction.ReportUser -> handleReportAction()
is RoomMemberProfileAction.VerifyUser -> prepareVerification() is RoomMemberProfileAction.VerifyUser -> prepareVerification()
is RoomMemberProfileAction.ShareRoomMemberProfile -> handleShareRoomMemberProfile() is RoomMemberProfileAction.ShareRoomMemberProfile -> handleShareRoomMemberProfile()
is RoomMemberProfileAction.SetPowerLevel -> handleSetPowerLevel(action) 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) { private fun handleOpenOrCreateDm(action: RoomMemberProfileAction.OpenOrCreateDm) {
viewModelScope.launch { viewModelScope.launch {
_viewEvents.post(RoomMemberProfileViewEvents.Loading()) _viewEvents.post(RoomMemberProfileViewEvents.Loading())

View File

@ -27,6 +27,7 @@ import im.vector.app.core.resources.StringProvider
import im.vector.app.features.auth.PendingAuthHandler import im.vector.app.features.auth.PendingAuthHandler
import im.vector.app.features.login.ReAuthHelper import im.vector.app.features.login.ReAuthHelper
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -52,6 +53,8 @@ class CrossSigningSettingsViewModel @AssistedInject constructor(
private val pendingAuthHandler: PendingAuthHandler, private val pendingAuthHandler: PendingAuthHandler,
) : VectorViewModel<CrossSigningSettingsViewState, CrossSigningSettingsAction, CrossSigningSettingsViewEvents>(initialState) { ) : VectorViewModel<CrossSigningSettingsViewState, CrossSigningSettingsAction, CrossSigningSettingsViewEvents>(initialState) {
private var observeCrossSigningJob: Job? = null
init { init {
observeCrossSigning() observeCrossSigning()
} }
@ -90,6 +93,8 @@ class CrossSigningSettingsViewModel @AssistedInject constructor(
} }
} }
}) })
// Force a fast refresh of the data
observeCrossSigning()
} catch (failure: Throwable) { } catch (failure: Throwable) {
handleInitializeXSigningError(failure) handleInitializeXSigningError(failure)
} finally { } finally {
@ -114,7 +119,8 @@ class CrossSigningSettingsViewModel @AssistedInject constructor(
// ) { myDevicesInfo, mxCrossSigningInfo -> // ) { myDevicesInfo, mxCrossSigningInfo ->
// myDevicesInfo to mxCrossSigningInfo // myDevicesInfo to mxCrossSigningInfo
// } // }
session.flow().liveCrossSigningInfo(session.myUserId) observeCrossSigningJob?.cancel()
observeCrossSigningJob = session.flow().liveCrossSigningInfo(session.myUserId)
.onEach { data -> .onEach { data ->
val crossSigningKeys = data.getOrNull() val crossSigningKeys = data.getOrNull()
val xSigningIsEnableInAccount = crossSigningKeys != null val xSigningIsEnableInAccount = crossSigningKeys != null
@ -128,7 +134,8 @@ class CrossSigningSettingsViewModel @AssistedInject constructor(
xSigningKeyCanSign = xSigningKeyCanSign xSigningKeyCanSign = xSigningKeyCanSign
) )
} }
}.launchIn(viewModelScope) }
.launchIn(viewModelScope)
} }
private fun handleInitializeXSigningError(failure: Throwable) { private fun handleInitializeXSigningError(failure: Throwable) {

View File

@ -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<VectorAnalyticsEvent>()
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<VectorAnalyticsEvent>()
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<VectorAnalyticsEvent>()
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<VectorAnalyticsEvent>()
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<VectorAnalyticsEvent>()
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<TimelineEvent>(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<VectorAnalyticsEvent>()
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<VectorAnalyticsEvent>()
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<VectorAnalyticsEvent>()
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<VectorAnalyticsEvent>()
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<VectorAnalyticsEvent>()
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()
}
}

View File

@ -53,7 +53,10 @@ class FakeSession(
mockkStatic("im.vector.app.core.extensions.SessionKt") 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 override val coroutineDispatchers = testCoroutineDispatchers

View File

@ -28,6 +28,7 @@ class FakeUri(contentEquals: String? = null) {
contentEquals?.let { contentEquals?.let {
givenEquals(it) givenEquals(it)
every { instance.toString() } returns it every { instance.toString() } returns it
every { instance.scheme } returns contentEquals.substring(0, contentEquals.indexOf(':'))
} }
} }