diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 4b0a6b2238..13f36c2ab6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -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 { @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 ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 88b25dd2d6..34e5f664ea 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -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 ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 8426f79721..9249b808a1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -52,5 +52,6 @@ fun aMessagesState() = MessagesState( ), actionListState = anActionListState(), hasNetworkConnection = true, + snackbarMessage = null, eventSink = {} ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 5bf26d88bc..cd70dc97cd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt index 8193c0c0b2..99d0f98b66 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt @@ -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 { @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 { + 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)) + } } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index d2ba50ad5a..6756f55f9c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -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(), ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt index dc1efd7d88..2ef7aa0c69 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt @@ -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 ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a6e923667e..3504a3b0fe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt index 137b15a893..80df69cbcc 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt @@ -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() } } diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt index 3e3afbfe0e..20e2a6d8ec 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt @@ -32,6 +32,7 @@ object MimeTypes { const val Gif = "image/gif" const val Videos = "video/*" + const val Mp4 = "video/mp4" const val Audio = "audio/*" diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioInfo.kt index 6547f32448..6ed6e474b6 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioInfo.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioInfo.kt @@ -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?, ) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 35451874aa..90b55a3a42 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -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 + suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result + + suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result + + suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result + + suspend fun sendFile(file: File, fileInfo: FileInfo): Result + suspend fun leave(): Result suspend fun acceptInvitation(): Result diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioInfo.kt index 1108dd1cc8..7c35c14fb7 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioInfo.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioInfo.kt @@ -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, ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FileInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FileInfo.kt index 98c96c4d9a..a13c48efc5 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FileInfo.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FileInfo.kt @@ -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 +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ImageInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ImageInfo.kt index 27ab6d656a..21130ab86e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ImageInfo.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ImageInfo.kt @@ -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, +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ThumbnailInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ThumbnailInfo.kt index 3f10720132..c3940ac967 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ThumbnailInfo.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ThumbnailInfo.kt @@ -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() +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt index 0db364f544..9d03e2be2f 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt @@ -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 +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 062f24ab62..3930ebebe6 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -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 { + return runCatching { + innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map()) + } + } + + override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result { + return runCatching { + innerRoom.sendVideo(file.path, thumbnailFile.path, videoInfo.map()) + } + } + + override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result { + return runCatching { + innerRoom.sendAudio(file.path, audioInfo.map()) + } + } + + override suspend fun sendFile(file: File, fileInfo: FileInfo): Result { + return runCatching { + innerRoom.sendFile(file.path, fileInfo.map()) + } + } + } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 28d6525739..a790812137 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -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 = 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 = sendMediaResult.also { sendMediaCount++ } + + override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result = sendMediaResult.also { sendMediaCount++ } + + override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result = sendMediaResult.also { sendMediaCount++ } + + override suspend fun sendFile(file: File, fileInfo: FileInfo): Result = sendMediaResult.also { sendMediaCount++ } + override fun close() = Unit fun givenLeaveRoomError(throwable: Throwable?) { @@ -165,4 +181,8 @@ class FakeMatrixRoom( fun givenUnIgnoreResult(result: Result) { unignoreResult = result } + + fun givenSendMediaResult(result: Result) { + sendMediaResult = result + } } diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt index 36eb8f5102..6696806e24 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt @@ -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, ) diff --git a/libraries/mediaupload/impl/build.gradle.kts b/libraries/mediaupload/impl/build.gradle.kts index c451d81c7e..a23ab14b74 100644 --- a/libraries/mediaupload/impl/build.gradle.kts +++ b/libraries/mediaupload/impl/build.gradle.kts @@ -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) diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt index 45c09a2a47..96d5e2ea63 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt @@ -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 = 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 { diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/MediaPreProcessorImpl.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/MediaPreProcessorImpl.kt index 3d51cb24f1..c6dd7eb841 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/MediaPreProcessorImpl.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/MediaPreProcessorImpl.kt @@ -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 = 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, ) diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/VideoCompressor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/VideoCompressor.kt index ea0dbf28fd..490e286353 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/VideoCompressor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/VideoCompressor.kt @@ -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 {