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

View File

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

View File

@@ -97,6 +97,7 @@ dependencies {
testImplementation(projects.libraries.matrixmedia.test) testImplementation(projects.libraries.matrixmedia.test)
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.services.appnavstate.impl)
testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.toolbox.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.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@@ -86,7 +87,7 @@ class DefaultCallWidgetProviderTest {
// No room from the client // No room from the client
givenGetRoomResult(A_ROOM_ID, null) givenGetRoomResult(A_ROOM_ID, null)
} }
val activeRoomsHolder = ActiveRoomsHolder().apply { val activeRoomsHolder = DefaultActiveRoomsHolder().apply {
// A current active room with the same room id // A current active room with the same room id
addRoom( addRoom(
FakeJoinedRoom( FakeJoinedRoom(
@@ -130,7 +131,7 @@ class DefaultCallWidgetProviderTest {
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider(), callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider(),
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(), activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(),
) = DefaultCallWidgetProvider( ) = DefaultCallWidgetProvider(
matrixClientsProvider = matrixClientProvider, matrixClientsProvider = matrixClientProvider,
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,

View File

@@ -90,6 +90,7 @@ dependencies {
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test) testImplementation(projects.services.toolbox.test)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.mediaupload.impl)
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.permissions.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.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.allFiles import io.element.android.libraries.mediaupload.api.allFiles
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
@@ -56,7 +56,7 @@ class AttachmentsPreviewPresenter(
@Assisted private val onDoneListener: OnDoneListener, @Assisted private val onDoneListener: OnDoneListener,
@Assisted private val timelineMode: Timeline.Mode, @Assisted private val timelineMode: Timeline.Mode,
@Assisted private val inReplyToEventId: EventId?, @Assisted private val inReplyToEventId: EventId?,
mediaSenderFactory: MediaSender.Factory, mediaSenderFactory: MediaSenderFactory,
private val permalinkBuilder: PermalinkBuilder, private val permalinkBuilder: PermalinkBuilder,
private val temporaryUriDeleter: TemporaryUriDeleter, private val temporaryUriDeleter: TemporaryUriDeleter,
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory, 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.matrix.ui.messages.reply.map
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider 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.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
@@ -107,7 +107,7 @@ class MessageComposerPresenter(
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
private val sessionPreferencesStore: SessionPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore,
private val localMediaFactory: LocalMediaFactory, private val localMediaFactory: LocalMediaFactory,
private val mediaSenderFactory: MediaSender.Factory, mediaSenderFactory: MediaSenderFactory,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val locationService: LocationService, 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.RoomScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
@@ -57,7 +57,7 @@ class DefaultVoiceMessageComposerPresenter(
@Assisted private val timelineMode: Timeline.Mode, @Assisted private val timelineMode: Timeline.Mode,
private val voiceRecorder: VoiceRecorder, private val voiceRecorder: VoiceRecorder,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
mediaSenderFactory: MediaSender.Factory, mediaSenderFactory: MediaSenderFactory,
private val player: VoiceMessageComposerPlayer, private val player: VoiceMessageComposerPlayer,
private val messageComposerContext: MessageComposerContext, private val messageComposerContext: MessageComposerContext,
permissionsPresenterFactory: PermissionsPresenter.Factory 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_THREAD_ID
import io.element.android.libraries.matrix.test.A_USER_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.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.core.aBuildMeta
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
@@ -166,6 +165,7 @@ class MessagesPresenterTest {
val toggleReactionSuccess = lambdaRecorder { _: String, _: EventOrTransactionId -> Result.success(true) } val toggleReactionSuccess = lambdaRecorder { _: String, _: EventOrTransactionId -> Result.success(true) }
val toggleReactionFailure = val toggleReactionFailure =
lambdaRecorder { _: String, _: EventOrTransactionId -> Result.failure<Boolean>(IllegalStateException("Failed to send reaction")) } lambdaRecorder { _: String, _: EventOrTransactionId -> Result.failure<Boolean>(IllegalStateException("Failed to send reaction")) }
val addRecentEmojiResult = lambdaRecorder { _: String -> Result.success(Unit) }
val timeline = FakeTimeline().apply { val timeline = FakeTimeline().apply {
this.toggleReactionLambda = toggleReactionSuccess this.toggleReactionLambda = toggleReactionSuccess
@@ -184,7 +184,8 @@ class MessagesPresenterTest {
val presenter = createMessagesPresenter( val presenter = createMessagesPresenter(
timeline = timeline, timeline = timeline,
joinedRoom = room, joinedRoom = room,
coroutineDispatchers = coroutineDispatchers addRecentEmoji = AddRecentEmoji { addRecentEmojiResult(it) },
coroutineDispatchers = coroutineDispatchers,
) )
presenter.testWithLifecycleOwner { presenter.testWithLifecycleOwner {
skipItems(1) skipItems(1)
@@ -201,6 +202,7 @@ class MessagesPresenterTest {
assert(toggleReactionFailure) assert(toggleReactionFailure)
.isCalledOnce() .isCalledOnce()
.with(value("👍"), value(AN_EVENT_ID.toEventOrTransactionId())) .with(value("👍"), value(AN_EVENT_ID.toEventOrTransactionId()))
addRecentEmojiResult.assertions().isCalledOnce().with(value("👍"))
} }
} }
@@ -212,7 +214,9 @@ class MessagesPresenterTest {
toggle = !toggle toggle = !toggle
Result.success(toggle) Result.success(toggle)
} }
val addRecentEmoji = lambdaRecorder { _: String ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply { val timeline = FakeTimeline().apply {
this.toggleReactionLambda = toggleReactionSuccess this.toggleReactionLambda = toggleReactionSuccess
} }
@@ -230,6 +234,7 @@ class MessagesPresenterTest {
val presenter = createMessagesPresenter( val presenter = createMessagesPresenter(
timeline = timeline, timeline = timeline,
joinedRoom = room, joinedRoom = room,
addRecentEmoji = AddRecentEmoji { addRecentEmoji(it) },
coroutineDispatchers = coroutineDispatchers coroutineDispatchers = coroutineDispatchers
) )
presenter.testWithLifecycleOwner { presenter.testWithLifecycleOwner {
@@ -244,6 +249,7 @@ class MessagesPresenterTest {
listOf(value("👍"), value(AN_EVENT_ID.toEventOrTransactionId())), listOf(value("👍"), value(AN_EVENT_ID.toEventOrTransactionId())),
) )
skipItems(1) skipItems(1)
addRecentEmoji.assertions().isCalledOnce().with(value("👍"))
} }
} }
@@ -1196,10 +1202,12 @@ class MessagesPresenterTest {
) )
presenter.testWithLifecycleOwner { presenter.testWithLifecycleOwner {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction( initialState.eventSink(
action = TimelineItemAction.ReplyInThread, MessagesEvents.HandleAction(
event = aMessageEvent(threadInfo = TimelineItemThreadInfo.ThreadResponse(A_THREAD_ID)) action = TimelineItemAction.ReplyInThread,
)) event = aMessageEvent(threadInfo = TimelineItemThreadInfo.ThreadResponse(A_THREAD_ID))
)
)
awaitItem() awaitItem()
openThreadLambda.assertions().isCalledOnce().with(value(A_THREAD_ID), value(null)) openThreadLambda.assertions().isCalledOnce().with(value(A_THREAD_ID), value(null))
} }
@@ -1216,14 +1224,16 @@ class MessagesPresenterTest {
) )
presenter.testWithLifecycleOwner { presenter.testWithLifecycleOwner {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction( initialState.eventSink(
action = TimelineItemAction.ReplyInThread, MessagesEvents.HandleAction(
event = aMessageEvent( action = TimelineItemAction.ReplyInThread,
// The event id will be used as the thread id instead event = aMessageEvent(
eventId = AN_EVENT_ID, // The event id will be used as the thread id instead
threadInfo = null, eventId = AN_EVENT_ID,
threadInfo = null,
)
) )
)) )
awaitItem() awaitItem()
openThreadLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID.toThreadId()), value(null)) openThreadLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID.toThreadId()), value(null))
} }
@@ -1334,7 +1344,7 @@ class MessagesPresenterTest {
encryptionService: FakeEncryptionService = FakeEncryptionService(), encryptionService: FakeEncryptionService = FakeEncryptionService(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
actionListEventSink: (ActionListEvents) -> Unit = {}, actionListEventSink: (ActionListEvents) -> Unit = {},
addRecentEmoji: AddRecentEmoji = AddRecentEmoji(FakeMatrixClient(), testCoroutineDispatchers()), addRecentEmoji: AddRecentEmoji = AddRecentEmoji { _ -> lambdaError() },
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(), markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(),
): MessagesPresenter { ): MessagesPresenter {
return 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.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.mediaupload.api.MediaUploadInfo 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.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
@@ -601,17 +602,15 @@ class AttachmentsPreviewPresenterTest {
return AttachmentsPreviewPresenter( return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia), attachment = aMediaAttachment(localMedia),
onDoneListener = onDoneListener, onDoneListener = onDoneListener,
mediaSenderFactory = object : MediaSender.Factory { mediaSenderFactory = MediaSenderFactory { timelineMode ->
override fun create(timelineMode: Timeline.Mode): MediaSender { DefaultMediaSender(
return MediaSender( preProcessor = mediaPreProcessor,
preProcessor = mediaPreProcessor, room = room,
room = room, timelineMode = timelineMode,
timelineMode = timelineMode, mediaOptimizationConfigProvider = {
mediaOptimizationConfigProvider = { MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD)
MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) }
} )
)
}
}, },
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
temporaryUriDeleter = temporaryUriDeleter, 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.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.AN_EXCEPTION 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.mediaupload.api.MaxUploadSizeProvider
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
@@ -206,7 +205,7 @@ class DefaultMediaOptimizationSelectorPresenterTest {
@Test @Test
fun `present - max upload size will default to 100MB if we can't get it`() = runTest { fun `present - max upload size will default to 100MB if we can't get it`() = runTest {
val presenter = createDefaultMediaOptimizationSelectorPresenter( val presenter = createDefaultMediaOptimizationSelectorPresenter(
maxUploadSizeProvider = MaxUploadSizeProvider(FakeMatrixClient(getMaxUploadSizeResult = { Result.failure(AN_EXCEPTION) })) maxUploadSizeProvider = MaxUploadSizeProvider { Result.failure(AN_EXCEPTION) }
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@@ -233,9 +232,7 @@ class DefaultMediaOptimizationSelectorPresenterTest {
private fun createDefaultMediaOptimizationSelectorPresenter( private fun createDefaultMediaOptimizationSelectorPresenter(
localMedia: LocalMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()), localMedia: LocalMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider( maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider { Result.success(1_000L) },
FakeMatrixClient(getMaxUploadSizeResult = { Result.success(1_000L) }),
),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)), featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)),
mediaExtractorFactory: FakeVideoMetadataExtractorFactory = FakeVideoMetadataExtractorFactory(), 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.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.mediaupload.api.MediaUploadInfo 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.FakeMediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
@@ -1551,20 +1552,18 @@ class MessageComposerPresenterTest {
mediaPickerProvider = pickerProvider, mediaPickerProvider = pickerProvider,
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
localMediaFactory = localMediaFactory, localMediaFactory = localMediaFactory,
mediaSenderFactory = object : MediaSender.Factory { mediaSenderFactory = MediaSenderFactory { timelineMode ->
override fun create(timelineMode: Timeline.Mode): MediaSender { DefaultMediaSender(
return MediaSender( preProcessor = mediaPreProcessor,
preProcessor = mediaPreProcessor, room = room,
room = room, timelineMode = timelineMode,
timelineMode = timelineMode, mediaOptimizationConfigProvider = {
mediaOptimizationConfigProvider = { MediaOptimizationConfig(
MediaOptimizationConfig(
compressImages = true, compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD videoCompressionPreset = VideoCompressionPreset.STANDARD
) )
} }
) )
}
}, },
snackbarDispatcher = snackbarDispatcher, snackbarDispatcher = snackbarDispatcher,
analyticsService = analyticsService, 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.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig 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.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.api.aPermissionsState import io.element.android.libraries.permissions.api.aPermissionsState
@@ -75,7 +75,7 @@ class VoiceMessageComposerPresenterTest {
}, },
) )
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() } private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
private val mediaSender = MediaSender( private val mediaSender = DefaultMediaSender(
preProcessor = mediaPreProcessor, preProcessor = mediaPreProcessor,
room = joinedRoom, room = joinedRoom,
timelineMode = Timeline.Mode.Live, timelineMode = Timeline.Mode.Live,
@@ -668,11 +668,7 @@ class VoiceMessageComposerPresenterTest {
timelineMode = Timeline.Mode.Live, timelineMode = Timeline.Mode.Live,
voiceRecorder = voiceRecorder, voiceRecorder = voiceRecorder,
analyticsService = analyticsService, analyticsService = analyticsService,
mediaSenderFactory = object : MediaSender.Factory { mediaSenderFactory = { mediaSender },
override fun create(timelineMode: Timeline.Mode): MediaSender {
return mediaSender
}
},
player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this), player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this),
messageComposerContext = messageComposerContext, messageComposerContext = messageComposerContext,
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),

View File

@@ -25,4 +25,5 @@ dependencies {
implementation(projects.libraries.voicerecorder.test) implementation(projects.libraries.voicerecorder.test)
implementation(projects.services.analytics.test) implementation(projects.services.analytics.test)
implementation(projects.tests.testutils) 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.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.mediaupload.api.MediaSender 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.FakeMediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
@@ -24,7 +25,7 @@ import kotlinx.coroutines.CoroutineScope
class FakeDefaultVoiceMessageComposerPresenterFactory( class FakeDefaultVoiceMessageComposerPresenterFactory(
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
private val mediaSender: MediaSender = MediaSender( private val mediaSender: MediaSender = DefaultMediaSender(
preProcessor = FakeMediaPreProcessor(), preProcessor = FakeMediaPreProcessor(),
room = FakeJoinedRoom(), room = FakeJoinedRoom(),
timelineMode = Timeline.Mode.Live, timelineMode = Timeline.Mode.Live,
@@ -37,11 +38,7 @@ class FakeDefaultVoiceMessageComposerPresenterFactory(
timelineMode = timelineMode, timelineMode = timelineMode,
voiceRecorder = FakeVoiceRecorder(), voiceRecorder = FakeVoiceRecorder(),
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),
mediaSenderFactory = object : MediaSender.Factory { mediaSenderFactory = { mediaSender },
override fun create(timelineMode: Timeline.Mode): MediaSender {
return mediaSender
}
},
player = VoiceMessageComposerPlayer( player = VoiceMessageComposerPlayer(
mediaPlayer = FakeMediaPlayer(), mediaPlayer = FakeMediaPlayer(),
sessionCoroutineScope = sessionCoroutineScope, sessionCoroutineScope = sessionCoroutineScope,

View File

@@ -115,6 +115,7 @@ dependencies {
testImplementation(projects.libraries.indicator.test) testImplementation(projects.libraries.indicator.test)
testImplementation(projects.libraries.pushproviders.test) testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.services.appnavstate.impl)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.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.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.push.test.FakePushService 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.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
@@ -34,7 +34,7 @@ import org.robolectric.RobolectricTestRunner
class DefaultClearCacheUseCaseTest { class DefaultClearCacheUseCaseTest {
@Test @Test
fun `execute clear cache should do all the expected tasks`() = runTest { 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 clearCacheLambda = lambdaRecorder<Unit> { }
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,

View File

@@ -48,4 +48,5 @@ dependencies {
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.roomselect.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.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.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.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -39,7 +37,7 @@ class SharePresenter(
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
private val shareIntentHandler: ShareIntentHandler, private val shareIntentHandler: ShareIntentHandler,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val mediaPreProcessor: MediaPreProcessor, private val mediaSenderRoomFactory: MediaSenderRoomFactory,
private val activeRoomsHolder: ActiveRoomsHolder, private val activeRoomsHolder: ActiveRoomsHolder,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
) : Presenter<ShareState> { ) : Presenter<ShareState> {
@@ -88,12 +86,7 @@ class SharePresenter(
roomIds roomIds
.map { roomId -> .map { roomId ->
val room = getJoinedRoom(roomId) ?: return@map false val room = getJoinedRoom(roomId) ?: return@map false
val mediaSender = MediaSender( val mediaSender = mediaSenderRoomFactory.create(room = room)
preProcessor = mediaPreProcessor,
room = room,
timelineMode = Timeline.Mode.Live,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
)
filesToShare filesToShare
.map { fileToShare -> .map { fileToShare ->
val result = mediaSender.sendMedia( 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.architecture.AsyncAction
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.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_MESSAGE
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient 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.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline 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.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.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@@ -37,7 +36,6 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import java.io.File
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class SharePresenterTest { class SharePresenterTest {
@@ -121,18 +119,16 @@ class SharePresenterTest {
@Test @Test
fun `present - send media ok`() = runTest { fun `present - send media ok`() = runTest {
val sendFileResult = val sendMediaResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
lambdaRecorder<File, FileInfo, String?, String?, EventId?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val joinedRoom = FakeJoinedRoom( val joinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply { liveTimeline = FakeTimeline(),
sendFileLambda = sendFileResult
},
) )
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, joinedRoom) givenGetRoomResult(A_ROOM_ID, joinedRoom)
} }
val mediaSender = FakeMediaSender(
sendMediaResult = sendMediaResult,
)
val presenter = createSharePresenter( val presenter = createSharePresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
shareIntentHandler = FakeShareIntentHandler { _, onFile, _ -> shareIntentHandler = FakeShareIntentHandler { _, onFile, _ ->
@@ -144,7 +140,8 @@ class SharePresenterTest {
) )
) )
) )
} },
mediaSenderRoomFactory = MediaSenderRoomFactory { mediaSender },
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@@ -156,7 +153,7 @@ class SharePresenterTest {
val success = awaitItem() val success = awaitItem()
assertThat(success.shareAction.isSuccess()).isTrue() assertThat(success.shareAction.isSuccess()).isTrue()
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID))) 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(), intent: Intent = Intent(),
shareIntentHandler: ShareIntentHandler = FakeShareIntentHandler(), shareIntentHandler: ShareIntentHandler = FakeShareIntentHandler(),
matrixClient: MatrixClient = FakeMatrixClient(), matrixClient: MatrixClient = FakeMatrixClient(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(), activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(),
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(), mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { FakeMediaSender() },
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
): SharePresenter { ): SharePresenter {
return SharePresenter( return SharePresenter(
intent = intent, intent = intent,
sessionCoroutineScope = this, sessionCoroutineScope = this,
shareIntentHandler = shareIntentHandler, shareIntentHandler = shareIntentHandler,
matrixClient = matrixClient, matrixClient = matrixClient,
mediaPreProcessor = mediaPreProcessor,
activeRoomsHolder = activeRoomsHolder, activeRoomsHolder = activeRoomsHolder,
mediaSenderRoomFactory = mediaSenderRoomFactory,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
) )
} }

View File

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

View File

@@ -8,28 +8,12 @@
package io.element.android.libraries.matrix.api.mxc package io.element.android.libraries.matrix.api.mxc
import dev.zacsweers.metro.Inject interface MxcTools {
@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://([^/]+)/([^/]+)$""")
/** /**
* Sanitizes an mxcUri to be used as a relative file path. * Sanitizes an mxcUri to be used as a relative file path.
* *
* @param mxcUri the Matrix Content (mxc://) URI of the file. * @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. * @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 -> fun mxcUri2FilePath(mxcUri: String): String?
buildString {
append(match.groupValues[1])
append("/")
append(match.groupValues[2])
}
}
} }

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. * 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 com.google.common.truth.Truth.assertThat
import org.junit.Test import org.junit.Test
class MxcToolsTest { class DefaultMxcToolsTest {
@Test @Test
fun `mxcUri2FilePath returns extracted path`() { fun `mxcUri2FilePath returns extracted path`() {
val mxcTools = MxcTools() val mxcTools = DefaultMxcTools()
val mxcUri = "mxc://server.org/abc123" val mxcUri = "mxc://server.org/abc123"
val filePath = mxcTools.mxcUri2FilePath(mxcUri) val filePath = mxcTools.mxcUri2FilePath(mxcUri)
assertThat(filePath).isEqualTo("server.org/abc123") assertThat(filePath).isEqualTo("server.org/abc123")
@@ -22,7 +22,7 @@ class MxcToolsTest {
@Test @Test
fun `mxcUri2FilePath returns null for invalid data`() { fun `mxcUri2FilePath returns null for invalid data`() {
val mxcTools = MxcTools() val mxcTools = DefaultMxcTools()
assertThat(mxcTools.mxcUri2FilePath("")).isNull() assertThat(mxcTools.mxcUri2FilePath("")).isNull()
assertThat(mxcTools.mxcUri2FilePath("mxc://server.org")).isNull() assertThat(mxcTools.mxcUri2FilePath("mxc://server.org")).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(projects.libraries.matrix.api)
api(libs.coroutines.core) api(libs.coroutines.core)
implementation(libs.coroutines.test) implementation(libs.coroutines.test)
implementation(projects.libraries.matrix.impl)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(projects.tests.testutils) implementation(projects.tests.testutils)
implementation(libs.kotlinx.collections.immutable) 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 (c) 2025 Element Creations Ltd.
* Copyright 2023, 2024 New Vector Ltd. * Copyright 2023, 2024 New Vector Ltd.
@@ -12,8 +10,6 @@ plugins {
id("io.element.android-compose-library") id("io.element.android-compose-library")
} }
setupDependencyInjection()
android { android {
namespace = "io.element.android.libraries.mediapickers.test" namespace = "io.element.android.libraries.mediapickers.test"
} }

View File

@@ -1,4 +1,3 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies import extension.testCommonDependencies
/* /*
@@ -13,8 +12,6 @@ plugins {
id("io.element.android-library") id("io.element.android-library")
} }
setupDependencyInjection()
android { android {
namespace = "io.element.android.libraries.mediaupload.api" namespace = "io.element.android.libraries.mediaupload.api"
} }
@@ -27,9 +24,4 @@ dependencies {
api(projects.libraries.matrix.api) api(projects.libraries.matrix.api)
api(projects.libraries.preferences.api) api(projects.libraries.preferences.api)
implementation(libs.coroutines.core) 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 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. * Provides the maximum upload size allowed by the Matrix server.
*/ */
@Inject fun interface MaxUploadSizeProvider {
class MaxUploadSizeProvider( suspend fun getMaxUploadSize(): Result<Long>
private val matrixClient: MatrixClient,
) {
suspend fun getMaxUploadSize(): Result<Long> {
return matrixClient.getMaxFileUploadSize()
}
} }

View File

@@ -9,73 +9,41 @@
package io.element.android.libraries.mediaupload.api package io.element.android.libraries.mediaupload.api
import android.net.Uri 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.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.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.Timeline 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 fun interface MediaSenderFactory {
class MediaSender( /**
private val preProcessor: MediaPreProcessor, * Create a [MediaSender] for the given [Timeline.Mode], in the Room Scope.
private val room: JoinedRoom, */
@Assisted private val timelineMode: Timeline.Mode, fun create(
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, timelineMode: Timeline.Mode,
) { ): MediaSender
@AssistedFactory }
interface Factory {
fun create(
timelineMode: Timeline.Mode,
): MediaSender
}
private val ongoingUploadJobs = ConcurrentHashMap<Job.Key, MediaUploadHandler>() fun interface MediaSenderRoomFactory {
val hasOngoingMediaUploads get() = ongoingUploadJobs.isNotEmpty() /**
* Create a [MediaSender] for the given [JoinedRoom], with timeline mode Live.
*/
fun create(
room: JoinedRoom,
): MediaSender
}
interface MediaSender {
suspend fun preProcessMedia( suspend fun preProcessMedia(
uri: Uri, uri: Uri,
mimeType: String, mimeType: String,
mediaOptimizationConfig: MediaOptimizationConfig, mediaOptimizationConfig: MediaOptimizationConfig,
): Result<MediaUploadInfo> { ): Result<MediaUploadInfo>
Timber.d("Pre-processing media | uri: ${mediaId(uri)} | mimeType: $mimeType")
return preProcessor
.process(
uri = uri,
mimeType = mimeType,
deleteOriginal = false,
mediaOptimizationConfig = mediaOptimizationConfig,
)
}
suspend fun sendPreProcessedMedia( suspend fun sendPreProcessedMedia(
mediaUploadInfo: MediaUploadInfo, mediaUploadInfo: MediaUploadInfo,
caption: String?, caption: String?,
formattedCaption: String?, formattedCaption: String?,
inReplyToEventId: EventId?, inReplyToEventId: EventId?,
): Result<Unit> { ): 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)
}
suspend fun sendMedia( suspend fun sendMedia(
uri: Uri, uri: Uri,
@@ -84,147 +52,14 @@ class MediaSender(
formattedCaption: String? = null, formattedCaption: String? = null,
inReplyToEventId: EventId? = null, inReplyToEventId: EventId? = null,
mediaOptimizationConfig: MediaOptimizationConfig, mediaOptimizationConfig: MediaOptimizationConfig,
): Result<Unit> { ): 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))
}
suspend fun sendVoiceMessage( suspend fun sendVoiceMessage(
uri: Uri, uri: Uri,
mimeType: String, mimeType: String,
waveForm: List<Float>, waveForm: List<Float>,
inReplyToEventId: EventId? = null, inReplyToEventId: EventId? = null,
): Result<Unit> { ): 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 fun cleanUp()
.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()
} }
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) testCommonDependencies(libs)
testImplementation(projects.services.toolbox.test) 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. * 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 android.net.Uri
import com.google.common.truth.Truth.assertThat 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.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline 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.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -33,7 +36,7 @@ import org.robolectric.RobolectricTestRunner
import java.io.File import java.io.File
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class MediaSenderTest { class DefaultMediaSenderTest {
private val mediaOptimizationConfig = MediaOptimizationConfig( private val mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true, compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD, videoCompressionPreset = VideoCompressionPreset.STANDARD,
@@ -42,7 +45,7 @@ class MediaSenderTest {
@Test @Test
fun `given an attachment when sending it the preprocessor always runs`() = runTest { fun `given an attachment when sending it the preprocessor always runs`() = runTest {
val preProcessor = FakeMediaPreProcessor() val preProcessor = FakeMediaPreProcessor()
val sender = createMediaSender( val sender = createDefaultMediaSender(
preProcessor = preProcessor, preProcessor = preProcessor,
room = FakeJoinedRoom( room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply { liveTimeline = FakeTimeline().apply {
@@ -77,7 +80,7 @@ class MediaSenderTest {
sendImageLambda = sendImageResult sendImageLambda = sendImageResult
}, },
) )
val sender = createMediaSender(room = room) val sender = createDefaultMediaSender(room = room)
val uri = Uri.parse("content://image.jpg") val uri = Uri.parse("content://image.jpg")
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig) sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
@@ -88,7 +91,7 @@ class MediaSenderTest {
val preProcessor = FakeMediaPreProcessor().apply { val preProcessor = FakeMediaPreProcessor().apply {
givenResult(Result.failure(Exception())) givenResult(Result.failure(Exception()))
} }
val sender = createMediaSender(preProcessor) val sender = createDefaultMediaSender(preProcessor)
val uri = Uri.parse("content://image.jpg") val uri = Uri.parse("content://image.jpg")
val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig) val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
@@ -110,7 +113,7 @@ class MediaSenderTest {
sendImageLambda = sendImageResult sendImageLambda = sendImageResult
}, },
) )
val sender = createMediaSender( val sender = createDefaultMediaSender(
preProcessor = preProcessor, preProcessor = preProcessor,
room = room, room = room,
) )
@@ -133,7 +136,7 @@ class MediaSenderTest {
sendFileLambda = sendFileResult sendFileLambda = sendFileResult
}, },
) )
val sender = createMediaSender(room = room) val sender = createDefaultMediaSender(room = room)
val sendJob = launch { val sendJob = launch {
val uri = Uri.parse("content://image.jpg") val uri = Uri.parse("content://image.jpg")
sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig) sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
@@ -155,11 +158,11 @@ class MediaSenderTest {
sendFileResult.assertions().isCalledOnce() sendFileResult.assertions().isCalledOnce()
} }
private fun createMediaSender( private fun createDefaultMediaSender(
preProcessor: MediaPreProcessor = FakeMediaPreProcessor(), preProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
room: JoinedRoom = FakeJoinedRoom(), room: JoinedRoom = FakeJoinedRoom(),
mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = MediaOptimizationConfigProvider { mediaOptimizationConfig }, mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = MediaOptimizationConfigProvider { mediaOptimizationConfig },
) = MediaSender( ) = DefaultMediaSender(
preProcessor = preProcessor, preProcessor = preProcessor,
room = room, room = room,
timelineMode = Timeline.Mode.Live, 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.enterprise.test)
testImplementation(projects.features.lockscreen.test) testImplementation(projects.features.lockscreen.test)
testImplementation(projects.features.networkmonitor.test) testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.services.appnavstate.impl)
testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.toolbox.impl) testImplementation(projects.services.toolbox.impl)
testImplementation(projects.services.toolbox.test) 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.impl.push.OnNotifiableEventReceived
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
import io.element.android.services.appnavstate.api.ActiveRoomsHolder 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.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.services.toolbox.test.strings.FakeStringProvider
@@ -482,7 +483,7 @@ class NotificationBroadcastReceiverHandlerTest {
onNotifiableEventReceived: OnNotifiableEventReceived = FakeOnNotifiableEventReceived(), onNotifiableEventReceived: OnNotifiableEventReceived = FakeOnNotifiableEventReceived(),
stringProvider: StringProvider = FakeStringProvider(), stringProvider: StringProvider = FakeStringProvider(),
replyMessageExtractor: ReplyMessageExtractor = FakeReplyMessageExtractor(), replyMessageExtractor: ReplyMessageExtractor = FakeReplyMessageExtractor(),
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(), activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(),
): NotificationBroadcastReceiverHandler { ): NotificationBroadcastReceiverHandler {
return NotificationBroadcastReceiverHandler( return NotificationBroadcastReceiverHandler(
appCoroutineScope = this, appCoroutineScope = this,

View File

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

View File

@@ -8,17 +8,6 @@
package io.element.android.libraries.recentemojis.api package io.element.android.libraries.recentemojis.api
import dev.zacsweers.metro.Inject fun interface AddRecentEmoji {
import io.element.android.libraries.core.coroutine.CoroutineDispatchers suspend operator fun invoke(emoji: String): Result<Unit>
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)
}
} }

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.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader 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.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.media.FakeMatrixMediaLoader
import io.element.android.libraries.matrix.test.mxc.FakeMxcTools
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -131,7 +131,7 @@ private fun createDefaultVoiceMessageMediaRepo(
mxcUri: String = MXC_URI, mxcUri: String = MXC_URI,
) = DefaultVoiceMessageMediaRepo( ) = DefaultVoiceMessageMediaRepo(
cacheDir = temporaryFolder.root, cacheDir = temporaryFolder.root,
mxcTools = MxcTools(), mxcTools = FakeMxcTools(),
matrixMediaLoader = matrixMediaLoader, matrixMediaLoader = matrixMediaLoader,
mediaSource = MediaSource( mediaSource = MediaSource(
url = mxcUri, url = mxcUri,

View File

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

View File

@@ -22,6 +22,10 @@ import org.gradle.plugin.use.PluginDependency
fun Project.setupDependencyInjection( fun Project.setupDependencyInjection(
generateNodeFactories: Boolean = shouldApplyAppyxCodegen(), 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>() val libs = the<LibrariesForLibs>()
// Apply Metro plugin and configure it // Apply Metro plugin and configure it

View File

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

View File

@@ -8,63 +8,36 @@
package io.element.android.services.appnavstate.api 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.JoinedRoom 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. * Holds the active rooms for a given session so they can be reused instead of instantiating new ones.
*/ */
@SingleIn(AppScope::class) interface ActiveRoomsHolder {
@Inject
class ActiveRoomsHolder {
private val rooms = ConcurrentHashMap<SessionId, MutableSet<JoinedRoom>>()
/** /**
* Adds a new held room for the given sessionId. * Adds a new held room for the given sessionId.
*/ */
fun addRoom(room: JoinedRoom) { 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)
}
}
/** /**
* Returns the last room added for the given [sessionId] or null if no room was added. * Returns the last room added for the given [sessionId] or null if no room was added.
*/ */
fun getActiveRoom(sessionId: SessionId): JoinedRoom? { fun getActiveRoom(sessionId: SessionId): JoinedRoom?
return rooms[sessionId]?.lastOrNull()
}
/** /**
* Returns an active room associated to the given [sessionId], with the given [roomId], or null if none match. * 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? { fun getActiveRoomMatching(sessionId: SessionId, roomId: RoomId): JoinedRoom?
return rooms[sessionId]?.find { it.roomId == roomId }
}
/** /**
* Removes any room matching the provided [sessionId] and [roomId]. * Removes any room matching the provided [sessionId] and [roomId].
*/ */
fun removeRoom(sessionId: SessionId, roomId: RoomId) { fun removeRoom(sessionId: SessionId, roomId: RoomId)
val roomsForSessionId = rooms[sessionId] ?: return
roomsForSessionId.removeIf { it.roomId == roomId }
}
/** /**
* Clears all the rooms for the given sessionId. * Clears all the rooms for the given sessionId.
*/ */
fun clear(sessionId: 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()
}
}
} }

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()
}
}
}