diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 266c7904be1..e44c7dcd8c1 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -63,6 +63,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) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt index dd0180c3bc0..77fcbae14ef 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt @@ -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( diff --git a/features/cachecleaner/api/build.gradle.kts b/features/cachecleaner/api/build.gradle.kts index 3731688c869..705107298a1 100644 --- a/features/cachecleaner/api/build.gradle.kts +++ b/features/cachecleaner/api/build.gradle.kts @@ -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) diff --git a/features/call/impl/build.gradle.kts b/features/call/impl/build.gradle.kts index c3bf2fb6dcd..c85c0ab6e53 100644 --- a/features/call/impl/build.gradle.kts +++ b/features/call/impl/build.gradle.kts @@ -97,6 +97,7 @@ dependencies { testImplementation(projects.libraries.matrixuiTest) 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) } diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt index 494bc109f12..95d53987047 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt @@ -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, diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index ea7ad34b08a..4b0a9a89956 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -89,6 +89,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) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index df962bcba08..d7e0332bd4b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 04d7146ad9c..276499d5bbb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt index 3750a31a257..1acaae2aae6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt @@ -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 diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 4562214aa44..852e2504b20 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -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(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( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt index 25c8da5fe5e..5263182fa0a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt @@ -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, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt index 53ff42839ef..aad3e0b8850 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt @@ -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(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index e9ebe0ca2e8..4a6e7771163 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -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, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt index a6eaef9f5c4..f3bc720792c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt @@ -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), diff --git a/features/messages/test/build.gradle.kts b/features/messages/test/build.gradle.kts index 63b87a32e67..09d357357d9 100644 --- a/features/messages/test/build.gradle.kts +++ b/features/messages/test/build.gradle.kts @@ -25,4 +25,5 @@ dependencies { implementation(projects.libraries.voicerecorder.test) implementation(projects.services.analytics.test) implementation(projects.tests.testutils) + implementation(projects.libraries.mediaupload.impl) } diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt index 6cdc5129ba7..0de1b69d78f 100644 --- a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt @@ -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, diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index deda6c1350c..ad28c909661 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -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) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt index 37755bbe5d0..6845ecb3a41 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt @@ -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 { } val matrixClient = FakeMatrixClient( sessionId = A_SESSION_ID, diff --git a/features/share/impl/build.gradle.kts b/features/share/impl/build.gradle.kts index 0f9f0ed2867..73748095a99 100644 --- a/features/share/impl/build.gradle.kts +++ b/features/share/impl/build.gradle.kts @@ -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) } diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt index 80a5efc88fd..4a4086ed87d 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt @@ -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 { @@ -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( diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt index 81c2288451e..0df1ed7baba 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt @@ -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> { _, _, _, _, _ -> - Result.success(FakeMediaUploadHandler()) - } + val sendMediaResult = lambdaRecorder> { 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, ) } diff --git a/libraries/matrix/api/build.gradle.kts b/libraries/matrix/api/build.gradle.kts index 8e831250184..1c70006cc89 100644 --- a/libraries/matrix/api/build.gradle.kts +++ b/libraries/matrix/api/build.gradle.kts @@ -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" diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt index 8e5cf0a15fa..306ab8354bf 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt @@ -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 "/" 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? } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mxc/DefaultMxcTools.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mxc/DefaultMxcTools.kt new file mode 100644 index 00000000000..b9ff2f55056 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mxc/DefaultMxcTools.kt @@ -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 "/" 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]) + } + } +} diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/mxc/MxcToolsTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mxc/DefaultMxcToolsTest.kt similarity index 84% rename from libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/mxc/MxcToolsTest.kt rename to libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mxc/DefaultMxcToolsTest.kt index 1daa8d40d8c..56863c51c4c 100644 --- a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/mxc/MxcToolsTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mxc/DefaultMxcToolsTest.kt @@ -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() diff --git a/libraries/matrix/test/build.gradle.kts b/libraries/matrix/test/build.gradle.kts index 63836d857a7..ccb1a37a256 100644 --- a/libraries/matrix/test/build.gradle.kts +++ b/libraries/matrix/test/build.gradle.kts @@ -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) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/mxc/FakeMxcTools.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/mxc/FakeMxcTools.kt new file mode 100644 index 00000000000..c348cd351c4 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/mxc/FakeMxcTools.kt @@ -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 diff --git a/libraries/mediapickers/test/build.gradle.kts b/libraries/mediapickers/test/build.gradle.kts index 9e29514527f..d7c2989871c 100644 --- a/libraries/mediapickers/test/build.gradle.kts +++ b/libraries/mediapickers/test/build.gradle.kts @@ -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" } diff --git a/libraries/mediaupload/api/build.gradle.kts b/libraries/mediaupload/api/build.gradle.kts index ee04c5aebb8..1f2d844a9a2 100644 --- a/libraries/mediaupload/api/build.gradle.kts +++ b/libraries/mediaupload/api/build.gradle.kts @@ -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) } diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MaxUploadSizeProvider.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MaxUploadSizeProvider.kt index fc1169ab127..a5462a6caff 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MaxUploadSizeProvider.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MaxUploadSizeProvider.kt @@ -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 { - return matrixClient.getMaxFileUploadSize() - } +fun interface MaxUploadSizeProvider { + suspend fun getMaxUploadSize(): Result } diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt index 707b856587a..628e760c1e5 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt @@ -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() - 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 { - Timber.d("Pre-processing media | uri: ${mediaId(uri)} | mimeType: $mimeType") - return preProcessor - .process( - uri = uri, - mimeType = mimeType, - deleteOriginal = false, - mediaOptimizationConfig = mediaOptimizationConfig, - ) - } + ): Result suspend fun sendPreProcessedMedia( mediaUploadInfo: MediaUploadInfo, caption: String?, formattedCaption: String?, inReplyToEventId: EventId?, - ): Result { - 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 suspend fun sendMedia( uri: Uri, @@ -84,147 +52,14 @@ class MediaSender( formattedCaption: String? = null, inReplyToEventId: EventId? = null, mediaOptimizationConfig: MediaOptimizationConfig, - ): Result { - 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 suspend fun sendVoiceMessage( uri: Uri, mimeType: String, waveForm: List, inReplyToEventId: EventId? = null, - ): Result { - 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.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 { - 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() - } - } + ): Result - private suspend fun getTimeline(): Result { - 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() diff --git a/libraries/mediaupload/impl/build.gradle.kts b/libraries/mediaupload/impl/build.gradle.kts index c5008fdb967..dd73164eb17 100644 --- a/libraries/mediaupload/impl/build.gradle.kts +++ b/libraries/mediaupload/impl/build.gradle.kts @@ -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) } diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMaxUploadSizeProvider.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMaxUploadSizeProvider.kt new file mode 100644 index 00000000000..0cd43bca011 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMaxUploadSizeProvider.kt @@ -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 { + return matrixClient.getMaxFileUploadSize() + } +} diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt new file mode 100644 index 00000000000..ea2cac29517 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt @@ -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() + val hasOngoingMediaUploads get() = ongoingUploadJobs.isNotEmpty() + + override suspend fun preProcessMedia( + uri: Uri, + mimeType: String, + mediaOptimizationConfig: MediaOptimizationConfig, + ): Result { + 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 { + 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 { + 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, + inReplyToEventId: EventId?, + ): Result { + 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.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 { + 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 { + 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() diff --git a/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSenderTest.kt similarity index 90% rename from libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt rename to libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSenderTest.kt index ddc4aae9882..139042804f7 100644 --- a/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSenderTest.kt @@ -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, diff --git a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaSender.kt b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaSender.kt new file mode 100644 index 00000000000..1713f7b5eaf --- /dev/null +++ b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaSender.kt @@ -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 = { lambdaError() }, + private val sendPreProcessedMediaResult: () -> Result = { lambdaError() }, + private val sendMediaResult: () -> Result = { lambdaError() }, + private val sendVoiceMessageResult: () -> Result = { lambdaError() }, + private val cleanUpResult: () -> Unit = { lambdaError() }, +) : MediaSender { + override suspend fun preProcessMedia( + uri: Uri, + mimeType: String, + mediaOptimizationConfig: MediaOptimizationConfig, + ): Result { + return preProcessMediaResult() + } + + override suspend fun sendPreProcessedMedia( + mediaUploadInfo: MediaUploadInfo, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result { + return sendPreProcessedMediaResult() + } + + override suspend fun sendMedia( + uri: Uri, + mimeType: String, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + mediaOptimizationConfig: MediaOptimizationConfig, + ): Result { + return sendMediaResult() + } + + override suspend fun sendVoiceMessage( + uri: Uri, + mimeType: String, + waveForm: List, + inReplyToEventId: EventId?, + ): Result { + return sendVoiceMessageResult() + } + + override fun cleanUp() { + cleanUpResult() + } +} diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index 162b62d2c15..cdd6ef7813f 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -89,6 +89,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) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt index f99c62f0c30..a52eb16b070 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt @@ -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, diff --git a/libraries/recentemojis/api/build.gradle.kts b/libraries/recentemojis/api/build.gradle.kts index c207c3242a9..2fc74c03c7d 100644 --- a/libraries/recentemojis/api/build.gradle.kts +++ b/libraries/recentemojis/api/build.gradle.kts @@ -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) diff --git a/libraries/recentemojis/api/src/main/kotlin/io/element/android/libraries/recentemojis/api/AddRecentEmoji.kt b/libraries/recentemojis/api/src/main/kotlin/io/element/android/libraries/recentemojis/api/AddRecentEmoji.kt index a0071173e45..5b50823f77a 100644 --- a/libraries/recentemojis/api/src/main/kotlin/io/element/android/libraries/recentemojis/api/AddRecentEmoji.kt +++ b/libraries/recentemojis/api/src/main/kotlin/io/element/android/libraries/recentemojis/api/AddRecentEmoji.kt @@ -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 = withContext(dispatchers.io) { - client.addRecentEmoji(emoji) - } +fun interface AddRecentEmoji { + suspend operator fun invoke(emoji: String): Result } diff --git a/libraries/recentemojis/impl/src/main/kotlin/io/element/android/libraries/recentemojis/impl/DefaultAddRecentEmoji.kt b/libraries/recentemojis/impl/src/main/kotlin/io/element/android/libraries/recentemojis/impl/DefaultAddRecentEmoji.kt new file mode 100644 index 00000000000..4444d7e01a8 --- /dev/null +++ b/libraries/recentemojis/impl/src/main/kotlin/io/element/android/libraries/recentemojis/impl/DefaultAddRecentEmoji.kt @@ -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 = withContext(dispatchers.io) { + client.addRecentEmoji(emoji) + } +} diff --git a/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessageMediaRepoTest.kt b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessageMediaRepoTest.kt index 10e92b1e641..5df259fb5b1 100644 --- a/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessageMediaRepoTest.kt +++ b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessageMediaRepoTest.kt @@ -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, diff --git a/libraries/workmanager/api/build.gradle.kts b/libraries/workmanager/api/build.gradle.kts index acd1b51953c..b53ed403945 100644 --- a/libraries/workmanager/api/build.gradle.kts +++ b/libraries/workmanager/api/build.gradle.kts @@ -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) diff --git a/plugins/src/main/kotlin/extension/DependencyInjectionExtensions.kt b/plugins/src/main/kotlin/extension/DependencyInjectionExtensions.kt index 4c433ba456e..b9cf87e233c 100644 --- a/plugins/src/main/kotlin/extension/DependencyInjectionExtensions.kt +++ b/plugins/src/main/kotlin/extension/DependencyInjectionExtensions.kt @@ -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() // Apply Metro plugin and configure it diff --git a/services/appnavstate/api/build.gradle.kts b/services/appnavstate/api/build.gradle.kts index bd21c1f94ba..c9849de20f1 100644 --- a/services/appnavstate/api/build.gradle.kts +++ b/services/appnavstate/api/build.gradle.kts @@ -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) diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/ActiveRoomsHolder.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/ActiveRoomsHolder.kt index 6d6b11f151f..c1e84de0795 100644 --- a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/ActiveRoomsHolder.kt +++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/ActiveRoomsHolder.kt @@ -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>() - +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) } diff --git a/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultActiveRoomsHolder.kt b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultActiveRoomsHolder.kt new file mode 100644 index 00000000000..871ec32d8ad --- /dev/null +++ b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultActiveRoomsHolder.kt @@ -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>() + + 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() + } + } +}