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.di.DependencyInjectionGraphOwner
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import kotlinx.parcelize.Parcelize
@@ -59,6 +61,7 @@ class LoggedInAppScopeFlowNode(
interface Callback : Plugin {
fun navigateToBugReport()
fun navigateToAddAccount()
fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?)
}
private val callback: Callback = callback()
@@ -91,6 +94,10 @@ class LoggedInAppScopeFlowNode(
override fun navigateToAddAccount() {
callback.navigateToAddAccount()
}
override fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) {
callback.switchAccountAndOpenRoom(sessionId, roomId)
}
}
return createNode<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.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
@@ -173,6 +174,7 @@ class LoggedInFlowNode(
interface Callback : Plugin {
fun navigateToBugReport()
fun navigateToAddAccount()
fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?)
}
private val callback: Callback = callback()
@@ -574,10 +576,16 @@ class LoggedInFlowNode(
buildContext = buildContext,
params = ShareEntryPoint.Params(shareIntentData = navTarget.shareIntentData),
callback = object : ShareEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) {
override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) {
// Remove the incoming share screen
backstack.pop()
if (sessionId != matrixClient.sessionId) {
// The data has been shared using another session, switch to it
callback.switchAccountAndOpenRoom(sessionId, roomIds.singleOrNull())
return
}
// Navigate to the room if the text/media was shared to a single one
roomIds.singleOrNull()?.let { roomId ->
lifecycleScope.launch {
@@ -62,6 +62,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.asEventId
@@ -349,8 +350,7 @@ class RootFlowNode(
@Parcelize data class AccountSelect(
val currentSessionId: SessionId,
val shareIntentData: ShareIntentData?,
val permalinkData: PermalinkData?,
val permalinkData: PermalinkData,
) : NavTarget
@Parcelize data class NotLoggedInFlow(
@@ -389,6 +389,15 @@ class RootFlowNode(
override fun navigateToAddAccount() {
backstack.push(NavTarget.NotLoggedInFlow(null))
}
override fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) {
lifecycleScope.launch {
val loggedInFlowNode = attachSession(sessionId)
roomId?.let {
loggedInFlowNode.attachRoom(it.toRoomIdOrAlias(), clearBackstack = false)
}
}
}
}
val savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap)
val buildContext = if (savedNavState != null) {
@@ -463,13 +472,7 @@ class RootFlowNode(
// Do not pop when the account is changed to avoid a UI flicker.
backstack.pop()
}
attachSession(sessionId).apply {
if (navTarget.shareIntentData != null) {
attachIncomingShare(navTarget.shareIntentData)
} else if (navTarget.permalinkData != null) {
attachPermalinkData(navTarget.permalinkData)
}
}
attachSession(sessionId).attachPermalinkData(navTarget.permalinkData)
}
}
@@ -539,21 +542,9 @@ class RootFlowNode(
// No session, open login
switchToNotLoggedInFlow(null)
} else {
// wait for the current session to be restored
val loggedInFlowNode = attachSession(latestSessionId)
if (sessionStore.numberOfSessions() > 1) {
// Several accounts, let the user choose which one to use
backstack.push(
NavTarget.AccountSelect(
currentSessionId = latestSessionId,
shareIntentData = shareIntentData,
permalinkData = null,
)
)
} else {
// Only one account, directly attach the incoming share node.
loggedInFlowNode.attachIncomingShare(shareIntentData)
}
// wait for the current session to be restored, then attach the incoming share node.
// In case of multiple accounts, the user can switch account from the room select screen.
attachSession(latestSessionId).attachIncomingShare(shareIntentData)
}
}
@@ -576,7 +567,6 @@ class RootFlowNode(
backstack.push(
NavTarget.AccountSelect(
currentSessionId = latestSessionId,
shareIntentData = null,
permalinkData = permalinkData,
)
)
@@ -28,6 +28,7 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
@@ -40,6 +41,7 @@ class ForwardMessagesNode(
@Assisted plugins: List<Plugin>,
presenterFactory: ForwardMessagesPresenter.Factory,
private val roomSelectEntryPoint: RoomSelectEntryPoint,
private val sessionId: SessionId,
) : ParentNode<ForwardMessagesNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget),
@@ -62,7 +64,8 @@ class ForwardMessagesNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
val callback = object : RoomSelectEntryPoint.Callback {
override fun onRoomSelected(roomIds: List<RoomId>) {
override fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) {
// In Forward mode, the session cannot be changed
presenter.onRoomSelected(roomIds)
}
@@ -75,6 +78,7 @@ class ForwardMessagesNode(
parentNode = this,
buildContext = buildContext,
params = RoomSelectEntryPoint.Params(
sessionId = sessionId,
mode = RoomSelectMode.Forward,
maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
),
@@ -15,6 +15,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.timeline.FakeTimelineProvider
import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
@@ -39,6 +40,7 @@ class DefaultForwardEntryPointTest {
plugins = plugins,
presenterFactory = { _, _ -> createForwardMessagesPresenter() },
roomSelectEntryPoint = FakeRoomSelectEntryPoint(),
sessionId = A_SESSION_ID,
)
}
val callback = object : ForwardEntryPoint.Callback {
@@ -8,8 +8,8 @@
package io.element.android.features.home.impl
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.toMatrixUser
import io.element.android.libraries.sessionstorage.api.SessionData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -32,12 +32,7 @@ class CurrentUserWithNeighborsBuilder {
matrixUser
} else {
// Use the data from the DB
MatrixUser(
userId = UserId(it.userId),
displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl,
avatarThumbnail = it.userAvatarData,
)
it.toMatrixUser()
}
}
.let { sessionList ->
@@ -29,8 +29,7 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.toMatrixUser
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.services.analytics.api.AnalyticsService
@@ -85,14 +84,7 @@ class PreferencesRootPresenter(
sessionStore.sessionsFlow().map { list ->
list
.filter { it.userId != matrixClient.sessionId.value }
.map {
MatrixUser(
userId = UserId(it.userId),
displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl,
avatarThumbnail = it.userAvatarData,
)
}
.map { it.toMatrixUser() }
.toImmutableList()
}
}.collectAsState(initial = persistentListOf())
@@ -13,6 +13,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
interface ShareEntryPoint : FeatureEntryPoint {
data class Params(val shareIntentData: ShareIntentData)
@@ -25,6 +26,11 @@ interface ShareEntryPoint : FeatureEntryPoint {
): Node
interface Callback : Plugin {
fun onDone(roomIds: List<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.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.parcelize.Parcelize
@@ -39,6 +40,7 @@ class ShareNode(
@Assisted plugins: List<Plugin>,
presenterFactory: SharePresenter.Factory,
private val roomSelectEntryPoint: RoomSelectEntryPoint,
private val sessionId: SessionId,
) : ParentNode<ShareNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget),
@@ -53,17 +55,17 @@ class ShareNode(
data class Inputs(val shareIntentData: ShareIntentData) : NodeInputs
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()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
val callback = object : RoomSelectEntryPoint.Callback {
override fun onRoomSelected(roomIds: List<RoomId>) {
presenter.onRoomSelected(roomIds)
override fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) {
presenter.onRoomSelected(sessionId, roomIds)
}
override fun onCancel() {
callback.onDone(emptyList())
callback.onDone(sessionId, emptyList())
}
}
@@ -71,6 +73,7 @@ class ShareNode(
parentNode = this,
buildContext = buildContext,
params = RoomSelectEntryPoint.Params(
sessionId = sessionId,
mode = RoomSelectMode.Share,
maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
),
@@ -89,7 +92,7 @@ class ShareNode(
val state = presenter.present()
ShareView(
state = state,
onShareSuccess = callback::onDone,
onShareSuccess = { roomIds -> callback.onDone(state.sessionId, roomIds) },
)
}
}
@@ -10,47 +10,54 @@ package io.element.android.features.share.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.share.api.OnSharedData
import io.element.android.features.share.api.ShareIntentData
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@AssistedInject
class SharePresenter(
@Assisted private val shareIntentData: ShareIntentData,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val matrixClient: MatrixClient,
private val mediaSenderRoomFactory: MediaSenderRoomFactory,
private val activeRoomsHolder: ActiveRoomsHolder,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val onSharedData: OnSharedData,
@Assisted initialSessionId: SessionId,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
private val matrixClientProvider: MatrixClientProvider,
private val shareDataSender: ShareDataSender,
) : Presenter<ShareState> {
@AssistedFactory
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)
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
@@ -62,80 +69,9 @@ class SharePresenter(
}
return ShareState(
sessionId = targetSessionId,
shareAction = shareActionState.value,
eventSink = ::handleEvent,
)
}
private suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom? {
return activeRoomsHolder.getActiveRoom(matrixClient.sessionId)
?.takeIf { it.roomId == roomId }
?: matrixClient.getJoinedRoom(roomId)
}
private fun CoroutineScope.share(
shareIntentData: ShareIntentData,
roomIds: List<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.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
data class ShareState(
val sessionId: SessionId,
val shareAction: AsyncAction<List<RoomId>>,
val eventSink: (ShareEvent) -> Unit
)
@@ -11,6 +11,7 @@ package io.element.android.features.share.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
open class ShareStatePreviewParam : PreviewParameterProvider<ShareState> {
override val values: Sequence<ShareState>
@@ -31,9 +32,11 @@ open class ShareStatePreviewParam : PreviewParameterProvider<ShareState> {
}
fun aShareState(
sessionId: SessionId = SessionId("@alice:server.org"),
shareAction: AsyncAction<List<RoomId>> = AsyncAction.Uninitialized,
eventSink: (ShareEvent) -> Unit = {}
) = ShareState(
sessionId = sessionId,
shareAction = shareAction,
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.ShareIntentData
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
@@ -37,12 +39,13 @@ class DefaultShareEntryPointTest {
ShareNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { createSharePresenter() },
presenterFactory = { _, _ -> createSharePresenter() },
roomSelectEntryPoint = FakeRoomSelectEntryPoint(),
sessionId = A_SESSION_ID,
)
}
val callback = object : ShareEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) = lambdaError()
override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) = lambdaError()
}
val params = ShareEntryPoint.Params(
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
import android.net.Uri
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.share.api.OnSharedData
import io.element.android.features.share.api.ShareIntentData
import io.element.android.features.share.api.UriToShare
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.test.FakeMediaSender
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class SharePresenterTest : RobolectricTest() {
class SharePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -49,22 +44,25 @@ class SharePresenterTest : RobolectricTest() {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sessionId).isEqualTo(A_SESSION_ID)
assertThat(initialState.shareAction.isUninitialized()).isTrue()
}
}
@Test
fun `present - on room selected error then clear error`() = runTest {
val presenter = createSharePresenter()
val presenter = createSharePresenter(
shareDataSender = FakeShareDataSender { _, _, _ -> Result.failure(AN_EXCEPTION) },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.shareAction.isUninitialized()).isTrue()
presenter.onRoomSelected(listOf(A_ROOM_ID))
presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID))
assertThat(awaitItem().shareAction.isLoading()).isTrue()
val failure = awaitItem()
assertThat(failure.shareAction.isFailure()).isTrue()
assertThat(failure.shareAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
failure.eventSink.invoke(ShareEvent.ClearError)
assertThat(awaitItem().shareAction.isUninitialized()).isTrue()
}
@@ -72,113 +70,115 @@ class SharePresenterTest : RobolectricTest() {
@Test
fun `present - on room selected ok`() = runTest {
val joinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
},
)
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, joinedRoom)
}
val client = FakeMatrixClient(sessionId = A_SESSION_ID)
val shareIntentData = ShareIntentData.PlainText(A_MESSAGE)
val sendResult = lambdaRecorder<MatrixClient, ShareIntentData, List<RoomId>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val presenter = createSharePresenter(
matrixClient = matrixClient,
shareIntentData = ShareIntentData.PlainText(A_MESSAGE),
shareIntentData = shareIntentData,
matrixClientProvider = FakeMatrixClientProvider { Result.success(client) },
shareDataSender = FakeShareDataSender(sendResult),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.shareAction.isUninitialized()).isTrue()
presenter.onRoomSelected(listOf(A_ROOM_ID))
presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID))
assertThat(awaitItem().shareAction.isLoading()).isTrue()
val success = awaitItem()
assertThat(success.shareAction.isSuccess()).isTrue()
assertThat(success.sessionId).isEqualTo(A_SESSION_ID)
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
sendResult.assertions().isCalledOnce().with(value(client), value(shareIntentData), value(listOf(A_ROOM_ID)))
}
}
@Test
fun `present - send text ok`() = runTest {
val joinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
},
)
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, joinedRoom)
fun `present - on room selected from another session ok`() = runTest {
val getClient = lambdaRecorder<SessionId, Result<MatrixClient>> { sessionId -> Result.success(FakeMatrixClient(sessionId = sessionId)) }
val sendResult = lambdaRecorder<MatrixClient, ShareIntentData, List<RoomId>, Result<Unit>> { client, _, _ ->
assertThat(client.sessionId).isEqualTo(A_SESSION_ID_2)
Result.success(Unit)
}
val presenter = createSharePresenter(
matrixClient = matrixClient,
shareIntentData = ShareIntentData.PlainText(A_MESSAGE),
matrixClientProvider = FakeMatrixClientProvider { getClient(it) },
shareDataSender = FakeShareDataSender(sendResult),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.shareAction.isUninitialized()).isTrue()
presenter.onRoomSelected(listOf(A_ROOM_ID))
assertThat(initialState.sessionId).isEqualTo(A_SESSION_ID)
presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID))
val sessionChanged = awaitItem()
assertThat(sessionChanged.sessionId).isEqualTo(A_SESSION_ID_2)
assertThat(sessionChanged.shareAction.isUninitialized()).isTrue()
assertThat(awaitItem().shareAction.isLoading()).isTrue()
val success = awaitItem()
assertThat(success.shareAction.isSuccess()).isTrue()
assertThat(success.sessionId).isEqualTo(A_SESSION_ID_2)
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
getClient.assertions().isCalledOnce().with(value(A_SESSION_ID_2))
sendResult.assertions().isCalledOnce()
}
}
@Test
fun `present - send media ok`() = runTest {
val sendMediaResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val joinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline(),
)
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, joinedRoom)
}
val mediaSender = FakeMediaSender(
sendMediaResult = sendMediaResult,
)
fun `present - on room selected, failure to get the client`() = runTest {
val sendResult = lambdaRecorder<MatrixClient, ShareIntentData, List<RoomId>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val presenter = createSharePresenter(
matrixClient = matrixClient,
shareIntentData = ShareIntentData.Uris(
text = A_MESSAGE,
listOf(
UriToShare(
uri = Uri.parse("content://image.jpg"),
mimeType = MimeTypes.Jpeg,
)
)
),
mediaSenderRoomFactory = MediaSenderRoomFactory { mediaSender },
matrixClientProvider = FakeMatrixClientProvider { Result.failure(AN_EXCEPTION) },
shareDataSender = FakeShareDataSender(sendResult),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.shareAction.isUninitialized()).isTrue()
presenter.onRoomSelected(listOf(A_ROOM_ID))
skipItems(1)
presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID))
assertThat(awaitItem().sessionId).isEqualTo(A_SESSION_ID_2)
assertThat(awaitItem().shareAction.isLoading()).isTrue()
assertThat(awaitItem().shareAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
sendResult.assertions().isNeverCalled()
}
}
@Test
fun `present - on room selected while sharing is ignored`() = runTest {
val completable = CompletableDeferred<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()
assertThat(success.shareAction.isSuccess()).isTrue()
assertThat(success.sessionId).isEqualTo(A_SESSION_ID)
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
sendMediaResult.assertions().isCalledOnce()
sendResult.assertions().isCalledOnce()
}
}
}
internal fun TestScope.createSharePresenter(
shareIntentData: ShareIntentData = ShareIntentData.PlainText(A_MESSAGE),
matrixClient: MatrixClient = FakeMatrixClient(),
activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(),
mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { FakeMediaSender() },
mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
onSharedData: OnSharedData = OnSharedData {},
initialSessionId: SessionId = A_SESSION_ID,
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
shareDataSender: ShareDataSender = FakeShareDataSender(),
): SharePresenter {
return SharePresenter(
shareIntentData = shareIntentData,
sessionCoroutineScope = this,
matrixClient = matrixClient,
activeRoomsHolder = activeRoomsHolder,
mediaSenderRoomFactory = mediaSenderRoomFactory,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
onSharedData = onSharedData,
initialSessionId = initialSessionId,
appCoroutineScope = this,
matrixClientProvider = matrixClientProvider,
shareDataSender = shareDataSender,
)
}
@@ -19,7 +19,7 @@ import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.libraries.matrix.api.user.toMatrixUser
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
@@ -58,11 +58,4 @@ class SignedOutPresenter(
eventSink = ::handleEvent,
)
}
private fun SessionData.toMatrixUser() = MatrixUser(
userId = sessionId,
displayName = userDisplayName,
avatarUrl = userAvatarUrl,
avatarThumbnail = userAvatarData,
)
}
@@ -29,6 +29,7 @@ import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.toMatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
@@ -101,16 +102,7 @@ class IncomingVerificationPresenter(
}
val currentUser by remember {
sessionStore.sessionsFlow().mapNotNull { sessions ->
sessions.find { it.userId == sessionId.value }?.let { sessionData ->
MatrixUser(
userId = sessionId,
displayName = sessionData.userDisplayName,
avatarUrl = sessionData.userAvatarUrl,
// Locally stored avatar, used as a fallback when the avatar cannot be loaded,
// for instance for a session which is not the current one
avatarThumbnail = sessionData.userAvatarData,
)
}
sessions.find { it.userId == sessionId.value }?.toMatrixUser()
}
}.collectAsState(MatrixUser(sessionId))
val step by remember {
@@ -13,8 +13,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.toMatrixUser
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -29,14 +29,7 @@ class AccountSelectPresenter(
val accounts by produceState<ImmutableList<MatrixUser>>(persistentListOf()) {
// Do not use sessionStore.sessionsFlow() to not make it change when an account is selected.
value = sessionStore.getAllSessions()
.map {
MatrixUser(
userId = UserId(it.userId),
displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl,
avatarThumbnail = it.userAvatarData,
)
}
.map { it.toMatrixUser() }
.toImmutableList()
}
+1
View File
@@ -54,6 +54,7 @@ dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.services.analytics.api)
implementation(projects.libraries.sessionStorage.api)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
@@ -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
import io.element.android.libraries.matrix.api.MatrixClient
/**
* Resolves how aggressively media should be compressed, combining the user's own preferences with the app defaults.
*/
@@ -15,3 +17,15 @@ fun interface MediaOptimizationConfigProvider {
/** Returns the configuration to pass to the pre-processing and sending calls. */
suspend fun get(): MediaOptimizationConfig
}
/**
* Creates a [MediaOptimizationConfigProvider] for a given session, used outside the session scope, for instance when sharing to another account.
*/
fun interface MediaOptimizationConfigProviderFactory {
/**
* Create a [MediaOptimizationConfigProvider] using the preferences of the session of [client].
*
* @param client the client of the session whose preferences should be used.
*/
fun create(client: MatrixClient): MediaOptimizationConfigProvider
}
@@ -9,6 +9,7 @@
package io.element.android.libraries.mediaupload.api
import android.net.Uri
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
@@ -34,9 +35,11 @@ fun interface MediaSenderRoomFactory {
/**
* Create a [MediaSender] for the given [JoinedRoom], with timeline mode Live.
*
* @param client the client of the session the room belongs to, used to read the media preferences of that session.
* @param room the room the media should be sent to.
*/
fun create(
client: MatrixClient,
room: JoinedRoom,
): MediaSender
}
@@ -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
import android.net.Uri
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.hash.hash
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.flatMapCatching
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
@@ -22,6 +23,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProviderFactory
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
@@ -52,19 +54,20 @@ class DefaultMediaSenderFactory(
}
}
@ContributesBinding(SessionScope::class)
@ContributesBinding(AppScope::class)
class DefaultMediaSenderRoomFactory(
private val preProcessor: MediaPreProcessor,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val mediaOptimizationConfigProviderFactory: MediaOptimizationConfigProviderFactory,
) : MediaSenderRoomFactory {
override fun create(
client: MatrixClient,
room: JoinedRoom,
): MediaSender {
return DefaultMediaSender(
preProcessor = preProcessor,
room = room,
timelineMode = Timeline.Mode.Live,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
mediaOptimizationConfigProvider = mediaOptimizationConfigProviderFactory.create(client),
)
}
}
@@ -13,9 +13,16 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
interface RoomSelectEntryPoint : FeatureEntryPoint {
/**
* @param sessionId the session to use initially. In [RoomSelectMode.Share] mode, the user can select another session.
* @param mode the mode of the screen.
* @param maxNumberOfRooms the maximum number of rooms the user can select.
*/
data class Params(
val sessionId: SessionId,
val mode: RoomSelectMode,
val maxNumberOfRooms: Int,
)
@@ -28,7 +35,11 @@ interface RoomSelectEntryPoint : FeatureEntryPoint {
): Node
interface Callback : Plugin {
fun onRoomSelected(roomIds: List<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()
}
@@ -32,10 +32,12 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
api(projects.libraries.roomselect.api)
testCommonDependencies(libs, includeTestComposeView = true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.sessionStorage.test)
}
@@ -10,12 +10,12 @@ package io.element.android.libraries.roomselect.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
@ContributesBinding(SessionScope::class)
@ContributesBinding(AppScope::class)
class DefaultRoomSelectEntryPoint : RoomSelectEntryPoint {
override fun createNode(
parentNode: Node,
@@ -27,6 +27,7 @@ class DefaultRoomSelectEntryPoint : RoomSelectEntryPoint {
buildContext = buildContext,
plugins = listOf(
RoomSelectNode.Inputs(
sessionId = params.sessionId,
mode = params.mode,
maxNumberOfRooms = params.maxNumberOfRooms,
),
@@ -8,10 +8,13 @@
package io.element.android.libraries.roomselect.impl
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
sealed interface RoomSelectEvent {
data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent
data object ToggleSearchActive : RoomSelectEvent
data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent
data object ToggleAccountListExpanded : RoomSelectEvent
data class SelectAccount(val sessionId: SessionId) : RoomSelectEvent
}
@@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
@@ -22,11 +23,11 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
@ContributesNode(SessionScope::class)
@ContributesNode(AppScope::class)
@AssistedInject
class RoomSelectNode(
@Assisted buildContext: BuildContext,
@@ -34,12 +35,14 @@ class RoomSelectNode(
presenterFactory: RoomSelectPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val sessionId: SessionId,
val mode: RoomSelectMode,
val maxNumberOfRooms: Int,
) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(
initialSessionId = inputs.sessionId,
mode = inputs.mode,
maxNumberOfRooms = inputs.maxNumberOfRooms,
)
@@ -52,7 +55,7 @@ class RoomSelectNode(
RoomSelectView(
state = state,
onDismiss = callback::onCancel,
onSubmit = callback::onRoomSelected,
onSubmit = { roomIds -> callback.onRoomSelected(state.selectedAccount.userId, roomIds) },
modifier = modifier
)
}
@@ -8,6 +8,7 @@
package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -15,7 +16,9 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -24,22 +27,33 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.toMatrixUser
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import timber.log.Timber
@AssistedInject
class RoomSelectPresenter(
@Assisted private val initialSessionId: SessionId,
@Assisted private val mode: RoomSelectMode,
@Assisted private val maxNumberOfRooms: Int,
private val dataSourceFactory: RoomSelectSearchDataSource.Factory,
private val matrixClientProvider: MatrixClientProvider,
private val sessionStore: SessionStore,
) : Presenter<RoomSelectState> {
@AssistedFactory
fun interface Factory {
fun create(
initialSessionId: SessionId,
mode: RoomSelectMode,
maxNumberOfRooms: Int,
): RoomSelectPresenter
@@ -50,18 +64,75 @@ class RoomSelectPresenter(
var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) }
val queryState = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val dataSource = remember { dataSourceFactory.create(coroutineScope) }
val searchQuery = queryState.text.toString()
LaunchedEffect(searchQuery) {
dataSource.setSearchQuery(searchQuery)
var isAccountListExpanded by remember { mutableStateOf(false) }
var selectedSessionId by remember { mutableStateOf(initialSessionId) }
val sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = emptyList())
// All the accounts are proposed, even the ones with an invalid token. Selecting them displays an error.
val accounts by remember {
derivedStateOf { sessions.map { it.toMatrixUser() } }
}
// The selected session can have an invalid token, or be invalidated while this screen is displayed, for instance if it is
// signed out from another device. The invalidation of the initial session, which is the current one, is handled by the application.
val isSelectedSessionInvalid by remember {
derivedStateOf {
selectedSessionId != initialSessionId && sessions.none { it.userId == selectedSessionId.value && it.isTokenValid }
}
}
LaunchedEffect(isSelectedSessionInvalid) {
if (isSelectedSessionInvalid) {
// The rooms of an invalid session cannot be used
selectedRooms = persistentListOf()
}
}
val selectedAccount by remember {
derivedStateOf {
// Fallback to a MatrixUser with only the userId until the sessions are loaded
accounts.find { it.userId == selectedSessionId } ?: MatrixUser(selectedSessionId)
}
}
val otherAccounts by remember {
derivedStateOf {
accounts.filter { it.userId != selectedSessionId }.toImmutableList()
}
}
val roomSummaryDetailsList by dataSource.roomInfoList.collectAsState(initial = persistentListOf())
val coroutineScope = rememberCoroutineScope()
// Create one data source per session. When the selected session changes, the previous
// data source is removed from the composition and its coroutine scope is cancelled.
val (selectedSessionDataSource, hasClientError) = key(selectedSessionId) {
val dataSourceCoroutineScope = rememberCoroutineScope()
// The sessions are restored at startup, so the client should already be in memory.
// A null value means that the client is being restored, or that the session is invalid: its client cannot be restored,
// and an in-memory client would have been destroyed, so do not try to get it.
val initialRoomListService = if (isSelectedSessionInvalid) null else matrixClientProvider.getOrNull(selectedSessionId)?.roomListService
val roomListServiceResult by produceState(initialRoomListService?.let { Result.success(it) }) {
if (value == null && !isSelectedSessionInvalid) {
value = matrixClientProvider.getOrRestore(selectedSessionId)
.onFailure { Timber.e(it, "Failed to get the client for $selectedSessionId") }
.map { it.roomListService }
}
}
val roomListService = roomListServiceResult?.getOrNull()
val dataSource = remember(roomListService) {
roomListService?.let { dataSourceFactory.create(dataSourceCoroutineScope, it) }
}
dataSource to (roomListServiceResult?.isFailure == true)
}
// Do not use the data source of an invalid session, its client has been destroyed
val dataSource = selectedSessionDataSource.takeIf { !isSelectedSessionInvalid }
val hasRoomListError = hasClientError || isSelectedSessionInvalid
val searchResults by remember<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 {
when {
roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList())
@@ -82,8 +153,16 @@ class RoomSelectPresenter(
}
}
RoomSelectEvent.ToggleSearchActive -> isSearchActive = !isSearchActive
RoomSelectEvent.ToggleAccountListExpanded -> isAccountListExpanded = !isAccountListExpanded
is RoomSelectEvent.SelectAccount -> {
selectedSessionId = event.sessionId
// The selected rooms and the search query are specific to the session
selectedRooms = persistentListOf()
queryState.clearText()
isAccountListExpanded = false
}
is RoomSelectEvent.UpdateVisibleRange -> coroutineScope.launch {
dataSource.updateVisibleRange(event.range)
dataSource?.updateVisibleRange(event.range)
}
}
}
@@ -95,6 +174,10 @@ class RoomSelectPresenter(
searchQuery = queryState,
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
selectedAccount = selectedAccount,
hasRoomListError = hasRoomListError,
otherAccounts = otherAccounts,
isAccountListExpanded = isAccountListExpanded,
eventSink = ::handleEvent,
)
}
@@ -36,12 +36,12 @@ private const val PAGE_SIZE = 30
@AssistedInject
class RoomSelectSearchDataSource(
@Assisted coroutineScope: CoroutineScope,
roomListService: RoomListService,
@Assisted roomListService: RoomListService,
coroutineDispatchers: CoroutineDispatchers,
) {
@AssistedFactory
interface Factory {
fun create(coroutineScope: CoroutineScope): RoomSelectSearchDataSource
fun create(coroutineScope: CoroutineScope, roomListService: RoomListService): RoomSelectSearchDataSource
}
private val roomList = roomListService.createRoomList(
@@ -10,6 +10,7 @@ package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.collections.immutable.ImmutableList
@@ -21,7 +22,19 @@ data class RoomSelectState(
val searchQuery: TextFieldState,
val isSearchActive: Boolean,
val selectedRooms: ImmutableList<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 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.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
@@ -47,6 +49,25 @@ open class RoomSelectStatePreviewParam : PreviewParameterProvider<RoomSelectStat
selectedRooms = aRoomSelectRoomList().subList(0, 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 = "",
isSearchActive: Boolean = false,
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")),
hasRoomListError: Boolean = false,
otherAccounts: ImmutableList<MatrixUser> = persistentListOf(),
isAccountListExpanded: Boolean = false,
eventSink: (RoomSelectEvent) -> Unit = {},
) = RoomSelectState(
mode = mode,
@@ -65,9 +90,18 @@ internal fun aRoomSelectState(
searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
selectedAccount = selectedAccount,
hasRoomListError = hasRoomListError,
otherAccounts = otherAccounts,
isAccountListExpanded = isAccountListExpanded,
eventSink = eventSink,
)
private fun anOtherAccountList() = persistentListOf(
aMatrixUser(id = "@bob:example.org", displayName = "Bob"),
aMatrixUser(id = "@carol:server.org", displayName = "Carol"),
)
internal fun aRoomSelectRoomList() = persistentListOf(
aSelectRoomInfo(
roomId = RoomId("!room1:domain"),
@@ -9,6 +9,11 @@
package io.element.android.libraries.roomselect.impl
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -21,7 +26,6 @@ import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
@@ -29,27 +33,42 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.TopAppBarScrollBehaviorLayout
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarRow
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
@@ -60,6 +79,10 @@ import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffec
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserHeader
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.components.SelectedRoom
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.getAvatarData
@@ -68,6 +91,9 @@ import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=195-38510
*/
@Suppress("MultipleEmitters") // False positive
@Composable
fun RoomSelectView(
@@ -111,7 +137,13 @@ fun RoomSelectView(
onBack = { onBackButton(state) }
)
val lazyListState = rememberLazyListState()
// Use a new list state when the selected account changes, so that the list of rooms is scrolled to the top
val lazyListState = key(state.selectedAccount.userId) { rememberLazyListState() }
// Hide the account switch section when the room list is scrolled up, and show it again as soon as it is scrolled down.
// Use a new state when the selected account changes, so that the section is fully displayed again.
val accountSwitchScrollBehavior = key(state.selectedAccount.userId) {
TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
}
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(RoomSelectEvent.UpdateVisibleRange(visibleRange))
}
@@ -145,6 +177,7 @@ fun RoomSelectView(
Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.nestedScroll(accountSwitchScrollBehavior.nestedScrollConnection)
) {
SearchBar(
modifier = Modifier
@@ -161,6 +194,12 @@ fun RoomSelectView(
resultState = state.resultState,
showBackButton = false,
) { summaries ->
if (summaries.isNotEmpty()) {
ListSectionHeader(
title = stringResource(CommonStrings.common_header_rooms),
hasDivider = false,
)
}
LazyColumn(
state = lazyListState,
contentPadding = lazyColumnContentPadding,
@@ -172,7 +211,53 @@ fun RoomSelectView(
)
}
items(summaries, key = { it.roomId.value }) { roomSummary ->
Column {
RoomSummaryView(
roomSummary,
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
onSelection = { roomSummary ->
state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary))
},
canBeSelected = state.canSelectMoreRooms,
)
}
}
}
if (!state.isSearchActive) {
if (state.showAccountSwitch) {
// Keep this space outside the collapsing section, so that the section does not touch the search bar when it is collapsing
Spacer(modifier = Modifier.height(8.dp))
TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) {
AccountSwitchSection(
selectedAccount = state.selectedAccount,
otherAccounts = state.otherAccounts,
isExpanded = state.isAccountListExpanded,
onToggleExpand = { state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) },
onSelectAccount = { state.eventSink(RoomSelectEvent.SelectAccount(it)) },
)
}
}
SelectedRoomsHelper(
selectedRooms = state.selectedRooms,
// showVerticalSpace only if there is no other accounts
showVerticalSpace = !state.showAccountSwitch,
)
if (state.hasRoomListError) {
RoomListError()
} else if (state.resultState is SearchBarResultState.Results) {
LazyColumn(
state = lazyListState,
contentPadding = lazyColumnContentPadding,
) {
if (state.resultState.results.isNotEmpty()) {
item {
ListSectionHeader(
title = stringResource(CommonStrings.common_header_rooms),
hasDivider = false,
)
}
}
items(state.resultState.results, key = { it.roomId.value }) { roomSummary ->
RoomSummaryView(
roomSummary,
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
@@ -181,34 +266,6 @@ fun RoomSelectView(
},
canBeSelected = state.canSelectMoreRooms,
)
HorizontalDivider(modifier = Modifier.fillMaxWidth())
}
}
}
}
if (!state.isSearchActive) {
SelectedRoomsHelper(
selectedRooms = state.selectedRooms,
showVerticalSpace = true,
)
if (state.resultState is SearchBarResultState.Results) {
LazyColumn(
state = lazyListState,
contentPadding = lazyColumnContentPadding,
) {
items(state.resultState.results, key = { it.roomId.value }) { roomSummary ->
Column {
RoomSummaryView(
roomSummary,
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
onSelection = { roomSummary ->
state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary))
},
canBeSelected = state.canSelectMoreRooms,
)
HorizontalDivider(modifier = Modifier.fillMaxWidth())
}
}
}
}
@@ -217,6 +274,114 @@ fun RoomSelectView(
}
}
@Composable
private fun RoomListError() {
IconTitleSubtitleMolecule(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 32.dp),
title = stringResource(CommonStrings.common_something_went_wrong),
subTitle = stringResource(R.string.screen_room_select_error_cannot_load_rooms),
iconStyle = BigIcon.Style.AlertSolid,
)
}
/**
* Similar to the MultiAccountSection of the PreferencesRootView.
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=5414-4759
*/
@Composable
private fun AccountSwitchSection(
selectedAccount: MatrixUser,
otherAccounts: ImmutableList<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,
)
}
}
@Composable
private fun SelectedRooms(
selectedRooms: ImmutableList<SelectRoomInfo>,
@@ -241,28 +406,25 @@ private fun RoomSummaryView(
canBeSelected: Boolean,
onSelection: (SelectRoomInfo) -> Unit,
) {
Row(
modifier = Modifier
.clickable { onSelection(roomInfo) }
.fillMaxWidth()
.padding(start = 16.dp, end = 4.dp)
.heightIn(56.dp),
verticalAlignment = Alignment.CenterVertically
ListItem(
onClick = { onSelection(roomInfo) },
leadingContent = ListItemContent.Custom {
Avatar(
avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem),
avatarType = AvatarType.Room(
heroes = roomInfo.heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem)
}.toImmutableList(),
isTombstoned = roomInfo.isTombstoned,
),
)
},
trailingContent = ListItemContent.Checkbox(
checked = isSelected,
enabled = isSelected || canBeSelected,
),
) {
Avatar(
avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem),
avatarType = AvatarType.Room(
heroes = roomInfo.heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem)
}.toImmutableList(),
isTombstoned = roomInfo.isTombstoned,
),
)
Column(
modifier = Modifier
.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp)
.weight(1f)
) {
Column {
// Name
Text(
style = ElementTheme.typography.fontBodyLgRegular,
@@ -284,11 +446,6 @@ private fun RoomSummaryView(
)
}
}
Checkbox(
checked = isSelected,
enabled = isSelected || canBeSelected,
onCheckedChange = { onSelection(roomInfo) },
)
}
}
@@ -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.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.tests.testutils.lambda.lambdaError
@@ -36,7 +38,8 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() {
RoomSelectNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { mode, maxNumberOfRooms ->
presenterFactory = { initialSessionId, mode, maxNumberOfRooms ->
assertThat(initialSessionId).isEqualTo(A_SESSION_ID)
assertThat(mode).isEqualTo(testMode)
assertThat(maxNumberOfRooms).isEqualTo(testMaxNumberOfRooms)
createRoomSelectPresenter(mode, maxNumberOfRooms)
@@ -44,10 +47,11 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() {
)
}
val callback = object : RoomSelectEntryPoint.Callback {
override fun onRoomSelected(roomIds: List<RoomId>) = lambdaError()
override fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) = lambdaError()
override fun onCancel() = lambdaError()
}
val params = RoomSelectEntryPoint.Params(
sessionId = A_SESSION_ID,
mode = testMode,
maxNumberOfRooms = testMaxNumberOfRooms,
)
@@ -58,7 +62,7 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() {
callback = callback,
)
assertThat(result).isInstanceOf(RoomSelectNode::class.java)
assertThat(result.plugins).contains(RoomSelectNode.Inputs(params.mode, params.maxNumberOfRooms))
assertThat(result.plugins).contains(RoomSelectNode.Inputs(params.sessionId, params.mode, params.maxNumberOfRooms))
assertThat(result.plugins).contains(callback)
}
}
@@ -9,18 +9,34 @@
package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import app.cash.turbine.Event
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
@@ -46,9 +62,228 @@ class RoomSelectPresenterTest {
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.maxNumberOfRooms).isEqualTo(10)
assertThat(initialState.canSelectMoreRooms).isTrue()
assertThat(initialState.selectedAccount).isEqualTo(MatrixUser(A_SESSION_ID))
assertThat(initialState.otherAccounts).isEmpty()
assertThat(initialState.isAccountListExpanded).isFalse()
assertThat(initialState.showAccountSwitch).isFalse()
assertThat(initialState.hasRoomListError).isFalse()
}
}
@Test
fun `present - failure to get the client shows an error`() = runTest {
val presenter = createRoomSelectPresenter(
matrixClientProvider = FakeMatrixClientProvider { Result.failure(AN_EXCEPTION) },
)
presenter.test {
val state = consumeItemsUntilPredicate { it.hasRoomListError }.last()
assertThat(state.hasRoomListError).isTrue()
assertThat(state.resultState).isInstanceOf(SearchBarResultState.Initial::class.java)
}
}
@Test
fun `present - select another account whose client cannot be restored shows an error`() = runTest {
val presenter = createRoomSelectPresenter(
mode = RoomSelectMode.Share,
sessionStore = aSessionStoreWithTwoAccounts(),
matrixClientProvider = FakeMatrixClientProvider { sessionId ->
if (sessionId == A_SESSION_ID) {
Result.success(FakeMatrixClient(sessionId = sessionId))
} else {
Result.failure(AN_EXCEPTION)
}
},
)
presenter.test {
val state = awaitLastSequentialItem()
assertThat(state.hasRoomListError).isFalse()
state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2))
val errorState = consumeItemsUntilPredicate { it.hasRoomListError }.last()
assertThat(errorState.selectedAccount.userId).isEqualTo(A_SESSION_ID_2)
// Going back to the first account clears the error
errorState.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID))
val finalState = consumeItemsUntilPredicate { it.selectedAccount.userId == A_SESSION_ID && !it.hasRoomListError }.last()
assertThat(finalState.hasRoomListError).isFalse()
}
}
@Test
fun `present - share mode with a single account does not show the account switch`() = runTest {
val presenter = createRoomSelectPresenter(
mode = RoomSelectMode.Share,
sessionStore = InMemorySessionStore(
initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value)),
),
)
presenter.test {
val state = awaitLastSequentialItem()
assertThat(state.selectedAccount.userId).isEqualTo(A_SESSION_ID)
assertThat(state.otherAccounts).isEmpty()
assertThat(state.showAccountSwitch).isFalse()
}
}
@Test
fun `present - share mode with several accounts shows the account switch`() = runTest {
val presenter = createRoomSelectPresenter(
mode = RoomSelectMode.Share,
sessionStore = aSessionStoreWithTwoAccounts(),
)
presenter.test {
val state = awaitLastSequentialItem()
assertThat(state.selectedAccount.userId).isEqualTo(A_SESSION_ID)
assertThat(state.selectedAccount.displayName).isEqualTo("Alice")
assertThat(state.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2)
assertThat(state.showAccountSwitch).isTrue()
state.eventSink(RoomSelectEvent.ToggleAccountListExpanded)
assertThat(awaitItem().isAccountListExpanded).isTrue()
state.eventSink(RoomSelectEvent.ToggleAccountListExpanded)
assertThat(awaitItem().isAccountListExpanded).isFalse()
}
}
@Test
fun `present - accounts with an invalid token are displayed, selecting one displays an error`() = runTest {
val presenter = createRoomSelectPresenter(
mode = RoomSelectMode.Share,
sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(sessionId = A_SESSION_ID.value, userDisplayName = "Alice", isTokenValid = true),
aSessionData(sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", isTokenValid = false),
),
),
// The client of the invalid session must not be requested
matrixClientProvider = FakeMatrixClientProvider { sessionId ->
if (sessionId == A_SESSION_ID) Result.success(FakeMatrixClient(sessionId = sessionId)) else lambdaError()
},
)
presenter.test {
val state = awaitLastSequentialItem()
assertThat(state.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2)
assertThat(state.hasRoomListError).isFalse()
state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2))
val errorState = consumeItemsUntilPredicate { it.selectedAccount.userId == A_SESSION_ID_2 && it.hasRoomListError }.last()
// The account information is still available
assertThat(errorState.selectedAccount.displayName).isEqualTo("Bob")
assertThat(errorState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID)
}
}
@Test
fun `present - the selected account is invalidated, an error is displayed and the selected rooms are cleared`() = runTest {
val roomSummary2 = aRoomSummary(roomId = A_ROOM_ID_2)
val roomListServices = mapOf(
A_SESSION_ID to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary()))) }),
A_SESSION_ID_2 to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(roomSummary2))) }),
)
val sessionStore = aSessionStoreWithTwoAccounts()
val presenter = createRoomSelectPresenter(
mode = RoomSelectMode.Share,
sessionStore = sessionStore,
matrixClientProvider = FakeMatrixClientProvider { sessionId ->
Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListServices.getValue(sessionId)))
},
)
presenter.test {
val state = awaitLastSequentialItem()
state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2))
val otherAccountState = consumeItemsUntilPredicate {
it.selectedAccount.userId == A_SESSION_ID_2 &&
(it.resultState as? SearchBarResultState.Results)?.results?.map { room -> room.roomId } == listOf(A_ROOM_ID_2)
}.last()
otherAccountState.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary2.toSelectRoomInfo()))
assertThat(consumeItemsUntilPredicate { it.selectedRooms.isNotEmpty() }.last().selectedRooms).hasSize(1)
// The session of the selected account is invalidated, for instance signed out from another device
sessionStore.updateData(sessionStore.getSession(A_SESSION_ID_2.value)!!.copy(isTokenValid = false))
val errorState = consumeItemsUntilPredicate { it.hasRoomListError && it.selectedRooms.isEmpty() }.last()
assertThat(errorState.selectedAccount.userId).isEqualTo(A_SESSION_ID_2)
assertThat(errorState.selectedAccount.displayName).isEqualTo("Bob")
assertThat(errorState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java)
// Go back to the current account: the invalid account is still proposed
errorState.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID))
val finalState = consumeItemsUntilPredicate { it.selectedAccount.userId == A_SESSION_ID && !it.hasRoomListError }.last()
assertThat(finalState.showAccountSwitch).isTrue()
assertThat(finalState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2)
}
}
@Test
fun `present - another account is invalidated, it is still proposed and there is no error`() = runTest {
val sessionStore = aSessionStoreWithTwoAccounts()
val presenter = createRoomSelectPresenter(
mode = RoomSelectMode.Share,
sessionStore = sessionStore,
)
presenter.test {
val state = awaitLastSequentialItem()
assertThat(state.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2)
sessionStore.updateData(sessionStore.getSession(A_SESSION_ID_2.value)!!.copy(isTokenValid = false))
testScheduler.advanceUntilIdle()
// The state may not change at all
val finalState = cancelAndConsumeRemainingEvents().filterIsInstance<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
fun `present - toggle search active`() = runTest {
val presenter = createRoomSelectPresenter()
@@ -157,16 +392,23 @@ internal fun TestScope.createRoomSelectPresenter(
mode: RoomSelectMode = RoomSelectMode.Forward,
maxNumberOfRooms: Int = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
roomListService: RoomListService = FakeRoomListService(),
sessionStore: SessionStore = InMemorySessionStore(),
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider { sessionId ->
Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListService))
},
) = RoomSelectPresenter(
initialSessionId = A_SESSION_ID,
mode = mode,
maxNumberOfRooms = maxNumberOfRooms,
dataSourceFactory = object : RoomSelectSearchDataSource.Factory {
override fun create(coroutineScope: CoroutineScope): RoomSelectSearchDataSource {
override fun create(coroutineScope: CoroutineScope, roomListService: RoomListService): RoomSelectSearchDataSource {
return RoomSelectSearchDataSource(
coroutineScope = coroutineScope,
roomListService = roomListService,
coroutineDispatchers = testCoroutineDispatchers(),
)
}
}
},
matrixClientProvider = matrixClientProvider,
sessionStore = sessionStore,
)
@@ -14,13 +14,19 @@ import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
@@ -72,6 +78,63 @@ class RoomSelectViewTest : RobolectricTest() {
onNodeWithText("Room with a single hero").assertIsDisplayed()
onNodeWithText("@alice:example.org").assertDoesNotExist()
}
@Test
fun `clicking on switch account emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<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(
+6
View File
@@ -14,6 +14,12 @@
"screen_bug_report_.*"
]
},
{
"name" : ":libraries:roomselect:impl",
"includeRegex" : [
"screen\\.room_select\\..*"
]
},
{
"name" : ":features:rageshake:api",
"includeRegex" : [