From e6750dd30155148e94d08d6fc083569e1075a87a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 28 Sep 2026 17:23:59 +0200 Subject: [PATCH 1/5] Show the account receiving the device verification request Replace SessionDetailsView by AccountInfoCard with the DeviceVerification mode on the incoming device verification screen, so that the account concerned by the request is clearly indicated, with its avatar and Matrix ID. The current user is built from the SessionStore, including the locally stored avatar thumbnail, used as a fallback when the avatar cannot be loaded, for instance for an account which is not the current one. Co-Authored-By: Claude Opus 5.5 (1M context) --- features/verifysession/impl/build.gradle.kts | 2 + .../incoming/IncomingVerificationPresenter.kt | 24 ++++ .../incoming/IncomingVerificationState.kt | 2 + .../IncomingVerificationStatePreviewParam.kt | 6 + .../impl/incoming/IncomingVerificationView.kt | 17 ++- .../impl/incoming/ui/SessionDetailsView.kt | 103 ------------------ .../IncomingVerificationPresenterTest.kt | 46 ++++++++ .../components/accountinfo/AccountInfoCard.kt | 3 +- .../accountinfo/AccountInfoCardState.kt | 1 - .../src/main/res/values/localazy.xml | 1 + 10 files changed, 94 insertions(+), 111 deletions(-) delete mode 100644 features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt diff --git a/features/verifysession/impl/build.gradle.kts b/features/verifysession/impl/build.gradle.kts index 89a87edf19..fc51d87acd 100644 --- a/features/verifysession/impl/build.gradle.kts +++ b/features/verifysession/impl/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.preferences.api) + implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.uiStrings) implementation(projects.features.logout.api) api(libs.statemachine) @@ -44,4 +45,5 @@ dependencies { testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.sessionStorage.test) } diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt index 4cde3f927f..00daf9b5b8 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt @@ -13,6 +13,7 @@ package io.element.android.features.verifysession.impl.incoming import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -26,13 +27,17 @@ import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.core.SessionId +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.SessionVerificationService import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationRequest +import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber @@ -45,6 +50,8 @@ class IncomingVerificationPresenter( @Assisted private val navigator: IncomingVerificationNavigator, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val sessionVerificationService: SessionVerificationService, + private val sessionId: SessionId, + private val sessionStore: SessionStore, private val stateMachine: IncomingVerificationStateMachine, private val dateFormatter: DateFormatter, ) : Presenter { @@ -92,9 +99,24 @@ class IncomingVerificationPresenter( mode = DateFormatterMode.TimeOrDate, ) } + val currentUser by remember { + sessionStore.sessionsFlow().mapNotNull { sessions -> + sessions.find { it.userId == sessionId.value }?.let { sessionData -> + MatrixUser( + userId = sessionId, + displayName = sessionData.userDisplayName, + avatarUrl = sessionData.userAvatarUrl, + // Locally stored avatar, used as a fallback when the avatar cannot be loaded, + // for instance for a session which is not the current one + avatarThumbnail = sessionData.userAvatarData, + ) + } + } + }.collectAsState(MatrixUser(sessionId)) val step by remember { derivedStateOf { stateAndDispatch.state.value.toVerificationStep( + currentUser = currentUser, sessionVerificationRequestDetails = verificationRequest.details, formattedSignInTime = formattedSignInTime, ) @@ -147,6 +169,7 @@ class IncomingVerificationPresenter( } private fun StateMachineState?.toVerificationStep( + currentUser: MatrixUser, sessionVerificationRequestDetails: SessionVerificationRequestDetails, formattedSignInTime: String, ): Step = @@ -156,6 +179,7 @@ class IncomingVerificationPresenter( StateMachineState.RejectingIncomingVerification, null -> { Step.Initial( + currentUser = currentUser, deviceDisplayName = sessionVerificationRequestDetails.deviceDisplayName, deviceId = sessionVerificationRequestDetails.deviceId, formattedSignInTime = formattedSignInTime, diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt index e3663800b2..bd4e343c49 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt @@ -10,6 +10,7 @@ package io.element.android.features.verifysession.impl.incoming import androidx.compose.runtime.Stable import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.verification.SessionVerificationData import io.element.android.libraries.matrix.api.verification.VerificationRequest @@ -21,6 +22,7 @@ data class IncomingVerificationState( @Stable sealed interface Step { data class Initial( + val currentUser: MatrixUser, val deviceDisplayName: String?, val deviceId: DeviceId, val formattedSignInTime: String, diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStatePreviewParam.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStatePreviewParam.kt index 68672be025..bdf3ad2196 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStatePreviewParam.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStatePreviewParam.kt @@ -13,6 +13,7 @@ import io.element.android.features.verifysession.impl.incoming.IncomingVerificat import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE +import io.element.android.libraries.designsystem.preview.USER_NAME_BOB 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.core.UserId @@ -50,6 +51,11 @@ open class IncomingVerificationStatePreviewParam : PreviewParameterProvider { _ -> } @@ -59,6 +98,7 @@ class IncomingVerificationPresenterTest { val initialState = awaitItem() assertThat(initialState.step).isEqualTo( IncomingVerificationState.Step.Initial( + currentUser = MatrixUser(A_SESSION_ID), deviceDisplayName = "a device name", deviceId = A_DEVICE_ID, formattedSignInTime = "567 TimeOrDate false", @@ -128,6 +168,7 @@ class IncomingVerificationPresenterTest { val initialState = awaitItem() assertThat(initialState.step).isEqualTo( IncomingVerificationState.Step.Initial( + currentUser = MatrixUser(A_SESSION_ID), deviceDisplayName = "a device name", deviceId = A_DEVICE_ID, formattedSignInTime = "567 TimeOrDate false", @@ -193,6 +234,7 @@ class IncomingVerificationPresenterTest { val initialState = awaitItem() assertThat(initialState.step).isEqualTo( IncomingVerificationState.Step.Initial( + currentUser = MatrixUser(A_SESSION_ID), deviceDisplayName = "a device name", deviceId = A_DEVICE_ID, formattedSignInTime = "567 TimeOrDate false", @@ -228,6 +270,7 @@ class IncomingVerificationPresenterTest { val initialState = awaitItem() assertThat(initialState.step).isEqualTo( IncomingVerificationState.Step.Initial( + currentUser = MatrixUser(A_SESSION_ID), deviceDisplayName = "a device name", deviceId = A_DEVICE_ID, formattedSignInTime = "567 TimeOrDate false", @@ -315,10 +358,13 @@ internal fun TestScope.createPresenter( navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() }, service: SessionVerificationService = FakeSessionVerificationService(), dateFormatter: DateFormatter = FakeDateFormatter(), + sessionStore: SessionStore = InMemorySessionStore(), ) = IncomingVerificationPresenter( verificationRequest = verificationRequest, navigator = navigator, sessionVerificationService = service, + sessionId = A_SESSION_ID, + sessionStore = sessionStore, stateMachine = IncomingVerificationStateMachine(service), dateFormatter = dateFormatter, sessionCoroutineScope = backgroundScope, diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCard.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCard.kt index ab0b172cc5..38de05999f 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCard.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCard.kt @@ -127,8 +127,7 @@ private fun DeviceInfoRow( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { TextWithLabelMolecule( - // TODO i18n - label = "Signed in", + label = stringResource(CommonStrings.common_signed_in), text = mode.signInFormattedTimestamp, modifier = Modifier.weight(1f), ) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCardState.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCardState.kt index 68b66d72ee..7077fa7c0b 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCardState.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCardState.kt @@ -24,7 +24,6 @@ sealed interface AccountInfoCardMode { val otherUser: MatrixUser, ) : AccountInfoCardMode - // TODO Replace SessionDetailsView with this component data class DeviceVerification( val deviceName: String?, val deviceId: DeviceId, diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 7a0ce8fd4e..1e568160d5 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -412,6 +412,7 @@ Reason: %1$s." "Shared live location" "Shared location" "Shared space" + "Signed in" "Removing device" "Something went wrong" "We encountered an issue. Please try again." From 36547dd5c50d6063ef37b0ba6f5034ac6287ee38 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 28 Sep 2026 17:24:00 +0200 Subject: [PATCH 2/5] Display incoming verification requests of all the signed-in accounts Restore all the sessions with a valid token so that they sync and can receive incoming verification requests, and listen to the requests of all of them with IncomingVerificationRequestObserver. Requests for the current account are still displayed by LoggedInFlowNode. Requests for another account are displayed on top of the current account, without switching, by OtherSessionIncomingVerificationNode which sets up the Session graph of the account receiving the request. Closes #7770 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../android/appnav/LoggedInFlowNode.kt | 47 ++---- .../io/element/android/appnav/RootFlowNode.kt | 76 +++++++++ .../appnav/session/MatrixSessionCache.kt | 16 ++ .../IncomingVerificationRequestObserver.kt | 75 +++++++++ .../OtherSessionIncomingVerificationNode.kt | 92 +++++++++++ .../android/appnav/RootFlowNodeTest.kt | 6 + .../appnav/session/MatrixSessionCacheTest.kt | 4 + ...IncomingVerificationRequestObserverTest.kt | 147 ++++++++++++++++++ 8 files changed, 428 insertions(+), 35 deletions(-) create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserver.kt create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionIncomingVerificationNode.kt create mode 100644 appnav/src/test/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserverTest.kt diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 2f0988281e..731c26f684 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -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() } ) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 5f2dc837ad..efeb31b525 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -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 @@ -63,6 +66,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 +79,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 +109,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( backstack = BackStack( @@ -127,6 +137,7 @@ class RootFlowNode( observeNavState(false) } } + observeIncomingVerificationRequests() super.onBuilt() } @@ -203,6 +214,49 @@ 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}") + } + } + } + + 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 { 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 +348,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 +407,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(buildContext, plugins = listOf(inputs, callback)) + } NavTarget.BugReport -> { val callback = object : BugReportEntryPoint.Callback { override fun onDone() { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt b/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt index ffae5f2b17..20d7bad228 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt @@ -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() + private val _matrixClients = MutableStateFlow>(emptyList()) + + /** + * The [MatrixClient]s currently in the cache. + */ + val matrixClients: StateFlow> = _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( diff --git a/appnav/src/main/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserver.kt b/appnav/src/main/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserver.kt new file mode 100644 index 0000000000..fbf3e70a0b --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserver.kt @@ -0,0 +1,75 @@ +/* + * 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.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 = channelFlow { + launch { + matrixSessionCache.matrixClients.collect { matrixClients -> + matrixClients.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) + } + } + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionIncomingVerificationNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionIncomingVerificationNode.kt new file mode 100644 index 0000000000..115c174bb8 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionIncomingVerificationNode.kt @@ -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, + sessionGraphFactory: SessionGraphFactory, + matrixClientProvider: MatrixClientProvider, + private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint, +) : ParentNode( + 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, + ) + } +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index fea30c8019..cc23aea626 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -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, ) } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt index bf315ddb01..8d4a162d4f 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt @@ -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 diff --git a/appnav/src/test/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserverTest.kt new file mode 100644 index 0000000000..feb17940c1 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserverTest.kt @@ -0,0 +1,147 @@ +/* + * 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.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 `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 = SessionVerificationRequestDetails( + senderProfile = MatrixUser(A_SESSION_ID), + flowId = FlowId("flowId"), + deviceId = DeviceId("deviceId"), + deviceDisplayName = "a device name", + firstSeenTimestamp = 0, + ) + ) +} From ee65fb99aeff3847f284bb17259fe5fb0d441eed Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 30 Sep 2026 15:21:35 +0000 Subject: [PATCH 3/5] Update screenshots --- ...fysession.impl.incoming.ui_SessionDetailsView_Day_0_en.png | 3 --- ...session.impl.incoming.ui_SessionDetailsView_Night_0_en.png | 3 --- ...ysession.impl.incoming_IncomingVerificationViewA11y_en.png | 4 ++-- ...ession.impl.incoming_IncomingVerificationView_Day_0_en.png | 4 ++-- ...ession.impl.incoming_IncomingVerificationView_Day_1_en.png | 4 ++-- ...ession.impl.incoming_IncomingVerificationView_Day_3_en.png | 4 ++-- ...sion.impl.incoming_IncomingVerificationView_Night_0_en.png | 4 ++-- ...sion.impl.incoming_IncomingVerificationView_Night_1_en.png | 4 ++-- ...sion.impl.incoming_IncomingVerificationView_Night_3_en.png | 4 ++-- 9 files changed, 14 insertions(+), 20 deletions(-) delete mode 100644 tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming.ui_SessionDetailsView_Day_0_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming.ui_SessionDetailsView_Night_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming.ui_SessionDetailsView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming.ui_SessionDetailsView_Day_0_en.png deleted file mode 100644 index d56a5c2c1c..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming.ui_SessionDetailsView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ac4f811358248644cd0d83505f28ca248602f26630c8ea7eee9cac4e8a2985c2 -size 24091 diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming.ui_SessionDetailsView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming.ui_SessionDetailsView_Night_0_en.png deleted file mode 100644 index a1e8eb4918..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming.ui_SessionDetailsView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:922170fd14008b3df570fd20075e4a26e5b6e2b7ab67722f10fa5e6fc1b5e5a1 -size 23404 diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationViewA11y_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationViewA11y_en.png index dd1e161549..af648dfd74 100644 --- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationViewA11y_en.png +++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationViewA11y_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:07d112b2a6fbdb4b0b95fa58b4e4bef0a966a9ab8a5be2fe37f28a8282db75bf -size 76657 +oid sha256:44c1e9dab64ce614941750808f002eb2f3510110d39a608fe31d068cec66a756 +size 79926 diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_0_en.png index 0223bf0d6d..2cab6515e3 100644 --- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:06194d9f61128a07129d90e9b82a767561d1cc4ec7b8a700329557d55547e3a7 -size 41988 +oid sha256:51975d611c94b398831339d793e02c76b3dc7967e8eb651b95dd5e38758e38f2 +size 41421 diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_1_en.png index 0223bf0d6d..2cab6515e3 100644 --- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:06194d9f61128a07129d90e9b82a767561d1cc4ec7b8a700329557d55547e3a7 -size 41988 +oid sha256:51975d611c94b398831339d793e02c76b3dc7967e8eb651b95dd5e38758e38f2 +size 41421 diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_3_en.png index da9e12181e..528f3545cb 100644 --- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3cee24c7f31f4062bd64286a5cc9560c551ca53ded85807d6a70531c3592279e -size 41895 +oid sha256:c0c2eacebbb95fa0fd13a1175f447aa7f14907eb84d3ae835fcb7af737bd6d6b +size 41344 diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_0_en.png index 36534212e4..8dde8b40ae 100644 --- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0225f023ac629f7f6dc9697376c47aa80d51fb89b4eebd8e84221c5b3ad3f727 -size 40671 +oid sha256:d0bbc942bf53c563b4952d2c6b8f16f1dc4a1b0e9ed099dd18690e5e1969f131 +size 40249 diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_1_en.png index 36534212e4..8dde8b40ae 100644 --- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0225f023ac629f7f6dc9697376c47aa80d51fb89b4eebd8e84221c5b3ad3f727 -size 40671 +oid sha256:d0bbc942bf53c563b4952d2c6b8f16f1dc4a1b0e9ed099dd18690e5e1969f131 +size 40249 diff --git a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_3_en.png index 2098df0116..7706d46599 100644 --- a/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.verifysession.impl.incoming_IncomingVerificationView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9035af1399a7a4db027d7610cfc382ae7853a38d8c9027d1b7bcf23f0ec835a1 -size 40493 +oid sha256:e06e5c83e9b82f11e7d8b89c7c9d8af3de04c1becc49ac4c8718147cd7c6d5a7 +size 40054 From d68e92a4f00b3702c17111ae3ffe37928d5a830a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 1 Oct 2026 18:18:19 +0200 Subject: [PATCH 4/5] Remove verification listeners of logged out sessions - Clear the listener when RustSessionVerificationService is destroyed. - IncomingVerificationRequestObserver removes the listener of the clients which are not in the MatrixSessionCache anymore. - Remove the sessions from the MatrixSessionCache when they are removed from the SessionStore (logout, account deactivation). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../io/element/android/appnav/RootFlowNode.kt | 18 +++++++++++++++++ .../IncomingVerificationRequestObserver.kt | 10 ++++++++-- ...IncomingVerificationRequestObserverTest.kt | 20 +++++++++++++++++++ .../RustSessionVerificationService.kt | 1 + 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index efeb31b525..c451a65d63 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -84,6 +84,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import kotlinx.parcelize.Parcelize @@ -138,6 +139,7 @@ class RootFlowNode( } } observeIncomingVerificationRequests() + observeRemovedSessions() super.onBuilt() } @@ -230,6 +232,22 @@ class RootFlowNode( } } + /** + * 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() } + .runningFold(emptySet() to emptySet()) { (_, previous), current -> previous to current } + .onEach { (previous, current) -> + (previous - 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) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserver.kt b/appnav/src/main/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserver.kt index fbf3e70a0b..bfc952334a 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserver.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserver.kt @@ -9,6 +9,7 @@ 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 @@ -41,8 +42,13 @@ class IncomingVerificationRequestObserver( */ fun incomingVerificationRequests(): Flow = channelFlow { launch { - matrixSessionCache.matrixClients.collect { matrixClients -> - matrixClients.forEach { matrixClient -> + 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 { diff --git a/appnav/src/test/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserverTest.kt index feb17940c1..04522629cd 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserverTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserverTest.kt @@ -63,6 +63,26 @@ class IncomingVerificationRequestObserverTest { 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 verification request is emitted when the app goes to foreground`() = runTest { val matrixSessionCache = createMatrixSessionCache() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt index 760937c567..a96d5ec214 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt @@ -245,6 +245,7 @@ class RustSessionVerificationService( fun destroy() { Timber.d("Destroying RustSessionVerificationService") + listener = null verificationStateListenerTaskHandle.cancelAndDestroy() recoveryStateListenerTaskHandle.cancelAndDestroy() if (this::verificationController.isInitialized) { From 86caf33504695047641556b10df5a659b9b90d2b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 1 Oct 2026 18:37:40 +0200 Subject: [PATCH 5/5] Use withPreviousValue() to detect removed sessions Replace the hand-written runningFold in RootFlowNode.observeRemovedSessions() with the existing withPreviousValue() helper, as done in IncomingVerificationRequestObserver. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../main/kotlin/io/element/android/appnav/RootFlowNode.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index c451a65d63..d8103c18c0 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -55,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 @@ -84,7 +85,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import kotlinx.parcelize.Parcelize @@ -238,9 +238,9 @@ class RootFlowNode( private fun observeRemovedSessions() { sessionStore.sessionsFlow() .map { sessions -> sessions.map { SessionId(it.userId) }.toSet() } - .runningFold(emptySet() to emptySet()) { (_, previous), current -> previous to current } + .withPreviousValue() .onEach { (previous, current) -> - (previous - current).forEach { sessionId -> + (previous.orEmpty() - current).forEach { sessionId -> Timber.d("Session $sessionId removed, remove it from the cache") matrixSessionCache.remove(sessionId) }