Merge pull request #7882 from element-hq/feature/bma/multiAccountShareAtRoot

Keep the incoming share displayed when the current account is signed out
This commit is contained in:
Benoit Marty
2026-10-12 11:19:49 +02:00
committed by GitHub
26 changed files with 696 additions and 153 deletions
@@ -8,9 +8,15 @@
package io.element.android.appnav package io.element.android.appnav
import com.bumble.appyx.core.navigation.NavKey
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.BackStackElement
import com.bumble.appyx.navmodel.backstack.BackStackElements
import com.bumble.appyx.navmodel.backstack.operation.BackStackOperation
import com.bumble.appyx.navmodel.backstack.operation.NewRoot import com.bumble.appyx.navmodel.backstack.operation.NewRoot
import com.bumble.appyx.navmodel.backstack.operation.Remove import com.bumble.appyx.navmodel.backstack.operation.Remove
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue
/** /**
* Don't process NewRoot if the nav target already exists in the stack. * Don't process NewRoot if the nav target already exists in the stack.
@@ -32,3 +38,45 @@ fun <T : Any> BackStack<T>.removeLast(element: T) {
} ?: return } ?: return
accept(Remove(lastExpectedNavElement.key)) accept(Remove(lastExpectedNavElement.key))
} }
/**
* Replace the root element of the backstack, and keep the elements on top of it, contrary to [safeRoot] which removes all the elements.
*/
fun <T : Any> BackStack<T>.replaceRoot(element: T) {
accept(ReplaceRoot(element))
}
/**
* Replace the root element of the backstack, keeping the elements on top of it.
*
* Operation:
*
* [A, B, C] + ReplaceRoot(D) = [D, B, C]
* [A] + ReplaceRoot(D) = [D]
*/
@Parcelize
internal data class ReplaceRoot<T : Any>(
private val element: @RawValue T
) : BackStackOperation<T> {
override fun isApplicable(elements: BackStackElements<T>): Boolean =
elements.rootElement()?.key?.navTarget != element
override fun invoke(elements: BackStackElements<T>): BackStackElements<T> {
val root = elements.rootElement() ?: return elements
if (root.targetState == BackStack.State.ACTIVE) {
// Nothing on top of the root, so this is the same as NewRoot
return NewRoot(element).invoke(elements)
}
// The root is stashed below other elements: drop it, as Remove does for a non active element,
// and add the new root, stashed, in place of it.
val newRoot = BackStackElement(
key = NavKey(element),
fromState = BackStack.State.STASHED,
targetState = BackStack.State.STASHED,
operation = this,
)
return elements.map { if (it == root) newRoot else it }
}
private fun BackStackElements<T>.rootElement() = firstOrNull { it.targetState != BackStack.State.DESTROYED }
}
@@ -33,8 +33,6 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@@ -61,7 +59,6 @@ class LoggedInAppScopeFlowNode(
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToBugReport() fun navigateToBugReport()
fun navigateToAddAccount() fun navigateToAddAccount()
fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?)
} }
private val callback: Callback = callback() private val callback: Callback = callback()
@@ -94,10 +91,6 @@ class LoggedInAppScopeFlowNode(
override fun navigateToAddAccount() { override fun navigateToAddAccount() {
callback.navigateToAddAccount() callback.navigateToAddAccount()
} }
override fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) {
callback.switchAccountAndOpenRoom(sessionId, roomId)
}
} }
return createNode<LoggedInFlowNode>(buildContext, listOf(callback)) return createNode<LoggedInFlowNode>(buildContext, listOf(callback))
} }
@@ -67,8 +67,6 @@ import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.share.api.ShareIntentData
import io.element.android.features.startchat.api.StartChatEntryPoint import io.element.android.features.startchat.api.StartChatEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
@@ -88,7 +86,6 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
@@ -138,7 +135,6 @@ class LoggedInFlowNode(
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
private val ftueService: FtueService, private val ftueService: FtueService,
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint, private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
private val shareEntryPoint: ShareEntryPoint,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val sendingQueue: SendQueues, private val sendingQueue: SendQueues,
private val nativeCallHost: NativeCallHost, private val nativeCallHost: NativeCallHost,
@@ -174,7 +170,6 @@ class LoggedInFlowNode(
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToBugReport() fun navigateToBugReport()
fun navigateToAddAccount() fun navigateToAddAccount()
fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?)
} }
private val callback: Callback = callback() private val callback: Callback = callback()
@@ -296,9 +291,6 @@ class LoggedInFlowNode(
@Parcelize @Parcelize
data object RoomDirectory : NavTarget data object RoomDirectory : NavTarget
@Parcelize
data class IncomingShare(val shareIntentData: ShareIntentData) : NavTarget
@Parcelize @Parcelize
data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget
} }
@@ -570,36 +562,6 @@ class LoggedInFlowNode(
}, },
) )
} }
is NavTarget.IncomingShare -> {
shareEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = ShareEntryPoint.Params(shareIntentData = navTarget.shareIntentData),
callback = object : ShareEntryPoint.Callback {
override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) {
// Remove the incoming share screen
backstack.pop()
if (sessionId != matrixClient.sessionId) {
// The data has been shared using another session, switch to it
callback.switchAccountAndOpenRoom(sessionId, roomIds.singleOrNull())
return
}
// Navigate to the room if the text/media was shared to a single one
roomIds.singleOrNull()?.let { roomId ->
lifecycleScope.launch {
// Wait until the incoming share screen is removed
backstack.elements.first { it.lastOrNull()?.key?.navTarget !is NavTarget.IncomingShare }
// Then attach the room
attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false)
}
}
}
},
)
}
is NavTarget.IncomingVerificationRequest -> { is NavTarget.IncomingVerificationRequest -> {
incomingVerificationEntryPoint.createNode( incomingVerificationEntryPoint.createNode(
parentNode = this, parentNode = this,
@@ -615,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(),
@@ -659,17 +630,6 @@ class LoggedInFlowNode(
} }
} }
internal suspend fun attachIncomingShare(shareIntentData: ShareIntentData) {
waitForNavTargetAttached { navTarget ->
navTarget is NavTarget.Home
}
attachChild<Node> {
backstack.push(
NavTarget.IncomingShare(shareIntentData)
)
}
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val colors by remember { val colors by remember {
@@ -43,10 +43,12 @@ 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
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.share.api.ShareIntentData import io.element.android.features.share.api.ShareIntentData
import io.element.android.features.signedout.api.SignedOutEntryPoint import io.element.android.features.signedout.api.SignedOutEntryPoint
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
@@ -112,6 +114,7 @@ class RootFlowNode(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher, private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
private val incomingVerificationRequestObserver: IncomingVerificationRequestObserver, private val incomingVerificationRequestObserver: IncomingVerificationRequestObserver,
private val shareEntryPoint: ShareEntryPoint,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope, @AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : BaseFlowNode<RootFlowNode.NavTarget>( ) : BaseFlowNode<RootFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
@@ -216,7 +219,7 @@ class RootFlowNode(
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) { private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
pendingLoginParams = null pendingLoginParams = null
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId)) setRoot(NavTarget.LoggedInFlow(sessionId, navId))
restoreAllSessions() restoreAllSessions()
} }
@@ -242,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)
} }
@@ -286,7 +291,20 @@ class RootFlowNode(
private fun switchToSignedOutFlow(sessionId: SessionId) { private fun switchToSignedOutFlow(sessionId: SessionId) {
pendingLoginParams = null pendingLoginParams = null
backstack.safeRoot(NavTarget.SignedOutFlow(sessionId)) setRoot(NavTarget.SignedOutFlow(sessionId))
}
/**
* Set the root of the backstack. If an incoming share is displayed, keep it on top of the new root, so that the user can
* finish sharing, for instance with another account if the current one has been signed out.
*/
private fun setRoot(navTarget: NavTarget) {
val hasIncomingShare = backstack.elements.value.any { it.key.navTarget is NavTarget.IncomingShare }
if (hasIncomingShare) {
backstack.replaceRoot(navTarget)
} else {
backstack.safeRoot(navTarget)
}
} }
private suspend fun restoreSessionIfNeeded( private suspend fun restoreSessionIfNeeded(
@@ -367,10 +385,19 @@ class RootFlowNode(
@Parcelize data object BugReport : NavTarget @Parcelize data object BugReport : NavTarget
@Parcelize data class IncomingShare(
val sessionId: SessionId,
val shareIntentData: ShareIntentData,
) : NavTarget
@Parcelize data class OtherAccountIncomingVerificationRequest( @Parcelize data class OtherAccountIncomingVerificationRequest(
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 {
@@ -389,15 +416,6 @@ class RootFlowNode(
override fun navigateToAddAccount() { override fun navigateToAddAccount() {
backstack.push(NavTarget.NotLoggedInFlow(null)) backstack.push(NavTarget.NotLoggedInFlow(null))
} }
override fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) {
lifecycleScope.launch {
val loggedInFlowNode = attachSession(sessionId)
roomId?.let {
loggedInFlowNode.attachRoom(it.toRoomIdOrAlias(), clearBackstack = false)
}
}
}
} }
val savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap) val savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap)
val buildContext = if (savedNavState != null) { val buildContext = if (savedNavState != null) {
@@ -451,6 +469,56 @@ 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 -> {
val callback = object : ShareEntryPoint.Callback {
override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) {
// Remove the incoming share screen
backstack.pop()
lifecycleScope.launch {
// Switch to the session which has been used to share the data, if necessary,
// and navigate to the room if the data was shared to a single one.
val loggedInFlowNode = attachSession(sessionId)
roomIds.singleOrNull()?.let { roomId ->
loggedInFlowNode.attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false)
}
}
}
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,
buildContext = buildContext,
params = ShareEntryPoint.Params(
sessionId = navTarget.sessionId,
shareIntentData = navTarget.shareIntentData,
),
callback = callback,
)
}
NavTarget.BugReport -> { NavTarget.BugReport -> {
val callback = object : BugReportEntryPoint.Callback { val callback = object : BugReportEntryPoint.Callback {
override fun onDone() { override fun onDone() {
@@ -542,9 +610,12 @@ 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 attach the incoming share node. // Wait for the current session to be restored and for the FTUE to be complete, so that the session is verified,
// In case of multiple accounts, the user can switch account from the room select screen. // then display the incoming share on top of it.
attachSession(latestSessionId).attachIncomingShare(shareIntentData) // 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).waitForHome()
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,
)
}
}
@@ -0,0 +1,56 @@
/*
* 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
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class BackstackExtTest {
@Test
fun `replaceRoot without element on top replaces the root`() {
val backStack = BackStack(initialElement = "A", savedStateMap = null)
backStack.replaceRoot("D")
assertThat(backStack.navTargets()).containsExactly("D")
assertThat(backStack.activeTarget()).isEqualTo("D")
}
@Test
fun `replaceRoot with elements on top replaces the root and keeps the elements on top`() {
val backStack = BackStack(initialElement = "A", savedStateMap = null)
backStack.push("B")
backStack.push("C")
backStack.replaceRoot("D")
assertThat(backStack.navTargets()).containsExactly("D", "B", "C").inOrder()
assertThat(backStack.activeTarget()).isEqualTo("C")
// When the elements on top are removed, the new root is displayed
backStack.pop()
backStack.pop()
assertThat(backStack.activeTarget()).isEqualTo("D")
}
@Test
fun `replaceRoot with the current root does nothing`() {
val backStack = BackStack(initialElement = "A", savedStateMap = null)
backStack.push("B")
backStack.replaceRoot("A")
assertThat(backStack.navTargets()).containsExactly("A", "B").inOrder()
assertThat(backStack.activeTarget()).isEqualTo("B")
}
private fun BackStack<String>.navTargets() = elements.value
.filter { it.targetState != BackStack.State.DESTROYED }
.map { it.key.navTarget }
private fun BackStack<String>.activeTarget() = elements.value
.single { it.targetState == BackStack.State.ACTIVE }
.key
.navTarget
}
@@ -14,6 +14,7 @@ import android.net.Uri
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.AncestryInfo import com.bumble.appyx.core.modality.AncestryInfo
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.node import com.bumble.appyx.core.node.node
import com.bumble.appyx.navmodel.backstack.activeElement import com.bumble.appyx.navmodel.backstack.activeElement
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
@@ -34,6 +35,7 @@ import io.element.android.features.login.test.FakeLoginIntentResolver
import io.element.android.features.login.test.accesscontrol.FakeAccountProviderAccessControl import io.element.android.features.login.test.accesscontrol.FakeAccountProviderAccessControl
import io.element.android.features.preferences.test.FakeCacheService import io.element.android.features.preferences.test.FakeCacheService
import io.element.android.features.rageshake.test.FakeBugReportEntryPoint import io.element.android.features.rageshake.test.FakeBugReportEntryPoint
import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.share.test.FakeShareIntentHandler import io.element.android.features.share.test.FakeShareIntentHandler
import io.element.android.features.signedout.test.FakeSignedOutEntryPoint import io.element.android.features.signedout.test.FakeSignedOutEntryPoint
import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint
@@ -52,6 +54,7 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher
import io.element.android.services.apperror.test.FakeAppErrorStateService import io.element.android.services.apperror.test.FakeAppErrorStateService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.FakeNodeFactoriesBindings import io.element.android.tests.testutils.node.FakeNodeFactoriesBindings
import io.element.android.tests.testutils.node.FakeParentNode import io.element.android.tests.testutils.node.FakeParentNode
import io.element.android.tests.testutils.presenter.NotUsedPresenter import io.element.android.tests.testutils.presenter.NotUsedPresenter
@@ -192,6 +195,14 @@ class RootFlowNodeTest : RobolectricTest() {
appForegroundStateService = FakeAppForegroundStateService(), appForegroundStateService = FakeAppForegroundStateService(),
), ),
appCoroutineScope = backgroundScope, appCoroutineScope = backgroundScope,
shareEntryPoint = object : ShareEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: ShareEntryPoint.Params,
callback: ShareEntryPoint.Callback,
): Node = lambdaError()
},
) )
} }
} }
@@ -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)
}
}
@@ -16,7 +16,14 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
interface ShareEntryPoint : FeatureEntryPoint { interface ShareEntryPoint : FeatureEntryPoint {
data class Params(val shareIntentData: ShareIntentData) /**
* @param sessionId the session to use initially. The user can select another session.
* @param shareIntentData the data to share.
*/
data class Params(
val sessionId: SessionId,
val shareIntentData: ShareIntentData,
)
fun createNode( fun createNode(
parentNode: Node, parentNode: Node,
@@ -27,10 +34,22 @@ interface ShareEntryPoint : FeatureEntryPoint {
interface Callback : Plugin { interface Callback : Plugin {
/** /**
* Called when the share is done, or cancelled. * Called when the data has been shared.
* @param sessionId the session which has been used to share the data. It can be different from the current session. * @param sessionId the session which has been used to share the data. It can be different from the current session.
* @param roomIds the rooms the data has been shared to, empty if the share has been cancelled. * @param roomIds the rooms the data has been shared to.
*/ */
fun onDone(sessionId: SessionId, roomIds: List<RoomId>) fun onDone(sessionId: SessionId, roomIds: List<RoomId>)
/**
* 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)
} }
} }
@@ -10,12 +10,12 @@ package io.element.android.features.share.impl
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.share.api.ShareEntryPoint import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
@ContributesBinding(SessionScope::class) @ContributesBinding(AppScope::class)
class DefaultShareEntryPoint : ShareEntryPoint { class DefaultShareEntryPoint : ShareEntryPoint {
override fun createNode( override fun createNode(
parentNode: Node, parentNode: Node,
@@ -26,7 +26,10 @@ class DefaultShareEntryPoint : ShareEntryPoint {
return parentNode.createNode<ShareNode>( return parentNode.createNode<ShareNode>(
buildContext = buildContext, buildContext = buildContext,
plugins = listOf( plugins = listOf(
ShareNode.Inputs(shareIntentData = params.shareIntentData), ShareNode.Inputs(
sessionId = params.sessionId,
shareIntentData = params.shareIntentData,
),
callback, callback,
) )
) )
@@ -18,6 +18,7 @@ import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
@@ -26,21 +27,19 @@ import io.element.android.features.share.api.ShareIntentData
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
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
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class) @ContributesNode(AppScope::class)
@AssistedInject @AssistedInject
class ShareNode( class ShareNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: SharePresenter.Factory, presenterFactory: SharePresenter.Factory,
private val roomSelectEntryPoint: RoomSelectEntryPoint, private val roomSelectEntryPoint: RoomSelectEntryPoint,
private val sessionId: SessionId,
) : ParentNode<ShareNode.NavTarget>( ) : ParentNode<ShareNode.NavTarget>(
navModel = PermanentNavModel( navModel = PermanentNavModel(
navTargets = setOf(NavTarget), navTargets = setOf(NavTarget),
@@ -52,10 +51,13 @@ class ShareNode(
@Parcelize @Parcelize
object NavTarget : Parcelable object NavTarget : Parcelable
data class Inputs(val shareIntentData: ShareIntentData) : NodeInputs data class Inputs(
val sessionId: SessionId,
val shareIntentData: ShareIntentData,
) : NodeInputs
private val inputs = inputs<Inputs>() private val inputs = inputs<Inputs>()
private val presenter = presenterFactory.create(inputs.shareIntentData, sessionId) private val presenter = presenterFactory.create(inputs.shareIntentData)
private val callback: ShareEntryPoint.Callback = callback() private val callback: ShareEntryPoint.Callback = callback()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -65,7 +67,11 @@ class ShareNode(
} }
override fun onCancel() { override fun onCancel() {
callback.onDone(sessionId, emptyList()) callback.onCancel()
}
override fun onSessionVerificationRequired(sessionId: SessionId) {
callback.onSessionVerificationRequired(sessionId)
} }
} }
@@ -73,7 +79,7 @@ class ShareNode(
parentNode = this, parentNode = this,
buildContext = buildContext, buildContext = buildContext,
params = RoomSelectEntryPoint.Params( params = RoomSelectEntryPoint.Params(
sessionId = sessionId, sessionId = inputs.sessionId,
mode = RoomSelectMode.Share, mode = RoomSelectMode.Share,
maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS, maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
), ),
@@ -92,7 +98,7 @@ class ShareNode(
val state = presenter.present() val state = presenter.present()
ShareView( ShareView(
state = state, state = state,
onShareSuccess = { roomIds -> callback.onDone(state.sessionId, roomIds) }, onShareSuccess = { result -> callback.onDone(result.sessionId, result.roomIds) },
) )
} }
} }
@@ -10,9 +10,7 @@ package io.element.android.features.share.impl
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
@@ -30,7 +28,6 @@ import kotlinx.coroutines.launch
@AssistedInject @AssistedInject
class SharePresenter( class SharePresenter(
@Assisted private val shareIntentData: ShareIntentData, @Assisted private val shareIntentData: ShareIntentData,
@Assisted initialSessionId: SessionId,
@AppCoroutineScope @AppCoroutineScope
private val appCoroutineScope: CoroutineScope, private val appCoroutineScope: CoroutineScope,
private val matrixClientProvider: MatrixClientProvider, private val matrixClientProvider: MatrixClientProvider,
@@ -38,24 +35,21 @@ class SharePresenter(
) : Presenter<ShareState> { ) : Presenter<ShareState> {
@AssistedFactory @AssistedFactory
fun interface Factory { fun interface Factory {
fun create(shareIntentData: ShareIntentData, initialSessionId: SessionId): SharePresenter fun create(shareIntentData: ShareIntentData): SharePresenter
} }
private val shareActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized) private val shareActionState: MutableState<AsyncAction<ShareResult>> = mutableStateOf(AsyncAction.Uninitialized)
/** /**
* The session the data is shared with, it can be updated if the user selects rooms from another session. * Share the data to the rooms with [roomIds], which belong to the session [sessionId]. It can be another session than the current one.
*/ */
private var targetSessionId by mutableStateOf(initialSessionId)
fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) { fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) {
if (shareActionState.value.isLoading()) return if (shareActionState.value.isLoading()) return
targetSessionId = sessionId
appCoroutineScope.launch { appCoroutineScope.launch {
suspend { suspend {
val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow() val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow()
shareDataSender.send(client, shareIntentData, roomIds).getOrThrow() shareDataSender.send(client, shareIntentData, roomIds).getOrThrow()
roomIds ShareResult(sessionId = sessionId, roomIds = roomIds)
}.runCatchingUpdatingState(shareActionState) }.runCatchingUpdatingState(shareActionState)
} }
} }
@@ -69,7 +63,6 @@ class SharePresenter(
} }
return ShareState( return ShareState(
sessionId = targetSessionId,
shareAction = shareActionState.value, shareAction = shareActionState.value,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
@@ -13,7 +13,16 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
data class ShareState( data class ShareState(
val sessionId: SessionId, val shareAction: AsyncAction<ShareResult>,
val shareAction: AsyncAction<List<RoomId>>,
val eventSink: (ShareEvent) -> Unit val eventSink: (ShareEvent) -> Unit
) )
/**
* The result of a successful share.
* @param sessionId the session which has been used to share the data, it can be another session than the current one.
* @param roomIds the rooms the data has been shared to.
*/
data class ShareResult(
val sessionId: SessionId,
val roomIds: List<RoomId>,
)
@@ -21,9 +21,7 @@ open class ShareStatePreviewParam : PreviewParameterProvider<ShareState> {
shareAction = AsyncAction.Loading, shareAction = AsyncAction.Loading,
), ),
aShareState( aShareState(
shareAction = AsyncAction.Success( shareAction = AsyncAction.Success(aShareResult())
listOf(RoomId("!room2:domain")),
)
), ),
aShareState( aShareState(
shareAction = AsyncAction.Failure(RuntimeException("error")), shareAction = AsyncAction.Failure(RuntimeException("error")),
@@ -31,12 +29,18 @@ open class ShareStatePreviewParam : PreviewParameterProvider<ShareState> {
) )
} }
fun aShareState( private fun aShareState(
sessionId: SessionId = SessionId("@alice:server.org"), shareAction: AsyncAction<ShareResult> = AsyncAction.Uninitialized,
shareAction: AsyncAction<List<RoomId>> = AsyncAction.Uninitialized,
eventSink: (ShareEvent) -> Unit = {} eventSink: (ShareEvent) -> Unit = {}
) = ShareState( ) = ShareState(
sessionId = sessionId,
shareAction = shareAction, shareAction = shareAction,
eventSink = eventSink eventSink = eventSink
) )
private fun aShareResult(
sessionId: SessionId = SessionId("@alice:server.org"),
roomIds: List<RoomId> = listOf(RoomId("!room2:domain")),
) = ShareResult(
sessionId = sessionId,
roomIds = roomIds,
)
@@ -13,12 +13,11 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.RoomId
@Composable @Composable
fun ShareView( fun ShareView(
state: ShareState, state: ShareState,
onShareSuccess: (List<RoomId>) -> Unit, onShareSuccess: (ShareResult) -> Unit,
) { ) {
AsyncActionView( AsyncActionView(
async = state.shareAction, async = state.shareAction,
@@ -39,15 +39,17 @@ class DefaultShareEntryPointTest {
ShareNode( ShareNode(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
presenterFactory = { _, _ -> createSharePresenter() }, presenterFactory = { createSharePresenter() },
roomSelectEntryPoint = FakeRoomSelectEntryPoint(), roomSelectEntryPoint = FakeRoomSelectEntryPoint(),
sessionId = A_SESSION_ID,
) )
} }
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 onSessionVerificationRequired(sessionId: SessionId) = lambdaError()
} }
val params = ShareEntryPoint.Params( val params = ShareEntryPoint.Params(
sessionId = A_SESSION_ID,
shareIntentData = ShareIntentData.PlainText(A_MESSAGE), shareIntentData = ShareIntentData.PlainText(A_MESSAGE),
) )
val result = entryPoint.createNode( val result = entryPoint.createNode(
@@ -57,7 +59,7 @@ class DefaultShareEntryPointTest {
callback = callback, callback = callback,
) )
assertThat(result).isInstanceOf(ShareNode::class.java) assertThat(result).isInstanceOf(ShareNode::class.java)
assertThat(result.plugins).contains(ShareNode.Inputs(params.shareIntentData)) assertThat(result.plugins).contains(ShareNode.Inputs(params.sessionId, params.shareIntentData))
assertThat(result.plugins).contains(callback) assertThat(result.plugins).contains(callback)
} }
} }
@@ -44,7 +44,6 @@ class SharePresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.sessionId).isEqualTo(A_SESSION_ID)
assertThat(initialState.shareAction.isUninitialized()).isTrue() assertThat(initialState.shareAction.isUninitialized()).isTrue()
} }
} }
@@ -86,8 +85,7 @@ class SharePresenterTest {
presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID)) presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID))
assertThat(awaitItem().shareAction.isLoading()).isTrue() assertThat(awaitItem().shareAction.isLoading()).isTrue()
val success = awaitItem() val success = awaitItem()
assertThat(success.sessionId).isEqualTo(A_SESSION_ID) assertThat(success.shareAction).isEqualTo(AsyncAction.Success(ShareResult(A_SESSION_ID, listOf(A_ROOM_ID))))
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
sendResult.assertions().isCalledOnce().with(value(client), value(shareIntentData), value(listOf(A_ROOM_ID))) sendResult.assertions().isCalledOnce().with(value(client), value(shareIntentData), value(listOf(A_ROOM_ID)))
} }
} }
@@ -106,16 +104,11 @@ class SharePresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() skipItems(1)
assertThat(initialState.sessionId).isEqualTo(A_SESSION_ID)
presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID)) presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID))
val sessionChanged = awaitItem()
assertThat(sessionChanged.sessionId).isEqualTo(A_SESSION_ID_2)
assertThat(sessionChanged.shareAction.isUninitialized()).isTrue()
assertThat(awaitItem().shareAction.isLoading()).isTrue() assertThat(awaitItem().shareAction.isLoading()).isTrue()
val success = awaitItem() val success = awaitItem()
assertThat(success.sessionId).isEqualTo(A_SESSION_ID_2) assertThat(success.shareAction).isEqualTo(AsyncAction.Success(ShareResult(A_SESSION_ID_2, listOf(A_ROOM_ID))))
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
getClient.assertions().isCalledOnce().with(value(A_SESSION_ID_2)) getClient.assertions().isCalledOnce().with(value(A_SESSION_ID_2))
sendResult.assertions().isCalledOnce() sendResult.assertions().isCalledOnce()
} }
@@ -133,7 +126,6 @@ class SharePresenterTest {
}.test { }.test {
skipItems(1) skipItems(1)
presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID)) presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID))
assertThat(awaitItem().sessionId).isEqualTo(A_SESSION_ID_2)
assertThat(awaitItem().shareAction.isLoading()).isTrue() assertThat(awaitItem().shareAction.isLoading()).isTrue()
assertThat(awaitItem().shareAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) assertThat(awaitItem().shareAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
sendResult.assertions().isNeverCalled() sendResult.assertions().isNeverCalled()
@@ -161,8 +153,8 @@ class SharePresenterTest {
presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID)) presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID))
completable.complete(Result.success(Unit)) completable.complete(Result.success(Unit))
val success = awaitItem() val success = awaitItem()
assertThat(success.sessionId).isEqualTo(A_SESSION_ID) // The second selection has been ignored
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID))) assertThat(success.shareAction).isEqualTo(AsyncAction.Success(ShareResult(A_SESSION_ID, listOf(A_ROOM_ID))))
sendResult.assertions().isCalledOnce() sendResult.assertions().isCalledOnce()
} }
} }
@@ -170,13 +162,11 @@ class SharePresenterTest {
internal fun TestScope.createSharePresenter( internal fun TestScope.createSharePresenter(
shareIntentData: ShareIntentData = ShareIntentData.PlainText(A_MESSAGE), shareIntentData: ShareIntentData = ShareIntentData.PlainText(A_MESSAGE),
initialSessionId: SessionId = A_SESSION_ID,
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
shareDataSender: ShareDataSender = FakeShareDataSender(), shareDataSender: ShareDataSender = FakeShareDataSender(),
): SharePresenter { ): SharePresenter {
return SharePresenter( return SharePresenter(
shareIntentData = shareIntentData, shareIntentData = shareIntentData,
initialSessionId = initialSessionId,
appCoroutineScope = this, appCoroutineScope = this,
matrixClientProvider = matrixClientProvider, matrixClientProvider = matrixClientProvider,
shareDataSender = shareDataSender, shareDataSender = shareDataSender,
@@ -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) {
@@ -30,12 +30,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
@@ -45,6 +47,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,
@@ -55,6 +58,7 @@ class RoomSelectPresenter(
initialSessionId: SessionId, initialSessionId: SessionId,
mode: RoomSelectMode, mode: RoomSelectMode,
maxNumberOfRooms: Int, maxNumberOfRooms: Int,
navigator: RoomSelectNavigator,
): RoomSelectPresenter ): RoomSelectPresenter
} }
@@ -63,17 +67,19 @@ class RoomSelectPresenter(
var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) } var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) }
val queryState = rememberTextFieldState() val queryState = rememberTextFieldState()
var isAccountListExpanded by remember { mutableStateOf(false) } var isAccountListExpanded by remember { mutableStateOf(false) }
// Null until the sessions are loaded
val sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = null)
var selectedSessionId by remember { mutableStateOf(initialSessionId) } var selectedSessionId by remember { mutableStateOf(initialSessionId) }
val sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = emptyList())
// All the accounts are proposed, even the ones with an invalid token. Selecting them displays an error. // All the accounts are proposed, even the ones with an invalid token. Selecting them displays an error.
val accounts by remember { val accounts by remember {
derivedStateOf { sessions.map { it.toMatrixUser() } } derivedStateOf { sessions.orEmpty().map { it.toMatrixUser() } }
} }
// The selected session can have an invalid token, or be invalidated while this screen is displayed, for instance if it is // The selected session can have an invalid token, or be invalidated while this screen is displayed, for instance if it is
// signed out from another device. The invalidation of the initial session, which is the current one, is handled by the application. // signed out from another device.
val isSelectedSessionInvalid by remember { val isSelectedSessionInvalid by remember {
derivedStateOf { derivedStateOf {
selectedSessionId != initialSessionId && sessions.none { it.userId == selectedSessionId.value && it.isTokenValid } val loadedSessions = sessions ?: return@derivedStateOf false
loadedSessions.none { it.userId == selectedSessionId.value && it.isTokenValid }
} }
} }
LaunchedEffect(isSelectedSessionInvalid) { LaunchedEffect(isSelectedSessionInvalid) {
@@ -97,28 +103,43 @@ class RoomSelectPresenter(
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
// Create one data source per session. When the selected session changes, the previous // Create one data source per session. When the selected session changes, the previous
// 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.
val (selectedSessionDataSource, hasClientError) = key(selectedSessionId) { // Also use the validity of the session as a key, so that the data source is removed if the session is invalidated, and recreated
val dataSourceCoroutineScope = rememberCoroutineScope() // if the session becomes valid again.
// The sessions are restored at startup, so the client should already be in memory. val (dataSource, hasClientError, isSessionNotVerified) = key(selectedSessionId, isSelectedSessionInvalid) {
// A null value means that the client is being restored, or that the session is invalid: its client cannot be restored, val sessionId = selectedSessionId
// and an in-memory client would have been destroyed, so do not try to get it. if (isSelectedSessionInvalid) {
val initialRoomListService = if (isSelectedSessionInvalid) null else matrixClientProvider.getOrNull(selectedSessionId)?.roomListService // Do not try to get the client of an invalid session: it cannot be restored, and an in-memory client would have been destroyed.
val roomListServiceResult by produceState(initialRoomListService?.let { Result.success(it) }) { Triple(null, false, false)
if (value == null && !isSelectedSessionInvalid) { } else {
value = matrixClientProvider.getOrRestore(selectedSessionId) val dataSourceCoroutineScope = rememberCoroutineScope()
.onFailure { Timber.e(it, "Failed to get the client for $selectedSessionId") } // The sessions are restored at startup, so the client should already be in memory.
.map { it.roomListService } // A null value means that the client is being restored.
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") }
}
} }
val client = clientResult?.getOrNull()
val dataSource = remember(client) {
client?.let { dataSourceFactory.create(dataSourceCoroutineScope, it.roomListService) }
}
// 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 roomListService = roomListServiceResult?.getOrNull()
val dataSource = remember(roomListService) {
roomListService?.let { dataSourceFactory.create(dataSourceCoroutineScope, it) }
}
dataSource to (roomListServiceResult?.isFailure == true)
} }
// Do not use the data source of an invalid session, its client has been destroyed
val dataSource = selectedSessionDataSource.takeIf { !isSelectedSessionInvalid }
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,17 +38,18 @@ 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)
createRoomSelectPresenter(mode, maxNumberOfRooms) createRoomSelectPresenter(mode = mode, maxNumberOfRooms = maxNumberOfRooms)
}, },
) )
} }
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,
@@ -13,9 +13,11 @@ import app.cash.turbine.Event
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
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
@@ -26,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
@@ -38,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
@@ -112,7 +116,7 @@ class RoomSelectPresenterTest {
val presenter = createRoomSelectPresenter( val presenter = createRoomSelectPresenter(
mode = RoomSelectMode.Share, mode = RoomSelectMode.Share,
sessionStore = InMemorySessionStore( sessionStore = InMemorySessionStore(
initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value)), initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value, isTokenValid = true)),
), ),
) )
presenter.test { presenter.test {
@@ -207,6 +211,30 @@ class RoomSelectPresenterTest {
} }
} }
@Test
fun `present - the current account is invalidated while selected, an error is displayed`() = runTest {
val roomSummary = aRoomSummary()
val sessionStore = aSessionStoreWithTwoAccounts()
val presenter = createRoomSelectPresenter(
mode = RoomSelectMode.Share,
sessionStore = sessionStore,
roomListService = FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(roomSummary))) }),
)
presenter.test {
val state = consumeItemsUntilPredicate { it.resultState is SearchBarResultState.Results }.last()
state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary.toSelectRoomInfo()))
assertThat(consumeItemsUntilPredicate { it.selectedRooms.isNotEmpty() }.last().selectedRooms).hasSize(1)
// The current session is signed out from another device while the screen is displayed
sessionStore.updateData(sessionStore.getSession(A_SESSION_ID.value)!!.copy(isTokenValid = false))
val errorState = consumeItemsUntilPredicate { it.hasRoomListError && it.selectedRooms.isEmpty() }.last()
assertThat(errorState.selectedAccount.userId).isEqualTo(A_SESSION_ID)
assertThat(errorState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java)
// The user can switch to the other account
assertThat(errorState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2)
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - another account is invalidated, it is still proposed and there is no error`() = runTest { fun `present - another account is invalidated, it is still proposed and there is no error`() = runTest {
val sessionStore = aSessionStoreWithTwoAccounts() val sessionStore = aSessionStoreWithTwoAccounts()
@@ -263,6 +291,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 - select another account keeps the search query and applies it to the rooms of this account`() = runTest { fun `present - select another account keeps the search query and applies it to the rooms of this account`() = runTest {
val roomList2 = FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary(roomId = A_ROOM_ID_2)))) val roomList2 = FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary(roomId = A_ROOM_ID_2))))
@@ -401,17 +503,23 @@ class RoomSelectPresenterTest {
} }
internal fun TestScope.createRoomSelectPresenter( internal fun TestScope.createRoomSelectPresenter(
initialSessionId: SessionId = A_SESSION_ID,
mode: RoomSelectMode = RoomSelectMode.Forward, mode: RoomSelectMode = RoomSelectMode.Forward,
maxNumberOfRooms: Int = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS, maxNumberOfRooms: Int = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
roomListService: RoomListService = FakeRoomListService(), roomListService: RoomListService = FakeRoomListService(),
sessionStore: SessionStore = InMemorySessionStore(), // The current session is always in the store
sessionStore: SessionStore = InMemorySessionStore(
initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value, isTokenValid = true)),
),
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 = A_SESSION_ID, 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(