Merge branch 'develop' into feature/fga/timeline-multi-forward

This commit is contained in:
ganfra
2026-10-05 17:54:00 +02:00
335 changed files with 2657 additions and 1194 deletions
@@ -93,7 +93,6 @@ import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
@@ -111,14 +110,10 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import java.time.Duration
import java.time.Instant
import java.util.Optional
import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toKotlinDuration
import im.vector.app.features.analytics.plan.JoinedRoom as JoinedRoomAnalyticsEvent
// The maximum number of room nodes that should be kept in the backstack at the same time.
@@ -185,36 +180,20 @@ class LoggedInFlowNode(
roomMembershipObserver = matrixClient.roomMembershipObserver,
)
private val verificationListener = object : SessionVerificationServiceListener {
override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) {
// Without this launch the rendering and actual state of this Appyx node's children gets out of sync, resulting in a crash.
// This might be because this method is called back from Rust in a background thread.
lifecycleScope.launch {
val receivedAt = Instant.now()
// Wait until the app is in foreground to display the incoming verification request
appNavigationStateService.appNavigationState.first { it.isInForeground }
// TODO there should also be a timeout for > 10 minutes elapsed since the request was created, but the SDK doesn't expose that info yet
val now = Instant.now()
val elapsedTimeSinceReceived = Duration.between(receivedAt, now).toKotlinDuration()
// Discard the incoming verification request if it has timed out
if (elapsedTimeSinceReceived > 2.minutes) {
Timber.w("Incoming verification request ${verificationRequest.details.flowId} discarded due to timeout.")
return@launch
/**
* Display the incoming verification request, the app is expected to be in foreground.
*/
fun onIncomingVerificationRequest(verificationRequest: VerificationRequest.Incoming) {
lifecycleScope.launch {
// Wait for the RoomList UI to be ready so the incoming verification screen can be displayed on top of it
// Otherwise, the RoomList UI may be incorrectly displayed on top
withTimeout(5.seconds) {
backstack.elements.first { elements ->
elements.any { it.key.navTarget == NavTarget.Home }
}
// Wait for the RoomList UI to be ready so the incoming verification screen can be displayed on top of it
// Otherwise, the RoomList UI may be incorrectly displayed on top
withTimeout(5.seconds) {
backstack.elements.first { elements ->
elements.any { it.key.navTarget == NavTarget.Home }
}
}
backstack.singleTop(NavTarget.IncomingVerificationRequest(verificationRequest))
}
backstack.singleTop(NavTarget.IncomingVerificationRequest(verificationRequest))
}
}
@@ -229,7 +208,6 @@ class LoggedInFlowNode(
analyticsRoomListStateWatcher.start()
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
matrixClient.sessionVerificationService.setListener(verificationListener)
mediaPreviewConfigMigration()
sessionCoroutineScope.launch {
// Wait for the network to be connected before pre-fetching the max file upload size
@@ -258,7 +236,6 @@ class LoggedInFlowNode(
onDestroy = {
appNavigationStateService.onLeavingSession(id)
loggedInFlowProcessor.stopObserving()
matrixClient.sessionVerificationService.setListener(null)
analyticsRoomListStateWatcher.stop()
}
)
@@ -40,6 +40,9 @@ import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView
import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.appnav.verification.IncomingVerificationRequestData
import io.element.android.appnav.verification.IncomingVerificationRequestObserver
import io.element.android.appnav.verification.OtherSessionIncomingVerificationNode
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
@@ -52,6 +55,7 @@ import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.rememberDelegateTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.core.coroutine.withPreviousValue
import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -63,6 +67,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.oauth.api.OAuthAction
import io.element.android.libraries.oauth.api.OAuthActionFlow
import io.element.android.libraries.sessionstorage.api.LoggedInState
@@ -75,11 +80,16 @@ import io.element.android.services.appnavstate.api.ROOM_OPENED_FROM_NOTIFICATION
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
@ContributesNode(AppScope::class)
@AssistedInject
@@ -100,6 +110,7 @@ class RootFlowNode(
private val announcementService: AnnouncementService,
private val analyticsService: AnalyticsService,
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
private val incomingVerificationRequestObserver: IncomingVerificationRequestObserver,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : BaseFlowNode<RootFlowNode.NavTarget>(
backstack = BackStack(
@@ -127,6 +138,8 @@ class RootFlowNode(
observeNavState(false)
}
}
observeIncomingVerificationRequests()
observeRemovedSessions()
super.onBuilt()
}
@@ -203,6 +216,65 @@ class RootFlowNode(
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
pendingLoginParams = null
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId))
restoreAllSessions()
}
/**
* Restore all the sessions with a valid token, so that they sync and can receive incoming verification requests.
*/
private fun restoreAllSessions() = lifecycleScope.launch {
sessionStore.getAllSessions()
.filter { it.isTokenValid }
.forEach { sessionData ->
matrixSessionCache.getOrRestore(SessionId(sessionData.userId)).onFailure {
Timber.e(it, "Failed to restore session ${sessionData.userId}")
}
}
}
/**
* Remove from the cache the sessions which have been removed from the session store, for instance after a logout.
*/
private fun observeRemovedSessions() {
sessionStore.sessionsFlow()
.map { sessions -> sessions.map { SessionId(it.userId) }.toSet() }
.withPreviousValue()
.onEach { (previous, current) ->
(previous.orEmpty() - current).forEach { sessionId ->
Timber.d("Session $sessionId removed, remove it from the cache")
matrixSessionCache.remove(sessionId)
}
}
.launchIn(lifecycleScope)
}
private fun observeIncomingVerificationRequests() {
incomingVerificationRequestObserver.incomingVerificationRequests()
.onEach(::onIncomingVerificationRequest)
.launchIn(lifecycleScope)
}
private fun onIncomingVerificationRequest(data: IncomingVerificationRequestData) = lifecycleScope.launch {
// Wait for a logged in flow to be displayed
val currentSessionId = withTimeoutOrNull(5.seconds) {
backstack.elements.map { elements ->
elements.firstNotNullOfOrNull { (it.key.navTarget as? NavTarget.LoggedInFlow)?.sessionId }
}.filterNotNull().first()
}
if (currentSessionId == null) {
Timber.w("Incoming verification request ${data.verificationRequest.details.flowId} discarded, no logged in flow.")
return@launch
}
if (currentSessionId == data.sessionId) {
waitForChildAttached<LoggedInAppScopeFlowNode, NavTarget> { navTarget ->
navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == currentSessionId
}
.attachSession()
.onIncomingVerificationRequest(data.verificationRequest)
} else {
// Display the request on top of the current session, without switching session
backstack.push(NavTarget.OtherAccountIncomingVerificationRequest(data.sessionId, data.verificationRequest))
}
}
private fun switchToNotLoggedInFlow(params: LoginParams?) {
@@ -294,6 +366,11 @@ class RootFlowNode(
) : NavTarget
@Parcelize data object BugReport : NavTarget
@Parcelize data class OtherAccountIncomingVerificationRequest(
val sessionId: SessionId,
val verificationRequest: VerificationRequest.Incoming,
) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -348,6 +425,23 @@ class RootFlowNode(
)
}
NavTarget.SplashScreen -> emptyNode(buildContext)
is NavTarget.OtherAccountIncomingVerificationRequest -> {
if (matrixSessionCache.getOrNull(navTarget.sessionId) == null) {
Timber.w("Couldn't find session ${navTarget.sessionId} for the incoming verification request")
lifecycleScope.launch { backstack.pop() }
return emptyNode(buildContext)
}
val inputs = OtherSessionIncomingVerificationNode.Inputs(
sessionId = navTarget.sessionId,
verificationRequest = navTarget.verificationRequest,
)
val callback = object : OtherSessionIncomingVerificationNode.Callback {
override fun onDone() {
backstack.pop()
}
}
createNode<OtherSessionIncomingVerificationNode>(buildContext, plugins = listOf(inputs, callback))
}
NavTarget.BugReport -> {
val callback = object : BugReportEntryPoint.Callback {
override fun onDone() {
@@ -21,6 +21,9 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.AnalyticsUserData
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
@@ -41,6 +44,12 @@ class MatrixSessionCache(
private val analyticsService: AnalyticsService,
) : MatrixClientProvider {
private val sessionIdsToMatrixSession = ConcurrentHashMap<SessionId, InMemoryMatrixSession>()
private val _matrixClients = MutableStateFlow<List<MatrixClient>>(emptyList())
/**
* The [MatrixClient]s currently in the cache.
*/
val matrixClients: StateFlow<List<MatrixClient>> = _matrixClients.asStateFlow()
private val restoreMutex = Mutex()
init {
@@ -51,10 +60,12 @@ class MatrixSessionCache(
fun removeAll() {
sessionIdsToMatrixSession.clear()
updateMatrixClients()
}
fun remove(sessionId: SessionId) {
sessionIdsToMatrixSession.remove(sessionId)
updateMatrixClients()
}
override fun getOrNull(sessionId: SessionId): MatrixClient? {
@@ -122,8 +133,13 @@ class MatrixSessionCache(
matrixClient = matrixClient,
syncOrchestrator = syncOrchestrator,
)
updateMatrixClients()
syncOrchestrator.start()
}
private fun updateMatrixClients() {
_matrixClients.value = sessionIdsToMatrixSession.values.map { it.matrixClient }
}
}
private data class InMemoryMatrixSession(
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.appnav.verification
import dev.zacsweers.metro.Inject
import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.libraries.core.coroutine.withPreviousValue
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
import kotlin.time.Duration.Companion.minutes
data class IncomingVerificationRequestData(
val sessionId: SessionId,
val verificationRequest: VerificationRequest.Incoming,
)
/**
* Listens to the incoming verification requests of all the sessions in [MatrixSessionCache], not only the current one.
*/
@Inject
class IncomingVerificationRequestObserver(
private val matrixSessionCache: MatrixSessionCache,
private val appForegroundStateService: AppForegroundStateService,
) {
/**
* Emits the incoming verification requests once the app is in foreground.
* Requests received while the app is in background for too long are discarded.
*/
fun incomingVerificationRequests(): Flow<IncomingVerificationRequestData> = channelFlow {
launch {
matrixSessionCache.matrixClients.withPreviousValue().collect { (previousMatrixClients, matrixClients) ->
val previousMatrixClientSet = previousMatrixClients.orEmpty().toSet()
// Remove the listener of the clients which are not in the cache anymore
(previousMatrixClientSet - matrixClients.toSet()).forEach { matrixClient ->
matrixClient.sessionVerificationService.setListener(null)
}
(matrixClients - previousMatrixClientSet).forEach { matrixClient ->
val sessionId = matrixClient.sessionId
matrixClient.sessionVerificationService.setListener(
object : SessionVerificationServiceListener {
override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) {
launch {
// Wait until the app is in foreground to display the incoming verification request
// TODO there should also be a timeout for > 10 minutes elapsed since the request was created,
// but the SDK doesn't expose that info yet
val isInForeground = withTimeoutOrNull(2.minutes) {
appForegroundStateService.isInForeground.first { it }
}
if (isInForeground == null) {
Timber.w("Incoming verification request ${verificationRequest.details.flowId} discarded due to timeout.")
} else {
send(IncomingVerificationRequestData(sessionId, verificationRequest))
}
}
}
}
)
}
}
}
awaitClose {
matrixSessionCache.matrixClients.value.forEach { matrixClient ->
matrixClient.sessionVerificationService.setListener(null)
}
}
}
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.appnav.verification
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.SessionGraphFactory
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import kotlinx.parcelize.Parcelize
/**
* Display an incoming verification request received by a session which is not the current one, without switching the current session.
* It sets up the Session graph of the session receiving the request.
*/
@ContributesNode(AppScope::class)
@AssistedInject
class OtherSessionIncomingVerificationNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
sessionGraphFactory: SessionGraphFactory,
matrixClientProvider: MatrixClientProvider,
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
) : ParentNode<OtherSessionIncomingVerificationNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
), DependencyInjectionGraphOwner {
interface Callback : Plugin {
fun onDone()
}
@Parcelize
object NavTarget : Parcelable
data class Inputs(
val sessionId: SessionId,
val verificationRequest: VerificationRequest.Incoming,
) : NodeInputs
private val callback: Callback = callback()
private val inputs: Inputs = inputs()
override val graph = sessionGraphFactory.create(
requireNotNull(matrixClientProvider.getOrNull(inputs.sessionId)) { "No MatrixClient for session ${inputs.sessionId}" }
)
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return incomingVerificationEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = IncomingVerificationEntryPoint.Params(inputs.verificationRequest),
callback = object : IncomingVerificationEntryPoint.Callback {
override fun onDone() {
callback.onDone()
}
},
)
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = navModel,
modifier = modifier,
)
}
}
@@ -25,6 +25,7 @@ import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.session.FakeSyncOrchestratorFactory
import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.appnav.verification.IncomingVerificationRequestObserver
import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.test.FakeLoginEntryPoint
@@ -50,6 +51,7 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher
import io.element.android.services.apperror.test.FakeAppErrorStateService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.node.FakeNodeFactoriesBindings
import io.element.android.tests.testutils.node.FakeParentNode
import io.element.android.tests.testutils.presenter.NotUsedPresenter
@@ -183,6 +185,10 @@ class RootFlowNodeTest : RobolectricTest() {
announcementService = FakeAnnouncementService(),
analyticsService = FakeAnalyticsService(),
analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(),
incomingVerificationRequestObserver = IncomingVerificationRequestObserver(
matrixSessionCache = matrixSessionCache,
appForegroundStateService = FakeAppForegroundStateService(),
),
appCoroutineScope = backgroundScope,
)
}
@@ -70,9 +70,11 @@ class MatrixSessionCacheTest {
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.matrixClients.value).containsExactly(fakeMatrixClient)
// Remove
matrixSessionCache.remove(A_SESSION_ID)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
assertThat(matrixSessionCache.matrixClients.value).isEmpty()
}
@Test
@@ -83,9 +85,11 @@ class MatrixSessionCacheTest {
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.matrixClients.value).containsExactly(fakeMatrixClient)
// Remove all
matrixSessionCache.removeAll()
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
assertThat(matrixSessionCache.matrixClients.value).isEmpty()
}
@Test
@@ -0,0 +1,191 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.appnav.verification
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.appnav.session.SyncOrchestrator
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class)
class IncomingVerificationRequestObserverTest {
private val sessionVerificationService1 = FakeSessionVerificationService()
private val sessionVerificationService2 = FakeSessionVerificationService()
@Test
fun `incoming verification requests of all the sessions are emitted with their session id`() = runTest {
val matrixSessionCache = createMatrixSessionCache()
val observer = IncomingVerificationRequestObserver(
matrixSessionCache = matrixSessionCache,
appForegroundStateService = FakeAppForegroundStateService(),
)
observer.incomingVerificationRequests().test {
matrixSessionCache.getOrRestore(A_SESSION_ID)
matrixSessionCache.getOrRestore(A_SESSION_ID_2)
runCurrent()
val request = anIncomingVerificationRequest()
sessionVerificationService2.listener!!.onIncomingSessionRequest(request)
assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID_2, request))
sessionVerificationService1.listener!!.onIncomingSessionRequest(request)
assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID, request))
}
// The listeners are removed when the flow is not collected anymore
assertThat(sessionVerificationService1.listener).isNull()
assertThat(sessionVerificationService2.listener).isNull()
}
@Test
fun `listener is removed when the session is removed from the cache`() = runTest {
val matrixSessionCache = createMatrixSessionCache()
val observer = IncomingVerificationRequestObserver(
matrixSessionCache = matrixSessionCache,
appForegroundStateService = FakeAppForegroundStateService(),
)
observer.incomingVerificationRequests().test {
matrixSessionCache.getOrRestore(A_SESSION_ID)
matrixSessionCache.getOrRestore(A_SESSION_ID_2)
runCurrent()
assertThat(sessionVerificationService1.listener).isNotNull()
assertThat(sessionVerificationService2.listener).isNotNull()
matrixSessionCache.remove(A_SESSION_ID)
runCurrent()
assertThat(sessionVerificationService1.listener).isNull()
assertThat(sessionVerificationService2.listener).isNotNull()
}
}
@Test
fun `incoming user verification request of another session is emitted with its session id`() = runTest {
val matrixSessionCache = createMatrixSessionCache()
val observer = IncomingVerificationRequestObserver(
matrixSessionCache = matrixSessionCache,
appForegroundStateService = FakeAppForegroundStateService(),
)
observer.incomingVerificationRequests().test {
matrixSessionCache.getOrRestore(A_SESSION_ID)
matrixSessionCache.getOrRestore(A_SESSION_ID_2)
runCurrent()
val request = anIncomingUserVerificationRequest()
sessionVerificationService2.listener!!.onIncomingSessionRequest(request)
assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID_2, request))
}
}
@Test
fun `incoming verification request is emitted when the app goes to foreground`() = runTest {
val matrixSessionCache = createMatrixSessionCache()
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = false)
val observer = IncomingVerificationRequestObserver(
matrixSessionCache = matrixSessionCache,
appForegroundStateService = appForegroundStateService,
)
observer.incomingVerificationRequests().test {
matrixSessionCache.getOrRestore(A_SESSION_ID)
runCurrent()
val request = anIncomingVerificationRequest()
sessionVerificationService1.listener!!.onIncomingSessionRequest(request)
advanceTimeBy(30.seconds)
expectNoEvents()
appForegroundStateService.givenIsInForeground(true)
assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID, request))
}
}
@Test
fun `incoming verification request is discarded if the app does not go to foreground in time`() = runTest {
val matrixSessionCache = createMatrixSessionCache()
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = false)
val observer = IncomingVerificationRequestObserver(
matrixSessionCache = matrixSessionCache,
appForegroundStateService = appForegroundStateService,
)
observer.incomingVerificationRequests().test {
matrixSessionCache.getOrRestore(A_SESSION_ID)
runCurrent()
sessionVerificationService1.listener!!.onIncomingSessionRequest(anIncomingVerificationRequest())
advanceTimeBy(3.minutes)
appForegroundStateService.givenIsInForeground(true)
runCurrent()
expectNoEvents()
}
}
private fun TestScope.createMatrixSessionCache(): MatrixSessionCache {
val matrixClient1 = FakeMatrixClient(
sessionId = A_SESSION_ID,
sessionCoroutineScope = backgroundScope,
sessionVerificationService = sessionVerificationService1,
userIdServerNameLambda = { "server" },
)
val matrixClient2 = FakeMatrixClient(
sessionId = A_SESSION_ID_2,
sessionCoroutineScope = backgroundScope,
sessionVerificationService = sessionVerificationService2,
userIdServerNameLambda = { "server" },
)
return MatrixSessionCache(
authenticationService = FakeMatrixAuthenticationService(
matrixClientResult = { sessionId ->
Result.success(if (sessionId == A_SESSION_ID) matrixClient1 else matrixClient2)
},
),
syncOrchestratorFactory = object : SyncOrchestrator.Factory {
override fun create(matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope) = SyncOrchestrator(
matrixClient = matrixClient,
sessionCoroutineScope = sessionCoroutineScope,
appForegroundStateService = FakeAppForegroundStateService(),
networkMonitor = FakeNetworkMonitor(),
dispatchers = testCoroutineDispatchers(),
analyticsService = FakeAnalyticsService(),
)
},
analyticsService = FakeAnalyticsService(),
)
}
private fun anIncomingVerificationRequest() = VerificationRequest.Incoming.OtherSession(
details = aSessionVerificationRequestDetails(senderProfile = MatrixUser(A_SESSION_ID)),
)
private fun anIncomingUserVerificationRequest() = VerificationRequest.Incoming.User(
details = aSessionVerificationRequestDetails(senderProfile = MatrixUser(A_USER_ID_3)),
)
private fun aSessionVerificationRequestDetails(senderProfile: MatrixUser) = SessionVerificationRequestDetails(
senderProfile = senderProfile,
flowId = FlowId("flowId"),
deviceId = DeviceId("deviceId"),
deviceDisplayName = "a device name",
firstSeenTimestamp = 0,
)
}