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(