Merge pull request #617 from vector-im/feature/fga/fix_media_pre_processing

Feature/fga/fix media pre processing
This commit is contained in:
ganfra
2023-06-20 16:17:50 +02:00
committed by GitHub
25 changed files with 296 additions and 214 deletions

View File

@@ -24,22 +24,24 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlin.math.min
private const val MAX_HEIGHT_IN_DP = 360f
private const val MIN_ASPECT_RATIO = 0.6f
private const val MAX_ASPECT_RATIO = 4f
private const val DEFAULT_ASPECT_RATIO = 1.33f
@Composable
fun TimelineItemAspectRatioBox(
height: Int?,
aspectRatio: Float,
aspectRatio: Float?,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
content: @Composable BoxScope.() -> Unit,
content: @Composable (BoxScope.() -> Unit),
) {
// TODO should probably be moved to an ElementTheme.dimensions
val maxHeight = min(300, height ?: 0)
val safeAspectRatio = (aspectRatio ?: DEFAULT_ASPECT_RATIO).coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO)
Box(
modifier = modifier
.heightIn(max = maxHeight.dp)
.aspectRatio(aspectRatio, matchHeightConstraintsFirst = true),
.heightIn(max = MAX_HEIGHT_IN_DP.dp)
.aspectRatio(safeAspectRatio, true),
contentAlignment = contentAlignment,
content = content
)

View File

@@ -16,7 +16,6 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
@@ -28,25 +27,20 @@ import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import kotlin.math.max
@Composable
fun TimelineItemImageView(
content: TimelineItemImageContent,
modifier: Modifier = Modifier,
) {
// TODO place this value somewhere else?
val minHeight = max(100, content.height ?: 0)
TimelineItemAspectRatioBox(
height = minHeight,
aspectRatio = content.aspectRatio,
modifier = modifier
) {
BlurHashAsyncImage(
model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
blurHash = content.blurhash,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
contentScale = ContentScale.Crop,
)
}
}

View File

@@ -43,7 +43,6 @@ fun TimelineItemVideoView(
modifier: Modifier = Modifier,
) {
TimelineItemAspectRatioBox(
height = content.height,
aspectRatio = content.aspectRatio,
modifier = modifier,
contentAlignment = Alignment.Center,
@@ -51,8 +50,7 @@ fun TimelineItemVideoView(
BlurHashAsyncImage(
model = MediaRequestData(content.thumbnailSource, MediaRequestData.Kind.File(content.body, content.mimeType)),
blurHash = content.blurHash,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
contentScale = ContentScale.Crop,
)
Box(
modifier = Modifier.roundedBackground(),

View File

@@ -102,11 +102,11 @@ class TimelineItemContentMessageFactory @Inject constructor(
}
}
private fun aspectRatioOf(width: Long?, height: Long?): Float {
private fun aspectRatioOf(width: Long?, height: Long?): Float? {
return if (height != null && width != null) {
width.toFloat() / height.toFloat()
} else {
0.7f
null
}
}
}

View File

@@ -29,7 +29,7 @@ data class TimelineItemImageContent(
val blurhash: String?,
val width: Int?,
val height: Int?,
val aspectRatio: Float
val aspectRatio: Float?
) : TimelineItemEventContent {
override val type: String = "TimelineItemImageContent"

View File

@@ -23,7 +23,7 @@ data class TimelineItemVideoContent(
val duration: Long,
val videoSource: MediaSource,
val thumbnailSource: MediaSource?,
val aspectRatio: Float,
val aspectRatio: Float?,
val blurHash: String?,
val height: Int?,
val width: Int?,

View File

@@ -36,7 +36,6 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.ThumbnailInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
@@ -50,7 +49,6 @@ 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.test.FakeMediaPreProcessor
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.mockk.mockk
@@ -301,16 +299,7 @@ class MessageComposerPresenterTest {
thumbnailSource = null,
blurhash = null,
),
thumbnailInfo = ThumbnailProcessingInfo(
file = File("/some/path"),
info = ThumbnailInfo(
width = null,
height = null,
mimetype = null,
size = null,
),
blurhash = "",
)
thumbnailFile = File("/some/path")
)
)
)
@@ -344,16 +333,7 @@ class MessageComposerPresenterTest {
thumbnailSource = null,
blurhash = null,
),
thumbnailInfo = ThumbnailProcessingInfo(
file = File("/some/path"),
info = ThumbnailInfo(
width = null,
height = null,
mimetype = null,
size = null,
),
blurhash = "",
)
thumbnailFile = File("/some/path")
)
)
)

View File

@@ -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 <T> MediaMetadataRetriever.runAndRelease(block: MediaMetadataRetriever.() -> T): T {
return block().also { release() }
return try {
block()
} finally {
release()
}
}

View File

@@ -44,15 +44,26 @@ class MediaSender @Inject constructor(
): Result<Unit> {
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"))
}

View File

@@ -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,
)

View File

@@ -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,27 +37,22 @@ 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 java.time.Duration
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,
@@ -70,23 +65,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
@@ -96,40 +74,34 @@ class AndroidMediaPreProcessor @Inject constructor(
mimeType: String,
deleteOriginal: Boolean,
compressIfPossible: Boolean,
): Result<MediaUploadInfo> = 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<MediaUploadInfo> = 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 {
@@ -143,33 +115,77 @@ 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<VideoTranscodingEvent.Completed>()
.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<VideoTranscodingEvent.Completed>()
.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 {
@@ -186,15 +202,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 +222,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,56 +231,32 @@ 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, thumbnailResult: ThumbnailResult) = ImageInfo(
width = width.toLong(),
height = height.toLong(),
mimetype = mimeType,
size = size,
thumbnailInfo = thumbnailResult.info,
// Will be computed by the rust sdk
thumbnailSource = null,
blurhash = thumbnailResult.blurhash,
)
private fun MediaMetadataRetriever.extractDuration(): Duration {
val durationInMs = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L
return Duration.ofMillis(durationInMs)
}
fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailUrl: String?, thumbnailInfo: ThumbnailInfo?) = 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,
)

View File

@@ -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<ImageCompressionResult> = 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 {

View File

@@ -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(bitmapFactory: (CancellationSignal) -> Bitmap?): ThumbnailResult = suspendCancellableCoroutine { continuation ->
val cancellationSignal = CancellationSignal()
continuation.invokeOnCancellation {
cancellationSignal.cancel()
}
val bitmapThumbnail: Bitmap? = bitmapFactory(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?,
)