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
@@ -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(