From 00dab131ddff344a04c419818530a0ebeee44e67 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 9 Oct 2026 16:36:29 +0200 Subject: [PATCH] Ask for session verification before sharing with an unverified account The incoming share is now displayed on top of the root, so the FTUE, and the session verification in particular, could be bypassed. - When the current account is not verified, wait for the FTUE to be complete before displaying the incoming share, as before. - When the user selects another account which is not verified in the room select screen, display the session verification flow of the FTUE for this account on top of the share. As in the FTUE, the user has to verify the session or to sign out. - Expose the FTUE session verification flow with a new SessionVerificationEntryPoint, displayed by OtherSessionVerificationNode which sets up the session graph of the account to verify. - Introduce RoomSelectNavigator so that RoomSelectPresenter can request the verification of the selected session. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../android/appnav/LoggedInFlowNode.kt | 9 ++ .../io/element/android/appnav/RootFlowNode.kt | 33 ++++++- .../OtherSessionVerificationNode.kt | 89 +++++++++++++++++++ .../forward/impl/ForwardMessagesNode.kt | 4 + .../ftue/api/SessionVerificationEntryPoint.kt | 32 +++++++ .../DefaultSessionVerificationEntryPoint.kt | 32 +++++++ ...efaultSessionVerificationEntryPointTest.kt | 63 +++++++++++++ .../features/share/api/ShareEntryPoint.kt | 7 ++ .../android/features/share/impl/ShareNode.kt | 4 + .../share/impl/DefaultShareEntryPointTest.kt | 1 + .../roomselect/api/RoomSelectEntryPoint.kt | 7 ++ .../roomselect/impl/RoomSelectNavigator.kt | 17 ++++ .../roomselect/impl/RoomSelectNode.kt | 9 +- .../roomselect/impl/RoomSelectPresenter.kt | 31 +++++-- .../impl/DefaultRoomSelectEntryPointTest.kt | 3 +- .../impl/RoomSelectPresenterTest.kt | 79 ++++++++++++++++ 16 files changed, 407 insertions(+), 13 deletions(-) create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionVerificationNode.kt create mode 100644 features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/SessionVerificationEntryPoint.kt create mode 100644 features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultSessionVerificationEntryPoint.kt create mode 100644 features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultSessionVerificationEntryPointTest.kt create mode 100644 libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNavigator.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 270ea6b052..fed49bdeab 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -577,6 +577,15 @@ class LoggedInFlowNode( } } + /** + * Wait for the home screen to be attached, i.e. for the FTUE to be complete. + */ + internal suspend fun waitForHome() { + waitForNavTargetAttached { navTarget -> + navTarget is NavTarget.Home + } + } + suspend fun attachRoom( roomIdOrAlias: RoomIdOrAlias, serverNames: List = emptyList(), 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 5ff887b209..673bdc1388 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -43,6 +43,7 @@ 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.appnav.verification.OtherSessionVerificationNode 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 @@ -244,6 +245,8 @@ class RootFlowNode( .withPreviousValue() .onEach { (previous, current) -> (previous.orEmpty() - current).forEach { sessionId -> + // The verification of a removed session cannot continue, for instance if the user has signed out from it + backstack.removeLast(NavTarget.OtherAccountSessionVerification(sessionId)) Timber.d("Session $sessionId removed, remove it from the cache") matrixSessionCache.remove(sessionId) } @@ -391,6 +394,10 @@ class RootFlowNode( val sessionId: SessionId, val verificationRequest: VerificationRequest.Incoming, ) : NavTarget + + @Parcelize data class OtherAccountSessionVerification( + val sessionId: SessionId, + ) : NavTarget } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -462,6 +469,20 @@ class RootFlowNode( } createNode(buildContext, plugins = listOf(inputs, callback)) } + is NavTarget.OtherAccountSessionVerification -> { + if (matrixSessionCache.getOrNull(navTarget.sessionId) == null) { + Timber.w("Couldn't find session ${navTarget.sessionId} to verify") + lifecycleScope.launch { backstack.pop() } + return emptyNode(buildContext) + } + val inputs = OtherSessionVerificationNode.Inputs(sessionId = navTarget.sessionId) + val callback = object : OtherSessionVerificationNode.Callback { + override fun onDone() { + backstack.pop() + } + } + createNode(buildContext, plugins = listOf(inputs, callback)) + } is NavTarget.IncomingShare -> { val callback = object : ShareEntryPoint.Callback { override fun onDone(sessionId: SessionId, roomIds: List) { @@ -480,6 +501,13 @@ class RootFlowNode( override fun onCancel() { backstack.pop() } + + override fun onSessionVerificationRequired(sessionId: SessionId) { + val navTarget = NavTarget.OtherAccountSessionVerification(sessionId) + if (backstack.elements.value.lastOrNull()?.key?.navTarget != navTarget) { + backstack.push(navTarget) + } + } } shareEntryPoint.createNode( parentNode = this, @@ -582,10 +610,11 @@ class RootFlowNode( // No session, open login switchToNotLoggedInFlow(null) } else { - // Wait for the current session to be restored, then display the incoming share on top of it. + // Wait for the current session to be restored and for the FTUE to be complete, so that the session is verified, + // then display the incoming share on top of it. // The incoming share does not belong to the current session: in case of multiple accounts, the user can switch account // from the room select screen, and the share is kept if the current session is signed out in the meantime. - attachSession(latestSessionId) + attachSession(latestSessionId).waitForHome() backstack.push(NavTarget.IncomingShare(latestSessionId, shareIntentData)) } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionVerificationNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionVerificationNode.kt new file mode 100644 index 0000000000..1b6078888a --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionVerificationNode.kt @@ -0,0 +1,89 @@ +/* + * 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.ftue.api.SessionVerificationEntryPoint +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 kotlinx.parcelize.Parcelize + +/** + * Display the verification of a session which is not the current one, without switching the current session. + * It sets up the Session graph of the session to verify. + */ +@ContributesNode(AppScope::class) +@AssistedInject +class OtherSessionVerificationNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + sessionGraphFactory: SessionGraphFactory, + matrixClientProvider: MatrixClientProvider, + private val sessionVerificationEntryPoint: SessionVerificationEntryPoint, +) : 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, + ) : 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 sessionVerificationEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + callback = object : SessionVerificationEntryPoint.Callback { + override fun onDone() { + callback.onDone() + } + }, + ) + } + + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = navModel, + modifier = modifier, + ) + } +} diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt index f8a1048591..669c414045 100644 --- a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt +++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt @@ -72,6 +72,10 @@ class ForwardMessagesNode( override fun onCancel() { callback.onDone(emptyList()) } + + override fun onSessionVerificationRequired(sessionId: SessionId) { + // Only called in Share mode + } } return roomSelectEntryPoint.createNode( diff --git a/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/SessionVerificationEntryPoint.kt b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/SessionVerificationEntryPoint.kt new file mode 100644 index 0000000000..95c801d009 --- /dev/null +++ b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/SessionVerificationEntryPoint.kt @@ -0,0 +1,32 @@ +/* + * 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.features.ftue.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint + +/** + * Entry point to the session verification flow displayed during the FTUE, to be used outside of the FTUE. + * As in the FTUE, the user cannot go back from this flow: they have to verify the session or to sign out. + */ +interface SessionVerificationEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: Callback, + ): Node + + interface Callback : Plugin { + /** + * Called when the session has been verified. + */ + fun onDone() + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultSessionVerificationEntryPoint.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultSessionVerificationEntryPoint.kt new file mode 100644 index 0000000000..7959391787 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultSessionVerificationEntryPoint.kt @@ -0,0 +1,32 @@ +/* + * 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.features.ftue.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.ftue.api.SessionVerificationEntryPoint +import io.element.android.features.ftue.impl.sessionverification.FtueSessionVerificationFlowNode +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultSessionVerificationEntryPoint : SessionVerificationEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: SessionVerificationEntryPoint.Callback, + ): Node { + val flowNodeCallback = object : FtueSessionVerificationFlowNode.Callback { + override fun onDone() { + callback.onDone() + } + } + return parentNode.createNode(buildContext, listOf(flowNodeCallback)) + } +} diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultSessionVerificationEntryPointTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultSessionVerificationEntryPointTest.kt new file mode 100644 index 0000000000..b9248b3bbf --- /dev/null +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultSessionVerificationEntryPointTest.kt @@ -0,0 +1,63 @@ +/* + * 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.features.ftue.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.features.ftue.api.SessionVerificationEntryPoint +import io.element.android.features.ftue.impl.sessionverification.FtueSessionVerificationFlowNode +import io.element.android.features.securebackup.api.SecureBackupEntryPoint +import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DefaultSessionVerificationEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() = runTest { + val entryPoint = DefaultSessionVerificationEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + FtueSessionVerificationFlowNode( + buildContext = buildContext, + plugins = plugins, + outgoingVerificationEntryPoint = object : OutgoingVerificationEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: OutgoingVerificationEntryPoint.Params, + callback: OutgoingVerificationEntryPoint.Callback, + ) = lambdaError() + }, + secureBackupEntryPoint = object : SecureBackupEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: SecureBackupEntryPoint.Params, + callback: SecureBackupEntryPoint.Callback, + ) = lambdaError() + }, + ) + } + val callback = object : SessionVerificationEntryPoint.Callback { + override fun onDone() = lambdaError() + } + val result = entryPoint.createNode(parentNode, BuildContext.root(null), callback) + assertThat(result).isInstanceOf(FtueSessionVerificationFlowNode::class.java) + } +} diff --git a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt index 3ceafc4b0b..e34deb9754 100644 --- a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt +++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt @@ -44,5 +44,12 @@ interface ShareEntryPoint : FeatureEntryPoint { * Called when the user cancels the share. */ fun onCancel() + + /** + * Called when the user has selected an account whose session is not verified. + * The session has to be verified before the user can share content with it. + * @param sessionId the session to verify. + */ + fun onSessionVerificationRequired(sessionId: SessionId) } } diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt index dc659391dd..72f331abb2 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt @@ -69,6 +69,10 @@ class ShareNode( override fun onCancel() { callback.onCancel() } + + override fun onSessionVerificationRequired(sessionId: SessionId) { + callback.onSessionVerificationRequired(sessionId) + } } return roomSelectEntryPoint.createNode( diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt index e1a5709df0..62c78d5a66 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt @@ -46,6 +46,7 @@ class DefaultShareEntryPointTest { val callback = object : ShareEntryPoint.Callback { override fun onDone(sessionId: SessionId, roomIds: List) = lambdaError() override fun onCancel() = lambdaError() + override fun onSessionVerificationRequired(sessionId: SessionId) = lambdaError() } val params = ShareEntryPoint.Params( sessionId = A_SESSION_ID, diff --git a/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt index 5fec007261..39025a95d1 100644 --- a/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt +++ b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt @@ -41,6 +41,13 @@ interface RoomSelectEntryPoint : FeatureEntryPoint { */ fun onRoomSelected(sessionId: SessionId, roomIds: List) fun onCancel() + + /** + * Called in [RoomSelectMode.Share] mode when the user has selected an account whose session is not verified. + * The session has to be verified before the user can share content with it. + * @param sessionId the session to verify. + */ + fun onSessionVerificationRequired(sessionId: SessionId) } companion object { diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNavigator.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNavigator.kt new file mode 100644 index 0000000000..1b7747736e --- /dev/null +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNavigator.kt @@ -0,0 +1,17 @@ +/* + * 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.libraries.roomselect.impl + +import io.element.android.libraries.matrix.api.core.SessionId + +fun interface RoomSelectNavigator { + /** + * Navigate to the verification of the session [sessionId], which has to be verified before content can be shared with it. + */ + fun navigateToSessionVerification(sessionId: SessionId) +} diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt index f137ecc848..148b8bf53d 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt @@ -33,7 +33,7 @@ class RoomSelectNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, presenterFactory: RoomSelectPresenter.Factory, -) : Node(buildContext, plugins = plugins) { +) : Node(buildContext, plugins = plugins), RoomSelectNavigator { data class Inputs( val sessionId: SessionId, val mode: RoomSelectMode, @@ -41,13 +41,18 @@ class RoomSelectNode( ) : NodeInputs private val inputs: Inputs = inputs() + private val callback: RoomSelectEntryPoint.Callback = callback() private val presenter = presenterFactory.create( initialSessionId = inputs.sessionId, mode = inputs.mode, maxNumberOfRooms = inputs.maxNumberOfRooms, + navigator = this, ) private val stateFlow = launchMolecule { presenter.present() } - private val callback: RoomSelectEntryPoint.Callback = callback() + + override fun navigateToSessionVerification(sessionId: SessionId) { + callback.onSessionVerificationRequired(sessionId) + } @Composable override fun View(modifier: Modifier) { diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt index d8a7a10823..ff2036226d 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt @@ -31,12 +31,14 @@ import io.element.android.libraries.matrix.api.MatrixClientProvider 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.user.toMatrixUser +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.roomselect.api.RoomSelectMode import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import timber.log.Timber @@ -46,6 +48,7 @@ class RoomSelectPresenter( @Assisted private val initialSessionId: SessionId, @Assisted private val mode: RoomSelectMode, @Assisted private val maxNumberOfRooms: Int, + @Assisted private val navigator: RoomSelectNavigator, private val dataSourceFactory: RoomSelectSearchDataSource.Factory, private val matrixClientProvider: MatrixClientProvider, private val sessionStore: SessionStore, @@ -56,6 +59,7 @@ class RoomSelectPresenter( initialSessionId: SessionId, mode: RoomSelectMode, maxNumberOfRooms: Int, + navigator: RoomSelectNavigator, ): RoomSelectPresenter } @@ -103,30 +107,41 @@ class RoomSelectPresenter( // data source is removed from the composition and its coroutine scope is cancelled. // Also use the validity of the session as a key, so that the data source is removed if the session is invalidated, and recreated // if the session becomes valid again. - val (dataSource, hasClientError) = key(selectedSessionId, isSelectedSessionInvalid) { + val (dataSource, hasClientError, isSessionNotVerified) = key(selectedSessionId, isSelectedSessionInvalid) { val sessionId = selectedSessionId if (isSelectedSessionInvalid) { // Do not try to get the client of an invalid session: it cannot be restored, and an in-memory client would have been destroyed. - null to false + Triple(null, false, false) } else { val dataSourceCoroutineScope = rememberCoroutineScope() // The sessions are restored at startup, so the client should already be in memory. // A null value means that the client is being restored. - val roomListServiceResult by produceState(matrixClientProvider.getOrNull(sessionId)?.roomListService?.let { Result.success(it) }) { + val clientResult by produceState(matrixClientProvider.getOrNull(sessionId)?.let { Result.success(it) }) { if (value == null) { value = matrixClientProvider.getOrRestore(sessionId) .onFailure { Timber.e(it, "Failed to get the client for $sessionId") } - .map { it.roomListService } } } - val roomListService = roomListServiceResult?.getOrNull() - val dataSource = remember(roomListService) { - roomListService?.let { dataSourceFactory.create(dataSourceCoroutineScope, it) } + val client = clientResult?.getOrNull() + val dataSource = remember(client) { + client?.let { dataSourceFactory.create(dataSourceCoroutineScope, it.roomListService) } } - dataSource to (roomListServiceResult?.isFailure == true) + // Content cannot be shared with a session which is not verified. The initial session is verified, since the FTUE is complete. + val sessionVerifiedStatus by remember(client) { + if (mode == RoomSelectMode.Share && sessionId != initialSessionId && client != null) { + client.sessionVerificationService.sessionVerifiedStatus + } else { + MutableStateFlow(SessionVerifiedStatus.Unknown) + } + }.collectAsState() + Triple(dataSource, clientResult?.isFailure == true, sessionVerifiedStatus == SessionVerifiedStatus.NotVerified) } } val hasRoomListError = hasClientError || isSelectedSessionInvalid + val sessionToVerify = selectedSessionId.takeIf { isSessionNotVerified } + LaunchedEffect(sessionToVerify) { + sessionToVerify?.let(navigator::navigateToSessionVerification) + } val searchQuery = queryState.text.toString() LaunchedEffect(dataSource, searchQuery) { diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPointTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPointTest.kt index 7f88bb06ac..229e6bd3b0 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPointTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPointTest.kt @@ -38,7 +38,7 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() { RoomSelectNode( buildContext = buildContext, plugins = plugins, - presenterFactory = { initialSessionId, mode, maxNumberOfRooms -> + presenterFactory = { initialSessionId, mode, maxNumberOfRooms, _ -> assertThat(initialSessionId).isEqualTo(A_SESSION_ID) assertThat(mode).isEqualTo(testMode) assertThat(maxNumberOfRooms).isEqualTo(testMaxNumberOfRooms) @@ -49,6 +49,7 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() { val callback = object : RoomSelectEntryPoint.Callback { override fun onRoomSelected(sessionId: SessionId, roomIds: List) = lambdaError() override fun onCancel() = lambdaError() + override fun onSessionVerificationRequired(sessionId: SessionId) = lambdaError() } val params = RoomSelectEntryPoint.Params( sessionId = A_SESSION_ID, diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt index 95f5fefa03..aa02171d29 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt @@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 @@ -27,6 +28,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.room.aRoomSummary import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectMode @@ -39,6 +41,7 @@ import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.collections.immutable.persistentListOf @@ -289,6 +292,80 @@ class RoomSelectPresenterTest { } } + @Test + fun `present - select another account which is not verified navigates to its verification`() = runTest { + val sessionVerificationService = FakeSessionVerificationService(SessionVerifiedStatus.NotVerified) + val navigateToSessionVerificationLambda = lambdaRecorder { } + val presenter = createRoomSelectPresenter( + navigator = RoomSelectNavigator(navigateToSessionVerificationLambda), + mode = RoomSelectMode.Share, + sessionStore = aSessionStoreWithTwoAccounts(), + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + Result.success( + FakeMatrixClient( + sessionId = sessionId, + sessionVerificationService = if (sessionId == A_SESSION_ID_2) sessionVerificationService else FakeSessionVerificationService(), + ) + ) + }, + ) + presenter.test { + val state = awaitLastSequentialItem() + state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2)) + consumeItemsUntilPredicate { it.selectedAccount.userId == A_SESSION_ID_2 } + testScheduler.advanceUntilIdle() + navigateToSessionVerificationLambda.assertions().isCalledOnce().with(value(A_SESSION_ID_2)) + // The session is verified, no new navigation + sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified) + testScheduler.advanceUntilIdle() + navigateToSessionVerificationLambda.assertions().isCalledOnce() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - the initial session is not verified, no navigation to its verification`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + Result.success( + FakeMatrixClient( + sessionId = sessionId, + sessionVerificationService = FakeSessionVerificationService(SessionVerifiedStatus.NotVerified), + ) + ) + }, + ) + presenter.test { + // The default navigator fails if it is invoked + awaitLastSequentialItem() + testScheduler.advanceUntilIdle() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - forward mode, no navigation to the verification of the session`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Forward, + initialSessionId = A_SESSION_ID_2, + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + Result.success( + FakeMatrixClient( + sessionId = sessionId, + sessionVerificationService = FakeSessionVerificationService(SessionVerifiedStatus.NotVerified), + ) + ) + }, + ) + presenter.test { + // The default navigator fails if it is invoked + awaitLastSequentialItem() + testScheduler.advanceUntilIdle() + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - forward mode with several accounts does not show the account switch`() = runTest { val presenter = createRoomSelectPresenter( @@ -425,10 +502,12 @@ internal fun TestScope.createRoomSelectPresenter( matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider { sessionId -> Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListService)) }, + navigator: RoomSelectNavigator = RoomSelectNavigator { lambdaError() }, ) = RoomSelectPresenter( initialSessionId = initialSessionId, mode = mode, maxNumberOfRooms = maxNumberOfRooms, + navigator = navigator, dataSourceFactory = object : RoomSelectSearchDataSource.Factory { override fun create(coroutineScope: CoroutineScope, roomListService: RoomListService): RoomSelectSearchDataSource { return RoomSelectSearchDataSource(