diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs index bcd109a9b4..0d03429e06 100644 --- a/crates/buzz-media/src/validation.rs +++ b/crates/buzz-media/src/validation.rs @@ -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(); diff --git a/crates/buzz-media/tests/fixtures/android/README.md b/crates/buzz-media/tests/fixtures/android/README.md new file mode 100644 index 0000000000..b0b06bb060 --- /dev/null +++ b/crates/buzz-media/tests/fixtures/android/README.md @@ -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. diff --git a/crates/buzz-media/tests/fixtures/android/bitmap-display-p3.jpg b/crates/buzz-media/tests/fixtures/android/bitmap-display-p3.jpg new file mode 100644 index 0000000000..0263319fba Binary files /dev/null and b/crates/buzz-media/tests/fixtures/android/bitmap-display-p3.jpg differ diff --git a/crates/buzz-media/tests/fixtures/android/bitmap-display-p3.png b/crates/buzz-media/tests/fixtures/android/bitmap-display-p3.png new file mode 100644 index 0000000000..d58374c28b Binary files /dev/null and b/crates/buzz-media/tests/fixtures/android/bitmap-display-p3.png differ diff --git a/crates/buzz-media/tests/fixtures/android/bitmap-srgb.jpg b/crates/buzz-media/tests/fixtures/android/bitmap-srgb.jpg new file mode 100644 index 0000000000..7dd225efac Binary files /dev/null and b/crates/buzz-media/tests/fixtures/android/bitmap-srgb.jpg differ diff --git a/crates/buzz-media/tests/fixtures/android/bitmap-srgb.png b/crates/buzz-media/tests/fixtures/android/bitmap-srgb.png new file mode 100644 index 0000000000..3fd77cd1e7 Binary files /dev/null and b/crates/buzz-media/tests/fixtures/android/bitmap-srgb.png differ diff --git a/crates/buzz-media/tests/fixtures/android/sanitized/bitmap-display-p3-sanitized.jpg b/crates/buzz-media/tests/fixtures/android/sanitized/bitmap-display-p3-sanitized.jpg new file mode 100644 index 0000000000..91c8080119 Binary files /dev/null and b/crates/buzz-media/tests/fixtures/android/sanitized/bitmap-display-p3-sanitized.jpg differ diff --git a/crates/buzz-media/tests/fixtures/android/sanitized/bitmap-display-p3-sanitized.png b/crates/buzz-media/tests/fixtures/android/sanitized/bitmap-display-p3-sanitized.png new file mode 100644 index 0000000000..b19a9850e5 Binary files /dev/null and b/crates/buzz-media/tests/fixtures/android/sanitized/bitmap-display-p3-sanitized.png differ diff --git a/crates/buzz-media/tests/fixtures/android/sanitized/bitmap-srgb-sanitized.jpg b/crates/buzz-media/tests/fixtures/android/sanitized/bitmap-srgb-sanitized.jpg new file mode 100644 index 0000000000..997b40b6bf Binary files /dev/null and b/crates/buzz-media/tests/fixtures/android/sanitized/bitmap-srgb-sanitized.jpg differ diff --git a/crates/buzz-media/tests/fixtures/android/sanitized/bitmap-srgb-sanitized.png b/crates/buzz-media/tests/fixtures/android/sanitized/bitmap-srgb-sanitized.png new file mode 100644 index 0000000000..3fd77cd1e7 Binary files /dev/null and b/crates/buzz-media/tests/fixtures/android/sanitized/bitmap-srgb-sanitized.png differ diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index f361b57a13..b3de0e9e77 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -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") diff --git a/mobile/android/app/src/androidTest/kotlin/xyz/block/buzz/mobile/AndroidImageProcessorTest.kt b/mobile/android/app/src/androidTest/kotlin/xyz/block/buzz/mobile/AndroidImageProcessorTest.kt new file mode 100644 index 0000000000..2cdbc50766 --- /dev/null +++ b/mobile/android/app/src/androidTest/kotlin/xyz/block/buzz/mobile/AndroidImageProcessorTest.kt @@ -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 { + val result = mutableListOf() + 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 { + val result = mutableListOf() + 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) + } +} diff --git a/mobile/android/app/src/androidTest/resources/fixtures/android/bitmap-display-p3.png b/mobile/android/app/src/androidTest/resources/fixtures/android/bitmap-display-p3.png new file mode 100644 index 0000000000..d58374c28b Binary files /dev/null and b/mobile/android/app/src/androidTest/resources/fixtures/android/bitmap-display-p3.png differ diff --git a/mobile/android/app/src/androidTest/resources/fixtures/android/bitmap-srgb.png b/mobile/android/app/src/androidTest/resources/fixtures/android/bitmap-srgb.png new file mode 100644 index 0000000000..3fd77cd1e7 Binary files /dev/null and b/mobile/android/app/src/androidTest/resources/fixtures/android/bitmap-srgb.png differ diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/AndroidMediaSanitizer.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/AndroidMediaSanitizer.kt new file mode 100644 index 0000000000..9a0b6e34d9 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/AndroidMediaSanitizer.kt @@ -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 +} diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt index 472c47a2a4..47582fc08c 100644 --- a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt @@ -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, diff --git a/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AndroidMediaSanitizerTest.kt b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AndroidMediaSanitizerTest.kt new file mode 100644 index 0000000000..d92d909770 --- /dev/null +++ b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AndroidMediaSanitizerTest.kt @@ -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 { + AndroidMediaSanitizer.scrubPng(byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47)) + } + assertFailsWith { + AndroidMediaSanitizer.scrubJpeg(byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte())) + } + assertFailsWith { + 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 { + val result = mutableListOf() + 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 { + val result = mutableListOf() + 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) + } +} diff --git a/mobile/android/app/src/test/resources/fixtures/android/bitmap-display-p3.jpg b/mobile/android/app/src/test/resources/fixtures/android/bitmap-display-p3.jpg new file mode 100644 index 0000000000..0263319fba Binary files /dev/null and b/mobile/android/app/src/test/resources/fixtures/android/bitmap-display-p3.jpg differ diff --git a/mobile/android/app/src/test/resources/fixtures/android/bitmap-display-p3.png b/mobile/android/app/src/test/resources/fixtures/android/bitmap-display-p3.png new file mode 100644 index 0000000000..d58374c28b Binary files /dev/null and b/mobile/android/app/src/test/resources/fixtures/android/bitmap-display-p3.png differ diff --git a/mobile/android/app/src/test/resources/fixtures/android/bitmap-srgb.jpg b/mobile/android/app/src/test/resources/fixtures/android/bitmap-srgb.jpg new file mode 100644 index 0000000000..7dd225efac Binary files /dev/null and b/mobile/android/app/src/test/resources/fixtures/android/bitmap-srgb.jpg differ diff --git a/mobile/android/app/src/test/resources/fixtures/android/bitmap-srgb.png b/mobile/android/app/src/test/resources/fixtures/android/bitmap-srgb.png new file mode 100644 index 0000000000..3fd77cd1e7 Binary files /dev/null and b/mobile/android/app/src/test/resources/fixtures/android/bitmap-srgb.png differ