From 33c74fb4e53417d34df6433795c8dbad97ef6262 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 16 Jun 2023 13:52:07 +0200 Subject: [PATCH] Fix MediaPreProcessor for images/videos sent as file --- .../MessageComposerPresenterTest.kt | 8 +- .../media/MediaMetaDataRetriever.kt | 6 +- .../libraries/mediaupload/api/MediaSender.kt | 17 +- .../mediaupload/api/MediaUploadInfo.kt | 11 +- .../mediaupload/AndroidMediaPreProcessor.kt | 219 ++++++++---------- .../libraries/mediaupload/ImageCompressor.kt | 15 +- .../libraries/mediaupload/ThumbnailFactory.kt | 122 ++++++++++ 7 files changed, 251 insertions(+), 147 deletions(-) create mode 100644 libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ThumbnailFactory.kt 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 8fc9b702bc..f646e1efb8 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 @@ -50,7 +50,7 @@ import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaUploadInfo -import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo +import io.element.android.libraries.mediaupload.api.ThumbnailResult import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.textcomposer.MessageComposerMode import io.mockk.mockk @@ -301,7 +301,7 @@ class MessageComposerPresenterTest { thumbnailSource = null, blurhash = null, ), - thumbnailInfo = ThumbnailProcessingInfo( + thumbnailInfo = ThumbnailResult( file = File("/some/path"), info = ThumbnailInfo( width = null, @@ -309,7 +309,6 @@ class MessageComposerPresenterTest { mimetype = null, size = null, ), - blurhash = "", ) ) ) @@ -344,7 +343,7 @@ class MessageComposerPresenterTest { thumbnailSource = null, blurhash = null, ), - thumbnailInfo = ThumbnailProcessingInfo( + thumbnailInfo = ThumbnailResult( file = File("/some/path"), info = ThumbnailInfo( width = null, @@ -352,7 +351,6 @@ class MessageComposerPresenterTest { mimetype = null, size = null, ), - blurhash = "", ) ) ) diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt index 3b29787285..8f942957e0 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt @@ -20,5 +20,9 @@ import android.media.MediaMetadataRetriever /** [MediaMetadataRetriever] only implements `AutoClosable` since API 29, so we need to execute this to have the same in older APIs. */ inline fun MediaMetadataRetriever.runAndRelease(block: MediaMetadataRetriever.() -> T): T { - return block().also { release() } + return try { + block() + } finally { + release() + } } 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 585670d939..5de8239e38 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 @@ -44,15 +44,26 @@ class MediaSender @Inject constructor( ): Result { return when (info) { is MediaUploadInfo.Image -> { - sendImage(info.file, info.thumbnailInfo.file, info.info) + sendImage( + file = info.file, + thumbnailFile = info.thumbnailFile, + imageInfo = info.info + ) } is MediaUploadInfo.Video -> { - sendVideo(info.file, info.thumbnailInfo.file, info.info) + sendVideo( + file = info.file, + thumbnailFile = info.thumbnailFile, + videoInfo = info.info + ) } is MediaUploadInfo.AnyFile -> { - sendFile(info.file, info.info) + sendFile( + file = info.file, + fileInfo = info.info + ) } else -> Result.failure(IllegalStateException("Unexpected MediaUploadInfo format: $info")) } 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 47fa26ae79..5da3d36c44 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 @@ -19,7 +19,6 @@ package io.element.android.libraries.mediaupload.api 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.ThumbnailInfo import io.element.android.libraries.matrix.api.media.VideoInfo import java.io.File @@ -27,14 +26,8 @@ sealed interface MediaUploadInfo { val file: File - data class Image(override val file: File, val info: ImageInfo, val thumbnailInfo: ThumbnailProcessingInfo) : MediaUploadInfo - data class Video(override val file: File, val info: VideoInfo, val thumbnailInfo: ThumbnailProcessingInfo) : MediaUploadInfo + data class Image(override val file: File, val info: ImageInfo, val thumbnailFile: File) : MediaUploadInfo + data class Video(override val file: File, val info: VideoInfo, val thumbnailFile: File) : MediaUploadInfo data class Audio(override val file: File, val info: AudioInfo) : MediaUploadInfo data class AnyFile(override val file: File, val info: FileInfo) : MediaUploadInfo } - -data class ThumbnailProcessingInfo( - val file: File, - val info: ThumbnailInfo, - val blurhash: String, -) 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 4882000307..dc56846b87 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 @@ -17,7 +17,7 @@ package io.element.android.libraries.mediaupload import android.content.Context -import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.media.MediaMetadataRetriever import android.net.Uri import androidx.exifinterface.media.ExifInterface @@ -37,26 +37,21 @@ import io.element.android.libraries.di.ApplicationContext 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.MediaSource -import io.element.android.libraries.matrix.api.media.ThumbnailInfo 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.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.withContext -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream import java.io.File import java.io.InputStream import javax.inject.Inject -import kotlin.time.Duration.Companion.seconds @ContributesBinding(AppScope::class) class AndroidMediaPreProcessor @Inject constructor( @ApplicationContext private val context: Context, + private val thumbnailFactory: ThumbnailFactory, private val imageCompressor: ImageCompressor, private val videoCompressor: VideoCompressor, private val coroutineDispatchers: CoroutineDispatchers, @@ -69,23 +64,6 @@ class AndroidMediaPreProcessor @Inject constructor( * values may surpass this limit. (i.e.: an image of `480x3000px` would have `inSampleSize=1` and be sent as is). */ private const val IMAGE_SCALE_REF_SIZE = 640 - - /** - * Max width of thumbnail images. - * See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/?ref=blog.gitter.im#thumbnails). - */ - private const val THUMB_MAX_WIDTH = 800 - - /** - * Max height of thumbnail images. - * See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/?ref=blog.gitter.im#thumbnails). - */ - private const val THUMB_MAX_HEIGHT = 600 - - /** - * Frame of the video to be used for generating a thumbnail. - */ - private val VIDEO_THUMB_FRAME = 5.seconds.inWholeMicroseconds } private val contentResolver = context.contentResolver @@ -95,40 +73,34 @@ class AndroidMediaPreProcessor @Inject constructor( mimeType: String, deleteOriginal: Boolean, compressIfPossible: Boolean, - ): Result = runCatching { - val shouldBeCompressed = compressIfPossible && - (mimeType.isMimeTypeImage() && mimeType != MimeTypes.Gif) || - mimeType.isMimeTypeVideo() - - val result = if (shouldBeCompressed) { - when { - mimeType.isMimeTypeImage() -> processImage(uri) - mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType) + ): Result = withContext(coroutineDispatchers.computation) { + runCatching { + val result = when { + mimeType.isMimeTypeImage() -> processImage(uri, mimeType, compressIfPossible && mimeType != MimeTypes.Gif) + mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType, compressIfPossible) mimeType.isMimeTypeAudio() -> processAudio(uri, mimeType) - else -> error("Cannot compress file of type: $mimeType") + else -> processFile(uri, mimeType) } - } else { - val file = copyToTmpFile(uri) - // Remove image metadata here too - if (mimeType.isMimeTypeImage() && mimeType != MimeTypes.Gif) { - removeSensitiveImageMetadata(file) + if (deleteOriginal) { + tryOrNull { + contentResolver.delete(uri, null, null) + } } - val info = FileInfo( - mimetype = mimeType, - size = file.length(), - thumbnailInfo = null, - thumbnailSource = null, - ) - MediaUploadInfo.AnyFile(file, info) + result.postProcess(uri) } - if (deleteOriginal) { - tryOrNull { - contentResolver.delete(uri, null, null) - } - } - result.postProcess(uri) }.mapFailure { MediaPreProcessor.Failure(it) } + private suspend fun processFile(uri: Uri, mimeType: String): MediaUploadInfo { + val file = copyToTmpFile(uri) + val info = FileInfo( + mimetype = mimeType, + size = file.length(), + thumbnailInfo = null, + thumbnailSource = null, + ) + return MediaUploadInfo.AnyFile(file, info) + } + private fun MediaUploadInfo.postProcess(uri: Uri): MediaUploadInfo { val name = context.getFileName(uri) ?: return this val renamedFile = File(context.cacheDir, name).also { @@ -142,40 +114,83 @@ class AndroidMediaPreProcessor @Inject constructor( } } - private suspend fun processImage(uri: Uri): MediaUploadInfo { - val compressedFileResult = contentResolver.openInputStream(uri).use { input -> - imageCompressor.compressToTmpFile( - inputStream = requireNotNull(input), - resizeMode = ResizeMode.Approximate(IMAGE_SCALE_REF_SIZE, IMAGE_SCALE_REF_SIZE), - ).getOrThrow() + private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo { + + suspend fun processImageWithCompression(): MediaUploadInfo { + val compressionResult = contentResolver.openInputStream(uri).use { input -> + imageCompressor.compressToTmpFile( + inputStream = requireNotNull(input), + resizeMode = ResizeMode.Approximate(IMAGE_SCALE_REF_SIZE, IMAGE_SCALE_REF_SIZE), + ).getOrThrow() + } + val thumbnailResult: ThumbnailResult = thumbnailFactory.createImageThumbnail(compressionResult.file) + val imageInfo = compressionResult.toImageInfo( + mimeType = mimeType, + thumbnailResult = thumbnailResult + ) + removeSensitiveImageMetadata(compressionResult.file) + return MediaUploadInfo.Image( + file = compressionResult.file, + info = imageInfo, + thumbnailFile = thumbnailResult.file + ) } - removeSensitiveImageMetadata(compressedFileResult.file) + suspend fun processImageWithoutCompression(): MediaUploadInfo { + val file = copyToTmpFile(uri) + val thumbnailResult: ThumbnailResult = thumbnailFactory.createImageThumbnail(file) + val imageInfo = contentResolver.openInputStream(uri).use { input -> + val bitmap = BitmapFactory.decodeStream(input, null, null)!! + ImageInfo( + width = bitmap.width.toLong(), + height = bitmap.height.toLong(), + mimetype = mimeType, + size = file.length(), + thumbnailInfo = thumbnailResult.info, + thumbnailSource = null, + blurhash = thumbnailResult.blurhash, + ) + } + removeSensitiveImageMetadata(file) + return MediaUploadInfo.Image( + file = file, + info = imageInfo, + thumbnailFile = thumbnailResult.file + ) + } - val thumbnailResult = compressedFileResult.file.inputStream().use { generateImageThumbnail(it) } - val processingResult = compressedFileResult.toImageInfo(MimeTypes.Jpeg, thumbnailResult.file.path, thumbnailResult.info) - return MediaUploadInfo.Image(compressedFileResult.file, processingResult, thumbnailResult) + return if (shouldBeCompressed) { + processImageWithCompression() + } else { + processImageWithoutCompression() + } } - private suspend fun processVideo(uri: Uri, mimeType: String?): MediaUploadInfo { - val thumbnailInfo = extractVideoThumbnail(uri) - val resultFile = videoCompressor.compress(uri) - .onEach { - // TODO handle progress - } - .filterIsInstance() - .first() - .file - - val videoProcessingInfo = extractVideoMetadata(resultFile, mimeType, thumbnailInfo.file.path, thumbnailInfo) - return MediaUploadInfo.Video(resultFile, videoProcessingInfo, thumbnailInfo) + private suspend fun processVideo(uri: Uri, mimeType: String?, shouldBeCompressed: Boolean): MediaUploadInfo { + val resultFile = if (shouldBeCompressed) { + videoCompressor.compress(uri) + .onEach { + // TODO handle progress + } + .filterIsInstance() + .first() + .file + } else { + copyToTmpFile(uri) + } + val thumbnailInfo = thumbnailFactory.createVideoThumbnail(resultFile) + val videoInfo = extractVideoMetadata(resultFile, mimeType, thumbnailInfo) + return MediaUploadInfo.Video( + file = resultFile, + info = videoInfo, + thumbnailFile = thumbnailInfo.file + ) } 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(), @@ -186,15 +201,6 @@ class AndroidMediaPreProcessor @Inject constructor( } } - private suspend fun generateImageThumbnail(inputStream: InputStream): ThumbnailProcessingInfo { - val thumbnailResult = imageCompressor - .compressToTmpFile( - inputStream = inputStream, - resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), - ).getOrThrow() - return thumbnailResult.toThumbnailProcessingInfo(MimeTypes.Jpeg) - } - private fun removeSensitiveImageMetadata(file: File) { // Remove GPS info, user comments and subject location tags val exifInterface = ExifInterface(file) @@ -215,7 +221,7 @@ class AndroidMediaPreProcessor @Inject constructor( } } - private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailUrl: String?, thumbnailInfo: ThumbnailProcessingInfo?): VideoInfo = + private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailResult: ThumbnailResult): VideoInfo = MediaMetadataRetriever().runAndRelease { setDataSource(context, Uri.fromFile(file)) VideoInfo( @@ -224,51 +230,26 @@ class AndroidMediaPreProcessor @Inject constructor( height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L, mimetype = mimeType, size = file.length(), - thumbnailInfo = thumbnailInfo?.info, - thumbnailSource = thumbnailUrl?.let { MediaSource(it) }, - blurhash = thumbnailInfo?.blurhash, + thumbnailInfo = thumbnailResult.info, + // Will be computed by the rust sdk + thumbnailSource = null, + blurhash = thumbnailResult.blurhash, ) } - private suspend fun extractVideoThumbnail(uri: Uri): ThumbnailProcessingInfo = - MediaMetadataRetriever().runAndRelease { - setDataSource(context, uri) - val bitmap = requireNotNull(getFrameAtTime(VIDEO_THUMB_FRAME)) - val inputStream = ByteArrayOutputStream().use { - bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it) - ByteArrayInputStream(it.toByteArray()) - } - - val result = imageCompressor.compressToTmpFile( - inputStream = inputStream, - resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), - ) - result.getOrThrow().toThumbnailProcessingInfo(MimeTypes.Jpeg) - } - private suspend fun copyToTmpFile(uri: Uri): File { return contentResolver.openInputStream(uri)?.use { createTmpFileWithInput(it) } ?: error("Could not copy the contents of $uri to a temporary file") } } -fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailUrl: String?, thumbnailInfo: ThumbnailInfo?) = ImageInfo( +fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailResult: ThumbnailResult) = ImageInfo( width = width.toLong(), height = height.toLong(), mimetype = mimeType, size = size, - thumbnailInfo = thumbnailInfo, - thumbnailSource = thumbnailUrl?.let { MediaSource(it) }, - blurhash = blurhash, -) - -fun ImageCompressionResult.toThumbnailProcessingInfo(mimeType: String) = ThumbnailProcessingInfo( - file = file, - info = ThumbnailInfo( - width = width.toLong(), - height = height.toLong(), - mimetype = mimeType, - size = size, - ), - blurhash = blurhash, + thumbnailInfo = thumbnailResult.info, + // Will be computed by the rust sdk + thumbnailSource = null, + blurhash = thumbnailResult.blurhash, ) 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 96d5e2ea63..2b8669fe42 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 @@ -42,27 +42,23 @@ class ImageCompressor @Inject constructor( * @return a [Result] containing the resulting [ImageCompressionResult] with the temporary [File] and some metadata. */ suspend fun compressToTmpFile( - inputStream: InputStream, - resizeMode: ResizeMode, - format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG, - desiredQuality: Int = 80, + inputStream: InputStream, + resizeMode: ResizeMode, + format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG, + desiredQuality: Int = 80, ): 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(extension = "jpeg") tmpFile.outputStream().use { compressedBitmap.compress(format, desiredQuality, it) } - ImageCompressionResult( file = tmpFile, width = compressedBitmap.width, height = compressedBitmap.height, - size = tmpFile.length(), - blurhash = blurhash + size = tmpFile.length() ) } } @@ -116,7 +112,6 @@ 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/ThumbnailFactory.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ThumbnailFactory.kt new file mode 100644 index 0000000000..f0010cf9a7 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ThumbnailFactory.kt @@ -0,0 +1,122 @@ +/* + * 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.mediaupload + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.media.MediaMetadataRetriever +import android.media.ThumbnailUtils +import android.os.Build +import android.os.CancellationSignal +import android.provider.MediaStore +import android.util.Size +import androidx.core.net.toUri +import com.vanniktech.blurhash.BlurHash +import io.element.android.libraries.androidutils.file.createTmpFile +import io.element.android.libraries.androidutils.media.runAndRelease +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.media.ThumbnailInfo +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.File +import javax.inject.Inject +import kotlin.coroutines.resume + +/** + * Max width of thumbnail images. + * See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/?ref=blog.gitter.im#thumbnails). + */ +private const val THUMB_MAX_WIDTH = 800 + +/** + * Max height of thumbnail images. + * See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/?ref=blog.gitter.im#thumbnails). + */ +private const val THUMB_MAX_HEIGHT = 600 + +/** + * Frame of the video to be used for generating a thumbnail. + */ +private const val VIDEO_THUMB_FRAME = 0L + +class ThumbnailFactory @Inject constructor( + @ApplicationContext private val context: Context, +) { + + @SuppressLint("NewApi") + suspend fun createImageThumbnail(file: File): ThumbnailResult { + return createThumbnail { cancellationSignal -> + // This API works correctly with GIF + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ThumbnailUtils.createImageThumbnail( + file, + Size(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), + cancellationSignal + ) + } else { + ThumbnailUtils.createImageThumbnail( + file.path, + MediaStore.Images.Thumbnails.MINI_KIND, + ) + } + } + } + + suspend fun createVideoThumbnail(file: File): ThumbnailResult { + return createThumbnail { + MediaMetadataRetriever().runAndRelease { + setDataSource(context, file.toUri()) + getFrameAtTime(VIDEO_THUMB_FRAME) + } + } + } + + private suspend fun createThumbnail(bitmapThumbnailFactory: (CancellationSignal) -> Bitmap?): ThumbnailResult = suspendCancellableCoroutine { continuation -> + val cancellationSignal = CancellationSignal() + continuation.invokeOnCancellation { + cancellationSignal.cancel() + } + val bitmapThumbnail: Bitmap? = bitmapThumbnailFactory(cancellationSignal) + val thumbnailFile = context.createTmpFile(extension = "jpeg") + thumbnailFile.outputStream().use { outputStream -> + bitmapThumbnail?.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) + } + val blurhash = bitmapThumbnail?.let { + BlurHash.encode(it, 3, 3) + } + val thumbnailResult = ThumbnailResult( + file = thumbnailFile, + info = ThumbnailInfo( + height = bitmapThumbnail?.height?.toLong(), + width = bitmapThumbnail?.width?.toLong(), + mimetype = MimeTypes.Jpeg, + size = thumbnailFile.length() + ), + blurhash = blurhash + ) + bitmapThumbnail?.recycle() + continuation.resume(thumbnailResult) + + } +} + +data class ThumbnailResult( + val file: File, + val info: ThumbnailInfo, + val blurhash: String?, +)