mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 10:32:34 +00:00
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:
+7
@@ -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 {
|
||||
|
||||
+17
@@ -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)
|
||||
}
|
||||
+7
-2
@@ -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) {
|
||||
|
||||
+43
-22
@@ -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) {
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+111
-3
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user