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
import com.bumble.appyx.core.navigation.NavKey
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.Remove
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue
/**
* 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
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.di.DependencyInjectionGraphOwner
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 kotlinx.parcelize.Parcelize
@@ -61,7 +59,6 @@ class LoggedInAppScopeFlowNode(
interface Callback : Plugin {
fun navigateToBugReport()
fun navigateToAddAccount()
fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?)
}
private val callback: Callback = callback()
@@ -94,10 +91,6 @@ class LoggedInAppScopeFlowNode(
override fun navigateToAddAccount() {
callback.navigateToAddAccount()
}
override fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) {
callback.switchAccountAndOpenRoom(sessionId, roomId)
}
}
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.RoomDirectoryEntryPoint
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.userprofile.api.UserProfileEntryPoint
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.RoomId
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.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
@@ -138,7 +135,6 @@ class LoggedInFlowNode(
private val sessionCoroutineScope: CoroutineScope,
private val ftueService: FtueService,
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
private val shareEntryPoint: ShareEntryPoint,
private val matrixClient: MatrixClient,
private val sendingQueue: SendQueues,
private val nativeCallHost: NativeCallHost,
@@ -174,7 +170,6 @@ class LoggedInFlowNode(
interface Callback : Plugin {
fun navigateToBugReport()
fun navigateToAddAccount()
fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?)
}
private val callback: Callback = callback()
@@ -296,9 +291,6 @@ class LoggedInFlowNode(
@Parcelize
data object RoomDirectory : NavTarget
@Parcelize
data class IncomingShare(val shareIntentData: ShareIntentData) : NavTarget
@Parcelize
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 -> {
incomingVerificationEntryPoint.createNode(
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(
roomIdOrAlias: RoomIdOrAlias,
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
override fun View(modifier: Modifier) {
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.IncomingVerificationRequestObserver
import io.element.android.appnav.verification.OtherSessionIncomingVerificationNode
import io.element.android.appnav.verification.OtherSessionVerificationNode
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
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.signedout.api.SignedOutEntryPoint
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
@@ -112,6 +114,7 @@ class RootFlowNode(
private val analyticsService: AnalyticsService,
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
private val incomingVerificationRequestObserver: IncomingVerificationRequestObserver,
private val shareEntryPoint: ShareEntryPoint,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : BaseFlowNode<RootFlowNode.NavTarget>(
backstack = BackStack(
@@ -216,7 +219,7 @@ class RootFlowNode(
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
pendingLoginParams = null
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId))
setRoot(NavTarget.LoggedInFlow(sessionId, navId))
restoreAllSessions()
}
@@ -242,6 +245,8 @@ class RootFlowNode(
.withPreviousValue()
.onEach { (previous, current) ->
(previous.orEmpty() - current).forEach { sessionId ->
// The verification of a removed session cannot continue, for instance if the user has signed out from it
backstack.removeLast(NavTarget.OtherAccountSessionVerification(sessionId))
Timber.d("Session $sessionId removed, remove it from the cache")
matrixSessionCache.remove(sessionId)
}
@@ -286,7 +291,20 @@ class RootFlowNode(
private fun switchToSignedOutFlow(sessionId: SessionId) {
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(
@@ -367,10 +385,19 @@ class RootFlowNode(
@Parcelize data object BugReport : NavTarget
@Parcelize data class IncomingShare(
val sessionId: SessionId,
val shareIntentData: ShareIntentData,
) : NavTarget
@Parcelize data class OtherAccountIncomingVerificationRequest(
val sessionId: SessionId,
val verificationRequest: VerificationRequest.Incoming,
) : NavTarget
@Parcelize data class OtherAccountSessionVerification(
val sessionId: SessionId,
) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -389,15 +416,6 @@ class RootFlowNode(
override fun navigateToAddAccount() {
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 buildContext = if (savedNavState != null) {
@@ -451,6 +469,56 @@ class RootFlowNode(
}
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 -> {
val callback = object : BugReportEntryPoint.Callback {
override fun onDone() {
@@ -542,9 +610,12 @@ class RootFlowNode(
// No session, open login
switchToNotLoggedInFlow(null)
} else {
// wait for the current session to be restored, then attach the incoming share node.
// In case of multiple accounts, the user can switch account from the room select screen.
attachSession(latestSessionId).attachIncomingShare(shareIntentData)
// Wait for the current session to be restored and for the FTUE to be complete, so that the session is verified,
// then display the incoming share on top of it.
// The incoming share does not belong to the current session: in case of multiple accounts, the user can switch account
// from the room select screen, and the share is kept if the current session is signed out in the meantime.
attachSession(latestSessionId).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 com.bumble.appyx.core.modality.AncestryInfo
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.navmodel.backstack.activeElement
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.preferences.test.FakeCacheService
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.signedout.test.FakeSignedOutEntryPoint
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.apperror.test.FakeAppErrorStateService
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.FakeParentNode
import io.element.android.tests.testutils.presenter.NotUsedPresenter
@@ -192,6 +195,14 @@ class RootFlowNodeTest : RobolectricTest() {
appForegroundStateService = FakeAppForegroundStateService(),
),
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() {
callback.onDone(emptyList())
}
override fun onSessionVerificationRequired(sessionId: SessionId) {
// Only called in Share mode
}
}
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
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(
parentNode: Node,
@@ -27,10 +34,22 @@ interface ShareEntryPoint : FeatureEntryPoint {
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 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>)
/**
* 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.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
@ContributesBinding(SessionScope::class)
@ContributesBinding(AppScope::class)
class DefaultShareEntryPoint : ShareEntryPoint {
override fun createNode(
parentNode: Node,
@@ -26,7 +26,10 @@ class DefaultShareEntryPoint : ShareEntryPoint {
return parentNode.createNode<ShareNode>(
buildContext = buildContext,
plugins = listOf(
ShareNode.Inputs(shareIntentData = params.shareIntentData),
ShareNode.Inputs(
sessionId = params.sessionId,
shareIntentData = params.shareIntentData,
),
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.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
@@ -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.callback
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.SessionId
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@ContributesNode(AppScope::class)
@AssistedInject
class ShareNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: SharePresenter.Factory,
private val roomSelectEntryPoint: RoomSelectEntryPoint,
private val sessionId: SessionId,
) : ParentNode<ShareNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget),
@@ -52,10 +51,13 @@ class ShareNode(
@Parcelize
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 presenter = presenterFactory.create(inputs.shareIntentData, sessionId)
private val presenter = presenterFactory.create(inputs.shareIntentData)
private val callback: ShareEntryPoint.Callback = callback()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -65,7 +67,11 @@ class ShareNode(
}
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,
buildContext = buildContext,
params = RoomSelectEntryPoint.Params(
sessionId = sessionId,
sessionId = inputs.sessionId,
mode = RoomSelectMode.Share,
maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
),
@@ -92,7 +98,7 @@ class ShareNode(
val state = presenter.present()
ShareView(
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.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
@@ -30,7 +28,6 @@ import kotlinx.coroutines.launch
@AssistedInject
class SharePresenter(
@Assisted private val shareIntentData: ShareIntentData,
@Assisted initialSessionId: SessionId,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
private val matrixClientProvider: MatrixClientProvider,
@@ -38,24 +35,21 @@ class SharePresenter(
) : Presenter<ShareState> {
@AssistedFactory
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>) {
if (shareActionState.value.isLoading()) return
targetSessionId = sessionId
appCoroutineScope.launch {
suspend {
val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow()
shareDataSender.send(client, shareIntentData, roomIds).getOrThrow()
roomIds
ShareResult(sessionId = sessionId, roomIds = roomIds)
}.runCatchingUpdatingState(shareActionState)
}
}
@@ -69,7 +63,6 @@ class SharePresenter(
}
return ShareState(
sessionId = targetSessionId,
shareAction = shareActionState.value,
eventSink = ::handleEvent,
)
@@ -13,7 +13,16 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
data class ShareState(
val sessionId: SessionId,
val shareAction: AsyncAction<List<RoomId>>,
val shareAction: AsyncAction<ShareResult>,
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,
),
aShareState(
shareAction = AsyncAction.Success(
listOf(RoomId("!room2:domain")),
)
shareAction = AsyncAction.Success(aShareResult())
),
aShareState(
shareAction = AsyncAction.Failure(RuntimeException("error")),
@@ -31,12 +29,18 @@ open class ShareStatePreviewParam : PreviewParameterProvider<ShareState> {
)
}
fun aShareState(
sessionId: SessionId = SessionId("@alice:server.org"),
shareAction: AsyncAction<List<RoomId>> = AsyncAction.Uninitialized,
private fun aShareState(
shareAction: AsyncAction<ShareResult> = AsyncAction.Uninitialized,
eventSink: (ShareEvent) -> Unit = {}
) = ShareState(
sessionId = sessionId,
shareAction = shareAction,
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.RoomId
@Composable
fun ShareView(
state: ShareState,
onShareSuccess: (List<RoomId>) -> Unit,
onShareSuccess: (ShareResult) -> Unit,
) {
AsyncActionView(
async = state.shareAction,
@@ -39,15 +39,17 @@ class DefaultShareEntryPointTest {
ShareNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { _, _ -> createSharePresenter() },
presenterFactory = { createSharePresenter() },
roomSelectEntryPoint = FakeRoomSelectEntryPoint(),
sessionId = A_SESSION_ID,
)
}
val callback = object : ShareEntryPoint.Callback {
override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) = lambdaError()
override fun onCancel() = lambdaError()
override fun onSessionVerificationRequired(sessionId: SessionId) = lambdaError()
}
val params = ShareEntryPoint.Params(
sessionId = A_SESSION_ID,
shareIntentData = ShareIntentData.PlainText(A_MESSAGE),
)
val result = entryPoint.createNode(
@@ -57,7 +59,7 @@ class DefaultShareEntryPointTest {
callback = callback,
)
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)
}
}
@@ -44,7 +44,6 @@ class SharePresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sessionId).isEqualTo(A_SESSION_ID)
assertThat(initialState.shareAction.isUninitialized()).isTrue()
}
}
@@ -86,8 +85,7 @@ class SharePresenterTest {
presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID))
assertThat(awaitItem().shareAction.isLoading()).isTrue()
val success = awaitItem()
assertThat(success.sessionId).isEqualTo(A_SESSION_ID)
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().with(value(client), value(shareIntentData), value(listOf(A_ROOM_ID)))
}
}
@@ -106,16 +104,11 @@ class SharePresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sessionId).isEqualTo(A_SESSION_ID)
skipItems(1)
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()
val success = awaitItem()
assertThat(success.sessionId).isEqualTo(A_SESSION_ID_2)
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(ShareResult(A_SESSION_ID_2, listOf(A_ROOM_ID))))
getClient.assertions().isCalledOnce().with(value(A_SESSION_ID_2))
sendResult.assertions().isCalledOnce()
}
@@ -133,7 +126,6 @@ class SharePresenterTest {
}.test {
skipItems(1)
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).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
sendResult.assertions().isNeverCalled()
@@ -161,8 +153,8 @@ class SharePresenterTest {
presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID))
completable.complete(Result.success(Unit))
val success = awaitItem()
assertThat(success.sessionId).isEqualTo(A_SESSION_ID)
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
// The second selection has been ignored
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(ShareResult(A_SESSION_ID, listOf(A_ROOM_ID))))
sendResult.assertions().isCalledOnce()
}
}
@@ -170,13 +162,11 @@ class SharePresenterTest {
internal fun TestScope.createSharePresenter(
shareIntentData: ShareIntentData = ShareIntentData.PlainText(A_MESSAGE),
initialSessionId: SessionId = A_SESSION_ID,
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
shareDataSender: ShareDataSender = FakeShareDataSender(),
): SharePresenter {
return SharePresenter(
shareIntentData = shareIntentData,
initialSessionId = initialSessionId,
appCoroutineScope = this,
matrixClientProvider = matrixClientProvider,
shareDataSender = shareDataSender,
@@ -41,6 +41,13 @@ interface RoomSelectEntryPoint : FeatureEntryPoint {
*/
fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>)
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 {
@@ -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 plugins: List<Plugin>,
presenterFactory: RoomSelectPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
) : Node(buildContext, plugins = plugins), RoomSelectNavigator {
data class Inputs(
val sessionId: SessionId,
val mode: RoomSelectMode,
@@ -41,13 +41,18 @@ class RoomSelectNode(
) : NodeInputs
private val inputs: Inputs = inputs()
private val callback: RoomSelectEntryPoint.Callback = callback()
private val presenter = presenterFactory.create(
initialSessionId = inputs.sessionId,
mode = inputs.mode,
maxNumberOfRooms = inputs.maxNumberOfRooms,
navigator = this,
)
private val stateFlow = launchMolecule { presenter.present() }
private val callback: RoomSelectEntryPoint.Callback = callback()
override fun navigateToSessionVerification(sessionId: SessionId) {
callback.onSessionVerificationRequired(sessionId)
}
@Composable
override fun View(modifier: Modifier) {
@@ -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.user.MatrixUser
import io.element.android.libraries.matrix.api.user.toMatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -45,6 +47,7 @@ class RoomSelectPresenter(
@Assisted private val initialSessionId: SessionId,
@Assisted private val mode: RoomSelectMode,
@Assisted private val maxNumberOfRooms: Int,
@Assisted private val navigator: RoomSelectNavigator,
private val dataSourceFactory: RoomSelectSearchDataSource.Factory,
private val matrixClientProvider: MatrixClientProvider,
private val sessionStore: SessionStore,
@@ -55,6 +58,7 @@ class RoomSelectPresenter(
initialSessionId: SessionId,
mode: RoomSelectMode,
maxNumberOfRooms: Int,
navigator: RoomSelectNavigator,
): RoomSelectPresenter
}
@@ -63,17 +67,19 @@ class RoomSelectPresenter(
var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) }
val queryState = rememberTextFieldState()
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) }
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.
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
// 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 {
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) {
@@ -97,28 +103,43 @@ class RoomSelectPresenter(
val coroutineScope = rememberCoroutineScope()
// 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.
val (selectedSessionDataSource, hasClientError) = key(selectedSessionId) {
val dataSourceCoroutineScope = rememberCoroutineScope()
// The sessions are restored at startup, so the client should already be in memory.
// A null value means that the client is being restored, or that the session is invalid: its client cannot be restored,
// and an in-memory client would have been destroyed, so do not try to get it.
val initialRoomListService = if (isSelectedSessionInvalid) null else matrixClientProvider.getOrNull(selectedSessionId)?.roomListService
val roomListServiceResult by produceState(initialRoomListService?.let { Result.success(it) }) {
if (value == null && !isSelectedSessionInvalid) {
value = matrixClientProvider.getOrRestore(selectedSessionId)
.onFailure { Timber.e(it, "Failed to get the client for $selectedSessionId") }
.map { it.roomListService }
// Also use the validity of the session as a key, so that the data source is removed if the session is invalidated, and recreated
// if the session becomes valid again.
val (dataSource, hasClientError, isSessionNotVerified) = key(selectedSessionId, isSelectedSessionInvalid) {
val sessionId = selectedSessionId
if (isSelectedSessionInvalid) {
// Do not try to get the client of an invalid session: it cannot be restored, and an in-memory client would have been destroyed.
Triple(null, false, false)
} else {
val dataSourceCoroutineScope = rememberCoroutineScope()
// The sessions are restored at startup, so the client should already be in memory.
// A null value means that the client is being restored.
val 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 sessionToVerify = selectedSessionId.takeIf { isSessionNotVerified }
LaunchedEffect(sessionToVerify) {
sessionToVerify?.let(navigator::navigateToSessionVerification)
}
val searchQuery = queryState.text.toString()
LaunchedEffect(dataSource, searchQuery) {
@@ -38,17 +38,18 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() {
RoomSelectNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { initialSessionId, mode, maxNumberOfRooms ->
presenterFactory = { initialSessionId, mode, maxNumberOfRooms, _ ->
assertThat(initialSessionId).isEqualTo(A_SESSION_ID)
assertThat(mode).isEqualTo(testMode)
assertThat(maxNumberOfRooms).isEqualTo(testMaxNumberOfRooms)
createRoomSelectPresenter(mode, maxNumberOfRooms)
createRoomSelectPresenter(mode = mode, maxNumberOfRooms = maxNumberOfRooms)
},
)
}
val callback = object : RoomSelectEntryPoint.Callback {
override fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) = lambdaError()
override fun onCancel() = lambdaError()
override fun onSessionVerificationRequired(sessionId: SessionId) = lambdaError()
}
val params = RoomSelectEntryPoint.Params(
sessionId = A_SESSION_ID,
@@ -13,9 +13,11 @@ import app.cash.turbine.Event
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
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.RoomListService
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
@@ -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.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
@@ -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.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
@@ -112,7 +116,7 @@ class RoomSelectPresenterTest {
val presenter = createRoomSelectPresenter(
mode = RoomSelectMode.Share,
sessionStore = InMemorySessionStore(
initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value)),
initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value, isTokenValid = true)),
),
)
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
fun `present - another account is invalidated, it is still proposed and there is no error`() = runTest {
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
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))))
@@ -401,17 +503,23 @@ class RoomSelectPresenterTest {
}
internal fun TestScope.createRoomSelectPresenter(
initialSessionId: SessionId = A_SESSION_ID,
mode: RoomSelectMode = RoomSelectMode.Forward,
maxNumberOfRooms: Int = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
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 ->
Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListService))
},
navigator: RoomSelectNavigator = RoomSelectNavigator { lambdaError() },
) = RoomSelectPresenter(
initialSessionId = A_SESSION_ID,
initialSessionId = initialSessionId,
mode = mode,
maxNumberOfRooms = maxNumberOfRooms,
navigator = navigator,
dataSourceFactory = object : RoomSelectSearchDataSource.Factory {
override fun create(coroutineScope: CoroutineScope, roomListService: RoomListService): RoomSelectSearchDataSource {
return RoomSelectSearchDataSource(