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>
@@ -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();
|
||||
|
||||
55
crates/buzz-media/tests/fixtures/android/README.md
vendored
Normal 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.
|
||||
BIN
crates/buzz-media/tests/fixtures/android/bitmap-display-p3.jpg
vendored
Normal file
|
After Width: | Height: | Size: 827 B |
BIN
crates/buzz-media/tests/fixtures/android/bitmap-display-p3.png
vendored
Normal file
|
After Width: | Height: | Size: 407 B |
BIN
crates/buzz-media/tests/fixtures/android/bitmap-srgb.jpg
vendored
Normal file
|
After Width: | Height: | Size: 857 B |
BIN
crates/buzz-media/tests/fixtures/android/bitmap-srgb.png
vendored
Normal file
|
After Width: | Height: | Size: 112 B |
BIN
crates/buzz-media/tests/fixtures/android/sanitized/bitmap-display-p3-sanitized.jpg
vendored
Normal file
|
After Width: | Height: | Size: 289 B |
BIN
crates/buzz-media/tests/fixtures/android/sanitized/bitmap-display-p3-sanitized.png
vendored
Normal file
|
After Width: | Height: | Size: 101 B |
BIN
crates/buzz-media/tests/fixtures/android/sanitized/bitmap-srgb-sanitized.jpg
vendored
Normal file
|
After Width: | Height: | Size: 376 B |
BIN
crates/buzz-media/tests/fixtures/android/sanitized/bitmap-srgb-sanitized.png
vendored
Normal file
|
After Width: | Height: | Size: 112 B |
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 407 B |
|
After Width: | Height: | Size: 112 B |
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 827 B |
|
After Width: | Height: | Size: 407 B |
|
After Width: | Height: | Size: 857 B |
|
After Width: | Height: | Size: 112 B |