Merge pull request #617 from vector-im/feature/fga/fix_media_pre_processing
Feature/fga/fix media pre processing
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user