diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/Attachment.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/Attachment.kt index 53626a5037..8739a45201 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/Attachment.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/Attachment.kt @@ -25,5 +25,5 @@ import kotlinx.parcelize.Parcelize sealed interface Attachment : Parcelable { @Parcelize - data class Media(val localMedia: LocalMedia) : Attachment + data class Media(val localMedia: LocalMedia, val compressIfPossible: Boolean) : Attachment } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index edc0248d18..d80359e88c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -16,7 +16,6 @@ package io.element.android.features.messages.impl.attachments.preview -import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf @@ -73,8 +72,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( when (attachment) { is Attachment.Media -> { sendMedia( - uri = attachment.localMedia.uri, - mimeType = attachment.localMedia.mimeType, + mediaAttachment = attachment, sendActionState = sendActionState ) } @@ -82,12 +80,11 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( } private suspend fun sendMedia( - uri: Uri, - mimeType: String, + mediaAttachment: Attachment.Media, sendActionState: MutableState>, ) { suspend { - mediaSender.sendMedia(uri, mimeType) + mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.mimeType, mediaAttachment.compressIfPossible) }.executeResult(sendActionState) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt index 151698b0f4..220b4554dc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt @@ -36,6 +36,7 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider = Async.Uninitialized) = AttachmentsPreviewState( attachment = Attachment.Media( localMedia = LocalMedia("".toUri(), mimeType = MimeTypes.OctetStream), + compressIfPossible = true ), sendActionState = sendActionState, eventSink = {} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index c2bf12228c..1b1bebf209 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -70,16 +70,16 @@ class MessageComposerPresenter @Inject constructor( mutableStateOf(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) attachmentsState.value = if (localMedia == null) { AttachmentsState.None } else { - val mediaAttachment = Attachment.Media(localMedia) + val mediaAttachment = Attachment.Media(localMedia, compressIfPossible) val isPreviewable = when { - MimeTypes.isImage(mimeType) -> true - MimeTypes.isVideo(mimeType) -> true - MimeTypes.isAudio(mimeType) -> true + MimeTypes.isImage(localMedia.mimeType) -> true + MimeTypes.isVideo(localMedia.mimeType) -> true + MimeTypes.isAudio(localMedia.mimeType) -> true else -> false } if (isPreviewable) { @@ -93,7 +93,7 @@ class MessageComposerPresenter @Inject constructor( val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { 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 cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker(onResult = { handlePickedMedia(it, MimeTypes.VIDEO_MP4) }) @@ -221,7 +221,7 @@ class MessageComposerPresenter @Inject constructor( mimeType: String, attachmentState: MutableState, ) { - mediaSender.sendMedia(uri, mimeType) + mediaSender.sendMedia(uri, mimeType, compressIfPossible = false) .onSuccess { attachmentState.value = AttachmentsState.None }.onFailure { diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/Context.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/Context.kt new file mode 100644 index 0000000000..a5fab7545c --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/Context.kt @@ -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() 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 80df69cbcc..581d45a2b0 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 @@ -18,8 +18,6 @@ package io.element.android.libraries.androidutils.file import android.content.Context import io.element.android.libraries.core.data.tryOrNull -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import timber.log.Timber import java.io.File 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" } - File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() } + return File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() } } diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt index 2144f7bdd7..31c6a813ff 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt @@ -27,7 +27,8 @@ interface MediaPreProcessor { suspend fun process( uri: Uri, mimeType: String, - deleteOriginal: Boolean = false + deleteOriginal: Boolean = false, + compressIfPossible: Boolean ): Result data class Failure(override val cause: Throwable?) : RuntimeException(cause) diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt index 416fe1d063..d08860d4ca 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt @@ -26,9 +26,14 @@ class MediaSender @Inject constructor( private val room: MatrixRoom, ) { - suspend fun sendMedia(uri: Uri, mimeType: String): Result { + suspend fun sendMedia(uri: Uri, mimeType: String, compressIfPossible: Boolean): Result { return preProcessor - .process(uri, mimeType, deleteOriginal = true) + .process( + uri = uri, + mimeType = mimeType, + deleteOriginal = true, + compressIfPossible = compressIfPossible + ) .flatMap { info -> room.sendMedia(info) } diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt index 5e46f719b7..a4b5b5ea63 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt @@ -23,7 +23,9 @@ import android.net.Uri import androidx.exifinterface.media.ExifInterface import com.squareup.anvil.annotations.ContributesBinding 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.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.extensions.mapFailure 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.MediaUploadInfo import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onEach @@ -58,6 +59,7 @@ class AndroidMediaPreProcessor @Inject constructor( @ApplicationContext private val context: Context, private val imageCompressor: ImageCompressor, private val videoCompressor: VideoCompressor, + private val coroutineDispatchers: CoroutineDispatchers, ) : MediaPreProcessor { companion object { /** @@ -92,12 +94,13 @@ class AndroidMediaPreProcessor @Inject constructor( uri: Uri, mimeType: String, deleteOriginal: Boolean, + compressIfPossible: Boolean, ): Result = runCatching { - val compressBeforeSending = ( - mimeType.isMimeTypeImage() && mimeType != MimeTypes.Gif) || + val shouldBeCompressed = compressIfPossible && + (mimeType.isMimeTypeImage() && mimeType != MimeTypes.Gif) || mimeType.isMimeTypeVideo() - val result = if (compressBeforeSending) { + val result = if (shouldBeCompressed) { when { mimeType.isMimeTypeImage() -> processImage(uri) mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType) @@ -123,9 +126,26 @@ class AndroidMediaPreProcessor @Inject constructor( contentResolver.delete(uri, null, null) } } - result + result.postProcess(uri) }.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 { val compressedFileResult = contentResolver.openInputStream(uri).use { input -> imageCompressor.compressToTmpFile( @@ -176,7 +196,6 @@ class AndroidMediaPreProcessor @Inject constructor( inputStream = inputStream, resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), ).getOrThrow() - return thumbnailResult.toThumbnailProcessingInfo(MimeTypes.Jpeg) } @@ -191,7 +210,7 @@ class AndroidMediaPreProcessor @Inject constructor( } private suspend fun createTmpFileWithInput(inputStream: InputStream): File? { - return withContext(Dispatchers.IO) { + return withContext(coroutineDispatchers.io) { tryOrNull { val tmpFile = context.createTmpFile() 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 = MediaMetadataRetriever().runAndRelease { setDataSource(context, Uri.fromFile(file)) - VideoInfo( duration = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L, width = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toLong() ?: 0L, @@ -229,7 +247,6 @@ class AndroidMediaPreProcessor @Inject constructor( inputStream = inputStream, resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), ) - result.getOrThrow().toThumbnailProcessingInfo(MimeTypes.Jpeg) } diff --git a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt index f8b7237bbe..c4ab8d57b1 100644 --- a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt +++ b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt @@ -36,7 +36,12 @@ class FakeMediaPreProcessor : MediaPreProcessor { ) ) - override suspend fun process(uri: Uri, mimeType: String, deleteOriginal: Boolean): Result = result + override suspend fun process( + uri: Uri, + mimeType: String, + deleteOriginal: Boolean, + compressIfPossible: Boolean + ): Result = result fun givenResult(value: Result) { this.result = value