Merge pull request #7839 from element-hq/feature/bma/multiAccountShare

Multi account: switch account from the room select screen when sharing
This commit is contained in:
Benoit Marty
2026-10-07 18:01:48 +02:00
committed by GitHub
58 changed files with 1265 additions and 355 deletions
@@ -33,6 +33,8 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.matrix.api.MatrixClient 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 io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@@ -59,6 +61,7 @@ class LoggedInAppScopeFlowNode(
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToBugReport() fun navigateToBugReport()
fun navigateToAddAccount() fun navigateToAddAccount()
fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?)
} }
private val callback: Callback = callback() private val callback: Callback = callback()
@@ -91,6 +94,10 @@ class LoggedInAppScopeFlowNode(
override fun navigateToAddAccount() { override fun navigateToAddAccount() {
callback.navigateToAddAccount() callback.navigateToAddAccount()
} }
override fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) {
callback.switchAccountAndOpenRoom(sessionId, roomId)
}
} }
return createNode<LoggedInFlowNode>(buildContext, listOf(callback)) return createNode<LoggedInFlowNode>(buildContext, listOf(callback))
} }
@@ -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.EventId
import io.element.android.libraries.matrix.api.core.RoomId 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.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.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
@@ -173,6 +174,7 @@ class LoggedInFlowNode(
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToBugReport() fun navigateToBugReport()
fun navigateToAddAccount() fun navigateToAddAccount()
fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?)
} }
private val callback: Callback = callback() private val callback: Callback = callback()
@@ -574,10 +576,16 @@ class LoggedInFlowNode(
buildContext = buildContext, buildContext = buildContext,
params = ShareEntryPoint.Params(shareIntentData = navTarget.shareIntentData), params = ShareEntryPoint.Params(shareIntentData = navTarget.shareIntentData),
callback = object : ShareEntryPoint.Callback { callback = object : ShareEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) { override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) {
// Remove the incoming share screen // Remove the incoming share screen
backstack.pop() 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 // Navigate to the room if the text/media was shared to a single one
roomIds.singleOrNull()?.let { roomId -> roomIds.singleOrNull()?.let { roomId ->
lifecycleScope.launch { lifecycleScope.launch {
@@ -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.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider 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.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.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.asEventId import io.element.android.libraries.matrix.api.core.asEventId
@@ -349,8 +350,7 @@ class RootFlowNode(
@Parcelize data class AccountSelect( @Parcelize data class AccountSelect(
val currentSessionId: SessionId, val currentSessionId: SessionId,
val shareIntentData: ShareIntentData?, val permalinkData: PermalinkData,
val permalinkData: PermalinkData?,
) : NavTarget ) : NavTarget
@Parcelize data class NotLoggedInFlow( @Parcelize data class NotLoggedInFlow(
@@ -389,6 +389,15 @@ class RootFlowNode(
override fun navigateToAddAccount() { override fun navigateToAddAccount() {
backstack.push(NavTarget.NotLoggedInFlow(null)) 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 savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap)
val buildContext = if (savedNavState != null) { val buildContext = if (savedNavState != null) {
@@ -463,13 +472,7 @@ class RootFlowNode(
// Do not pop when the account is changed to avoid a UI flicker. // Do not pop when the account is changed to avoid a UI flicker.
backstack.pop() backstack.pop()
} }
attachSession(sessionId).apply { attachSession(sessionId).attachPermalinkData(navTarget.permalinkData)
if (navTarget.shareIntentData != null) {
attachIncomingShare(navTarget.shareIntentData)
} else if (navTarget.permalinkData != null) {
attachPermalinkData(navTarget.permalinkData)
}
}
} }
} }
@@ -539,21 +542,9 @@ class RootFlowNode(
// No session, open login // No session, open login
switchToNotLoggedInFlow(null) switchToNotLoggedInFlow(null)
} else { } else {
// wait for the current session to be restored // wait for the current session to be restored, then attach the incoming share node.
val loggedInFlowNode = attachSession(latestSessionId) // In case of multiple accounts, the user can switch account from the room select screen.
if (sessionStore.numberOfSessions() > 1) { attachSession(latestSessionId).attachIncomingShare(shareIntentData)
// 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)
}
} }
} }
@@ -576,7 +567,6 @@ class RootFlowNode(
backstack.push( backstack.push(
NavTarget.AccountSelect( NavTarget.AccountSelect(
currentSessionId = latestSessionId, currentSessionId = latestSessionId,
shareIntentData = null,
permalinkData = permalinkData, permalinkData = permalinkData,
) )
) )
@@ -28,6 +28,7 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode import io.element.android.libraries.roomselect.api.RoomSelectMode
@@ -40,6 +41,7 @@ class ForwardMessagesNode(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: ForwardMessagesPresenter.Factory, presenterFactory: ForwardMessagesPresenter.Factory,
private val roomSelectEntryPoint: RoomSelectEntryPoint, private val roomSelectEntryPoint: RoomSelectEntryPoint,
private val sessionId: SessionId,
) : ParentNode<ForwardMessagesNode.NavTarget>( ) : ParentNode<ForwardMessagesNode.NavTarget>(
navModel = PermanentNavModel( navModel = PermanentNavModel(
navTargets = setOf(NavTarget), navTargets = setOf(NavTarget),
@@ -62,7 +64,8 @@ class ForwardMessagesNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
val callback = object : RoomSelectEntryPoint.Callback { val callback = object : RoomSelectEntryPoint.Callback {
override fun onRoomSelected(roomIds: List<RoomId>) { override fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) {
// In Forward mode, the session cannot be changed
presenter.onRoomSelected(roomIds) presenter.onRoomSelected(roomIds)
} }
@@ -75,6 +78,7 @@ class ForwardMessagesNode(
parentNode = this, parentNode = this,
buildContext = buildContext, buildContext = buildContext,
params = RoomSelectEntryPoint.Params( params = RoomSelectEntryPoint.Params(
sessionId = sessionId,
mode = RoomSelectMode.Forward, mode = RoomSelectMode.Forward,
maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS, maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
), ),
@@ -15,6 +15,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.forward.api.ForwardEntryPoint import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId 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.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.matrix.test.timeline.FakeTimelineProvider
import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
@@ -39,6 +40,7 @@ class DefaultForwardEntryPointTest {
plugins = plugins, plugins = plugins,
presenterFactory = { _, _ -> createForwardMessagesPresenter() }, presenterFactory = { _, _ -> createForwardMessagesPresenter() },
roomSelectEntryPoint = FakeRoomSelectEntryPoint(), roomSelectEntryPoint = FakeRoomSelectEntryPoint(),
sessionId = A_SESSION_ID,
) )
} }
val callback = object : ForwardEntryPoint.Callback { val callback = object : ForwardEntryPoint.Callback {
@@ -8,8 +8,8 @@
package io.element.android.features.home.impl 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.MatrixUser
import io.element.android.libraries.matrix.api.user.toMatrixUser
import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionData
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -32,12 +32,7 @@ class CurrentUserWithNeighborsBuilder {
matrixUser matrixUser
} else { } else {
// Use the data from the DB // Use the data from the DB
MatrixUser( it.toMatrixUser()
userId = UserId(it.userId),
displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl,
avatarThumbnail = it.userAvatarData,
)
} }
} }
.let { sessionList -> .let { sessionList ->
@@ -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.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient 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.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@@ -85,14 +84,7 @@ class PreferencesRootPresenter(
sessionStore.sessionsFlow().map { list -> sessionStore.sessionsFlow().map { list ->
list list
.filter { it.userId != matrixClient.sessionId.value } .filter { it.userId != matrixClient.sessionId.value }
.map { .map { it.toMatrixUser() }
MatrixUser(
userId = UserId(it.userId),
displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl,
avatarThumbnail = it.userAvatarData,
)
}
.toImmutableList() .toImmutableList()
} }
}.collectAsState(initial = persistentListOf()) }.collectAsState(initial = persistentListOf())
@@ -13,6 +13,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
interface ShareEntryPoint : FeatureEntryPoint { interface ShareEntryPoint : FeatureEntryPoint {
data class Params(val shareIntentData: ShareIntentData) data class Params(val shareIntentData: ShareIntentData)
@@ -25,6 +26,11 @@ interface ShareEntryPoint : FeatureEntryPoint {
): Node ): Node
interface Callback : Plugin { interface Callback : Plugin {
fun onDone(roomIds: List<RoomId>) /**
* 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<RoomId>)
} }
} }
@@ -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<RoomId>,
): Result<Unit> = 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)
}
}
@@ -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<RoomId>): Result<Unit>
}
@@ -28,6 +28,7 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId 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.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@@ -39,6 +40,7 @@ class ShareNode(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: SharePresenter.Factory, presenterFactory: SharePresenter.Factory,
private val roomSelectEntryPoint: RoomSelectEntryPoint, private val roomSelectEntryPoint: RoomSelectEntryPoint,
private val sessionId: SessionId,
) : ParentNode<ShareNode.NavTarget>( ) : ParentNode<ShareNode.NavTarget>(
navModel = PermanentNavModel( navModel = PermanentNavModel(
navTargets = setOf(NavTarget), navTargets = setOf(NavTarget),
@@ -53,17 +55,17 @@ class ShareNode(
data class Inputs(val shareIntentData: ShareIntentData) : NodeInputs data class Inputs(val shareIntentData: ShareIntentData) : NodeInputs
private val inputs = inputs<Inputs>() private val inputs = inputs<Inputs>()
private val presenter = presenterFactory.create(inputs.shareIntentData) private val presenter = presenterFactory.create(inputs.shareIntentData, sessionId)
private val callback: ShareEntryPoint.Callback = callback() private val callback: ShareEntryPoint.Callback = callback()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
val callback = object : RoomSelectEntryPoint.Callback { val callback = object : RoomSelectEntryPoint.Callback {
override fun onRoomSelected(roomIds: List<RoomId>) { override fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) {
presenter.onRoomSelected(roomIds) presenter.onRoomSelected(sessionId, roomIds)
} }
override fun onCancel() { override fun onCancel() {
callback.onDone(emptyList()) callback.onDone(sessionId, emptyList())
} }
} }
@@ -71,6 +73,7 @@ class ShareNode(
parentNode = this, parentNode = this,
buildContext = buildContext, buildContext = buildContext,
params = RoomSelectEntryPoint.Params( params = RoomSelectEntryPoint.Params(
sessionId = sessionId,
mode = RoomSelectMode.Share, mode = RoomSelectMode.Share,
maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS, maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
), ),
@@ -89,7 +92,7 @@ class ShareNode(
val state = presenter.present() val state = presenter.present()
ShareView( ShareView(
state = state, state = state,
onShareSuccess = callback::onDone, onShareSuccess = { roomIds -> callback.onDone(state.sessionId, roomIds) },
) )
} }
} }
@@ -10,47 +10,54 @@ package io.element.android.features.share.impl
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject 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.features.share.api.ShareIntentData
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.matrix.api.MatrixClientProvider
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.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.core.SessionId
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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@AssistedInject @AssistedInject
class SharePresenter( class SharePresenter(
@Assisted private val shareIntentData: ShareIntentData, @Assisted private val shareIntentData: ShareIntentData,
@SessionCoroutineScope @Assisted initialSessionId: SessionId,
private val sessionCoroutineScope: CoroutineScope, @AppCoroutineScope
private val matrixClient: MatrixClient, private val appCoroutineScope: CoroutineScope,
private val mediaSenderRoomFactory: MediaSenderRoomFactory, private val matrixClientProvider: MatrixClientProvider,
private val activeRoomsHolder: ActiveRoomsHolder, private val shareDataSender: ShareDataSender,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val onSharedData: OnSharedData,
) : Presenter<ShareState> { ) : Presenter<ShareState> {
@AssistedFactory @AssistedFactory
fun interface Factory { fun interface Factory {
fun create(shareIntentData: ShareIntentData): SharePresenter fun create(shareIntentData: ShareIntentData, initialSessionId: SessionId): SharePresenter
} }
private val shareActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized) private val shareActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)
fun onRoomSelected(roomIds: List<RoomId>) { /**
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<RoomId>) {
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 @Composable
@@ -62,80 +69,9 @@ class SharePresenter(
} }
return ShareState( return ShareState(
sessionId = targetSessionId,
shareAction = shareActionState.value, shareAction = shareActionState.value,
eventSink = ::handleEvent, 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<RoomId>,
) = 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)
}
} }
@@ -10,8 +10,10 @@ package io.element.android.features.share.impl
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
data class ShareState( data class ShareState(
val sessionId: SessionId,
val shareAction: AsyncAction<List<RoomId>>, val shareAction: AsyncAction<List<RoomId>>,
val eventSink: (ShareEvent) -> Unit val eventSink: (ShareEvent) -> Unit
) )
@@ -11,6 +11,7 @@ package io.element.android.features.share.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
open class ShareStatePreviewParam : PreviewParameterProvider<ShareState> { open class ShareStatePreviewParam : PreviewParameterProvider<ShareState> {
override val values: Sequence<ShareState> override val values: Sequence<ShareState>
@@ -31,9 +32,11 @@ open class ShareStatePreviewParam : PreviewParameterProvider<ShareState> {
} }
fun aShareState( fun aShareState(
sessionId: SessionId = SessionId("@alice:server.org"),
shareAction: AsyncAction<List<RoomId>> = AsyncAction.Uninitialized, shareAction: AsyncAction<List<RoomId>> = AsyncAction.Uninitialized,
eventSink: (ShareEvent) -> Unit = {} eventSink: (ShareEvent) -> Unit = {}
) = ShareState( ) = ShareState(
sessionId = sessionId,
shareAction = shareAction, shareAction = shareAction,
eventSink = eventSink eventSink = eventSink
) )
@@ -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<ShareIntentData, Unit> { }
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<ShareIntentData, Unit> { }
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<Unit>> { 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,
)
@@ -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.ShareEntryPoint
import io.element.android.features.share.api.ShareIntentData 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.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_MESSAGE
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode import io.element.android.tests.testutils.node.TestParentNode
@@ -37,12 +39,13 @@ class DefaultShareEntryPointTest {
ShareNode( ShareNode(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
presenterFactory = { createSharePresenter() }, presenterFactory = { _, _ -> createSharePresenter() },
roomSelectEntryPoint = FakeRoomSelectEntryPoint(), roomSelectEntryPoint = FakeRoomSelectEntryPoint(),
sessionId = A_SESSION_ID,
) )
} }
val callback = object : ShareEntryPoint.Callback { val callback = object : ShareEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) = lambdaError() override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) = lambdaError()
} }
val params = ShareEntryPoint.Params( val params = ShareEntryPoint.Params(
shareIntentData = ShareIntentData.PlainText(A_MESSAGE), shareIntentData = ShareIntentData.PlainText(A_MESSAGE),
@@ -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<RoomId>) -> Result<Unit> = { _, _, _ -> lambdaError() },
) : ShareDataSender {
override suspend fun send(client: MatrixClient, shareIntentData: ShareIntentData, roomIds: List<RoomId>): Result<Unit> {
return sendResult(client, shareIntentData, roomIds)
}
}
@@ -8,37 +8,32 @@
package io.element.android.features.share.impl package io.element.android.features.share.impl
import android.net.Uri
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat 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.ShareIntentData
import io.element.android.features.share.api.UriToShare
import io.element.android.libraries.architecture.AsyncAction 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.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_MESSAGE
import io.element.android.libraries.matrix.test.A_ROOM_ID 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.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
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.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder 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.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
class SharePresenterTest : RobolectricTest() { class SharePresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@@ -49,22 +44,25 @@ class SharePresenterTest : RobolectricTest() {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.sessionId).isEqualTo(A_SESSION_ID)
assertThat(initialState.shareAction.isUninitialized()).isTrue() assertThat(initialState.shareAction.isUninitialized()).isTrue()
} }
} }
@Test @Test
fun `present - on room selected error then clear error`() = runTest { 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) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.shareAction.isUninitialized()).isTrue() 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() assertThat(awaitItem().shareAction.isLoading()).isTrue()
val failure = awaitItem() val failure = awaitItem()
assertThat(failure.shareAction.isFailure()).isTrue() assertThat(failure.shareAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
failure.eventSink.invoke(ShareEvent.ClearError) failure.eventSink.invoke(ShareEvent.ClearError)
assertThat(awaitItem().shareAction.isUninitialized()).isTrue() assertThat(awaitItem().shareAction.isUninitialized()).isTrue()
} }
@@ -72,113 +70,115 @@ class SharePresenterTest : RobolectricTest() {
@Test @Test
fun `present - on room selected ok`() = runTest { fun `present - on room selected ok`() = runTest {
val joinedRoom = FakeJoinedRoom( val client = FakeMatrixClient(sessionId = A_SESSION_ID)
liveTimeline = FakeTimeline().apply { val shareIntentData = ShareIntentData.PlainText(A_MESSAGE)
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) } val sendResult = lambdaRecorder<MatrixClient, ShareIntentData, List<RoomId>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
},
)
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, joinedRoom)
}
val presenter = createSharePresenter( val presenter = createSharePresenter(
matrixClient = matrixClient, shareIntentData = shareIntentData,
shareIntentData = ShareIntentData.PlainText(A_MESSAGE), matrixClientProvider = FakeMatrixClientProvider { Result.success(client) },
shareDataSender = FakeShareDataSender(sendResult),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.shareAction.isUninitialized()).isTrue() 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() assertThat(awaitItem().shareAction.isLoading()).isTrue()
val success = awaitItem() 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))) assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
sendResult.assertions().isCalledOnce().with(value(client), value(shareIntentData), value(listOf(A_ROOM_ID)))
} }
} }
@Test @Test
fun `present - send text ok`() = runTest { fun `present - on room selected from another session ok`() = runTest {
val joinedRoom = FakeJoinedRoom( val getClient = lambdaRecorder<SessionId, Result<MatrixClient>> { sessionId -> Result.success(FakeMatrixClient(sessionId = sessionId)) }
liveTimeline = FakeTimeline().apply { val sendResult = lambdaRecorder<MatrixClient, ShareIntentData, List<RoomId>, Result<Unit>> { client, _, _ ->
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) } assertThat(client.sessionId).isEqualTo(A_SESSION_ID_2)
}, Result.success(Unit)
)
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, joinedRoom)
} }
val presenter = createSharePresenter( val presenter = createSharePresenter(
matrixClient = matrixClient, matrixClientProvider = FakeMatrixClientProvider { getClient(it) },
shareIntentData = ShareIntentData.PlainText(A_MESSAGE), shareDataSender = FakeShareDataSender(sendResult),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.shareAction.isUninitialized()).isTrue() assertThat(initialState.sessionId).isEqualTo(A_SESSION_ID)
presenter.onRoomSelected(listOf(A_ROOM_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() assertThat(awaitItem().shareAction.isLoading()).isTrue()
val success = awaitItem() 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))) assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
getClient.assertions().isCalledOnce().with(value(A_SESSION_ID_2))
sendResult.assertions().isCalledOnce()
} }
} }
@Test @Test
fun `present - send media ok`() = runTest { fun `present - on room selected, failure to get the client`() = runTest {
val sendMediaResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) } val sendResult = lambdaRecorder<MatrixClient, ShareIntentData, List<RoomId>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val joinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline(),
)
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, joinedRoom)
}
val mediaSender = FakeMediaSender(
sendMediaResult = sendMediaResult,
)
val presenter = createSharePresenter( val presenter = createSharePresenter(
matrixClient = matrixClient, matrixClientProvider = FakeMatrixClientProvider { Result.failure(AN_EXCEPTION) },
shareIntentData = ShareIntentData.Uris( shareDataSender = FakeShareDataSender(sendResult),
text = A_MESSAGE,
listOf(
UriToShare(
uri = Uri.parse("content://image.jpg"),
mimeType = MimeTypes.Jpeg,
)
)
),
mediaSenderRoomFactory = MediaSenderRoomFactory { mediaSender },
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() skipItems(1)
assertThat(initialState.shareAction.isUninitialized()).isTrue() presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID))
presenter.onRoomSelected(listOf(A_ROOM_ID)) assertThat(awaitItem().sessionId).isEqualTo(A_SESSION_ID_2)
assertThat(awaitItem().shareAction.isLoading()).isTrue() 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<Result<Unit>>()
val sendResult = lambdaRecorder<MatrixClient, ShareIntentData, List<RoomId>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val presenter = createSharePresenter(
shareDataSender = object : ShareDataSender {
override suspend fun send(client: MatrixClient, shareIntentData: ShareIntentData, roomIds: List<RoomId>): Result<Unit> {
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() 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))) assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
sendMediaResult.assertions().isCalledOnce() sendResult.assertions().isCalledOnce()
} }
} }
} }
internal fun TestScope.createSharePresenter( internal fun TestScope.createSharePresenter(
shareIntentData: ShareIntentData = ShareIntentData.PlainText(A_MESSAGE), shareIntentData: ShareIntentData = ShareIntentData.PlainText(A_MESSAGE),
matrixClient: MatrixClient = FakeMatrixClient(), initialSessionId: SessionId = A_SESSION_ID,
activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(), matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { FakeMediaSender() }, shareDataSender: ShareDataSender = FakeShareDataSender(),
mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
onSharedData: OnSharedData = OnSharedData {},
): SharePresenter { ): SharePresenter {
return SharePresenter( return SharePresenter(
shareIntentData = shareIntentData, shareIntentData = shareIntentData,
sessionCoroutineScope = this, initialSessionId = initialSessionId,
matrixClient = matrixClient, appCoroutineScope = this,
activeRoomsHolder = activeRoomsHolder, matrixClientProvider = matrixClientProvider,
mediaSenderRoomFactory = mediaSenderRoomFactory, shareDataSender = shareDataSender,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
onSharedData = onSharedData,
) )
} }
@@ -19,7 +19,7 @@ import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.SessionId 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.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 io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
@@ -58,11 +58,4 @@ class SignedOutPresenter(
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
private fun SessionData.toMatrixUser() = MatrixUser(
userId = sessionId,
displayName = userDisplayName,
avatarUrl = userAvatarUrl,
avatarThumbnail = userAvatarData,
)
} }
@@ -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.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.SessionId 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.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.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationFlowState
@@ -101,16 +102,7 @@ class IncomingVerificationPresenter(
} }
val currentUser by remember { val currentUser by remember {
sessionStore.sessionsFlow().mapNotNull { sessions -> sessionStore.sessionsFlow().mapNotNull { sessions ->
sessions.find { it.userId == sessionId.value }?.let { sessionData -> sessions.find { it.userId == sessionId.value }?.toMatrixUser()
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,
)
}
} }
}.collectAsState(MatrixUser(sessionId)) }.collectAsState(MatrixUser(sessionId))
val step by remember { val step by remember {
@@ -13,8 +13,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter 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.MatrixUser
import io.element.android.libraries.matrix.api.user.toMatrixUser
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@@ -29,14 +29,7 @@ class AccountSelectPresenter(
val accounts by produceState<ImmutableList<MatrixUser>>(persistentListOf()) { val accounts by produceState<ImmutableList<MatrixUser>>(persistentListOf()) {
// Do not use sessionStore.sessionsFlow() to not make it change when an account is selected. // Do not use sessionStore.sessionsFlow() to not make it change when an account is selected.
value = sessionStore.getAllSessions() value = sessionStore.getAllSessions()
.map { .map { it.toMatrixUser() }
MatrixUser(
userId = UserId(it.userId),
displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl,
avatarThumbnail = it.userAvatarData,
)
}
.toImmutableList() .toImmutableList()
} }
+1
View File
@@ -54,6 +54,7 @@ dependencies {
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(projects.libraries.sessionStorage.api)
testCommonDependencies(libs) testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
@@ -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,
)
@@ -8,6 +8,8 @@
package io.element.android.libraries.mediaupload.api 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. * 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. */ /** Returns the configuration to pass to the pre-processing and sending calls. */
suspend fun get(): MediaOptimizationConfig 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
}
@@ -9,6 +9,7 @@
package io.element.android.libraries.mediaupload.api package io.element.android.libraries.mediaupload.api
import android.net.Uri 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.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.Timeline 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. * 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. * @param room the room the media should be sent to.
*/ */
fun create( fun create(
client: MatrixClient,
room: JoinedRoom, room: JoinedRoom,
): MediaSender ): MediaSender
} }
@@ -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,
)
}
}
@@ -9,12 +9,13 @@
package io.element.android.libraries.mediaupload.impl package io.element.android.libraries.mediaupload.impl
import android.net.Uri import android.net.Uri
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.hash.hash import io.element.android.libraries.androidutils.hash.hash
import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.flatMapCatching import io.element.android.libraries.core.extensions.flatMapCatching
import io.element.android.libraries.di.RoomScope 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.core.EventId
import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.room.CreateTimelineParams 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.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider 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.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaSenderFactory import io.element.android.libraries.mediaupload.api.MediaSenderFactory
@@ -52,19 +54,20 @@ class DefaultMediaSenderFactory(
} }
} }
@ContributesBinding(SessionScope::class) @ContributesBinding(AppScope::class)
class DefaultMediaSenderRoomFactory( class DefaultMediaSenderRoomFactory(
private val preProcessor: MediaPreProcessor, private val preProcessor: MediaPreProcessor,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, private val mediaOptimizationConfigProviderFactory: MediaOptimizationConfigProviderFactory,
) : MediaSenderRoomFactory { ) : MediaSenderRoomFactory {
override fun create( override fun create(
client: MatrixClient,
room: JoinedRoom, room: JoinedRoom,
): MediaSender { ): MediaSender {
return DefaultMediaSender( return DefaultMediaSender(
preProcessor = preProcessor, preProcessor = preProcessor,
room = room, room = room,
timelineMode = Timeline.Mode.Live, timelineMode = Timeline.Mode.Live,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, mediaOptimizationConfigProvider = mediaOptimizationConfigProviderFactory.create(client),
) )
} }
} }
@@ -13,9 +13,16 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
interface RoomSelectEntryPoint : FeatureEntryPoint { 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( data class Params(
val sessionId: SessionId,
val mode: RoomSelectMode, val mode: RoomSelectMode,
val maxNumberOfRooms: Int, val maxNumberOfRooms: Int,
) )
@@ -28,7 +35,11 @@ interface RoomSelectEntryPoint : FeatureEntryPoint {
): Node ): Node
interface Callback : Plugin { interface Callback : Plugin {
fun onRoomSelected(roomIds: List<RoomId>) /**
* @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<RoomId>)
fun onCancel() fun onCancel()
} }
@@ -32,10 +32,12 @@ dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
api(projects.libraries.roomselect.api) api(projects.libraries.roomselect.api)
testCommonDependencies(libs, includeTestComposeView = true) testCommonDependencies(libs, includeTestComposeView = true)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.sessionStorage.test)
} }
@@ -10,12 +10,12 @@ package io.element.android.libraries.roomselect.impl
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
@ContributesBinding(SessionScope::class) @ContributesBinding(AppScope::class)
class DefaultRoomSelectEntryPoint : RoomSelectEntryPoint { class DefaultRoomSelectEntryPoint : RoomSelectEntryPoint {
override fun createNode( override fun createNode(
parentNode: Node, parentNode: Node,
@@ -27,6 +27,7 @@ class DefaultRoomSelectEntryPoint : RoomSelectEntryPoint {
buildContext = buildContext, buildContext = buildContext,
plugins = listOf( plugins = listOf(
RoomSelectNode.Inputs( RoomSelectNode.Inputs(
sessionId = params.sessionId,
mode = params.mode, mode = params.mode,
maxNumberOfRooms = params.maxNumberOfRooms, maxNumberOfRooms = params.maxNumberOfRooms,
), ),
@@ -8,10 +8,13 @@
package io.element.android.libraries.roomselect.impl 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 import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
sealed interface RoomSelectEvent { sealed interface RoomSelectEvent {
data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent
data object ToggleSearchActive : RoomSelectEvent data object ToggleSearchActive : RoomSelectEvent
data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent
data object ToggleAccountListExpanded : RoomSelectEvent
data class SelectAccount(val sessionId: SessionId) : RoomSelectEvent
} }
@@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode 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.appyx.launchMolecule
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs 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.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode import io.element.android.libraries.roomselect.api.RoomSelectMode
@ContributesNode(SessionScope::class) @ContributesNode(AppScope::class)
@AssistedInject @AssistedInject
class RoomSelectNode( class RoomSelectNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@@ -34,12 +35,14 @@ class RoomSelectNode(
presenterFactory: RoomSelectPresenter.Factory, presenterFactory: RoomSelectPresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
data class Inputs( data class Inputs(
val sessionId: SessionId,
val mode: RoomSelectMode, val mode: RoomSelectMode,
val maxNumberOfRooms: Int, val maxNumberOfRooms: Int,
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create( private val presenter = presenterFactory.create(
initialSessionId = inputs.sessionId,
mode = inputs.mode, mode = inputs.mode,
maxNumberOfRooms = inputs.maxNumberOfRooms, maxNumberOfRooms = inputs.maxNumberOfRooms,
) )
@@ -52,7 +55,7 @@ class RoomSelectNode(
RoomSelectView( RoomSelectView(
state = state, state = state,
onDismiss = callback::onCancel, onDismiss = callback::onCancel,
onSubmit = callback::onRoomSelected, onSubmit = { roomIds -> callback.onRoomSelected(state.selectedAccount.userId, roomIds) },
modifier = modifier modifier = modifier
) )
} }
@@ -8,6 +8,7 @@
package io.element.android.libraries.roomselect.impl package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -15,7 +16,9 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -24,22 +27,33 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState 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.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode 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.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
@AssistedInject @AssistedInject
class RoomSelectPresenter( class RoomSelectPresenter(
@Assisted private val initialSessionId: SessionId,
@Assisted private val mode: RoomSelectMode, @Assisted private val mode: RoomSelectMode,
@Assisted private val maxNumberOfRooms: Int, @Assisted private val maxNumberOfRooms: Int,
private val dataSourceFactory: RoomSelectSearchDataSource.Factory, private val dataSourceFactory: RoomSelectSearchDataSource.Factory,
private val matrixClientProvider: MatrixClientProvider,
private val sessionStore: SessionStore,
) : Presenter<RoomSelectState> { ) : Presenter<RoomSelectState> {
@AssistedFactory @AssistedFactory
fun interface Factory { fun interface Factory {
fun create( fun create(
initialSessionId: SessionId,
mode: RoomSelectMode, mode: RoomSelectMode,
maxNumberOfRooms: Int, maxNumberOfRooms: Int,
): RoomSelectPresenter ): RoomSelectPresenter
@@ -50,18 +64,75 @@ class RoomSelectPresenter(
var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) } var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) }
val queryState = rememberTextFieldState() val queryState = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) } var isSearchActive by remember { mutableStateOf(false) }
var isAccountListExpanded by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope() var selectedSessionId by remember { mutableStateOf(initialSessionId) }
val dataSource = remember { dataSourceFactory.create(coroutineScope) } 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 searchQuery = queryState.text.toString() val accounts by remember {
LaunchedEffect(searchQuery) { derivedStateOf { sessions.map { it.toMatrixUser() } }
dataSource.setSearchQuery(searchQuery) }
// 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<State<SearchBarResultState<ImmutableList<SelectRoomInfo>>>> { 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<State<SearchBarResultState<ImmutableList<SelectRoomInfo>>>>(dataSource) {
derivedStateOf { derivedStateOf {
when { when {
roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList()) roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList())
@@ -82,8 +153,16 @@ class RoomSelectPresenter(
} }
} }
RoomSelectEvent.ToggleSearchActive -> isSearchActive = !isSearchActive 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 { is RoomSelectEvent.UpdateVisibleRange -> coroutineScope.launch {
dataSource.updateVisibleRange(event.range) dataSource?.updateVisibleRange(event.range)
} }
} }
} }
@@ -95,6 +174,10 @@ class RoomSelectPresenter(
searchQuery = queryState, searchQuery = queryState,
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
selectedRooms = selectedRooms, selectedRooms = selectedRooms,
selectedAccount = selectedAccount,
hasRoomListError = hasRoomListError,
otherAccounts = otherAccounts,
isAccountListExpanded = isAccountListExpanded,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@@ -36,12 +36,12 @@ private const val PAGE_SIZE = 30
@AssistedInject @AssistedInject
class RoomSelectSearchDataSource( class RoomSelectSearchDataSource(
@Assisted coroutineScope: CoroutineScope, @Assisted coroutineScope: CoroutineScope,
roomListService: RoomListService, @Assisted roomListService: RoomListService,
coroutineDispatchers: CoroutineDispatchers, coroutineDispatchers: CoroutineDispatchers,
) { ) {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(coroutineScope: CoroutineScope): RoomSelectSearchDataSource fun create(coroutineScope: CoroutineScope, roomListService: RoomListService): RoomSelectSearchDataSource
} }
private val roomList = roomListService.createRoomList( private val roomList = roomListService.createRoomList(
@@ -10,6 +10,7 @@ package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState 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.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -21,7 +22,19 @@ data class RoomSelectState(
val searchQuery: TextFieldState, val searchQuery: TextFieldState,
val isSearchActive: Boolean, val isSearchActive: Boolean,
val selectedRooms: ImmutableList<SelectRoomInfo>, val selectedRooms: ImmutableList<SelectRoomInfo>,
val selectedAccount: MatrixUser,
/**
* True if the rooms of the selected account cannot be loaded.
*/
val hasRoomListError: Boolean,
val otherAccounts: ImmutableList<MatrixUser>,
val isAccountListExpanded: Boolean,
val eventSink: (RoomSelectEvent) -> Unit, val eventSink: (RoomSelectEvent) -> Unit,
) { ) {
val canSelectMoreRooms = selectedRooms.size < maxNumberOfRooms 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()
} }
@@ -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.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomAlias 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.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.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
@@ -47,6 +49,25 @@ open class RoomSelectStatePreviewParam : PreviewParameterProvider<RoomSelectStat
selectedRooms = aRoomSelectRoomList().subList(0, 1), selectedRooms = aRoomSelectRoomList().subList(0, 1),
maxNumberOfRooms = 1, maxNumberOfRooms = 1,
), ),
aRoomSelectState(
mode = RoomSelectMode.Share,
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
selectedAccount = aMatrixUser(id = "@alice:example.org", displayName = "Alice"),
otherAccounts = anOtherAccountList(),
),
aRoomSelectState(
mode = RoomSelectMode.Share,
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
selectedAccount = aMatrixUser(id = "@alice:example.org", displayName = "Alice"),
otherAccounts = anOtherAccountList(),
isAccountListExpanded = true,
),
aRoomSelectState(
mode = RoomSelectMode.Share,
selectedAccount = aMatrixUser(id = "@alice:example.org", displayName = "Alice"),
otherAccounts = anOtherAccountList(),
hasRoomListError = true,
),
) )
} }
@@ -57,6 +78,10 @@ internal fun aRoomSelectState(
searchQuery: String = "", searchQuery: String = "",
isSearchActive: Boolean = false, isSearchActive: Boolean = false,
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(), selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")),
hasRoomListError: Boolean = false,
otherAccounts: ImmutableList<MatrixUser> = persistentListOf(),
isAccountListExpanded: Boolean = false,
eventSink: (RoomSelectEvent) -> Unit = {}, eventSink: (RoomSelectEvent) -> Unit = {},
) = RoomSelectState( ) = RoomSelectState(
mode = mode, mode = mode,
@@ -65,9 +90,18 @@ internal fun aRoomSelectState(
searchQuery = TextFieldState(initialText = searchQuery), searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
selectedRooms = selectedRooms, selectedRooms = selectedRooms,
selectedAccount = selectedAccount,
hasRoomListError = hasRoomListError,
otherAccounts = otherAccounts,
isAccountListExpanded = isAccountListExpanded,
eventSink = eventSink, eventSink = eventSink,
) )
private fun anOtherAccountList() = persistentListOf(
aMatrixUser(id = "@bob:example.org", displayName = "Bob"),
aMatrixUser(id = "@carol:server.org", displayName = "Carol"),
)
internal fun aRoomSelectRoomList() = persistentListOf( internal fun aRoomSelectRoomList() = persistentListOf(
aSelectRoomInfo( aSelectRoomInfo(
roomId = RoomId("!room1:domain"), roomId = RoomId("!room1:domain"),
@@ -9,6 +9,11 @@
package io.element.android.libraries.roomselect.impl package io.element.android.libraries.roomselect.impl
import androidx.activity.compose.BackHandler 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing 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.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme 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.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.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType 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.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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.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.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState 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.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets 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.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.components.SelectedRoom
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.getAvatarData 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.ImmutableList
import kotlinx.collections.immutable.toImmutableList 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 @Suppress("MultipleEmitters") // False positive
@Composable @Composable
fun RoomSelectView( fun RoomSelectView(
@@ -111,7 +137,13 @@ fun RoomSelectView(
onBack = { onBackButton(state) } 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 -> OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(RoomSelectEvent.UpdateVisibleRange(visibleRange)) state.eventSink(RoomSelectEvent.UpdateVisibleRange(visibleRange))
} }
@@ -145,6 +177,7 @@ fun RoomSelectView(
Modifier Modifier
.padding(paddingValues) .padding(paddingValues)
.consumeWindowInsets(paddingValues) .consumeWindowInsets(paddingValues)
.nestedScroll(accountSwitchScrollBehavior.nestedScrollConnection)
) { ) {
SearchBar( SearchBar(
modifier = Modifier modifier = Modifier
@@ -161,6 +194,12 @@ fun RoomSelectView(
resultState = state.resultState, resultState = state.resultState,
showBackButton = false, showBackButton = false,
) { summaries -> ) { summaries ->
if (summaries.isNotEmpty()) {
ListSectionHeader(
title = stringResource(CommonStrings.common_header_rooms),
hasDivider = false,
)
}
LazyColumn( LazyColumn(
state = lazyListState, state = lazyListState,
contentPadding = lazyColumnContentPadding, contentPadding = lazyColumnContentPadding,
@@ -172,7 +211,6 @@ fun RoomSelectView(
) )
} }
items(summaries, key = { it.roomId.value }) { roomSummary -> items(summaries, key = { it.roomId.value }) { roomSummary ->
Column {
RoomSummaryView( RoomSummaryView(
roomSummary, roomSummary,
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
@@ -181,24 +219,45 @@ fun RoomSelectView(
}, },
canBeSelected = state.canSelectMoreRooms, canBeSelected = state.canSelectMoreRooms,
) )
HorizontalDivider(modifier = Modifier.fillMaxWidth())
}
} }
} }
} }
if (!state.isSearchActive) { 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( SelectedRoomsHelper(
selectedRooms = state.selectedRooms, selectedRooms = state.selectedRooms,
showVerticalSpace = true, // showVerticalSpace only if there is no other accounts
showVerticalSpace = !state.showAccountSwitch,
) )
if (state.resultState is SearchBarResultState.Results) { if (state.hasRoomListError) {
RoomListError()
} else if (state.resultState is SearchBarResultState.Results) {
LazyColumn( LazyColumn(
state = lazyListState, state = lazyListState,
contentPadding = lazyColumnContentPadding, 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 -> items(state.resultState.results, key = { it.roomId.value }) { roomSummary ->
Column {
RoomSummaryView( RoomSummaryView(
roomSummary, roomSummary,
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
@@ -207,13 +266,119 @@ fun RoomSelectView(
}, },
canBeSelected = state.canSelectMoreRooms, canBeSelected = state.canSelectMoreRooms,
) )
HorizontalDivider(modifier = Modifier.fillMaxWidth())
} }
} }
} }
} }
} }
} }
}
@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<MatrixUser>,
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,
)
} }
} }
@@ -241,14 +406,9 @@ private fun RoomSummaryView(
canBeSelected: Boolean, canBeSelected: Boolean,
onSelection: (SelectRoomInfo) -> Unit, onSelection: (SelectRoomInfo) -> Unit,
) { ) {
Row( ListItem(
modifier = Modifier onClick = { onSelection(roomInfo) },
.clickable { onSelection(roomInfo) } leadingContent = ListItemContent.Custom {
.fillMaxWidth()
.padding(start = 16.dp, end = 4.dp)
.heightIn(56.dp),
verticalAlignment = Alignment.CenterVertically
) {
Avatar( Avatar(
avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem), avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem),
avatarType = AvatarType.Room( avatarType = AvatarType.Room(
@@ -258,11 +418,13 @@ private fun RoomSummaryView(
isTombstoned = roomInfo.isTombstoned, isTombstoned = roomInfo.isTombstoned,
), ),
) )
Column( },
modifier = Modifier trailingContent = ListItemContent.Checkbox(
.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp) checked = isSelected,
.weight(1f) enabled = isSelected || canBeSelected,
),
) { ) {
Column {
// Name // Name
Text( Text(
style = ElementTheme.typography.fontBodyLgRegular, style = ElementTheme.typography.fontBodyLgRegular,
@@ -284,11 +446,6 @@ private fun RoomSummaryView(
) )
} }
} }
Checkbox(
checked = isSelected,
enabled = isSelected || canBeSelected,
onCheckedChange = { onSelection(roomInfo) },
)
} }
} }
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_select_error_cannot_load_rooms">"The room list associated with this account cannot be loaded."</string>
</resources>
@@ -12,6 +12,8 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.RoomId 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.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
@@ -36,7 +38,8 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() {
RoomSelectNode( RoomSelectNode(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
presenterFactory = { mode, maxNumberOfRooms -> presenterFactory = { initialSessionId, mode, maxNumberOfRooms ->
assertThat(initialSessionId).isEqualTo(A_SESSION_ID)
assertThat(mode).isEqualTo(testMode) assertThat(mode).isEqualTo(testMode)
assertThat(maxNumberOfRooms).isEqualTo(testMaxNumberOfRooms) assertThat(maxNumberOfRooms).isEqualTo(testMaxNumberOfRooms)
createRoomSelectPresenter(mode, maxNumberOfRooms) createRoomSelectPresenter(mode, maxNumberOfRooms)
@@ -44,10 +47,11 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() {
) )
} }
val callback = object : RoomSelectEntryPoint.Callback { val callback = object : RoomSelectEntryPoint.Callback {
override fun onRoomSelected(roomIds: List<RoomId>) = lambdaError() override fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) = lambdaError()
override fun onCancel() = lambdaError() override fun onCancel() = lambdaError()
} }
val params = RoomSelectEntryPoint.Params( val params = RoomSelectEntryPoint.Params(
sessionId = A_SESSION_ID,
mode = testMode, mode = testMode,
maxNumberOfRooms = testMaxNumberOfRooms, maxNumberOfRooms = testMaxNumberOfRooms,
) )
@@ -58,7 +62,7 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() {
callback = callback, callback = callback,
) )
assertThat(result).isInstanceOf(RoomSelectNode::class.java) 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) assertThat(result.plugins).contains(callback)
} }
} }
@@ -9,18 +9,34 @@
package io.element.android.libraries.roomselect.impl package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import app.cash.turbine.Event
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState 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.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService 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.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode 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.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.assert
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
@@ -46,9 +62,228 @@ class RoomSelectPresenterTest {
assertThat(initialState.isSearchActive).isFalse() assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.maxNumberOfRooms).isEqualTo(10) assertThat(initialState.maxNumberOfRooms).isEqualTo(10)
assertThat(initialState.canSelectMoreRooms).isTrue() 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<Event.Item<RoomSelectState>>().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 @Test
fun `present - toggle search active`() = runTest { fun `present - toggle search active`() = runTest {
val presenter = createRoomSelectPresenter() val presenter = createRoomSelectPresenter()
@@ -157,16 +392,23 @@ internal fun TestScope.createRoomSelectPresenter(
mode: RoomSelectMode = RoomSelectMode.Forward, mode: RoomSelectMode = RoomSelectMode.Forward,
maxNumberOfRooms: Int = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS, maxNumberOfRooms: Int = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
roomListService: RoomListService = FakeRoomListService(), roomListService: RoomListService = FakeRoomListService(),
sessionStore: SessionStore = InMemorySessionStore(),
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider { sessionId ->
Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListService))
},
) = RoomSelectPresenter( ) = RoomSelectPresenter(
initialSessionId = A_SESSION_ID,
mode = mode, mode = mode,
maxNumberOfRooms = maxNumberOfRooms, maxNumberOfRooms = maxNumberOfRooms,
dataSourceFactory = object : RoomSelectSearchDataSource.Factory { dataSourceFactory = object : RoomSelectSearchDataSource.Factory {
override fun create(coroutineScope: CoroutineScope): RoomSelectSearchDataSource { override fun create(coroutineScope: CoroutineScope, roomListService: RoomListService): RoomSelectSearchDataSource {
return RoomSelectSearchDataSource( return RoomSelectSearchDataSource(
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
roomListService = roomListService, roomListService = roomListService,
coroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers = testCoroutineDispatchers(),
) )
} }
} },
matrixClientProvider = matrixClientProvider,
sessionStore = sessionStore,
) )
@@ -14,13 +14,19 @@ import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState 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.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.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo 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.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam 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 io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import org.junit.Test import org.junit.Test
@@ -72,6 +78,63 @@ class RoomSelectViewTest : RobolectricTest() {
onNodeWithText("Room with a single hero").assertIsDisplayed() onNodeWithText("Room with a single hero").assertIsDisplayed()
onNodeWithText("@alice:example.org").assertDoesNotExist() onNodeWithText("@alice:example.org").assertDoesNotExist()
} }
@Test
fun `clicking on switch account emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomSelectEvent>()
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<RoomSelectEvent>()
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<ComponentActivity> {
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<ComponentActivity>.setRoomSelectView( private fun AndroidComposeUiTest<ComponentActivity>.setRoomSelectView(
+6
View File
@@ -14,6 +14,12 @@
"screen_bug_report_.*" "screen_bug_report_.*"
] ]
}, },
{
"name" : ":libraries:roomselect:impl",
"includeRegex" : [
"screen\\.room_select\\..*"
]
},
{ {
"name" : ":features:rageshake:api", "name" : ":features:rageshake:api",
"includeRegex" : [ "includeRegex" : [