API modules do not need to setup dependency injection.
Move the implementation to the impl modules.
This commit is contained in:
parent
9eb66afd87
commit
b3ec256dfa
43 changed files with 620 additions and 405 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -8,28 +8,12 @@
|
|||
|
||||
package io.element.android.libraries.matrix.api.mxc
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
|
||||
@Inject
|
||||
class MxcTools {
|
||||
/**
|
||||
* Regex to match a Matrix Content (mxc://) URI.
|
||||
*
|
||||
* See: https://spec.matrix.org/v1.8/client-server-api/#matrix-content-mxc-uris
|
||||
*/
|
||||
private val mxcRegex = Regex("""^mxc://([^/]+)/([^/]+)$""")
|
||||
|
||||
interface MxcTools {
|
||||
/**
|
||||
* Sanitizes an mxcUri to be used as a relative file path.
|
||||
*
|
||||
* @param mxcUri the Matrix Content (mxc://) URI of the file.
|
||||
* @return the relative file path as "<server-name>/<media-id>" or null if the mxcUri is invalid.
|
||||
*/
|
||||
fun mxcUri2FilePath(mxcUri: String): String? = mxcRegex.matchEntire(mxcUri)?.let { match ->
|
||||
buildString {
|
||||
append(match.groupValues[1])
|
||||
append("/")
|
||||
append(match.groupValues[2])
|
||||
}
|
||||
}
|
||||
fun mxcUri2FilePath(mxcUri: String): String?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,17 +8,9 @@
|
|||
|
||||
package io.element.android.libraries.mediaupload.api
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
|
||||
/**
|
||||
* Provides the maximum upload size allowed by the Matrix server.
|
||||
*/
|
||||
@Inject
|
||||
class MaxUploadSizeProvider(
|
||||
private val matrixClient: MatrixClient,
|
||||
) {
|
||||
suspend fun getMaxUploadSize(): Result<Long> {
|
||||
return matrixClient.getMaxFileUploadSize()
|
||||
}
|
||||
fun interface MaxUploadSizeProvider {
|
||||
suspend fun getMaxUploadSize(): Result<Long>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,73 +9,41 @@
|
|||
package io.element.android.libraries.mediaupload.api
|
||||
|
||||
import android.net.Uri
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.libraries.androidutils.hash.hash
|
||||
import io.element.android.libraries.core.extensions.flatMap
|
||||
import io.element.android.libraries.core.extensions.flatMapCatching
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
|
||||
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@AssistedInject
|
||||
class MediaSender(
|
||||
private val preProcessor: MediaPreProcessor,
|
||||
private val room: JoinedRoom,
|
||||
@Assisted private val timelineMode: Timeline.Mode,
|
||||
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||
) {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
timelineMode: Timeline.Mode,
|
||||
): MediaSender
|
||||
}
|
||||
fun interface MediaSenderFactory {
|
||||
/**
|
||||
* Create a [MediaSender] for the given [Timeline.Mode], in the Room Scope.
|
||||
*/
|
||||
fun create(
|
||||
timelineMode: Timeline.Mode,
|
||||
): MediaSender
|
||||
}
|
||||
|
||||
private val ongoingUploadJobs = ConcurrentHashMap<Job.Key, MediaUploadHandler>()
|
||||
val hasOngoingMediaUploads get() = ongoingUploadJobs.isNotEmpty()
|
||||
fun interface MediaSenderRoomFactory {
|
||||
/**
|
||||
* Create a [MediaSender] for the given [JoinedRoom], with timeline mode Live.
|
||||
*/
|
||||
fun create(
|
||||
room: JoinedRoom,
|
||||
): MediaSender
|
||||
}
|
||||
|
||||
interface MediaSender {
|
||||
suspend fun preProcessMedia(
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||
): Result<MediaUploadInfo> {
|
||||
Timber.d("Pre-processing media | uri: ${mediaId(uri)} | mimeType: $mimeType")
|
||||
return preProcessor
|
||||
.process(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
deleteOriginal = false,
|
||||
mediaOptimizationConfig = mediaOptimizationConfig,
|
||||
)
|
||||
}
|
||||
): Result<MediaUploadInfo>
|
||||
|
||||
suspend fun sendPreProcessedMedia(
|
||||
mediaUploadInfo: MediaUploadInfo,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
inReplyToEventId: EventId?,
|
||||
): Result<Unit> {
|
||||
val mediaLogId = mediaId(mediaUploadInfo.file)
|
||||
return getTimeline().flatMap {
|
||||
Timber.d("Started sending media $mediaLogId using timeline: ${it.mode}")
|
||||
it.sendMedia(
|
||||
uploadInfo = mediaUploadInfo,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption,
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
}
|
||||
.handleSendResult(mediaLogId)
|
||||
}
|
||||
): Result<Unit>
|
||||
|
||||
suspend fun sendMedia(
|
||||
uri: Uri,
|
||||
|
|
@ -84,147 +52,14 @@ class MediaSender(
|
|||
formattedCaption: String? = null,
|
||||
inReplyToEventId: EventId? = null,
|
||||
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||
): Result<Unit> {
|
||||
return preProcessor
|
||||
.process(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
deleteOriginal = false,
|
||||
mediaOptimizationConfig = mediaOptimizationConfig,
|
||||
)
|
||||
.flatMapCatching { info ->
|
||||
getTimeline().getOrThrow().sendMedia(
|
||||
uploadInfo = info,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption,
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
}
|
||||
.handleSendResult(mediaId(uri))
|
||||
}
|
||||
): Result<Unit>
|
||||
|
||||
suspend fun sendVoiceMessage(
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
waveForm: List<Float>,
|
||||
inReplyToEventId: EventId? = null,
|
||||
): Result<Unit> {
|
||||
return preProcessor
|
||||
.process(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
deleteOriginal = true,
|
||||
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
|
||||
)
|
||||
.flatMapCatching { info ->
|
||||
val audioInfo = (info as MediaUploadInfo.Audio).audioInfo
|
||||
val newInfo = MediaUploadInfo.VoiceMessage(
|
||||
file = info.file,
|
||||
audioInfo = audioInfo,
|
||||
waveform = waveForm,
|
||||
)
|
||||
getTimeline().getOrThrow().sendMedia(
|
||||
uploadInfo = newInfo,
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
}
|
||||
.handleSendResult(mediaId(uri))
|
||||
}
|
||||
): Result<Unit>
|
||||
|
||||
private fun Result<Unit>.handleSendResult(mediaId: String) = this
|
||||
.onFailure { error ->
|
||||
val job = ongoingUploadJobs.remove(Job)
|
||||
Timber.e(error, "Sending media $mediaId failed. Removing ongoing upload job. Total: ${ongoingUploadJobs.size}")
|
||||
if (error !is CancellationException) {
|
||||
job?.cancel()
|
||||
}
|
||||
}
|
||||
.onSuccess {
|
||||
Timber.d("Sent media $mediaId successfully. Removing ongoing upload job. Total: ${ongoingUploadJobs.size}")
|
||||
ongoingUploadJobs.remove(Job)
|
||||
}
|
||||
|
||||
private suspend fun Timeline.sendMedia(
|
||||
uploadInfo: MediaUploadInfo,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
inReplyToEventId: EventId?,
|
||||
): Result<Unit> {
|
||||
val handler = when (uploadInfo) {
|
||||
is MediaUploadInfo.Image -> {
|
||||
sendImage(
|
||||
file = uploadInfo.file,
|
||||
thumbnailFile = uploadInfo.thumbnailFile,
|
||||
imageInfo = uploadInfo.imageInfo,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption,
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
}
|
||||
is MediaUploadInfo.Video -> {
|
||||
sendVideo(
|
||||
file = uploadInfo.file,
|
||||
thumbnailFile = uploadInfo.thumbnailFile,
|
||||
videoInfo = uploadInfo.videoInfo,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption,
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
}
|
||||
is MediaUploadInfo.Audio -> {
|
||||
sendAudio(
|
||||
file = uploadInfo.file,
|
||||
audioInfo = uploadInfo.audioInfo,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption,
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
}
|
||||
is MediaUploadInfo.VoiceMessage -> {
|
||||
sendVoiceMessage(
|
||||
file = uploadInfo.file,
|
||||
audioInfo = uploadInfo.audioInfo,
|
||||
waveform = uploadInfo.waveform,
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
}
|
||||
is MediaUploadInfo.AnyFile -> {
|
||||
sendFile(
|
||||
file = uploadInfo.file,
|
||||
fileInfo = uploadInfo.fileInfo,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption,
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// We handle the cancellations here manually, so we suppress the warning
|
||||
@Suppress("RunCatchingNotAllowed")
|
||||
return handler
|
||||
.mapCatching { uploadHandler ->
|
||||
Timber.d("Added ongoing upload job, total: ${ongoingUploadJobs.size + 1}")
|
||||
ongoingUploadJobs[Job] = uploadHandler
|
||||
uploadHandler.await()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getTimeline(): Result<Timeline> {
|
||||
return when (timelineMode) {
|
||||
is Timeline.Mode.Thread -> {
|
||||
room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = timelineMode.threadRootId))
|
||||
}
|
||||
else -> Result.success(room.liveTimeline)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up any temporary files or resources used during the media processing.
|
||||
*/
|
||||
fun cleanUp() = preProcessor.cleanUp()
|
||||
fun cleanUp()
|
||||
}
|
||||
|
||||
private fun mediaId(uri: Uri?): String = uri?.path.orEmpty().hash()
|
||||
private fun mediaId(file: File): String = file.path.orEmpty().hash()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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,
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -8,17 +8,6 @@
|
|||
|
||||
package io.element.android.libraries.recentemojis.api
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Inject
|
||||
class AddRecentEmoji(
|
||||
private val client: MatrixClient,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
suspend operator fun invoke(emoji: String): Result<Unit> = withContext(dispatchers.io) {
|
||||
client.addRecentEmoji(emoji)
|
||||
}
|
||||
fun interface AddRecentEmoji {
|
||||
suspend operator fun invoke(emoji: String): Result<Unit>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue