Media: keep the name of the file when possible

This commit is contained in:
ganfra 2023-05-22 20:24:42 +02:00
parent c8c2cb8ff3
commit 4c19bd3644
10 changed files with 90 additions and 31 deletions

View file

@ -25,5 +25,5 @@ import kotlinx.parcelize.Parcelize
sealed interface Attachment : Parcelable { sealed interface Attachment : Parcelable {
@Parcelize @Parcelize
data class Media(val localMedia: LocalMedia) : Attachment data class Media(val localMedia: LocalMedia, val compressIfPossible: Boolean) : Attachment
} }

View file

@ -16,7 +16,6 @@
package io.element.android.features.messages.impl.attachments.preview package io.element.android.features.messages.impl.attachments.preview
import android.net.Uri
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -73,8 +72,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
when (attachment) { when (attachment) {
is Attachment.Media -> { is Attachment.Media -> {
sendMedia( sendMedia(
uri = attachment.localMedia.uri, mediaAttachment = attachment,
mimeType = attachment.localMedia.mimeType,
sendActionState = sendActionState sendActionState = sendActionState
) )
} }
@ -82,12 +80,11 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
} }
private suspend fun sendMedia( private suspend fun sendMedia(
uri: Uri, mediaAttachment: Attachment.Media,
mimeType: String,
sendActionState: MutableState<Async<Unit>>, sendActionState: MutableState<Async<Unit>>,
) { ) {
suspend { suspend {
mediaSender.sendMedia(uri, mimeType) mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.mimeType, mediaAttachment.compressIfPossible)
}.executeResult(sendActionState) }.executeResult(sendActionState)
} }
} }

View file

@ -36,6 +36,7 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
fun anAttachmentsPreviewState(sendActionState: Async<Unit> = Async.Uninitialized) = AttachmentsPreviewState( fun anAttachmentsPreviewState(sendActionState: Async<Unit> = Async.Uninitialized) = AttachmentsPreviewState(
attachment = Attachment.Media( attachment = Attachment.Media(
localMedia = LocalMedia("".toUri(), mimeType = MimeTypes.OctetStream), localMedia = LocalMedia("".toUri(), mimeType = MimeTypes.OctetStream),
compressIfPossible = true
), ),
sendActionState = sendActionState, sendActionState = sendActionState,
eventSink = {} eventSink = {}

View file

@ -70,16 +70,16 @@ class MessageComposerPresenter @Inject constructor(
mutableStateOf<AttachmentsState>(AttachmentsState.None) mutableStateOf<AttachmentsState>(AttachmentsState.None)
} }
fun handlePickedMedia(uri: Uri?, mimeType: String? = null) { fun handlePickedMedia(uri: Uri?, mimeType: String? = null, compressIfPossible: Boolean = true) {
val localMedia = localMediaFactory.createFromUri(uri, mimeType) val localMedia = localMediaFactory.createFromUri(uri, mimeType)
attachmentsState.value = if (localMedia == null) { attachmentsState.value = if (localMedia == null) {
AttachmentsState.None AttachmentsState.None
} else { } else {
val mediaAttachment = Attachment.Media(localMedia) val mediaAttachment = Attachment.Media(localMedia, compressIfPossible)
val isPreviewable = when { val isPreviewable = when {
MimeTypes.isImage(mimeType) -> true MimeTypes.isImage(localMedia.mimeType) -> true
MimeTypes.isVideo(mimeType) -> true MimeTypes.isVideo(localMedia.mimeType) -> true
MimeTypes.isAudio(mimeType) -> true MimeTypes.isAudio(localMedia.mimeType) -> true
else -> false else -> false
} }
if (isPreviewable) { if (isPreviewable) {
@ -93,7 +93,7 @@ class MessageComposerPresenter @Inject constructor(
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri, mimeType -> val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri, mimeType ->
handlePickedMedia(uri, mimeType) handlePickedMedia(uri, mimeType)
}) })
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes, onResult = { handlePickedMedia(it) }) val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes, onResult = { handlePickedMedia(it, compressIfPossible = false) })
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(onResult = { handlePickedMedia(it, MimeTypes.IMAGE_JPEG) }) val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(onResult = { handlePickedMedia(it, MimeTypes.IMAGE_JPEG) })
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker(onResult = { handlePickedMedia(it, MimeTypes.VIDEO_MP4) }) val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker(onResult = { handlePickedMedia(it, MimeTypes.VIDEO_MP4) })
@ -221,7 +221,7 @@ class MessageComposerPresenter @Inject constructor(
mimeType: String, mimeType: String,
attachmentState: MutableState<AttachmentsState>, attachmentState: MutableState<AttachmentsState>,
) { ) {
mediaSender.sendMedia(uri, mimeType) mediaSender.sendMedia(uri, mimeType, compressIfPossible = false)
.onSuccess { .onSuccess {
attachmentState.value = AttachmentsState.None attachmentState.value = AttachmentsState.None
}.onFailure { }.onFailure {

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.androidutils.file
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import java.io.File
fun Context.getFileName(uri: Uri): String? = when (uri.scheme) {
ContentResolver.SCHEME_CONTENT -> getContentFileName(uri)
else -> uri.path?.let(::File)?.name
}
private fun Context.getContentFileName(uri: Uri): String? = runCatching {
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
cursor.moveToFirst()
return@use cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME).let(cursor::getString)
}
}.getOrNull()

View file

@ -18,8 +18,6 @@ package io.element.android.libraries.androidutils.file
import android.content.Context import android.content.Context
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
@ -37,7 +35,7 @@ fun File.safeDelete() {
) )
} }
suspend fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null): File = withContext(Dispatchers.IO) { fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null): File {
val suffix = extension?.let { ".$extension" } val suffix = extension?.let { ".$extension" }
File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() } return File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() }
} }

View file

@ -27,7 +27,8 @@ interface MediaPreProcessor {
suspend fun process( suspend fun process(
uri: Uri, uri: Uri,
mimeType: String, mimeType: String,
deleteOriginal: Boolean = false deleteOriginal: Boolean = false,
compressIfPossible: Boolean
): Result<MediaUploadInfo> ): Result<MediaUploadInfo>
data class Failure(override val cause: Throwable?) : RuntimeException(cause) data class Failure(override val cause: Throwable?) : RuntimeException(cause)

View file

@ -26,9 +26,14 @@ class MediaSender @Inject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
) { ) {
suspend fun sendMedia(uri: Uri, mimeType: String): Result<Unit> { suspend fun sendMedia(uri: Uri, mimeType: String, compressIfPossible: Boolean): Result<Unit> {
return preProcessor return preProcessor
.process(uri, mimeType, deleteOriginal = true) .process(
uri = uri,
mimeType = mimeType,
deleteOriginal = true,
compressIfPossible = compressIfPossible
)
.flatMap { info -> .flatMap { info ->
room.sendMedia(info) room.sendMedia(info)
} }

View file

@ -23,7 +23,9 @@ import android.net.Uri
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.androidutils.file.createTmpFile import io.element.android.libraries.androidutils.file.createTmpFile
import io.element.android.libraries.androidutils.file.getFileName
import io.element.android.libraries.androidutils.media.runAndRelease import io.element.android.libraries.androidutils.media.runAndRelease
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
@ -41,7 +43,6 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
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.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo
import kotlinx.coroutines.Dispatchers
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
@ -58,6 +59,7 @@ class AndroidMediaPreProcessor @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val imageCompressor: ImageCompressor, private val imageCompressor: ImageCompressor,
private val videoCompressor: VideoCompressor, private val videoCompressor: VideoCompressor,
private val coroutineDispatchers: CoroutineDispatchers,
) : MediaPreProcessor { ) : MediaPreProcessor {
companion object { companion object {
/** /**
@ -92,12 +94,13 @@ class AndroidMediaPreProcessor @Inject constructor(
uri: Uri, uri: Uri,
mimeType: String, mimeType: String,
deleteOriginal: Boolean, deleteOriginal: Boolean,
compressIfPossible: Boolean,
): Result<MediaUploadInfo> = runCatching { ): Result<MediaUploadInfo> = runCatching {
val compressBeforeSending = ( val shouldBeCompressed = compressIfPossible &&
mimeType.isMimeTypeImage() && mimeType != MimeTypes.Gif) || (mimeType.isMimeTypeImage() && mimeType != MimeTypes.Gif) ||
mimeType.isMimeTypeVideo() mimeType.isMimeTypeVideo()
val result = if (compressBeforeSending) { val result = if (shouldBeCompressed) {
when { when {
mimeType.isMimeTypeImage() -> processImage(uri) mimeType.isMimeTypeImage() -> processImage(uri)
mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType) mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType)
@ -123,9 +126,26 @@ class AndroidMediaPreProcessor @Inject constructor(
contentResolver.delete(uri, null, null) contentResolver.delete(uri, null, null)
} }
} }
result result.postProcess(uri)
}.mapFailure { MediaPreProcessor.Failure(it) } }.mapFailure { MediaPreProcessor.Failure(it) }
private fun MediaUploadInfo.postProcess(uri: Uri): MediaUploadInfo {
fun File.rename(name: String): File {
return File(context.cacheDir, name).also {
renameTo(it)
}
}
val name = context.getFileName(uri) ?: return this
return when (this) {
is MediaUploadInfo.AnyFile -> copy(file = file.rename(name))
is MediaUploadInfo.Audio -> copy(file = file.rename(name))
is MediaUploadInfo.Image -> copy(file = file.rename(name))
is MediaUploadInfo.Video -> copy(file = file.rename(name))
}
}
private suspend fun processImage(uri: Uri): MediaUploadInfo { private suspend fun processImage(uri: Uri): MediaUploadInfo {
val compressedFileResult = contentResolver.openInputStream(uri).use { input -> val compressedFileResult = contentResolver.openInputStream(uri).use { input ->
imageCompressor.compressToTmpFile( imageCompressor.compressToTmpFile(
@ -176,7 +196,6 @@ class AndroidMediaPreProcessor @Inject constructor(
inputStream = inputStream, inputStream = inputStream,
resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT),
).getOrThrow() ).getOrThrow()
return thumbnailResult.toThumbnailProcessingInfo(MimeTypes.Jpeg) return thumbnailResult.toThumbnailProcessingInfo(MimeTypes.Jpeg)
} }
@ -191,7 +210,7 @@ class AndroidMediaPreProcessor @Inject constructor(
} }
private suspend fun createTmpFileWithInput(inputStream: InputStream): File? { private suspend fun createTmpFileWithInput(inputStream: InputStream): File? {
return withContext(Dispatchers.IO) { return withContext(coroutineDispatchers.io) {
tryOrNull { tryOrNull {
val tmpFile = context.createTmpFile() val tmpFile = context.createTmpFile()
tmpFile.outputStream().use { inputStream.copyTo(it) } tmpFile.outputStream().use { inputStream.copyTo(it) }
@ -203,7 +222,6 @@ class AndroidMediaPreProcessor @Inject constructor(
private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailUrl: String?, thumbnailInfo: ThumbnailProcessingInfo?): 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))
VideoInfo( VideoInfo(
duration = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L, duration = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L,
width = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toLong() ?: 0L, width = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toLong() ?: 0L,
@ -229,7 +247,6 @@ class AndroidMediaPreProcessor @Inject constructor(
inputStream = inputStream, inputStream = inputStream,
resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT),
) )
result.getOrThrow().toThumbnailProcessingInfo(MimeTypes.Jpeg) result.getOrThrow().toThumbnailProcessingInfo(MimeTypes.Jpeg)
} }

View file

@ -36,7 +36,12 @@ class FakeMediaPreProcessor : MediaPreProcessor {
) )
) )
override suspend fun process(uri: Uri, mimeType: String, deleteOriginal: Boolean): Result<MediaUploadInfo> = result override suspend fun process(
uri: Uri,
mimeType: String,
deleteOriginal: Boolean,
compressIfPossible: Boolean
): Result<MediaUploadInfo> = result
fun givenResult(value: Result<MediaUploadInfo>) { fun givenResult(value: Result<MediaUploadInfo>) {
this.result = value this.result = value