From ca5692a23217b79c5ae84adb6986f562e09b736b Mon Sep 17 00:00:00 2001 From: Gabriel Donadel Dall'Agnol Date: Tue, 8 Sep 2026 06:02:57 -0300 Subject: [PATCH 1/3] fix(android): skip explicit Kotlin plugin when AGP registers the kotlin extension (#3798) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Android Gradle Plugin 9 ships built-in Kotlin support and enables it by default, so AGP registers the `kotlin` extension itself. When a library *also* applies `kotlin-android` explicitly, the two collide and configuration fails before anything compiles. AGP words it two ways, both the same problem: ``` > Failed to apply plugin 'kotlin-android'. > Cannot add extension with name 'kotlin', as there is an extension already registered with that name. ``` ``` > The 'kotlin-android' plugin is no longer required for Kotlin support since AGP 9.0. ``` The apply is unconditional in all 2 modules below, so on an AGP 9 project this cannot be built at all. There is no consumer-side workaround short of patching the file โ€” setting `android.builtInKotlin=false` project-wide just to build one dependency is not a reasonable ask, and that escape hatch is removed in AGP 10. ## Change Apply the plugin only when nothing has registered the `kotlin` extension yet: ```groovy if (project.extensions.findByName('kotlin') == null) { apply plugin: 'kotlin-android' } ``` Files changed: - `package/expo-package/android/build.gradle` - `package/native-package/android/build.gradle` This tests the condition that actually fails, so there is no AGP version table to keep in sync, and it covers AGP 10 โ€” where the `android.builtInKotlin` opt-out is removed โ€” without a special case. | AGP | `android.builtInKotlin` | `kotlin` extension | explicit apply | |---|---|---|---| | 8.x | unset or `false` | absent | yes (unchanged) | | 9.x | unset or `true` | registered by AGP | no | | 9.x | `false` | absent | yes | | 10+ | n/a (removed) | registered by AGP | no | The guard sits after `apply plugin: 'com.android.library'` in every file it touches, so AGP has already registered its extensions by the time it runs. I checked that ordering per file rather than assuming it. ## What I verified, and what I did not - **Verified end to end** on a real Expo SDK 58 / React Native 0.87 project with AGP 9.2.1 and Gradle 9.4.1: `:app:assembleDebug` succeeds both with `-Pandroid.newDsl=true -Pandroid.builtInKotlin=true` and with both flags off. - Confirmed both branches actually execute rather than one path always winning: with the flags off, `compileDebugKotlin` runs from the explicitly applied plugin; with them on the build completes without it. - Every changed file passes a Groovy `Phases.CONVERSION` syntax check. - **Not run:** this repo's own CI or example app. ## Where this came from A sweep of 500 popular React Native libraries against the AGP 9 defaults. 152 failed with the new DSL enabled, and **144 of those failed on exactly this collision** โ€” by far the most common blocker. Affects `stream-chat-expo` here. The same guard shape was accepted in [RevenueCat/react-native-purchases#1934](https://github.com/RevenueCat/react-native-purchases/pull/1934), at that maintainer's suggestion. --- package/expo-package/android/build.gradle | 8 +++++++- package/native-package/android/build.gradle | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/package/expo-package/android/build.gradle b/package/expo-package/android/build.gradle index e5d534cfc2..e517fc9dfa 100644 --- a/package/expo-package/android/build.gradle +++ b/package/expo-package/android/build.gradle @@ -20,7 +20,13 @@ def isNewArchitectureEnabled() { } apply plugin: "com.android.library" -apply plugin: "kotlin-android" +// AGP 9 ships built-in Kotlin support and registers the `kotlin` extension +// itself. Applying the Kotlin plugin on top of it fails configuration with +// "Cannot add extension with name 'kotlin'". Only apply it when nothing has +// registered that extension yet. +if (project.extensions.findByName('kotlin') == null) { + apply plugin: "kotlin-android" +} if (isNewArchitectureEnabled()) { apply plugin: "com.facebook.react" diff --git a/package/native-package/android/build.gradle b/package/native-package/android/build.gradle index 0d699c4ebf..be804ad0f3 100644 --- a/package/native-package/android/build.gradle +++ b/package/native-package/android/build.gradle @@ -20,7 +20,13 @@ def isNewArchitectureEnabled() { } apply plugin: "com.android.library" -apply plugin: "kotlin-android" +// AGP 9 ships built-in Kotlin support and registers the `kotlin` extension +// itself. Applying the Kotlin plugin on top of it fails configuration with +// "Cannot add extension with name 'kotlin'". Only apply it when nothing has +// registered that extension yet. +if (project.extensions.findByName('kotlin') == null) { + apply plugin: "kotlin-android" +} def appProject = rootProject.allprojects.find { it.plugins.hasPlugin('com.android.application') } From cb145c70e652e10816bde777c673600a8e96b6cc Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Tue, 8 Sep 2026 12:50:18 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(android):=20set=20the=20Kotlin=20jvmTar?= =?UTF-8?q?get=20without=20requiring=20the=20Kotlin=20G=E2=80=A6=20(#3799)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #3798. That change stops applying `kotlin-android` when AGP has already registered the `kotlin` extension, which is what AGP 9 does by default. But `android.kotlinOptions` is contributed by the Kotlin Gradle plugin itself, so skipping the plugin left both wrapper modules unable to configure at all: > Could not find method kotlinOptions() for arguments [...] on extension 'android' of type com.android.build.gradle.LibraryExtension. Set the target on the KotlinCompile tasks instead. That form resolves on both paths. A note for integrators: the `android.builtInKotlin=false` is still necessary because the SDK has peer dependencies that require this setting. --- package/expo-package/android/build.gradle | 15 +++++++++------ package/native-package/android/build.gradle | 15 +++++++++------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/package/expo-package/android/build.gradle b/package/expo-package/android/build.gradle index e517fc9dfa..5e231ac5dc 100644 --- a/package/expo-package/android/build.gradle +++ b/package/expo-package/android/build.gradle @@ -69,16 +69,13 @@ android { } compileOptions { - // Must match the Kotlin jvmTarget below. AGP 9 (React Native 0.87) fails the build on an - // inconsistent JVM-target between the Java and Kotlin compilation tasks. + // Must match the Kotlin jvmTarget set on the KotlinCompile tasks below: an inconsistent + // JVM-target between the Java and Kotlin compilation tasks fails the build. On AGP 9 this + // is also what AGP's built-in Kotlin derives the Kotlin jvmTarget from. sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } - sourceSets { main { if (isNewArchitectureEnabled()) { @@ -128,6 +125,12 @@ tasks.matching { it.name == "preBuild" }.configureEach { tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { dependsOn("syncSharedShimmerSources") + // Set here rather than via `android.kotlinOptions`: that DSL is contributed by the Kotlin + // Gradle plugin, which is deliberately not applied when AGP registers the `kotlin` + // extension itself (see the guard at the top of this file). This form works either way. + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } } repositories { diff --git a/package/native-package/android/build.gradle b/package/native-package/android/build.gradle index be804ad0f3..11d3af6763 100644 --- a/package/native-package/android/build.gradle +++ b/package/native-package/android/build.gradle @@ -79,16 +79,13 @@ android { } compileOptions { - // Must match the Kotlin jvmTarget below. AGP 9 (React Native 0.87) fails the build on an - // inconsistent JVM-target between the Java and Kotlin compilation tasks. + // Must match the Kotlin jvmTarget set on the KotlinCompile tasks below: an inconsistent + // JVM-target between the Java and Kotlin compilation tasks fails the build. On AGP 9 this + // is also what AGP's built-in Kotlin derives the Kotlin jvmTarget from. sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } - sourceSets { main { if (isNewArchitectureEnabled()) { @@ -141,6 +138,12 @@ tasks.matching { it.name == "preBuild" }.configureEach { tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { dependsOn("syncSharedShimmerSources") + // Set here rather than via `android.kotlinOptions`: that DSL is contributed by the Kotlin + // Gradle plugin, which is deliberately not applied when AGP registers the `kotlin` + // extension itself (see the guard at the top of this file). This form works either way. + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } } repositories { From 1d46a536e2c6391f15e38b0164c8b2154e689726 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Tue, 15 Sep 2026 14:12:10 +0200 Subject: [PATCH 3/3] feat: allow providing background color when transforming images with alpha channel to a format without it (#3800) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal Feature request: https://getstream.slack.com/archives/C02GBL5M1BK/p1788176084433019 The problem: - When transforming images with an alpha channel (PNG/Webp) to a format without an alpha channel (for example JPEG) the resulting image's background is platform-dependent: black on Android, white on iOS 26 - The request: provide an option for integrators to specify an explicit background color for these transforms How to use it? ```ts const localCompressImage = defaultNativeHandlers.compressImage; registerNativeHandlers({ compressImage: localCompressImage ? (params) => localCompressImage({ ...params, backgroundColor: '#FFFFFF' }) : undefined, }); ``` ## ๐Ÿ›  Implementation details Works for CLI-only because `expo-image-manipulator` doesn't have such option. The default background color is white. ## ๐ŸŽจ UI Changes
iOS
Before After
Android
Before After
## ๐Ÿงช Testing ## โ˜‘๏ธ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android --- .../handlers/__tests__/compressImage.test.ts | 44 +++++ .../StreamChatReactNative.java | 59 ++++++- .../StreamChatReactNativeModule.java | 15 +- .../StreamChatReactNative.java | 2 +- .../ios/StreamChatReactNative.mm | 37 +++- .../handlers/__tests__/compressImage.test.ts | 130 ++++++++++++++ .../src/handlers/compressImage.ts | 33 +++- .../src/native/NativeStreamChatReactNative.ts | 1 + .../__tests__/createResizedImage.test.ts | 165 ++++++++++++++++++ package/native-package/src/native/index.tsx | 29 ++- package/native-package/src/native/types.ts | 30 ++++ package/native-package/types/index.d.ts | 87 ++++++++- 12 files changed, 606 insertions(+), 26 deletions(-) create mode 100644 package/expo-package/src/handlers/__tests__/compressImage.test.ts create mode 100644 package/native-package/src/handlers/__tests__/compressImage.test.ts create mode 100644 package/native-package/src/native/__tests__/createResizedImage.test.ts diff --git a/package/expo-package/src/handlers/__tests__/compressImage.test.ts b/package/expo-package/src/handlers/__tests__/compressImage.test.ts new file mode 100644 index 0000000000..740b160ede --- /dev/null +++ b/package/expo-package/src/handlers/__tests__/compressImage.test.ts @@ -0,0 +1,44 @@ +describe('expo compressImage', () => { + const manipulateAsync = jest.fn(); + + const loadHandler = () => { + jest.doMock('expo-image-manipulator', () => ({ manipulateAsync }), { virtual: true }); + + return require('../compressImage').compressImage as (params: { + compressImageQuality: number; + uri: string; + }) => Promise; + }; + + beforeEach(() => { + manipulateAsync.mockResolvedValue({ uri: 'file:///cache/out.jpg' }); + }); + + afterEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + }); + + it('ignores a background colour rather than forwarding one', async () => { + // Deliberate asymmetry: backgroundColor is CLI-only. expo-image-manipulator can only fill a + // background while *extending* an image and marks that option @platform web, so there is no + // way to honour it here. + // + // The *type-level* guarantee (passing one is a compile error) is enforced by tsc over `src`, + // not by this file - expo-package/tsconfig.json excludes `**/__tests__`, so a + // `@ts-expect-error` here would never be verified and would only look like a guarantee. + // What this test pins is the runtime half: nothing reaches ImageManipulator. + const compressImage = loadHandler(); + + await compressImage({ + compressImageQuality: 0.5, + uri: 'file:///in.png', + ...({ backgroundColor: '#FFFFFF' } as Record), + }); + + expect(manipulateAsync).toHaveBeenCalledWith('file:///in.png', [], { compress: 0.5 }); + const [, , options] = manipulateAsync.mock.calls[0]; + expect(options).not.toHaveProperty('backgroundColor'); + expect(Object.keys(options)).toEqual(['compress']); + }); +}); diff --git a/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNative.java b/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNative.java index 28352c4613..a377a034a2 100644 --- a/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNative.java +++ b/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNative.java @@ -5,7 +5,10 @@ import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; +import android.graphics.Canvas; import android.graphics.Matrix; +import android.graphics.Paint; +import androidx.annotation.Nullable; import androidx.exifinterface.media.ExifInterface; import android.net.Uri; import android.os.Build; @@ -34,9 +37,15 @@ public class StreamChatReactNative { private final static String SCHEME_HTTPS = "https"; /** * Resize the specified bitmap. + * + * When backgroundColor is non-null the colour is painted first and the image composited over + * it, flattening any alpha channel instead of leaving it for the encoder to drop. That happens + * inside this scale pass rather than after it, so it costs no extra bitmap - the same thing + * iOS does by filling its graphics context before drawing into it. */ private static Bitmap resizeImage(Bitmap image, int newWidth, int newHeight, - String mode, boolean onlyScaleDown) { + String mode, boolean onlyScaleDown, + @Nullable Integer backgroundColor) { Bitmap newImage = null; if (image == null) { return null; // Can't load the image from the given path. @@ -73,6 +82,14 @@ private static Bitmap resizeImage(Bitmap image, int newWidth, int newHeight, finalHeight = (int) Math.round(height * ratio); } + // Only images that actually carry an alpha channel need a background: drawing a fully + // opaque bitmap over any colour reproduces that bitmap exactly, so for everything else + // this would be a pixel-for-pixel no-op - and not a cheap one. createScaledBitmap hands + // back the source object untouched when the requested size already matches + if (backgroundColor != null && image.hasAlpha()) { + return scaleOntoBackground(image, finalWidth, finalHeight, backgroundColor); + } + try { newImage = Bitmap.createScaledBitmap(image, finalWidth, finalHeight, true); } catch (OutOfMemoryError e) { @@ -83,6 +100,38 @@ private static Bitmap resizeImage(Bitmap image, int newWidth, int newHeight, return newImage; } + /** + * Scale the given bitmap into a new one of the given size, over a fill of the given colour, so + * that any alpha channel is flattened rather than dropped. + * + * Encoders without an alpha channel (JPEG) discard alpha and keep the underlying RGB, which + * turns transparent areas black. Drawing onto a filled canvas first blends semi-transparent + * pixels toward the colour and replaces fully transparent ones with it. + * + * This replaces the createScaledBitmap call it stands in for rather than running after it, so + * the fill costs no second full-size bitmap. Returns null if the bitmap can't be allocated. + * The caller owns the result and should recycle the source. + */ + private static Bitmap scaleOntoBackground(Bitmap source, int newWidth, int newHeight, int color) { + Bitmap flattened; + try { + flattened = Bitmap.createBitmap(newWidth, newHeight, Bitmap.Config.ARGB_8888); + } catch (OutOfMemoryError e) { + return null; + } + + Matrix scale = new Matrix(); + scale.setScale((float) newWidth / source.getWidth(), (float) newHeight / source.getHeight()); + + Canvas canvas = new Canvas(flattened); + canvas.drawColor(color); + // FILTER_BITMAP_FLAG matches the `filter = true` that createScaledBitmap is called with on + // the path this replaces, so scaling quality is unchanged. + canvas.drawBitmap(source, scale, new Paint(Paint.FILTER_BITMAP_FLAG)); + + return flattened; + } + /** * Rotate the specified bitmap with the given angle, in degrees. */ @@ -390,7 +439,8 @@ private static Bitmap loadBitmapFromBase64(Uri imageUri) { */ public static Bitmap createResizedImage(Context context, Uri imageUri, int newWidth, int newHeight, int quality, int rotation, - String mode, boolean onlyScaleDown) throws IOException { + String mode, boolean onlyScaleDown, + @Nullable Integer backgroundColor) throws IOException { Bitmap sourceImage = null; String imageUriScheme = imageUri.getScheme(); @@ -425,8 +475,9 @@ public static Bitmap createResizedImage(Context context, Uri imageUri, int newWi sourceImage.recycle(); } - // Scale image - Bitmap scaledImage = StreamChatReactNative.resizeImage(rotatedImage, newWidth, newHeight, mode, onlyScaleDown); + // Scale image, painting the requested background behind it on the way if it has an alpha + // channel to flatten. + Bitmap scaledImage = StreamChatReactNative.resizeImage(rotatedImage, newWidth, newHeight, mode, onlyScaleDown, backgroundColor); if(scaledImage == null){ throw new IOException("Unable to resize image. Most likely due to not enough memory."); diff --git a/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativeModule.java b/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativeModule.java index 4afbe15650..4b2cd578e9 100644 --- a/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativeModule.java +++ b/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativeModule.java @@ -34,17 +34,23 @@ public String getName() { } @ReactMethod - public void createResizedImage(String uri, double width, double height, String format, double quality, String mode, boolean onlyScaleDown, Double rotation, @Nullable String outputPath, Promise promise) { + public void createResizedImage(String uri, double width, double height, String format, double quality, String mode, boolean onlyScaleDown, Double rotation, @Nullable String outputPath, @Nullable Double backgroundColor, Promise promise) { WritableMap options = Arguments.createMap(); options.putString("mode", mode); options.putBoolean("onlyScaleDown", onlyScaleDown); + // processColor() hands us an ARGB int, but codegen boxes every JS number as a Double. + // Double.intValue() *saturates*, so the unsigned form (white is 4294967295.0) would clamp + // to Integer.MAX_VALUE and paint teal. Going via long truncates instead, which wraps + // correctly for both the signed and unsigned forms processColor produces. + final Integer argb = backgroundColor == null ? null : (int) (long) backgroundColor.doubleValue(); + // Run in guarded async task to prevent blocking the React bridge new GuardedAsyncTask(this.getReactApplicationContext()) { @Override protected void doInBackgroundGuarded(Void... params) { try { - Object response = createResizedImageWithExceptions(uri, (int) width, (int) height, format, (int) quality, rotation.intValue(), outputPath, options); + Object response = createResizedImageWithExceptions(uri, (int) width, (int) height, format, (int) quality, rotation.intValue(), outputPath, argb, options); promise.resolve(response); } catch (IOException e) { @@ -57,13 +63,16 @@ protected void doInBackgroundGuarded(Void... params) { @SuppressLint("LongLogTag") private Object createResizedImageWithExceptions(String imagePath, int newWidth, int newHeight, String compressFormatString, int quality, int rotation, String outputPath, + @Nullable Integer backgroundColor, final ReadableMap options) throws IOException { Bitmap.CompressFormat compressFormat = Bitmap.CompressFormat.valueOf(compressFormatString); Uri imageUri = Uri.parse(imagePath); + // The background colour is applied inside the resize, as part of the same canvas pass that + // scales the image, so no second full-size bitmap is allocated for it. Bitmap scaledImage = StreamChatReactNative.createResizedImage(this.getReactApplicationContext(), imageUri, newWidth, newHeight, quality, rotation, - options.getString("mode"), options.getBoolean("onlyScaleDown")); + options.getString("mode"), options.getBoolean("onlyScaleDown"), backgroundColor); if (scaledImage == null) { throw new IOException("The image failed to be resized; invalid Bitmap result."); diff --git a/package/native-package/android/src/oldarch/com/streamchatreactnative/StreamChatReactNative.java b/package/native-package/android/src/oldarch/com/streamchatreactnative/StreamChatReactNative.java index e8ade13ae3..7dc89f8c8d 100644 --- a/package/native-package/android/src/oldarch/com/streamchatreactnative/StreamChatReactNative.java +++ b/package/native-package/android/src/oldarch/com/streamchatreactnative/StreamChatReactNative.java @@ -12,5 +12,5 @@ abstract class StreamChatReactNativeSpec extends ReactContextBaseJavaModule { super(context); } - public abstract void createResizedImage(String uri, double width, double height, String format, double quality, String mode, boolean onlyScaleDown, Double rotation, @Nullable String outputPath, Promise promise); + public abstract void createResizedImage(String uri, double width, double height, String format, double quality, String mode, boolean onlyScaleDown, Double rotation, @Nullable String outputPath, @Nullable Double backgroundColor, Promise promise); } diff --git a/package/native-package/ios/StreamChatReactNative.mm b/package/native-package/ios/StreamChatReactNative.mm index 017dd29c40..df91ac26fb 100644 --- a/package/native-package/ios/StreamChatReactNative.mm +++ b/package/native-package/ios/StreamChatReactNative.mm @@ -19,7 +19,7 @@ static NSString *generateFilePath(NSString *ext, NSString *outputPath); static UIImage *rotateImage(UIImage *inputImage, float rotationDegrees); static float getScaleForProportionalResize(CGSize theSize, CGSize intoSize, bool onlyScaleDown, bool maximize); -static UIImage *scaleImage(UIImage *image, CGSize toSize, NSString *mode, bool onlyScaleDown); +static UIImage *scaleImage(UIImage *image, CGSize toSize, NSString *mode, bool onlyScaleDown, UIColor *backgroundColor); static NSDictionary *transformImage(UIImage *image, int rotation, CGSize newSize, NSString *fullPath, NSString *format, int quality, NSDictionary *options); @implementation StreamChatReactNative @@ -28,12 +28,12 @@ @implementation StreamChatReactNative RCT_EXPORT_MODULE() -RCT_REMAP_METHOD(createResizedImage, uri:(NSString *)uri width:(double)width height:(double)height format:(NSString *)format quality:(double)quality mode:(NSString *)mode onlyScaleDown:(BOOL)onlyScaleDown rotation:(nonnull NSNumber *)rotation outputPath:(NSString *)outputPath resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) +RCT_REMAP_METHOD(createResizedImage, uri:(NSString *)uri width:(double)width height:(double)height format:(NSString *)format quality:(double)quality mode:(NSString *)mode onlyScaleDown:(BOOL)onlyScaleDown rotation:(nonnull NSNumber *)rotation outputPath:(NSString *)outputPath backgroundColor:(NSNumber *)backgroundColor resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { - [self createResizedImage:uri width:width height:height format:format quality:quality mode:mode onlyScaleDown:onlyScaleDown rotation:rotation outputPath:outputPath resolve:resolve reject:reject]; + [self createResizedImage:uri width:width height:height format:format quality:quality mode:mode onlyScaleDown:onlyScaleDown rotation:rotation outputPath:outputPath backgroundColor:backgroundColor resolve:resolve reject:reject]; } -- (void)createResizedImage:(NSString *)uri width:(double)width height:(double)height format:(NSString *)format quality:(double)quality mode:(NSString *)mode onlyScaleDown:(BOOL)onlyScaleDown rotation:(nonnull NSNumber *)rotation outputPath:(NSString *)outputPath resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { +- (void)createResizedImage:(NSString *)uri width:(double)width height:(double)height format:(NSString *)format quality:(double)quality mode:(NSString *)mode onlyScaleDown:(BOOL)onlyScaleDown rotation:(nonnull NSNumber *)rotation outputPath:(NSString *)outputPath backgroundColor:(NSNumber *)backgroundColor resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ @try { CGSize newSize = CGSizeMake(width, height); @@ -51,6 +51,15 @@ - (void)createResizedImage:(NSString *)uri width:(double)width height:(double)he [NSException raise:moduleName format:@"Invalid output path."]; } + // Nil unless the caller asked for a backdrop. Converted here (on the JS-side + // argument) rather than in scaleImage so the ARGB decoding lives in one place. + UIColor *fillColor = backgroundColor == nil ? nil : [RCTConvert UIColor:backgroundColor]; + + NSMutableDictionary *options = [@{@"mode": mode, @"onlyScaleDown": [NSNumber numberWithBool:onlyScaleDown]} mutableCopy]; + if (fillColor != nil) { + options[@"backgroundColor"] = fillColor; + } + RCTImageLoader *loader = [self.bridge moduleForName:@"ImageLoader" lazilyLoadIfNecessary:YES]; NSURLRequest *request = [RCTConvert NSURLRequest:uri]; [loader loadImageWithURLRequest:request @@ -66,7 +75,7 @@ - (void)createResizedImage:(NSString *)uri width:(double)width height:(double)he reject([NSString stringWithFormat: @"%ld", (long)error.code], error.description, nil); return; } - NSDictionary * response = transformImage(image, [rotation integerValue], newSize, fullPath, format, (int)quality, @{@"mode": mode, @"onlyScaleDown": [NSNumber numberWithBool:onlyScaleDown]}); + NSDictionary * response = transformImage(image, [rotation integerValue], newSize, fullPath, format, (int)quality, options); resolve(response); }]; } @catch (NSException *exception) { @@ -195,7 +204,7 @@ static float getScaleForProportionalResize(CGSize theSize, CGSize intoSize, bool // any :image scale factor. // The returned image is an unscaled image (scale = 1.0) // so no additional scaling math needs to be done to get its pixel dimensions -static UIImage* scaleImage (UIImage* image, CGSize toSize, NSString* mode, bool onlyScaleDown) +static UIImage* scaleImage (UIImage* image, CGSize toSize, NSString* mode, bool onlyScaleDown, UIColor* backgroundColor) { // Need to do scaling corrections @@ -226,7 +235,18 @@ static float getScaleForProportionalResize(CGSize theSize, CGSize intoSize, bool newSize = CGSizeMake(roundf(imageSize.width * scale), roundf(imageSize.height * scale)); } - UIGraphicsBeginImageContextWithOptions(newSize, NO, 1.0); + // A non-opaque context initialises to white (tested on iOS 26). Encoders + // without an alpha channel (JPEG) then drop the alpha and keep the RGB, which + // is why transparent areas come out white. When a backdrop is requested we + // make the context opaque and fill it first, so the image composites onto the + // colour in the same render pass - no extra bitmap, and an opaque context is + // cheaper than an alpha one. + BOOL opaque = (backgroundColor != nil); + UIGraphicsBeginImageContextWithOptions(newSize, opaque, 1.0); + if (opaque) { + [backgroundColor setFill]; + UIRectFill(CGRectMake(0, 0, newSize.width, newSize.height)); + } [image drawInRect:CGRectMake(0, 0, newSize.width, newSize.height)]; UIImage *newImage = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); @@ -258,7 +278,8 @@ static float getScaleForProportionalResize(CGSize theSize, CGSize intoSize, bool image, newSize, options[@"mode"], - [[options objectForKey:@"onlyScaleDown"] boolValue] + [[options objectForKey:@"onlyScaleDown"] boolValue], + options[@"backgroundColor"] ); if (scaledImage == nil) { diff --git a/package/native-package/src/handlers/__tests__/compressImage.test.ts b/package/native-package/src/handlers/__tests__/compressImage.test.ts new file mode 100644 index 0000000000..449687297c --- /dev/null +++ b/package/native-package/src/handlers/__tests__/compressImage.test.ts @@ -0,0 +1,130 @@ +describe('native compressImage', () => { + const createResizedImage = jest.fn(); + + const loadHandler = () => { + // `__esModule: true` matters: the handler uses a default import, so Babel runs the mock + // through _interopRequireDefault, which would double-wrap a plain `{ default }` object. + jest.doMock('../../native', () => ({ + __esModule: true, + default: { createResizedImage }, + })); + + return require('../compressImage').compressImage as (params: { + backgroundColor?: string | number | null; + compressImageQuality: number; + height: number; + uri: string; + width: number; + }) => Promise; + }; + + beforeEach(() => { + createResizedImage.mockResolvedValue({ uri: 'file:///cache/out.JPEG' }); + }); + + afterEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + }); + + it('forwards the background colour through to the native resizer', async () => { + const compressImage = loadHandler(); + + await expect( + compressImage({ + backgroundColor: '#FFFFFF', + compressImageQuality: 0.5, + height: 900, + uri: 'file:///in.png', + width: 1200, + }), + ).resolves.toBe('file:///cache/out.JPEG'); + + expect(createResizedImage).toHaveBeenCalledWith( + 'file:///in.png', + 1200, + 900, + 'JPEG', + 50, + 0, + undefined, + { backgroundColor: '#FFFFFF', mode: 'cover' }, + ); + }); + + it('defaults to white when no colour is given', async () => { + // The encoder is JPEG either way, so the alpha channel cannot survive. Without a default the + // resulting colour is the platform's: black on Android, white on iOS. + const compressImage = loadHandler(); + + await compressImage({ + compressImageQuality: 0.5, + height: 900, + uri: 'file:///in.png', + width: 1200, + }); + + expect(createResizedImage.mock.calls[0].at(-1)).toEqual({ + backgroundColor: '#FFFFFF', + mode: 'cover', + }); + }); + + it('treats an explicit null as opting out, not as "use the default"', async () => { + const compressImage = loadHandler(); + + await compressImage({ + backgroundColor: null, + compressImageQuality: 0.5, + height: 900, + uri: 'file:///in.png', + width: 1200, + }); + + const options = createResizedImage.mock.calls[0].at(-1); + expect(options).toEqual({ backgroundColor: null, mode: 'cover' }); + expect(options.backgroundColor).toBeNull(); + }); + + it('still clamps the quality and keeps cover mode', async () => { + const compressImage = loadHandler(); + + await compressImage({ + compressImageQuality: 5, + height: 900, + uri: 'file:///in.png', + width: 1200, + }); + await compressImage({ + compressImageQuality: -3, + height: 900, + uri: 'file:///in.png', + width: 1200, + }); + + expect(createResizedImage.mock.calls[0][4]).toBe(100); + expect(createResizedImage.mock.calls[1][4]).toBe(0); + expect(createResizedImage.mock.calls[0].at(-1)).toMatchObject({ mode: 'cover' }); + }); + + it('falls back to the original uri when the native call rejects', async () => { + // Pre-existing behaviour, pinned here because it also swallows the resizer's + // "unsupported backgroundColor" error - an invalid colour silently skips compression. + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const compressImage = loadHandler(); + createResizedImage.mockRejectedValue(new Error('unsupported backgroundColor')); + + await expect( + compressImage({ + backgroundColor: 'not-a-colour', + compressImageQuality: 0.5, + height: 900, + uri: 'file:///in.png', + width: 1200, + }), + ).resolves.toBe('file:///in.png'); + + expect(logSpy).toHaveBeenCalled(); + logSpy.mockRestore(); + }); +}); diff --git a/package/native-package/src/handlers/compressImage.ts b/package/native-package/src/handlers/compressImage.ts index 27d9236c63..8ae2ba4b20 100644 --- a/package/native-package/src/handlers/compressImage.ts +++ b/package/native-package/src/handlers/compressImage.ts @@ -1,6 +1,33 @@ import StreamChatReactNative from '../native'; +import type { BackgroundColor } from '../native/types'; -type CompressImageParams = { +/** + * Painted behind every image this handler compresses unless the caller says otherwise. + * + * White rather than nothing: the encoder is always JPEG, so the alpha channel cannot survive + * either way, and leaving the choice to the platform produces black on Android and white on + * iOS for the same input. + */ +export const DEFAULT_BACKGROUND_COLOR = '#FFFFFF'; + +export type CompressImageParams = { + /** + * Painted behind the image, flattening any alpha channel onto this colour. + * + * This handler always encodes to JPEG, which has no alpha channel, so a transparent area of a + * PNG or WebP has to become *some* colour. Left to the platform that colour is black on + * Android and white on iOS; defaulting to white here makes the two agree and matches what a + * transparent image is nearly always designed to sit on. + * + * Always painted fully opaque; any alpha in the colour is ignored. Pass `null` to opt out and + * get the platform's own behaviour back. + * + * Only supported by `stream-chat-react-native` (React Native CLI). `stream-chat-expo` has no + * equivalent, so an Expo app keeps the platform default. + * + * (Default: '#FFFFFF') + */ + backgroundColor?: BackgroundColor; compressImageQuality: number; height: number; uri: string; @@ -8,6 +35,8 @@ type CompressImageParams = { }; export const compressImage = async ({ + // Only substituted for `undefined`, so an explicit `null` still means "paint nothing". + backgroundColor = DEFAULT_BACKGROUND_COLOR, compressImageQuality = 1, height, uri, @@ -22,7 +51,7 @@ export const compressImage = async ({ Math.min(Math.max(0, compressImageQuality), 1) * 100, 0, undefined, - { mode: 'cover' }, + { backgroundColor, mode: 'cover' }, ); return compressedUri; } catch (error) { diff --git a/package/native-package/src/native/NativeStreamChatReactNative.ts b/package/native-package/src/native/NativeStreamChatReactNative.ts index b0992ae8ad..060281c9d3 100644 --- a/package/native-package/src/native/NativeStreamChatReactNative.ts +++ b/package/native-package/src/native/NativeStreamChatReactNative.ts @@ -13,6 +13,7 @@ export interface Spec extends TurboModule { onlyScaleDown: boolean, rotation?: number, outputPath?: string | null, + backgroundColor?: number | null, ): Promise<{ base64: string; height: number; diff --git a/package/native-package/src/native/__tests__/createResizedImage.test.ts b/package/native-package/src/native/__tests__/createResizedImage.test.ts new file mode 100644 index 0000000000..5fefc1a84b --- /dev/null +++ b/package/native-package/src/native/__tests__/createResizedImage.test.ts @@ -0,0 +1,165 @@ +import { NativeModules } from 'react-native'; + +import type { Options, ResizeFormat } from '../types'; + +const NATIVE_RESPONSE = { + height: 900, + name: 'out.JPEG', + path: '/cache/out.JPEG', + size: 1234, + uri: 'file:///cache/out.JPEG', + width: 1200, +}; + +describe('native createResizedImage', () => { + const nativeCreateResizedImage = jest.fn(); + + /** + * The module resolves its native binding at import time, so it has to be in place before + * `../index` is required. Jest leaves `global.__turboModuleProxy` unset, so the module takes + * the `NativeModules` branch - and that is the one to stub. Setting the TurboModule flag here + * instead would break every other RN module that resolves through TurboModuleRegistry. + */ + const loadModule = () => { + // @ts-expect-error - the real module is only registered by the native side + NativeModules.StreamChatReactNative = { createResizedImage: nativeCreateResizedImage }; + + return require('../index').default as { + createResizedImage: ( + uri: string, + width: number, + height: number, + format: ResizeFormat, + quality: number, + rotation?: number, + outputPath?: string | null, + options?: Options, + ) => Promise; + }; + }; + + beforeEach(() => { + nativeCreateResizedImage.mockResolvedValue(NATIVE_RESPONSE); + }); + + afterEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + delete NativeModules.StreamChatReactNative; + }); + + it('forwards the processed background colour as the trailing argument', async () => { + const { createResizedImage } = loadModule(); + + await expect( + createResizedImage('file:///in.png', 1200, 900, 'JPEG', 80, 0, null, { + backgroundColor: '#FFFFFF', + }), + ).resolves.toEqual(NATIVE_RESPONSE); + + expect(nativeCreateResizedImage).toHaveBeenCalledWith( + 'file:///in.png', + 1200, + 900, + 'JPEG', + 80, + 'contain', + false, + 0, + null, + 0xffffffff, + ); + }); + + it('accepts a named colour and an integer, rotating the integer to 0xAARRGGBB', async () => { + const { createResizedImage } = loadModule(); + + await createResizedImage('file:///in.webp', 1200, 900, 'JPEG', 80, 0, null, { + backgroundColor: 'white', + }); + // processColor reads a *number* as 0xRRGGBBAA, so the alpha byte is the last one, not the + // first. 0x123456ff is therefore opaque, and every byte differs, so a rotation applied in + // the wrong direction cannot pass this assertion. + await createResizedImage('file:///in.webp', 1200, 900, 'JPEG', 80, 0, null, { + backgroundColor: 0x123456ff, + }); + + // Asserted against literals rather than processColor(): what matters is that the native side + // receives 0xAARRGGBB, and `processColor(x) === processColor(x)` cannot show that. The values + // are unsigned on both platforms, because the wrapper normalises the sign processColor leaves + // platform-dependent. + expect(nativeCreateResizedImage.mock.calls[0].at(-1)).toBe(0xffffffff); + expect(nativeCreateResizedImage.mock.calls[1].at(-1)).toBe(0xff123456); + }); + + it('forces the colour opaque, so a see-through background is not a silent no-op', async () => { + const { createResizedImage } = loadModule(); + + // Left as given, every one of these would reach a JPEG encoder that has no alpha channel to + // put them in, and the transparent areas would come out platform-dependent again - which is + // the whole thing this option exists to prevent. `transparent` is the sharpest case: + // processColor reduces it to 0, a number, so nothing upstream rejects it. + for (const backgroundColor of ['transparent', '#FFFFFF00', 0xffffff00, '#12345680']) { + await createResizedImage('file:///in.png', 1200, 900, 'JPEG', 80, 0, null, { + backgroundColor, + }); + } + + expect(nativeCreateResizedImage.mock.calls.map((call) => call.at(-1))).toEqual([ + 0xff000000, 0xffffffff, 0xffffffff, 0xff123456, + ]); + }); + + it('sends null when no background colour is given, leaving the other arguments untouched', async () => { + const { createResizedImage } = loadModule(); + + await createResizedImage('file:///in.png', 640, 480, 'PNG', 100, 90, '/tmp/out', { + mode: 'cover', + onlyScaleDown: true, + }); + + expect(nativeCreateResizedImage).toHaveBeenCalledWith( + 'file:///in.png', + 640, + 480, + 'PNG', + 100, + 'cover', + true, + 90, + '/tmp/out', + null, + ); + }); + + it('keeps the default options and argument order when only the required arguments are passed', async () => { + const { createResizedImage } = loadModule(); + + await createResizedImage('file:///in.jpg', 100, 100, 'JPEG', 50); + + expect(nativeCreateResizedImage).toHaveBeenCalledWith( + 'file:///in.jpg', + 100, + 100, + 'JPEG', + 50, + 'contain', + false, + 0, + undefined, + null, + ); + }); + + it('rejects a background colour that cannot be reduced to a plain integer', async () => { + const { createResizedImage } = loadModule(); + + await expect( + createResizedImage('file:///in.png', 1200, 900, 'JPEG', 80, 0, null, { + backgroundColor: 'not-a-colour', + }), + ).rejects.toThrow(/unsupported backgroundColor/); + + expect(nativeCreateResizedImage).not.toHaveBeenCalled(); + }); +}); diff --git a/package/native-package/src/native/index.tsx b/package/native-package/src/native/index.tsx index 418fd83b6f..95d72fd815 100644 --- a/package/native-package/src/native/index.tsx +++ b/package/native-package/src/native/index.tsx @@ -1,4 +1,4 @@ -import { NativeModules } from 'react-native'; +import { NativeModules, processColor } from 'react-native'; import type { Options, ResizeFormat, Response } from './types'; export type { ResizeFormat, ResizeMode, Response } from './types'; @@ -26,7 +26,31 @@ async function createResizedImage( outputPath?: string | null, options: Options = defaultOptions, ): Promise { - const { mode, onlyScaleDown } = { ...defaultOptions, ...options }; + const { backgroundColor, mode, onlyScaleDown } = { ...defaultOptions, ...options }; + + // The colour has to reach the native side as a plain ARGB integer, so anything + // processColor cannot reduce to a number (PlatformColor, an unparseable string) + // is rejected here rather than silently dropped. + let processedBackgroundColor: number | null = null; + if (backgroundColor !== undefined && backgroundColor !== null) { + const processed = processColor(backgroundColor); + if (typeof processed !== 'number') { + throw new Error( + `createResizedImage: unsupported backgroundColor \`${String( + backgroundColor, + )}\`. Pass a colour string such as '#FFFFFF' or an integer; PlatformColor and DynamicColorIOS are not supported.`, + ); + } + // Force the colour opaque. A background with any transparency is at best a silent no-op: + // processColor('transparent') is 0, which passes the check above, and an encoder without an + // alpha channel then drops it again and leaves exactly the platform-dependent result this + // option exists to prevent. Partial alpha is worse, because it is resolved at a different + // stage on each platform - '#FFFFFF00' comes out white on iOS and black on Android - so + // there is no reason the two agree. Overriding the alpha byte makes the option always mean + // what it says. `>>> 0` normalises to the unsigned form, which both native sides accept + // (see the Double -> int conversion in StreamChatReactNativeModule.createResizedImage). + processedBackgroundColor = (processed | 0xff000000) >>> 0; + } return await ImageResizer.createResizedImage( uri, @@ -38,6 +62,7 @@ async function createResizedImage( onlyScaleDown, rotation, outputPath, + processedBackgroundColor, ); } diff --git a/package/native-package/src/native/types.ts b/package/native-package/src/native/types.ts index 715041f110..cdc3d509fa 100644 --- a/package/native-package/src/native/types.ts +++ b/package/native-package/src/native/types.ts @@ -12,7 +12,37 @@ export interface VideoThumbnailResponse extends Response {} export type ResizeFormat = 'PNG' | 'JPEG' | 'WEBP'; export type ResizeMode = 'contain' | 'cover' | 'stretch'; +/** + * A colour `processColor` can reduce to a plain ARGB integer: a colour string + * (`'#FFFFFF'`, `'white'`, `'rgba(255, 255, 255, 1)'`) or an RGBA integer โ€” note the channel + * order, `0xRRGGBBAA`, so opaque white is `0xFFFFFFFF`. `null` is treated the same as + * omitting it: no background is painted. + * + * The colour's own alpha is ignored โ€” the background is always painted fully opaque. A + * see-through background would be flattened away again by the first encoder without an alpha + * channel, leaving the platform-dependent result this option exists to replace. + * + * Narrower than react-native's `ColorValue`, which also admits + * `PlatformColor`/`DynamicColorIOS`. Those cannot cross the bridge as a plain integer and are + * rejected at runtime โ€” and `compressImage` swallows that rejection and silently returns the + * uncompressed image, so this type is the only guardrail a caller actually gets. + */ +export type BackgroundColor = string | number | null; + export type Options = { + /** + * Painted behind the image, flattening any alpha channel onto this colour. + * + * When converting to a format without alpha channel without a background any transparent area of a PNG or WebP depends on platform behavior. Pass a color value to explicitly control background color. + * + * Always painted fully opaque; any alpha in the colour is ignored. + * + * Only supported by `stream-chat-react-native` (React Native CLI). `stream-chat-expo` has no + * equivalent. + * + * (Default: undefined - no background is painted) + */ + backgroundColor?: BackgroundColor; /** * Either `contain` (the default), `cover`, or `stretch`. Similar to * [react-native 's resizeMode](https://reactnative.dev/docs/image#resizemode) diff --git a/package/native-package/types/index.d.ts b/package/native-package/types/index.d.ts index 8d03079494..0e838ed001 100644 --- a/package/native-package/types/index.d.ts +++ b/package/native-package/types/index.d.ts @@ -2,6 +2,68 @@ import { registerNativeHandlers } from 'stream-chat-react-native-core'; export * from 'stream-chat-react-native-core'; +type NativeHandlers = Parameters[0]; + +/** + * A colour `processColor` can reduce to a plain ARGB integer: a colour string + * (`'#FFFFFF'`, `'white'`, `'rgba(255, 255, 255, 1)'`) or an RGBA integer โ€” note the + * channel order, `0xRRGGBBAA`, so opaque white is `0xFFFFFFFF`. `null` is treated the same + * as omitting it: no background is painted. + * + * The colour's own alpha is ignored โ€” the background is always painted fully opaque. A + * see-through background would be flattened away again by the first encoder without an alpha + * channel, leaving the platform-dependent result this option exists to replace. + * + * Narrower than react-native's `ColorValue` on purpose. `ColorValue` admits + * `PlatformColor`/`DynamicColorIOS`, which cannot cross the bridge as an integer and are + * rejected at runtime โ€” and `compressImage` swallows that rejection and silently returns the + * uncompressed image, so this type is the only guardrail a caller actually gets. `ColorValue` + * also has two live definitions across the supported react-native range, the older of which + * excludes `number` and would reject the integer form documented above. + */ +type BackgroundColor = string | number | null; + +// Declared inline rather than imported from `src/handlers/compressImage.ts`, even though that +// duplicates the shape. +// +// This file is the package's published type surface (`"types": "types/index.d.ts"`), so +// anything it imports is pulled into the *consumer's* TypeScript program and checked with +// *their* compiler options โ€” `skipLibCheck` covers `.d.ts` but not `.ts`. Importing the source +// would make our public types only as portable as each consumer's config, across a +// `react-native >=0.76` peer range, and from a workspace whose own tsconfig is deliberately +// laxer than a strict app's (see the `strictNullChecks: false` rationale in ../tsconfig.json). +// Two failures are reproducible today: `moduleResolution: node10` (react-native publishes its +// types behind `exports`) and any project without `jsx` set (`../native` resolves to a `.tsx`). +// Both land as errors inside `node_modules`, in files the integrator does not own. +// +// The cost is that this must be kept in sync by hand with `BackgroundColor` in +// `src/native/types.ts` and `CompressImageParams` in `src/handlers/compressImage.ts`, which +// share a single source-side definition. It is five properties; if it grows, add a +// compile-time assertion under `src/` (inside the workspace tsconfig's `include`, never +// imported at runtime) rather than importing the source here. +/** + * This package's `compressImage`, which accepts one option the shared `CompressImage` + * contract in `stream-chat-react-native-core` does not: `backgroundColor`, painted behind the + * image so an alpha channel is flattened onto it instead of being dropped by an encoder that + * has none (JPEG). + * + * Defaults to `'#FFFFFF'`. Left to the platform the same transparent PNG comes out black on + * Android and white on iOS, so the default exists to make the two agree; pass `null` to opt out + * and get that platform behaviour back. + * + * `stream-chat-expo` has no equivalent โ€” `expo-image-manipulator` can only fill a background + * while *extending* an image, and marks that option `@platform web` โ€” which is why the + * widening lives on this wrapper rather than in core's shared contract. An Expo app therefore + * keeps the platform default. + */ +type CompressImageWithBackground = (params: { + backgroundColor?: BackgroundColor; + compressImageQuality: number; + height: number; + uri: string; + width: number; +}) => Promise; + /** * The default native handlers this package registers with the core SDK. * @@ -13,18 +75,31 @@ export * from 'stream-chat-react-native-core'; * Example: * * ```ts - * import { registerNativeHandlers, defaultNativeHandlers } from 'stream-chat-expo'; + * import { registerNativeHandlers, defaultNativeHandlers } from 'stream-chat-react-native'; * * const localTakePhoto = defaultNativeHandlers.takePhoto; * * registerNativeHandlers({ * takePhoto: localTakePhoto - * ? (options) => { - * console.log('[#3379 demo] wrapped takePhoto โ€” forcing mediaType "image"', options); - * return localTakePhoto({ ...options, mediaType: 'image' }); - * } + * ? (options) => localTakePhoto({ ...options, mediaType: 'image' }) + * : undefined, + * }); + * ``` + * + * The same pattern is the only way to reach `compressImage`'s `backgroundColor`: the SDK's own + * attachment path forwards just `compressImageQuality`/`height`/`uri`/`width`, so wrap the + * default handler to add a backdrop to every image the composer compresses. + * + * ```ts + * const localCompressImage = defaultNativeHandlers.compressImage; + * + * registerNativeHandlers({ + * compressImage: localCompressImage + * ? (params) => localCompressImage({ ...params, backgroundColor: '#FFFFFF' }) * : undefined, * }); * ``` */ -export declare const defaultNativeHandlers: Parameters[0]; +export declare const defaultNativeHandlers: Omit & { + compressImage?: CompressImageWithBackground; +};