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.
This commit is contained in:
hayyaksi
2026-08-17 22:08:48 +03:00
committed by GitHub
parent e6e93c9bd0
commit 75607c73ec
2 changed files with 64 additions and 3 deletions
@@ -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()}")
@@ -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<Uri?, Unit> {}
@@ -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 {