[Media upload] Upload image, video and files (#411)

* Add media upload

* Display media upload error messages using a Snackbar.
This commit is contained in:
Jorge Martin Espinosa
2023-05-11 17:56:13 +02:00
committed by GitHub
parent 6a2cb1bbb5
commit ed16ea5e48
24 changed files with 373 additions and 77 deletions

View File

@@ -41,6 +41,8 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -52,6 +54,7 @@ class MessagesPresenter @Inject constructor(
private val timelinePresenter: TimelinePresenter,
private val actionListPresenter: ActionListPresenter,
private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<MessagesState> {
@Composable
@@ -71,6 +74,8 @@ class MessagesPresenter @Inject constructor(
val networkConnectionStatus by networkMonitor.connectivity.collectAsState(initial = networkMonitor.currentConnectivityStatus)
val snackbarMessage = handleSnackbarMessage(snackbarDispatcher)
LaunchedEffect(syncUpdateFlow) {
roomAvatar.value =
AvatarData(
@@ -97,6 +102,7 @@ class MessagesPresenter @Inject constructor(
timelineState = timelineState,
actionListState = actionListState,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
snackbarMessage = snackbarMessage,
eventSink = ::handleEvents
)
}

View File

@@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId
@Immutable
@@ -32,5 +33,6 @@ data class MessagesState(
val timelineState: TimelineState,
val actionListState: ActionListState,
val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?,
val eventSink: (MessagesEvents) -> Unit
)

View File

@@ -52,5 +52,6 @@ fun aMessagesState() = MessagesState(
),
actionListState = anActionListState(),
hasNetworkConnection = true,
snackbarMessage = null,
eventSink = {}
)

View File

@@ -46,6 +46,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@@ -94,7 +95,6 @@ fun MessagesView(
val itemActionsBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
)
val snackbarHostState = remember { SnackbarHostState() }
val composerState = state.composerState
val initialBottomSheetState = if (LocalInspectionMode.current && composerState.attachmentSourcePicker != null) {
ModalBottomSheetValue.Expanded
@@ -110,6 +110,19 @@ fun MessagesView(
}
}
val snackbarHostState = remember { SnackbarHostState() }
val snackbarMessageText = state.snackbarMessage?.let { stringResource(it.messageResId) }
if (snackbarMessageText != null) {
SideEffect {
coroutineScope.launch {
snackbarHostState.showSnackbar(
message = snackbarMessageText,
duration = state.snackbarMessage.duration
)
}
}
}
// This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
val localView = LocalView.current

View File

@@ -32,6 +32,8 @@ import io.element.android.libraries.core.data.toStableCharSequence
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -40,11 +42,13 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaType
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import io.element.android.libraries.ui.strings.R as StringR
@SingleIn(RoomScope::class)
class MessageComposerPresenter @Inject constructor(
@@ -53,6 +57,7 @@ class MessageComposerPresenter @Inject constructor(
private val mediaPickerProvider: PickerProvider,
private val featureFlagService: FeatureFlagService,
private val mediaPreProcessor: MediaPreProcessor,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<MessageComposerState> {
@Composable
@@ -60,6 +65,7 @@ class MessageComposerPresenter @Inject constructor(
val localCoroutineScope = rememberCoroutineScope()
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri, mimeType ->
if (uri == null) return@registerGalleryPicker
Timber.d("Media picked from $uri")
// We don't know which type of media was retrieved, so we need this check
val mediaType = when {
@@ -67,22 +73,25 @@ class MessageComposerPresenter @Inject constructor(
mimeType.isMimeTypeVideo() -> MediaType.Video
else -> error("MimeType must be either image/* or video/*")
}
localCoroutineScope.handleMediaPreProcessing(uri, mediaType)
appCoroutineScope.sendMedia(uri, mediaType)
})
val filesPicker = mediaPickerProvider.registerFilePicker(mimeType = MimeTypes.Any) { uri ->
if (uri == null) return@registerFilePicker
Timber.d("File picked from $uri")
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.File)
appCoroutineScope.sendMedia(uri, MediaType.File)
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
if (uri == null) return@registerCameraPhotoPicker
Timber.d("Photo saved at $uri")
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.Image, deleteOriginal = true)
appCoroutineScope.sendMedia(uri, MediaType.Image, deleteOriginal = true)
}
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
if (uri == null) return@registerCameraVideoPicker
Timber.d("Video saved at $uri")
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.Video, deleteOriginal = true)
appCoroutineScope.sendMedia(uri, MediaType.Video, deleteOriginal = true)
}
val isFullScreen = rememberSaveable {
@@ -181,14 +190,44 @@ class MessageComposerPresenter @Inject constructor(
}
}
private fun CoroutineScope.handleMediaPreProcessing(
uri: Uri?,
private fun CoroutineScope.sendMedia(
uri: Uri,
mediaType: MediaType,
deleteOriginal: Boolean = false
) = launch {
if (uri == null) return@launch
runCatching {
val info = handleMediaPreProcessing(uri, mediaType, deleteOriginal).getOrNull() ?: return@runCatching
when (info) {
is MediaUploadInfo.Image -> {
room.sendImage(info.file, info.thumbnailInfo.file, info.info)
}
val result = mediaPreProcessor.process(uri, mediaType, deleteOriginal = deleteOriginal)
is MediaUploadInfo.Video -> {
room.sendVideo(info.file, info.thumbnailInfo.file, info.info)
}
is MediaUploadInfo.AnyFile -> {
room.sendFile(info.file, info.info)
}
else -> error("Unexpected MediaUploadInfo format: $info")
}.getOrThrow()
}.onFailure {
snackbarDispatcher.post(SnackbarMessage(StringR.string.screen_media_upload_preview_error_failed_sending))
Timber.e(it, "Couldn't upload media")
}.onSuccess {
Timber.d("Media uploaded")
}
}
private suspend fun handleMediaPreProcessing(
uri: Uri,
mediaType: MediaType,
deleteOriginal: Boolean,
): Result<MediaUploadInfo> {
val result = mediaPreProcessor.process(uri, mediaType, deleteOriginal = deleteOriginal)
Timber.d("Pre-processed media result: $result")
return result.onFailure {
snackbarDispatcher.post(SnackbarMessage(StringR.string.screen_media_upload_preview_error_failed_processing))
}
}
}

View File

@@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -136,6 +137,7 @@ class MessagesPresenterTest {
mediaPickerProvider = FakePickerProvider(),
featureFlagService = FakeFeatureFlagService(),
mediaPreProcessor = FakeMediaPreProcessor(),
snackbarDispatcher = SnackbarDispatcher(),
)
val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
@@ -148,6 +150,7 @@ class MessagesPresenterTest {
timelinePresenter = timelinePresenter,
actionListPresenter = actionListPresenter,
networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(),
)
}
}

View File

@@ -29,9 +29,13 @@ import io.element.android.features.messages.impl.textcomposer.MessageComposerPre
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.ThumbnailInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -42,14 +46,22 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.android.awaitFrame
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.Test
import java.io.File
class MessageComposerPresenterTest {
@@ -62,6 +74,7 @@ class MessageComposerPresenterTest {
}
}
private val mediaPreProcessor = FakeMediaPreProcessor()
private val snackbarDispatcher = SnackbarDispatcher()
@Test
fun `present - initial state`() = runTest {
@@ -285,16 +298,83 @@ class MessageComposerPresenterTest {
}
@Test
fun `present - Pick media from gallery`() = runTest {
val presenter = createPresenter(this)
fun `present - Pick image from gallery`() = runTest {
val room = FakeMatrixRoom()
val presenter = createPresenter(this, room = room)
pickerProvider.givenMimeType(MimeTypes.Images)
mediaPreProcessor.givenResult(Result.success(
MediaUploadInfo.Image(
file = File("/some/path"),
info = ImageInfo(
width = null,
height = null,
mimetype = null,
size = null,
thumbnailInfo = null,
thumbnailUrl = null,
blurhash = null,
),
thumbnailInfo = ThumbnailProcessingInfo(
file = File("/some/path"),
info = ThumbnailInfo(
width = null,
height = null,
mimetype = null,
size = null,
),
blurhash = "",
)
)
))
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
// Wait for the launched upload coroutine to run
runCurrent()
assertThat(room.sendMediaCount).isEqualTo(1)
}
}
// TODO verify some post processing of the selected media is done
@Test
fun `present - Pick video from gallery`() = runTest {
val room = FakeMatrixRoom()
val presenter = createPresenter(this, room = room)
pickerProvider.givenMimeType(MimeTypes.Videos)
mediaPreProcessor.givenResult(Result.success(
MediaUploadInfo.Video(
file = File("/some/path"),
info = VideoInfo(
width = null,
height = null,
mimetype = null,
duration = null,
size = null,
thumbnailInfo = null,
thumbnailUrl = null,
blurhash = null,
),
thumbnailInfo = ThumbnailProcessingInfo(
file = File("/some/path"),
info = ThumbnailInfo(
width = null,
height = null,
mimetype = null,
size = null,
),
blurhash = "",
)
)
))
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
// Wait for the launched upload coroutine to run
runCurrent()
assertThat(room.sendMediaCount).isEqualTo(1)
}
}
@@ -329,40 +409,67 @@ class MessageComposerPresenterTest {
@Test
fun `present - Pick file from storage`() = runTest {
val presenter = createPresenter(this)
val room = FakeMatrixRoom()
val presenter = createPresenter(this, room = room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
// Wait for the launched upload coroutine to run
runCurrent()
assertThat(room.sendMediaCount).isEqualTo(1)
}
}
@Test
fun `present - Take photo`() = runTest {
val room = FakeMatrixRoom()
val presenter = createPresenter(this, room = room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Photo)
// Wait for the launched upload coroutine to run
runCurrent()
assertThat(room.sendMediaCount).isEqualTo(1)
}
}
@Test
fun `present - Record video`() = runTest {
val room = FakeMatrixRoom()
val presenter = createPresenter(this, room = room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Video)
// Wait for the launched upload coroutine to run
runCurrent()
assertThat(room.sendMediaCount).isEqualTo(1)
}
}
@Test
fun `present - Uploading media failure can be recovered from`() = runTest {
val room = FakeMatrixRoom().apply {
givenSendMediaResult(Result.failure(Exception()))
}
val presenter = createPresenter(this, room = room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
// TODO verify some post processing of the selected media is done
}
}
@Test
fun `present - Take photo`() = runTest {
val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Photo)
// TODO verify some post processing of the captured image is done
}
}
@Test
fun `present - Record video`() = runTest {
val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Video)
// TODO verify some post processing of the captured video is done
snackbarDispatcher.snackbarMessage.test {
// Initial value is always null
skipItems(1)
// Assert error message received
assertThat(awaitItem()).isNotNull()
}
}
}
@@ -381,8 +488,9 @@ class MessageComposerPresenterTest {
pickerProvider: PickerProvider = this.pickerProvider,
featureFlagService: FeatureFlagService = this.featureFlagService,
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
) = MessageComposerPresenter(
coroutineScope, room, pickerProvider, featureFlagService, mediaPreProcessor
coroutineScope, room, pickerProvider, featureFlagService, mediaPreProcessor, snackbarDispatcher
)
}

