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) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-09 16:36:29 +02:00
parent 9bc7e751c4
commit 00dab131dd
16 changed files with 407 additions and 13 deletions
@@ -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( suspend fun attachRoom(
roomIdOrAlias: RoomIdOrAlias, roomIdOrAlias: RoomIdOrAlias,
serverNames: List<String> = emptyList(), serverNames: List<String> = emptyList(),
@@ -43,6 +43,7 @@ import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.appnav.verification.IncomingVerificationRequestData import io.element.android.appnav.verification.IncomingVerificationRequestData
import io.element.android.appnav.verification.IncomingVerificationRequestObserver import io.element.android.appnav.verification.IncomingVerificationRequestObserver
import io.element.android.appnav.verification.OtherSessionIncomingVerificationNode 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.announcement.api.AnnouncementService
import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
@@ -244,6 +245,8 @@ class RootFlowNode(
.withPreviousValue() .withPreviousValue()
.onEach { (previous, current) -> .onEach { (previous, current) ->
(previous.orEmpty() - current).forEach { sessionId -> (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") Timber.d("Session $sessionId removed, remove it from the cache")
matrixSessionCache.remove(sessionId) matrixSessionCache.remove(sessionId)
} }
@@ -391,6 +394,10 @@ class RootFlowNode(
val sessionId: SessionId, val sessionId: SessionId,
val verificationRequest: VerificationRequest.Incoming, val verificationRequest: VerificationRequest.Incoming,
) : NavTarget ) : NavTarget
@Parcelize data class OtherAccountSessionVerification(
val sessionId: SessionId,
) : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -462,6 +469,20 @@ class RootFlowNode(
} }
createNode<OtherSessionIncomingVerificationNode>(buildContext, plugins = listOf(inputs, callback)) createNode<OtherSessionIncomingVerificationNode>(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<OtherSessionVerificationNode>(buildContext, plugins = listOf(inputs, callback))
}
is NavTarget.IncomingShare -> { is NavTarget.IncomingShare -> {
val callback = object : ShareEntryPoint.Callback { val callback = object : ShareEntryPoint.Callback {
override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) { override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) {
@@ -480,6 +501,13 @@ class RootFlowNode(
override fun onCancel() { override fun onCancel() {
backstack.pop() 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( shareEntryPoint.createNode(
parentNode = this, parentNode = this,
@@ -582,10 +610,11 @@ class RootFlowNode(
// No session, open login // No session, open login
switchToNotLoggedInFlow(null) switchToNotLoggedInFlow(null)
} else { } 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 // 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. // 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)) backstack.push(NavTarget.IncomingShare(latestSessionId, shareIntentData))
} }
} }
@@ -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<Plugin>,
sessionGraphFactory: SessionGraphFactory,
matrixClientProvider: MatrixClientProvider,
private val sessionVerificationEntryPoint: SessionVerificationEntryPoint,
) : ParentNode<OtherSessionVerificationNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
), DependencyInjectionGraphOwner {
interface Callback : Plugin {
fun onDone()
}
@Parcelize
object NavTarget : Parcelable
data class Inputs(
val sessionId: SessionId,
) : 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,
)
}
}
@@ -72,6 +72,10 @@ class ForwardMessagesNode(
override fun onCancel() { override fun onCancel() {
callback.onDone(emptyList()) callback.onDone(emptyList())
} }
override fun onSessionVerificationRequired(sessionId: SessionId) {
// Only called in Share mode
}
} }
return roomSelectEntryPoint.createNode( return roomSelectEntryPoint.createNode(
@@ -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()
}
}
@@ -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<FtueSessionVerificationFlowNode>(buildContext, listOf(flowNodeCallback))
}
}
@@ -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)
}
}
@@ -44,5 +44,12 @@ interface ShareEntryPoint : FeatureEntryPoint {
* Called when the user cancels the share. * Called when the user cancels the share.
*/ */
fun onCancel() 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)
} }
} }
@@ -69,6 +69,10 @@ class ShareNode(
override fun onCancel() { override fun onCancel() {
callback.onCancel() callback.onCancel()
} }
override fun onSessionVerificationRequired(sessionId: SessionId) {
callback.onSessionVerificationRequired(sessionId)
}
} }
return roomSelectEntryPoint.createNode( return roomSelectEntryPoint.createNode(
@@ -46,6 +46,7 @@ class DefaultShareEntryPointTest {
val callback = object : ShareEntryPoint.Callback { val callback = object : ShareEntryPoint.Callback {
override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) = lambdaError() override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) = lambdaError()
override fun onCancel() = lambdaError() override fun onCancel() = lambdaError()
override fun onSessionVerificationRequired(sessionId: SessionId) = lambdaError()
} }
val params = ShareEntryPoint.Params( val params = ShareEntryPoint.Params(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
@@ -41,6 +41,13 @@ interface RoomSelectEntryPoint : FeatureEntryPoint {
*/ */
fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>)
fun onCancel() 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 { companion object {
@@ -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)
}
@@ -33,7 +33,7 @@ class RoomSelectNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: RoomSelectPresenter.Factory, presenterFactory: RoomSelectPresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins), RoomSelectNavigator {
data class Inputs( data class Inputs(
val sessionId: SessionId, val sessionId: SessionId,
val mode: RoomSelectMode, val mode: RoomSelectMode,
@@ -41,13 +41,18 @@ class RoomSelectNode(
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
private val callback: RoomSelectEntryPoint.Callback = callback()
private val presenter = presenterFactory.create( private val presenter = presenterFactory.create(
initialSessionId = inputs.sessionId, initialSessionId = inputs.sessionId,
mode = inputs.mode, mode = inputs.mode,
maxNumberOfRooms = inputs.maxNumberOfRooms, maxNumberOfRooms = inputs.maxNumberOfRooms,
navigator = this,
) )
private val stateFlow = launchMolecule { presenter.present() } private val stateFlow = launchMolecule { presenter.present() }
private val callback: RoomSelectEntryPoint.Callback = callback()
override fun navigateToSessionVerification(sessionId: SessionId) {
callback.onSessionVerificationRequired(sessionId)
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
@@ -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.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser 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.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.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@@ -46,6 +48,7 @@ class RoomSelectPresenter(
@Assisted private val initialSessionId: SessionId, @Assisted private val initialSessionId: SessionId,
@Assisted private val mode: RoomSelectMode, @Assisted private val mode: RoomSelectMode,
@Assisted private val maxNumberOfRooms: Int, @Assisted private val maxNumberOfRooms: Int,
@Assisted private val navigator: RoomSelectNavigator,
private val dataSourceFactory: RoomSelectSearchDataSource.Factory, private val dataSourceFactory: RoomSelectSearchDataSource.Factory,
private val matrixClientProvider: MatrixClientProvider, private val matrixClientProvider: MatrixClientProvider,
private val sessionStore: SessionStore, private val sessionStore: SessionStore,
@@ -56,6 +59,7 @@ class RoomSelectPresenter(
initialSessionId: SessionId, initialSessionId: SessionId,
mode: RoomSelectMode, mode: RoomSelectMode,
maxNumberOfRooms: Int, maxNumberOfRooms: Int,
navigator: RoomSelectNavigator,
): RoomSelectPresenter ): RoomSelectPresenter
} }
@@ -103,30 +107,41 @@ class RoomSelectPresenter(
// data source is removed from the composition and its coroutine scope is cancelled. // 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 // 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. // if the session becomes valid again.
val (dataSource, hasClientError) = key(selectedSessionId, isSelectedSessionInvalid) { val (dataSource, hasClientError, isSessionNotVerified) = key(selectedSessionId, isSelectedSessionInvalid) {
val sessionId = selectedSessionId val sessionId = selectedSessionId
if (isSelectedSessionInvalid) { 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. // 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 { } else {
val dataSourceCoroutineScope = rememberCoroutineScope() val dataSourceCoroutineScope = rememberCoroutineScope()
// The sessions are restored at startup, so the client should already be in memory. // The sessions are restored at startup, so the client should already be in memory.
// A null value means that the client is being restored. // 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) { if (value == null) {
value = matrixClientProvider.getOrRestore(sessionId) value = matrixClientProvider.getOrRestore(sessionId)
.onFailure { Timber.e(it, "Failed to get the client for $sessionId") } .onFailure { Timber.e(it, "Failed to get the client for $sessionId") }
.map { it.roomListService }
} }
} }
val roomListService = roomListServiceResult?.getOrNull() val client = clientResult?.getOrNull()
val dataSource = remember(roomListService) { val dataSource = remember(client) {
roomListService?.let { dataSourceFactory.create(dataSourceCoroutineScope, it) } 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 hasRoomListError = hasClientError || isSelectedSessionInvalid
val sessionToVerify = selectedSessionId.takeIf { isSessionNotVerified }
LaunchedEffect(sessionToVerify) {
sessionToVerify?.let(navigator::navigateToSessionVerification)
}
val searchQuery = queryState.text.toString() val searchQuery = queryState.text.toString()
LaunchedEffect(dataSource, searchQuery) { LaunchedEffect(dataSource, searchQuery) {
@@ -38,7 +38,7 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() {
RoomSelectNode( RoomSelectNode(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
presenterFactory = { initialSessionId, mode, maxNumberOfRooms -> presenterFactory = { initialSessionId, mode, maxNumberOfRooms, _ ->
assertThat(initialSessionId).isEqualTo(A_SESSION_ID) assertThat(initialSessionId).isEqualTo(A_SESSION_ID)
assertThat(mode).isEqualTo(testMode) assertThat(mode).isEqualTo(testMode)
assertThat(maxNumberOfRooms).isEqualTo(testMaxNumberOfRooms) assertThat(maxNumberOfRooms).isEqualTo(testMaxNumberOfRooms)
@@ -49,6 +49,7 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() {
val callback = object : RoomSelectEntryPoint.Callback { val callback = object : RoomSelectEntryPoint.Callback {
override fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) = lambdaError() override fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) = lambdaError()
override fun onCancel() = lambdaError() override fun onCancel() = lambdaError()
override fun onSessionVerificationRequired(sessionId: SessionId) = lambdaError()
} }
val params = RoomSelectEntryPoint.Params( val params = RoomSelectEntryPoint.Params(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
@@ -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.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService 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.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.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2 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.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList 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.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.matrix.ui.model.toSelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode 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.assert
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder 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.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf 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<SessionId, Unit> { }
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 @Test
fun `present - forward mode with several accounts does not show the account switch`() = runTest { fun `present - forward mode with several accounts does not show the account switch`() = runTest {
val presenter = createRoomSelectPresenter( val presenter = createRoomSelectPresenter(
@@ -425,10 +502,12 @@ internal fun TestScope.createRoomSelectPresenter(
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider { sessionId -> matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider { sessionId ->
Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListService)) Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListService))
}, },
navigator: RoomSelectNavigator = RoomSelectNavigator { lambdaError() },
) = RoomSelectPresenter( ) = RoomSelectPresenter(
initialSessionId = initialSessionId, initialSessionId = initialSessionId,
mode = mode, mode = mode,
maxNumberOfRooms = maxNumberOfRooms, maxNumberOfRooms = maxNumberOfRooms,
navigator = navigator,
dataSourceFactory = object : RoomSelectSearchDataSource.Factory { dataSourceFactory = object : RoomSelectSearchDataSource.Factory {
override fun create(coroutineScope: CoroutineScope, roomListService: RoomListService): RoomSelectSearchDataSource { override fun create(coroutineScope: CoroutineScope, roomListService: RoomListService): RoomSelectSearchDataSource {
return RoomSelectSearchDataSource( return RoomSelectSearchDataSource(