[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.libraries.textcomposer.MessageComposerMode
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus 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.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@ -52,6 +54,7 @@ class MessagesPresenter @Inject constructor(
private val timelinePresenter: TimelinePresenter, private val timelinePresenter: TimelinePresenter,
private val actionListPresenter: ActionListPresenter, private val actionListPresenter: ActionListPresenter,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<MessagesState> { ) : Presenter<MessagesState> {
@Composable @Composable
@ -71,6 +74,8 @@ class MessagesPresenter @Inject constructor(
val networkConnectionStatus by networkMonitor.connectivity.collectAsState(initial = networkMonitor.currentConnectivityStatus) val networkConnectionStatus by networkMonitor.connectivity.collectAsState(initial = networkMonitor.currentConnectivityStatus)
val snackbarMessage = handleSnackbarMessage(snackbarDispatcher)
LaunchedEffect(syncUpdateFlow) { LaunchedEffect(syncUpdateFlow) {
roomAvatar.value = roomAvatar.value =
AvatarData( AvatarData(
@ -97,6 +102,7 @@ class MessagesPresenter @Inject constructor(
timelineState = timelineState, timelineState = timelineState,
actionListState = actionListState, actionListState = actionListState,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
snackbarMessage = snackbarMessage,
eventSink = ::handleEvents 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.textcomposer.MessageComposerState
import io.element.android.features.messages.impl.timeline.TimelineState import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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 import io.element.android.libraries.matrix.api.core.RoomId
@Immutable @Immutable
@ -32,5 +33,6 @@ data class MessagesState(
val timelineState: TimelineState, val timelineState: TimelineState,
val actionListState: ActionListState, val actionListState: ActionListState,
val hasNetworkConnection: Boolean, val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?,
val eventSink: (MessagesEvents) -> Unit val eventSink: (MessagesEvents) -> Unit
) )

View file

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

View file

@ -46,6 +46,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -94,7 +95,6 @@ fun MessagesView(
val itemActionsBottomSheetState = rememberModalBottomSheetState( val itemActionsBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden, initialValue = ModalBottomSheetValue.Hidden,
) )
val snackbarHostState = remember { SnackbarHostState() }
val composerState = state.composerState val composerState = state.composerState
val initialBottomSheetState = if (LocalInspectionMode.current && composerState.attachmentSourcePicker != null) { val initialBottomSheetState = if (LocalInspectionMode.current && composerState.attachmentSourcePicker != null) {
ModalBottomSheetValue.Expanded 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 // This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
val localView = LocalView.current 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
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo 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.RoomScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService 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.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaType import io.element.android.libraries.mediaupload.api.MediaType
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import io.element.android.libraries.ui.strings.R as StringR
@SingleIn(RoomScope::class) @SingleIn(RoomScope::class)
class MessageComposerPresenter @Inject constructor( class MessageComposerPresenter @Inject constructor(
@ -53,6 +57,7 @@ class MessageComposerPresenter @Inject constructor(
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val mediaPreProcessor: MediaPreProcessor, private val mediaPreProcessor: MediaPreProcessor,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
@Composable @Composable
@ -60,6 +65,7 @@ class MessageComposerPresenter @Inject constructor(
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri, mimeType -> val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri, mimeType ->
if (uri == null) return@registerGalleryPicker
Timber.d("Media picked from $uri") Timber.d("Media picked from $uri")
// We don't know which type of media was retrieved, so we need this check // We don't know which type of media was retrieved, so we need this check
val mediaType = when { val mediaType = when {
@ -67,22 +73,25 @@ class MessageComposerPresenter @Inject constructor(
mimeType.isMimeTypeVideo() -> MediaType.Video mimeType.isMimeTypeVideo() -> MediaType.Video
else -> error("MimeType must be either image/* or 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 -> val filesPicker = mediaPickerProvider.registerFilePicker(mimeType = MimeTypes.Any) { uri ->
if (uri == null) return@registerFilePicker
Timber.d("File picked from $uri") Timber.d("File picked from $uri")
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.File) appCoroutineScope.sendMedia(uri, MediaType.File)
} }
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
if (uri == null) return@registerCameraPhotoPicker
Timber.d("Photo saved at $uri") 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 -> val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
if (uri == null) return@registerCameraVideoPicker
Timber.d("Video saved at $uri") Timber.d("Video saved at $uri")
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.Video, deleteOriginal = true) appCoroutineScope.sendMedia(uri, MediaType.Video, deleteOriginal = true)
} }
val isFullScreen = rememberSaveable { val isFullScreen = rememberSaveable {
@ -181,14 +190,44 @@ class MessageComposerPresenter @Inject constructor(
} }
} }
private fun CoroutineScope.handleMediaPreProcessing( private fun CoroutineScope.sendMedia(
uri: Uri?, uri: Uri,
mediaType: MediaType, mediaType: MediaType,
deleteOriginal: Boolean = false deleteOriginal: Boolean = false
) = launch { ) = 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") 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.textcomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor 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.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -136,6 +137,7 @@ class MessagesPresenterTest {
mediaPickerProvider = FakePickerProvider(), mediaPickerProvider = FakePickerProvider(),
featureFlagService = FakeFeatureFlagService(), featureFlagService = FakeFeatureFlagService(),
mediaPreProcessor = FakeMediaPreProcessor(), mediaPreProcessor = FakeMediaPreProcessor(),
snackbarDispatcher = SnackbarDispatcher(),
) )
val timelinePresenter = TimelinePresenter( val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
@ -148,6 +150,7 @@ class MessagesPresenterTest {
timelinePresenter = timelinePresenter, timelinePresenter = timelinePresenter,
actionListPresenter = actionListPresenter, actionListPresenter = actionListPresenter,
networkMonitor = FakeNetworkMonitor(), 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.features.messages.impl.textcomposer.MessageComposerState
import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.core.mimetype.MimeTypes 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.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.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.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
import io.element.android.libraries.matrix.test.AN_EVENT_ID 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.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor 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.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.android.awaitFrame
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.Test import org.junit.Test
import java.io.File
class MessageComposerPresenterTest { class MessageComposerPresenterTest {
@ -62,6 +74,7 @@ class MessageComposerPresenterTest {
} }
} }
private val mediaPreProcessor = FakeMediaPreProcessor() private val mediaPreProcessor = FakeMediaPreProcessor()
private val snackbarDispatcher = SnackbarDispatcher()
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
@ -285,16 +298,83 @@ class MessageComposerPresenterTest {
} }
@Test @Test
fun `present - Pick media from gallery`() = runTest { fun `present - Pick image from gallery`() = runTest {
val presenter = createPresenter(this) val room = FakeMatrixRoom()
val presenter = createPresenter(this, room = room)
pickerProvider.givenMimeType(MimeTypes.Images) 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) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) 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 @Test
fun `present - Pick file from storage`() = runTest { 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) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
// TODO verify some post processing of the selected media is done snackbarDispatcher.snackbarMessage.test {
} // Initial value is always null
} skipItems(1)
// Assert error message received
@Test assertThat(awaitItem()).isNotNull()
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
} }
} }
@ -381,8 +488,9 @@ class MessageComposerPresenterTest {
pickerProvider: PickerProvider = this.pickerProvider, pickerProvider: PickerProvider = this.pickerProvider,
featureFlagService: FeatureFlagService = this.featureFlagService, featureFlagService: FeatureFlagService = this.featureFlagService,
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor, mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
) = MessageComposerPresenter( ) = 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" unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1"
gujun_span = "me.gujun.android:span:1.7" gujun_span = "me.gujun.android:span:1.7"
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5" otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0"
# Di # Di
inject = "javax.inject:javax.inject:1" 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) { suspend fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null): File = withContext(Dispatchers.IO) {
File.createTempFile(UUID.randomUUID().toString(), null, baseDir).apply { mkdirs() } 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 Gif = "image/gif"
const val Videos = "video/*" const val Videos = "video/*"
const val Mp4 = "video/mp4"
const val Audio = "audio/*" const val Audio = "audio/*"

View file

@ -18,5 +18,6 @@ package io.element.android.libraries.matrix.api.media
data class AudioInfo( data class AudioInfo(
val duration: Long?, 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId 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 io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import java.io.Closeable import java.io.Closeable
import java.io.File
interface MatrixRoom : Closeable { interface MatrixRoom : Closeable {
val sessionId: SessionId val sessionId: SessionId
@ -67,6 +72,14 @@ interface MatrixRoom : Closeable {
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit> 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 leave(): Result<Unit>
suspend fun acceptInvitation(): 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( fun RustAudioInfo.map(): AudioInfo = AudioInfo(
duration = duration?.toLong(), 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(), thumbnailInfo = thumbnailInfo?.map(),
thumbnailUrl = thumbnailSource?.useUrl() 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 package io.element.android.libraries.matrix.impl.media
import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.ImageInfo
import org.matrix.rustcomponents.sdk.MediaSource
import org.matrix.rustcomponents.sdk.ImageInfo as RustImageInfo import org.matrix.rustcomponents.sdk.ImageInfo as RustImageInfo
fun RustImageInfo.map(): ImageInfo = ImageInfo( fun RustImageInfo.map(): ImageInfo = ImageInfo(
@ -28,3 +29,13 @@ fun RustImageInfo.map(): ImageInfo = ImageInfo(
thumbnailUrl = thumbnailSource?.useUrl(), thumbnailUrl = thumbnailSource?.useUrl(),
blurhash = blurhash 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, mimetype = mimetype,
size = size?.toLong() 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(), thumbnailUrl = thumbnailSource?.useUrl(),
blurhash = blurhash 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState 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.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline 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 io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow 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.UpdateSummary
import org.matrix.rustcomponents.sdk.genTransactionId import org.matrix.rustcomponents.sdk.genTransactionId
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import java.io.File
class RustMatrixRoom( class RustMatrixRoom(
override val sessionId: SessionId, 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline 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.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import java.io.File
class FakeMatrixRoom( class FakeMatrixRoom(
override val sessionId: SessionId = A_SESSION_ID, override val sessionId: SessionId = A_SESSION_ID,
@ -54,6 +59,9 @@ class FakeMatrixRoom(
private var updateMembersResult: Result<Unit> = Result.success(Unit) private var updateMembersResult: Result<Unit> = Result.success(Unit)
private var acceptInviteResult = Result.success(Unit) private var acceptInviteResult = Result.success(Unit)
private var rejectInviteResult = Result.success(Unit) private var rejectInviteResult = Result.success(Unit)
private var sendMediaResult = Result.success(Unit)
var sendMediaCount = 0
private set
var isInviteAccepted: Boolean = false var isInviteAccepted: Boolean = false
private set private set
@ -128,6 +136,14 @@ class FakeMatrixRoom(
return rejectInviteResult 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 override fun close() = Unit
fun givenLeaveRoomError(throwable: Throwable?) { fun givenLeaveRoomError(throwable: Throwable?) {
@ -165,4 +181,8 @@ class FakeMatrixRoom(
fun givenUnIgnoreResult(result: Result<Unit>) { fun givenUnIgnoreResult(result: Result<Unit>) {
unignoreResult = result 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 import java.io.File
sealed interface MediaUploadInfo { sealed interface MediaUploadInfo {
data class Image(val file: File, val info: ImageInfo, 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 Video(val file: File, val info: VideoInfo, val thumbnailInfo: ThumbnailProcessingInfo) : MediaUploadInfo
data class Audio(val file: File, val info: AudioInfo) : MediaUploadInfo data class Audio(val file: File, val info: AudioInfo) : MediaUploadInfo
data class AnyFile(val file: File, val info: FileInfo) : MediaUploadInfo data class AnyFile(val file: File, val info: FileInfo) : MediaUploadInfo
} }
@ -33,4 +33,5 @@ sealed interface MediaUploadInfo {
data class ThumbnailProcessingInfo( data class ThumbnailProcessingInfo(
val file: File, val file: File,
val info: ThumbnailInfo, val info: ThumbnailInfo,
val blurhash: String,
) )

View file

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

View file

@ -19,6 +19,7 @@ package io.element.android.libraries.mediaupload
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import com.vanniktech.blurhash.BlurHash
import io.element.android.libraries.androidutils.bitmap.calculateInSampleSize import io.element.android.libraries.androidutils.bitmap.calculateInSampleSize
import io.element.android.libraries.androidutils.bitmap.resizeToMax import io.element.android.libraries.androidutils.bitmap.resizeToMax
import io.element.android.libraries.androidutils.bitmap.rotateToMetadataOrientation import io.element.android.libraries.androidutils.bitmap.rotateToMetadataOrientation
@ -48,14 +49,21 @@ class ImageCompressor @Inject constructor(
): Result<ImageCompressionResult> = withContext(Dispatchers.IO) { ): Result<ImageCompressionResult> = withContext(Dispatchers.IO) {
runCatching { runCatching {
val compressedBitmap = compressToBitmap(inputStream, resizeMode).getOrThrow() val compressedBitmap = compressToBitmap(inputStream, resizeMode).getOrThrow()
val blurhash = BlurHash.encode(compressedBitmap, 3, 3)
// Encode bitmap to the destination temporary file // Encode bitmap to the destination temporary file
val tmpFile = context.createTmpFile() val tmpFile = context.createTmpFile(extension = "jpeg")
tmpFile.outputStream().use { tmpFile.outputStream().use {
compressedBitmap.compress(format, desiredQuality, it) 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 width: Int,
val height: Int, val height: Int,
val size: Long, val size: Long,
val blurhash: String,
) )
sealed interface ResizeMode { 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.androidutils.media.runAndRelease
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.mimetype.MimeTypes 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.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.media.AudioInfo 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.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -93,19 +90,23 @@ class MediaPreProcessorImpl @Inject constructor(
deleteOriginal: Boolean, deleteOriginal: Boolean,
): Result<MediaUploadInfo> = runCatching { ): Result<MediaUploadInfo> = runCatching {
// Camera returns an 'octet-stream' mimetype, so it needs to be overridden // Camera returns an 'octet-stream' mimetype, so it needs to be overridden
val originalMimeType = contentResolver.getType(uri) val mimeType = contentResolver.getType(uri)
val mimeType = when (mediaType) { val mimeTypeOrDefault = if (mimeType == MimeTypes.OctetStream) {
MediaType.Image -> MimeTypes.Images when(mediaType) {
MediaType.Video -> MimeTypes.Videos MediaType.Image -> MimeTypes.Jpeg
MediaType.Audio -> MimeTypes.Audio MediaType.Video -> MimeTypes.Mp4
else -> originalMimeType MediaType.Audio -> MimeTypes.Ogg
else -> mimeType
}
} else {
mimeType
} }
val compressBeforeSending = mediaType in sequenceOf(MediaType.Image, MediaType.Video) val compressBeforeSending = mediaType in sequenceOf(MediaType.Image, MediaType.Video)
val result = if (compressBeforeSending && mimeType != MimeTypes.Gif) { val result = if (compressBeforeSending && mimeType != MimeTypes.Gif) {
when { when(mediaType) {
mimeType.isMimeTypeImage() -> processImage(uri) MediaType.Image -> processImage(uri)
mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType) MediaType.Video -> processVideo(uri, mimeTypeOrDefault)
mimeType.isMimeTypeAudio() -> processAudio(uri) MediaType.Audio -> processAudio(uri, mimeTypeOrDefault)
else -> error("Cannot compress file of type: $mimeType") else -> error("Cannot compress file of type: $mimeType")
} }
} else { } else {
@ -115,7 +116,7 @@ class MediaPreProcessorImpl @Inject constructor(
removeSensitiveImageMetadata(file) removeSensitiveImageMetadata(file)
} }
val info = FileInfo( val info = FileInfo(
mimetype = originalMimeType, mimetype = mimeType,
size = file.length(), size = file.length(),
thumbnailInfo = null, thumbnailInfo = null,
thumbnailUrl = null, thumbnailUrl = null,
@ -141,7 +142,7 @@ class MediaPreProcessorImpl @Inject constructor(
removeSensitiveImageMetadata(compressedFileResult.file) removeSensitiveImageMetadata(compressedFileResult.file)
val thumbnailResult = compressedFileResult.file.inputStream().use { generateImageThumbnail(it) } 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) return MediaUploadInfo.Image(compressedFileResult.file, processingResult, thumbnailResult)
} }
@ -155,32 +156,33 @@ class MediaPreProcessorImpl @Inject constructor(
.first() .first()
.file .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) 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) val file = copyToTmpFile(uri)
return MediaMetadataRetriever().runAndRelease { return MediaMetadataRetriever().runAndRelease {
setDataSource(context, Uri.fromFile(file)) setDataSource(context, Uri.fromFile(file))
val info = AudioInfo( val info = AudioInfo(
duration = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L, duration = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L,
size = file.length() size = file.length(),
mimeType = mimeType,
) )
MediaUploadInfo.Audio(file, info) MediaUploadInfo.Audio(file, info)
} }
} }
private suspend fun generateImageThumbnail(inputStream: InputStream): ThumbnailProcessingInfo? { private suspend fun generateImageThumbnail(inputStream: InputStream): ThumbnailProcessingInfo {
val thumbnailResult = imageCompressor val thumbnailResult = imageCompressor
.compressToTmpFile( .compressToTmpFile(
inputStream = inputStream, inputStream = inputStream,
resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), 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) { 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 { MediaMetadataRetriever().runAndRelease {
setDataSource(context, Uri.fromFile(file)) setDataSource(context, Uri.fromFile(file))
@ -213,16 +215,16 @@ class MediaPreProcessorImpl @Inject constructor(
height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L, height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L,
mimetype = mimeType, mimetype = mimeType,
size = file.length(), size = file.length(),
thumbnailInfo = thumbnailInfo, thumbnailInfo = thumbnailInfo?.info,
thumbnailUrl = thumbnailUrl, thumbnailUrl = thumbnailUrl,
blurhash = null, blurhash = thumbnailInfo?.blurhash,
) )
} }
private suspend fun extractVideoThumbnail(uri: Uri): ThumbnailProcessingInfo? = private suspend fun extractVideoThumbnail(uri: Uri): ThumbnailProcessingInfo =
MediaMetadataRetriever().runAndRelease { MediaMetadataRetriever().runAndRelease {
setDataSource(context, uri) setDataSource(context, uri)
val bitmap = getFrameAtTime(VIDEO_THUMB_FRAME) ?: return@runAndRelease null val bitmap = requireNotNull(getFrameAtTime(VIDEO_THUMB_FRAME))
val inputStream = ByteArrayOutputStream().use { val inputStream = ByteArrayOutputStream().use {
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it) bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it)
ByteArrayInputStream(it.toByteArray()) ByteArrayInputStream(it.toByteArray())
@ -249,7 +251,7 @@ fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailUrl: String?,
size = size, size = size,
thumbnailInfo = thumbnailInfo, thumbnailInfo = thumbnailInfo,
thumbnailUrl = thumbnailUrl, thumbnailUrl = thumbnailUrl,
blurhash = null, blurhash = blurhash,
) )
fun ImageCompressionResult.toThumbnailProcessingInfo(mimeType: String) = ThumbnailProcessingInfo( fun ImageCompressionResult.toThumbnailProcessingInfo(mimeType: String) = ThumbnailProcessingInfo(
@ -260,4 +262,5 @@ fun ImageCompressionResult.toThumbnailProcessingInfo(mimeType: String) = Thumbna
mimetype = mimeType, mimetype = mimeType,
size = size, size = size,
), ),
blurhash = blurhash,
) )

View file

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