View File

@@ -138,6 +138,7 @@ sqlite = "androidx.sqlite:sqlite:2.3.1"
unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1"
gujun_span = "me.gujun.android:span:1.7"
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0"
# Di
inject = "javax.inject:javax.inject:1"

View File

@@ -37,6 +37,7 @@ fun File.safeDelete() {
)
}
suspend fun Context.createTmpFile(baseDir: File = cacheDir): File = withContext(Dispatchers.IO) {
File.createTempFile(UUID.randomUUID().toString(), null, baseDir).apply { mkdirs() }
suspend fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null): File = withContext(Dispatchers.IO) {
val suffix = extension?.let { ".$extension" }
File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() }
}

View File

@@ -32,6 +32,7 @@ object MimeTypes {
const val Gif = "image/gif"
const val Videos = "video/*"
const val Mp4 = "video/mp4"
const val Audio = "audio/*"

View File

@@ -18,5 +18,6 @@ package io.element.android.libraries.matrix.api.media
data class AudioInfo(
val duration: Long?,
val size: Long?
val size: Long?,
val mimeType: String?,
)

View File

@@ -20,10 +20,15 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.io.Closeable
import java.io.File
interface MatrixRoom : Closeable {
val sessionId: SessionId
@@ -67,6 +72,14 @@ interface MatrixRoom : Closeable {
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>
suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit>
suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit>
suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result<Unit>
suspend fun sendFile(file: File, fileInfo: FileInfo): Result<Unit>
suspend fun leave(): Result<Unit>
suspend fun acceptInvitation(): Result<Unit>

View File

@@ -21,5 +21,12 @@ import org.matrix.rustcomponents.sdk.AudioInfo as RustAudioInfo
fun RustAudioInfo.map(): AudioInfo = AudioInfo(
duration = duration?.toLong(),
size = size?.toLong()
size = size?.toLong(),
mimeType = mimetype
)
fun AudioInfo.map(): RustAudioInfo = RustAudioInfo(
duration = duration?.toULong(),
size = size?.toULong(),
mimetype = mimeType,
)

View File

@@ -27,3 +27,10 @@ fun RustFileInfo.map(): FileInfo = FileInfo(
thumbnailInfo = thumbnailInfo?.map(),
thumbnailUrl = thumbnailSource?.useUrl()
)
fun FileInfo.map(): RustFileInfo = RustFileInfo(
mimetype = mimetype,
size = size?.toULong(),
thumbnailInfo = thumbnailInfo?.map(),
thumbnailSource = null
)

View File

@@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.impl.media
import io.element.android.libraries.matrix.api.media.ImageInfo
import org.matrix.rustcomponents.sdk.MediaSource
import org.matrix.rustcomponents.sdk.ImageInfo as RustImageInfo
fun RustImageInfo.map(): ImageInfo = ImageInfo(
@@ -28,3 +29,13 @@ fun RustImageInfo.map(): ImageInfo = ImageInfo(
thumbnailUrl = thumbnailSource?.useUrl(),
blurhash = blurhash
)
fun ImageInfo.map(): RustImageInfo = RustImageInfo(
height = height?.toULong(),
width = width?.toULong(),
mimetype = mimetype,
size = size?.toULong(),
thumbnailInfo = thumbnailInfo?.map(),
thumbnailSource = null,
blurhash = blurhash,
)

View File

@@ -25,3 +25,10 @@ fun RustThumbnailInfo.map(): ThumbnailInfo = ThumbnailInfo(
mimetype = mimetype,
size = size?.toLong()
)
fun ThumbnailInfo.map(): RustThumbnailInfo = RustThumbnailInfo(
height = height?.toULong(),
width = width?.toULong(),
mimetype = mimetype,
size = size?.toULong()
)

View File

@@ -29,3 +29,14 @@ fun RustVideoInfo.map(): VideoInfo = VideoInfo(
thumbnailUrl = thumbnailSource?.useUrl(),
blurhash = blurhash
)
fun VideoInfo.map(): RustVideoInfo = RustVideoInfo(
duration = duration?.toULong(),
height = height?.toULong(),
width = width?.toULong(),
mimetype = mimetype,
size = size?.toULong(),
thumbnailInfo = thumbnailInfo?.map(),
thumbnailSource = null,
blurhash = blurhash
)

View File

@@ -21,10 +21,15 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
@@ -39,6 +44,7 @@ import org.matrix.rustcomponents.sdk.SlidingSyncRoom
import org.matrix.rustcomponents.sdk.UpdateSummary
import org.matrix.rustcomponents.sdk.genTransactionId
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import java.io.File
class RustMatrixRoom(
override val sessionId: SessionId,
@@ -202,4 +208,28 @@ class RustMatrixRoom(
}
}
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> {
return runCatching {
innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map())
}
}
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> {
return runCatching {
innerRoom.sendVideo(file.path, thumbnailFile.path, videoInfo.map())
}
}
override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result<Unit> {
return runCatching {
innerRoom.sendAudio(file.path, audioInfo.map())
}
}
override suspend fun sendFile(file: File, fileInfo: FileInfo): Result<Unit> {
return runCatching {
innerRoom.sendFile(file.path, fileInfo.map())
}
}
}

View File

@@ -20,6 +20,10 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
@@ -30,6 +34,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import java.io.File
class FakeMatrixRoom(
override val sessionId: SessionId = A_SESSION_ID,
@@ -54,6 +59,9 @@ class FakeMatrixRoom(
private var updateMembersResult: Result<Unit> = Result.success(Unit)
private var acceptInviteResult = Result.success(Unit)
private var rejectInviteResult = Result.success(Unit)
private var sendMediaResult = Result.success(Unit)
var sendMediaCount = 0
private set
var isInviteAccepted: Boolean = false
private set
@@ -128,6 +136,14 @@ class FakeMatrixRoom(
return rejectInviteResult
}
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ }
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ }
override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ }
override suspend fun sendFile(file: File, fileInfo: FileInfo): Result<Unit> = sendMediaResult.also { sendMediaCount++ }
override fun close() = Unit
fun givenLeaveRoomError(throwable: Throwable?) {
@@ -165,4 +181,8 @@ class FakeMatrixRoom(
fun givenUnIgnoreResult(result: Result<Unit>) {
unignoreResult = result
}
fun givenSendMediaResult(result: Result<Unit>) {
sendMediaResult = result
}
}

View File

@@ -24,8 +24,8 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import java.io.File
sealed interface MediaUploadInfo {
data class Image(val file: File, val info: ImageInfo, val thumbnailInfo: ThumbnailProcessingInfo?) : MediaUploadInfo
data class Video(val file: File, val info: VideoInfo, val thumbnailInfo: ThumbnailProcessingInfo?) : MediaUploadInfo
data class Image(val file: File, val info: ImageInfo, val thumbnailInfo: ThumbnailProcessingInfo) : MediaUploadInfo
data class Video(val file: File, val info: VideoInfo, val thumbnailInfo: ThumbnailProcessingInfo) : MediaUploadInfo
data class Audio(val file: File, val info: AudioInfo) : MediaUploadInfo
data class AnyFile(val file: File, val info: FileInfo) : MediaUploadInfo
}
@@ -33,4 +33,5 @@ sealed interface MediaUploadInfo {
data class ThumbnailProcessingInfo(
val file: File,
val info: ThumbnailInfo,
val blurhash: String,
)

View File

@@ -41,6 +41,7 @@ android {
implementation(libs.androidx.exifinterface)
implementation(libs.coroutines.core)
implementation(libs.otaliastudios.transcoder)
implementation(libs.vanniktech.blurhash)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)

View File

@@ -19,6 +19,7 @@ package io.element.android.libraries.mediaupload
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import com.vanniktech.blurhash.BlurHash
import io.element.android.libraries.androidutils.bitmap.calculateInSampleSize
import io.element.android.libraries.androidutils.bitmap.resizeToMax
import io.element.android.libraries.androidutils.bitmap.rotateToMetadataOrientation
@@ -48,14 +49,21 @@ class ImageCompressor @Inject constructor(
): Result<ImageCompressionResult> = withContext(Dispatchers.IO) {
runCatching {
val compressedBitmap = compressToBitmap(inputStream, resizeMode).getOrThrow()
val blurhash = BlurHash.encode(compressedBitmap, 3, 3)
// Encode bitmap to the destination temporary file
val tmpFile = context.createTmpFile()
val tmpFile = context.createTmpFile(extension = "jpeg")
tmpFile.outputStream().use {
compressedBitmap.compress(format, desiredQuality, it)
}
ImageCompressionResult(tmpFile, compressedBitmap.width, compressedBitmap.height, tmpFile.length())
ImageCompressionResult(
file = tmpFile,
width = compressedBitmap.width,
height = compressedBitmap.height,
size = tmpFile.length(),
blurhash = blurhash
)
}
}
@@ -108,6 +116,7 @@ data class ImageCompressionResult(
val width: Int,
val height: Int,
val size: Long,
val blurhash: String,
)
sealed interface ResizeMode {

View File

@@ -26,9 +26,7 @@ import io.element.android.libraries.androidutils.file.createTmpFile
import io.element.android.libraries.androidutils.media.runAndRelease
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.media.AudioInfo
@@ -41,7 +39,6 @@ import io.element.android.libraries.mediaupload.api.MediaType
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
@@ -93,19 +90,23 @@ class MediaPreProcessorImpl @Inject constructor(
deleteOriginal: Boolean,
): Result<MediaUploadInfo> = runCatching {
// Camera returns an 'octet-stream' mimetype, so it needs to be overridden
val originalMimeType = contentResolver.getType(uri)
val mimeType = when (mediaType) {
MediaType.Image -> MimeTypes.Images
MediaType.Video -> MimeTypes.Videos
MediaType.Audio -> MimeTypes.Audio
else -> originalMimeType
val mimeType = contentResolver.getType(uri)
val mimeTypeOrDefault = if (mimeType == MimeTypes.OctetStream) {
when(mediaType) {
MediaType.Image -> MimeTypes.Jpeg
MediaType.Video -> MimeTypes.Mp4
MediaType.Audio -> MimeTypes.Ogg
else -> mimeType
}
} else {
mimeType
}
val compressBeforeSending = mediaType in sequenceOf(MediaType.Image, MediaType.Video)
val result = if (compressBeforeSending && mimeType != MimeTypes.Gif) {
when {
mimeType.isMimeTypeImage() -> processImage(uri)
mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType)
mimeType.isMimeTypeAudio() -> processAudio(uri)
when(mediaType) {
MediaType.Image -> processImage(uri)
MediaType.Video -> processVideo(uri, mimeTypeOrDefault)
MediaType.Audio -> processAudio(uri, mimeTypeOrDefault)
else -> error("Cannot compress file of type: $mimeType")
}
} else {
@@ -115,7 +116,7 @@ class MediaPreProcessorImpl @Inject constructor(
removeSensitiveImageMetadata(file)
}
val info = FileInfo(
mimetype = originalMimeType,
mimetype = mimeType,
size = file.length(),
thumbnailInfo = null,
thumbnailUrl = null,
@@ -141,7 +142,7 @@ class MediaPreProcessorImpl @Inject constructor(
removeSensitiveImageMetadata(compressedFileResult.file)
val thumbnailResult = compressedFileResult.file.inputStream().use { generateImageThumbnail(it) }
val processingResult = compressedFileResult.toImageInfo(MimeTypes.Jpeg, thumbnailResult?.file?.path, thumbnailResult?.info)
val processingResult = compressedFileResult.toImageInfo(MimeTypes.Jpeg, thumbnailResult.file.path, thumbnailResult.info)
return MediaUploadInfo.Image(compressedFileResult.file, processingResult, thumbnailResult)
}
@@ -155,32 +156,33 @@ class MediaPreProcessorImpl @Inject constructor(
.first()
.file
val videoProcessingInfo = extractVideoMetadata(resultFile, mimeType, thumbnailInfo?.file?.path, thumbnailInfo?.info)
val videoProcessingInfo = extractVideoMetadata(resultFile, mimeType, thumbnailInfo.file.path, thumbnailInfo)
return MediaUploadInfo.Video(resultFile, videoProcessingInfo, thumbnailInfo)
}
private suspend fun processAudio(uri: Uri): MediaUploadInfo {
private suspend fun processAudio(uri: Uri, mimeType: String?): MediaUploadInfo {
val file = copyToTmpFile(uri)
return MediaMetadataRetriever().runAndRelease {
setDataSource(context, Uri.fromFile(file))
val info = AudioInfo(
duration = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L,
size = file.length()
size = file.length(),
mimeType = mimeType,
)
MediaUploadInfo.Audio(file, info)
}
}
private suspend fun generateImageThumbnail(inputStream: InputStream): ThumbnailProcessingInfo? {
private suspend fun generateImageThumbnail(inputStream: InputStream): ThumbnailProcessingInfo {
val thumbnailResult = imageCompressor
.compressToTmpFile(
inputStream = inputStream,
resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT),
).getOrNull()
).getOrThrow()
return thumbnailResult?.toThumbnailProcessingInfo(MimeTypes.Jpeg)
return thumbnailResult.toThumbnailProcessingInfo(MimeTypes.Jpeg)
}
private fun removeSensitiveImageMetadata(file: File) {
@@ -203,7 +205,7 @@ class MediaPreProcessorImpl @Inject constructor(
}
}
private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailUrl: String?, thumbnailInfo: ThumbnailInfo?): VideoInfo =
private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailUrl: String?, thumbnailInfo: ThumbnailProcessingInfo?): VideoInfo =
MediaMetadataRetriever().runAndRelease {
setDataSource(context, Uri.fromFile(file))
@@ -213,16 +215,16 @@ class MediaPreProcessorImpl @Inject constructor(
height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L,
mimetype = mimeType,
size = file.length(),
thumbnailInfo = thumbnailInfo,
thumbnailInfo = thumbnailInfo?.info,
thumbnailUrl = thumbnailUrl,
blurhash = null,
blurhash = thumbnailInfo?.blurhash,
)
}
private suspend fun extractVideoThumbnail(uri: Uri): ThumbnailProcessingInfo? =
private suspend fun extractVideoThumbnail(uri: Uri): ThumbnailProcessingInfo =
MediaMetadataRetriever().runAndRelease {
setDataSource(context, uri)
val bitmap = getFrameAtTime(VIDEO_THUMB_FRAME) ?: return@runAndRelease null
val bitmap = requireNotNull(getFrameAtTime(VIDEO_THUMB_FRAME))
val inputStream = ByteArrayOutputStream().use {
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it)
ByteArrayInputStream(it.toByteArray())
@@ -249,7 +251,7 @@ fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailUrl: String?,
size = size,
thumbnailInfo = thumbnailInfo,
thumbnailUrl = thumbnailUrl,
blurhash = null,
blurhash = blurhash,
)
fun ImageCompressionResult.toThumbnailProcessingInfo(mimeType: String) = ThumbnailProcessingInfo(
@@ -260,4 +262,5 @@ fun ImageCompressionResult.toThumbnailProcessingInfo(mimeType: String) = Thumbna
mimetype = mimeType,
size = size,
),
blurhash = blurhash,
)

View File

@@ -32,7 +32,7 @@ class VideoCompressor @Inject constructor(
) {
fun compress(uri: Uri) = callbackFlow {
val tmpFile = context.createTmpFile()
val tmpFile = context.createTmpFile(extension = "mp4")
val future = Transcoder.into(tmpFile.path)
.addDataSource(context, uri)
.setListener(object : TranscoderListener {