From 75607c73ecfd00ddfc60cc032f811b265c7e0862 Mon Sep 17 00:00:00 2001 From: hayyaksi <193020925+hayaksi1@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:08:48 +0300 Subject: [PATCH] Keep transparency when an image is shared into the app with an imprecise mime type (#7452) * Resolve the real image format before choosing the encoder The upload pre-processor picks the encoder from the mime type it is given. When an image is shared from another app that type is often broader than the image really is, so a transparent PNG shared as a wildcard image type is re-encoded as JPEG and its alpha channel is flattened to black. Read the format from the image header when the declared type is not already a concrete PNG or JPEG, and use that to decide both whether the image can be compressed at all and which encoder to use. * Cover the mime type fallbacks the wildcard resolution introduced resolveImageMimeType now decides whether an image is compressible, so two of its outcomes had no test: a GIF shared with a wildcard type has to be recognised and left uncompressed rather than recompressed into a still, and a file the decoder cannot read at all has to fall back to the type it was shared with. The isMimeTypeImage() guard on the decoded type is dropped with them. The BitmapFactory decoders only ever report an image type in outMimeType, so the guard could not fire and there was no way to exercise it. --- .../impl/AndroidMediaPreProcessor.kt | 18 ++++++- .../impl/AndroidMediaPreProcessorTest.kt | 49 ++++++++++++++++++- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt index b374e530f4..eebbfbeab6 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt @@ -85,8 +85,9 @@ class AndroidMediaPreProcessor( val result = when { mimeType == MimeTypes.Svg -> processSvgImage(uri, mimeType) mimeType.isMimeTypeImage() -> { - val shouldBeCompressed = mediaOptimizationConfig.compressImages && mimeType !in notCompressibleImageTypes - processImage(uri, mimeType, shouldBeCompressed) + val imageMimeType = resolveImageMimeType(uri, mimeType) + val shouldBeCompressed = mediaOptimizationConfig.compressImages && imageMimeType !in notCompressibleImageTypes + processImage(uri, imageMimeType, shouldBeCompressed) } mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType, mediaOptimizationConfig.videoCompressionPreset) mimeType.isMimeTypeAudio() -> processAudio(uri, mimeType) @@ -155,6 +156,19 @@ class AndroidMediaPreProcessor( } } + /** + * The declared mime type can be broader than the image actually is - a wildcard from a share intent, or a mislabelled file - and the encoder is picked + * from it. Re-encoding a transparent image as JPEG flattens its alpha channel to black, so resolve the real format from the image header first. + */ + private fun resolveImageMimeType(uri: Uri, declaredMimeType: String): String { + if (declaredMimeType == MimeTypes.Png || declaredMimeType == MimeTypes.Jpeg) return declaredMimeType + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + tryOrNull { + contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) } + } + return options.outMimeType ?: declaredMimeType + } + private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo { Timber.d("Processing image ${uri.path.orEmpty().hash()}") diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt index 3fb639aa9b..79f6fd10f8 100644 --- a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt @@ -70,6 +70,7 @@ class AndroidMediaPreProcessorTest : RobolectricTest() { mediaOptimizationConfig: MediaOptimizationConfig, sdkIntVersion: Int = Build.VERSION_CODES.P, deleteOriginal: Boolean = false, + mimeType: String = asset.mimeType, ): MediaUploadInfo { val context = InstrumentationRegistry.getInstrumentation().context val deleteCallback = lambdaRecorder {} @@ -81,7 +82,7 @@ class AndroidMediaPreProcessorTest : RobolectricTest() { val file = getFileFromAssets(context, asset.filename) val result = sut.process( uri = file.toUri(), - mimeType = asset.mimeType, + mimeType = mimeType, deleteOriginal = deleteOriginal, mediaOptimizationConfig = mediaOptimizationConfig, ) @@ -91,6 +92,52 @@ class AndroidMediaPreProcessorTest : RobolectricTest() { return data } + @Test + fun `a png shared with a wildcard mime type is still encoded as a png`() = runTest { + val mediaUploadInfo = process( + asset = assetImagePng, + mimeType = MimeTypes.Images, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + sdkIntVersion = Build.VERSION_CODES.Q, + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Png) + } + + @Test + fun `a gif shared with a wildcard mime type is recognised and left uncompressed`() = runTest { + val mediaUploadInfo = process( + asset = assetAnimatedGif, + mimeType = MimeTypes.Images, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + sdkIntVersion = Build.VERSION_CODES.Q, + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Gif) + assertThat(info.imageInfo.size).isEqualTo(assetAnimatedGif.size) + } + + @Test + fun `a file that is not an image keeps the wildcard mime type it was shared with`() = runTest { + val mediaUploadInfo = process( + asset = assetText, + mimeType = MimeTypes.Images, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = false, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + sdkIntVersion = Build.VERSION_CODES.Q, + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Images) + } + @Test @Ignore("Ignore now that min API for enterprise is 33") fun `test processing png`() = runTest {