From d2d5fb8f42e81d07ab95b9cb67a140fa7bf36188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20M=C4=99drek?= Date: Tue, 14 Nov 2023 22:26:18 +0100 Subject: [PATCH 1/2] refactor: module android impl from Java to Kotlin - convert files from Java to Kotlin - replace deprecated onCatalystInstanceDestroy with invalidate - replace deprecated AsyncTask classes with Kotlin Coroutines --- android/build.gradle | 14 +- android/gradle.properties | 3 + .../imageeditor/ImageEditorModuleImpl.java | 490 ------------------ .../imageeditor/ImageEditorModuleImpl.kt | 427 +++++++++++++++ .../imageeditor/ImageEditorPackage.java | 65 --- .../imageeditor/ImageEditorPackage.kt | 45 ++ .../imageeditor/ImageEditorModule.java | 36 -- .../imageeditor/ImageEditorModule.kt | 33 ++ .../imageeditor/ImageEditorModule.java | 44 -- .../imageeditor/ImageEditorModule.kt | 36 ++ 10 files changed, 557 insertions(+), 636 deletions(-) delete mode 100644 android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.java create mode 100644 android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.kt delete mode 100644 android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorPackage.java create mode 100644 android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorPackage.kt delete mode 100644 android/src/newarch/com/reactnativecommunity/imageeditor/ImageEditorModule.java create mode 100644 android/src/newarch/com/reactnativecommunity/imageeditor/ImageEditorModule.kt delete mode 100644 android/src/oldarch/com/reactnativecommunity/imageeditor/ImageEditorModule.java create mode 100644 android/src/oldarch/com/reactnativecommunity/imageeditor/ImageEditorModule.kt diff --git a/android/build.gradle b/android/build.gradle index c94841e..259f7e7 100755 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,11 +1,14 @@ buildscript { repositories { - google() mavenCentral() + google() } + def kotlin_version = rootProject.ext.has('kotlinVersion') ? rootProject.ext.get('kotlinVersion') : project.properties['RNImageEditor_kotlinVersion'] + dependencies { classpath "com.android.tools.build:gradle:7.2.1" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -14,6 +17,7 @@ def isNewArchitectureEnabled() { } apply plugin: "com.android.library" +apply plugin: "kotlin-android" if (isNewArchitectureEnabled()) { apply plugin: "com.facebook.react" @@ -70,6 +74,9 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + packagingOptions { + resources.excludes += "DebugProbesKt.bin" + } sourceSets { main { @@ -87,9 +94,14 @@ repositories { google() } +def kotlinx_coroutines_version = getExtOrDefault('kotlinxCoroutinesVersion') +def androidx_exifinterface_version = getExtOrDefault('androidxExifinterfaceVersion') + dependencies { // For < 0.71, this will be from the local maven repo // For > 0.71, this will be replaced by `com.facebook.react:react-android:$version` by react gradle plugin //noinspection GradleDynamicVersion implementation "com.facebook.react:react-native:+" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinx_coroutines_version" + implementation "androidx.exifinterface:exifinterface:$androidx_exifinterface_version" } diff --git a/android/gradle.properties b/android/gradle.properties index b66fc42..252c771 100755 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,6 @@ RNImageEditor_compileSdkVersion=34 RNImageEditor_targetSdkVersion=34 RNImageEditor_minSdkVersion=21 +RNImageEditor_kotlinxCoroutinesVersion=1.7.3 +RNImageEditor_androidxExifinterfaceVersion=1.3.6 +RNImageEditor_kotlinVersion=1.7.22 diff --git a/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.java b/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.java deleted file mode 100644 index 6ea4d32..0000000 --- a/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.java +++ /dev/null @@ -1,490 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ -package com.reactnativecommunity.imageeditor; - -import javax.annotation.Nullable; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.FilenameFilter; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.URL; -import java.net.URLConnection; -import java.util.Arrays; -import java.util.List; - -import android.annotation.SuppressLint; -import android.content.ContentResolver; -import android.content.Context; -import android.database.Cursor; -import android.graphics.Bitmap; -import android.graphics.BitmapRegionDecoder; -import android.graphics.BitmapFactory; -import android.graphics.Matrix; -import android.graphics.Rect; -import android.media.ExifInterface; -import android.net.Uri; -import android.os.AsyncTask; -import android.provider.MediaStore; -import android.text.TextUtils; - -import com.facebook.common.logging.FLog; -import com.facebook.react.bridge.GuardedAsyncTask; -import com.facebook.react.bridge.Promise; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContext; -import com.facebook.react.bridge.ReactContextBaseJavaModule; -import com.facebook.react.bridge.JSApplicationIllegalArgumentException; -import com.facebook.react.bridge.ReadableMap; -import com.facebook.infer.annotation.Assertions; -import com.facebook.react.common.ReactConstants; - -public class ImageEditorModuleImpl { - private ReactApplicationContext reactContext; - - protected static final String NAME = "RNCImageEditor"; - - private static final List LOCAL_URI_PREFIXES = Arrays.asList( - ContentResolver.SCHEME_FILE, - ContentResolver.SCHEME_CONTENT, - ContentResolver.SCHEME_ANDROID_RESOURCE - ); - - private static final String TEMP_FILE_PREFIX = "ReactNative_cropped_image_"; - - /** Compress quality of the output file. */ - private static final int COMPRESS_QUALITY = 90; - - @SuppressLint("InlinedApi") private static final String[] EXIF_ATTRIBUTES = new String[] { - ExifInterface.TAG_APERTURE, - ExifInterface.TAG_DATETIME, - ExifInterface.TAG_DATETIME_DIGITIZED, - ExifInterface.TAG_EXPOSURE_TIME, - ExifInterface.TAG_FLASH, - ExifInterface.TAG_FOCAL_LENGTH, - ExifInterface.TAG_GPS_ALTITUDE, - ExifInterface.TAG_GPS_ALTITUDE_REF, - ExifInterface.TAG_GPS_DATESTAMP, - ExifInterface.TAG_GPS_LATITUDE, - ExifInterface.TAG_GPS_LATITUDE_REF, - ExifInterface.TAG_GPS_LONGITUDE, - ExifInterface.TAG_GPS_LONGITUDE_REF, - ExifInterface.TAG_GPS_PROCESSING_METHOD, - ExifInterface.TAG_GPS_TIMESTAMP, - ExifInterface.TAG_IMAGE_LENGTH, - ExifInterface.TAG_IMAGE_WIDTH, - ExifInterface.TAG_ISO, - ExifInterface.TAG_MAKE, - ExifInterface.TAG_MODEL, - ExifInterface.TAG_ORIENTATION, - ExifInterface.TAG_SUBSEC_TIME, - ExifInterface.TAG_SUBSEC_TIME_DIG, - ExifInterface.TAG_SUBSEC_TIME_ORIG, - ExifInterface.TAG_WHITE_BALANCE - }; - - public ImageEditorModuleImpl(ReactApplicationContext context) { - reactContext = context; - new CleanTask(reactContext).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - public void onCatalystInstanceDestroy() { - new CleanTask(reactContext).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - /** - * Asynchronous task that cleans up cache dirs (internal and, if available, external) of cropped - * image files. This is run when the catalyst instance is being destroyed (i.e. app is shutting - * down) and when the module is instantiated, to handle the case where the app crashed. - */ - private static class CleanTask extends GuardedAsyncTask { - private final Context mContext; - - private CleanTask(ReactContext context) { - super(context); - mContext = context; - } - - @Override - protected void doInBackgroundGuarded(Void... params) { - cleanDirectory(mContext.getCacheDir()); - File externalCacheDir = mContext.getExternalCacheDir(); - if (externalCacheDir != null) { - cleanDirectory(externalCacheDir); - } - } - - private void cleanDirectory(File directory) { - File[] toDelete = directory.listFiles( - new FilenameFilter() { - @Override - public boolean accept(File dir, String filename) { - return filename.startsWith(TEMP_FILE_PREFIX); - } - }); - if (toDelete != null) { - for (File file: toDelete) { - file.delete(); - } - } - } - } - - /** - * Crop an image. If all goes well, the promise will be resolved with the file:// URI of - * the new image as the only argument. This is a temporary file - consider using - * CameraRollManager.saveImageWithTag to save it in the gallery. - * - * @param uri the URI of the image to crop - * @param options crop parameters specified as {@code {offset: {x, y}, size: {width, height}}}. - * Optionally this also contains {@code {targetSize: {width, height}}}. If this is - * specified, the cropped image will be resized to that size. - * All units are in pixels (not DPs). - * @param promise Promise to be resolved when the image has been cropped; the only argument that - * is passed to this is the file:// URI of the new image - */ - public void cropImage( - String uri, - ReadableMap options, - Promise promise) { - ReadableMap offset = options.hasKey("offset") ? options.getMap("offset") : null; - ReadableMap size = options.hasKey("size") ? options.getMap("size") : null; - if (offset == null || size == null || - !offset.hasKey("x") || !offset.hasKey("y") || - !size.hasKey("width") || !size.hasKey("height")) { - throw new JSApplicationIllegalArgumentException("Please specify offset and size"); - } - if (uri == null || uri.isEmpty()) { - throw new JSApplicationIllegalArgumentException("Please specify a URI"); - } - - CropTask cropTask = new CropTask( - reactContext, - uri, - (int) offset.getDouble("x"), - (int) offset.getDouble("y"), - (int) size.getDouble("width"), - (int) size.getDouble("height"), - promise); - if (options.hasKey("displaySize")) { - ReadableMap targetSize = options.getMap("displaySize"); - cropTask.setTargetSize( - (int) targetSize.getDouble("width"), - (int) targetSize.getDouble("height")); - } - cropTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - private static class CropTask extends GuardedAsyncTask { - final Context mContext; - final String mUri; - final int mX; - final int mY; - final int mWidth; - final int mHeight; - int mTargetWidth = 0; - int mTargetHeight = 0; - final Promise mPromise; - - private CropTask( - ReactContext context, - String uri, - int x, - int y, - int width, - int height, - Promise promise) { - super(context); - if (x < 0 || y < 0 || width <= 0 || height <= 0) { - throw new JSApplicationIllegalArgumentException(String.format( - "Invalid crop rectangle: [%d, %d, %d, %d]", x, y, width, height)); - } - mContext = context; - mUri = uri; - mX = x; - mY = y; - mWidth = width; - mHeight = height; - mPromise = promise; - } - - public void setTargetSize(int width, int height) { - if (width <= 0 || height <= 0) { - throw new JSApplicationIllegalArgumentException(String.format( - "Invalid target size: [%d, %d]", width, height)); - } - mTargetWidth = width; - mTargetHeight = height; - } - - private InputStream openBitmapInputStream() throws IOException { - InputStream stream; - if (isLocalUri(mUri)) { - stream = mContext.getContentResolver().openInputStream(Uri.parse(mUri)); - } else { - URLConnection connection = new URL(mUri).openConnection(); - stream = connection.getInputStream(); - } - if (stream == null) { - throw new IOException("Cannot open bitmap: " + mUri); - } - return stream; - } - - @Override - protected void doInBackgroundGuarded(Void... params) { - try { - BitmapFactory.Options outOptions = new BitmapFactory.Options(); - - // If we're downscaling, we can decode the bitmap more efficiently, using less memory - boolean hasTargetSize = (mTargetWidth > 0) && (mTargetHeight > 0); - - Bitmap cropped; - if (hasTargetSize) { - cropped = cropAndResize(mTargetWidth, mTargetHeight, outOptions); - } else { - cropped = crop(outOptions); - } - - String mimeType = outOptions.outMimeType; - if (mimeType == null || mimeType.isEmpty()) { - throw new IOException("Could not determine MIME type"); - } - - File tempFile = createTempFile(mContext, mimeType); - writeCompressedBitmapToFile(cropped, mimeType, tempFile); - - if (mimeType.equals("image/jpeg")) { - copyExif(mContext, Uri.parse(mUri), tempFile); - } - - mPromise.resolve(Uri.fromFile(tempFile).toString()); - } catch (Exception e) { - mPromise.reject(e); - } - } - - /** - * Reads and crops the bitmap. - * @param outOptions Bitmap options, useful to determine {@code outMimeType}. - */ - private Bitmap crop(BitmapFactory.Options outOptions) throws IOException { - InputStream inputStream = openBitmapInputStream(); - // Effeciently crops image without loading full resolution into memory - // https://developer.android.com/reference/android/graphics/BitmapRegionDecoder.html - BitmapRegionDecoder decoder = BitmapRegionDecoder.newInstance(inputStream, false); - try { - Rect rect = new Rect(mX, mY, mX + mWidth, mY + mHeight); - return decoder.decodeRegion(rect, outOptions); - } finally { - if (inputStream != null) { - inputStream.close(); - } - decoder.recycle(); - } - } - - /** - * Crop the rectangle given by {@code mX, mY, mWidth, mHeight} within the source bitmap - * and scale the result to {@code targetWidth, targetHeight}. - * @param outOptions Bitmap options, useful to determine {@code outMimeType}. - */ - private Bitmap cropAndResize( - int targetWidth, - int targetHeight, - BitmapFactory.Options outOptions) - throws IOException { - Assertions.assertNotNull(outOptions); - - // Loading large bitmaps efficiently: - // http://developer.android.com/training/displaying-bitmaps/load-bitmap.html - - // This uses scaling mode COVER - - // Where would the crop rect end up within the scaled bitmap? - float newWidth, newHeight, newX, newY, scale; - float cropRectRatio = mWidth / (float) mHeight; - float targetRatio = targetWidth / (float) targetHeight; - if (cropRectRatio > targetRatio) { - // e.g. source is landscape, target is portrait - newWidth = mHeight * targetRatio; - newHeight = mHeight; - newX = mX + (mWidth - newWidth) / 2; - newY = mY; - scale = targetHeight / (float) mHeight; - } else { - // e.g. source is landscape, target is portrait - newWidth = mWidth; - newHeight = mWidth / targetRatio; - newX = mX; - newY = mY + (mHeight - newHeight) / 2; - scale = targetWidth / (float) mWidth; - } - - // Decode the bitmap. We have to open the stream again, like in the example linked above. - // Is there a way to just continue reading from the stream? - outOptions.inSampleSize = getDecodeSampleSize(mWidth, mHeight, targetWidth, targetHeight); - InputStream inputStream = openBitmapInputStream(); - - Bitmap bitmap; - try { - // This can use significantly less memory than decoding the full-resolution bitmap - bitmap = BitmapFactory.decodeStream(inputStream, null, outOptions); - if (bitmap == null) { - throw new IOException("Cannot decode bitmap: " + mUri); - } - } finally { - if (inputStream != null) { - inputStream.close(); - } - } - - int cropX = Math.round(newX / (float) outOptions.inSampleSize); - int cropY = Math.round(newY / (float) outOptions.inSampleSize); - int cropWidth = Math.round(newWidth / (float) outOptions.inSampleSize); - int cropHeight = Math.round(newHeight / (float) outOptions.inSampleSize); - float cropScale = scale * outOptions.inSampleSize; - - Matrix scaleMatrix = new Matrix(); - scaleMatrix.setScale(cropScale, cropScale); - boolean filter = true; - - return Bitmap.createBitmap(bitmap, cropX, cropY, cropWidth, cropHeight, scaleMatrix, filter); - } - } - - // Utils - - private static void copyExif(Context context, Uri oldImage, File newFile) throws IOException { - File oldFile = getFileFromUri(context, oldImage); - if (oldFile == null) { - FLog.w(ReactConstants.TAG, "Couldn't get real path for uri: " + oldImage); - return; - } - - ExifInterface oldExif = new ExifInterface(oldFile.getAbsolutePath()); - ExifInterface newExif = new ExifInterface(newFile.getAbsolutePath()); - for (String attribute : EXIF_ATTRIBUTES) { - String value = oldExif.getAttribute(attribute); - if (value != null) { - newExif.setAttribute(attribute, value); - } - } - newExif.saveAttributes(); - } - - private static @Nullable File getFileFromUri(Context context, Uri uri) { - if (uri.getScheme().equals("file")) { - return new File(uri.getPath()); - } else if (uri.getScheme().equals("content")) { - Cursor cursor = context.getContentResolver() - .query(uri, new String[] { MediaStore.MediaColumns.DATA }, null, null, null); - if (cursor != null) { - try { - if (cursor.moveToFirst()) { - String path = cursor.getString(0); - if (!TextUtils.isEmpty(path)) { - return new File(path); - } - } - } finally { - cursor.close(); - } - } - } - - return null; - } - - private static boolean isLocalUri(String uri) { - for (String localPrefix : LOCAL_URI_PREFIXES) { - if (uri.startsWith(localPrefix)) { - return true; - } - } - return false; - } - - private static String getFileExtensionForType(@Nullable String mimeType) { - if ("image/png".equals(mimeType)) { - return ".png"; - } - if ("image/webp".equals(mimeType)) { - return ".webp"; - } - return ".jpg"; - } - - private static Bitmap.CompressFormat getCompressFormatForType(String type) { - if ("image/png".equals(type)) { - return Bitmap.CompressFormat.PNG; - } - if ("image/webp".equals(type)) { - return Bitmap.CompressFormat.WEBP; - } - return Bitmap.CompressFormat.JPEG; - } - - private static void writeCompressedBitmapToFile(Bitmap cropped, String mimeType, File tempFile) - throws IOException { - OutputStream out = new FileOutputStream(tempFile); - try { - cropped.compress(getCompressFormatForType(mimeType), COMPRESS_QUALITY, out); - } finally { - if (out != null) { - out.close(); - } - } - } - - /** - * Create a temporary file in the cache directory on either internal or external storage, - * whichever is available and has more free space. - * - * @param mimeType the MIME type of the file to create (image/*) - */ - private static File createTempFile(Context context, @Nullable String mimeType) - throws IOException { - File externalCacheDir = context.getExternalCacheDir(); - File internalCacheDir = context.getCacheDir(); - File cacheDir; - if (externalCacheDir == null && internalCacheDir == null) { - throw new IOException("No cache directory available"); - } - if (externalCacheDir == null) { - cacheDir = internalCacheDir; - } - else if (internalCacheDir == null) { - cacheDir = externalCacheDir; - } else { - cacheDir = externalCacheDir.getFreeSpace() > internalCacheDir.getFreeSpace() ? - externalCacheDir : internalCacheDir; - } - return File.createTempFile(TEMP_FILE_PREFIX, getFileExtensionForType(mimeType), cacheDir); - } - - /** - * When scaling down the bitmap, decode only every n-th pixel in each dimension. - * Calculate the largest {@code inSampleSize} value that is a power of 2 and keeps both - * {@code width, height} larger or equal to {@code targetWidth, targetHeight}. - * This can significantly reduce memory usage. - */ - private static int getDecodeSampleSize(int width, int height, int targetWidth, int targetHeight) { - int inSampleSize = 1; - if (height > targetHeight || width > targetWidth) { - int halfHeight = height / 2; - int halfWidth = width / 2; - while ((halfWidth / inSampleSize) >= targetWidth - && (halfHeight / inSampleSize) >= targetHeight) { - inSampleSize *= 2; - } - } - return inSampleSize; - } -} diff --git a/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.kt b/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.kt new file mode 100644 index 0000000..1de4253 --- /dev/null +++ b/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.kt @@ -0,0 +1,427 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +package com.reactnativecommunity.imageeditor + +import android.annotation.SuppressLint +import android.content.ContentResolver +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat +import android.graphics.BitmapFactory +import android.graphics.BitmapRegionDecoder +import android.graphics.Matrix +import android.graphics.Rect +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.text.TextUtils +import androidx.exifinterface.media.ExifInterface +import com.facebook.common.logging.FLog +import com.facebook.infer.annotation.Assertions +import com.facebook.react.bridge.JSApplicationIllegalArgumentException +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.common.ReactConstants +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.net.URL +import kotlin.math.roundToInt + +class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) { + private val moduleCoroutineScope = CoroutineScope(Dispatchers.Default) + + init { + cleanTask() + } + + fun invalidate() { + if (moduleCoroutineScope.isActive) { + moduleCoroutineScope.cancel() + } + cleanTask() + } + + /** + * Asynchronous task that cleans up cache dirs (internal and, if available, external) of cropped + * image files. This is run when the module is invalidated (i.e. app is shutting + * down) and when the module is instantiated, to handle the case where the app crashed. + */ + private fun cleanTask() { + moduleCoroutineScope.launch { + cleanDirectory(reactContext.cacheDir) + val externalCacheDir = reactContext.externalCacheDir + externalCacheDir?.let { cleanDirectory(it) } + } + } + + private fun cleanDirectory(directory: File) { + val toDelete = directory.listFiles { _, filename -> + filename.startsWith(TEMP_FILE_PREFIX) + } + if (toDelete != null) { + for (file in toDelete) { + file.delete() + } + } + } + + /** + * Crop an image. If all goes well, the promise will be resolved with the file:// URI of + * the new image as the only argument. This is a temporary file - consider using + * CameraRollManager.saveImageWithTag to save it in the gallery. + * + * @param uri the URI of the image to crop + * @param options crop parameters specified as `{offset: {x, y}, size: {width, height}}`. + * Optionally this also contains `{targetSize: {width, height}}`. If this is + * specified, the cropped image will be resized to that size. + * All units are in pixels (not DPs). + * @param promise Promise to be resolved when the image has been cropped; the only argument that + * is passed to this is the file:// URI of the new image + */ + fun cropImage( + uri: String?, + options: ReadableMap, + promise: Promise + ) { + val offset = if (options.hasKey("offset")) options.getMap("offset") else null + val size = if (options.hasKey("size")) options.getMap("size") else null + if (offset == null || size == null || + !offset.hasKey("x") || !offset.hasKey("y") || + !size.hasKey("width") || !size.hasKey("height") + ) { + throw JSApplicationIllegalArgumentException("Please specify offset and size") + } + if (uri.isNullOrEmpty()) { + throw JSApplicationIllegalArgumentException("Please specify a URI") + } + val x = offset.getDouble("x").toInt() + val y = offset.getDouble("y").toInt() + val width = size.getDouble("width").toInt() + val height = size.getDouble("height").toInt() + val (targetWidth, targetHeight) = if (options.hasKey("displaySize")) { + val targetSize = options.getMap("displaySize")!! + Pair(targetSize.getDouble("width").toInt(), targetSize.getDouble("height").toInt()) + } else Pair(0, 0) + + moduleCoroutineScope.launch { + try { + val outOptions = BitmapFactory.Options() + + // If we're downscaling, we can decode the bitmap more efficiently, using less memory + val hasTargetSize = targetWidth > 0 && targetHeight > 0 + val cropped: Bitmap? = if (hasTargetSize) { + cropAndResizeTask(outOptions, uri, x, y, width, height, targetWidth, targetHeight) + } else { + cropTask(outOptions, uri, x, y, width, height) + } + if (cropped == null) { + throw IOException("Cannot decode bitmap: $uri") + } + val mimeType = outOptions.outMimeType + if (mimeType.isNullOrEmpty()) { + throw IOException("Could not determine MIME type") + } + + val tempFile = createTempFile(reactContext, mimeType) + writeCompressedBitmapToFile(cropped, mimeType, tempFile) + if (mimeType == "image/jpeg") { + copyExif(reactContext, Uri.parse(uri), tempFile) + } + promise.resolve(Uri.fromFile(tempFile).toString()) + } catch (e: Exception) { + promise.reject(e) + } + } + } + + /** + * Reads and crops the bitmap. + * @param outOptions Bitmap options, useful to determine `outMimeType`. + * @param uri the URI of the image to crop + * @param x left coordinate of the cropped image + * @param y top coordinate of the cropped image + * @param width width of the cropped image + * @param height height of the cropped image + */ + private fun cropTask( + outOptions: BitmapFactory.Options, + uri: String, + x: Int, + y: Int, + width: Int, + height: Int + ): Bitmap? { + return openBitmapInputStream(uri)?.use { + // Efficiently crops image without loading full resolution into memory + // https://developer.android.com/reference/android/graphics/BitmapRegionDecoder.html + val decoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + BitmapRegionDecoder.newInstance(it) + } else { + @Suppress("DEPRECATION") + BitmapRegionDecoder.newInstance(it, false) + } + return@use try { + val rect = Rect(x, y, x + width, y + height) + decoder!!.decodeRegion(rect, outOptions) + } finally { + decoder!!.recycle() + } + } + } + + /** + * Crop the rectangle given by `mX, mY, mWidth, mHeight` within the source bitmap + * and scale the result to `targetWidth, targetHeight`. + * @param outOptions Bitmap options, useful to determine `outMimeType`. + * @param uri the URI of the image to crop + * @param x left coordinate of the cropped image + * @param y top coordinate of the cropped image + * @param width width of the cropped image + * @param height height of the cropped image + * @param targetWidth width of the resized image + * @param targetHeight height of the resized image + */ + private fun cropAndResizeTask( + outOptions: BitmapFactory.Options, + uri: String, + x: Int, + y: Int, + width: Int, + height: Int, + targetWidth: Int, + targetHeight: Int, + ): Bitmap? { + Assertions.assertNotNull(outOptions) + + // Loading large bitmaps efficiently: + // http://developer.android.com/training/displaying-bitmaps/load-bitmap.html + + // This uses scaling mode COVER + + // Where would the crop rect end up within the scaled bitmap? + val cropRectRatio = width / height.toFloat() + val targetRatio = targetWidth / targetHeight.toFloat() + val isCropRatioLargerThanTargetRatio = cropRectRatio > targetRatio + val newWidth = if (isCropRatioLargerThanTargetRatio) height * targetRatio else width.toFloat() + val newHeight = if (isCropRatioLargerThanTargetRatio) height.toFloat() else width / targetRatio + val newX = if (isCropRatioLargerThanTargetRatio) x + (width - newWidth) / 2 else x.toFloat() + val newY = if (isCropRatioLargerThanTargetRatio) y.toFloat() else y + (height - newHeight) / 2 + val scale = if (isCropRatioLargerThanTargetRatio) targetHeight / height.toFloat() else targetWidth / width.toFloat() + + // Decode the bitmap. We have to open the stream again, like in the example linked above. + // Is there a way to just continue reading from the stream? + outOptions.inSampleSize = + getDecodeSampleSize(width, height, targetWidth, targetHeight) + val bitmap = openBitmapInputStream(uri)?.use { + // This can use significantly less memory than decoding the full-resolution bitmap + BitmapFactory.decodeStream(it, null, outOptions) + } ?: return null + + val cropX = (newX / outOptions.inSampleSize.toFloat()).roundToInt() + val cropY = (newY / outOptions.inSampleSize.toFloat()).roundToInt() + val cropWidth = (newWidth / outOptions.inSampleSize.toFloat()).roundToInt() + val cropHeight = (newHeight / outOptions.inSampleSize.toFloat()).roundToInt() + val cropScale = scale * outOptions.inSampleSize + val scaleMatrix = Matrix().apply { + setScale(cropScale, cropScale) + } + val filter = true + + return Bitmap.createBitmap( + bitmap, + cropX, + cropY, + cropWidth, + cropHeight, + scaleMatrix, + filter + ) + } + + private fun openBitmapInputStream(uri: String): InputStream? { + return if (isLocalUri(uri)) { + reactContext.contentResolver.openInputStream(Uri.parse(uri)) + } else { + val connection = URL(uri).openConnection() + connection.getInputStream() + } + } + + companion object { + const val NAME = "RNCImageEditor" + private val LOCAL_URI_PREFIXES = listOf( + ContentResolver.SCHEME_FILE, + ContentResolver.SCHEME_CONTENT, + ContentResolver.SCHEME_ANDROID_RESOURCE + ) + private const val TEMP_FILE_PREFIX = "ReactNative_cropped_image_" + + /** Compress quality of the output file. */ + private const val COMPRESS_QUALITY = 90 + + @SuppressLint("InlinedApi") + private val EXIF_ATTRIBUTES = arrayOf( + ExifInterface.TAG_DATETIME, + ExifInterface.TAG_DATETIME_DIGITIZED, + ExifInterface.TAG_EXPOSURE_TIME, + ExifInterface.TAG_FLASH, + ExifInterface.TAG_FOCAL_LENGTH, + ExifInterface.TAG_GPS_ALTITUDE, + ExifInterface.TAG_GPS_ALTITUDE_REF, + ExifInterface.TAG_GPS_DATESTAMP, + ExifInterface.TAG_GPS_LATITUDE, + ExifInterface.TAG_GPS_LATITUDE_REF, + ExifInterface.TAG_GPS_LONGITUDE, + ExifInterface.TAG_GPS_LONGITUDE_REF, + ExifInterface.TAG_GPS_PROCESSING_METHOD, + ExifInterface.TAG_GPS_TIMESTAMP, + ExifInterface.TAG_IMAGE_LENGTH, + ExifInterface.TAG_IMAGE_WIDTH, + ExifInterface.TAG_MAKE, + ExifInterface.TAG_MODEL, + ExifInterface.TAG_ORIENTATION, + ExifInterface.TAG_SUBSEC_TIME, + ExifInterface.TAG_WHITE_BALANCE + ) + + // Utils + @Throws(IOException::class) + private fun copyExif(context: Context, oldImage: Uri, newFile: File) { + val oldFile = getFileFromUri(context, oldImage) + if (oldFile == null) { + FLog.w(ReactConstants.TAG, "Couldn't get real path for uri: $oldImage") + return + } + val oldExif = ExifInterface(oldFile.absolutePath) + val newExif = ExifInterface(newFile.absolutePath) + for (attribute in EXIF_ATTRIBUTES) { + val value = oldExif.getAttribute(attribute) + if (value != null) { + newExif.setAttribute(attribute, value) + } + } + newExif.saveAttributes() + } + + private fun getFileFromUri(context: Context, uri: Uri): File? { + if (uri.scheme == "file") { + return uri.path?.let { File(it) } + } + if (uri.scheme == "content") { + context.contentResolver + .query(uri, arrayOf(MediaStore.MediaColumns.DATA), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val path = cursor.getString(0) + if (!TextUtils.isEmpty(path)) { + return File(path) + } + } + } + } + return null + } + + private fun isLocalUri(uri: String): Boolean { + for (localPrefix in LOCAL_URI_PREFIXES) { + if (uri.startsWith(localPrefix)) { + return true + } + } + return false + } + + private fun getFileExtensionForType(mimeType: String?): String { + return when (mimeType) { + "image/png" -> ".png" + "image/webp" -> ".webp" + else -> ".jpg" + } + } + + private fun getCompressFormatForType(mimeType: String): CompressFormat { + val webpCompressFormat = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + CompressFormat.WEBP_LOSSY + } + else { + @Suppress("DEPRECATION") + CompressFormat.WEBP + } + return when (mimeType) { + "image/png" -> CompressFormat.PNG + "image/webp" -> webpCompressFormat + else -> CompressFormat.JPEG + } + } + + @Throws(IOException::class) + private fun writeCompressedBitmapToFile(cropped: Bitmap, mimeType: String, tempFile: File) { + FileOutputStream(tempFile).use { + cropped.compress(getCompressFormatForType(mimeType), COMPRESS_QUALITY, it) + } + } + + /** + * Create a temporary file in the cache directory on either internal or external storage, + * whichever is available and has more free space. + * + * @param mimeType the MIME type of the file to create (image/ *) + */ + @Throws(IOException::class) + private fun createTempFile(context: Context, mimeType: String?): File { + val externalCacheDir = context.externalCacheDir + val internalCacheDir = context.cacheDir + if (externalCacheDir == null && internalCacheDir == null) { + throw IOException("No cache directory available") + } + val cacheDir: File? = if (externalCacheDir == null) { + internalCacheDir + } else if (internalCacheDir == null) { + externalCacheDir + } else { + if (externalCacheDir.freeSpace > internalCacheDir.freeSpace) externalCacheDir else internalCacheDir + } + return File.createTempFile( + TEMP_FILE_PREFIX, + getFileExtensionForType(mimeType), + cacheDir + ) + } + + /** + * When scaling down the bitmap, decode only every n-th pixel in each dimension. + * Calculate the largest `inSampleSize` value that is a power of 2 and keeps both + * `width, height` larger or equal to `targetWidth, targetHeight`. + * This can significantly reduce memory usage. + */ + private fun getDecodeSampleSize( + width: Int, + height: Int, + targetWidth: Int, + targetHeight: Int + ): Int { + var inSampleSize = 1 + if (height > targetHeight || width > targetWidth) { + val halfHeight = height / 2 + val halfWidth = width / 2 + while (halfWidth / inSampleSize >= targetWidth + && halfHeight / inSampleSize >= targetHeight + ) { + inSampleSize *= 2 + } + } + return inSampleSize + } + } +} diff --git a/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorPackage.java b/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorPackage.java deleted file mode 100644 index 334447e..0000000 --- a/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorPackage.java +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -package com.reactnativecommunity.imageeditor; - -import androidx.annotation.Nullable; - -import com.facebook.react.TurboReactPackage; -import com.facebook.react.bridge.NativeModule; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.module.annotations.ReactModule; -import com.facebook.react.module.model.ReactModuleInfo; -import com.facebook.react.module.model.ReactModuleInfoProvider; -import com.facebook.react.turbomodule.core.interfaces.TurboModule; - -import java.util.HashMap; -import java.util.Map; - -public class ImageEditorPackage extends TurboReactPackage { - - @Nullable - @Override - public NativeModule getModule(String name, ReactApplicationContext reactContext) { - if (name.equals(ImageEditorModule.NAME)) { - return new ImageEditorModule(reactContext); - } else { - return null; - } - } - - @Override - public ReactModuleInfoProvider getReactModuleInfoProvider() { - Class[] moduleList = new Class[] { - ImageEditorModule.class - }; - final Map reactModuleInfoMap = new HashMap<>(); - - for (Class moduleClass : moduleList) { - ReactModule reactModule = moduleClass.getAnnotation(ReactModule.class); - reactModuleInfoMap.put( - reactModule.name(), - new ReactModuleInfo( - reactModule.name(), - moduleClass.getName(), - true, - reactModule.needsEagerInit(), - reactModule.hasConstants(), - reactModule.isCxxModule(), - TurboModule.class.isAssignableFrom(moduleClass) - ) - ); - } - - return new ReactModuleInfoProvider() { - @Override - public Map getReactModuleInfos() { - return reactModuleInfoMap; - } - }; - } -} diff --git a/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorPackage.kt b/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorPackage.kt new file mode 100644 index 0000000..19019e2 --- /dev/null +++ b/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorPackage.kt @@ -0,0 +1,45 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +package com.reactnativecommunity.imageeditor + +import com.facebook.react.TurboReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider +import com.facebook.react.turbomodule.core.interfaces.TurboModule + +class ImageEditorPackage : TurboReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return if (name == ImageEditorModule.NAME) { + ImageEditorModule(reactContext) + } else { + null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + val moduleList: Array> = arrayOf( + ImageEditorModule::class.java + ) + val reactModuleInfoMap: MutableMap = HashMap() + for (moduleClass in moduleList) { + val reactModule = moduleClass.getAnnotation(ReactModule::class.java) ?: continue + reactModuleInfoMap[reactModule.name] = ReactModuleInfo( + reactModule.name, + moduleClass.name, + true, + reactModule.needsEagerInit, + reactModule.hasConstants, + reactModule.isCxxModule, + TurboModule::class.java.isAssignableFrom(moduleClass) + ) + } + return ReactModuleInfoProvider { reactModuleInfoMap } + } +} diff --git a/android/src/newarch/com/reactnativecommunity/imageeditor/ImageEditorModule.java b/android/src/newarch/com/reactnativecommunity/imageeditor/ImageEditorModule.java deleted file mode 100644 index 5fc0fe6..0000000 --- a/android/src/newarch/com/reactnativecommunity/imageeditor/ImageEditorModule.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.reactnativecommunity.imageeditor; - -import androidx.annotation.NonNull; - -import com.facebook.react.bridge.Promise; -import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.module.annotations.ReactModule; - -@ReactModule(name = ImageEditorModule.NAME) -public class ImageEditorModule extends NativeRNCImageEditorSpec { - private ImageEditorModuleImpl moduleImpl; - - ImageEditorModule(ReactApplicationContext context) { - super(context); - moduleImpl = new ImageEditorModuleImpl(context); - } - - public static final String NAME = ImageEditorModuleImpl.NAME; - - @Override - @NonNull - public String getName() { - return ImageEditorModuleImpl.NAME; - } - - @Override - public void onCatalystInstanceDestroy() { - moduleImpl.onCatalystInstanceDestroy(); - } - - @Override - public void cropImage(String uri, ReadableMap cropData, Promise promise) { - moduleImpl.cropImage(uri, cropData, promise); - } -} diff --git a/android/src/newarch/com/reactnativecommunity/imageeditor/ImageEditorModule.kt b/android/src/newarch/com/reactnativecommunity/imageeditor/ImageEditorModule.kt new file mode 100644 index 0000000..e42d86b --- /dev/null +++ b/android/src/newarch/com/reactnativecommunity/imageeditor/ImageEditorModule.kt @@ -0,0 +1,33 @@ +package com.reactnativecommunity.imageeditor + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.module.annotations.ReactModule + +@ReactModule(name = ImageEditorModule.NAME) +class ImageEditorModule(reactContext: ReactApplicationContext) : + NativeRNCImageEditorSpec(reactContext) { + private val moduleImpl: ImageEditorModuleImpl + + init { + moduleImpl = ImageEditorModuleImpl(reactContext) + } + + override fun getName(): String { + return ImageEditorModuleImpl.NAME + } + + override fun invalidate() { + moduleImpl.invalidate() + super.invalidate() + } + + override fun cropImage(uri: String, cropData: ReadableMap, promise: Promise) { + moduleImpl.cropImage(uri, cropData, promise) + } + + companion object { + const val NAME = ImageEditorModuleImpl.NAME + } +} diff --git a/android/src/oldarch/com/reactnativecommunity/imageeditor/ImageEditorModule.java b/android/src/oldarch/com/reactnativecommunity/imageeditor/ImageEditorModule.java deleted file mode 100644 index fbe518f..0000000 --- a/android/src/oldarch/com/reactnativecommunity/imageeditor/ImageEditorModule.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.reactnativecommunity.imageeditor; - -import java.util.Collections; -import java.util.Map; - -import com.facebook.react.bridge.Promise; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContextBaseJavaModule; -import com.facebook.react.bridge.ReactMethod; -import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.module.annotations.ReactModule; - -@ReactModule(name = ImageEditorModule.NAME) -public class ImageEditorModule extends ReactContextBaseJavaModule { - private ImageEditorModuleImpl moduleImpl; - - public ImageEditorModule(ReactApplicationContext reactContext) { - super(reactContext); - moduleImpl = new ImageEditorModuleImpl(reactContext); - } - - public static final String NAME = ImageEditorModuleImpl.NAME; - - @Override - public String getName() { - return ImageEditorModuleImpl.NAME; - } - - - @Override - public Map getConstants() { - return Collections.emptyMap(); - } - - @Override - public void onCatalystInstanceDestroy() { - moduleImpl.onCatalystInstanceDestroy(); - } - - @ReactMethod - public void cropImage(String uri, ReadableMap options, Promise promise) { - moduleImpl.cropImage(uri, options, promise); - } -} diff --git a/android/src/oldarch/com/reactnativecommunity/imageeditor/ImageEditorModule.kt b/android/src/oldarch/com/reactnativecommunity/imageeditor/ImageEditorModule.kt new file mode 100644 index 0000000..e27e4d8 --- /dev/null +++ b/android/src/oldarch/com/reactnativecommunity/imageeditor/ImageEditorModule.kt @@ -0,0 +1,36 @@ +package com.reactnativecommunity.imageeditor + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.module.annotations.ReactModule + +@ReactModule(name = ImageEditorModule.NAME) +class ImageEditorModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + private val moduleImpl: ImageEditorModuleImpl + + init { + moduleImpl = ImageEditorModuleImpl(reactContext) + } + + override fun getName(): String { + return ImageEditorModuleImpl.NAME + } + + override fun invalidate() { + moduleImpl.invalidate() + super.invalidate() + } + + @ReactMethod + fun cropImage(uri: String, options: ReadableMap, promise: Promise) { + moduleImpl.cropImage(uri, options, promise) + } + + companion object { + const val NAME = ImageEditorModuleImpl.NAME + } +} From 401a056b9c3e3f2f09586624971b6771562a79c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20M=C4=99drek?= Date: Tue, 14 Nov 2023 23:10:35 +0100 Subject: [PATCH 2/2] chore: lint Android code with spotless & ktfmt - add spotless & ktfmt - format existing android code - add lint & format scripts in package.json - add lint step in code-quality job --- .github/workflows/main.yml | 3 + android/build.gradle | 6 + android/gradle.properties | 1 + android/gradlew | 0 android/gradlew.bat | 168 ++--- android/spotless.gradle | 16 + .../imageeditor/ImageEditorModuleImpl.kt | 711 +++++++++--------- .../imageeditor/ImageEditorPackage.kt | 27 +- package.json | 2 + 9 files changed, 486 insertions(+), 448 deletions(-) mode change 100644 => 100755 android/gradlew mode change 100644 => 100755 android/gradlew.bat create mode 100644 android/spotless.gradle diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 29de6f3..ad7e09c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -42,6 +42,9 @@ jobs: - name: Lint files run: yarn lint + - name: Lint Android files + run: yarn lint:android + - name: Types check run: yarn ts diff --git a/android/build.gradle b/android/build.gradle index 259f7e7..eca5f81 100755 --- a/android/build.gradle +++ b/android/build.gradle @@ -5,10 +5,12 @@ buildscript { } def kotlin_version = rootProject.ext.has('kotlinVersion') ? rootProject.ext.get('kotlinVersion') : project.properties['RNImageEditor_kotlinVersion'] + def spotless_version = project.properties['RNImageEditor_spotlessVersion'] dependencies { classpath "com.android.tools.build:gradle:7.2.1" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "com.diffplug.spotless:spotless-plugin-gradle:$spotless_version" } } @@ -16,6 +18,10 @@ def isNewArchitectureEnabled() { return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true" } +if (project == rootProject) { + apply from: 'spotless.gradle' +} + apply plugin: "com.android.library" apply plugin: "kotlin-android" diff --git a/android/gradle.properties b/android/gradle.properties index 252c771..2cc3686 100755 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -4,3 +4,4 @@ RNImageEditor_minSdkVersion=21 RNImageEditor_kotlinxCoroutinesVersion=1.7.3 RNImageEditor_androidxExifinterfaceVersion=1.3.6 RNImageEditor_kotlinVersion=1.7.22 +RNImageEditor_spotlessVersion=6.22.0 diff --git a/android/gradlew b/android/gradlew old mode 100644 new mode 100755 diff --git a/android/gradlew.bat b/android/gradlew.bat old mode 100644 new mode 100755 index e95643d..f955316 --- a/android/gradlew.bat +++ b/android/gradlew.bat @@ -1,84 +1,84 @@ -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/spotless.gradle b/android/spotless.gradle new file mode 100644 index 0000000..3d03cd9 --- /dev/null +++ b/android/spotless.gradle @@ -0,0 +1,16 @@ +// formatter & linter configuration for kotlin +apply plugin: 'com.diffplug.spotless' + +spotless { + java { + target 'src/*/java/**/*.java' + googleJavaFormat() + } + kotlin { + target 'src/**/*.kt' + ktfmt('0.46').kotlinlangStyle() + trimTrailingWhitespace() + indentWithSpaces() + endWithNewline() + } +} diff --git a/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.kt b/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.kt index 1de4253..f49b5ac 100644 --- a/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.kt +++ b/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.kt @@ -1,8 +1,8 @@ /** * Copyright (c) Facebook, Inc. and its affiliates. * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. + * This source code is licensed under the MIT license found in the LICENSE file in the root + * directory of this source tree. */ package com.reactnativecommunity.imageeditor @@ -27,401 +27,412 @@ import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap import com.facebook.react.common.ReactConstants -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import java.io.File import java.io.FileOutputStream import java.io.IOException import java.io.InputStream import java.net.URL import kotlin.math.roundToInt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) { - private val moduleCoroutineScope = CoroutineScope(Dispatchers.Default) - - init { - cleanTask() - } + private val moduleCoroutineScope = CoroutineScope(Dispatchers.Default) - fun invalidate() { - if (moduleCoroutineScope.isActive) { - moduleCoroutineScope.cancel() + init { + cleanTask() } - cleanTask() - } - /** - * Asynchronous task that cleans up cache dirs (internal and, if available, external) of cropped - * image files. This is run when the module is invalidated (i.e. app is shutting - * down) and when the module is instantiated, to handle the case where the app crashed. - */ - private fun cleanTask() { - moduleCoroutineScope.launch { - cleanDirectory(reactContext.cacheDir) - val externalCacheDir = reactContext.externalCacheDir - externalCacheDir?.let { cleanDirectory(it) } + fun invalidate() { + if (moduleCoroutineScope.isActive) { + moduleCoroutineScope.cancel() + } + cleanTask() } - } - private fun cleanDirectory(directory: File) { - val toDelete = directory.listFiles { _, filename -> - filename.startsWith(TEMP_FILE_PREFIX) - } - if (toDelete != null) { - for (file in toDelete) { - file.delete() - } + /** + * Asynchronous task that cleans up cache dirs (internal and, if available, external) of cropped + * image files. This is run when the module is invalidated (i.e. app is shutting down) and when + * the module is instantiated, to handle the case where the app crashed. + */ + private fun cleanTask() { + moduleCoroutineScope.launch { + cleanDirectory(reactContext.cacheDir) + val externalCacheDir = reactContext.externalCacheDir + externalCacheDir?.let { cleanDirectory(it) } + } } - } - /** - * Crop an image. If all goes well, the promise will be resolved with the file:// URI of - * the new image as the only argument. This is a temporary file - consider using - * CameraRollManager.saveImageWithTag to save it in the gallery. - * - * @param uri the URI of the image to crop - * @param options crop parameters specified as `{offset: {x, y}, size: {width, height}}`. - * Optionally this also contains `{targetSize: {width, height}}`. If this is - * specified, the cropped image will be resized to that size. - * All units are in pixels (not DPs). - * @param promise Promise to be resolved when the image has been cropped; the only argument that - * is passed to this is the file:// URI of the new image - */ - fun cropImage( - uri: String?, - options: ReadableMap, - promise: Promise - ) { - val offset = if (options.hasKey("offset")) options.getMap("offset") else null - val size = if (options.hasKey("size")) options.getMap("size") else null - if (offset == null || size == null || - !offset.hasKey("x") || !offset.hasKey("y") || - !size.hasKey("width") || !size.hasKey("height") - ) { - throw JSApplicationIllegalArgumentException("Please specify offset and size") - } - if (uri.isNullOrEmpty()) { - throw JSApplicationIllegalArgumentException("Please specify a URI") + private fun cleanDirectory(directory: File) { + val toDelete = directory.listFiles { _, filename -> filename.startsWith(TEMP_FILE_PREFIX) } + if (toDelete != null) { + for (file in toDelete) { + file.delete() + } + } } - val x = offset.getDouble("x").toInt() - val y = offset.getDouble("y").toInt() - val width = size.getDouble("width").toInt() - val height = size.getDouble("height").toInt() - val (targetWidth, targetHeight) = if (options.hasKey("displaySize")) { - val targetSize = options.getMap("displaySize")!! - Pair(targetSize.getDouble("width").toInt(), targetSize.getDouble("height").toInt()) - } else Pair(0, 0) - - moduleCoroutineScope.launch { - try { - val outOptions = BitmapFactory.Options() - // If we're downscaling, we can decode the bitmap more efficiently, using less memory - val hasTargetSize = targetWidth > 0 && targetHeight > 0 - val cropped: Bitmap? = if (hasTargetSize) { - cropAndResizeTask(outOptions, uri, x, y, width, height, targetWidth, targetHeight) - } else { - cropTask(outOptions, uri, x, y, width, height) - } - if (cropped == null) { - throw IOException("Cannot decode bitmap: $uri") + /** + * Crop an image. If all goes well, the promise will be resolved with the file:// URI of the new + * image as the only argument. This is a temporary file - consider using + * CameraRollManager.saveImageWithTag to save it in the gallery. + * + * @param uri the URI of the image to crop + * @param options crop parameters specified as `{offset: {x, y}, size: {width, height}}`. + * Optionally this also contains `{targetSize: {width, height}}`. If this is specified, the + * cropped image will be resized to that size. All units are in pixels (not DPs). + * @param promise Promise to be resolved when the image has been cropped; the only argument that + * is passed to this is the file:// URI of the new image + */ + fun cropImage(uri: String?, options: ReadableMap, promise: Promise) { + val offset = if (options.hasKey("offset")) options.getMap("offset") else null + val size = if (options.hasKey("size")) options.getMap("size") else null + if ( + offset == null || + size == null || + !offset.hasKey("x") || + !offset.hasKey("y") || + !size.hasKey("width") || + !size.hasKey("height") + ) { + throw JSApplicationIllegalArgumentException("Please specify offset and size") } - val mimeType = outOptions.outMimeType - if (mimeType.isNullOrEmpty()) { - throw IOException("Could not determine MIME type") + if (uri.isNullOrEmpty()) { + throw JSApplicationIllegalArgumentException("Please specify a URI") } + val x = offset.getDouble("x").toInt() + val y = offset.getDouble("y").toInt() + val width = size.getDouble("width").toInt() + val height = size.getDouble("height").toInt() + val (targetWidth, targetHeight) = + if (options.hasKey("displaySize")) { + val targetSize = options.getMap("displaySize")!! + Pair(targetSize.getDouble("width").toInt(), targetSize.getDouble("height").toInt()) + } else Pair(0, 0) - val tempFile = createTempFile(reactContext, mimeType) - writeCompressedBitmapToFile(cropped, mimeType, tempFile) - if (mimeType == "image/jpeg") { - copyExif(reactContext, Uri.parse(uri), tempFile) + moduleCoroutineScope.launch { + try { + val outOptions = BitmapFactory.Options() + + // If we're downscaling, we can decode the bitmap more efficiently, using less + // memory + val hasTargetSize = targetWidth > 0 && targetHeight > 0 + val cropped: Bitmap? = + if (hasTargetSize) { + cropAndResizeTask( + outOptions, + uri, + x, + y, + width, + height, + targetWidth, + targetHeight + ) + } else { + cropTask(outOptions, uri, x, y, width, height) + } + if (cropped == null) { + throw IOException("Cannot decode bitmap: $uri") + } + val mimeType = outOptions.outMimeType + if (mimeType.isNullOrEmpty()) { + throw IOException("Could not determine MIME type") + } + + val tempFile = createTempFile(reactContext, mimeType) + writeCompressedBitmapToFile(cropped, mimeType, tempFile) + if (mimeType == "image/jpeg") { + copyExif(reactContext, Uri.parse(uri), tempFile) + } + promise.resolve(Uri.fromFile(tempFile).toString()) + } catch (e: Exception) { + promise.reject(e) + } } - promise.resolve(Uri.fromFile(tempFile).toString()) - } catch (e: Exception) { - promise.reject(e) - } } - } - /** - * Reads and crops the bitmap. - * @param outOptions Bitmap options, useful to determine `outMimeType`. - * @param uri the URI of the image to crop - * @param x left coordinate of the cropped image - * @param y top coordinate of the cropped image - * @param width width of the cropped image - * @param height height of the cropped image - */ - private fun cropTask( - outOptions: BitmapFactory.Options, - uri: String, - x: Int, - y: Int, - width: Int, - height: Int - ): Bitmap? { - return openBitmapInputStream(uri)?.use { - // Efficiently crops image without loading full resolution into memory - // https://developer.android.com/reference/android/graphics/BitmapRegionDecoder.html - val decoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - BitmapRegionDecoder.newInstance(it) - } else { - @Suppress("DEPRECATION") - BitmapRegionDecoder.newInstance(it, false) - } - return@use try { - val rect = Rect(x, y, x + width, y + height) - decoder!!.decodeRegion(rect, outOptions) - } finally { - decoder!!.recycle() - } + /** + * Reads and crops the bitmap. + * + * @param outOptions Bitmap options, useful to determine `outMimeType`. + * @param uri the URI of the image to crop + * @param x left coordinate of the cropped image + * @param y top coordinate of the cropped image + * @param width width of the cropped image + * @param height height of the cropped image + */ + private fun cropTask( + outOptions: BitmapFactory.Options, + uri: String, + x: Int, + y: Int, + width: Int, + height: Int + ): Bitmap? { + return openBitmapInputStream(uri)?.use { + // Efficiently crops image without loading full resolution into memory + // https://developer.android.com/reference/android/graphics/BitmapRegionDecoder.html + val decoder = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + BitmapRegionDecoder.newInstance(it) + } else { + @Suppress("DEPRECATION") BitmapRegionDecoder.newInstance(it, false) + } + return@use try { + val rect = Rect(x, y, x + width, y + height) + decoder!!.decodeRegion(rect, outOptions) + } finally { + decoder!!.recycle() + } + } } - } - /** - * Crop the rectangle given by `mX, mY, mWidth, mHeight` within the source bitmap - * and scale the result to `targetWidth, targetHeight`. - * @param outOptions Bitmap options, useful to determine `outMimeType`. - * @param uri the URI of the image to crop - * @param x left coordinate of the cropped image - * @param y top coordinate of the cropped image - * @param width width of the cropped image - * @param height height of the cropped image - * @param targetWidth width of the resized image - * @param targetHeight height of the resized image - */ - private fun cropAndResizeTask( - outOptions: BitmapFactory.Options, - uri: String, - x: Int, - y: Int, - width: Int, - height: Int, - targetWidth: Int, - targetHeight: Int, - ): Bitmap? { - Assertions.assertNotNull(outOptions) + /** + * Crop the rectangle given by `mX, mY, mWidth, mHeight` within the source bitmap and scale the + * result to `targetWidth, targetHeight`. + * + * @param outOptions Bitmap options, useful to determine `outMimeType`. + * @param uri the URI of the image to crop + * @param x left coordinate of the cropped image + * @param y top coordinate of the cropped image + * @param width width of the cropped image + * @param height height of the cropped image + * @param targetWidth width of the resized image + * @param targetHeight height of the resized image + */ + private fun cropAndResizeTask( + outOptions: BitmapFactory.Options, + uri: String, + x: Int, + y: Int, + width: Int, + height: Int, + targetWidth: Int, + targetHeight: Int, + ): Bitmap? { + Assertions.assertNotNull(outOptions) - // Loading large bitmaps efficiently: - // http://developer.android.com/training/displaying-bitmaps/load-bitmap.html + // Loading large bitmaps efficiently: + // http://developer.android.com/training/displaying-bitmaps/load-bitmap.html - // This uses scaling mode COVER + // This uses scaling mode COVER - // Where would the crop rect end up within the scaled bitmap? - val cropRectRatio = width / height.toFloat() - val targetRatio = targetWidth / targetHeight.toFloat() - val isCropRatioLargerThanTargetRatio = cropRectRatio > targetRatio - val newWidth = if (isCropRatioLargerThanTargetRatio) height * targetRatio else width.toFloat() - val newHeight = if (isCropRatioLargerThanTargetRatio) height.toFloat() else width / targetRatio - val newX = if (isCropRatioLargerThanTargetRatio) x + (width - newWidth) / 2 else x.toFloat() - val newY = if (isCropRatioLargerThanTargetRatio) y.toFloat() else y + (height - newHeight) / 2 - val scale = if (isCropRatioLargerThanTargetRatio) targetHeight / height.toFloat() else targetWidth / width.toFloat() + // Where would the crop rect end up within the scaled bitmap? + val cropRectRatio = width / height.toFloat() + val targetRatio = targetWidth / targetHeight.toFloat() + val isCropRatioLargerThanTargetRatio = cropRectRatio > targetRatio + val newWidth = + if (isCropRatioLargerThanTargetRatio) height * targetRatio else width.toFloat() + val newHeight = + if (isCropRatioLargerThanTargetRatio) height.toFloat() else width / targetRatio + val newX = if (isCropRatioLargerThanTargetRatio) x + (width - newWidth) / 2 else x.toFloat() + val newY = + if (isCropRatioLargerThanTargetRatio) y.toFloat() else y + (height - newHeight) / 2 + val scale = + if (isCropRatioLargerThanTargetRatio) targetHeight / height.toFloat() + else targetWidth / width.toFloat() - // Decode the bitmap. We have to open the stream again, like in the example linked above. - // Is there a way to just continue reading from the stream? - outOptions.inSampleSize = - getDecodeSampleSize(width, height, targetWidth, targetHeight) - val bitmap = openBitmapInputStream(uri)?.use { - // This can use significantly less memory than decoding the full-resolution bitmap - BitmapFactory.decodeStream(it, null, outOptions) - } ?: return null + // Decode the bitmap. We have to open the stream again, like in the example linked above. + // Is there a way to just continue reading from the stream? + outOptions.inSampleSize = getDecodeSampleSize(width, height, targetWidth, targetHeight) + val bitmap = + openBitmapInputStream(uri)?.use { + // This can use significantly less memory than decoding the full-resolution bitmap + BitmapFactory.decodeStream(it, null, outOptions) + } ?: return null - val cropX = (newX / outOptions.inSampleSize.toFloat()).roundToInt() - val cropY = (newY / outOptions.inSampleSize.toFloat()).roundToInt() - val cropWidth = (newWidth / outOptions.inSampleSize.toFloat()).roundToInt() - val cropHeight = (newHeight / outOptions.inSampleSize.toFloat()).roundToInt() - val cropScale = scale * outOptions.inSampleSize - val scaleMatrix = Matrix().apply { - setScale(cropScale, cropScale) - } - val filter = true + val cropX = (newX / outOptions.inSampleSize.toFloat()).roundToInt() + val cropY = (newY / outOptions.inSampleSize.toFloat()).roundToInt() + val cropWidth = (newWidth / outOptions.inSampleSize.toFloat()).roundToInt() + val cropHeight = (newHeight / outOptions.inSampleSize.toFloat()).roundToInt() + val cropScale = scale * outOptions.inSampleSize + val scaleMatrix = Matrix().apply { setScale(cropScale, cropScale) } + val filter = true - return Bitmap.createBitmap( - bitmap, - cropX, - cropY, - cropWidth, - cropHeight, - scaleMatrix, - filter - ) - } + return Bitmap.createBitmap(bitmap, cropX, cropY, cropWidth, cropHeight, scaleMatrix, filter) + } - private fun openBitmapInputStream(uri: String): InputStream? { - return if (isLocalUri(uri)) { - reactContext.contentResolver.openInputStream(Uri.parse(uri)) - } else { - val connection = URL(uri).openConnection() - connection.getInputStream() + private fun openBitmapInputStream(uri: String): InputStream? { + return if (isLocalUri(uri)) { + reactContext.contentResolver.openInputStream(Uri.parse(uri)) + } else { + val connection = URL(uri).openConnection() + connection.getInputStream() + } } - } - companion object { - const val NAME = "RNCImageEditor" - private val LOCAL_URI_PREFIXES = listOf( - ContentResolver.SCHEME_FILE, - ContentResolver.SCHEME_CONTENT, - ContentResolver.SCHEME_ANDROID_RESOURCE - ) - private const val TEMP_FILE_PREFIX = "ReactNative_cropped_image_" + companion object { + const val NAME = "RNCImageEditor" + private val LOCAL_URI_PREFIXES = + listOf( + ContentResolver.SCHEME_FILE, + ContentResolver.SCHEME_CONTENT, + ContentResolver.SCHEME_ANDROID_RESOURCE + ) + private const val TEMP_FILE_PREFIX = "ReactNative_cropped_image_" - /** Compress quality of the output file. */ - private const val COMPRESS_QUALITY = 90 + /** Compress quality of the output file. */ + private const val COMPRESS_QUALITY = 90 - @SuppressLint("InlinedApi") - private val EXIF_ATTRIBUTES = arrayOf( - ExifInterface.TAG_DATETIME, - ExifInterface.TAG_DATETIME_DIGITIZED, - ExifInterface.TAG_EXPOSURE_TIME, - ExifInterface.TAG_FLASH, - ExifInterface.TAG_FOCAL_LENGTH, - ExifInterface.TAG_GPS_ALTITUDE, - ExifInterface.TAG_GPS_ALTITUDE_REF, - ExifInterface.TAG_GPS_DATESTAMP, - ExifInterface.TAG_GPS_LATITUDE, - ExifInterface.TAG_GPS_LATITUDE_REF, - ExifInterface.TAG_GPS_LONGITUDE, - ExifInterface.TAG_GPS_LONGITUDE_REF, - ExifInterface.TAG_GPS_PROCESSING_METHOD, - ExifInterface.TAG_GPS_TIMESTAMP, - ExifInterface.TAG_IMAGE_LENGTH, - ExifInterface.TAG_IMAGE_WIDTH, - ExifInterface.TAG_MAKE, - ExifInterface.TAG_MODEL, - ExifInterface.TAG_ORIENTATION, - ExifInterface.TAG_SUBSEC_TIME, - ExifInterface.TAG_WHITE_BALANCE - ) + @SuppressLint("InlinedApi") + private val EXIF_ATTRIBUTES = + arrayOf( + ExifInterface.TAG_DATETIME, + ExifInterface.TAG_DATETIME_DIGITIZED, + ExifInterface.TAG_EXPOSURE_TIME, + ExifInterface.TAG_FLASH, + ExifInterface.TAG_FOCAL_LENGTH, + ExifInterface.TAG_GPS_ALTITUDE, + ExifInterface.TAG_GPS_ALTITUDE_REF, + ExifInterface.TAG_GPS_DATESTAMP, + ExifInterface.TAG_GPS_LATITUDE, + ExifInterface.TAG_GPS_LATITUDE_REF, + ExifInterface.TAG_GPS_LONGITUDE, + ExifInterface.TAG_GPS_LONGITUDE_REF, + ExifInterface.TAG_GPS_PROCESSING_METHOD, + ExifInterface.TAG_GPS_TIMESTAMP, + ExifInterface.TAG_IMAGE_LENGTH, + ExifInterface.TAG_IMAGE_WIDTH, + ExifInterface.TAG_MAKE, + ExifInterface.TAG_MODEL, + ExifInterface.TAG_ORIENTATION, + ExifInterface.TAG_SUBSEC_TIME, + ExifInterface.TAG_WHITE_BALANCE + ) - // Utils - @Throws(IOException::class) - private fun copyExif(context: Context, oldImage: Uri, newFile: File) { - val oldFile = getFileFromUri(context, oldImage) - if (oldFile == null) { - FLog.w(ReactConstants.TAG, "Couldn't get real path for uri: $oldImage") - return - } - val oldExif = ExifInterface(oldFile.absolutePath) - val newExif = ExifInterface(newFile.absolutePath) - for (attribute in EXIF_ATTRIBUTES) { - val value = oldExif.getAttribute(attribute) - if (value != null) { - newExif.setAttribute(attribute, value) + // Utils + @Throws(IOException::class) + private fun copyExif(context: Context, oldImage: Uri, newFile: File) { + val oldFile = getFileFromUri(context, oldImage) + if (oldFile == null) { + FLog.w(ReactConstants.TAG, "Couldn't get real path for uri: $oldImage") + return + } + val oldExif = ExifInterface(oldFile.absolutePath) + val newExif = ExifInterface(newFile.absolutePath) + for (attribute in EXIF_ATTRIBUTES) { + val value = oldExif.getAttribute(attribute) + if (value != null) { + newExif.setAttribute(attribute, value) + } + } + newExif.saveAttributes() } - } - newExif.saveAttributes() - } - private fun getFileFromUri(context: Context, uri: Uri): File? { - if (uri.scheme == "file") { - return uri.path?.let { File(it) } - } - if (uri.scheme == "content") { - context.contentResolver - .query(uri, arrayOf(MediaStore.MediaColumns.DATA), null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val path = cursor.getString(0) - if (!TextUtils.isEmpty(path)) { - return File(path) - } + private fun getFileFromUri(context: Context, uri: Uri): File? { + if (uri.scheme == "file") { + return uri.path?.let { File(it) } } - } - } - return null - } + if (uri.scheme == "content") { + context.contentResolver + .query(uri, arrayOf(MediaStore.MediaColumns.DATA), null, null, null) + ?.use { cursor -> + if (cursor.moveToFirst()) { + val path = cursor.getString(0) + if (!TextUtils.isEmpty(path)) { + return File(path) + } + } + } + } + return null + } - private fun isLocalUri(uri: String): Boolean { - for (localPrefix in LOCAL_URI_PREFIXES) { - if (uri.startsWith(localPrefix)) { - return true + private fun isLocalUri(uri: String): Boolean { + for (localPrefix in LOCAL_URI_PREFIXES) { + if (uri.startsWith(localPrefix)) { + return true + } + } + return false } - } - return false - } - private fun getFileExtensionForType(mimeType: String?): String { - return when (mimeType) { - "image/png" -> ".png" - "image/webp" -> ".webp" - else -> ".jpg" - } - } + private fun getFileExtensionForType(mimeType: String?): String { + return when (mimeType) { + "image/png" -> ".png" + "image/webp" -> ".webp" + else -> ".jpg" + } + } - private fun getCompressFormatForType(mimeType: String): CompressFormat { - val webpCompressFormat = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - CompressFormat.WEBP_LOSSY - } - else { - @Suppress("DEPRECATION") - CompressFormat.WEBP - } - return when (mimeType) { - "image/png" -> CompressFormat.PNG - "image/webp" -> webpCompressFormat - else -> CompressFormat.JPEG - } - } + private fun getCompressFormatForType(mimeType: String): CompressFormat { + val webpCompressFormat = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + CompressFormat.WEBP_LOSSY + } else { + @Suppress("DEPRECATION") CompressFormat.WEBP + } + return when (mimeType) { + "image/png" -> CompressFormat.PNG + "image/webp" -> webpCompressFormat + else -> CompressFormat.JPEG + } + } - @Throws(IOException::class) - private fun writeCompressedBitmapToFile(cropped: Bitmap, mimeType: String, tempFile: File) { - FileOutputStream(tempFile).use { - cropped.compress(getCompressFormatForType(mimeType), COMPRESS_QUALITY, it) - } - } + @Throws(IOException::class) + private fun writeCompressedBitmapToFile(cropped: Bitmap, mimeType: String, tempFile: File) { + FileOutputStream(tempFile).use { + cropped.compress(getCompressFormatForType(mimeType), COMPRESS_QUALITY, it) + } + } - /** - * Create a temporary file in the cache directory on either internal or external storage, - * whichever is available and has more free space. - * - * @param mimeType the MIME type of the file to create (image/ *) - */ - @Throws(IOException::class) - private fun createTempFile(context: Context, mimeType: String?): File { - val externalCacheDir = context.externalCacheDir - val internalCacheDir = context.cacheDir - if (externalCacheDir == null && internalCacheDir == null) { - throw IOException("No cache directory available") - } - val cacheDir: File? = if (externalCacheDir == null) { - internalCacheDir - } else if (internalCacheDir == null) { - externalCacheDir - } else { - if (externalCacheDir.freeSpace > internalCacheDir.freeSpace) externalCacheDir else internalCacheDir - } - return File.createTempFile( - TEMP_FILE_PREFIX, - getFileExtensionForType(mimeType), - cacheDir - ) - } + /** + * Create a temporary file in the cache directory on either internal or external storage, + * whichever is available and has more free space. + * + * @param mimeType the MIME type of the file to create (image/ *) + */ + @Throws(IOException::class) + private fun createTempFile(context: Context, mimeType: String?): File { + val externalCacheDir = context.externalCacheDir + val internalCacheDir = context.cacheDir + if (externalCacheDir == null && internalCacheDir == null) { + throw IOException("No cache directory available") + } + val cacheDir: File? = + if (externalCacheDir == null) { + internalCacheDir + } else if (internalCacheDir == null) { + externalCacheDir + } else { + if (externalCacheDir.freeSpace > internalCacheDir.freeSpace) externalCacheDir + else internalCacheDir + } + return File.createTempFile( + TEMP_FILE_PREFIX, + getFileExtensionForType(mimeType), + cacheDir + ) + } - /** - * When scaling down the bitmap, decode only every n-th pixel in each dimension. - * Calculate the largest `inSampleSize` value that is a power of 2 and keeps both - * `width, height` larger or equal to `targetWidth, targetHeight`. - * This can significantly reduce memory usage. - */ - private fun getDecodeSampleSize( - width: Int, - height: Int, - targetWidth: Int, - targetHeight: Int - ): Int { - var inSampleSize = 1 - if (height > targetHeight || width > targetWidth) { - val halfHeight = height / 2 - val halfWidth = width / 2 - while (halfWidth / inSampleSize >= targetWidth - && halfHeight / inSampleSize >= targetHeight - ) { - inSampleSize *= 2 + /** + * When scaling down the bitmap, decode only every n-th pixel in each dimension. Calculate + * the largest `inSampleSize` value that is a power of 2 and keeps both `width, height` + * larger or equal to `targetWidth, targetHeight`. This can significantly reduce memory + * usage. + */ + private fun getDecodeSampleSize( + width: Int, + height: Int, + targetWidth: Int, + targetHeight: Int + ): Int { + var inSampleSize = 1 + if (height > targetHeight || width > targetWidth) { + val halfHeight = height / 2 + val halfWidth = width / 2 + while ( + halfWidth / inSampleSize >= targetWidth && + halfHeight / inSampleSize >= targetHeight + ) { + inSampleSize *= 2 + } + } + return inSampleSize } - } - return inSampleSize } - } } diff --git a/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorPackage.kt b/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorPackage.kt index 19019e2..a748cea 100644 --- a/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorPackage.kt +++ b/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorPackage.kt @@ -1,8 +1,8 @@ /** * Copyright (c) Facebook, Inc. and its affiliates. * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. + * This source code is licensed under the MIT license found in the LICENSE file in the root + * directory of this source tree. */ package com.reactnativecommunity.imageeditor @@ -24,21 +24,20 @@ class ImageEditorPackage : TurboReactPackage() { } override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { - val moduleList: Array> = arrayOf( - ImageEditorModule::class.java - ) + val moduleList: Array> = arrayOf(ImageEditorModule::class.java) val reactModuleInfoMap: MutableMap = HashMap() for (moduleClass in moduleList) { val reactModule = moduleClass.getAnnotation(ReactModule::class.java) ?: continue - reactModuleInfoMap[reactModule.name] = ReactModuleInfo( - reactModule.name, - moduleClass.name, - true, - reactModule.needsEagerInit, - reactModule.hasConstants, - reactModule.isCxxModule, - TurboModule::class.java.isAssignableFrom(moduleClass) - ) + reactModuleInfoMap[reactModule.name] = + ReactModuleInfo( + reactModule.name, + moduleClass.name, + true, + reactModule.needsEagerInit, + reactModule.hasConstants, + reactModule.isCxxModule, + TurboModule::class.java.isAssignableFrom(moduleClass) + ) } return ReactModuleInfoProvider { reactModuleInfoMap } } diff --git a/package.json b/package.json index 0558d5b..f26e980 100755 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "scripts": { "ts": "yarn tsc --noEmit", "lint": "eslint '**/*.{js,ts,tsx}'", + "lint:android": "./android/gradlew -p android spotlessCheck --quiet", + "format:android": "./android/gradlew -p android spotlessapply", "release": "release-it", "build": "bob build", "prepack": "yarn run build"