Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions package/expo-package/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -63,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()) {
Expand Down Expand Up @@ -122,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 {
Expand Down
44 changes: 44 additions & 0 deletions package/expo-package/src/handlers/__tests__/compressImage.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
};

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<string, never>),
});

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']);
});
});
23 changes: 16 additions & 7 deletions package/native-package/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') }
Expand Down Expand Up @@ -73,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()) {
Expand Down Expand Up @@ -135,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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Void>(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) {
Expand All @@ -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.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading
Loading