Merge pull request #2058 from element-hq/feature/bma/optionalThumbnails

Optional thumbnails
This commit is contained in:
Benoit Marty 2023-12-19 13:30:24 +01:00 committed by GitHub
commit cf212b0cf8
11 changed files with 86 additions and 72 deletions

View file

@ -2,6 +2,7 @@
<dictionary name="shared"> <dictionary name="shared">
<words> <words>
<w>backstack</w> <w>backstack</w>
<w>blurhash</w>
<w>ftue</w> <w>ftue</w>
<w>homeserver</w> <w>homeserver</w>
<w>konsist</w> <w>konsist</w>

View file

@ -100,9 +100,9 @@ interface MatrixRoom : Closeable {
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit> suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>
suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> suspend fun sendImage(file: File, thumbnailFile: File?, imageInfo: ImageInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> suspend fun sendVideo(file: File, thumbnailFile: File?, videoInfo: VideoInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>

View file

@ -360,15 +360,25 @@ class RustMatrixRoom(
} }
} }
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> { override suspend fun sendImage(
return sendAttachment(listOf(file, thumbnailFile)) { file: File,
innerTimeline.sendImage(file.path, thumbnailFile.path, imageInfo.map(), progressCallback?.toProgressWatcher()) thumbnailFile: File?,
imageInfo: ImageInfo,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> {
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
innerTimeline.sendImage(file.path, thumbnailFile?.path, imageInfo.map(), progressCallback?.toProgressWatcher())
} }
} }
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> { override suspend fun sendVideo(
return sendAttachment(listOf(file, thumbnailFile)) { file: File,
innerTimeline.sendVideo(file.path, thumbnailFile.path, videoInfo.map(), progressCallback?.toProgressWatcher()) thumbnailFile: File?,
videoInfo: VideoInfo,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> {
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
innerTimeline.sendVideo(file.path, thumbnailFile?.path, videoInfo.map(), progressCallback?.toProgressWatcher())
} }
} }

View file

