From f88f38001eccd96f48db5ed9a85c3ea45f702ea1 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 10 Sep 2026 08:26:40 +0200 Subject: [PATCH 1/9] feat: allow providing backgroundColor when transforming png/webp to images without alpha channel --- .../handlers/__tests__/compressImage.test.ts | 44 ++++++ .../StreamChatReactNative.java | 31 ++++ .../StreamChatReactNativeModule.java | 25 +++- .../StreamChatReactNative.java | 2 +- .../ios/StreamChatReactNative.mm | 37 ++++- .../handlers/__tests__/compressImage.test.ts | 111 ++++++++++++++ .../src/handlers/compressImage.ts | 20 ++- .../src/native/NativeStreamChatReactNative.ts | 1 + .../__tests__/createResizedImage.test.ts | 140 ++++++++++++++++++ package/native-package/src/native/index.tsx | 21 ++- package/native-package/src/native/types.ts | 24 +++ 11 files changed, 441 insertions(+), 15 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..dc1bda7b75 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,6 +5,7 @@ import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; +import android.graphics.Canvas; import android.graphics.Matrix; import androidx.exifinterface.media.ExifInterface; import android.net.Uri; @@ -83,6 +84,36 @@ private static Bitmap resizeImage(Bitmap image, int newWidth, int newHeight, return newImage; } + /** + * Composite the given bitmap onto an opaque background 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. + * + * Returns null if the intermediate bitmap can't be allocated. The caller owns the result and + * should recycle the source. + */ + public static Bitmap flattenOntoBackground(Bitmap source, int color) { + if (source == null) { + return null; + } + + Bitmap flattened; + try { + flattened = Bitmap.createBitmap(source.getWidth(), source.getHeight(), Bitmap.Config.ARGB_8888); + } catch (OutOfMemoryError e) { + return null; + } + + Canvas canvas = new Canvas(flattened); + canvas.drawColor(color); + canvas.drawBitmap(source, 0, 0, null); + + return flattened; + } + /** * Rotate the specified bitmap with the given angle, in degrees. */ 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..31f10a2a09 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,6 +63,7 @@ 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); @@ -69,6 +76,20 @@ private Object createResizedImageWithExceptions(String imagePath, int newWidth, throw new IOException("The image failed to be resized; invalid Bitmap result."); } + // Flatten any alpha channel onto the requested colour before encoding, so transparent + // areas do not come out black in a format that has no alpha channel. + if (backgroundColor != null) { + Bitmap flattenedImage = StreamChatReactNative.flattenOntoBackground(scaledImage, backgroundColor); + + if (flattenedImage == null) { + scaledImage.recycle(); + throw new IOException("Unable to apply the background colour. Most likely due to not enough memory."); + } + + scaledImage.recycle(); + scaledImage = flattenedImage; + } + // Save the resulting image File path = this.getReactApplicationContext().getCacheDir(); if (outputPath != null) { 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..f878a6287d 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 transparent black (0,0,0,0). Encoders + // without an alpha channel (JPEG) then drop the alpha and keep the RGB, which + // is why transparent areas come out black. 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..9c4b731f49 --- /dev/null +++ b/package/native-package/src/handlers/__tests__/compressImage.test.ts @@ -0,0 +1,111 @@ +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; + 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('leaves the colour undefined when none is given, so the native layer receives null', async () => { + const compressImage = loadHandler(); + + await compressImage({ + compressImageQuality: 0.5, + height: 900, + uri: 'file:///in.png', + width: 1200, + }); + + const options = createResizedImage.mock.calls[0].at(-1); + expect(options).toEqual({ backgroundColor: undefined, mode: 'cover' }); + expect(options.backgroundColor).toBeUndefined(); + }); + + 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..3d3ba0e786 100644 --- a/package/native-package/src/handlers/compressImage.ts +++ b/package/native-package/src/handlers/compressImage.ts @@ -1,6 +1,21 @@ +import type { ColorValue } from 'react-native'; + import StreamChatReactNative from '../native'; -type CompressImageParams = { +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 without a background + * any transparent area of a PNG or WebP comes out **black**. Pass `'#FFFFFF'` to get the + * white backdrop a browser canvas would give you instead. + * + * Only supported by `stream-chat-react-native` (React Native CLI). `stream-chat-expo` has no + * equivalent. + * + * (Default: undefined - no background is painted) + */ + backgroundColor?: ColorValue; compressImageQuality: number; height: number; uri: string; @@ -8,6 +23,7 @@ type CompressImageParams = { }; export const compressImage = async ({ + backgroundColor, compressImageQuality = 1, height, uri, @@ -22,7 +38,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..78d65e2750 --- /dev/null +++ b/package/native-package/src/native/__tests__/createResizedImage.test.ts @@ -0,0 +1,140 @@ +import { NativeModules, processColor } 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, + processColor('#FFFFFF'), + ); + }); + + it('accepts a named colour and an integer as well as a hex string', async () => { + const { createResizedImage } = loadModule(); + + await createResizedImage('file:///in.webp', 1200, 900, 'JPEG', 80, 0, null, { + backgroundColor: 'white', + }); + await createResizedImage('file:///in.webp', 1200, 900, 'JPEG', 80, 0, null, { + backgroundColor: 0xff00ff00, + }); + + expect(nativeCreateResizedImage.mock.calls[0].at(-1)).toBe(processColor('white')); + expect(nativeCreateResizedImage.mock.calls[1].at(-1)).toBe(processColor(0xff00ff00)); + }); + + 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..103b91307e 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,23 @@ 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.`, + ); + } + processedBackgroundColor = processed; + } return await ImageResizer.createResizedImage( uri, @@ -38,6 +54,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..77d7ff147c 100644 --- a/package/native-package/src/native/types.ts +++ b/package/native-package/src/native/types.ts @@ -1,3 +1,5 @@ +import type { ColorValue } from 'react-native'; + export interface Response { height: number; name: string; @@ -13,6 +15,28 @@ export type ResizeFormat = 'PNG' | 'JPEG' | 'WEBP'; export type ResizeMode = 'contain' | 'cover' | 'stretch'; export type Options = { + /** + * Painted behind the image before it is encoded, flattening any alpha channel + * onto this colour. + * + * Without it, converting an image that has transparency to a format that has + * no alpha channel (`'JPEG'`) turns the transparent areas **black**, because + * the encoder drops the alpha and keeps the underlying RGB. Pass + * `backgroundColor: '#FFFFFF'` to get the white backdrop a browser canvas + * would give you instead. + * + * Applied whenever it is set, for every output format — asking for `'PNG'` + * output with a `backgroundColor` produces an opaque PNG. When omitted, the + * image is encoded exactly as before. + * + * Accepts any colour string or integer that + * [processColor](https://reactnative.dev/docs/colors) understands, e.g. + * `'#FFFFFF'` or `'white'`. `PlatformColor`/`DynamicColorIOS` values are not + * supported, because the colour has to cross the bridge as a plain integer. + * + * (Default: undefined — no background is painted) + */ + backgroundColor?: ColorValue; /** * Either `contain` (the default), `cover`, or `stretch`. Similar to * [react-native 's resizeMode](https://reactnative.dev/docs/image#resizemode) From b000de78d6fcbef38d27a3d7ac64212d0d3f0051 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 10 Sep 2026 08:53:53 +0200 Subject: [PATCH 2/9] fix(review): change background color type; expose proper type for CLI package --- .../src/handlers/compressImage.ts | 5 +- package/native-package/src/native/types.ts | 17 +++- package/native-package/types/index.d.ts | 78 +++++++++++++++++-- 3 files changed, 88 insertions(+), 12 deletions(-) diff --git a/package/native-package/src/handlers/compressImage.ts b/package/native-package/src/handlers/compressImage.ts index 3d3ba0e786..51fef8c53d 100644 --- a/package/native-package/src/handlers/compressImage.ts +++ b/package/native-package/src/handlers/compressImage.ts @@ -1,6 +1,5 @@ -import type { ColorValue } from 'react-native'; - import StreamChatReactNative from '../native'; +import type { BackgroundColor } from '../native/types'; export type CompressImageParams = { /** @@ -15,7 +14,7 @@ export type CompressImageParams = { * * (Default: undefined - no background is painted) */ - backgroundColor?: ColorValue; + backgroundColor?: BackgroundColor; compressImageQuality: number; height: number; uri: string; diff --git a/package/native-package/src/native/types.ts b/package/native-package/src/native/types.ts index 77d7ff147c..c5588094ab 100644 --- a/package/native-package/src/native/types.ts +++ b/package/native-package/src/native/types.ts @@ -1,5 +1,3 @@ -import type { ColorValue } from 'react-native'; - export interface Response { height: number; name: string; @@ -14,6 +12,19 @@ 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. + * + * 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 before it is encoded, flattening any alpha channel @@ -36,7 +47,7 @@ export type Options = { * * (Default: undefined — no background is painted) */ - backgroundColor?: ColorValue; + 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..d42b30430e 100644 --- a/package/native-package/types/index.d.ts +++ b/package/native-package/types/index.d.ts @@ -2,6 +2,59 @@ 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. + * + * 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). + * + * `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. + */ +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 +66,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; +}; From f019188126d79e717283ed1ad74791fd0220114f Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 10 Sep 2026 09:28:11 +0200 Subject: [PATCH 3/9] fix: reword misleading code comment --- .../src/handlers/compressImage.ts | 3 +-- package/native-package/src/native/types.ts | 22 +++++-------------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/package/native-package/src/handlers/compressImage.ts b/package/native-package/src/handlers/compressImage.ts index 51fef8c53d..c4e59a3d6a 100644 --- a/package/native-package/src/handlers/compressImage.ts +++ b/package/native-package/src/handlers/compressImage.ts @@ -6,8 +6,7 @@ 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 without a background - * any transparent area of a PNG or WebP comes out **black**. Pass `'#FFFFFF'` to get the - * white backdrop a browser canvas would give you instead. + * any transparent area of a PNG or WebP depends on platform behavior. Pass a color value to explicitly control background color. * * Only supported by `stream-chat-react-native` (React Native CLI). `stream-chat-expo` has no * equivalent. diff --git a/package/native-package/src/native/types.ts b/package/native-package/src/native/types.ts index c5588094ab..60ae2dd211 100644 --- a/package/native-package/src/native/types.ts +++ b/package/native-package/src/native/types.ts @@ -27,25 +27,15 @@ export type BackgroundColor = string | number | null; export type Options = { /** - * Painted behind the image before it is encoded, flattening any alpha channel - * onto this colour. + * Painted behind the image, flattening any alpha channel onto this colour. * - * Without it, converting an image that has transparency to a format that has - * no alpha channel (`'JPEG'`) turns the transparent areas **black**, because - * the encoder drops the alpha and keeps the underlying RGB. Pass - * `backgroundColor: '#FFFFFF'` to get the white backdrop a browser canvas - * would give you instead. + * This handler always encodes to JPEG, which has no alpha channel, so without a background + * any transparent area of a PNG or WebP depends on platform behavior. Pass a color value to explicitly control background color. * - * Applied whenever it is set, for every output format — asking for `'PNG'` - * output with a `backgroundColor` produces an opaque PNG. When omitted, the - * image is encoded exactly as before. + * Only supported by `stream-chat-react-native` (React Native CLI). `stream-chat-expo` has no + * equivalent. * - * Accepts any colour string or integer that - * [processColor](https://reactnative.dev/docs/colors) understands, e.g. - * `'#FFFFFF'` or `'white'`. `PlatformColor`/`DynamicColorIOS` values are not - * supported, because the colour has to cross the bridge as a plain integer. - * - * (Default: undefined — no background is painted) + * (Default: undefined - no background is painted) */ backgroundColor?: BackgroundColor; /** From 11a9ac19d934f2711be2e0945aed79e448556734 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 10 Sep 2026 10:21:26 +0200 Subject: [PATCH 4/9] fix: more review fixes --- .../native-package/ios/StreamChatReactNative.mm | 4 ++-- .../native/__tests__/createResizedImage.test.ts | 16 +++++++++++++--- package/native-package/src/native/types.ts | 3 +-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/package/native-package/ios/StreamChatReactNative.mm b/package/native-package/ios/StreamChatReactNative.mm index f878a6287d..df91ac26fb 100644 --- a/package/native-package/ios/StreamChatReactNative.mm +++ b/package/native-package/ios/StreamChatReactNative.mm @@ -235,9 +235,9 @@ static float getScaleForProportionalResize(CGSize theSize, CGSize intoSize, bool newSize = CGSizeMake(roundf(imageSize.width * scale), roundf(imageSize.height * scale)); } - // A non-opaque context initialises to transparent black (0,0,0,0). Encoders + // 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 black. When a backdrop is requested we + // 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. diff --git a/package/native-package/src/native/__tests__/createResizedImage.test.ts b/package/native-package/src/native/__tests__/createResizedImage.test.ts index 78d65e2750..f305848fa9 100644 --- a/package/native-package/src/native/__tests__/createResizedImage.test.ts +++ b/package/native-package/src/native/__tests__/createResizedImage.test.ts @@ -71,18 +71,28 @@ describe('native createResizedImage', () => { ); }); - it('accepts a named colour and an integer as well as a hex string', async () => { + 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: 0xff00ff00, + backgroundColor: 0x123456ff, }); expect(nativeCreateResizedImage.mock.calls[0].at(-1)).toBe(processColor('white')); - expect(nativeCreateResizedImage.mock.calls[1].at(-1)).toBe(processColor(0xff00ff00)); + + // Asserted against a literal rather than processColor(): what matters is that the native + // side receives 0xAARRGGBB, and `processColor(x) === processColor(x)` cannot show that. + // `>>> 0` normalises the sign, because processColor returns a signed int32 on Android and + // an unsigned one on iOS. + const forwarded = nativeCreateResizedImage.mock.calls[1].at(-1); + expect(typeof forwarded).toBe('number'); + expect(forwarded >>> 0).toBe(0xff123456); }); it('sends null when no background colour is given, leaving the other arguments untouched', async () => { diff --git a/package/native-package/src/native/types.ts b/package/native-package/src/native/types.ts index 60ae2dd211..c1c356e8c6 100644 --- a/package/native-package/src/native/types.ts +++ b/package/native-package/src/native/types.ts @@ -29,8 +29,7 @@ export type Options = { /** * Painted behind the image, flattening any alpha channel onto this colour. * - * This handler always encodes to JPEG, which has no alpha channel, so without a background - * any transparent area of a PNG or WebP depends on platform behavior. Pass a color value to explicitly control background color. + * 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. * * Only supported by `stream-chat-react-native` (React Native CLI). `stream-chat-expo` has no * equivalent. From 9f1c2695c24c3325332eb4e5f319776be675d0e2 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 10 Sep 2026 11:43:20 +0200 Subject: [PATCH 5/9] fix: avoid unnecessary Android memory increase; remove unnecessary alpha layer on PNG for Android --- .../streamchatreactnative/StreamChatReactNative.java | 11 +++++++++++ .../StreamChatReactNativeModule.java | 11 ++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) 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 dc1bda7b75..302a1a1a8d 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 @@ -6,6 +6,7 @@ import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; +import android.graphics.Color; import android.graphics.Matrix; import androidx.exifinterface.media.ExifInterface; import android.net.Uri; @@ -111,6 +112,16 @@ public static Bitmap flattenOntoBackground(Bitmap source, int color) { canvas.drawColor(color); canvas.drawBitmap(source, 0, 0, null); + // Every pixel is opaque once an opaque colour has been drawn underneath, but the bitmap + // still *declares* an alpha channel, and the PNG and WebP encoders emit one whenever it + // does - roughly a third more bytes for a channel that carries no information. Clearing + // the flag matches iOS, whose opaque graphics context has no alpha channel at all. JPEG is + // unaffected either way, since it cannot store alpha. Guarded on the colour really being + // opaque: for a translucent colour the remaining transparency is real and must be kept. + if (Color.alpha(color) == 255) { + flattened.setHasAlpha(false); + } + return flattened; } 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 31f10a2a09..f993df7e82 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 @@ -78,7 +78,16 @@ private Object createResizedImageWithExceptions(String imagePath, int newWidth, // Flatten any alpha channel onto the requested colour before encoding, so transparent // areas do not come out black in a format that has no alpha channel. - if (backgroundColor != null) { + // + // Only images that actually carry an alpha channel need this. hasAlpha() is a flag lookup + // rather than a pixel scan, and is false for anything decoded from a JPEG, so the common + // case - a camera photo - skips a second full-size bitmap allocation. Skipping is safe + // because it cannot change the output: drawing a fully opaque bitmap over any colour + // reproduces that bitmap exactly. It also removes an OOM risk that mattered: the SDK asks + // for the source's own dimensions, so this decodes at full resolution (~48 MB for a 12 MP + // photo), and an OOM here surfaces as an IOException that compressImage swallows - the + // upload then silently proceeds with the original, uncompressed image. + if (backgroundColor != null && scaledImage.hasAlpha()) { Bitmap flattenedImage = StreamChatReactNative.flattenOntoBackground(scaledImage, backgroundColor); if (flattenedImage == null) { From fa101c25069b52d8c406c392f8e196db2de1f55f Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Mon, 14 Sep 2026 14:23:46 -0500 Subject: [PATCH 6/9] fix: avoid duplicate bitmap on android --- .../StreamChatReactNative.java | 54 +++++++++++++------ .../StreamChatReactNativeModule.java | 27 ++-------- 2 files changed, 40 insertions(+), 41 deletions(-) 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 302a1a1a8d..58c16ac785 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 @@ -8,6 +8,8 @@ import android.graphics.Canvas; import android.graphics.Color; 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; @@ -36,9 +38,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. @@ -75,6 +83,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) { @@ -86,38 +102,40 @@ private static Bitmap resizeImage(Bitmap image, int newWidth, int newHeight, } /** - * Composite the given bitmap onto an opaque background of the given colour, so that any - * alpha channel is flattened rather than dropped. + * 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. * - * Returns null if the intermediate bitmap can't be allocated. The caller owns the result and - * should recycle the source. + * 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. */ - public static Bitmap flattenOntoBackground(Bitmap source, int color) { - if (source == null) { - return null; - } - + private static Bitmap scaleOntoBackground(Bitmap source, int newWidth, int newHeight, int color) { Bitmap flattened; try { - flattened = Bitmap.createBitmap(source.getWidth(), source.getHeight(), Bitmap.Config.ARGB_8888); + 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); - canvas.drawBitmap(source, 0, 0, null); + // 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)); // Every pixel is opaque once an opaque colour has been drawn underneath, but the bitmap // still *declares* an alpha channel, and the PNG and WebP encoders emit one whenever it // does - roughly a third more bytes for a channel that carries no information. Clearing // the flag matches iOS, whose opaque graphics context has no alpha channel at all. JPEG is - // unaffected either way, since it cannot store alpha. Guarded on the colour really being - // opaque: for a translucent colour the remaining transparency is real and must be kept. + // unaffected either way, since it cannot store alpha. The JS wrapper forces the alpha byte + // to 255, so the guard only holds the line for a caller reaching the native module directly. if (Color.alpha(color) == 255) { flattened.setHasAlpha(false); } @@ -432,7 +450,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(); @@ -467,8 +486,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 f993df7e82..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 @@ -69,36 +69,15 @@ private Object createResizedImageWithExceptions(String imagePath, int newWidth, 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."); } - // Flatten any alpha channel onto the requested colour before encoding, so transparent - // areas do not come out black in a format that has no alpha channel. - // - // Only images that actually carry an alpha channel need this. hasAlpha() is a flag lookup - // rather than a pixel scan, and is false for anything decoded from a JPEG, so the common - // case - a camera photo - skips a second full-size bitmap allocation. Skipping is safe - // because it cannot change the output: drawing a fully opaque bitmap over any colour - // reproduces that bitmap exactly. It also removes an OOM risk that mattered: the SDK asks - // for the source's own dimensions, so this decodes at full resolution (~48 MB for a 12 MP - // photo), and an OOM here surfaces as an IOException that compressImage swallows - the - // upload then silently proceeds with the original, uncompressed image. - if (backgroundColor != null && scaledImage.hasAlpha()) { - Bitmap flattenedImage = StreamChatReactNative.flattenOntoBackground(scaledImage, backgroundColor); - - if (flattenedImage == null) { - scaledImage.recycle(); - throw new IOException("Unable to apply the background colour. Most likely due to not enough memory."); - } - - scaledImage.recycle(); - scaledImage = flattenedImage; - } - // Save the resulting image File path = this.getReactApplicationContext().getCacheDir(); if (outputPath != null) { From 7a76e6a923154b2940a0ffddb3a57642d731154f Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Mon, 14 Sep 2026 14:24:58 -0500 Subject: [PATCH 7/9] fix: force opaque color for backgroundColor --- .../src/handlers/compressImage.ts | 2 ++ .../__tests__/createResizedImage.test.ts | 35 +++++++++++++------ package/native-package/src/native/index.tsx | 10 +++++- package/native-package/src/native/types.ts | 6 ++++ package/native-package/types/index.d.ts | 4 +++ 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/package/native-package/src/handlers/compressImage.ts b/package/native-package/src/handlers/compressImage.ts index c4e59a3d6a..2fda8189ce 100644 --- a/package/native-package/src/handlers/compressImage.ts +++ b/package/native-package/src/handlers/compressImage.ts @@ -8,6 +8,8 @@ export type CompressImageParams = { * This handler always encodes to JPEG, which has no alpha channel, so 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. * diff --git a/package/native-package/src/native/__tests__/createResizedImage.test.ts b/package/native-package/src/native/__tests__/createResizedImage.test.ts index f305848fa9..5fefc1a84b 100644 --- a/package/native-package/src/native/__tests__/createResizedImage.test.ts +++ b/package/native-package/src/native/__tests__/createResizedImage.test.ts @@ -1,4 +1,4 @@ -import { NativeModules, processColor } from 'react-native'; +import { NativeModules } from 'react-native'; import type { Options, ResizeFormat } from '../types'; @@ -67,7 +67,7 @@ describe('native createResizedImage', () => { false, 0, null, - processColor('#FFFFFF'), + 0xffffffff, ); }); @@ -84,15 +84,30 @@ describe('native createResizedImage', () => { backgroundColor: 0x123456ff, }); - expect(nativeCreateResizedImage.mock.calls[0].at(-1)).toBe(processColor('white')); + // 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(); - // Asserted against a literal rather than processColor(): what matters is that the native - // side receives 0xAARRGGBB, and `processColor(x) === processColor(x)` cannot show that. - // `>>> 0` normalises the sign, because processColor returns a signed int32 on Android and - // an unsigned one on iOS. - const forwarded = nativeCreateResizedImage.mock.calls[1].at(-1); - expect(typeof forwarded).toBe('number'); - expect(forwarded >>> 0).toBe(0xff123456); + // 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 () => { diff --git a/package/native-package/src/native/index.tsx b/package/native-package/src/native/index.tsx index 103b91307e..95d72fd815 100644 --- a/package/native-package/src/native/index.tsx +++ b/package/native-package/src/native/index.tsx @@ -41,7 +41,15 @@ async function createResizedImage( )}\`. Pass a colour string such as '#FFFFFF' or an integer; PlatformColor and DynamicColorIOS are not supported.`, ); } - processedBackgroundColor = processed; + // 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( diff --git a/package/native-package/src/native/types.ts b/package/native-package/src/native/types.ts index c1c356e8c6..cdc3d509fa 100644 --- a/package/native-package/src/native/types.ts +++ b/package/native-package/src/native/types.ts @@ -18,6 +18,10 @@ export type ResizeMode = 'contain' | 'cover' | 'stretch'; * 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 @@ -31,6 +35,8 @@ export type Options = { * * 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. * diff --git a/package/native-package/types/index.d.ts b/package/native-package/types/index.d.ts index d42b30430e..0c81f223ee 100644 --- a/package/native-package/types/index.d.ts +++ b/package/native-package/types/index.d.ts @@ -10,6 +10,10 @@ type NativeHandlers = Parameters[0]; * 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 From bfef06ea07f62c61a72cb4cd8cb9133de2c9d890 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Mon, 14 Sep 2026 14:38:32 -0500 Subject: [PATCH 8/9] fix: remove unnecessary alpha check from android --- .../streamchatreactnative/StreamChatReactNative.java | 11 ----------- 1 file changed, 11 deletions(-) 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 58c16ac785..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 @@ -6,7 +6,6 @@ import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; -import android.graphics.Color; import android.graphics.Matrix; import android.graphics.Paint; import androidx.annotation.Nullable; @@ -130,16 +129,6 @@ private static Bitmap scaleOntoBackground(Bitmap source, int newWidth, int newHe // the path this replaces, so scaling quality is unchanged. canvas.drawBitmap(source, scale, new Paint(Paint.FILTER_BITMAP_FLAG)); - // Every pixel is opaque once an opaque colour has been drawn underneath, but the bitmap - // still *declares* an alpha channel, and the PNG and WebP encoders emit one whenever it - // does - roughly a third more bytes for a channel that carries no information. Clearing - // the flag matches iOS, whose opaque graphics context has no alpha channel at all. JPEG is - // unaffected either way, since it cannot store alpha. The JS wrapper forces the alpha byte - // to 255, so the guard only holds the line for a caller reaching the native module directly. - if (Color.alpha(color) == 255) { - flattened.setHasAlpha(false); - } - return flattened; } From 704c7c8e5bd8aae57a2c771f386300f15b96ad7d Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Mon, 14 Sep 2026 15:09:01 -0500 Subject: [PATCH 9/9] feat: use white as the default background color --- .../handlers/__tests__/compressImage.test.ts | 27 ++++++++++++++++--- .../src/handlers/compressImage.ts | 25 ++++++++++++----- package/native-package/types/index.d.ts | 7 ++++- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/package/native-package/src/handlers/__tests__/compressImage.test.ts b/package/native-package/src/handlers/__tests__/compressImage.test.ts index 9c4b731f49..449687297c 100644 --- a/package/native-package/src/handlers/__tests__/compressImage.test.ts +++ b/package/native-package/src/handlers/__tests__/compressImage.test.ts @@ -10,7 +10,7 @@ describe('native compressImage', () => { })); return require('../compressImage').compressImage as (params: { - backgroundColor?: string; + backgroundColor?: string | number | null; compressImageQuality: number; height: number; uri: string; @@ -52,7 +52,9 @@ describe('native compressImage', () => { ); }); - it('leaves the colour undefined when none is given, so the native layer receives null', async () => { + 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({ @@ -62,9 +64,26 @@ describe('native compressImage', () => { 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: undefined, mode: 'cover' }); - expect(options.backgroundColor).toBeUndefined(); + expect(options).toEqual({ backgroundColor: null, mode: 'cover' }); + expect(options.backgroundColor).toBeNull(); }); it('still clamps the quality and keeps cover mode', async () => { diff --git a/package/native-package/src/handlers/compressImage.ts b/package/native-package/src/handlers/compressImage.ts index 2fda8189ce..8ae2ba4b20 100644 --- a/package/native-package/src/handlers/compressImage.ts +++ b/package/native-package/src/handlers/compressImage.ts @@ -1,19 +1,31 @@ import StreamChatReactNative from '../native'; import type { BackgroundColor } from '../native/types'; +/** + * 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 without a background - * any transparent area of a PNG or WebP depends on platform behavior. Pass a color value to explicitly control background color. + * 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. + * 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. + * equivalent, so an Expo app keeps the platform default. * - * (Default: undefined - no background is painted) + * (Default: '#FFFFFF') */ backgroundColor?: BackgroundColor; compressImageQuality: number; @@ -23,7 +35,8 @@ export type CompressImageParams = { }; export const compressImage = async ({ - backgroundColor, + // Only substituted for `undefined`, so an explicit `null` still means "paint nothing". + backgroundColor = DEFAULT_BACKGROUND_COLOR, compressImageQuality = 1, height, uri, diff --git a/package/native-package/types/index.d.ts b/package/native-package/types/index.d.ts index 0c81f223ee..0e838ed001 100644 --- a/package/native-package/types/index.d.ts +++ b/package/native-package/types/index.d.ts @@ -47,9 +47,14 @@ type BackgroundColor = string | number | null; * 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. + * 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;