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..32e1723d99 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt @@ -33,6 +33,8 @@ import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import kotlinx.parcelize.Parcelize @@ -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/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/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/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/DefaultShareDataSender.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareDataSender.kt new file mode 100644 index 0000000000..54085791f6 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareDataSender.kt @@ -0,0 +1,102 @@ +/* + * 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 { + // 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 + 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 = mediaOptimizationConfig, + ) + // 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 1abac1afa6..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 @@ -28,6 +28,7 @@ import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectMode import kotlinx.parcelize.Parcelize @@ -39,6 +40,7 @@ class ShareNode( @Assisted plugins: List, presenterFactory: SharePresenter.Factory, private val roomSelectEntryPoint: RoomSelectEntryPoint, + private val sessionId: SessionId, ) : ParentNode( navModel = PermanentNavModel( navTargets = setOf(NavTarget), @@ -53,17 +55,17 @@ class ShareNode( data class Inputs(val shareIntentData: ShareIntentData) : NodeInputs private val inputs = inputs() - private val presenter = presenterFactory.create(inputs.shareIntentData) + private val presenter = presenterFactory.create(inputs.shareIntentData, sessionId) private val callback: ShareEntryPoint.Callback = callback() 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) { + presenter.onRoomSelected(sessionId, roomIds) } override fun onCancel() { - callback.onDone(emptyList()) + callback.onDone(sessionId, emptyList()) } } @@ -71,6 +73,7 @@ class ShareNode( parentNode = this, buildContext = buildContext, params = RoomSelectEntryPoint.Params( + sessionId = sessionId, mode = RoomSelectMode.Share, maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS, ), @@ -89,7 +92,7 @@ class ShareNode( val state = presenter.present() ShareView( state = state, - onShareSuccess = callback::onDone, + 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/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 459fa423ec..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 @@ -15,7 +15,9 @@ 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.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.roomselect.test.FakeRoomSelectEntryPoint import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.node.TestParentNode @@ -37,12 +39,13 @@ class DefaultShareEntryPointTest { ShareNode( buildContext = buildContext, plugins = plugins, - presenterFactory = { createSharePresenter() }, + presenterFactory = { _, _ -> createSharePresenter() }, roomSelectEntryPoint = FakeRoomSelectEntryPoint(), + sessionId = A_SESSION_ID, ) } val callback = object : ShareEntryPoint.Callback { - override fun onDone(roomIds: List) = lambdaError() + override fun onDone(sessionId: SessionId, roomIds: List) = lambdaError() } val params = ShareEntryPoint.Params( shareIntentData = ShareIntentData.PlainText(A_MESSAGE), 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/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, +) 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), ) } } 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..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 @@ -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,33 @@ 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.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 +64,75 @@ 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 sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = emptyList()) + // All the accounts are proposed, even the ones with an invalid token. Selecting them displays an error. + val accounts by remember { + derivedStateOf { sessions.map { it.toMatrixUser() } } + } + // 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 } + } + } + 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 + 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 (selectedSessionDataSource, hasClientError) = key(selectedSessionId) { + val dataSourceCoroutineScope = rememberCoroutineScope() + // The sessions are restored at startup, so the client should already be in memory. + // A null value means that the client is being restored, or that the session is invalid: its client cannot be restored, + // and an in-memory client would have been destroyed, so do not try to get it. + val initialRoomListService = if (isSelectedSessionInvalid) null else matrixClientProvider.getOrNull(selectedSessionId)?.roomListService + val roomListServiceResult by produceState(initialRoomListService?.let { Result.success(it) }) { + if (value == null && !isSelectedSessionInvalid) { + value = matrixClientProvider.getOrRestore(selectedSessionId) + .onFailure { Timber.e(it, "Failed to get the client for $selectedSessionId") } + .map { it.roomListService } + } + } + val roomListService = roomListServiceResult?.getOrNull() + val dataSource = remember(roomListService) { + roomListService?.let { dataSourceFactory.create(dataSourceCoroutineScope, it) } + } + dataSource to (roomListServiceResult?.isFailure == true) + } + // Do not use the data source of an invalid session, its client has been destroyed + val dataSource = selectedSessionDataSource.takeIf { !isSelectedSessionInvalid } + val hasRoomListError = hasClientError || isSelectedSessionInvalid - val 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 +153,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 +174,10 @@ class RoomSelectPresenter( searchQuery = queryState, 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/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..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 @@ -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,19 @@ data class RoomSelectState( val searchQuery: TextFieldState, 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, ) { 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..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 @@ -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,25 @@ open class RoomSelectStatePreviewParam : PreviewParameterProvider = persistentListOf(), + selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")), + hasRoomListError: Boolean = false, + otherAccounts: ImmutableList = persistentListOf(), + isAccountListExpanded: Boolean = false, eventSink: (RoomSelectEvent) -> Unit = {}, ) = RoomSelectState( mode = mode, @@ -65,9 +90,18 @@ internal fun aRoomSelectState( searchQuery = TextFieldState(initialText = searchQuery), isSearchActive = isSearchActive, selectedRooms = selectedRooms, + selectedAccount = selectedAccount, + hasRoomListError = hasRoomListError, + 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..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 @@ -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 @@ -29,27 +33,42 @@ 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 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.input.nestedscroll.nestedScroll 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.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 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 +79,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 +91,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 +137,13 @@ 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() } + // 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)) } @@ -145,6 +177,7 @@ fun RoomSelectView( Modifier .padding(paddingValues) .consumeWindowInsets(paddingValues) + .nestedScroll(accountSwitchScrollBehavior.nestedScrollConnection) ) { SearchBar( modifier = Modifier @@ -161,6 +194,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 +211,53 @@ 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) { + // 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, + // showVerticalSpace only if there is no other accounts + showVerticalSpace = !state.showAccountSwitch, + ) + if (state.hasRoomListError) { + RoomListError() + } else if (state.resultState is SearchBarResultState.Results) { + 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, isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, @@ -181,34 +266,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 +274,114 @@ 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 + */ +@Composable +private fun AccountSwitchSection( + selectedAccount: MatrixUser, + otherAccounts: ImmutableList, + isExpanded: Boolean, + onToggleExpand: () -> Unit, + onSelectAccount: (SessionId) -> Unit, +) { + Column { + 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 +406,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 +446,6 @@ private fun RoomSummaryView( ) } } - Checkbox( - checked = isSelected, - enabled = isSelected || canBeSelected, - onCheckedChange = { onSelection(roomInfo) }, - ) } } 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/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..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,18 +9,34 @@ 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 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 +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.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 @@ -46,9 +62,228 @@ 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() + 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() + } + } + + @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 - 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) + 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 { + // 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 = 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() + 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", isTokenValid = true), + aSessionData(sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", isTokenValid = true), + ), + ) + @Test fun `present - toggle search active`() = runTest { val presenter = createRoomSelectPresenter() @@ -157,16 +392,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..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 @@ -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,63 @@ 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() + } + + @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/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_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_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 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 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" : [