From ec190715ffa0a8382bdc95da37047cccbf6f5f5f Mon Sep 17 00:00:00 2001 From: staszekscp Date: Thu, 17 Sep 2026 13:53:30 +0200 Subject: [PATCH] b --- patches/expo-image-manipulator/details.md | 23 +++++ ...+fix-android-10bit-hdr-jpeg-encoding.patch | 95 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 patches/expo-image-manipulator/expo-image-manipulator+57.0.6+003+fix-android-10bit-hdr-jpeg-encoding.patch diff --git a/patches/expo-image-manipulator/details.md b/patches/expo-image-manipulator/details.md index 44de87f57255..524297dc9e51 100644 --- a/patches/expo-image-manipulator/details.md +++ b/patches/expo-image-manipulator/details.md @@ -28,3 +28,26 @@ - Upstream PR/issue: https://github.com/expo/expo/pull/50011 (open), fixes https://github.com/expo/expo/issues/49953. Alternative fix under review in https://github.com/expo/expo/pull/50009 - E/App issue: https://github.com/Expensify/App/issues/100133 - PR introducing patch: https://github.com/Expensify/App/pull/101102 + +### [expo-image-manipulator+57.0.6+003+fix-android-10bit-hdr-jpeg-encoding.patch](expo-image-manipulator+57.0.6+003+fix-android-10bit-hdr-jpeg-encoding.patch) + +- Reason: + + ``` + On Android 13+, `BitmapFactory` decodes 10-bit HEIC into an `RGBA_1010102` bitmap even though Glide asks for + `ARGB_8888` (`SkAndroidCodec::computeOutputColorType` picks it for any 10-bit HEIF). Skia's JPEG encoder has + no scanline transform for that pixel format, so `Bitmap.compress` returned `false` and `saveAsync` reported + success while leaving a 0-byte JPEG behind, which then failed to upload. The patch redraws `RGBA_1010102` + bitmaps (and only those; `HARDWARE` bitmaps in particular must not be drawn to a software `Canvas`) into an + sRGB `ARGB_8888` bitmap before encoding, and turns a `false` from `compress` into `ImageWriteFailedException` + (deleting the empty file) instead of silently reporting success. + + Newer Skia builds added `RGBA_1010102` support to the JPEG encoder, so on some Android 14+ devices + `compress` may already succeed and the redraw is a cheap no-op there. Verified behaviour per API level: + - API 33 (Android 13): 🛑 TODO — fails without the patch, passes with it + - API 34+ (Android 14/15): 🛑 TODO + ``` + +- Upstream PR/issue: 🛑 TODO +- E/App issue: https://github.com/Expensify/App/issues/101348 +- PR introducing patch: 🛑 TODO diff --git a/patches/expo-image-manipulator/expo-image-manipulator+57.0.6+003+fix-android-10bit-hdr-jpeg-encoding.patch b/patches/expo-image-manipulator/expo-image-manipulator+57.0.6+003+fix-android-10bit-hdr-jpeg-encoding.patch new file mode 100644 index 000000000000..45e236cbe99e --- /dev/null +++ b/patches/expo-image-manipulator/expo-image-manipulator+57.0.6+003+fix-android-10bit-hdr-jpeg-encoding.patch @@ -0,0 +1,95 @@ +diff --git a/node_modules/expo-image-manipulator/android/src/main/java/expo/modules/imagemanipulator/ImageManipulatorModule.kt b/node_modules/expo-image-manipulator/android/src/main/java/expo/modules/imagemanipulator/ImageManipulatorModule.kt +index a080c8a..b6a7191 100644 +--- a/node_modules/expo-image-manipulator/android/src/main/java/expo/modules/imagemanipulator/ImageManipulatorModule.kt ++++ b/node_modules/expo-image-manipulator/android/src/main/java/expo/modules/imagemanipulator/ImageManipulatorModule.kt +@@ -1,9 +1,12 @@ + package expo.modules.imagemanipulator + + import android.graphics.Bitmap ++import android.graphics.Canvas ++import android.graphics.ColorSpace + import android.graphics.drawable.BitmapDrawable + import android.graphics.drawable.Drawable + import android.net.Uri ++import android.os.Build + import android.util.Base64 + import expo.modules.imagemanipulator.transformers.CropTransformer + import expo.modules.imagemanipulator.transformers.FlipTransformer +@@ -118,14 +121,29 @@ class ImageManipulatorModule : Module() { + + var base64String: String? = null + appContext.backgroundCoroutineScope.async { +- FileOutputStream(path).use { fileOut -> +- val compressFormat = options.format.compressFormat +- resultBitmap.compress(compressFormat, compression, fileOut) +- if (options.base64) { +- ByteArrayOutputStream().use { byteOut -> +- resultBitmap.compress(compressFormat, compression, byteOut) +- base64String = Base64.encodeToString(byteOut.toByteArray(), Base64.NO_WRAP) ++ val encodableBitmap = resultBitmap.toEncodableBitmap() ++ try { ++ FileOutputStream(path).use { fileOut -> ++ val compressFormat = options.format.compressFormat ++ // `compress` reports an unsupported pixel format by returning `false` rather than throwing, ++ // which would otherwise leave an empty file behind that only fails once it is read back. ++ if (!encodableBitmap.compress(compressFormat, compression, fileOut)) { ++ File(path).delete() ++ throw ImageWriteFailedException(path) + } ++ if (options.base64) { ++ ByteArrayOutputStream().use { byteOut -> ++ if (!encodableBitmap.compress(compressFormat, compression, byteOut)) { ++ File(path).delete() ++ throw ImageWriteFailedException(path) ++ } ++ base64String = Base64.encodeToString(byteOut.toByteArray(), Base64.NO_WRAP) ++ } ++ } ++ } ++ } finally { ++ if (encodableBitmap !== resultBitmap) { ++ encodableBitmap.recycle() + } + } + }.await() +@@ -140,3 +158,40 @@ class ImageManipulatorModule : Module() { + } + } + } ++ ++/** ++ * Whether `Bitmap.compress` is known to reject this bitmap's pixel format. ++ * ++ * `BitmapFactory` decodes 10-bit HEIC (what iPhones shoot by default) into `RGBA_1010102` on Android 13+, ++ * no matter that the image loader asked for `ARGB_8888`, and Skia's JPEG encoder has no scanline transform ++ * for that format on those releases. This is deliberately an allow-list of one: every other config either ++ * encodes fine, or (`HARDWARE`) is read back by `compress` itself and must not be drawn onto a software ++ * `Canvas`, which throws `IllegalStateException: Software rendering doesn't support hardware bitmaps`. ++ */ ++private fun Bitmap.needsRedrawForEncoding(): Boolean { ++ if (config == Bitmap.Config.HARDWARE) { ++ return false ++ } ++ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102 ++} ++ ++/** ++ * Returns a bitmap that `Bitmap.compress` can encode in every supported format. ++ * ++ * Redrawing through a `Canvas` goes through Skia's rasterizer, which accepts any source pixel format and ++ * color space and converts the pixels to standard-range sRGB, so the saved file is encodable and renders ++ * the same everywhere. Bitmaps that `compress` already handles are returned as they are. ++ */ ++private fun Bitmap.toEncodableBitmap(): Bitmap { ++ if (!needsRedrawForEncoding()) { ++ return this ++ } ++ // Redundant with the TIRAMISU check above, but kept inline so Android lint's NewApi check can see it. ++ val encodableBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { ++ Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888, hasAlpha(), ColorSpace.get(ColorSpace.Named.SRGB)) ++ } else { ++ Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) ++ } ++ Canvas(encodableBitmap).drawBitmap(this, 0f, 0f, null) ++ return encodableBitmap ++}