From dc031dd0e63a36d20e79b087849d6e6580756bf9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 2 Oct 2026 11:44:23 +0200 Subject: [PATCH 01/12] Move SessionData.toMatrixUser() extension to a common place, then use it across the codebase. --- .../impl/CurrentUserWithNeighborsBuilder.kt | 9 ++------ .../impl/root/PreferencesRootPresenter.kt | 12 ++-------- .../signedout/impl/SignedOutPresenter.kt | 9 +------- .../incoming/IncomingVerificationPresenter.kt | 12 ++-------- .../impl/AccountSelectPresenter.kt | 11 ++------- libraries/matrix/api/build.gradle.kts | 1 + .../matrix/api/user/SessionDataExtensions.kt | 23 +++++++++++++++++++ 7 files changed, 33 insertions(+), 44 deletions(-) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/SessionDataExtensions.kt diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt index f9c5fc3bc0..f4620b0f09 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt @@ -8,8 +8,8 @@ package io.element.android.features.home.impl -import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.toMatrixUser import io.element.android.libraries.sessionstorage.api.SessionData import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -32,12 +32,7 @@ class CurrentUserWithNeighborsBuilder { matrixUser } else { // Use the data from the DB - MatrixUser( - userId = UserId(it.userId), - displayName = it.userDisplayName, - avatarUrl = it.userAvatarUrl, - avatarThumbnail = it.userAvatarData, - ) + it.toMatrixUser() } } .let { sessionList -> diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 4bbe2eeaf8..53234c3723 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -29,8 +29,7 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.toMatrixUser import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.services.analytics.api.AnalyticsService @@ -85,14 +84,7 @@ class PreferencesRootPresenter( sessionStore.sessionsFlow().map { list -> list .filter { it.userId != matrixClient.sessionId.value } - .map { - MatrixUser( - userId = UserId(it.userId), - displayName = it.userDisplayName, - avatarUrl = it.userAvatarUrl, - avatarThumbnail = it.userAvatarData, - ) - } + .map { it.toMatrixUser() } .toImmutableList() } }.collectAsState(initial = persistentListOf()) diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt index 1f35da352a..3f9a3b3e4c 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt @@ -19,7 +19,7 @@ import dev.zacsweers.metro.AssistedInject import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.matrix.api.user.toMatrixUser import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -58,11 +58,4 @@ class SignedOutPresenter( eventSink = ::handleEvent, ) } - - private fun SessionData.toMatrixUser() = MatrixUser( - userId = sessionId, - displayName = userDisplayName, - avatarUrl = userAvatarUrl, - avatarThumbnail = userAvatarData, - ) } diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt index 00daf9b5b8..98a1791774 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt @@ -29,6 +29,7 @@ import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.di.annotations.SessionCoroutineScope 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.SessionVerificationRequestDetails import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.VerificationFlowState @@ -101,16 +102,7 @@ class IncomingVerificationPresenter( } val currentUser by remember { sessionStore.sessionsFlow().mapNotNull { sessions -> - sessions.find { it.userId == sessionId.value }?.let { sessionData -> - MatrixUser( - userId = sessionId, - displayName = sessionData.userDisplayName, - avatarUrl = sessionData.userAvatarUrl, - // Locally stored avatar, used as a fallback when the avatar cannot be loaded, - // for instance for a session which is not the current one - avatarThumbnail = sessionData.userAvatarData, - ) - } + sessions.find { it.userId == sessionId.value }?.toMatrixUser() } }.collectAsState(MatrixUser(sessionId)) val step by remember { diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt index e1d6b2800e..8ed9a18b85 100644 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt @@ -13,8 +13,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import dev.zacsweers.metro.Inject import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.toMatrixUser import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -29,14 +29,7 @@ class AccountSelectPresenter( val accounts by produceState>(persistentListOf()) { // Do not use sessionStore.sessionsFlow() to not make it change when an account is selected. value = sessionStore.getAllSessions() - .map { - MatrixUser( - userId = UserId(it.userId), - displayName = it.userDisplayName, - avatarUrl = it.userAvatarUrl, - avatarThumbnail = it.userAvatarData, - ) - } + .map { it.toMatrixUser() } .toImmutableList() } diff --git a/libraries/matrix/api/build.gradle.kts b/libraries/matrix/api/build.gradle.kts index 65919e69f2..8387c1fa26 100644 --- a/libraries/matrix/api/build.gradle.kts +++ b/libraries/matrix/api/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.services.analytics.api) + implementation(projects.libraries.sessionStorage.api) testCommonDependencies(libs) testImplementation(projects.libraries.matrix.test) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/SessionDataExtensions.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/SessionDataExtensions.kt new file mode 100644 index 0000000000..136dbf58c3 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/SessionDataExtensions.kt @@ -0,0 +1,23 @@ +/* + * 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.matrix.api.user + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.sessionstorage.api.SessionData + +/** + * Create a [MatrixUser] from the profile data stored locally for the session. + * The avatar thumbnail is used as a fallback when the avatar cannot be loaded, + * for instance for a session which is not the current one. + */ +fun SessionData.toMatrixUser() = MatrixUser( + userId = UserId(userId), + displayName = userDisplayName, + avatarUrl = userAvatarUrl, + avatarThumbnail = userAvatarData, +) From 792e0adde333292d014270434339f774981c0101 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 2 Oct 2026 16:09:32 +0200 Subject: [PATCH 02/12] Let the user switch account from the room select screen when sharing When several accounts are signed in, sharing into the app no longer goes through the account selection screen. The room select screen (Share mode) now displays the selected account and an account switch on top of the room list, with a design similar to the multi account section of the settings. Selecting another account displays the rooms of this account in place, scrolled to the top, without recreating the screen. The content is then shared from the room select screen using the selected account, with the same progress and error dialogs as when sharing with the current account. The current account is switched only once the share has succeeded, then the room is opened if the content has been shared to a single room. Implementation: - RoomSelectNode is now in the AppScope and uses MatrixClientProvider to get the room list of the selected session. - ShareNode gets the SharePresenter of the selected session from its session graph, using the new ShareBindings. - SessionGraphFactory is moved to the matrix api module so that it can be used by features. The account selection screen is still used for permalinks. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../x/di/DefaultSessionGraphFactory.kt | 2 +- .../appnav/LoggedInAppScopeFlowNode.kt | 9 +- .../android/appnav/LoggedInFlowNode.kt | 10 +- .../io/element/android/appnav/RootFlowNode.kt | 40 ++- .../OtherSessionIncomingVerificationNode.kt | 2 +- .../forward/impl/ForwardMessagesNode.kt | 6 +- .../impl/DefaultForwardEntryPointTest.kt | 2 + .../features/share/api/ShareEntryPoint.kt | 8 +- .../android/features/share/impl/ShareNode.kt | 75 +++++- .../features/share/impl/di/ShareBindings.kt | 20 ++ .../share/impl/DefaultShareEntryPointTest.kt | 12 +- .../matrix/api}/SessionGraphFactory.kt | 8 +- .../roomselect/api/RoomSelectEntryPoint.kt | 13 +- libraries/roomselect/impl/build.gradle.kts | 2 + .../impl/DefaultRoomSelectEntryPoint.kt | 5 +- .../roomselect/impl/RoomSelectEvent.kt | 3 + .../roomselect/impl/RoomSelectNode.kt | 9 +- .../roomselect/impl/RoomSelectPresenter.kt | 79 +++++- .../impl/RoomSelectSearchDataSource.kt | 4 +- .../roomselect/impl/RoomSelectState.kt | 9 + .../impl/RoomSelectStatePreviewParam.kt | 26 ++ .../roomselect/impl/RoomSelectView.kt | 242 +++++++++++++----- .../impl/DefaultRoomSelectEntryPointTest.kt | 10 +- .../impl/RoomSelectPresenterTest.kt | 113 +++++++- .../roomselect/impl/RoomSelectViewTest.kt | 50 ++++ 25 files changed, 634 insertions(+), 125 deletions(-) create mode 100644 features/share/impl/src/main/kotlin/io/element/android/features/share/impl/di/ShareBindings.kt rename {appnav/src/main/kotlin/io/element/android/appnav/di => libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api}/SessionGraphFactory.kt (60%) diff --git a/app/src/main/kotlin/io/element/android/x/di/DefaultSessionGraphFactory.kt b/app/src/main/kotlin/io/element/android/x/di/DefaultSessionGraphFactory.kt index 5bfe082ffe..6135d7774f 100644 --- a/app/src/main/kotlin/io/element/android/x/di/DefaultSessionGraphFactory.kt +++ b/app/src/main/kotlin/io/element/android/x/di/DefaultSessionGraphFactory.kt @@ -10,8 +10,8 @@ package io.element.android.x.di import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.appnav.di.SessionGraphFactory import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.SessionGraphFactory @ContributesBinding(AppScope::class) class DefaultSessionGraphFactory( diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt index ee8398186d..74f0d81dc1 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt @@ -26,13 +26,15 @@ 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.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.callback 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.SessionGraphFactory +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 @@ -59,6 +61,7 @@ class LoggedInAppScopeFlowNode( interface Callback : Plugin { fun navigateToBugReport() fun navigateToAddAccount() + fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) } private val callback: Callback = callback() @@ -91,6 +94,10 @@ class LoggedInAppScopeFlowNode( override fun navigateToAddAccount() { callback.navigateToAddAccount() } + + override fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) { + callback.switchAccountAndOpenRoom(sessionId, roomId) + } } return createNode(buildContext, listOf(callback)) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index dce0911165..a361576050 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -88,6 +88,7 @@ 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 @@ -173,6 +174,7 @@ class LoggedInFlowNode( interface Callback : Plugin { fun navigateToBugReport() fun navigateToAddAccount() + fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) } private val callback: Callback = callback() @@ -574,10 +576,16 @@ class LoggedInFlowNode( buildContext = buildContext, params = ShareEntryPoint.Params(shareIntentData = navTarget.shareIntentData), callback = object : ShareEntryPoint.Callback { - override fun onDone(roomIds: List) { + override fun onDone(sessionId: SessionId, roomIds: List) { // 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 { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index d8103c18c0..934964e447 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -62,6 +62,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.accountprovider.AccountProvider 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.SessionId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.asEventId @@ -349,8 +350,7 @@ class RootFlowNode( @Parcelize data class AccountSelect( val currentSessionId: SessionId, - val shareIntentData: ShareIntentData?, - val permalinkData: PermalinkData?, + val permalinkData: PermalinkData, ) : NavTarget @Parcelize data class NotLoggedInFlow( @@ -389,6 +389,15 @@ 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) { @@ -463,13 +472,7 @@ class RootFlowNode( // Do not pop when the account is changed to avoid a UI flicker. backstack.pop() } - attachSession(sessionId).apply { - if (navTarget.shareIntentData != null) { - attachIncomingShare(navTarget.shareIntentData) - } else if (navTarget.permalinkData != null) { - attachPermalinkData(navTarget.permalinkData) - } - } + attachSession(sessionId).attachPermalinkData(navTarget.permalinkData) } } @@ -539,21 +542,9 @@ class RootFlowNode( // No session, open login switchToNotLoggedInFlow(null) } else { - // wait for the current session to be restored - val loggedInFlowNode = attachSession(latestSessionId) - if (sessionStore.numberOfSessions() > 1) { - // Several accounts, let the user choose which one to use - backstack.push( - NavTarget.AccountSelect( - currentSessionId = latestSessionId, - shareIntentData = shareIntentData, - permalinkData = null, - ) - ) - } else { - // Only one account, directly attach the incoming share node. - loggedInFlowNode.attachIncomingShare(shareIntentData) - } + // 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) } } @@ -576,7 +567,6 @@ class RootFlowNode( backstack.push( NavTarget.AccountSelect( currentSessionId = latestSessionId, - shareIntentData = null, permalinkData = permalinkData, ) ) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionIncomingVerificationNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionIncomingVerificationNode.kt index 115c174bb8..eb282fffe8 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionIncomingVerificationNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionIncomingVerificationNode.kt @@ -20,13 +20,13 @@ 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.verifysession.api.IncomingVerificationEntryPoint 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.SessionGraphFactory import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.verification.VerificationRequest import kotlinx.parcelize.Parcelize diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt index 1c565a390a..f8a1048591 100644 --- a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt +++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt @@ -28,6 +28,7 @@ import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope 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.SessionId import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectMode @@ -40,6 +41,7 @@ class ForwardMessagesNode( @Assisted plugins: List, presenterFactory: ForwardMessagesPresenter.Factory, private val roomSelectEntryPoint: RoomSelectEntryPoint, + private val sessionId: SessionId, ) : ParentNode( navModel = PermanentNavModel( navTargets = setOf(NavTarget), @@ -62,7 +64,8 @@ class ForwardMessagesNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { val callback = object : RoomSelectEntryPoint.Callback { - override fun onRoomSelected(roomIds: List) { + override fun onRoomSelected(sessionId: SessionId, roomIds: List) { + // In Forward mode, the session cannot be changed presenter.onRoomSelected(roomIds) } @@ -75,6 +78,7 @@ class ForwardMessagesNode( parentNode = this, buildContext = buildContext, params = RoomSelectEntryPoint.Params( + sessionId = sessionId, mode = RoomSelectMode.Forward, maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS, ), diff --git a/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPointTest.kt b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPointTest.kt index 9ee932a246..5774f99d0a 100644 --- a/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPointTest.kt +++ b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPointTest.kt @@ -15,6 +15,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.forward.api.ForwardEntryPoint import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.timeline.FakeTimelineProvider import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint import io.element.android.tests.testutils.lambda.lambdaError @@ -39,6 +40,7 @@ class DefaultForwardEntryPointTest { plugins = plugins, presenterFactory = { _, _ -> createForwardMessagesPresenter() }, roomSelectEntryPoint = FakeRoomSelectEntryPoint(), + sessionId = A_SESSION_ID, ) } val callback = object : ForwardEntryPoint.Callback { diff --git a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt index c83d4fbdc6..cf6ae2c219 100644 --- a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt +++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt @@ -13,6 +13,7 @@ import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint 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) @@ -25,6 +26,11 @@ interface ShareEntryPoint : FeatureEntryPoint { ): Node interface Callback : Plugin { - fun onDone(roomIds: List) + /** + * Called when the share is done, or cancelled. + * @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. + */ + fun onDone(sessionId: SessionId, roomIds: List) } } diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt index 1abac1afa6..52464d4691 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt @@ -11,7 +11,12 @@ package io.element.android.features.share.impl import android.os.Parcelable import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope import com.bumble.appyx.core.composable.Children import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel @@ -23,14 +28,20 @@ import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.features.share.api.ShareEntryPoint import io.element.android.features.share.api.ShareIntentData +import io.element.android.features.share.impl.di.ShareBindings 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.MatrixClientProvider +import io.element.android.libraries.matrix.api.SessionGraphFactory 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.coroutines.launch import kotlinx.parcelize.Parcelize +import timber.log.Timber @ContributesNode(SessionScope::class) @AssistedInject @@ -39,6 +50,9 @@ class ShareNode( @Assisted plugins: List, presenterFactory: SharePresenter.Factory, private val roomSelectEntryPoint: RoomSelectEntryPoint, + private val sessionId: SessionId, + private val matrixClientProvider: MatrixClientProvider, + private val sessionGraphFactory: SessionGraphFactory, ) : ParentNode( navModel = PermanentNavModel( navTargets = setOf(NavTarget), @@ -52,18 +66,57 @@ class ShareNode( data class Inputs(val shareIntentData: ShareIntentData) : NodeInputs + /** + * The presenter used to share the data, and the session it belongs to. + */ + private data class ActiveShare( + val sessionId: SessionId, + val presenter: SharePresenter, + ) + private val inputs = inputs() - private val presenter = presenterFactory.create(inputs.shareIntentData) private val callback: ShareEntryPoint.Callback = callback() + private val currentSessionShare = ActiveShare( + sessionId = sessionId, + presenter = presenterFactory.create(inputs.shareIntentData), + ) + private var activeShare by mutableStateOf(currentSessionShare) + + private fun onRoomSelected(sessionId: SessionId, roomIds: List) { + if (sessionId == currentSessionShare.sessionId) { + activeShare = currentSessionShare + currentSessionShare.presenter.onRoomSelected(roomIds) + } else if (sessionId == activeShare.sessionId) { + // Retry with the same other session + activeShare.presenter.onRoomSelected(roomIds) + } else { + lifecycleScope.launch { + // Share using the graph of the selected session, the current session will be changed once the share is done + matrixClientProvider.getOrRestore(sessionId) + .onSuccess { client -> + val presenterFactory = (sessionGraphFactory.create(client) as ShareBindings).sharePresenterFactory() + activeShare = ActiveShare( + sessionId = sessionId, + presenter = presenterFactory.create(inputs.shareIntentData), + ).also { + it.presenter.onRoomSelected(roomIds) + } + } + .onFailure { + Timber.e(it, "Failed to get the client for session $sessionId") + } + } + } + } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { val callback = object : RoomSelectEntryPoint.Callback { - override fun onRoomSelected(roomIds: List) { - presenter.onRoomSelected(roomIds) + override fun onRoomSelected(sessionId: SessionId, roomIds: List) { + this@ShareNode.onRoomSelected(sessionId, roomIds) } override fun onCancel() { - callback.onDone(emptyList()) + callback.onDone(sessionId, emptyList()) } } @@ -71,6 +124,7 @@ class ShareNode( parentNode = this, buildContext = buildContext, params = RoomSelectEntryPoint.Params( + sessionId = sessionId, mode = RoomSelectMode.Share, maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS, ), @@ -86,11 +140,14 @@ class ShareNode( navModel = navModel, ) - val state = presenter.present() - ShareView( - state = state, - onShareSuccess = callback::onDone, - ) + val currentShare = activeShare + key(currentShare) { + val state = currentShare.presenter.present() + ShareView( + state = state, + onShareSuccess = { roomIds -> callback.onDone(currentShare.sessionId, roomIds) }, + ) + } } } } diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/di/ShareBindings.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/di/ShareBindings.kt new file mode 100644 index 0000000000..2bf1c3e1e4 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/di/ShareBindings.kt @@ -0,0 +1,20 @@ +/* + * 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.share.impl.di + +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.share.impl.SharePresenter +import io.element.android.libraries.di.SessionScope + +/** + * Used to get a [SharePresenter.Factory] from the graph of a session which is not the current one. + */ +@ContributesTo(SessionScope::class) +interface ShareBindings { + fun sharePresenterFactory(): SharePresenter.Factory +} diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt index 459fa423ec..8396544516 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt @@ -14,8 +14,13 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.google.common.truth.Truth.assertThat import io.element.android.features.share.api.ShareEntryPoint import io.element.android.features.share.api.ShareIntentData +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.SessionGraphFactory import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.A_MESSAGE +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.node.TestParentNode @@ -39,10 +44,15 @@ class DefaultShareEntryPointTest { plugins = plugins, presenterFactory = { createSharePresenter() }, roomSelectEntryPoint = FakeRoomSelectEntryPoint(), + sessionId = A_SESSION_ID, + matrixClientProvider = FakeMatrixClientProvider(), + sessionGraphFactory = object : SessionGraphFactory { + override fun create(client: MatrixClient): Any = lambdaError() + }, ) } val callback = object : ShareEntryPoint.Callback { - override fun onDone(roomIds: List) = lambdaError() + override fun onDone(sessionId: SessionId, roomIds: List) = lambdaError() } val params = ShareEntryPoint.Params( shareIntentData = ShareIntentData.PlainText(A_MESSAGE), diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/SessionGraphFactory.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SessionGraphFactory.kt similarity index 60% rename from appnav/src/main/kotlin/io/element/android/appnav/di/SessionGraphFactory.kt rename to libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SessionGraphFactory.kt index bc3960409e..cd940aa18e 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/SessionGraphFactory.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SessionGraphFactory.kt @@ -6,10 +6,12 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.appnav.di - -import io.element.android.libraries.matrix.api.MatrixClient +package io.element.android.libraries.matrix.api +/** + * Create the dependency injection graph of a session. + * It can be used to get session scoped dependencies of a session which is not the current one. + */ interface SessionGraphFactory { fun create(client: MatrixClient): Any } diff --git a/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt index 3c5c5d5ac3..5fec007261 100644 --- a/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt +++ b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt @@ -13,9 +13,16 @@ import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId interface RoomSelectEntryPoint : FeatureEntryPoint { + /** + * @param sessionId the session to use initially. In [RoomSelectMode.Share] mode, the user can select another session. + * @param mode the mode of the screen. + * @param maxNumberOfRooms the maximum number of rooms the user can select. + */ data class Params( + val sessionId: SessionId, val mode: RoomSelectMode, val maxNumberOfRooms: Int, ) @@ -28,7 +35,11 @@ interface RoomSelectEntryPoint : FeatureEntryPoint { ): Node interface Callback : Plugin { - fun onRoomSelected(roomIds: List) + /** + * @param sessionId the session which has been selected by the user, the rooms belong to this session. + * @param roomIds the selected rooms. + */ + fun onRoomSelected(sessionId: SessionId, roomIds: List) fun onCancel() } diff --git a/libraries/roomselect/impl/build.gradle.kts b/libraries/roomselect/impl/build.gradle.kts index 07443bbf9d..281e1025b8 100644 --- a/libraries/roomselect/impl/build.gradle.kts +++ b/libraries/roomselect/impl/build.gradle.kts @@ -32,10 +32,12 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) + implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) api(projects.libraries.roomselect.api) testCommonDependencies(libs, includeTestComposeView = true) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.sessionStorage.test) } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt index ff3ba06f0d..076c1209ee 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt @@ -10,12 +10,12 @@ package io.element.android.libraries.roomselect.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.libraries.architecture.createNode -import io.element.android.libraries.di.SessionScope import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint -@ContributesBinding(SessionScope::class) +@ContributesBinding(AppScope::class) class DefaultRoomSelectEntryPoint : RoomSelectEntryPoint { override fun createNode( parentNode: Node, @@ -27,6 +27,7 @@ class DefaultRoomSelectEntryPoint : RoomSelectEntryPoint { buildContext = buildContext, plugins = listOf( RoomSelectNode.Inputs( + sessionId = params.sessionId, mode = params.mode, maxNumberOfRooms = params.maxNumberOfRooms, ), diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvent.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvent.kt index 13c5bc5f8d..0f7baecf12 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvent.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvent.kt @@ -8,10 +8,13 @@ package io.element.android.libraries.roomselect.impl +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.ui.model.SelectRoomInfo sealed interface RoomSelectEvent { data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent data object ToggleSearchActive : RoomSelectEvent data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent + data object ToggleAccountListExpanded : RoomSelectEvent + data class SelectAccount(val sessionId: SessionId) : RoomSelectEvent } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt index 0297f13066..f137ecc848 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node 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 @@ -22,11 +23,11 @@ import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.appyx.launchMolecule 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.SessionId import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectMode -@ContributesNode(SessionScope::class) +@ContributesNode(AppScope::class) @AssistedInject class RoomSelectNode( @Assisted buildContext: BuildContext, @@ -34,12 +35,14 @@ class RoomSelectNode( presenterFactory: RoomSelectPresenter.Factory, ) : Node(buildContext, plugins = plugins) { data class Inputs( + val sessionId: SessionId, val mode: RoomSelectMode, val maxNumberOfRooms: Int, ) : NodeInputs private val inputs: Inputs = inputs() private val presenter = presenterFactory.create( + initialSessionId = inputs.sessionId, mode = inputs.mode, maxNumberOfRooms = inputs.maxNumberOfRooms, ) @@ -52,7 +55,7 @@ class RoomSelectNode( RoomSelectView( state = state, onDismiss = callback::onCancel, - onSubmit = callback::onRoomSelected, + onSubmit = { roomIds -> callback.onRoomSelected(state.selectedAccount.userId, roomIds) }, modifier = modifier ) } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt index 618f30a3e8..8026dbdd6d 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.roomselect.impl +import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -15,7 +16,9 @@ import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -24,22 +27,34 @@ import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.libraries.architecture.Presenter 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.user.MatrixUser +import io.element.android.libraries.matrix.api.user.toMatrixUser 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.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import timber.log.Timber @AssistedInject class RoomSelectPresenter( + @Assisted private val initialSessionId: SessionId, @Assisted private val mode: RoomSelectMode, @Assisted private val maxNumberOfRooms: Int, private val dataSourceFactory: RoomSelectSearchDataSource.Factory, + private val matrixClientProvider: MatrixClientProvider, + private val sessionStore: SessionStore, ) : Presenter { @AssistedFactory fun interface Factory { fun create( + initialSessionId: SessionId, mode: RoomSelectMode, maxNumberOfRooms: Int, ): RoomSelectPresenter @@ -50,18 +65,51 @@ class RoomSelectPresenter( var selectedRooms by remember { mutableStateOf(persistentListOf()) } val queryState = rememberTextFieldState() var isSearchActive by remember { mutableStateOf(false) } - - val coroutineScope = rememberCoroutineScope() - val dataSource = remember { dataSourceFactory.create(coroutineScope) } - - val searchQuery = queryState.text.toString() - LaunchedEffect(searchQuery) { - dataSource.setSearchQuery(searchQuery) + var isAccountListExpanded by remember { mutableStateOf(false) } + var selectedSessionId by remember { mutableStateOf(initialSessionId) } + val accounts by remember { + sessionStore.sessionsFlow().map { list -> + list.map { it.toMatrixUser() } + } + }.collectAsState(initial = emptyList()) + // Fallback to a MatrixUser with only the userId until the sessions are loaded + val selectedAccount = accounts.find { it.userId == selectedSessionId } ?: MatrixUser(selectedSessionId) + val otherAccounts by remember { + derivedStateOf { + accounts.filter { it.userId != selectedSessionId }.toImmutableList() + } } - val roomSummaryDetailsList by dataSource.roomInfoList.collectAsState(initial = persistentListOf()) + 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 dataSource = key(selectedSessionId) { + val dataSourceCoroutineScope = rememberCoroutineScope() + // The sessions are restored at startup, so the client should already be in memory + val roomListService by produceState(matrixClientProvider.getOrNull(selectedSessionId)?.roomListService) { + if (value == null) { + value = matrixClientProvider.getOrRestore(selectedSessionId) + .onFailure { Timber.e(it, "Failed to get the client for $selectedSessionId") } + .getOrNull() + ?.roomListService + } + } + remember(roomListService) { + roomListService?.let { dataSourceFactory.create(dataSourceCoroutineScope, it) } + } + } - val searchResults by remember>>> { + val searchQuery = queryState.text.toString() + LaunchedEffect(dataSource, searchQuery) { + dataSource?.setSearchQuery(searchQuery) + } + + // Use a key so that the rooms of the previously selected session are not rendered while loading the new ones + val roomSummaryDetailsList by key(dataSource) { + (dataSource?.roomInfoList ?: flowOf(persistentListOf())).collectAsState(initial = persistentListOf()) + } + + val searchResults by remember>>>(dataSource) { derivedStateOf { when { roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList()) @@ -82,8 +130,16 @@ class RoomSelectPresenter( } } RoomSelectEvent.ToggleSearchActive -> isSearchActive = !isSearchActive + RoomSelectEvent.ToggleAccountListExpanded -> isAccountListExpanded = !isAccountListExpanded + is RoomSelectEvent.SelectAccount -> { + selectedSessionId = event.sessionId + // The selected rooms and the search query are specific to the session + selectedRooms = persistentListOf() + queryState.clearText() + isAccountListExpanded = false + } is RoomSelectEvent.UpdateVisibleRange -> coroutineScope.launch { - dataSource.updateVisibleRange(event.range) + dataSource?.updateVisibleRange(event.range) } } } @@ -95,6 +151,9 @@ class RoomSelectPresenter( searchQuery = queryState, isSearchActive = isSearchActive, selectedRooms = selectedRooms, + selectedAccount = selectedAccount, + otherAccounts = otherAccounts, + isAccountListExpanded = isAccountListExpanded, eventSink = ::handleEvent, ) } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt index c6e6bc88fc..b17b74e48f 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt @@ -36,12 +36,12 @@ private const val PAGE_SIZE = 30 @AssistedInject class RoomSelectSearchDataSource( @Assisted coroutineScope: CoroutineScope, - roomListService: RoomListService, + @Assisted roomListService: RoomListService, coroutineDispatchers: CoroutineDispatchers, ) { @AssistedFactory interface Factory { - fun create(coroutineScope: CoroutineScope): RoomSelectSearchDataSource + fun create(coroutineScope: CoroutineScope, roomListService: RoomListService): RoomSelectSearchDataSource } private val roomList = roomListService.createRoomList( diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt index aadb7c10d1..198ce2655d 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.roomselect.impl import androidx.compose.foundation.text.input.TextFieldState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.roomselect.api.RoomSelectMode import kotlinx.collections.immutable.ImmutableList @@ -21,7 +22,15 @@ data class RoomSelectState( val searchQuery: TextFieldState, val isSearchActive: Boolean, val selectedRooms: ImmutableList, + val selectedAccount: MatrixUser, + val otherAccounts: ImmutableList, + val isAccountListExpanded: Boolean, val eventSink: (RoomSelectEvent) -> Unit, ) { val canSelectMoreRooms = selectedRooms.size < maxNumberOfRooms + + /** + * The account switch is only displayed when sharing, and if there are several accounts. + */ + val showAccountSwitch = mode == RoomSelectMode.Share && otherAccounts.isNotEmpty() } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt index 41fbf93532..d4e5ba011e 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt @@ -13,6 +13,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo import io.element.android.libraries.matrix.ui.model.SelectRoomInfo @@ -47,6 +49,19 @@ open class RoomSelectStatePreviewParam : PreviewParameterProvider = persistentListOf(), + selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")), + otherAccounts: ImmutableList = persistentListOf(), + isAccountListExpanded: Boolean = false, eventSink: (RoomSelectEvent) -> Unit = {}, ) = RoomSelectState( mode = mode, @@ -65,9 +83,17 @@ internal fun aRoomSelectState( searchQuery = TextFieldState(initialText = searchQuery), isSearchActive = isSearchActive, selectedRooms = selectedRooms, + selectedAccount = selectedAccount, + otherAccounts = otherAccounts, + isAccountListExpanded = isAccountListExpanded, eventSink = eventSink, ) +private fun anOtherAccountList() = persistentListOf( + aMatrixUser(id = "@bob:example.org", displayName = "Bob"), + aMatrixUser(id = "@carol:server.org", displayName = "Carol"), +) + internal fun aRoomSelectRoomList() = persistentListOf( aSelectRoomInfo( roomId = RoomId("!room1:domain"), diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt index ac6990f194..69802fcf30 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -9,6 +9,11 @@ package io.element.android.libraries.roomselect.impl import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -21,7 +26,6 @@ import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing @@ -31,25 +35,34 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarRow import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.Checkbox import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListSectionHeader import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.SearchBar import io.element.android.libraries.designsystem.theme.components.SearchBarResultState @@ -60,6 +73,10 @@ import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffec import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.MatrixUserHeader +import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.matrix.ui.components.SelectedRoom import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.matrix.ui.model.getAvatarData @@ -68,6 +85,9 @@ import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +/** + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=195-38510 + */ @Suppress("MultipleEmitters") // False positive @Composable fun RoomSelectView( @@ -111,7 +131,8 @@ fun RoomSelectView( onBack = { onBackButton(state) } ) - val lazyListState = rememberLazyListState() + // Use a new list state when the selected account changes, so that the list of rooms is scrolled to the top + val lazyListState = key(state.selectedAccount.userId) { rememberLazyListState() } OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> state.eventSink(RoomSelectEvent.UpdateVisibleRange(visibleRange)) } @@ -161,6 +182,12 @@ fun RoomSelectView( resultState = state.resultState, showBackButton = false, ) { summaries -> + if (summaries.isNotEmpty()) { + ListSectionHeader( + title = stringResource(CommonStrings.common_header_rooms), + hasDivider = false, + ) + } LazyColumn( state = lazyListState, contentPadding = lazyColumnContentPadding, @@ -172,7 +199,45 @@ fun RoomSelectView( ) } items(summaries, key = { it.roomId.value }) { roomSummary -> - Column { + RoomSummaryView( + roomSummary, + isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, + onSelection = { roomSummary -> + state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary)) + }, + canBeSelected = state.canSelectMoreRooms, + ) + } + } + } + + if (!state.isSearchActive) { + if (state.showAccountSwitch) { + AccountSwitchSection( + selectedAccount = state.selectedAccount, + otherAccounts = state.otherAccounts, + isExpanded = state.isAccountListExpanded, + onToggleExpand = { state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) }, + onSelectAccount = { state.eventSink(RoomSelectEvent.SelectAccount(it)) }, + ) + } + SelectedRoomsHelper( + selectedRooms = state.selectedRooms, + // showVerticalSpace only if there is no other accounts + showVerticalSpace = !state.showAccountSwitch, + ) + if (state.resultState is SearchBarResultState.Results) { + if (state.resultState.results.isNotEmpty()) { + ListSectionHeader( + title = stringResource(CommonStrings.common_header_rooms), + hasDivider = false, + ) + } + LazyColumn( + state = lazyListState, + contentPadding = lazyColumnContentPadding, + ) { + items(state.resultState.results, key = { it.roomId.value }) { roomSummary -> RoomSummaryView( roomSummary, isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, @@ -181,34 +246,6 @@ fun RoomSelectView( }, canBeSelected = state.canSelectMoreRooms, ) - HorizontalDivider(modifier = Modifier.fillMaxWidth()) - } - } - } - } - - if (!state.isSearchActive) { - SelectedRoomsHelper( - selectedRooms = state.selectedRooms, - showVerticalSpace = true, - ) - if (state.resultState is SearchBarResultState.Results) { - LazyColumn( - state = lazyListState, - contentPadding = lazyColumnContentPadding, - ) { - items(state.resultState.results, key = { it.roomId.value }) { roomSummary -> - Column { - RoomSummaryView( - roomSummary, - isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, - onSelection = { roomSummary -> - state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary)) - }, - canBeSelected = state.canSelectMoreRooms, - ) - HorizontalDivider(modifier = Modifier.fillMaxWidth()) - } } } } @@ -217,6 +254,103 @@ fun RoomSelectView( } } +/** + * Similar to the MultiAccountSection of the PreferencesRootView. + * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=5414-4759 + */ +@Composable +private fun AccountSwitchSection( + selectedAccount: MatrixUser, + otherAccounts: ImmutableList, + isExpanded: Boolean, + onToggleExpand: () -> Unit, + onSelectAccount: (SessionId) -> Unit, +) { + Column { + Spacer(modifier = Modifier.height(8.dp)) + MatrixUserHeader( + matrixUser = selectedAccount, + ) + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) + val expandedStateDescription = if (isExpanded) { + stringResource(CommonStrings.a11y_state_expanded) + } else { + stringResource(CommonStrings.a11y_state_collapsed) + } + ListItem( + modifier = Modifier.semantics { + stateDescription = expandedStateDescription + }, + content = { Text(stringResource(CommonStrings.common_switch_account)) }, + onClick = onToggleExpand, + trailingContent = ListItemContent.Custom { _ -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AnimatedVisibility( + visible = !isExpanded, + enter = fadeIn(), + exit = fadeOut(), + ) { + AvatarRow( + avatarDataList = otherAccounts + .take(3) + .map { it.getAvatarData(AvatarSize.OtherAccountItem) } + .toImmutableList(), + avatarType = AvatarType.User, + lastOnTop = true, + ) + } + // Animate the chevron icon to rotate when the section is expanded/collapsed + val rotation: Float by animateFloatAsState( + targetValue = if (isExpanded) -180f else 0f, + animationSpec = tween( + delayMillis = 0, + durationMillis = 300, + ), + label = "chevron" + ) + Icon( + modifier = Modifier.rotate(rotation), + imageVector = CompoundIcons.ChevronDown(), + contentDescription = null, + ) + } + }, + ) + AnimatedVisibility( + visible = isExpanded, + ) { + Column { + HorizontalDivider( + thickness = 1.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) + otherAccounts.forEach { matrixUser -> + MatrixUserRow( + modifier = Modifier + .clickable { + onSelectAccount(matrixUser.userId) + } + .padding(top = 2.dp, bottom = 2.dp, end = 8.dp), + matrixUser = matrixUser, + avatarSize = AvatarSize.AccountItem, + verticalSpaceWidth = 16.dp, + ) + } + } + } + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) + } +} + @Composable private fun SelectedRooms( selectedRooms: ImmutableList, @@ -241,28 +375,25 @@ private fun RoomSummaryView( canBeSelected: Boolean, onSelection: (SelectRoomInfo) -> Unit, ) { - Row( - modifier = Modifier - .clickable { onSelection(roomInfo) } - .fillMaxWidth() - .padding(start = 16.dp, end = 4.dp) - .heightIn(56.dp), - verticalAlignment = Alignment.CenterVertically + ListItem( + onClick = { onSelection(roomInfo) }, + leadingContent = ListItemContent.Custom { + Avatar( + avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem), + avatarType = AvatarType.Room( + heroes = roomInfo.heroes.map { user -> + user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem) + }.toImmutableList(), + isTombstoned = roomInfo.isTombstoned, + ), + ) + }, + trailingContent = ListItemContent.Checkbox( + checked = isSelected, + enabled = isSelected || canBeSelected, + ), ) { - Avatar( - avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem), - avatarType = AvatarType.Room( - heroes = roomInfo.heroes.map { user -> - user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem) - }.toImmutableList(), - isTombstoned = roomInfo.isTombstoned, - ), - ) - Column( - modifier = Modifier - .padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp) - .weight(1f) - ) { + Column { // Name Text( style = ElementTheme.typography.fontBodyLgRegular, @@ -284,11 +415,6 @@ private fun RoomSummaryView( ) } } - Checkbox( - checked = isSelected, - enabled = isSelected || canBeSelected, - onCheckedChange = { onSelection(roomInfo) }, - ) } } diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPointTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPointTest.kt index 6863c1ce20..c25e3303cc 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPointTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPointTest.kt @@ -12,6 +12,8 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectMode import io.element.android.tests.testutils.lambda.lambdaError @@ -36,7 +38,8 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() { RoomSelectNode( buildContext = buildContext, plugins = plugins, - presenterFactory = { mode, maxNumberOfRooms -> + presenterFactory = { initialSessionId, mode, maxNumberOfRooms -> + assertThat(initialSessionId).isEqualTo(A_SESSION_ID) assertThat(mode).isEqualTo(testMode) assertThat(maxNumberOfRooms).isEqualTo(testMaxNumberOfRooms) createRoomSelectPresenter(mode, maxNumberOfRooms) @@ -44,10 +47,11 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() { ) } val callback = object : RoomSelectEntryPoint.Callback { - override fun onRoomSelected(roomIds: List) = lambdaError() + override fun onRoomSelected(sessionId: SessionId, roomIds: List) = lambdaError() override fun onCancel() = lambdaError() } val params = RoomSelectEntryPoint.Params( + sessionId = A_SESSION_ID, mode = testMode, maxNumberOfRooms = testMaxNumberOfRooms, ) @@ -58,7 +62,7 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() { callback = callback, ) assertThat(result).isInstanceOf(RoomSelectNode::class.java) - assertThat(result.plugins).contains(RoomSelectNode.Inputs(params.mode, params.maxNumberOfRooms)) + assertThat(result.plugins).contains(RoomSelectNode.Inputs(params.sessionId, params.mode, params.maxNumberOfRooms)) assertThat(result.plugins).contains(callback) } } diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt index b5a4ccfd12..ecd15c5bf2 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt @@ -11,15 +11,27 @@ package io.element.android.libraries.roomselect.impl import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd 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.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.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +import io.element.android.libraries.matrix.test.FakeMatrixClient +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.ui.model.toSelectRoomInfo import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectMode +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.test @@ -46,9 +58,99 @@ class RoomSelectPresenterTest { assertThat(initialState.isSearchActive).isFalse() assertThat(initialState.maxNumberOfRooms).isEqualTo(10) assertThat(initialState.canSelectMoreRooms).isTrue() + assertThat(initialState.selectedAccount).isEqualTo(MatrixUser(A_SESSION_ID)) + assertThat(initialState.otherAccounts).isEmpty() + assertThat(initialState.isAccountListExpanded).isFalse() + assertThat(initialState.showAccountSwitch).isFalse() } } + @Test + fun `present - share mode with a single account does not show the account switch`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value)), + ), + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.selectedAccount.userId).isEqualTo(A_SESSION_ID) + assertThat(state.otherAccounts).isEmpty() + assertThat(state.showAccountSwitch).isFalse() + } + } + + @Test + fun `present - share mode with several accounts shows the account switch`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = aSessionStoreWithTwoAccounts(), + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.selectedAccount.userId).isEqualTo(A_SESSION_ID) + assertThat(state.selectedAccount.displayName).isEqualTo("Alice") + assertThat(state.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2) + assertThat(state.showAccountSwitch).isTrue() + state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) + assertThat(awaitItem().isAccountListExpanded).isTrue() + state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) + assertThat(awaitItem().isAccountListExpanded).isFalse() + } + } + + @Test + fun `present - select another account displays the rooms of this account`() = runTest { + val roomSummary1 = aRoomSummary(roomId = A_ROOM_ID) + val roomSummary2 = aRoomSummary(roomId = A_ROOM_ID_2) + val roomListServices = mapOf( + A_SESSION_ID to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(roomSummary1))) }), + A_SESSION_ID_2 to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(roomSummary2))) }), + ) + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = aSessionStoreWithTwoAccounts(), + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListServices.getValue(sessionId))) + }, + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.selectedAccount.userId).isEqualTo(A_SESSION_ID) + assertThat((state.resultState as SearchBarResultState.Results).results.map { it.roomId }).containsExactly(A_ROOM_ID) + state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary1.toSelectRoomInfo())) + state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) + state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2)) + val finalState = awaitLastSequentialItem() + assertThat(finalState.selectedAccount.userId).isEqualTo(A_SESSION_ID_2) + assertThat(finalState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID) + assertThat(finalState.selectedRooms).isEmpty() + assertThat(finalState.isAccountListExpanded).isFalse() + assertThat((finalState.resultState as SearchBarResultState.Results).results.map { it.roomId }).containsExactly(A_ROOM_ID_2) + } + } + + @Test + fun `present - forward mode with several accounts does not show the account switch`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Forward, + sessionStore = aSessionStoreWithTwoAccounts(), + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.otherAccounts).isNotEmpty() + assertThat(state.showAccountSwitch).isFalse() + } + } + + private fun aSessionStoreWithTwoAccounts() = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_SESSION_ID.value, userDisplayName = "Alice"), + aSessionData(sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob"), + ), + ) + @Test fun `present - toggle search active`() = runTest { val presenter = createRoomSelectPresenter() @@ -157,16 +259,23 @@ internal fun TestScope.createRoomSelectPresenter( mode: RoomSelectMode = RoomSelectMode.Forward, maxNumberOfRooms: Int = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS, roomListService: RoomListService = FakeRoomListService(), + sessionStore: SessionStore = InMemorySessionStore(), + matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider { sessionId -> + Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListService)) + }, ) = RoomSelectPresenter( + initialSessionId = A_SESSION_ID, mode = mode, maxNumberOfRooms = maxNumberOfRooms, dataSourceFactory = object : RoomSelectSearchDataSource.Factory { - override fun create(coroutineScope: CoroutineScope): RoomSelectSearchDataSource { + override fun create(coroutineScope: CoroutineScope, roomListService: RoomListService): RoomSelectSearchDataSource { return RoomSelectSearchDataSource( coroutineScope = coroutineScope, roomListService = roomListService, coroutineDispatchers = testCoroutineDispatchers(), ) } - } + }, + matrixClientProvider = matrixClientProvider, + sessionStore = sessionStore, ) diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt index db0219bb04..4abdddd8a8 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt @@ -14,13 +14,19 @@ import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo +import io.element.android.libraries.roomselect.api.RoomSelectMode +import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.collections.immutable.persistentListOf import org.junit.Test @@ -72,6 +78,50 @@ class RoomSelectViewTest : RobolectricTest() { onNodeWithText("Room with a single hero").assertIsDisplayed() onNodeWithText("@alice:example.org").assertDoesNotExist() } + + @Test + fun `clicking on switch account emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setRoomSelectView( + aRoomSelectState( + mode = RoomSelectMode.Share, + selectedAccount = aMatrixUser(id = "@alice:example.org", displayName = "Alice"), + otherAccounts = persistentListOf(aMatrixUser(id = "@bob:example.org", displayName = "Bob")), + eventSink = eventsRecorder, + ), + ) + clickOn(CommonStrings.common_switch_account) + // The first event is the initial UpdateVisibleRange + eventsRecorder.assertList(listOf(RoomSelectEvent.UpdateVisibleRange(0..-1), RoomSelectEvent.ToggleAccountListExpanded)) + } + + @Test + fun `clicking on another account emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setRoomSelectView( + aRoomSelectState( + mode = RoomSelectMode.Share, + selectedAccount = aMatrixUser(id = "@alice:example.org", displayName = "Alice"), + otherAccounts = persistentListOf(aMatrixUser(id = "@bob:example.org", displayName = "Bob")), + isAccountListExpanded = true, + eventSink = eventsRecorder, + ), + ) + onNodeWithText("Bob").performClick() + // The first event is the initial UpdateVisibleRange + eventsRecorder.assertList(listOf(RoomSelectEvent.UpdateVisibleRange(0..-1), RoomSelectEvent.SelectAccount(UserId("@bob:example.org")))) + } + + @Test + fun `the account switch is not displayed if there is no other account`() = runAndroidComposeUiTest { + setRoomSelectView( + aRoomSelectState( + mode = RoomSelectMode.Share, + selectedAccount = aMatrixUser(id = "@alice:example.org", displayName = "Alice"), + ), + ) + onNodeWithText(activity!!.getString(CommonStrings.common_switch_account)).assertDoesNotExist() + } } private fun AndroidComposeUiTest.setRoomSelectView( From 15f7afc7c55d6734bc5e89c2e3e00a32239349ae Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 2 Oct 2026 14:51:24 +0000 Subject: [PATCH 03/12] Update screenshots --- .../libraries.roomselect.impl_RoomSelectView_Day_2_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_3_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_4_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_5_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_6_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Day_7_en.png | 3 +++ .../libraries.roomselect.impl_RoomSelectView_Day_8_en.png | 3 +++ .../libraries.roomselect.impl_RoomSelectView_Night_2_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_3_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_4_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_5_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_6_en.png | 4 ++-- .../libraries.roomselect.impl_RoomSelectView_Night_7_en.png | 3 +++ .../libraries.roomselect.impl_RoomSelectView_Night_8_en.png | 3 +++ 14 files changed, 32 insertions(+), 20 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_8_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_2_en.png index c186f08d29..a3efa82e01 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4bbd7ca482fe8e846b2748d25e429a5fffd2c0ecc88014942efb90ea312cb298 -size 34413 +oid sha256:c4614a485e74a734ae29d28588f482e38124543c16169443859bbfb1052fa348 +size 36016 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_3_en.png index cb7871cabf..1bd6536c16 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:18eef832d5c341dbc7d9c6c5495464dd9bfbd5c9355c3fe74384e6b1fdb0fe40 -size 32481 +oid sha256:7a8c5f7dd955e112736c4eeb6a0f9a52cda2f08264f50567d13e2bca8ed1fcf5 +size 33921 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_4_en.png index 16399a868c..566292b739 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ebb9b7f9c8e657d8e5a7fae6a133449fbbbb7529260dc4feb6c5da176eb1103 -size 36481 +oid sha256:23c03d8ceeb921a6fe5ec71764c71e6cb6623ae25b6e455cd4e39e673732bab7 +size 37812 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_5_en.png index defb96005c..ce8da40ea2 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ae42acc9b1e77edee6c07f62a5c29a15b0e99b51622ebca8ba0b94943a1952b6 -size 32119 +oid sha256:512f9d0246ac666d024395672ef7ff33473a40d2fe261bc84b70c1bb4381a1bd +size 33822 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_6_en.png index 0ab82207e4..16a6ac408f 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a4e584927a4aa9c6f85c667bed8c959cb4d525ea038d7c843db62cace4652d02 -size 36072 +oid sha256:ea0a573af47011a6f19ecc304d4cbb78522a1e6c41a353923425aeb7c8ea8e07 +size 37392 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_7_en.png new file mode 100644 index 0000000000..d17e9a0c3d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06a3b2ffff122289becfa847e8ff4979987ba17aeab85e2c6d277f0d2234878a +size 44276 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_8_en.png new file mode 100644 index 0000000000..718c7d031a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:454810b015c969009fd610b5b0141a892f68234147b87724d5dad0a0d7b5aab3 +size 48658 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_2_en.png index 6ecca16bce..660cfc382e 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0653e38a43b99d9b07e41cdccc1bdad0e1d22a1449fea87f3909c061dffeea1e -size 34221 +oid sha256:f68669e01f3ad0e984e70876cbcb2fe54fdbe78fa579000919bb025fab05ab27 +size 35943 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_3_en.png index dd4fa7ec73..5e079df0be 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:396c1b84d656baefa9161221bbc82343d55552dd7ea6aa44d87b4fc1a294a769 -size 32436 +oid sha256:23a17d6f9e1e44452d4f18b31ccfd93070baba947d7d5b5d5dce1accc737c964 +size 34066 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_4_en.png index 5178aaf31c..4b82ca3c80 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b5e5d889237d4e539e595c0dffd0f565b47b11257a8ef592567de0d302116f92 -size 36737 +oid sha256:c6b20e6f50dc0a3a7f9086252fb1be322602cfed48c0750791ad24d88feb9ca1 +size 38032 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_5_en.png index c0882218bf..0c2e69b595 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:da64e7f3f820e4ad902fd989e41daf310d29a3b90833ae71ca1e3712cedf5075 -size 31883 +oid sha256:c402aa3102e2f31c8b9ec911fdfeddd626d65032973f3f5753b2117a90118edb +size 33636 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_6_en.png index f64fc0fe0a..8d1929baf4 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:890ff1e4285f413dbbd6babe32d0c4e3f675a0567d3c2a827112a2336621f063 -size 36013 +oid sha256:54d3269f34abec26529050d7a89b581bdc3d5d1e20980d63993a08e73ec7a655 +size 37244 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_7_en.png new file mode 100644 index 0000000000..267a25e404 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e37a74fe450f9f150963f0099d25d51b5fca93905ebf24dd243e703344eec2d7 +size 44540 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_8_en.png new file mode 100644 index 0000000000..075b7ea1ed --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a0cd1e2aba9e07a10237b310359f1011434ac4d55fe68acc79bf2f83461fbb88 +size 48843 From ed2a55373692c5c350d99d0bbabbdddd884c95f3 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 2 Oct 2026 18:17:48 +0200 Subject: [PATCH 04/12] Fix flaky test. --- .../libraries/roomselect/impl/RoomSelectPresenterTest.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt index ecd15c5bf2..3eeb3397d8 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt @@ -32,6 +32,7 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.awaitLastSequentialItem +import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.test @@ -116,13 +117,17 @@ class RoomSelectPresenterTest { }, ) presenter.test { - val state = awaitLastSequentialItem() + // Wait for the rooms to be loaded, they are emitted on another dispatcher + val state = consumeItemsUntilPredicate { it.resultState is SearchBarResultState.Results }.last() assertThat(state.selectedAccount.userId).isEqualTo(A_SESSION_ID) assertThat((state.resultState as SearchBarResultState.Results).results.map { it.roomId }).containsExactly(A_ROOM_ID) state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary1.toSelectRoomInfo())) state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2)) - val finalState = awaitLastSequentialItem() + val finalState = consumeItemsUntilPredicate { + it.selectedAccount.userId == A_SESSION_ID_2 && + (it.resultState as? SearchBarResultState.Results)?.results?.map { room -> room.roomId } == listOf(A_ROOM_ID_2) + }.last() assertThat(finalState.selectedAccount.userId).isEqualTo(A_SESSION_ID_2) assertThat(finalState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID) assertThat(finalState.selectedRooms).isEmpty() From 72e3a7d92b24495f4063ebfab89aa5fab4711174 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 7 Oct 2026 10:34:20 +0200 Subject: [PATCH 05/12] Introduce ShareDataSender so that SharePresenter is not session scoped Instead of creating a SharePresenter from the graph of another session, the SharePresenter now gets the client of the selected session from the MatrixClientProvider and uses an AppScope ShareDataSender to send the data. To be able to send media from AppScope, MediaSenderRoomFactory is now bound in AppScope and takes the MatrixClient as a parameter, and a new MediaOptimizationConfigProviderFactory provides the media optimization config of any session. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../share/impl/DefaultShareDataSender.kt | 101 +++++++++++ .../features/share/impl/ShareDataSender.kt | 22 +++ .../android/features/share/impl/ShareNode.kt | 68 +------- .../features/share/impl/SharePresenter.kt | 118 +++---------- .../android/features/share/impl/ShareState.kt | 2 + .../share/impl/ShareStatePreviewParam.kt | 3 + .../features/share/impl/di/ShareBindings.kt | 20 --- .../share/impl/DefaultShareDataSenderTest.kt | 119 +++++++++++++ .../share/impl/DefaultShareEntryPointTest.kt | 9 +- .../share/impl/FakeShareDataSender.kt | 21 +++ .../features/share/impl/SharePresenterTest.kt | 160 +++++++++--------- .../api/MediaOptimizationConfigProvider.kt | 14 ++ .../libraries/mediaupload/api/MediaSender.kt | 3 + ...tMediaOptimizationConfigProviderFactory.kt | 29 ++++ .../mediaupload/impl/DefaultMediaSender.kt | 11 +- 15 files changed, 436 insertions(+), 264 deletions(-) create mode 100644 features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareDataSender.kt create mode 100644 features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareDataSender.kt delete mode 100644 features/share/impl/src/main/kotlin/io/element/android/features/share/impl/di/ShareBindings.kt create mode 100644 features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareDataSenderTest.kt create mode 100644 features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareDataSender.kt create mode 100644 libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProviderFactory.kt diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareDataSender.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareDataSender.kt new file mode 100644 index 0000000000..1836bfae10 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareDataSender.kt @@ -0,0 +1,101 @@ +/* + * 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.share.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.share.api.OnSharedData +import io.element.android.features.share.api.ShareIntentData +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProviderFactory +import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory +import io.element.android.services.appnavstate.api.ActiveRoomsHolder +import kotlin.coroutines.cancellation.CancellationException + +@ContributesBinding(AppScope::class) +class DefaultShareDataSender( + private val mediaSenderRoomFactory: MediaSenderRoomFactory, + private val mediaOptimizationConfigProviderFactory: MediaOptimizationConfigProviderFactory, + private val activeRoomsHolder: ActiveRoomsHolder, + private val onSharedData: OnSharedData, +) : ShareDataSender { + override suspend fun send( + client: MatrixClient, + shareIntentData: ShareIntentData, + roomIds: List, + ): Result = runCatchingExceptions { + val result = when (shareIntentData) { + is ShareIntentData.PlainText -> { + roomIds + .map { roomId -> + client.findJoinedRoom(roomId)?.liveTimeline?.sendMessage( + body = shareIntentData.content, + htmlBody = null, + intentionalMentions = emptyList(), + )?.isSuccess.orFalse() + } + .all { it } + } + is ShareIntentData.Uris -> { + val filesToShare = shareIntentData.uris + if (filesToShare.isEmpty()) { + false + } else { + val mediaOptimizationConfigProvider = mediaOptimizationConfigProviderFactory.create(client) + roomIds + .map { roomId -> + val room = client.findJoinedRoom(roomId) ?: return@map false + val mediaSender = mediaSenderRoomFactory.create(client = client, room = room) + filesToShare + .map { fileToShare -> + val result = mediaSender.sendMedia( + caption = shareIntentData.text, + uri = fileToShare.uri, + mimeType = fileToShare.mimeType, + mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), + ) + // If the coroutine was cancelled, destroy the room and rethrow the exception + val cancellationException = result.exceptionOrNull() as? CancellationException + if (cancellationException != null) { + if (activeRoomsHolder.getActiveRoomMatching(client.sessionId, roomId) == null) { + room.destroy() + } + throw cancellationException + } + result.isSuccess + } + .all { isSuccess -> isSuccess } + .also { + if (activeRoomsHolder.getActiveRoomMatching(client.sessionId, roomId) == null) { + room.destroy() + } + } + } + .all { it } + } + } + } + + // Handle post-processing of shared data + onSharedData(shareIntentData) + + if (!result) { + error("Failed to handle incoming share intent") + } + } + + private suspend fun MatrixClient.findJoinedRoom(roomId: RoomId): JoinedRoom? { + return activeRoomsHolder.getActiveRoom(sessionId) + ?.takeIf { it.roomId == roomId } + ?: getJoinedRoom(roomId) + } +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareDataSender.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareDataSender.kt new file mode 100644 index 0000000000..a4f4c34970 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareDataSender.kt @@ -0,0 +1,22 @@ +/* + * 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.share.impl + +import io.element.android.features.share.api.ShareIntentData +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId + +/** + * Sends the shared data to some rooms of a session. + */ +interface ShareDataSender { + /** + * Send [shareIntentData] to the rooms with [roomIds], using the session of [client]. + */ + suspend fun send(client: MatrixClient, shareIntentData: ShareIntentData, roomIds: List): Result +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt index 52464d4691..8b44f2bd0f 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt @@ -11,12 +11,7 @@ package io.element.android.features.share.impl import android.os.Parcelable import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.lifecycle.lifecycleScope import com.bumble.appyx.core.composable.Children import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel @@ -28,20 +23,15 @@ import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.features.share.api.ShareEntryPoint import io.element.android.features.share.api.ShareIntentData -import io.element.android.features.share.impl.di.ShareBindings 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.MatrixClientProvider -import io.element.android.libraries.matrix.api.SessionGraphFactory 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.coroutines.launch import kotlinx.parcelize.Parcelize -import timber.log.Timber @ContributesNode(SessionScope::class) @AssistedInject @@ -51,8 +41,6 @@ class ShareNode( presenterFactory: SharePresenter.Factory, private val roomSelectEntryPoint: RoomSelectEntryPoint, private val sessionId: SessionId, - private val matrixClientProvider: MatrixClientProvider, - private val sessionGraphFactory: SessionGraphFactory, ) : ParentNode( navModel = PermanentNavModel( navTargets = setOf(NavTarget), @@ -66,53 +54,14 @@ class ShareNode( data class Inputs(val shareIntentData: ShareIntentData) : NodeInputs - /** - * The presenter used to share the data, and the session it belongs to. - */ - private data class ActiveShare( - val sessionId: SessionId, - val presenter: SharePresenter, - ) - private val inputs = inputs() + private val presenter = presenterFactory.create(inputs.shareIntentData, sessionId) private val callback: ShareEntryPoint.Callback = callback() - private val currentSessionShare = ActiveShare( - sessionId = sessionId, - presenter = presenterFactory.create(inputs.shareIntentData), - ) - private var activeShare by mutableStateOf(currentSessionShare) - - private fun onRoomSelected(sessionId: SessionId, roomIds: List) { - if (sessionId == currentSessionShare.sessionId) { - activeShare = currentSessionShare - currentSessionShare.presenter.onRoomSelected(roomIds) - } else if (sessionId == activeShare.sessionId) { - // Retry with the same other session - activeShare.presenter.onRoomSelected(roomIds) - } else { - lifecycleScope.launch { - // Share using the graph of the selected session, the current session will be changed once the share is done - matrixClientProvider.getOrRestore(sessionId) - .onSuccess { client -> - val presenterFactory = (sessionGraphFactory.create(client) as ShareBindings).sharePresenterFactory() - activeShare = ActiveShare( - sessionId = sessionId, - presenter = presenterFactory.create(inputs.shareIntentData), - ).also { - it.presenter.onRoomSelected(roomIds) - } - } - .onFailure { - Timber.e(it, "Failed to get the client for session $sessionId") - } - } - } - } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { val callback = object : RoomSelectEntryPoint.Callback { override fun onRoomSelected(sessionId: SessionId, roomIds: List) { - this@ShareNode.onRoomSelected(sessionId, roomIds) + presenter.onRoomSelected(sessionId, roomIds) } override fun onCancel() { @@ -140,14 +89,11 @@ class ShareNode( navModel = navModel, ) - val currentShare = activeShare - key(currentShare) { - val state = currentShare.presenter.present() - ShareView( - state = state, - onShareSuccess = { roomIds -> callback.onDone(currentShare.sessionId, roomIds) }, - ) - } + val state = presenter.present() + ShareView( + state = state, + onShareSuccess = { roomIds -> callback.onDone(state.sessionId, roomIds) }, + ) } } } diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt index 3bd9b98227..905840d1df 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt @@ -10,47 +10,54 @@ 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 -import io.element.android.features.share.api.OnSharedData import io.element.android.features.share.api.ShareIntentData import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState -import io.element.android.libraries.core.bool.orFalse -import io.element.android.libraries.di.annotations.SessionCoroutineScope -import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.room.JoinedRoom -import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider -import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory -import io.element.android.services.appnavstate.api.ActiveRoomsHolder +import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import kotlin.coroutines.cancellation.CancellationException @AssistedInject class SharePresenter( @Assisted private val shareIntentData: ShareIntentData, - @SessionCoroutineScope - private val sessionCoroutineScope: CoroutineScope, - private val matrixClient: MatrixClient, - private val mediaSenderRoomFactory: MediaSenderRoomFactory, - private val activeRoomsHolder: ActiveRoomsHolder, - private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, - private val onSharedData: OnSharedData, + @Assisted initialSessionId: SessionId, + @AppCoroutineScope + private val appCoroutineScope: CoroutineScope, + private val matrixClientProvider: MatrixClientProvider, + private val shareDataSender: ShareDataSender, ) : Presenter { @AssistedFactory fun interface Factory { - fun create(shareIntentData: ShareIntentData): SharePresenter + fun create(shareIntentData: ShareIntentData, initialSessionId: SessionId): SharePresenter } private val shareActionState: MutableState>> = mutableStateOf(AsyncAction.Uninitialized) - fun onRoomSelected(roomIds: List) { - sessionCoroutineScope.share(shareIntentData, roomIds) + /** + * The session the data is shared with, it can be updated if the user selects rooms from another session. + */ + private var targetSessionId by mutableStateOf(initialSessionId) + + fun onRoomSelected(sessionId: SessionId, roomIds: List) { + if (shareActionState.value.isLoading()) return + targetSessionId = sessionId + appCoroutineScope.launch { + suspend { + val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow() + shareDataSender.send(client, shareIntentData, roomIds).getOrThrow() + roomIds + }.runCatchingUpdatingState(shareActionState) + } } @Composable @@ -62,80 +69,9 @@ class SharePresenter( } return ShareState( + sessionId = targetSessionId, shareAction = shareActionState.value, eventSink = ::handleEvent, ) } - - private suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom? { - return activeRoomsHolder.getActiveRoom(matrixClient.sessionId) - ?.takeIf { it.roomId == roomId } - ?: matrixClient.getJoinedRoom(roomId) - } - - private fun CoroutineScope.share( - shareIntentData: ShareIntentData, - roomIds: List, - ) = launch { - suspend { - val result = when (shareIntentData) { - is ShareIntentData.PlainText -> { - roomIds - .map { roomId -> - getJoinedRoom(roomId)?.liveTimeline?.sendMessage( - body = shareIntentData.content, - htmlBody = null, - intentionalMentions = emptyList(), - )?.isSuccess.orFalse() - } - .all { it } - } - is ShareIntentData.Uris -> { - val filesToShare = shareIntentData.uris - if (filesToShare.isEmpty()) { - false - } else { - roomIds - .map { roomId -> - val room = getJoinedRoom(roomId) ?: return@map false - val mediaSender = mediaSenderRoomFactory.create(room = room) - filesToShare - .map { fileToShare -> - val result = mediaSender.sendMedia( - caption = shareIntentData.text, - uri = fileToShare.uri, - mimeType = fileToShare.mimeType, - mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), - ) - // If the coroutine was cancelled, destroy the room and rethrow the exception - val cancellationException = result.exceptionOrNull() as? CancellationException - if (cancellationException != null) { - if (activeRoomsHolder.getActiveRoomMatching(matrixClient.sessionId, roomId) == null) { - room.destroy() - } - throw cancellationException - } - result.isSuccess - } - .all { isSuccess -> isSuccess } - .also { - if (activeRoomsHolder.getActiveRoomMatching(matrixClient.sessionId, roomId) == null) { - room.destroy() - } - } - } - .all { it } - } - } - } - - // Handle post-processing of shared data - onSharedData(shareIntentData) - - if (!result) { - error("Failed to handle incoming share intent") - } - roomIds - }.runCatchingUpdatingState(shareActionState) - } } diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt index 7538309941..10046cf3b2 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt @@ -10,8 +10,10 @@ package io.element.android.features.share.impl import io.element.android.libraries.architecture.AsyncAction 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>, val eventSink: (ShareEvent) -> Unit ) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStatePreviewParam.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStatePreviewParam.kt index 18258f1b95..c765db9195 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStatePreviewParam.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStatePreviewParam.kt @@ -11,6 +11,7 @@ package io.element.android.features.share.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId open class ShareStatePreviewParam : PreviewParameterProvider { override val values: Sequence @@ -31,9 +32,11 @@ open class ShareStatePreviewParam : PreviewParameterProvider { } fun aShareState( + sessionId: SessionId = SessionId("@alice:server.org"), shareAction: AsyncAction> = AsyncAction.Uninitialized, eventSink: (ShareEvent) -> Unit = {} ) = ShareState( + sessionId = sessionId, shareAction = shareAction, eventSink = eventSink ) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/di/ShareBindings.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/di/ShareBindings.kt deleted file mode 100644 index 2bf1c3e1e4..0000000000 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/di/ShareBindings.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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.share.impl.di - -import dev.zacsweers.metro.ContributesTo -import io.element.android.features.share.impl.SharePresenter -import io.element.android.libraries.di.SessionScope - -/** - * Used to get a [SharePresenter.Factory] from the graph of a session which is not the current one. - */ -@ContributesTo(SessionScope::class) -interface ShareBindings { - fun sharePresenterFactory(): SharePresenter.Factory -} diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareDataSenderTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareDataSenderTest.kt new file mode 100644 index 0000000000..bdd99f6a4d --- /dev/null +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareDataSenderTest.kt @@ -0,0 +1,119 @@ +/* + * 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.share.impl + +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import io.element.android.features.share.api.OnSharedData +import io.element.android.features.share.api.ShareIntentData +import io.element.android.features.share.api.UriToShare +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.test.A_MESSAGE +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProviderFactory +import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory +import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.test.FakeMediaSender +import io.element.android.services.appnavstate.api.ActiveRoomsHolder +import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultShareDataSenderTest : RobolectricTest() { + @Test + fun `send text ok`() = runTest { + val joinedRoom = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) } + }, + ) + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, joinedRoom) + } + val onSharedData = lambdaRecorder { } + val sender = createDefaultShareDataSender( + onSharedData = { onSharedData(it) }, + ) + val result = sender.send(matrixClient, ShareIntentData.PlainText(A_MESSAGE), listOf(A_ROOM_ID)) + assertThat(result.isSuccess).isTrue() + onSharedData.assertions().isCalledOnce() + } + + @Test + fun `send text, room not found`() = runTest { + val onSharedData = lambdaRecorder { } + val sender = createDefaultShareDataSender( + onSharedData = { onSharedData(it) }, + ) + val result = sender.send(FakeMatrixClient(), ShareIntentData.PlainText(A_MESSAGE), listOf(A_ROOM_ID)) + assertThat(result.isFailure).isTrue() + onSharedData.assertions().isCalledOnce() + } + + @Test + fun `send media ok`() = runTest { + val sendMediaResult = lambdaRecorder> { Result.success(Unit) } + val joinedRoom = FakeJoinedRoom( + liveTimeline = FakeTimeline(), + ) + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, joinedRoom) + } + val mediaSender = FakeMediaSender( + sendMediaResult = sendMediaResult, + ) + val sender = createDefaultShareDataSender( + mediaSenderRoomFactory = { _, _ -> mediaSender }, + ) + val result = sender.send( + client = matrixClient, + shareIntentData = ShareIntentData.Uris( + text = A_MESSAGE, + listOf( + UriToShare( + uri = Uri.parse("content://image.jpg"), + mimeType = MimeTypes.Jpeg, + ) + ) + ), + roomIds = listOf(A_ROOM_ID), + ) + assertThat(result.isSuccess).isTrue() + sendMediaResult.assertions().isCalledOnce() + } + + @Test + fun `send media with no uris is a failure`() = runTest { + val sender = createDefaultShareDataSender() + val result = sender.send( + client = FakeMatrixClient(), + shareIntentData = ShareIntentData.Uris(text = A_MESSAGE, emptyList()), + roomIds = listOf(A_ROOM_ID), + ) + assertThat(result.isFailure).isTrue() + } +} + +private fun createDefaultShareDataSender( + mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { _, _ -> FakeMediaSender() }, + mediaOptimizationConfigProviderFactory: MediaOptimizationConfigProviderFactory = MediaOptimizationConfigProviderFactory { + FakeMediaOptimizationConfigProvider() + }, + activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(), + onSharedData: OnSharedData = OnSharedData {}, +) = DefaultShareDataSender( + mediaSenderRoomFactory = mediaSenderRoomFactory, + mediaOptimizationConfigProviderFactory = mediaOptimizationConfigProviderFactory, + activeRoomsHolder = activeRoomsHolder, + onSharedData = onSharedData, +) diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt index 8396544516..85068f5402 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt @@ -14,13 +14,10 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.google.common.truth.Truth.assertThat import io.element.android.features.share.api.ShareEntryPoint import io.element.android.features.share.api.ShareIntentData -import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.SessionGraphFactory import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.node.TestParentNode @@ -42,13 +39,9 @@ class DefaultShareEntryPointTest { ShareNode( buildContext = buildContext, plugins = plugins, - presenterFactory = { createSharePresenter() }, + presenterFactory = { _, _ -> createSharePresenter() }, roomSelectEntryPoint = FakeRoomSelectEntryPoint(), sessionId = A_SESSION_ID, - matrixClientProvider = FakeMatrixClientProvider(), - sessionGraphFactory = object : SessionGraphFactory { - override fun create(client: MatrixClient): Any = lambdaError() - }, ) } val callback = object : ShareEntryPoint.Callback { diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareDataSender.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareDataSender.kt new file mode 100644 index 0000000000..370ed8b31c --- /dev/null +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareDataSender.kt @@ -0,0 +1,21 @@ +/* + * 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.share.impl + +import io.element.android.features.share.api.ShareIntentData +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeShareDataSender( + private val sendResult: (MatrixClient, ShareIntentData, List) -> Result = { _, _, _ -> lambdaError() }, +) : ShareDataSender { + override suspend fun send(client: MatrixClient, shareIntentData: ShareIntentData, roomIds: List): Result { + return sendResult(client, shareIntentData, roomIds) + } +} diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt index 44ccea92d6..8fc5109636 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt @@ -8,37 +8,32 @@ package io.element.android.features.share.impl -import android.net.Uri import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.features.share.api.OnSharedData import io.element.android.features.share.api.ShareIntentData -import io.element.android.features.share.api.UriToShare import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.core.mimetype.MimeTypes 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.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.FakeMatrixClient -import io.element.android.libraries.matrix.test.room.FakeJoinedRoom -import io.element.android.libraries.matrix.test.timeline.FakeTimeline -import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider -import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory -import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider -import io.element.android.libraries.mediaupload.test.FakeMediaSender -import io.element.android.services.appnavstate.api.ActiveRoomsHolder -import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class SharePresenterTest : RobolectricTest() { +class SharePresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -49,22 +44,25 @@ class SharePresenterTest : RobolectricTest() { presenter.present() }.test { val initialState = awaitItem() + assertThat(initialState.sessionId).isEqualTo(A_SESSION_ID) assertThat(initialState.shareAction.isUninitialized()).isTrue() } } @Test fun `present - on room selected error then clear error`() = runTest { - val presenter = createSharePresenter() + val presenter = createSharePresenter( + shareDataSender = FakeShareDataSender { _, _, _ -> Result.failure(AN_EXCEPTION) }, + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(initialState.shareAction.isUninitialized()).isTrue() - presenter.onRoomSelected(listOf(A_ROOM_ID)) + presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID)) assertThat(awaitItem().shareAction.isLoading()).isTrue() val failure = awaitItem() - assertThat(failure.shareAction.isFailure()).isTrue() + assertThat(failure.shareAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) failure.eventSink.invoke(ShareEvent.ClearError) assertThat(awaitItem().shareAction.isUninitialized()).isTrue() } @@ -72,113 +70,115 @@ class SharePresenterTest : RobolectricTest() { @Test fun `present - on room selected ok`() = runTest { - val joinedRoom = FakeJoinedRoom( - liveTimeline = FakeTimeline().apply { - sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) } - }, - ) - val matrixClient = FakeMatrixClient().apply { - givenGetRoomResult(A_ROOM_ID, joinedRoom) - } + val client = FakeMatrixClient(sessionId = A_SESSION_ID) + val shareIntentData = ShareIntentData.PlainText(A_MESSAGE) + val sendResult = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } val presenter = createSharePresenter( - matrixClient = matrixClient, - shareIntentData = ShareIntentData.PlainText(A_MESSAGE), + shareIntentData = shareIntentData, + matrixClientProvider = FakeMatrixClientProvider { Result.success(client) }, + shareDataSender = FakeShareDataSender(sendResult), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(initialState.shareAction.isUninitialized()).isTrue() - presenter.onRoomSelected(listOf(A_ROOM_ID)) + presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID)) assertThat(awaitItem().shareAction.isLoading()).isTrue() val success = awaitItem() - assertThat(success.shareAction.isSuccess()).isTrue() + assertThat(success.sessionId).isEqualTo(A_SESSION_ID) assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID))) + sendResult.assertions().isCalledOnce().with(value(client), value(shareIntentData), value(listOf(A_ROOM_ID))) } } @Test - fun `present - send text ok`() = runTest { - val joinedRoom = FakeJoinedRoom( - liveTimeline = FakeTimeline().apply { - sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) } - }, - ) - val matrixClient = FakeMatrixClient().apply { - givenGetRoomResult(A_ROOM_ID, joinedRoom) + fun `present - on room selected from another session ok`() = runTest { + val getClient = lambdaRecorder> { sessionId -> Result.success(FakeMatrixClient(sessionId = sessionId)) } + val sendResult = lambdaRecorder, Result> { client, _, _ -> + assertThat(client.sessionId).isEqualTo(A_SESSION_ID_2) + Result.success(Unit) } val presenter = createSharePresenter( - matrixClient = matrixClient, - shareIntentData = ShareIntentData.PlainText(A_MESSAGE), + matrixClientProvider = FakeMatrixClientProvider { getClient(it) }, + shareDataSender = FakeShareDataSender(sendResult), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.shareAction.isUninitialized()).isTrue() - presenter.onRoomSelected(listOf(A_ROOM_ID)) + assertThat(initialState.sessionId).isEqualTo(A_SESSION_ID) + presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID)) + val sessionChanged = awaitItem() + assertThat(sessionChanged.sessionId).isEqualTo(A_SESSION_ID_2) + assertThat(sessionChanged.shareAction.isUninitialized()).isTrue() assertThat(awaitItem().shareAction.isLoading()).isTrue() val success = awaitItem() - assertThat(success.shareAction.isSuccess()).isTrue() + assertThat(success.sessionId).isEqualTo(A_SESSION_ID_2) assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID))) + getClient.assertions().isCalledOnce().with(value(A_SESSION_ID_2)) + sendResult.assertions().isCalledOnce() } } @Test - fun `present - send media ok`() = runTest { - val sendMediaResult = lambdaRecorder> { Result.success(Unit) } - val joinedRoom = FakeJoinedRoom( - liveTimeline = FakeTimeline(), - ) - val matrixClient = FakeMatrixClient().apply { - givenGetRoomResult(A_ROOM_ID, joinedRoom) - } - val mediaSender = FakeMediaSender( - sendMediaResult = sendMediaResult, - ) + fun `present - on room selected, failure to get the client`() = runTest { + val sendResult = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } val presenter = createSharePresenter( - matrixClient = matrixClient, - shareIntentData = ShareIntentData.Uris( - text = A_MESSAGE, - listOf( - UriToShare( - uri = Uri.parse("content://image.jpg"), - mimeType = MimeTypes.Jpeg, - ) - ) - ), - mediaSenderRoomFactory = MediaSenderRoomFactory { mediaSender }, + matrixClientProvider = FakeMatrixClientProvider { Result.failure(AN_EXCEPTION) }, + shareDataSender = FakeShareDataSender(sendResult), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = awaitItem() - assertThat(initialState.shareAction.isUninitialized()).isTrue() - presenter.onRoomSelected(listOf(A_ROOM_ID)) + 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() + } + } + + @Test + fun `present - on room selected while sharing is ignored`() = runTest { + val completable = CompletableDeferred>() + val sendResult = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } + val presenter = createSharePresenter( + shareDataSender = object : ShareDataSender { + override suspend fun send(client: MatrixClient, shareIntentData: ShareIntentData, roomIds: List): Result { + sendResult(client, shareIntentData, roomIds) + return completable.await() + } + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID)) + assertThat(awaitItem().shareAction.isLoading()).isTrue() + presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID)) + completable.complete(Result.success(Unit)) val success = awaitItem() - assertThat(success.shareAction.isSuccess()).isTrue() + assertThat(success.sessionId).isEqualTo(A_SESSION_ID) assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID))) - sendMediaResult.assertions().isCalledOnce() + sendResult.assertions().isCalledOnce() } } } internal fun TestScope.createSharePresenter( shareIntentData: ShareIntentData = ShareIntentData.PlainText(A_MESSAGE), - matrixClient: MatrixClient = FakeMatrixClient(), - activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(), - mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { FakeMediaSender() }, - mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), - onSharedData: OnSharedData = OnSharedData {}, + initialSessionId: SessionId = A_SESSION_ID, + matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), + shareDataSender: ShareDataSender = FakeShareDataSender(), ): SharePresenter { return SharePresenter( shareIntentData = shareIntentData, - sessionCoroutineScope = this, - matrixClient = matrixClient, - activeRoomsHolder = activeRoomsHolder, - mediaSenderRoomFactory = mediaSenderRoomFactory, - mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, - onSharedData = onSharedData, + initialSessionId = initialSessionId, + appCoroutineScope = this, + matrixClientProvider = matrixClientProvider, + shareDataSender = shareDataSender, ) } diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfigProvider.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfigProvider.kt index 60dbf353eb..b0efb618b5 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfigProvider.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfigProvider.kt @@ -8,6 +8,8 @@ package io.element.android.libraries.mediaupload.api +import io.element.android.libraries.matrix.api.MatrixClient + /** * Resolves how aggressively media should be compressed, combining the user's own preferences with the app defaults. */ @@ -15,3 +17,15 @@ fun interface MediaOptimizationConfigProvider { /** Returns the configuration to pass to the pre-processing and sending calls. */ suspend fun get(): MediaOptimizationConfig } + +/** + * Creates a [MediaOptimizationConfigProvider] for a given session, used outside the session scope, for instance when sharing to another account. + */ +fun interface MediaOptimizationConfigProviderFactory { + /** + * Create a [MediaOptimizationConfigProvider] using the preferences of the session of [client]. + * + * @param client the client of the session whose preferences should be used. + */ + fun create(client: MatrixClient): MediaOptimizationConfigProvider +} diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt index 808575f9e6..aa76473634 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.mediaupload.api import android.net.Uri +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.timeline.Timeline @@ -34,9 +35,11 @@ fun interface MediaSenderRoomFactory { /** * Create a [MediaSender] for the given [JoinedRoom], with timeline mode Live. * + * @param client the client of the session the room belongs to, used to read the media preferences of that session. * @param room the room the media should be sent to. */ fun create( + client: MatrixClient, room: JoinedRoom, ): MediaSender } diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProviderFactory.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProviderFactory.kt new file mode 100644 index 0000000000..7d2e7d1a81 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProviderFactory.kt @@ -0,0 +1,29 @@ +/* + * 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.mediaupload.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProviderFactory +import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory + +@ContributesBinding(AppScope::class) +class DefaultMediaOptimizationConfigProviderFactory( + private val sessionPreferencesStoreFactory: SessionPreferencesStoreFactory, + private val featureFlagService: FeatureFlagService, +) : MediaOptimizationConfigProviderFactory { + override fun create(client: MatrixClient): MediaOptimizationConfigProvider { + return DefaultMediaOptimizationConfigProvider( + sessionPreferencesStore = sessionPreferencesStoreFactory.get(client.sessionId, client.sessionCoroutineScope), + featureFlagsService = featureFlagService, + ) + } +} diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt index 36c3ba99fb..a5e0b1c38a 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt @@ -9,12 +9,13 @@ package io.element.android.libraries.mediaupload.impl import android.net.Uri +import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.androidutils.hash.hash import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.extensions.flatMapCatching import io.element.android.libraries.di.RoomScope -import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.room.CreateTimelineParams @@ -22,6 +23,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProviderFactory import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSenderFactory @@ -52,19 +54,20 @@ class DefaultMediaSenderFactory( } } -@ContributesBinding(SessionScope::class) +@ContributesBinding(AppScope::class) class DefaultMediaSenderRoomFactory( private val preProcessor: MediaPreProcessor, - private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, + private val mediaOptimizationConfigProviderFactory: MediaOptimizationConfigProviderFactory, ) : MediaSenderRoomFactory { override fun create( + client: MatrixClient, room: JoinedRoom, ): MediaSender { return DefaultMediaSender( preProcessor = preProcessor, room = room, timelineMode = Timeline.Mode.Live, - mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, + mediaOptimizationConfigProvider = mediaOptimizationConfigProviderFactory.create(client), ) } } From 3c212ae9a3cf4ac28317c920b198faaefe678460 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 7 Oct 2026 10:35:35 +0200 Subject: [PATCH 06/12] Move SessionGraphFactory back to the appnav module The share feature does not need it anymore, so there is no reason to expose it in the matrix api module. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../io/element/android/x/di/DefaultSessionGraphFactory.kt | 2 +- .../io/element/android/appnav/LoggedInAppScopeFlowNode.kt | 2 +- .../io/element/android/appnav/di}/SessionGraphFactory.kt | 8 +++----- .../verification/OtherSessionIncomingVerificationNode.kt | 2 +- 4 files changed, 6 insertions(+), 8 deletions(-) rename {libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api => appnav/src/main/kotlin/io/element/android/appnav/di}/SessionGraphFactory.kt (60%) diff --git a/app/src/main/kotlin/io/element/android/x/di/DefaultSessionGraphFactory.kt b/app/src/main/kotlin/io/element/android/x/di/DefaultSessionGraphFactory.kt index 6135d7774f..5bfe082ffe 100644 --- a/app/src/main/kotlin/io/element/android/x/di/DefaultSessionGraphFactory.kt +++ b/app/src/main/kotlin/io/element/android/x/di/DefaultSessionGraphFactory.kt @@ -10,8 +10,8 @@ package io.element.android.x.di import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding +import io.element.android.appnav.di.SessionGraphFactory import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.SessionGraphFactory @ContributesBinding(AppScope::class) class DefaultSessionGraphFactory( diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt index 74f0d81dc1..32e1723d99 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt @@ -26,13 +26,13 @@ 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.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.callback 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.SessionGraphFactory 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 diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SessionGraphFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/SessionGraphFactory.kt similarity index 60% rename from libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SessionGraphFactory.kt rename to appnav/src/main/kotlin/io/element/android/appnav/di/SessionGraphFactory.kt index cd940aa18e..bc3960409e 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SessionGraphFactory.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/SessionGraphFactory.kt @@ -6,12 +6,10 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.api +package io.element.android.appnav.di + +import io.element.android.libraries.matrix.api.MatrixClient -/** - * Create the dependency injection graph of a session. - * It can be used to get session scoped dependencies of a session which is not the current one. - */ interface SessionGraphFactory { fun create(client: MatrixClient): Any } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionIncomingVerificationNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionIncomingVerificationNode.kt index eb282fffe8..115c174bb8 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionIncomingVerificationNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionIncomingVerificationNode.kt @@ -20,13 +20,13 @@ 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.verifysession.api.IncomingVerificationEntryPoint 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.SessionGraphFactory import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.verification.VerificationRequest import kotlinx.parcelize.Parcelize From 30b71cf90b6937ecaa67163a95a51ff53e354afc Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 7 Oct 2026 10:40:54 +0200 Subject: [PATCH 07/12] Use derivedStateOf for selectedAccount too --- .../libraries/roomselect/impl/RoomSelectPresenter.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt index 8026dbdd6d..11e6c65415 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt @@ -72,8 +72,12 @@ class RoomSelectPresenter( list.map { it.toMatrixUser() } } }.collectAsState(initial = emptyList()) - // Fallback to a MatrixUser with only the userId until the sessions are loaded - val selectedAccount = accounts.find { it.userId == selectedSessionId } ?: MatrixUser(selectedSessionId) + val selectedAccount by remember { + derivedStateOf { + // Fallback to a MatrixUser with only the userId until the sessions are loaded + accounts.find { it.userId == selectedSessionId } ?: MatrixUser(selectedSessionId) + } + } val otherAccounts by remember { derivedStateOf { accounts.filter { it.userId != selectedSessionId }.toImmutableList() From 6f1033ee671e7094c6e2ea4885a2a7ca4ec10e90 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 7 Oct 2026 11:19:42 +0200 Subject: [PATCH 08/12] RoomSelectView: improve scroll behavior Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=195-40292 --- .../roomselect/impl/RoomSelectView.kt | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt index 69802fcf30..224802c823 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -33,6 +33,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.key @@ -42,6 +44,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription @@ -51,6 +54,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.TopAppBarScrollBehaviorLayout import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarRow import io.element.android.libraries.designsystem.components.avatar.AvatarSize @@ -133,6 +137,11 @@ fun RoomSelectView( // Use a new list state when the selected account changes, so that the list of rooms is scrolled to the top val lazyListState = key(state.selectedAccount.userId) { rememberLazyListState() } + // Hide the account switch section when the room list is scrolled up, and show it again as soon as it is scrolled down. + // Use a new state when the selected account changes, so that the section is fully displayed again. + val accountSwitchScrollBehavior = key(state.selectedAccount.userId) { + TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) + } OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> state.eventSink(RoomSelectEvent.UpdateVisibleRange(visibleRange)) } @@ -166,6 +175,7 @@ fun RoomSelectView( Modifier .padding(paddingValues) .consumeWindowInsets(paddingValues) + .nestedScroll(accountSwitchScrollBehavior.nestedScrollConnection) ) { SearchBar( modifier = Modifier @@ -213,13 +223,17 @@ fun RoomSelectView( if (!state.isSearchActive) { if (state.showAccountSwitch) { - AccountSwitchSection( - selectedAccount = state.selectedAccount, - otherAccounts = state.otherAccounts, - isExpanded = state.isAccountListExpanded, - onToggleExpand = { state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) }, - onSelectAccount = { state.eventSink(RoomSelectEvent.SelectAccount(it)) }, - ) + // Keep this space outside the collapsing section, so that the section does not touch the search bar when it is collapsing + Spacer(modifier = Modifier.height(8.dp)) + TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) { + AccountSwitchSection( + selectedAccount = state.selectedAccount, + otherAccounts = state.otherAccounts, + isExpanded = state.isAccountListExpanded, + onToggleExpand = { state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) }, + onSelectAccount = { state.eventSink(RoomSelectEvent.SelectAccount(it)) }, + ) + } } SelectedRoomsHelper( selectedRooms = state.selectedRooms, @@ -227,16 +241,18 @@ fun RoomSelectView( showVerticalSpace = !state.showAccountSwitch, ) if (state.resultState is SearchBarResultState.Results) { - if (state.resultState.results.isNotEmpty()) { - ListSectionHeader( - title = stringResource(CommonStrings.common_header_rooms), - hasDivider = false, - ) - } LazyColumn( state = lazyListState, contentPadding = lazyColumnContentPadding, ) { + if (state.resultState.results.isNotEmpty()) { + item { + ListSectionHeader( + title = stringResource(CommonStrings.common_header_rooms), + hasDivider = false, + ) + } + } items(state.resultState.results, key = { it.roomId.value }) { roomSummary -> RoomSummaryView( roomSummary, @@ -267,7 +283,6 @@ private fun AccountSwitchSection( onSelectAccount: (SessionId) -> Unit, ) { Column { - Spacer(modifier = Modifier.height(8.dp)) MatrixUserHeader( matrixUser = selectedAccount, ) From 3c4632ee9d1f2d80425e5cd2053e5f6297154ed4 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 7 Oct 2026 10:47:30 +0200 Subject: [PATCH 09/12] Render an error in the room select screen if the rooms cannot be loaded If the client of the selected account cannot be restored, the room list was never loaded and there was no feedback to the user. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../roomselect/impl/RoomSelectPresenter.kt | 15 ++++--- .../roomselect/impl/RoomSelectState.kt | 4 ++ .../impl/RoomSelectStatePreviewParam.kt | 8 ++++ .../roomselect/impl/RoomSelectView.kt | 18 ++++++++- .../impl/src/main/res/values/localazy.xml | 4 ++ .../impl/RoomSelectPresenterTest.kt | 40 +++++++++++++++++++ .../roomselect/impl/RoomSelectViewTest.kt | 13 ++++++ tools/localazy/config.json | 6 +++ 8 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 libraries/roomselect/impl/src/main/res/values/localazy.xml diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt index 11e6c65415..0850cbc4e9 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt @@ -87,20 +87,22 @@ 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 dataSource = key(selectedSessionId) { + val (dataSource, hasRoomListError) = key(selectedSessionId) { val dataSourceCoroutineScope = rememberCoroutineScope() - // The sessions are restored at startup, so the client should already be in memory - val roomListService by produceState(matrixClientProvider.getOrNull(selectedSessionId)?.roomListService) { + // 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 roomListServiceResult by produceState(matrixClientProvider.getOrNull(selectedSessionId)?.roomListService?.let { Result.success(it) }) { if (value == null) { value = matrixClientProvider.getOrRestore(selectedSessionId) .onFailure { Timber.e(it, "Failed to get the client for $selectedSessionId") } - .getOrNull() - ?.roomListService + .map { it.roomListService } } } - remember(roomListService) { + val roomListService = roomListServiceResult?.getOrNull() + val dataSource = remember(roomListService) { roomListService?.let { dataSourceFactory.create(dataSourceCoroutineScope, it) } } + dataSource to (roomListServiceResult?.isFailure == true) } val searchQuery = queryState.text.toString() @@ -156,6 +158,7 @@ class RoomSelectPresenter( isSearchActive = isSearchActive, selectedRooms = selectedRooms, selectedAccount = selectedAccount, + hasRoomListError = hasRoomListError, otherAccounts = otherAccounts, isAccountListExpanded = isAccountListExpanded, eventSink = ::handleEvent, diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt index 198ce2655d..7af7b28ddf 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt @@ -23,6 +23,10 @@ data class RoomSelectState( val isSearchActive: Boolean, val selectedRooms: ImmutableList, val selectedAccount: MatrixUser, + /** + * True if the rooms of the selected account cannot be loaded. + */ + val hasRoomListError: Boolean, val otherAccounts: ImmutableList, val isAccountListExpanded: Boolean, val eventSink: (RoomSelectEvent) -> Unit, diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt index d4e5ba011e..0c46c652ef 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt @@ -62,6 +62,12 @@ open class RoomSelectStatePreviewParam : PreviewParameterProvider = persistentListOf(), selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")), + hasRoomListError: Boolean = false, otherAccounts: ImmutableList = persistentListOf(), isAccountListExpanded: Boolean = false, eventSink: (RoomSelectEvent) -> Unit = {}, @@ -84,6 +91,7 @@ internal fun aRoomSelectState( isSearchActive = isSearchActive, selectedRooms = selectedRooms, selectedAccount = selectedAccount, + hasRoomListError = hasRoomListError, otherAccounts = otherAccounts, isAccountListExpanded = isAccountListExpanded, eventSink = eventSink, diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt index 224802c823..5865d4416a 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -54,6 +54,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.TopAppBarScrollBehaviorLayout import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarRow @@ -240,7 +242,9 @@ fun RoomSelectView( // showVerticalSpace only if there is no other accounts showVerticalSpace = !state.showAccountSwitch, ) - if (state.resultState is SearchBarResultState.Results) { + if (state.hasRoomListError) { + RoomListError() + } else if (state.resultState is SearchBarResultState.Results) { LazyColumn( state = lazyListState, contentPadding = lazyColumnContentPadding, @@ -270,6 +274,18 @@ fun RoomSelectView( } } +@Composable +private fun RoomListError() { + IconTitleSubtitleMolecule( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 32.dp), + title = stringResource(CommonStrings.common_something_went_wrong), + subTitle = stringResource(R.string.screen_room_select_error_cannot_load_rooms), + iconStyle = BigIcon.Style.AlertSolid, + ) +} + /** * Similar to the MultiAccountSection of the PreferencesRootView. * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=5414-4759 diff --git a/libraries/roomselect/impl/src/main/res/values/localazy.xml b/libraries/roomselect/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..377391b835 --- /dev/null +++ b/libraries/roomselect/impl/src/main/res/values/localazy.xml @@ -0,0 +1,4 @@ + + + "The room list associated with this account cannot be loaded." + diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt index 3eeb3397d8..983d676dec 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt @@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.MatrixClientProvider 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.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_SESSION_ID @@ -63,6 +64,45 @@ class RoomSelectPresenterTest { assertThat(initialState.otherAccounts).isEmpty() assertThat(initialState.isAccountListExpanded).isFalse() assertThat(initialState.showAccountSwitch).isFalse() + assertThat(initialState.hasRoomListError).isFalse() + } + } + + @Test + fun `present - failure to get the client shows an error`() = runTest { + val presenter = createRoomSelectPresenter( + matrixClientProvider = FakeMatrixClientProvider { Result.failure(AN_EXCEPTION) }, + ) + presenter.test { + val state = consumeItemsUntilPredicate { it.hasRoomListError }.last() + assertThat(state.hasRoomListError).isTrue() + assertThat(state.resultState).isInstanceOf(SearchBarResultState.Initial::class.java) + } + } + + @Test + fun `present - select another account whose client cannot be restored shows an error`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = aSessionStoreWithTwoAccounts(), + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + if (sessionId == A_SESSION_ID) { + Result.success(FakeMatrixClient(sessionId = sessionId)) + } else { + Result.failure(AN_EXCEPTION) + } + }, + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.hasRoomListError).isFalse() + state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2)) + val errorState = consumeItemsUntilPredicate { it.hasRoomListError }.last() + assertThat(errorState.selectedAccount.userId).isEqualTo(A_SESSION_ID_2) + // Going back to the first account clears the error + errorState.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID)) + val finalState = consumeItemsUntilPredicate { it.selectedAccount.userId == A_SESSION_ID && !it.hasRoomListError }.last() + assertThat(finalState.hasRoomListError).isFalse() } } diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt index 4abdddd8a8..543d20b521 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt @@ -122,6 +122,19 @@ class RoomSelectViewTest : RobolectricTest() { ) onNodeWithText(activity!!.getString(CommonStrings.common_switch_account)).assertDoesNotExist() } + + @Test + fun `an error is displayed instead of the rooms if they cannot be loaded`() = runAndroidComposeUiTest { + setRoomSelectView( + aRoomSelectState( + resultState = SearchBarResultState.Results(aRoomSelectRoomList()), + hasRoomListError = true, + ), + ) + onNodeWithText(activity!!.getString(CommonStrings.common_something_went_wrong)).assertIsDisplayed() + onNodeWithText(activity!!.getString(R.string.screen_room_select_error_cannot_load_rooms)).assertIsDisplayed() + onNodeWithText("Room with alias").assertDoesNotExist() + } } private fun AndroidComposeUiTest.setRoomSelectView( diff --git a/tools/localazy/config.json b/tools/localazy/config.json index e7f5f1e036..a46ae3adc1 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -14,6 +14,12 @@ "screen_bug_report_.*" ] }, + { + "name" : ":libraries:roomselect:impl", + "includeRegex" : [ + "screen\\.room_select\\..*" + ] + }, { "name" : ":features:rageshake:api", "includeRegex" : [ From 54b1a909bcf912e2ac28c79ebbdb0ddc1d7ca327 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 7 Oct 2026 16:01:14 +0200 Subject: [PATCH 10/12] Display an error in the room select screen if the selected account has an invalid token All the accounts are proposed in the account switch, even the ones with an invalid token, so that the user can still see them. If the selected account has an invalid token, or is invalidated while the screen is displayed (for instance signed out from another device), the error is displayed instead of the room list, the selected rooms are cleared, and the client of this account is not used anymore. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../roomselect/impl/RoomSelectPresenter.kt | 33 +++++-- .../impl/RoomSelectPresenterTest.kt | 92 ++++++++++++++++++- 2 files changed, 115 insertions(+), 10 deletions(-) diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt index 0850cbc4e9..3de9d22ba8 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt @@ -38,7 +38,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import timber.log.Timber @@ -67,11 +66,24 @@ class RoomSelectPresenter( var isSearchActive by remember { mutableStateOf(false) } var isAccountListExpanded by remember { mutableStateOf(false) } 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 { - sessionStore.sessionsFlow().map { list -> - list.map { it.toMatrixUser() } + derivedStateOf { sessions.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. + val isSelectedSessionInvalid by remember { + derivedStateOf { + selectedSessionId != initialSessionId && sessions.none { it.userId == selectedSessionId.value && it.isTokenValid } } - }.collectAsState(initial = emptyList()) + } + LaunchedEffect(isSelectedSessionInvalid) { + if (isSelectedSessionInvalid) { + // The rooms of an invalid session cannot be used + selectedRooms = persistentListOf() + } + } val selectedAccount by remember { derivedStateOf { // Fallback to a MatrixUser with only the userId until the sessions are loaded @@ -87,12 +99,14 @@ 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 (dataSource, hasRoomListError) = key(selectedSessionId) { + 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. - val roomListServiceResult by produceState(matrixClientProvider.getOrNull(selectedSessionId)?.roomListService?.let { Result.success(it) }) { - if (value == null) { + // 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 } @@ -104,6 +118,9 @@ class RoomSelectPresenter( } 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 searchQuery = queryState.text.toString() LaunchedEffect(dataSource, searchQuery) { diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt index 983d676dec..0694cc27f0 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.roomselect.impl import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +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 @@ -35,6 +36,7 @@ import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.awaitLastSequentialItem 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.test import io.element.android.tests.testutils.testCoroutineDispatchers @@ -141,6 +143,92 @@ class RoomSelectPresenterTest { } } + @Test + fun `present - accounts with an invalid token are displayed, selecting one displays an error`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_SESSION_ID.value, userDisplayName = "Alice", isTokenValid = true), + aSessionData(sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", isTokenValid = false), + ), + ), + // The client of the invalid session must not be requested + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + if (sessionId == A_SESSION_ID) Result.success(FakeMatrixClient(sessionId = sessionId)) else lambdaError() + }, + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2) + assertThat(state.hasRoomListError).isFalse() + state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2)) + val errorState = consumeItemsUntilPredicate { it.selectedAccount.userId == A_SESSION_ID_2 && it.hasRoomListError }.last() + // The account information is still available + assertThat(errorState.selectedAccount.displayName).isEqualTo("Bob") + assertThat(errorState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID) + } + } + + @Test + fun `present - the selected account is invalidated, an error is displayed and the selected rooms are cleared`() = runTest { + val roomSummary2 = aRoomSummary(roomId = A_ROOM_ID_2) + val roomListServices = mapOf( + A_SESSION_ID to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary()))) }), + A_SESSION_ID_2 to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(roomSummary2))) }), + ) + val sessionStore = aSessionStoreWithTwoAccounts() + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = sessionStore, + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListServices.getValue(sessionId))) + }, + ) + presenter.test { + val state = awaitLastSequentialItem() + state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2)) + val otherAccountState = consumeItemsUntilPredicate { + it.selectedAccount.userId == A_SESSION_ID_2 && + (it.resultState as? SearchBarResultState.Results)?.results?.map { room -> room.roomId } == listOf(A_ROOM_ID_2) + }.last() + otherAccountState.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary2.toSelectRoomInfo())) + assertThat(consumeItemsUntilPredicate { it.selectedRooms.isNotEmpty() }.last().selectedRooms).hasSize(1) + // The session of the selected account is invalidated, for instance signed out from another device + sessionStore.updateData(sessionStore.getSession(A_SESSION_ID_2.value)!!.copy(isTokenValid = false)) + val errorState = consumeItemsUntilPredicate { it.hasRoomListError && it.selectedRooms.isEmpty() }.last() + assertThat(errorState.selectedAccount.userId).isEqualTo(A_SESSION_ID_2) + assertThat(errorState.selectedAccount.displayName).isEqualTo("Bob") + assertThat(errorState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java) + // Go back to the current account: the invalid account is still proposed + errorState.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID)) + val finalState = consumeItemsUntilPredicate { it.selectedAccount.userId == A_SESSION_ID && !it.hasRoomListError }.last() + assertThat(finalState.showAccountSwitch).isTrue() + assertThat(finalState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2) + } + } + + @Test + fun `present - another account is invalidated, it is still proposed and there is no error`() = runTest { + val sessionStore = aSessionStoreWithTwoAccounts() + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = sessionStore, + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2) + sessionStore.updateData(sessionStore.getSession(A_SESSION_ID_2.value)!!.copy(isTokenValid = false)) + testScheduler.advanceUntilIdle() + // The state may not change at all + val finalState = cancelAndConsumeRemainingEvents().filterIsInstance>().lastOrNull()?.value ?: state + assertThat(finalState.selectedAccount.userId).isEqualTo(A_SESSION_ID) + assertThat(finalState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2) + assertThat(finalState.hasRoomListError).isFalse() + assertThat(finalState.showAccountSwitch).isTrue() + } + } + @Test fun `present - select another account displays the rooms of this account`() = runTest { val roomSummary1 = aRoomSummary(roomId = A_ROOM_ID) @@ -191,8 +279,8 @@ class RoomSelectPresenterTest { private fun aSessionStoreWithTwoAccounts() = InMemorySessionStore( initialList = listOf( - aSessionData(sessionId = A_SESSION_ID.value, userDisplayName = "Alice"), - aSessionData(sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob"), + aSessionData(sessionId = A_SESSION_ID.value, userDisplayName = "Alice", isTokenValid = true), + aSessionData(sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", isTokenValid = true), ), ) From 4dfb35206e0c9bcdb2898f64c353140cc3bb1735 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 7 Oct 2026 13:22:57 +0000 Subject: [PATCH 11/12] Update screenshots --- .../libraries.roomselect.impl_RoomSelectView_Day_9_en.png | 3 +++ .../libraries.roomselect.impl_RoomSelectView_Night_9_en.png | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_9_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_9_en.png diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_9_en.png new file mode 100644 index 0000000000..2625c34346 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Day_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e2d9e7c6b7b866b610df385ff5c7a61a5bb716efd4479f9a7f07e4c61e69487 +size 37335 diff --git a/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_9_en.png new file mode 100644 index 0000000000..9dc7466680 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.roomselect.impl_RoomSelectView_Night_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a327f4c2c4e3204a14a4ee601a1ddb784354a921525825868e0273f5c941f595 +size 36987 From 3a93e8089eae1733504b84fadd7a46761b98e859 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 7 Oct 2026 17:06:42 +0200 Subject: [PATCH 12/12] Get the media optimization config only once when sharing media The config is the same for all the files and all the rooms, so there is no need to read it for each file. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../android/features/share/impl/DefaultShareDataSender.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareDataSender.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareDataSender.kt index 1836bfae10..54085791f6 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareDataSender.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareDataSender.kt @@ -50,7 +50,8 @@ class DefaultShareDataSender( if (filesToShare.isEmpty()) { false } else { - val mediaOptimizationConfigProvider = mediaOptimizationConfigProviderFactory.create(client) + // The media optimization config is the same for all the files and all the rooms + val mediaOptimizationConfig = mediaOptimizationConfigProviderFactory.create(client).get() roomIds .map { roomId -> val room = client.findJoinedRoom(roomId) ?: return@map false @@ -61,7 +62,7 @@ class DefaultShareDataSender( caption = shareIntentData.text, uri = fileToShare.uri, mimeType = fileToShare.mimeType, - mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), + mediaOptimizationConfig = mediaOptimizationConfig, ) // If the coroutine was cancelled, destroy the room and rethrow the exception val cancellationException = result.exceptionOrNull() as? CancellationException