Merge pull request #5722 from element-hq/feature/bma/moduleCleanup

Module cleanup
This commit is contained in:
Benoit Marty
2025-11-18 16:14:10 +01:00
committed by GitHub
47 changed files with 642 additions and 407 deletions

View File

@@ -64,6 +64,7 @@ dependencies {
testImplementation(projects.features.forward.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.rageshake.test)
testImplementation(projects.services.appnavstate.impl)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test)

View File

@@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
@@ -108,7 +109,7 @@ class JoinedRoomLoadedFlowNodeTest {
roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(),
spaceEntryPoint: SpaceEntryPoint = FakeSpaceEntryPoint(),
forwardEntryPoint: ForwardEntryPoint = FakeForwardEntryPoint(),
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(),
matrixClient: FakeMatrixClient = FakeMatrixClient(),
) = JoinedRoomLoadedFlowNode(
buildContext = BuildContext.root(savedStateMap = null),
@@ -192,7 +193,7 @@ class JoinedRoomLoadedFlowNodeTest {
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Root())
val activeRoomsHolder = ActiveRoomsHolder()
val activeRoomsHolder = DefaultActiveRoomsHolder()
val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs, FakeJoinedRoomLoadedFlowNodeCallback()),
messagesEntryPoint = fakeMessagesEntryPoint,
@@ -215,7 +216,7 @@ class JoinedRoomLoadedFlowNodeTest {
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Root())
val activeRoomsHolder = ActiveRoomsHolder().apply {
val activeRoomsHolder = DefaultActiveRoomsHolder().apply {
addRoom(room)
}
val roomFlowNode = createJoinedRoomLoadedFlowNode(

View File

@@ -1,5 +1,3 @@
import extension.setupDependencyInjection
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023, 2024 New Vector Ltd.
@@ -16,8 +14,6 @@ android {
namespace = "io.element.android.features.cachecleaner.api"
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.architecture)
implementation(libs.androidx.startup)

View File

@@ -97,6 +97,7 @@ dependencies {
testImplementation(projects.libraries.matrixmedia.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.appnavstate.impl)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.toolbox.test)
}

View File

@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -86,7 +87,7 @@ class DefaultCallWidgetProviderTest {
// No room from the client
givenGetRoomResult(A_ROOM_ID, null)
}
val activeRoomsHolder = ActiveRoomsHolder().apply {
val activeRoomsHolder = DefaultActiveRoomsHolder().apply {
// A current active room with the same room id
addRoom(
FakeJoinedRoom(
@@ -130,7 +131,7 @@ class DefaultCallWidgetProviderTest {
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider(),
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(),
) = DefaultCallWidgetProvider(
matrixClientsProvider = matrixClientProvider,
appPreferencesStore = appPreferencesStore,

View File

@@ -90,6 +90,7 @@ dependencies {
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.mediaupload.impl)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.permissions.test)

View File

@@ -37,7 +37,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.allFiles
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
@@ -56,7 +56,7 @@ class AttachmentsPreviewPresenter(
@Assisted private val onDoneListener: OnDoneListener,
@Assisted private val timelineMode: Timeline.Mode,
@Assisted private val inReplyToEventId: EventId?,
mediaSenderFactory: MediaSender.Factory,
mediaSenderFactory: MediaSenderFactory,
private val permalinkBuilder: PermalinkBuilder,
private val temporaryUriDeleter: TemporaryUriDeleter,
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,

View File

@@ -61,7 +61,7 @@ import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
@@ -107,7 +107,7 @@ class MessageComposerPresenter(
private val mediaPickerProvider: PickerProvider,
private val sessionPreferencesStore: SessionPreferencesStore,
private val localMediaFactory: LocalMediaFactory,
private val mediaSenderFactory: MediaSender.Factory,
mediaSenderFactory: MediaSenderFactory,
private val snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService,
private val locationService: LocationService,

View File

@@ -32,7 +32,7 @@ import io.element.android.features.messages.api.timeline.voicemessages.composer.
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
@@ -57,7 +57,7 @@ class DefaultVoiceMessageComposerPresenter(
@Assisted private val timelineMode: Timeline.Mode,
private val voiceRecorder: VoiceRecorder,
private val analyticsService: AnalyticsService,
mediaSenderFactory: MediaSender.Factory,
mediaSenderFactory: MediaSenderFactory,
private val player: VoiceMessageComposerPlayer,
private val messageComposerContext: MessageComposerContext,
permissionsPresenterFactory: PermissionsPresenter.Factory

View File

@@ -78,7 +78,6 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
@@ -166,6 +165,7 @@ class MessagesPresenterTest {
val toggleReactionSuccess = lambdaRecorder { _: String, _: EventOrTransactionId -> Result.success(true) }
val toggleReactionFailure =
lambdaRecorder { _: String, _: EventOrTransactionId -> Result.failure<Boolean>(IllegalStateException("Failed to send reaction")) }
val addRecentEmojiResult = lambdaRecorder { _: String -> Result.success(Unit) }
val timeline = FakeTimeline().apply {
this.toggleReactionLambda = toggleReactionSuccess
@@ -184,7 +184,8 @@ class MessagesPresenterTest {
val presenter = createMessagesPresenter(
timeline = timeline,
joinedRoom = room,
coroutineDispatchers = coroutineDispatchers
addRecentEmoji = AddRecentEmoji { addRecentEmojiResult(it) },
coroutineDispatchers = coroutineDispatchers,
)
presenter.testWithLifecycleOwner {
skipItems(1)
@@ -201,6 +202,7 @@ class MessagesPresenterTest {
assert(toggleReactionFailure)
.isCalledOnce()
.with(value("👍"), value(AN_EVENT_ID.toEventOrTransactionId()))
addRecentEmojiResult.assertions().isCalledOnce().with(value("👍"))
}
}
@@ -212,7 +214,9 @@ class MessagesPresenterTest {
toggle = !toggle
Result.success(toggle)
}
val addRecentEmoji = lambdaRecorder { _: String ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
this.toggleReactionLambda = toggleReactionSuccess
}
@@ -230,6 +234,7 @@ class MessagesPresenterTest {
val presenter = createMessagesPresenter(
timeline = timeline,
joinedRoom = room,
addRecentEmoji = AddRecentEmoji { addRecentEmoji(it) },
coroutineDispatchers = coroutineDispatchers
)
presenter.testWithLifecycleOwner {
@@ -244,6 +249,7 @@ class MessagesPresenterTest {
listOf(value("👍"), value(AN_EVENT_ID.toEventOrTransactionId())),
)
skipItems(1)
addRecentEmoji.assertions().isCalledOnce().with(value("👍"))
}
}
@@ -1196,10 +1202,12 @@ class MessagesPresenterTest {
)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(
action = TimelineItemAction.ReplyInThread,
event = aMessageEvent(threadInfo = TimelineItemThreadInfo.ThreadResponse(A_THREAD_ID))
))
initialState.eventSink(
MessagesEvents.HandleAction(
action = TimelineItemAction.ReplyInThread,
event = aMessageEvent(threadInfo = TimelineItemThreadInfo.ThreadResponse(A_THREAD_ID))
)
)
awaitItem()
openThreadLambda.assertions().isCalledOnce().with(value(A_THREAD_ID), value(null))
}
@@ -1216,14 +1224,16 @@ class MessagesPresenterTest {
)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(
action = TimelineItemAction.ReplyInThread,
event = aMessageEvent(
// The event id will be used as the thread id instead
eventId = AN_EVENT_ID,
threadInfo = null,
initialState.eventSink(
MessagesEvents.HandleAction(
action = TimelineItemAction.ReplyInThread,
event = aMessageEvent(
// The event id will be used as the thread id instead
eventId = AN_EVENT_ID,
threadInfo = null,
)
)
))
)
awaitItem()
openThreadLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID.toThreadId()), value(null))
}
@@ -1334,7 +1344,7 @@ class MessagesPresenterTest {
encryptionService: FakeEncryptionService = FakeEncryptionService(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
actionListEventSink: (ActionListEvents) -> Unit = {},
addRecentEmoji: AddRecentEmoji = AddRecentEmoji(FakeMatrixClient(), testCoroutineDispatchers()),
addRecentEmoji: AddRecentEmoji = AddRecentEmoji { _ -> lambdaError() },
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(),
): MessagesPresenter {
return MessagesPresenter(

View File

@@ -41,8 +41,9 @@ 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.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.impl.DefaultMediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
@@ -601,17 +602,15 @@ class AttachmentsPreviewPresenterTest {
return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia),
onDoneListener = onDoneListener,
mediaSenderFactory = object : MediaSender.Factory {
override fun create(timelineMode: Timeline.Mode): MediaSender {
return MediaSender(
preProcessor = mediaPreProcessor,
room = room,
timelineMode = timelineMode,
mediaOptimizationConfigProvider = {
MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD)
}
)
}
mediaSenderFactory = MediaSenderFactory { timelineMode ->
DefaultMediaSender(
preProcessor = mediaPreProcessor,
room = room,
timelineMode = timelineMode,
mediaOptimizationConfigProvider = {
MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD)
}
)
},
permalinkBuilder = permalinkBuilder,
temporaryUriDeleter = temporaryUriDeleter,

View File

@@ -21,7 +21,6 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.mediaupload.api.MaxUploadSizeProvider
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
@@ -206,7 +205,7 @@ class DefaultMediaOptimizationSelectorPresenterTest {
@Test
fun `present - max upload size will default to 100MB if we can't get it`() = runTest {
val presenter = createDefaultMediaOptimizationSelectorPresenter(
maxUploadSizeProvider = MaxUploadSizeProvider(FakeMatrixClient(getMaxUploadSizeResult = { Result.failure(AN_EXCEPTION) }))
maxUploadSizeProvider = MaxUploadSizeProvider { Result.failure(AN_EXCEPTION) }
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -233,9 +232,7 @@ class DefaultMediaOptimizationSelectorPresenterTest {
private fun createDefaultMediaOptimizationSelectorPresenter(
localMedia: LocalMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider(
FakeMatrixClient(getMaxUploadSizeResult = { Result.success(1_000L) }),
),
maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider { Result.success(1_000L) },
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)),
mediaExtractorFactory: FakeVideoMetadataExtractorFactory = FakeVideoMetadataExtractorFactory(),

View File

@@ -75,8 +75,9 @@ import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.impl.DefaultMediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
@@ -1551,20 +1552,18 @@ class MessageComposerPresenterTest {
mediaPickerProvider = pickerProvider,
sessionPreferencesStore = sessionPreferencesStore,
localMediaFactory = localMediaFactory,
mediaSenderFactory = object : MediaSender.Factory {
override fun create(timelineMode: Timeline.Mode): MediaSender {
return MediaSender(
preProcessor = mediaPreProcessor,
room = room,
timelineMode = timelineMode,
mediaOptimizationConfigProvider = {
MediaOptimizationConfig(
mediaSenderFactory = MediaSenderFactory { timelineMode ->
DefaultMediaSender(
preProcessor = mediaPreProcessor,
room = room,
timelineMode = timelineMode,
mediaOptimizationConfigProvider = {
MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD
)
}
)
}
}
)
},
snackbarDispatcher = snackbarDispatcher,
analyticsService = analyticsService,

View File

@@ -30,7 +30,7 @@ import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.impl.DefaultMediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.api.aPermissionsState
@@ -75,7 +75,7 @@ class VoiceMessageComposerPresenterTest {
},
)
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
private val mediaSender = MediaSender(
private val mediaSender = DefaultMediaSender(
preProcessor = mediaPreProcessor,
room = joinedRoom,
timelineMode = Timeline.Mode.Live,
@@ -668,11 +668,7 @@ class VoiceMessageComposerPresenterTest {
timelineMode = Timeline.Mode.Live,
voiceRecorder = voiceRecorder,
analyticsService = analyticsService,
mediaSenderFactory = object : MediaSender.Factory {
override fun create(timelineMode: Timeline.Mode): MediaSender {
return mediaSender
}
},
mediaSenderFactory = { mediaSender },
player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this),
messageComposerContext = messageComposerContext,
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),

View File

@@ -25,4 +25,5 @@ dependencies {
implementation(projects.libraries.voicerecorder.test)
implementation(projects.services.analytics.test)
implementation(projects.tests.testutils)
implementation(projects.libraries.mediaupload.impl)
}

View File

@@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.impl.DefaultMediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
@@ -24,7 +25,7 @@ import kotlinx.coroutines.CoroutineScope
class FakeDefaultVoiceMessageComposerPresenterFactory(
private val sessionCoroutineScope: CoroutineScope,
private val mediaSender: MediaSender = MediaSender(
private val mediaSender: MediaSender = DefaultMediaSender(
preProcessor = FakeMediaPreProcessor(),
room = FakeJoinedRoom(),
timelineMode = Timeline.Mode.Live,
@@ -37,11 +38,7 @@ class FakeDefaultVoiceMessageComposerPresenterFactory(
timelineMode = timelineMode,
voiceRecorder = FakeVoiceRecorder(),
analyticsService = FakeAnalyticsService(),
mediaSenderFactory = object : MediaSender.Factory {
override fun create(timelineMode: Timeline.Mode): MediaSender {
return mediaSender
}
},
mediaSenderFactory = { mediaSender },
player = VoiceMessageComposerPlayer(
mediaPlayer = FakeMediaPlayer(),
sessionCoroutineScope = sessionCoroutineScope,

View File

@@ -115,6 +115,7 @@ dependencies {
testImplementation(projects.libraries.indicator.test)
testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.services.appnavstate.impl)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test)
}

View File

@@ -19,7 +19,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.push.test.FakePushService
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.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
@@ -34,7 +34,7 @@ import org.robolectric.RobolectricTestRunner
class DefaultClearCacheUseCaseTest {
@Test
fun `execute clear cache should do all the expected tasks`() = runTest {
val activeRoomsHolder = ActiveRoomsHolder().apply { addRoom(FakeJoinedRoom()) }
val activeRoomsHolder = DefaultActiveRoomsHolder().apply { addRoom(FakeJoinedRoom()) }
val clearCacheLambda = lambdaRecorder<Unit> { }
val matrixClient = FakeMatrixClient(
sessionId = A_SESSION_ID,

View File

@@ -48,4 +48,5 @@ dependencies {
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.roomselect.test)
testImplementation(projects.services.appnavstate.impl)
}

View File

@@ -23,10 +23,8 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope
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.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -39,7 +37,7 @@ class SharePresenter(
private val sessionCoroutineScope: CoroutineScope,
private val shareIntentHandler: ShareIntentHandler,
private val matrixClient: MatrixClient,
private val mediaPreProcessor: MediaPreProcessor,
private val mediaSenderRoomFactory: MediaSenderRoomFactory,
private val activeRoomsHolder: ActiveRoomsHolder,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
) : Presenter<ShareState> {
@@ -88,12 +86,7 @@ class SharePresenter(
roomIds
.map { roomId ->
val room = getJoinedRoom(roomId) ?: return@map false
val mediaSender = MediaSender(
preProcessor = mediaPreProcessor,
room = room,
timelineMode = Timeline.Mode.Live,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
)
val mediaSender = mediaSenderRoomFactory.create(room = room)
filesToShare
.map { fileToShare ->
val result = mediaSender.sendMedia(

View File

@@ -17,18 +17,17 @@ import com.google.common.truth.Truth.assertThat
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.EventId
import io.element.android.libraries.matrix.api.media.FileInfo
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.media.FakeMediaUploadHandler
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.MediaPreProcessor
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.FakeMediaPreProcessor
import io.element.android.libraries.mediaupload.test.FakeMediaSender
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.TestScope
@@ -37,7 +36,6 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File
@RunWith(RobolectricTestRunner::class)
class SharePresenterTest {
@@ -121,18 +119,16 @@ class SharePresenterTest {
@Test
fun `present - send media ok`() = runTest {
val sendFileResult =
lambdaRecorder<File, FileInfo, String?, String?, EventId?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val sendMediaResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val joinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendFileLambda = sendFileResult
},
liveTimeline = FakeTimeline(),
)
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, joinedRoom)
}
val mediaSender = FakeMediaSender(
sendMediaResult = sendMediaResult,
)
val presenter = createSharePresenter(
matrixClient = matrixClient,
shareIntentHandler = FakeShareIntentHandler { _, onFile, _ ->
@@ -144,7 +140,8 @@ class SharePresenterTest {
)
)
)
}
},
mediaSenderRoomFactory = MediaSenderRoomFactory { mediaSender },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -156,7 +153,7 @@ class SharePresenterTest {
val success = awaitItem()
assertThat(success.shareAction.isSuccess()).isTrue()
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
sendFileResult.assertions().isCalledOnce()
sendMediaResult.assertions().isCalledOnce()
}
}
}
@@ -165,17 +162,17 @@ internal fun TestScope.createSharePresenter(
intent: Intent = Intent(),
shareIntentHandler: ShareIntentHandler = FakeShareIntentHandler(),
matrixClient: MatrixClient = FakeMatrixClient(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(),
mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { FakeMediaSender() },
mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
): SharePresenter {
return SharePresenter(
intent = intent,
sessionCoroutineScope = this,
shareIntentHandler = shareIntentHandler,
matrixClient = matrixClient,
mediaPreProcessor = mediaPreProcessor,
activeRoomsHolder = activeRoomsHolder,
mediaSenderRoomFactory = mediaSenderRoomFactory,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
)
}

View File

@@ -1,6 +1,5 @@
import config.BuildTimeConfig
import extension.buildConfigFieldStr
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
@@ -17,8 +16,6 @@ plugins {
alias(libs.plugins.kotlin.serialization)
}
setupDependencyInjection()
android {
namespace = "io.element.android.libraries.matrix.api"

View File

@@ -8,28 +8,12 @@
package io.element.android.libraries.matrix.api.mxc
import dev.zacsweers.metro.Inject
@Inject
class MxcTools {
/**
* Regex to match a Matrix Content (mxc://) URI.
*
* See: https://spec.matrix.org/v1.8/client-server-api/#matrix-content-mxc-uris
*/
private val mxcRegex = Regex("""^mxc://([^/]+)/([^/]+)$""")
interface MxcTools {
/**
* Sanitizes an mxcUri to be used as a relative file path.
*
* @param mxcUri the Matrix Content (mxc://) URI of the file.
* @return the relative file path as "<server-name>/<media-id>" or null if the mxcUri is invalid.
*/
fun mxcUri2FilePath(mxcUri: String): String? = mxcRegex.matchEntire(mxcUri)?.let { match ->
buildString {
append(match.groupValues[1])
append("/")
append(match.groupValues[2])
}
}
fun mxcUri2FilePath(mxcUri: String): String?
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 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.impl.mxc
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.mxc.MxcTools
@ContributesBinding(AppScope::class)
class DefaultMxcTools : MxcTools {
/**
* Regex to match a Matrix Content (mxc://) URI.
*
* See: https://spec.matrix.org/v1.8/client-server-api/#matrix-content-mxc-uris
*/
private val mxcRegex = Regex("""^mxc://([^/]+)/([^/]+)$""")
/**
* Sanitizes an mxcUri to be used as a relative file path.
*
* @param mxcUri the Matrix Content (mxc://) URI of the file.
* @return the relative file path as "<server-name>/<media-id>" or null if the mxcUri is invalid.
*/
override fun mxcUri2FilePath(mxcUri: String): String? = mxcRegex.matchEntire(mxcUri)?.let { match ->
buildString {
append(match.groupValues[1])
append("/")
append(match.groupValues[2])
}
}
}

View File

@@ -6,15 +6,15 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.mxc
package io.element.android.libraries.matrix.impl.mxc
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class MxcToolsTest {
class DefaultMxcToolsTest {
@Test
fun `mxcUri2FilePath returns extracted path`() {
val mxcTools = MxcTools()
val mxcTools = DefaultMxcTools()
val mxcUri = "mxc://server.org/abc123"
val filePath = mxcTools.mxcUri2FilePath(mxcUri)
assertThat(filePath).isEqualTo("server.org/abc123")
@@ -22,7 +22,7 @@ class MxcToolsTest {
@Test
fun `mxcUri2FilePath returns null for invalid data`() {
val mxcTools = MxcTools()
val mxcTools = DefaultMxcTools()
assertThat(mxcTools.mxcUri2FilePath("")).isNull()
assertThat(mxcTools.mxcUri2FilePath("mxc://server.org")).isNull()
assertThat(mxcTools.mxcUri2FilePath("mxc://server.org/")).isNull()

View File

@@ -19,6 +19,7 @@ dependencies {
api(projects.libraries.matrix.api)
api(libs.coroutines.core)
implementation(libs.coroutines.test)
implementation(projects.libraries.matrix.impl)
implementation(projects.services.analytics.api)
implementation(projects.tests.testutils)
implementation(libs.kotlinx.collections.immutable)

View File

@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 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.test.mxc
import io.element.android.libraries.matrix.api.mxc.MxcTools
import io.element.android.libraries.matrix.impl.mxc.DefaultMxcTools
class FakeMxcTools(
private val delegate: MxcTools = DefaultMxcTools()
) : MxcTools by delegate

View File

@@ -1,5 +1,3 @@
import extension.setupDependencyInjection
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023, 2024 New Vector Ltd.
@@ -12,8 +10,6 @@ plugins {
id("io.element.android-compose-library")
}
setupDependencyInjection()
android {
namespace = "io.element.android.libraries.mediapickers.test"
}

View File

@@ -1,4 +1,3 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
@@ -13,8 +12,6 @@ plugins {
id("io.element.android-library")
}
setupDependencyInjection()
android {
namespace = "io.element.android.libraries.mediaupload.api"
}
@@ -27,9 +24,4 @@ dependencies {
api(projects.libraries.matrix.api)
api(projects.libraries.preferences.api)
implementation(libs.coroutines.core)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.mediaupload.test)
}

View File

@@ -8,17 +8,9 @@
package io.element.android.libraries.mediaupload.api
import dev.zacsweers.metro.Inject
import io.element.android.libraries.matrix.api.MatrixClient
/**
* Provides the maximum upload size allowed by the Matrix server.
*/
@Inject
class MaxUploadSizeProvider(
private val matrixClient: MatrixClient,
) {
suspend fun getMaxUploadSize(): Result<Long> {
return matrixClient.getMaxFileUploadSize()
}
fun interface MaxUploadSizeProvider {
suspend fun getMaxUploadSize(): Result<Long>
}

View File

@@ -9,73 +9,41 @@
package io.element.android.libraries.mediaupload.api
import android.net.Uri
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
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.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import timber.log.Timber
import java.io.File
import java.util.concurrent.ConcurrentHashMap
@AssistedInject
class MediaSender(
private val preProcessor: MediaPreProcessor,
private val room: JoinedRoom,
@Assisted private val timelineMode: Timeline.Mode,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
) {
@AssistedFactory
interface Factory {
fun create(
timelineMode: Timeline.Mode,
): MediaSender
}
fun interface MediaSenderFactory {
/**
* Create a [MediaSender] for the given [Timeline.Mode], in the Room Scope.
*/
fun create(
timelineMode: Timeline.Mode,
): MediaSender
}
private val ongoingUploadJobs = ConcurrentHashMap<Job.Key, MediaUploadHandler>()
val hasOngoingMediaUploads get() = ongoingUploadJobs.isNotEmpty()
fun interface MediaSenderRoomFactory {
/**
* Create a [MediaSender] for the given [JoinedRoom], with timeline mode Live.
*/
fun create(
room: JoinedRoom,
): MediaSender
}
interface MediaSender {
suspend fun preProcessMedia(
uri: Uri,
mimeType: String,
mediaOptimizationConfig: MediaOptimizationConfig,
): Result<MediaUploadInfo> {
Timber.d("Pre-processing media | uri: ${mediaId(uri)} | mimeType: $mimeType")
return preProcessor
.process(
uri = uri,
mimeType = mimeType,
deleteOriginal = false,
mediaOptimizationConfig = mediaOptimizationConfig,
)
}
): Result<MediaUploadInfo>
suspend fun sendPreProcessedMedia(
mediaUploadInfo: MediaUploadInfo,
caption: String?,
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<Unit> {
val mediaLogId = mediaId(mediaUploadInfo.file)
return getTimeline().flatMap {
Timber.d("Started sending media $mediaLogId using timeline: ${it.mode}")
it.sendMedia(
uploadInfo = mediaUploadInfo,
caption = caption,
formattedCaption = formattedCaption,
inReplyToEventId = inReplyToEventId,
)
}
.handleSendResult(mediaLogId)
}
): Result<Unit>
suspend fun sendMedia(
uri: Uri,
@@ -84,147 +52,14 @@ class MediaSender(
formattedCaption: String? = null,
inReplyToEventId: EventId? = null,
mediaOptimizationConfig: MediaOptimizationConfig,
): Result<Unit> {
return preProcessor
.process(
uri = uri,
mimeType = mimeType,
deleteOriginal = false,
mediaOptimizationConfig = mediaOptimizationConfig,
)
.flatMapCatching { info ->
getTimeline().getOrThrow().sendMedia(
uploadInfo = info,
caption = caption,
formattedCaption = formattedCaption,
inReplyToEventId = inReplyToEventId,
)
}
.handleSendResult(mediaId(uri))
}
): Result<Unit>
suspend fun sendVoiceMessage(
uri: Uri,
mimeType: String,
waveForm: List<Float>,
inReplyToEventId: EventId? = null,
): Result<Unit> {
return preProcessor
.process(
uri = uri,
mimeType = mimeType,
deleteOriginal = true,
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
)
.flatMapCatching { info ->
val audioInfo = (info as MediaUploadInfo.Audio).audioInfo
val newInfo = MediaUploadInfo.VoiceMessage(
file = info.file,
audioInfo = audioInfo,
waveform = waveForm,
)
getTimeline().getOrThrow().sendMedia(
uploadInfo = newInfo,
caption = null,
formattedCaption = null,
inReplyToEventId = inReplyToEventId,
)
}
.handleSendResult(mediaId(uri))
}
): Result<Unit>
private fun Result<Unit>.handleSendResult(mediaId: String) = this
.onFailure { error ->
val job = ongoingUploadJobs.remove(Job)
Timber.e(error, "Sending media $mediaId failed. Removing ongoing upload job. Total: ${ongoingUploadJobs.size}")
if (error !is CancellationException) {
job?.cancel()
}
}
.onSuccess {
Timber.d("Sent media $mediaId successfully. Removing ongoing upload job. Total: ${ongoingUploadJobs.size}")
ongoingUploadJobs.remove(Job)
}
private suspend fun Timeline.sendMedia(
uploadInfo: MediaUploadInfo,
caption: String?,
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<Unit> {
val handler = when (uploadInfo) {
is MediaUploadInfo.Image -> {
sendImage(
file = uploadInfo.file,
thumbnailFile = uploadInfo.thumbnailFile,
imageInfo = uploadInfo.imageInfo,
caption = caption,
formattedCaption = formattedCaption,
inReplyToEventId = inReplyToEventId,
)
}
is MediaUploadInfo.Video -> {
sendVideo(
file = uploadInfo.file,
thumbnailFile = uploadInfo.thumbnailFile,
videoInfo = uploadInfo.videoInfo,
caption = caption,
formattedCaption = formattedCaption,
inReplyToEventId = inReplyToEventId,
)
}
is MediaUploadInfo.Audio -> {
sendAudio(
file = uploadInfo.file,
audioInfo = uploadInfo.audioInfo,
caption = caption,
formattedCaption = formattedCaption,
inReplyToEventId = inReplyToEventId,
)
}
is MediaUploadInfo.VoiceMessage -> {
sendVoiceMessage(
file = uploadInfo.file,
audioInfo = uploadInfo.audioInfo,
waveform = uploadInfo.waveform,
inReplyToEventId = inReplyToEventId,
)
}
is MediaUploadInfo.AnyFile -> {
sendFile(
file = uploadInfo.file,
fileInfo = uploadInfo.fileInfo,
caption = caption,
formattedCaption = formattedCaption,
inReplyToEventId = inReplyToEventId,
)
}
}
// We handle the cancellations here manually, so we suppress the warning
@Suppress("RunCatchingNotAllowed")
return handler
.mapCatching { uploadHandler ->
Timber.d("Added ongoing upload job, total: ${ongoingUploadJobs.size + 1}")
ongoingUploadJobs[Job] = uploadHandler
uploadHandler.await()
}
}
private suspend fun getTimeline(): Result<Timeline> {
return when (timelineMode) {
is Timeline.Mode.Thread -> {
room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = timelineMode.threadRootId))
}
else -> Result.success(room.liveTimeline)
}
}
/**
* Clean up any temporary files or resources used during the media processing.
*/
fun cleanUp() = preProcessor.cleanUp()
fun cleanUp()
}
private fun mediaId(uri: Uri?): String = uri?.path.orEmpty().hash()
private fun mediaId(file: File): String = file.path.orEmpty().hash()

View File

@@ -42,4 +42,7 @@ dependencies {
testCommonDependencies(libs)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.mediaupload.test)
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.mediaupload.api.MaxUploadSizeProvider
/**
* Provides the maximum upload size allowed by the Matrix server.
*/
@ContributesBinding(SessionScope::class)
class DefaultMaxUploadSizeProvider(
private val matrixClient: MatrixClient,
) : MaxUploadSizeProvider {
override suspend fun getMaxUploadSize(): Result<Long> {
return matrixClient.getMaxFileUploadSize()
}
}

View File

@@ -0,0 +1,264 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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 android.net.Uri
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.core.EventId
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.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.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import timber.log.Timber
import java.io.File
import java.util.concurrent.ConcurrentHashMap
@ContributesBinding(RoomScope::class)
class DefaultMediaSenderFactory(
private val preProcessor: MediaPreProcessor,
private val room: JoinedRoom,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
) : MediaSenderFactory {
override fun create(
timelineMode: Timeline.Mode,
): MediaSender {
return DefaultMediaSender(
preProcessor = preProcessor,
room = room,
timelineMode = timelineMode,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
)
}
}
@ContributesBinding(SessionScope::class)
class DefaultMediaSenderRoomFactory(
private val preProcessor: MediaPreProcessor,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
) : MediaSenderRoomFactory {
override fun create(
room: JoinedRoom,
): MediaSender {
return DefaultMediaSender(
preProcessor = preProcessor,
room = room,
timelineMode = Timeline.Mode.Live,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
)
}
}
class DefaultMediaSender(
private val preProcessor: MediaPreProcessor,
private val room: JoinedRoom,
private val timelineMode: Timeline.Mode,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
) : MediaSender {
private val ongoingUploadJobs = ConcurrentHashMap<Job.Key, MediaUploadHandler>()
val hasOngoingMediaUploads get() = ongoingUploadJobs.isNotEmpty()
override suspend fun preProcessMedia(
uri: Uri,
mimeType: String,
mediaOptimizationConfig: MediaOptimizationConfig,
): Result<MediaUploadInfo> {
Timber.d("Pre-processing media | uri: ${mediaId(uri)} | mimeType: $mimeType")
return preProcessor
.process(
uri = uri,
mimeType = mimeType,
deleteOriginal = false,
mediaOptimizationConfig = mediaOptimizationConfig,
)
}
override suspend fun sendPreProcessedMedia(
mediaUploadInfo: MediaUploadInfo,
caption: String?,
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<Unit> {
val mediaLogId = mediaId(mediaUploadInfo.file)
return getTimeline().flatMap {
Timber.d("Started sending media $mediaLogId using timeline: ${it.mode}")
it.sendMedia(
uploadInfo = mediaUploadInfo,
caption = caption,
formattedCaption = formattedCaption,
inReplyToEventId = inReplyToEventId,
)
}
.handleSendResult(mediaLogId)
}
override suspend fun sendMedia(
uri: Uri,
mimeType: String,
caption: String?,
formattedCaption: String?,
inReplyToEventId: EventId?,
mediaOptimizationConfig: MediaOptimizationConfig,
): Result<Unit> {
return preProcessor
.process(
uri = uri,
mimeType = mimeType,
deleteOriginal = false,
mediaOptimizationConfig = mediaOptimizationConfig,
)
.flatMapCatching { info ->
getTimeline().getOrThrow().sendMedia(
uploadInfo = info,
caption = caption,
formattedCaption = formattedCaption,
inReplyToEventId = inReplyToEventId,
)
}
.handleSendResult(mediaId(uri))
}
override suspend fun sendVoiceMessage(
uri: Uri,
mimeType: String,
waveForm: List<Float>,
inReplyToEventId: EventId?,
): Result<Unit> {
return preProcessor
.process(
uri = uri,
mimeType = mimeType,
deleteOriginal = true,
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
)
.flatMapCatching { info ->
val audioInfo = (info as MediaUploadInfo.Audio).audioInfo
val newInfo = MediaUploadInfo.VoiceMessage(
file = info.file,
audioInfo = audioInfo,
waveform = waveForm,
)
getTimeline().getOrThrow().sendMedia(
uploadInfo = newInfo,
caption = null,
formattedCaption = null,
inReplyToEventId = inReplyToEventId,
)
}
.handleSendResult(mediaId(uri))
}
private fun Result<Unit>.handleSendResult(mediaId: String) = this
.onFailure { error ->
val job = ongoingUploadJobs.remove(Job)
Timber.e(error, "Sending media $mediaId failed. Removing ongoing upload job. Total: ${ongoingUploadJobs.size}")
if (error !is CancellationException) {
job?.cancel()
}
}
.onSuccess {
Timber.d("Sent media $mediaId successfully. Removing ongoing upload job. Total: ${ongoingUploadJobs.size}")
ongoingUploadJobs.remove(Job)
}
private suspend fun Timeline.sendMedia(
uploadInfo: MediaUploadInfo,
caption: String?,
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<Unit> {
val handler = when (uploadInfo) {
is MediaUploadInfo.Image -> {
sendImage(
file = uploadInfo.file,
thumbnailFile = uploadInfo.thumbnailFile,
imageInfo = uploadInfo.imageInfo,
caption = caption,
formattedCaption = formattedCaption,
inReplyToEventId = inReplyToEventId,
)
}
is MediaUploadInfo.Video -> {
sendVideo(
file = uploadInfo.file,
thumbnailFile = uploadInfo.thumbnailFile,
videoInfo = uploadInfo.videoInfo,
caption = caption,
formattedCaption = formattedCaption,
inReplyToEventId = inReplyToEventId,
)
}
is MediaUploadInfo.Audio -> {
sendAudio(
file = uploadInfo.file,
audioInfo = uploadInfo.audioInfo,
caption = caption,
formattedCaption = formattedCaption,
inReplyToEventId = inReplyToEventId,
)
}
is MediaUploadInfo.VoiceMessage -> {
sendVoiceMessage(
file = uploadInfo.file,
audioInfo = uploadInfo.audioInfo,
waveform = uploadInfo.waveform,
inReplyToEventId = inReplyToEventId,
)
}
is MediaUploadInfo.AnyFile -> {
sendFile(
file = uploadInfo.file,
fileInfo = uploadInfo.fileInfo,
caption = caption,
formattedCaption = formattedCaption,
inReplyToEventId = inReplyToEventId,
)
}
}
// We handle the cancellations here manually, so we suppress the warning
@Suppress("RunCatchingNotAllowed")
return handler
.mapCatching { uploadHandler ->
Timber.d("Added ongoing upload job, total: ${ongoingUploadJobs.size + 1}")
ongoingUploadJobs[Job] = uploadHandler
uploadHandler.await()
}
}
private suspend fun getTimeline(): Result<Timeline> {
return when (timelineMode) {
is Timeline.Mode.Thread -> {
room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = timelineMode.threadRootId))
}
else -> Result.success(room.liveTimeline)
}
}
/**
* Clean up any temporary files or resources used during the media processing.
*/
override fun cleanUp() = preProcessor.cleanUp()
}
private fun mediaId(uri: Uri?): String = uri?.path.orEmpty().hash()
private fun mediaId(file: File): String = file.path.orEmpty().hash()

View File

@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaupload.api
package io.element.android.libraries.mediaupload.impl
import android.net.Uri
import com.google.common.truth.Truth.assertThat
@@ -19,6 +19,9 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
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.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -33,7 +36,7 @@ import org.robolectric.RobolectricTestRunner
import java.io.File
@RunWith(RobolectricTestRunner::class)
class MediaSenderTest {
class DefaultMediaSenderTest {
private val mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
@@ -42,7 +45,7 @@ class MediaSenderTest {
@Test
fun `given an attachment when sending it the preprocessor always runs`() = runTest {
val preProcessor = FakeMediaPreProcessor()
val sender = createMediaSender(
val sender = createDefaultMediaSender(
preProcessor = preProcessor,
room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
@@ -77,7 +80,7 @@ class MediaSenderTest {
sendImageLambda = sendImageResult
},
)
val sender = createMediaSender(room = room)
val sender = createDefaultMediaSender(room = room)
val uri = Uri.parse("content://image.jpg")
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
@@ -88,7 +91,7 @@ class MediaSenderTest {
val preProcessor = FakeMediaPreProcessor().apply {
givenResult(Result.failure(Exception()))
}
val sender = createMediaSender(preProcessor)
val sender = createDefaultMediaSender(preProcessor)
val uri = Uri.parse("content://image.jpg")
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
@@ -110,7 +113,7 @@ class MediaSenderTest {
sendImageLambda = sendImageResult
},
)
val sender = createMediaSender(
val sender = createDefaultMediaSender(
preProcessor = preProcessor,
room = room,
)
@@ -133,7 +136,7 @@ class MediaSenderTest {
sendFileLambda = sendFileResult
},
)
val sender = createMediaSender(room = room)
val sender = createDefaultMediaSender(room = room)
val sendJob = launch {
val uri = Uri.parse("content://image.jpg")
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
@@ -155,11 +158,11 @@ class MediaSenderTest {
sendFileResult.assertions().isCalledOnce()
}
private fun createMediaSender(
private fun createDefaultMediaSender(
preProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
room: JoinedRoom = FakeJoinedRoom(),
mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = MediaOptimizationConfigProvider { mediaOptimizationConfig },
) = MediaSender(
) = DefaultMediaSender(
preProcessor = preProcessor,
room = room,
timelineMode = Timeline.Mode.Live,

View File

@@ -0,0 +1,64 @@
/*
* Copyright (c) 2025 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.test
import android.net.Uri
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.tests.testutils.lambda.lambdaError
class FakeMediaSender(
private val preProcessMediaResult: () -> Result<MediaUploadInfo> = { lambdaError() },
private val sendPreProcessedMediaResult: () -> Result<Unit> = { lambdaError() },
private val sendMediaResult: () -> Result<Unit> = { lambdaError() },
private val sendVoiceMessageResult: () -> Result<Unit> = { lambdaError() },
private val cleanUpResult: () -> Unit = { lambdaError() },
) : MediaSender {
override suspend fun preProcessMedia(
uri: Uri,
mimeType: String,
mediaOptimizationConfig: MediaOptimizationConfig,
): Result<MediaUploadInfo> {
return preProcessMediaResult()
}
override suspend fun sendPreProcessedMedia(
mediaUploadInfo: MediaUploadInfo,
caption: String?,
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<Unit> {
return sendPreProcessedMediaResult()
}
override suspend fun sendMedia(
uri: Uri,
mimeType: String,
caption: String?,
formattedCaption: String?,
inReplyToEventId: EventId?,
mediaOptimizationConfig: MediaOptimizationConfig,
): Result<Unit> {
return sendMediaResult()
}
override suspend fun sendVoiceMessage(
uri: Uri,
mimeType: String,
waveForm: List<Float>,
inReplyToEventId: EventId?,
): Result<Unit> {
return sendVoiceMessageResult()
}
override fun cleanUp() {
cleanUpResult()
}
}

View File

@@ -84,6 +84,7 @@ dependencies {
testImplementation(projects.features.enterprise.test)
testImplementation(projects.features.lockscreen.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.services.appnavstate.impl)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.toolbox.impl)
testImplementation(projects.services.toolbox.test)

View File

@@ -40,6 +40,7 @@ import io.element.android.libraries.push.impl.push.FakeOnNotifiableEventReceived
import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.services.toolbox.test.strings.FakeStringProvider
@@ -482,7 +483,7 @@ class NotificationBroadcastReceiverHandlerTest {
onNotifiableEventReceived: OnNotifiableEventReceived = FakeOnNotifiableEventReceived(),
stringProvider: StringProvider = FakeStringProvider(),
replyMessageExtractor: ReplyMessageExtractor = FakeReplyMessageExtractor(),
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(),
): NotificationBroadcastReceiverHandler {
return NotificationBroadcastReceiverHandler(
appCoroutineScope = this,

View File

@@ -1,5 +1,3 @@
import extension.setupDependencyInjection
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
@@ -16,8 +14,6 @@ android {
namespace = "io.element.android.libraries.recentemojis.api"
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)

View File

@@ -8,17 +8,6 @@
package io.element.android.libraries.recentemojis.api
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.coroutines.withContext
@Inject
class AddRecentEmoji(
private val client: MatrixClient,
private val dispatchers: CoroutineDispatchers,
) {
suspend operator fun invoke(emoji: String): Result<Unit> = withContext(dispatchers.io) {
client.addRecentEmoji(emoji)
}
fun interface AddRecentEmoji {
suspend operator fun invoke(emoji: String): Result<Unit>
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.recentemojis.impl
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.recentemojis.api.AddRecentEmoji
import kotlinx.coroutines.withContext
@ContributesBinding(SessionScope::class)
class DefaultAddRecentEmoji(
private val client: MatrixClient,
private val dispatchers: CoroutineDispatchers,
) : AddRecentEmoji {
override suspend operator fun invoke(emoji: String): Result<Unit> = withContext(dispatchers.io) {
client.addRecentEmoji(emoji)
}
}

View File

@@ -12,8 +12,8 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.mxc.MxcTools
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
import io.element.android.libraries.matrix.test.mxc.FakeMxcTools
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -131,7 +131,7 @@ private fun createDefaultVoiceMessageMediaRepo(
mxcUri: String = MXC_URI,
) = DefaultVoiceMessageMediaRepo(
cacheDir = temporaryFolder.root,
mxcTools = MxcTools(),
mxcTools = FakeMxcTools(),
matrixMediaLoader = matrixMediaLoader,
mediaSource = MediaSource(
url = mxcUri,

View File

@@ -1,5 +1,3 @@
import extension.setupDependencyInjection
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
@@ -15,8 +13,6 @@ android {
namespace = "io.element.android.libraries.workmanager.api"
}
setupDependencyInjection()
dependencies {
api(libs.androidx.workmanager.runtime)

View File

@@ -22,6 +22,10 @@ import org.gradle.plugin.use.PluginDependency
fun Project.setupDependencyInjection(
generateNodeFactories: Boolean = shouldApplyAppyxCodegen(),
) {
if (project.path.endsWith(":api")) {
error("api module should not use setupDependencyInjection(). Move the implementation to `:impl` module")
}
val libs = the<LibrariesForLibs>()
// Apply Metro plugin and configure it

View File

@@ -1,5 +1,3 @@
import extension.setupDependencyInjection
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2024 New Vector Ltd.
@@ -16,8 +14,6 @@ android {
namespace = "io.element.android.services.appnavstate.api"
}
setupDependencyInjection()
dependencies {
implementation(libs.coroutines.core)
implementation(libs.androidx.lifecycle.runtime)

View File

@@ -8,63 +8,36 @@
package io.element.android.services.appnavstate.api
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
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.room.JoinedRoom
import java.util.concurrent.ConcurrentHashMap
/**
* Holds the active rooms for a given session so they can be reused instead of instantiating new ones.
*/
@SingleIn(AppScope::class)
@Inject
class ActiveRoomsHolder {
private val rooms = ConcurrentHashMap<SessionId, MutableSet<JoinedRoom>>()
interface ActiveRoomsHolder {
/**
* Adds a new held room for the given sessionId.
*/
fun addRoom(room: JoinedRoom) {
val roomsForSessionId = rooms.getOrPut(key = room.sessionId, defaultValue = { mutableSetOf() })
if (roomsForSessionId.none { it.roomId == room.roomId }) {
// We don't want to add the same room multiple times
roomsForSessionId.add(room)
}
}
fun addRoom(room: JoinedRoom)
/**
* Returns the last room added for the given [sessionId] or null if no room was added.
*/
fun getActiveRoom(sessionId: SessionId): JoinedRoom? {
return rooms[sessionId]?.lastOrNull()
}
fun getActiveRoom(sessionId: SessionId): JoinedRoom?
/**
* Returns an active room associated to the given [sessionId], with the given [roomId], or null if none match.
*/
fun getActiveRoomMatching(sessionId: SessionId, roomId: RoomId): JoinedRoom? {
return rooms[sessionId]?.find { it.roomId == roomId }
}
fun getActiveRoomMatching(sessionId: SessionId, roomId: RoomId): JoinedRoom?
/**
* Removes any room matching the provided [sessionId] and [roomId].
*/
fun removeRoom(sessionId: SessionId, roomId: RoomId) {
val roomsForSessionId = rooms[sessionId] ?: return
roomsForSessionId.removeIf { it.roomId == roomId }
}
fun removeRoom(sessionId: SessionId, roomId: RoomId)
/**
* Clears all the rooms for the given sessionId.
*/
fun clear(sessionId: SessionId) {
val activeRooms = rooms.remove(sessionId) ?: return
for (room in activeRooms) {
// Destroy the room to reset the live timelines
room.destroy()
}
}
fun clear(sessionId: SessionId)
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.services.appnavstate.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
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.room.JoinedRoom
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import java.util.concurrent.ConcurrentHashMap
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultActiveRoomsHolder : ActiveRoomsHolder {
private val rooms = ConcurrentHashMap<SessionId, MutableSet<JoinedRoom>>()
override fun addRoom(room: JoinedRoom) {
val roomsForSessionId = rooms.getOrPut(key = room.sessionId, defaultValue = { mutableSetOf() })
if (roomsForSessionId.none { it.roomId == room.roomId }) {
// We don't want to add the same room multiple times
roomsForSessionId.add(room)
}
}
override fun getActiveRoom(sessionId: SessionId): JoinedRoom? {
return rooms[sessionId]?.lastOrNull()
}
override fun getActiveRoomMatching(sessionId: SessionId, roomId: RoomId): JoinedRoom? {
return rooms[sessionId]?.find { it.roomId == roomId }
}
override fun removeRoom(sessionId: SessionId, roomId: RoomId) {
val roomsForSessionId = rooms[sessionId] ?: return
roomsForSessionId.removeIf { it.roomId == roomId }
}
override fun clear(sessionId: SessionId) {
val activeRooms = rooms.remove(sessionId) ?: return
for (room in activeRooms) {
// Destroy the room to reset the live timelines
room.destroy()
}
}
}