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 c94841e..eca5f81 100755 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,11 +1,16 @@ buildscript { repositories { - google() mavenCentral() + google() } + 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" } } @@ -13,7 +18,12 @@ 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" if (isNewArchitectureEnabled()) { apply plugin: "com.facebook.react" @@ -70,6 +80,9 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + packagingOptions { + resources.excludes += "DebugProbesKt.bin" + } sourceSets { main { @@ -87,9 +100,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..2cc3686 100755 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,7 @@ RNImageEditor_compileSdkVersion=34 RNImageEditor_targetSdkVersion=34 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.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..f49b5ac --- /dev/null +++ b/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.kt @@ -0,0 +1,438 @@ +/** + * 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 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() + } + + 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..a748cea --- /dev/null +++ b/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorPackage.kt @@ -0,0 +1,44 @@ +/** + * 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 + } +} 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"