Introduce ShareDataSender so that SharePresenter is not session scoped

Instead of creating a SharePresenter from the graph of another session, the
SharePresenter now gets the client of the selected session from the
MatrixClientProvider and uses an AppScope ShareDataSender to send the data.

To be able to send media from AppScope, MediaSenderRoomFactory is now bound in
AppScope and takes the MatrixClient as a parameter, and a new
MediaOptimizationConfigProviderFactory provides the media optimization config
of any session.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-07 10:34:20 +02:00
parent ed2a553736
commit 72e3a7d92b
15 changed files with 436 additions and 264 deletions
@@ -0,0 +1,101 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.share.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.share.api.OnSharedData
import io.element.android.features.share.api.ShareIntentData
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProviderFactory
import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import kotlin.coroutines.cancellation.CancellationException
@ContributesBinding(AppScope::class)
class DefaultShareDataSender(
private val mediaSenderRoomFactory: MediaSenderRoomFactory,
private val mediaOptimizationConfigProviderFactory: MediaOptimizationConfigProviderFactory,
private val activeRoomsHolder: ActiveRoomsHolder,
private val onSharedData: OnSharedData,
) : ShareDataSender {
override suspend fun send(
client: MatrixClient,
shareIntentData: ShareIntentData,
roomIds: List<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 {
val mediaOptimizationConfigProvider = mediaOptimizationConfigProviderFactory.create(client)
roomIds
.map { roomId ->
val room = client.findJoinedRoom(roomId) ?: return@map false
val mediaSender = mediaSenderRoomFactory.create(client = client, room = room)
filesToShare
.map { fileToShare ->
val result = mediaSender.sendMedia(
caption = shareIntentData.text,
uri = fileToShare.uri,
mimeType = fileToShare.mimeType,
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
)
// If the coroutine was cancelled, destroy the room and rethrow the exception
val cancellationException = result.exceptionOrNull() as? CancellationException
if (cancellationException != null) {
if (activeRoomsHolder.getActiveRoomMatching(client.sessionId, roomId) == null) {
room.destroy()
}
throw cancellationException
}
result.isSuccess
}
.all { isSuccess -> isSuccess }
.also {
if (activeRoomsHolder.getActiveRoomMatching(client.sessionId, roomId) == null) {
room.destroy()
}
}
}
.all { it }
}
}
}
// Handle post-processing of shared data
onSharedData(shareIntentData)
if (!result) {
error("Failed to handle incoming share intent")
}
}
private suspend fun MatrixClient.findJoinedRoom(roomId: RoomId): JoinedRoom? {
return activeRoomsHolder.getActiveRoom(sessionId)
?.takeIf { it.roomId == roomId }
?: getJoinedRoom(roomId)
}
}
@@ -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>
}
@@ -11,12 +11,7 @@ package io.element.android.features.share.impl
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
@@ -28,20 +23,15 @@ import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.share.api.ShareIntentData
import io.element.android.features.share.impl.di.ShareBindings
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.SessionGraphFactory
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ContributesNode(SessionScope::class)
@AssistedInject
@@ -51,8 +41,6 @@ class ShareNode(
presenterFactory: SharePresenter.Factory,
private val roomSelectEntryPoint: RoomSelectEntryPoint,
private val sessionId: SessionId,
private val matrixClientProvider: MatrixClientProvider,
private val sessionGraphFactory: SessionGraphFactory,
) : ParentNode<ShareNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget),
@@ -66,53 +54,14 @@ class ShareNode(
data class Inputs(val shareIntentData: ShareIntentData) : NodeInputs
/**
* The presenter used to share the data, and the session it belongs to.
*/
private data class ActiveShare(
val sessionId: SessionId,
val presenter: SharePresenter,
)
private val inputs = inputs<Inputs>()
private val presenter = presenterFactory.create(inputs.shareIntentData, sessionId)
private val callback: ShareEntryPoint.Callback = callback()
private val currentSessionShare = ActiveShare(
sessionId = sessionId,
presenter = presenterFactory.create(inputs.shareIntentData),
)
private var activeShare by mutableStateOf(currentSessionShare)
private fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) {
if (sessionId == currentSessionShare.sessionId) {
activeShare = currentSessionShare
currentSessionShare.presenter.onRoomSelected(roomIds)
} else if (sessionId == activeShare.sessionId) {
// Retry with the same other session
activeShare.presenter.onRoomSelected(roomIds)
} else {
lifecycleScope.launch {
// Share using the graph of the selected session, the current session will be changed once the share is done
matrixClientProvider.getOrRestore(sessionId)
.onSuccess { client ->
val presenterFactory = (sessionGraphFactory.create(client) as ShareBindings).sharePresenterFactory()
activeShare = ActiveShare(
sessionId = sessionId,
presenter = presenterFactory.create(inputs.shareIntentData),
).also {
it.presenter.onRoomSelected(roomIds)
}
}
.onFailure {
Timber.e(it, "Failed to get the client for session $sessionId")
}
}
}
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
val callback = object : RoomSelectEntryPoint.Callback {
override fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) {
this@ShareNode.onRoomSelected(sessionId, roomIds)
presenter.onRoomSelected(sessionId, roomIds)
}
override fun onCancel() {
@@ -140,14 +89,11 @@ class ShareNode(
navModel = navModel,
)
val currentShare = activeShare
key(currentShare) {
val state = currentShare.presenter.present()
ShareView(
state = state,
onShareSuccess = { roomIds -> callback.onDone(currentShare.sessionId, roomIds) },
)
}
val state = presenter.present()
ShareView(
state = state,
onShareSuccess = { roomIds -> callback.onDone(state.sessionId, roomIds) },
)
}
}
}
@@ -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
)
@@ -1,20 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.share.impl.di
import dev.zacsweers.metro.ContributesTo
import io.element.android.features.share.impl.SharePresenter
import io.element.android.libraries.di.SessionScope
/**
* Used to get a [SharePresenter.Factory] from the graph of a session which is not the current one.
*/
@ContributesTo(SessionScope::class)
interface ShareBindings {
fun sharePresenterFactory(): SharePresenter.Factory
}
@@ -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,
)
@@ -14,13 +14,10 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.share.api.ShareIntentData
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.SessionGraphFactory
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
@@ -42,13 +39,9 @@ class DefaultShareEntryPointTest {
ShareNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { createSharePresenter() },
presenterFactory = { _, _ -> createSharePresenter() },
roomSelectEntryPoint = FakeRoomSelectEntryPoint(),
sessionId = A_SESSION_ID,
matrixClientProvider = FakeMatrixClientProvider(),
sessionGraphFactory = object : SessionGraphFactory {
override fun create(client: MatrixClient): Any = lambdaError()
},
)
}
val callback = object : ShareEntryPoint.Callback {
@@ -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,
)
}
@@ -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),
)
}
}