fix(mobile): sanitize Android image uploads (#2188)

Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tom Brow
2026-07-20 19:18:30 -07:00
committed by GitHub
parent 03fe19d603
commit ee21da90bd
21 changed files with 681 additions and 24 deletions

View File

@@ -1087,6 +1087,79 @@ mod tests {
));
}
#[test]
fn test_android_image_processor_outputs_match_relay_contract() {
let config = test_config();
for (name, bytes, expected_mime) in [
(
"sRGB PNG",
include_bytes!("../tests/fixtures/android/sanitized/bitmap-srgb-sanitized.png")
.as_slice(),
"image/png",
),
(
"sRGB JPEG",
include_bytes!("../tests/fixtures/android/sanitized/bitmap-srgb-sanitized.jpg")
.as_slice(),
"image/jpeg",
),
(
"Display-P3 PNG",
include_bytes!(
"../tests/fixtures/android/sanitized/bitmap-display-p3-sanitized.png"
)
.as_slice(),
"image/png",
),
(
"Display-P3 JPEG",
include_bytes!(
"../tests/fixtures/android/sanitized/bitmap-display-p3-sanitized.jpg"
)
.as_slice(),
"image/jpeg",
),
] {
let actual = validate_content(bytes, &config).unwrap_or_else(|error| {
panic!("rejected Android-sanitized {name} fixture: {error}")
});
assert_eq!(actual, expected_mime);
}
}
#[test]
fn test_android_bitmap_encoder_outputs_require_sanitization() {
let config = test_config();
let srgb_png = include_bytes!("../tests/fixtures/android/bitmap-srgb.png").as_slice();
assert_eq!(
validate_content(srgb_png, &config).expect("rejected Android sRGB PNG fixture"),
"image/png"
);
for (name, bytes) in [
(
"sRGB JPEG",
include_bytes!("../tests/fixtures/android/bitmap-srgb.jpg").as_slice(),
),
(
"Display-P3 PNG",
include_bytes!("../tests/fixtures/android/bitmap-display-p3.png").as_slice(),
),
(
"Display-P3 JPEG",
include_bytes!("../tests/fixtures/android/bitmap-display-p3.jpg").as_slice(),
),
] {
assert!(
matches!(
validate_content(bytes, &config),
Err(MediaError::MetadataForbidden)
),
"accepted unsanitized Android Bitmap.compress {name} fixture"
);
}
}
#[test]
fn test_ios_uikit_sanitizer_outputs_match_relay_contract() {
let config = test_config();

View File

@@ -0,0 +1,55 @@
# Android Bitmap media fixtures
These 3 x 2 fixtures were produced by Android 16 (API 36) `Bitmap.compress`, not by a generic image encoder.
## Regeneration
1. Compile and run the following program on an API 36 emulator:
```java
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.ColorSpace;
import java.io.FileOutputStream;
public final class Main {
private static void write(Bitmap bitmap, Bitmap.CompressFormat format, String stem)
throws Exception {
String extension = format == Bitmap.CompressFormat.PNG ? "png" : "jpg";
try (FileOutputStream output =
new FileOutputStream("/data/local/tmp/" + stem + "." + extension)) {
if (!bitmap.compress(format, 100, output)) {
throw new IllegalStateException("Bitmap.compress failed for " + stem);
}
}
}
public static void main(String[] args) throws Exception {
Bitmap srgb = Bitmap.createBitmap(3, 2, Bitmap.Config.ARGB_8888);
srgb.setPixels(new int[] {
Color.argb(255, 255, 0, 0), Color.argb(255, 0, 255, 0),
Color.argb(255, 0, 0, 255), Color.argb(128, 255, 255, 0),
Color.argb(64, 0, 255, 255), Color.argb(0, 255, 0, 255),
}, 0, 3, 0, 0, 3, 2);
write(srgb, Bitmap.CompressFormat.PNG, "bitmap-srgb");
write(srgb, Bitmap.CompressFormat.JPEG, "bitmap-srgb");
Bitmap displayP3 = Bitmap.createBitmap(
3, 2, Bitmap.Config.RGBA_F16, true,
ColorSpace.get(ColorSpace.Named.DISPLAY_P3));
displayP3.eraseColor(Color.pack(
1.0f, 0.0f, 0.0f, 1.0f,
ColorSpace.get(ColorSpace.Named.DISPLAY_P3)));
write(displayP3, Bitmap.CompressFormat.PNG, "bitmap-display-p3");
write(displayP3, Bitmap.CompressFormat.JPEG, "bitmap-display-p3");
}
}
```
2. Pull the four files from `/data/local/tmp/` into this directory. Copy all four into `mobile/android/app/src/test/resources/fixtures/android/`, and copy `bitmap-display-p3.png` and `bitmap-srgb.png` into `mobile/android/app/src/androidTest/resources/fixtures/android/`.
3. Run `cmp` on every copied fixture to confirm it is byte-identical.
4. Run the app's `AndroidImageProcessor.decodeSrgbBitmap` and `encodeAndScrub` path for both source color spaces and both formats on the API 36 emulator. Save the four outputs under `sanitized/` with the `-sanitized` suffix.
5. Run `cargo test -p buzz-media android_` to verify the relay accepts every sanitized fixture while rejecting the three unsanitized fixtures that contain forbidden metadata.
6. Run `cd mobile/android && ./gradlew app:testDebugUnitTest app:connectedDebugAndroidTest` with an API 36 emulator to verify structural scrubbing and the Display-P3 to sRGB conversion for sanitized PNG and JPEG output.
Regenerate both the encoded inputs and sanitized outputs whenever Android `Bitmap.compress` behavior or `AndroidMediaSanitizer` changes. Do not update only the sanitized files, because the tests cover the exact encoder-to-sanitizer boundary.

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 857 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

View File

@@ -63,6 +63,7 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
@@ -85,6 +86,14 @@ android {
}
}
dependencies {
testImplementation(kotlin("test"))
androidTestImplementation(kotlin("test"))
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test:runner:1.7.0")
}
gradle.taskGraph.whenReady {
val buildsRelease = allTasks.any { task ->
task.project == project && task.name in setOf("assembleRelease", "bundleRelease")

View File

@@ -0,0 +1,188 @@
package xyz.block.buzz.mobile
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import android.os.Build
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.math.abs
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@RunWith(AndroidJUnit4::class)
class AndroidImageProcessorTest {
companion object {
private val ALLOWED_PNG_CHUNKS = setOf(
"IHDR", "PLTE", "IDAT", "IEND", "cHRM", "gAMA", "sBIT", "sRGB", "bKGD", "hIST", "tRNS", "sPLT",
"acTL", "fcTL", "fdAT",
)
}
@Test
fun supportedApiCanProcessImagesWithoutNewerPlatformApis() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return
val sourceBytes = fixtureBytes("bitmap-srgb.png")
val processed = assertNotNull(AndroidImageProcessor.decodeSrgbBitmap(sourceBytes))
assertEquals(Bitmap.Config.ARGB_8888, processed.config)
val scrubbed = assertNotNull(
AndroidImageProcessor.encodeAndScrub(processed, Bitmap.CompressFormat.PNG),
)
val chunkTypes = pngChunkTypes(scrubbed)
assertEquals("IHDR", chunkTypes.first())
assertEquals("IEND", chunkTypes.last())
assertTrue(chunkTypes.all { it in ALLOWED_PNG_CHUNKS })
assertPngPixelsPreserved(sourceBytes, scrubbed)
}
@Test
fun transparentPngPixelsSurviveSrgbProcessing() {
val sourceBytes = fixtureBytes("bitmap-srgb.png")
val source = assertNotNull(BitmapFactory.decodeByteArray(sourceBytes, 0, sourceBytes.size))
val processed = assertNotNull(AndroidImageProcessor.decodeSrgbBitmap(sourceBytes))
assertTrue(processed.hasAlpha())
val scrubbed = assertNotNull(
AndroidImageProcessor.encodeAndScrub(processed, Bitmap.CompressFormat.PNG),
)
assertPngPixelsPreserved(sourceBytes, scrubbed)
assertEquals(128, Color.alpha(source.getPixel(0, 1)))
assertEquals(64, Color.alpha(source.getPixel(1, 1)))
assertEquals(0, Color.alpha(source.getPixel(2, 1)))
}
@Test
fun displayP3InputIsConvertedToSrgbBeforeEncodedMetadataIsScrubbed() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val sourceBytes = fixtureBytes("bitmap-display-p3.png")
val source = assertNotNull(BitmapFactory.decodeByteArray(sourceBytes, 0, sourceBytes.size))
assertEquals(android.graphics.ColorSpace.get(android.graphics.ColorSpace.Named.DISPLAY_P3), source.colorSpace)
val expectedSrgbPixel = source.getColor(0, 0).convert(
android.graphics.ColorSpace.get(android.graphics.ColorSpace.Named.SRGB),
)
val srgbBitmap = assertNotNull(AndroidImageProcessor.decodeSrgbBitmap(sourceBytes))
assertEquals(android.graphics.ColorSpace.get(android.graphics.ColorSpace.Named.SRGB), srgbBitmap.colorSpace)
assertEquals(Bitmap.Config.ARGB_8888, srgbBitmap.config)
assertColorClose(expectedSrgbPixel, srgbBitmap.getColor(0, 0), tolerance = 1.0f / 255.0f)
for ((format, tolerance) in listOf(
Bitmap.CompressFormat.PNG to (1.0f / 255.0f),
Bitmap.CompressFormat.JPEG to (2.0f / 255.0f),
)) {
val scrubbed = assertNotNull(AndroidImageProcessor.encodeAndScrub(srgbBitmap, format))
when (format) {
Bitmap.CompressFormat.PNG -> {
assertEquals(listOf("IHDR", "sRGB", "sBIT", "IDAT", "IEND"), pngChunkTypes(scrubbed))
}
Bitmap.CompressFormat.JPEG -> {
assertEquals(listOf(0xE0), jpegMetadataMarkers(scrubbed))
}
else -> error("Unexpected format: $format")
}
val decodedOutput = assertNotNull(BitmapFactory.decodeByteArray(scrubbed, 0, scrubbed.size))
assertEquals(
android.graphics.ColorSpace.get(android.graphics.ColorSpace.Named.SRGB),
decodedOutput.colorSpace,
)
assertColorClose(expectedSrgbPixel, decodedOutput.getColor(0, 0), tolerance)
}
}
private fun assertPngPixelsPreserved(expectedBytes: ByteArray, actualBytes: ByteArray) {
val expected = assertNotNull(BitmapFactory.decodeByteArray(expectedBytes, 0, expectedBytes.size))
val actual = assertNotNull(BitmapFactory.decodeByteArray(actualBytes, 0, actualBytes.size))
assertEquals(expected.width, actual.width)
assertEquals(expected.height, actual.height)
for (y in 0 until expected.height) {
for (x in 0 until expected.width) {
assertEquals(
expected.getPixel(x, y),
actual.getPixel(x, y),
"pixel ($x, $y)",
)
}
}
}
private fun fixtureBytes(name: String): ByteArray {
return requireNotNull(javaClass.getResourceAsStream("/fixtures/android/$name")) {
"Missing fixture: $name"
}.use { it.readBytes() }
}
private fun assertColorClose(expected: Color, actual: Color, tolerance: Float) {
val expectedComponents = expected.components
val actualComponents = actual.convert(expected.colorSpace).components
for (index in expectedComponents.indices) {
assertTrue(
abs(expectedComponents[index] - actualComponents[index]) <= tolerance,
"component $index: expected ${expectedComponents[index]}, got ${actualComponents[index]}",
)
}
}
private fun pngChunkTypes(bytes: ByteArray): List<String> {
val result = mutableListOf<String>()
var offset = 8
while (offset < bytes.size) {
require(bytes.size - offset >= 12)
val payloadLength = readUnsignedInt(bytes, offset)
val type = bytes.decodeToString(offset + 4, offset + 8)
result += type
offset += payloadLength + 12
if (type == "IEND") {
assertEquals(bytes.size, offset)
return result
}
}
error("PNG is missing IEND")
}
private fun jpegMetadataMarkers(bytes: ByteArray): List<Int> {
val result = mutableListOf<Int>()
var offset = 2
var inScan = false
while (offset < bytes.size) {
if (inScan && bytes[offset] != 0xFF.toByte()) {
offset += 1
continue
}
require(bytes[offset] == 0xFF.toByte())
while (offset < bytes.size && bytes[offset] == 0xFF.toByte()) {
offset += 1
}
require(offset < bytes.size)
val marker = bytes[offset].toInt() and 0xFF
offset += 1
if (inScan && marker == 0x00) continue
if (marker in 0xD0..0xD7 || marker == 0x01) continue
if (marker == 0xD9) {
assertEquals(bytes.size, offset)
return result
}
val segmentLength = readUnsignedShort(bytes, offset)
if (marker in 0xE0..0xEF || marker == 0xFE) result += marker
offset += segmentLength
inScan = marker == 0xDA
}
error("JPEG is missing EOI")
}
private fun readUnsignedShort(bytes: ByteArray, offset: Int): Int {
return ((bytes[offset].toInt() and 0xFF) shl 8) or (bytes[offset + 1].toInt() and 0xFF)
}
private fun readUnsignedInt(bytes: ByteArray, offset: Int): Int {
return ((bytes[offset].toInt() and 0xFF) shl 24) or
((bytes[offset + 1].toInt() and 0xFF) shl 16) or
((bytes[offset + 2].toInt() and 0xFF) shl 8) or
(bytes[offset + 3].toInt() and 0xFF)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

View File

@@ -0,0 +1,160 @@
package xyz.block.buzz.mobile
import java.io.ByteArrayOutputStream
import java.nio.charset.StandardCharsets
internal object AndroidMediaSanitizer {
private val pngSignature = byteArrayOf(
0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
)
private val allowedPngAncillaryChunks = setOf(
"cHRM", "gAMA", "sBIT", "sRGB", "bKGD", "hIST", "tRNS", "sPLT", "acTL", "fcTL", "fdAT",
)
fun scrubPng(bytes: ByteArray): ByteArray {
require(bytes.size >= pngSignature.size && bytes.copyOfRange(0, pngSignature.size).contentEquals(pngSignature)) {
"Invalid PNG signature"
}
val output = ByteArrayOutputStream(bytes.size)
output.write(pngSignature)
var offset = pngSignature.size
while (offset < bytes.size) {
require(bytes.size - offset >= PNG_CHUNK_OVERHEAD) { "Truncated PNG chunk" }
val payloadLength = readUnsignedIntBigEndian(bytes, offset)
require(payloadLength <= bytes.size.toLong() - offset - PNG_CHUNK_OVERHEAD) {
"Invalid PNG chunk length"
}
val chunkLength = payloadLength.toInt() + PNG_CHUNK_OVERHEAD
val typeStart = offset + 4
val type = String(bytes, typeStart, 4, StandardCharsets.US_ASCII)
val isAncillary = bytes[typeStart].toInt() and 0x20 != 0
if (!isAncillary || type in allowedPngAncillaryChunks) {
output.write(bytes, offset, chunkLength)
}
offset += chunkLength
if (type == "IEND") {
return output.toByteArray()
}
}
throw IllegalArgumentException("PNG is missing IEND")
}
fun scrubJpeg(bytes: ByteArray): ByteArray {
require(
bytes.size >= 2 &&
bytes[0] == 0xFF.toByte() &&
(bytes[1].toInt() and 0xFF) == JPEG_SOI,
) {
"Invalid JPEG signature"
}
val output = ByteArrayOutputStream(bytes.size)
output.write(byteArrayOf(0xFF.toByte(), JPEG_SOI.toByte()))
var offset = 2
var inScan = false
while (offset < bytes.size) {
if (inScan && bytes[offset] != 0xFF.toByte()) {
val nextMarker = bytes.indexOf(0xFF.toByte(), offset).let { if (it == -1) bytes.size else it }
output.write(bytes, offset, nextMarker - offset)
offset = nextMarker
continue
}
require(bytes[offset] == 0xFF.toByte()) { "Invalid JPEG marker" }
val markerStart = offset
while (offset < bytes.size && bytes[offset] == 0xFF.toByte()) {
offset += 1
}
require(offset < bytes.size) { "Truncated JPEG marker" }
val marker = bytes[offset].toInt() and 0xFF
offset += 1
if (inScan && marker == 0x00) {
output.write(bytes, markerStart, offset - markerStart)
continue
}
if (marker in JPEG_RESTART_MARKERS || marker == JPEG_TEMP) {
output.write(bytes, markerStart, offset - markerStart)
continue
}
if (marker == JPEG_EOI) {
output.write(bytes, markerStart, offset - markerStart)
return output.toByteArray()
}
require(marker != JPEG_SOI && bytes.size - offset >= 2) { "Invalid JPEG segment" }
val segmentLength = readUnsignedShortBigEndian(bytes, offset)
require(segmentLength >= 2 && segmentLength <= bytes.size - offset) { "Invalid JPEG segment length" }
val segmentEnd = offset + segmentLength
if (shouldKeepJpegSegment(marker, bytes, offset + 2, segmentEnd)) {
output.write(bytes, markerStart, segmentEnd - markerStart)
}
offset = segmentEnd
inScan = marker == JPEG_SOS
}
throw IllegalArgumentException("JPEG is missing EOI")
}
private fun shouldKeepJpegSegment(
marker: Int,
bytes: ByteArray,
payloadStart: Int,
payloadEnd: Int,
): Boolean {
val payloadLength = payloadEnd - payloadStart
return when (marker) {
JPEG_APP0 -> {
if (payloadLength < 14 || !bytes.matchesAscii(payloadStart, "JFIF\u0000")) {
false
} else {
val thumbnailWidth = bytes[payloadStart + 12].toInt() and 0xFF
val thumbnailHeight = bytes[payloadStart + 13].toInt() and 0xFF
payloadLength == 14 + 3 * thumbnailWidth * thumbnailHeight
}
}
JPEG_APP14 -> payloadLength == 12 && bytes.matchesAscii(payloadStart, "Adobe")
in JPEG_FORBIDDEN_APP_MARKERS, JPEG_APP15, JPEG_COMMENT -> false
else -> true
}
}
private fun readUnsignedShortBigEndian(bytes: ByteArray, offset: Int): Int {
require(bytes.size - offset >= 2) { "Truncated two-byte integer" }
return (bytes[offset].toInt() and 0xFF shl 8) or (bytes[offset + 1].toInt() and 0xFF)
}
private fun readUnsignedIntBigEndian(bytes: ByteArray, offset: Int): Long {
require(bytes.size - offset >= 4) { "Truncated four-byte integer" }
return (bytes[offset].toLong() and 0xFF shl 24) or
(bytes[offset + 1].toLong() and 0xFF shl 16) or
(bytes[offset + 2].toLong() and 0xFF shl 8) or
(bytes[offset + 3].toLong() and 0xFF)
}
private fun ByteArray.matchesAscii(offset: Int, value: String): Boolean {
val expected = value.toByteArray(StandardCharsets.US_ASCII)
return size - offset >= expected.size && expected.indices.all { this[offset + it] == expected[it] }
}
private fun ByteArray.indexOf(value: Byte, startIndex: Int): Int {
for (index in startIndex until size) {
if (this[index] == value) return index
}
return -1
}
private const val PNG_CHUNK_OVERHEAD = 12
private const val JPEG_SOI = 0xD8
private const val JPEG_EOI = 0xD9
private const val JPEG_SOS = 0xDA
private const val JPEG_TEMP = 0x01
private const val JPEG_APP0 = 0xE0
private const val JPEG_APP14 = 0xEE
private const val JPEG_APP15 = 0xEF
private const val JPEG_COMMENT = 0xFE
private val JPEG_RESTART_MARKERS = 0xD0..0xD7
private val JPEG_FORBIDDEN_APP_MARKERS = 0xE1..0xED
}

View File

@@ -2,10 +2,13 @@ package xyz.block.buzz.mobile
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.ColorSpace
import android.graphics.ImageDecoder
import android.media.MediaExtractor
import android.media.MediaMuxer
import android.os.Build
import androidx.annotation.RequiresApi
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
@@ -14,6 +17,65 @@ import java.io.File
import java.nio.ByteBuffer
import java.util.UUID
internal object AndroidImageProcessor {
fun decodeSrgbBitmap(bytes: ByteArray): Bitmap? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
decodeSrgbBitmapWithColorManagement(bytes)
} else {
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun decodeSrgbBitmapWithColorManagement(bytes: ByteArray): Bitmap? {
val decoded = decodeColorManagedBitmap(bytes) ?: return null
val srgb = ColorSpace.get(ColorSpace.Named.SRGB)
if (decoded.config == Bitmap.Config.ARGB_8888 && decoded.colorSpace == srgb) return decoded
val srgbBitmap = Bitmap.createBitmap(
decoded.width,
decoded.height,
Bitmap.Config.ARGB_8888,
decoded.hasAlpha(),
srgb,
)
Canvas(srgbBitmap).drawBitmap(decoded, 0f, 0f, null)
return srgbBitmap
}
@RequiresApi(Build.VERSION_CODES.O)
private fun decodeColorManagedBitmap(bytes: ByteArray): Bitmap? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
runCatching {
val source = ImageDecoder.createSource(ByteBuffer.wrap(bytes))
ImageDecoder.decodeBitmap(source) { decoder, _, _ ->
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
}
}.getOrNull()?.let { return it }
}
val options = BitmapFactory.Options().apply {
inPreferredColorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
}
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
}
fun encodeAndScrub(
bitmap: Bitmap,
format: Bitmap.CompressFormat,
): ByteArray? {
val output = ByteArrayOutputStream()
if (!bitmap.compress(format, 100, output)) return null
return when (format) {
Bitmap.CompressFormat.PNG -> AndroidMediaSanitizer.scrubPng(output.toByteArray())
Bitmap.CompressFormat.JPEG -> AndroidMediaSanitizer.scrubJpeg(output.toByteArray())
else -> error("Unsupported upload image format: $format")
}
}
}
class MainActivity : FlutterActivity() {
private var mediaUploadChannel: MethodChannel? = null
@@ -96,28 +158,6 @@ class MainActivity : FlutterActivity() {
)
}
private fun decodeBitmap(bytes: ByteArray): Bitmap? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
runCatching {
val source = ImageDecoder.createSource(ByteBuffer.wrap(bytes))
ImageDecoder.decodeBitmap(source) { decoder, _, _ ->
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
}
}.getOrNull()?.let { return it }
}
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}
private fun encodeBitmap(
bitmap: Bitmap,
format: Bitmap.CompressFormat,
): ByteArray? {
val output = ByteArrayOutputStream()
val encoded = bitmap.compress(format, 100, output)
return if (encoded) output.toByteArray() else null
}
private fun sanitizeCompressFormatFor(
mimeType: String,
): Bitmap.CompressFormat? {
@@ -136,7 +176,7 @@ class MainActivity : FlutterActivity() {
encodeFailureMessage: String,
errorDetails: Any? = null,
) {
val bitmap = decodeBitmap(bytes) ?: run {
val bitmap = AndroidImageProcessor.decodeSrgbBitmap(bytes) ?: run {
result.error(
errorCode,
"Unable to decode picked image.",
@@ -145,7 +185,11 @@ class MainActivity : FlutterActivity() {
return
}
val transformedBytes = encodeBitmap(bitmap, format) ?: run {
val transformedBytes = try {
AndroidImageProcessor.encodeAndScrub(bitmap, format)
} catch (_: IllegalArgumentException) {
null
} ?: run {
result.error(
errorCode,
encodeFailureMessage,

View File

@@ -0,0 +1,128 @@
package xyz.block.buzz.mobile
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class AndroidMediaSanitizerTest {
@Test
fun `scrubPng keeps canonical sRGB output unchanged`() {
val fixture = fixtureBytes("bitmap-srgb.png")
val sanitized = AndroidMediaSanitizer.scrubPng(fixture)
assertContentEquals(fixture, sanitized)
assertEquals(listOf("IHDR", "sRGB", "sBIT", "IDAT", "IEND"), pngChunkTypes(sanitized))
}
@Test
fun `scrubPng removes Display P3 profile and trailing data`() {
val fixture = fixtureBytes("bitmap-display-p3.png")
val withTrailingData = fixture + "hidden location".encodeToByteArray()
val sanitized = AndroidMediaSanitizer.scrubPng(withTrailingData)
assertEquals(listOf("IHDR", "sBIT", "IDAT", "IEND"), pngChunkTypes(sanitized))
}
@Test
fun `scrubJpeg removes Android ICC profile and trailing data`() {
for (fixtureName in listOf("bitmap-srgb.jpg", "bitmap-display-p3.jpg")) {
val fixture = fixtureBytes(fixtureName)
val withTrailingData = fixture + "hidden location".encodeToByteArray()
val sanitized = AndroidMediaSanitizer.scrubJpeg(withTrailingData)
assertEquals(listOf(0xE0), jpegMetadataMarkers(sanitized), fixtureName)
}
}
@Test
fun `scrubbers fail closed for malformed containers`() {
assertFailsWith<IllegalArgumentException> {
AndroidMediaSanitizer.scrubPng(byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47))
}
assertFailsWith<IllegalArgumentException> {
AndroidMediaSanitizer.scrubJpeg(byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte()))
}
assertFailsWith<IllegalArgumentException> {
AndroidMediaSanitizer.scrubJpeg(
byteArrayOf(
0xFF.toByte(),
0xD8.toByte(),
0xFF.toByte(),
0xD8.toByte(),
0x00,
0x02,
0xFF.toByte(),
0xD9.toByte(),
),
)
}
}
private fun fixtureBytes(name: String): ByteArray {
return requireNotNull(javaClass.getResourceAsStream("/fixtures/android/$name")) {
"Missing fixture: $name"
}.use { it.readBytes() }
}
private fun pngChunkTypes(bytes: ByteArray): List<String> {
val result = mutableListOf<String>()
var offset = 8
while (offset < bytes.size) {
require(bytes.size - offset >= 12)
val payloadLength = readUnsignedInt(bytes, offset)
val type = bytes.decodeToString(offset + 4, offset + 8)
result += type
offset += payloadLength + 12
if (type == "IEND") {
assertEquals(bytes.size, offset)
return result
}
}
error("PNG is missing IEND")
}
private fun jpegMetadataMarkers(bytes: ByteArray): List<Int> {
val result = mutableListOf<Int>()
var offset = 2
var inScan = false
while (offset < bytes.size) {
if (inScan && bytes[offset] != 0xFF.toByte()) {
offset += 1
continue
}
require(bytes[offset] == 0xFF.toByte())
while (offset < bytes.size && bytes[offset] == 0xFF.toByte()) {
offset += 1
}
require(offset < bytes.size)
val marker = bytes[offset].toInt() and 0xFF
offset += 1
if (inScan && marker == 0x00) continue
if (marker in 0xD0..0xD7 || marker == 0x01) continue
if (marker == 0xD9) {
assertEquals(bytes.size, offset)
return result
}
val segmentLength = readUnsignedShort(bytes, offset)
if (marker in 0xE0..0xEF || marker == 0xFE) result += marker
offset += segmentLength
inScan = marker == 0xDA
}
error("JPEG is missing EOI")
}
private fun readUnsignedShort(bytes: ByteArray, offset: Int): Int {
return ((bytes[offset].toInt() and 0xFF) shl 8) or (bytes[offset + 1].toInt() and 0xFF)
}
private fun readUnsignedInt(bytes: ByteArray, offset: Int): Int {
return ((bytes[offset].toInt() and 0xFF) shl 24) or
((bytes[offset + 1].toInt() and 0xFF) shl 16) or
((bytes[offset + 2].toInt() and 0xFF) shl 8) or
(bytes[offset + 3].toInt() and 0xFF)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 857 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B