diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/camera/ImageEditingManager.java b/ReactAndroid/src/main/java/com/facebook/react/modules/camera/ImageEditingManager.java deleted file mode 100644 index 105d4d3c153..00000000000 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/camera/ImageEditingManager.java +++ /dev/null @@ -1,525 +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.facebook.react.modules.camera; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.database.Cursor; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.BitmapRegionDecoder; -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 androidx.annotation.Nullable; -import com.facebook.common.logging.FLog; -import com.facebook.fbreact.specs.NativeImageEditorSpec; -import com.facebook.infer.annotation.Assertions; -import com.facebook.react.bridge.Callback; -import com.facebook.react.bridge.GuardedAsyncTask; -import com.facebook.react.bridge.JSApplicationIllegalArgumentException; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContext; -import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.common.ReactConstants; -import com.facebook.react.module.annotations.ReactModule; -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; - -/** Native module that provides image cropping functionality. */ -@ReactModule(name = ImageEditingManager.NAME) -public class ImageEditingManager extends NativeImageEditorSpec { - - public static final String NAME = "ImageEditingManager"; - - private static final List LOCAL_URI_PREFIXES = Arrays.asList("file://", "content://"); - - 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 ImageEditingManager(ReactApplicationContext reactContext) { - super(reactContext); - new CleanTask(getReactApplicationContext()).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - @Override - public String getName() { - return NAME; - } - - @Override - public void onCatalystInstanceDestroy() { - new CleanTask(getReactApplicationContext()).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 success callback will be called 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 MediaStore 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 success callback to be invoked when the image has been cropped; the only argument that - * is passed to this callback is the file:// URI of the new image - * @param error callback to be invoked when an error occurs (e.g. can't create file etc.) - */ - @Override - public void cropImage( - String uri, ReadableMap options, final Callback success, final Callback error) { - ReadableMap offset = options.hasKey("offset") ? options.getMap("offset") : null; - ReadableMap size = options.hasKey("size") ? options.getMap("size") : null; - boolean allowExternalStorage = - options.hasKey("allowExternalStorage") ? options.getBoolean("allowExternalStorage") : true; - - 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( - getReactApplicationContext(), - uri, - (int) offset.getDouble("x"), - (int) offset.getDouble("y"), - (int) size.getDouble("width"), - (int) size.getDouble("height"), - allowExternalStorage, - success, - error); - 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; - final boolean mAllowExternalStorage; - int mTargetWidth = 0; - int mTargetHeight = 0; - final Callback mSuccess; - final Callback mError; - - private CropTask( - ReactContext context, - String uri, - int x, - int y, - int width, - int height, - boolean allowExternalStorage, - Callback success, - Callback error) { - 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; - mAllowExternalStorage = allowExternalStorage; - mSuccess = success; - mError = error; - } - - 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; - try { - tempFile = writeBitmapToInternalCache(mContext, cropped, mimeType); - } catch (Exception e) { - if (mAllowExternalStorage) { - tempFile = writeBitmapToExternalCache(mContext, cropped, mimeType); - } else { - throw new SecurityException( - "We couldn't create file in internal cache and external cache is disabled. Did you forget to pass allowExternalStorage=true?"); - } - } - - if (mimeType.equals("image/jpeg")) { - copyExif(mContext, Uri.parse(mUri), tempFile); - } - - mSuccess.invoke(Uri.fromFile(tempFile).toString()); - } catch (Exception e) { - mError.invoke(e.getMessage()); - } - } - - /** - * 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(); - // Efficiently 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 - - // Just decode the dimensions - BitmapFactory.Options options = new BitmapFactory.Options(); - options.inJustDecodeBounds = true; - InputStream inputStream = openBitmapInputStream(); - try { - BitmapFactory.decodeStream(inputStream, null, options); - } finally { - if (inputStream != null) { - inputStream.close(); - } - } - - // 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); - options.inJustDecodeBounds = false; - 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 = (int) Math.floor(newX / (float) outOptions.inSampleSize); - int cropY = (int) Math.floor(newY / (float) outOptions.inSampleSize); - int cropWidth = (int) Math.floor(newWidth / (float) outOptions.inSampleSize); - int cropHeight = (int) Math.floor(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 File writeBitmapToInternalCache(Context context, Bitmap cropped, String mimeType) - throws IOException { - File tempFile = createTempFile(context.getCacheDir(), mimeType); - writeCompressedBitmapToFile(cropped, mimeType, tempFile); - return tempFile; - } - - private static File writeBitmapToExternalCache(Context context, Bitmap cropped, String mimeType) - throws IOException { - File tempFile = createTempFile(context.getExternalCacheDir(), mimeType); - writeCompressedBitmapToFile(cropped, mimeType, tempFile); - return tempFile; - } - - private static void writeCompressedBitmapToFile(Bitmap cropped, String mimeType, File tempFile) - throws IOException { - OutputStream out = new FileOutputStream(tempFile); - cropped.compress(getCompressFormatForType(mimeType), COMPRESS_QUALITY, out); - } - - /** - * Create a temporary file in internal / external storage to use for image scaling and caching. - * - * @param mimeType the MIME type of the file to create (image/*) - */ - private static File createTempFile(@Nullable File cacheDir, @Nullable String mimeType) - throws IOException { - if (cacheDir == null) { - throw new IOException("No cache directory available"); - } - 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 > targetWidth || width > targetHeight) { - int halfHeight = height / 2; - int halfWidth = width / 2; - while ((halfWidth / inSampleSize) >= targetWidth - && (halfHeight / inSampleSize) >= targetHeight) { - inSampleSize *= 2; - } - } - return inSampleSize; - } -} diff --git a/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java b/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java index 3cd6defd986..669718e63cc 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java +++ b/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java @@ -22,7 +22,6 @@ import com.facebook.react.modules.appstate.AppStateModule; import com.facebook.react.modules.blob.BlobModule; import com.facebook.react.modules.blob.FileReaderModule; import com.facebook.react.modules.camera.CameraRollManager; -import com.facebook.react.modules.camera.ImageEditingManager; import com.facebook.react.modules.camera.ImageStoreManager; import com.facebook.react.modules.clipboard.ClipboardModule; import com.facebook.react.modules.datepicker.DatePickerDialogModule; @@ -80,7 +79,6 @@ import java.util.Map; DialogModule.class, FrescoModule.class, I18nManagerModule.class, - ImageEditingManager.class, ImageLoaderModule.class, ImageStoreManager.class, IntentModule.class, @@ -132,8 +130,6 @@ public class MainReactPackage extends TurboReactPackage { return new FrescoModule(context, true, mConfig != null ? mConfig.getFrescoConfig() : null); case I18nManagerModule.NAME: return new I18nManagerModule(context); - case ImageEditingManager.NAME: - return new ImageEditingManager(context); case ImageLoaderModule.NAME: return new ImageLoaderModule(context); case ImageStoreManager.NAME: @@ -213,7 +209,6 @@ public class MainReactPackage extends TurboReactPackage { DialogModule.class, FrescoModule.class, I18nManagerModule.class, - ImageEditingManager.class, ImageLoaderModule.class, ImageStoreManager.class, IntentModule.class,