@ -299,14 +299,14 @@ class FakeMatrixRoom(
override suspend fun sendImage( override suspend fun sendImage(
file: File, file: File,
thumbnailFile: File, thumbnailFile: File?,
imageInfo: ImageInfo, imageInfo: ImageInfo,
progressCallback: ProgressCallback? progressCallback: ProgressCallback?
): Result<MediaUploadHandler> = fakeSendMedia(progressCallback) ): Result<MediaUploadHandler> = fakeSendMedia(progressCallback)
override suspend fun sendVideo( override suspend fun sendVideo(
file: File, file: File,
thumbnailFile: File, thumbnailFile: File?,
videoInfo: VideoInfo, videoInfo: VideoInfo,
progressCallback: ProgressCallback? progressCallback: ProgressCallback?
): Result<MediaUploadHandler> = fakeSendMedia( ): Result<MediaUploadHandler> = fakeSendMedia(

View file

@ -101,7 +101,6 @@ class MediaSender @Inject constructor(
progressCallback = progressCallback progressCallback = progressCallback
) )
} }
is MediaUploadInfo.Video -> { is MediaUploadInfo.Video -> {
sendVideo( sendVideo(
file = uploadInfo.file, file = uploadInfo.file,

View file

@ -26,8 +26,8 @@ sealed interface MediaUploadInfo {
val file: File val file: File
data class Image(override val file: File, val imageInfo: ImageInfo, val thumbnailFile: File) : MediaUploadInfo data class Image(override val file: File, val imageInfo: ImageInfo, val thumbnailFile: File?) : MediaUploadInfo
data class Video(override val file: File, val videoInfo: VideoInfo, val thumbnailFile: File) : MediaUploadInfo data class Video(override val file: File, val videoInfo: VideoInfo, val thumbnailFile: File?) : MediaUploadInfo
data class Audio(override val file: File, val audioInfo: AudioInfo) : MediaUploadInfo data class Audio(override val file: File, val audioInfo: AudioInfo) : MediaUploadInfo
data class VoiceMessage(override val file: File, val audioInfo: AudioInfo, val waveform: List<Float>) : MediaUploadInfo data class VoiceMessage(override val file: File, val audioInfo: AudioInfo, val waveform: List<Float>) : MediaUploadInfo
data class AnyFile(override val file: File, val fileInfo: FileInfo) : MediaUploadInfo data class AnyFile(override val file: File, val fileInfo: FileInfo) : MediaUploadInfo

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.mediaupload package io.element.android.libraries.mediaupload.impl
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
@ -137,7 +137,7 @@ class AndroidMediaPreProcessor @Inject constructor(
resizeMode = ResizeMode.Approximate(IMAGE_SCALE_REF_SIZE, IMAGE_SCALE_REF_SIZE), resizeMode = ResizeMode.Approximate(IMAGE_SCALE_REF_SIZE, IMAGE_SCALE_REF_SIZE),
orientation = orientation, orientation = orientation,
).getOrThrow() ).getOrThrow()
val thumbnailResult: ThumbnailResult = thumbnailFactory.createImageThumbnail(compressionResult.file) val thumbnailResult = thumbnailFactory.createImageThumbnail(compressionResult.file)
val imageInfo = compressionResult.toImageInfo( val imageInfo = compressionResult.toImageInfo(
mimeType = mimeType, mimeType = mimeType,
thumbnailResult = thumbnailResult thumbnailResult = thumbnailResult
@ -146,13 +146,13 @@ class AndroidMediaPreProcessor @Inject constructor(
return MediaUploadInfo.Image( return MediaUploadInfo.Image(
file = compressionResult.file, file = compressionResult.file,
imageInfo = imageInfo, imageInfo = imageInfo,
thumbnailFile = thumbnailResult.file thumbnailFile = thumbnailResult?.file
) )
} }
suspend fun processImageWithoutCompression(): MediaUploadInfo { suspend fun processImageWithoutCompression(): MediaUploadInfo {
val file = copyToTmpFile(uri) val file = copyToTmpFile(uri)
val thumbnailResult: ThumbnailResult = thumbnailFactory.createImageThumbnail(file) val thumbnailResult = thumbnailFactory.createImageThumbnail(file)
val imageInfo = contentResolver.openInputStream(uri).use { input -> val imageInfo = contentResolver.openInputStream(uri).use { input ->
val bitmap = BitmapFactory.decodeStream(input, null, null)!! val bitmap = BitmapFactory.decodeStream(input, null, null)!!
ImageInfo( ImageInfo(
@ -160,16 +160,16 @@ class AndroidMediaPreProcessor @Inject constructor(
height = bitmap.height.toLong(), height = bitmap.height.toLong(),
mimetype = mimeType, mimetype = mimeType,
size = file.length(), size = file.length(),
thumbnailInfo = thumbnailResult.info, thumbnailInfo = thumbnailResult?.info,
thumbnailSource = null, thumbnailSource = null,
blurhash = thumbnailResult.blurhash, blurhash = thumbnailResult?.blurhash,
) )
} }
removeSensitiveImageMetadata(file) removeSensitiveImageMetadata(file)
return MediaUploadInfo.Image( return MediaUploadInfo.Image(
file = file, file = file,
imageInfo = imageInfo, imageInfo = imageInfo,
thumbnailFile = thumbnailResult.file thumbnailFile = thumbnailResult?.file
) )
} }
@ -197,7 +197,7 @@ class AndroidMediaPreProcessor @Inject constructor(
return MediaUploadInfo.Video( return MediaUploadInfo.Video(
file = resultFile, file = resultFile,
videoInfo = videoInfo, videoInfo = videoInfo,
thumbnailFile = thumbnailInfo.file thumbnailFile = thumbnailInfo?.file
) )
} }
@ -235,7 +235,7 @@ class AndroidMediaPreProcessor @Inject constructor(
} }
} }
private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailResult: ThumbnailResult): VideoInfo = private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailResult: ThumbnailResult?): VideoInfo =
MediaMetadataRetriever().runAndRelease { MediaMetadataRetriever().runAndRelease {
setDataSource(context, Uri.fromFile(file)) setDataSource(context, Uri.fromFile(file))
VideoInfo( VideoInfo(
@ -244,10 +244,10 @@ class AndroidMediaPreProcessor @Inject constructor(
height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L, height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L,
mimetype = mimeType, mimetype = mimeType,
size = file.length(), size = file.length(),
thumbnailInfo = thumbnailResult.info, thumbnailInfo = thumbnailResult?.info,
// Will be computed by the rust sdk // Will be computed by the rust sdk
thumbnailSource = null, thumbnailSource = null,
blurhash = thumbnailResult.blurhash, blurhash = thumbnailResult?.blurhash,
) )
} }
@ -257,15 +257,15 @@ class AndroidMediaPreProcessor @Inject constructor(
} }
} }
fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailResult: ThumbnailResult) = ImageInfo( private fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailResult: ThumbnailResult?) = ImageInfo(
width = width.toLong(), width = width.toLong(),
height = height.toLong(), height = height.toLong(),
mimetype = mimeType, mimetype = mimeType,
size = size, size = size,
thumbnailInfo = thumbnailResult.info, thumbnailInfo = thumbnailResult?.info,
// Will be computed by the rust sdk // Will be computed by the rust sdk
thumbnailSource = null, thumbnailSource = null,
blurhash = thumbnailResult.blurhash, blurhash = thumbnailResult?.blurhash,
) )
private fun MediaMetadataRetriever.extractDuration(): Duration { private fun MediaMetadataRetriever.extractDuration(): Duration {

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.mediaupload package io.element.android.libraries.mediaupload.impl
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.mediaupload package io.element.android.libraries.mediaupload.impl
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
@ -34,7 +34,9 @@ import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.media.ThumbnailInfo import io.element.android.libraries.matrix.api.media.ThumbnailInfo
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import timber.log.Timber
import java.io.File import java.io.File
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.resume import kotlin.coroutines.resume
@ -61,26 +63,36 @@ class ThumbnailFactory @Inject constructor(
) { ) {
@SuppressLint("NewApi") @SuppressLint("NewApi")
suspend fun createImageThumbnail(file: File): ThumbnailResult { suspend fun createImageThumbnail(file: File): ThumbnailResult? {
return createThumbnail { cancellationSignal -> return createThumbnail { cancellationSignal ->
// This API works correctly with GIF try {
if (sdkIntProvider.isAtLeast(Build.VERSION_CODES.Q)) { // This API works correctly with GIF
ThumbnailUtils.createImageThumbnail( if (sdkIntProvider.isAtLeast(Build.VERSION_CODES.Q)) {
file, try {
Size(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), ThumbnailUtils.createImageThumbnail(
cancellationSignal file,
) Size(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT),
} else { cancellationSignal
@Suppress("DEPRECATION") )
ThumbnailUtils.createImageThumbnail( } catch (ioException: IOException) {
file.path, Timber.w(ioException, "Failed to create thumbnail for $file")
MediaStore.Images.Thumbnails.MINI_KIND, null
) }
} else {
@Suppress("DEPRECATION")
ThumbnailUtils.createImageThumbnail(
file.path,
MediaStore.Images.Thumbnails.MINI_KIND,
)
}
} catch (throwable: Throwable) {
Timber.w(throwable, "Failed to create thumbnail for $file")
null
} }
} }
} }
suspend fun createVideoThumbnail(file: File): ThumbnailResult { suspend fun createVideoThumbnail(file: File): ThumbnailResult? {
return createThumbnail { return createThumbnail {
MediaMetadataRetriever().runAndRelease { MediaMetadataRetriever().runAndRelease {
setDataSource(context, file.toUri()) setDataSource(context, file.toUri())
@ -89,37 +101,38 @@ class ThumbnailFactory @Inject constructor(
} }
} }
private suspend fun createThumbnail(bitmapFactory: (CancellationSignal) -> Bitmap?): ThumbnailResult = suspendCancellableCoroutine { continuation -> private suspend fun createThumbnail(bitmapFactory: (CancellationSignal) -> Bitmap?): ThumbnailResult? = suspendCancellableCoroutine { continuation ->
val cancellationSignal = CancellationSignal() val cancellationSignal = CancellationSignal()
continuation.invokeOnCancellation { continuation.invokeOnCancellation {
cancellationSignal.cancel() cancellationSignal.cancel()
} }
val bitmapThumbnail: Bitmap? = bitmapFactory(cancellationSignal) val bitmapThumbnail: Bitmap? = bitmapFactory(cancellationSignal)
if (bitmapThumbnail == null) {
continuation.resume(null)
return@suspendCancellableCoroutine
}
val thumbnailFile = context.createTmpFile(extension = "jpeg") val thumbnailFile = context.createTmpFile(extension = "jpeg")
thumbnailFile.outputStream().use { outputStream -> thumbnailFile.outputStream().use { outputStream ->
bitmapThumbnail?.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) bitmapThumbnail.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
}
val blurhash = bitmapThumbnail?.let {
BlurHash.encode(it, 3, 3)
} }
val blurhash = BlurHash.encode(bitmapThumbnail, 3, 3)
val thumbnailResult = ThumbnailResult( val thumbnailResult = ThumbnailResult(
file = thumbnailFile, file = thumbnailFile,
info = ThumbnailInfo( info = ThumbnailInfo(
height = bitmapThumbnail?.height?.toLong(), height = bitmapThumbnail.height.toLong(),
width = bitmapThumbnail?.width?.toLong(), width = bitmapThumbnail.width.toLong(),
mimetype = MimeTypes.Jpeg, mimetype = MimeTypes.Jpeg,
size = thumbnailFile.length() size = thumbnailFile.length()
), ),
blurhash = blurhash blurhash = blurhash
) )
bitmapThumbnail?.recycle() bitmapThumbnail.recycle()
continuation.resume(thumbnailResult) continuation.resume(thumbnailResult)
} }
} }
data class ThumbnailResult( data class ThumbnailResult(
val file: File, val file: File,
val info: ThumbnailInfo, val info: ThumbnailInfo,
val blurhash: String?, val blurhash: String,
) )

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.mediaupload package io.element.android.libraries.mediaupload.impl
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.libraries.mediaupload package io.element.android.libraries.mediaupload.impl
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
@ -55,15 +55,11 @@ class AndroidMediaPreProcessorTest {
deleteOriginal = false, deleteOriginal = false,
compressIfPossible = true, compressIfPossible = true,
) )
// This is failing for now
val error = result.exceptionOrNull()
assertThat(error).isInstanceOf(MediaPreProcessor.Failure::class.java)
assertThat(error?.cause).isInstanceOf(NullPointerException::class.java)
/*
val data = result.getOrThrow() val data = result.getOrThrow()
assertThat(data.file.path).endsWith("image.png") assertThat(data.file.path).endsWith("image.png")
val info = data as MediaUploadInfo.Image val info = data as MediaUploadInfo.Image
assertThat(info.thumbnailFile).isNull() // TODO Check this // Computing thumbnailFile is failing with Robolectric
assertThat(info.thumbnailFile).isNull()
assertThat(info.imageInfo).isEqualTo( assertThat(info.imageInfo).isEqualTo(
ImageInfo( ImageInfo(
height = 1_178, height = 1_178,
@ -76,7 +72,6 @@ class AndroidMediaPreProcessorTest {
) )
) )
assertThat(file.exists()).isTrue() assertThat(file.exists()).isTrue()
*/
} }
@Test @Test
@ -90,15 +85,11 @@ class AndroidMediaPreProcessorTest {
deleteOriginal = false, deleteOriginal = false,
compressIfPossible = true, compressIfPossible = true,
) )
// This is not working for now
val error = result.exceptionOrNull()
assertThat(error).isInstanceOf(MediaPreProcessor.Failure::class.java)
assertThat(error?.cause).isInstanceOf(NoSuchMethodError::class.java)
/*
val data = result.getOrThrow() val data = result.getOrThrow()
assertThat(data.file.path).endsWith("image.png") assertThat(data.file.path).endsWith("image.png")
val info = data as MediaUploadInfo.Image val info = data as MediaUploadInfo.Image
assertThat(info.thumbnailFile).isNull() // TODO Check this // Computing thumbnailFile is failing with Robolectric
assertThat(info.thumbnailFile).isNull()
assertThat(info.imageInfo).isEqualTo( assertThat(info.imageInfo).isEqualTo(
ImageInfo( ImageInfo(
height = 1_178, height = 1_178,
@ -111,7 +102,6 @@ class AndroidMediaPreProcessorTest {
) )
) )
assertThat(file.exists()).isTrue() assertThat(file.exists()).isTrue()
*/
} }
@Test @Test
@ -266,7 +256,8 @@ class AndroidMediaPreProcessorTest {
).getOrThrow() ).getOrThrow()
assertThat(result.file.path).endsWith("video.mp4") assertThat(result.file.path).endsWith("video.mp4")
val info = result as MediaUploadInfo.Video val info = result as MediaUploadInfo.Video
assertThat(info.thumbnailFile).isNotNull() // Computing thumbnailFile is failing with Robolectric
assertThat(info.thumbnailFile).isNull()
assertThat(info.videoInfo).isEqualTo( assertThat(info.videoInfo).isEqualTo(
VideoInfo( VideoInfo(
duration = Duration.ZERO, // Not available with Robolectric? duration = Duration.ZERO, // Not available with Robolectric?
@ -274,7 +265,7 @@ class AndroidMediaPreProcessorTest {
width = 0, // Not available with Robolectric? width = 0, // Not available with Robolectric?
mimetype = MimeTypes.Mp4, mimetype = MimeTypes.Mp4,
size = 1_673_712, size = 1_673_712,
thumbnailInfo = ThumbnailInfo(height = null, width = null, mimetype = MimeTypes.Jpeg, size = 0), // Not available with Robolectric? thumbnailInfo = null,
thumbnailSource = null, thumbnailSource = null,
blurhash = null, blurhash = null,
) )