From 1d819e9503e69737733c3a0890ae272597527083 Mon Sep 17 00:00:00 2001 From: Konstantin Raev Date: Wed, 27 Jan 2016 10:49:31 -0800 Subject: [PATCH] Open sourced , for Android Reviewed By: mkonicek Differential Revision: D2869751 fb-gh-sync-id: 862c266601dd83ca3bf9c9bcbf107f7b17b8bdfd --- Examples/UIExplorer/ImageEditingExample.js | 33 +- Examples/UIExplorer/UIExplorerList.android.js | 7 +- Examples/UIExplorer/UIExplorerList.ios.js | 2 +- Libraries/Image/ImageStore.js | 3 +- .../modules/camera/ImageEditingManager.java | 440 ++++++++++++++++++ .../modules/camera/ImageStoreManager.java | 107 +++++ .../react/shell/MainReactPackage.java | 4 + 7 files changed, 575 insertions(+), 21 deletions(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/modules/camera/ImageEditingManager.java create mode 100644 ReactAndroid/src/main/java/com/facebook/react/modules/camera/ImageStoreManager.java diff --git a/Examples/UIExplorer/ImageEditingExample.js b/Examples/UIExplorer/ImageEditingExample.js index 29b2dfd84..8f4e4e7f6 100644 --- a/Examples/UIExplorer/ImageEditingExample.js +++ b/Examples/UIExplorer/ImageEditingExample.js @@ -67,22 +67,21 @@ class SquareImageCropper extends React.Component { this._fetchRandomPhoto(); } - _fetchRandomPhoto() { - CameraRoll.getPhotos( - {first: PAGE_SIZE}, - (data) => { - if (!this._isMounted) { - return; - } - var edges = data.edges; - var edge = edges[Math.floor(Math.random() * edges.length)]; - var randomPhoto = edge && edge.node && edge.node.image; - if (randomPhoto) { - this.setState({randomPhoto}); - } - }, - (error) => undefined - ); + async _fetchRandomPhoto() { + try { + const data = await CameraRoll.getPhotos({first: PAGE_SIZE}); + if (!this._isMounted) { + return; + } + var edges = data.edges; + var edge = edges[Math.floor(Math.random() * edges.length)]; + var randomPhoto = edge && edge.node && edge.node.image; + if (randomPhoto) { + this.setState({randomPhoto}); + } + } catch (error) { + console.warn("Can't get a photo from camera roll", error); + } } componentWillUnmount() { @@ -209,6 +208,8 @@ class ImageCropper extends React.Component { height: this.props.size.height, }; } + // a quick hack for android because Android ScrollView does not have zoom feature + this._scaledImageSize.width = 2 * this._scaledImageSize.width; this._contentOffset = { x: (this._scaledImageSize.width - this.props.size.width) / 2, y: (this._scaledImageSize.height - this.props.size.height) / 2, diff --git a/Examples/UIExplorer/UIExplorerList.android.js b/Examples/UIExplorer/UIExplorerList.android.js index 66caeef51..367b256de 100644 --- a/Examples/UIExplorer/UIExplorerList.android.js +++ b/Examples/UIExplorer/UIExplorerList.android.js @@ -25,12 +25,12 @@ var UIExplorerListBase = require('./UIExplorerListBase'); var COMPONENTS = [ require('./ImageExample'), require('./ListViewExample'), + require('./PickerAndroidExample'), require('./ProgressBarAndroidExample'), + require('./PullToRefreshViewAndroidExample.android'), + require('./RefreshControlExample'), require('./ScrollViewSimpleExample'), require('./SwitchExample'), - require('./RefreshControlExample'), - require('./PickerAndroidExample'), - require('./PullToRefreshViewAndroidExample.android'), require('./TextExample.android'), require('./TextInputExample.android'), require('./ToolbarAndroidExample'), @@ -49,6 +49,7 @@ var APIS = [ require('./ClipboardExample'), require('./DatePickerAndroidExample'), require('./GeolocationExample'), + require('./ImageEditingExample'), require('./IntentAndroidExample.android'), require('./LayoutEventsExample'), require('./LayoutExample'), diff --git a/Examples/UIExplorer/UIExplorerList.ios.js b/Examples/UIExplorer/UIExplorerList.ios.js index f33d97c86..3fa57d818 100644 --- a/Examples/UIExplorer/UIExplorerList.ios.js +++ b/Examples/UIExplorer/UIExplorerList.ios.js @@ -71,6 +71,7 @@ var APIS = [ require('./CameraRollExample'), require('./ClipboardExample'), require('./GeolocationExample'), + require('./ImageEditingExample'), require('./LayoutExample'), require('./NetInfoExample'), require('./PanResponderExample'), @@ -82,7 +83,6 @@ var APIS = [ require('./TransformExample'), require('./VibrationIOSExample'), require('./XHRExample.ios'), - require('./ImageEditingExample'), ]; type Props = { diff --git a/Libraries/Image/ImageStore.js b/Libraries/Image/ImageStore.js index e4006a73d..f03262d6d 100644 --- a/Libraries/Image/ImageStore.js +++ b/Libraries/Image/ImageStore.js @@ -51,6 +51,7 @@ class ImageStore { * Note that it is very inefficient to transfer large quantities of binary * data between JS and native code, so you should avoid calling this more * than necessary. + * @platform ios */ static addImageFromBase64( base64ImageData: string, @@ -80,4 +81,4 @@ class ImageStore { } } -module.exports = ImageStore; \ No newline at end of file +module.exports = ImageStore; 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 new file mode 100644 index 000000000..cbcfbb596 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/camera/ImageEditingManager.java @@ -0,0 +1,440 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.modules.camera; + +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.Collections; +import java.util.List; +import java.util.Map; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Matrix; +import android.net.Uri; +import android.os.AsyncTask; + +import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.GuardedAsyncTask; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.JSApplicationIllegalArgumentException; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.infer.annotation.Assertions; + +/** + * Native module that provides image cropping functionality. + */ +public class ImageEditingManager extends ReactContextBaseJavaModule { + + 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; + + public ImageEditingManager(ReactApplicationContext reactContext) { + super(reactContext); + new CleanTask(getReactApplicationContext()).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + @Override + public String getName() { + return "RKImageEditingManager"; + } + + @Override + public Map getConstants() { + return Collections.emptyMap(); + } + + @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.) + */ + @ReactMethod + 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; + 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"), + success, + error); + if (options.hasKey("displaySize")) { + ReadableMap targetSize = options.getMap("displaySize"); + cropTask.setTargetSize(targetSize.getInt("width"), targetSize.getInt("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 Callback mSuccess; + final Callback mError; + + private CropTask( + ReactContext context, + String uri, + int x, + int y, + int width, + int height, + 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; + 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 = createTempFile(mContext, mimeType); + writeCompressedBitmapToFile(cropped, mimeType, 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(); + try { + // This can use a lot of memory + Bitmap fullResolutionBitmap = BitmapFactory.decodeStream(inputStream, null, outOptions); + if (fullResolutionBitmap == null) { + throw new IOException("Cannot decode bitmap: " + mUri); + } + return Bitmap.createBitmap(fullResolutionBitmap, mX, mY, mWidth, mHeight); + } finally { + if (inputStream != null) { + inputStream.close(); + } + } + } + + /** + * 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 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 && externalCacheDir == 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 > 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/modules/camera/ImageStoreManager.java b/ReactAndroid/src/main/java/com/facebook/react/modules/camera/ImageStoreManager.java new file mode 100644 index 000000000..68329f42a --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/camera/ImageStoreManager.java @@ -0,0 +1,107 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.modules.camera; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; + +import android.content.ContentResolver; +import android.net.Uri; +import android.os.AsyncTask; +import android.util.Base64; +import android.util.Base64OutputStream; + +import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.GuardedAsyncTask; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; + +public class ImageStoreManager extends ReactContextBaseJavaModule { + + private static final int BUFFER_SIZE = 8192; + + public ImageStoreManager(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + public String getName() { + return "ImageStoreManager"; + } + + /** + * Calculate the base64 representation for an image. The "tag" comes from iOS naming. + * + * @param uri the URI of the image, file:// or content:// + * @param success callback to be invoked with the base64 string as the only argument + * @param error callback to be invoked on error (e.g. file not found, not readable etc.) + */ + @ReactMethod + public void getBase64ForTag(String uri, Callback success, Callback error) { + new GetBase64Task(getReactApplicationContext(), uri, success, error) + .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + private class GetBase64Task extends GuardedAsyncTask { + private final String mUri; + private final Callback mSuccess; + private final Callback mError; + + private GetBase64Task( + ReactContext reactContext, + String uri, + Callback success, + Callback error) { + super(reactContext); + mUri = uri; + mSuccess = success; + mError = error; + } + + @Override + protected void doInBackgroundGuarded(Void... params) { + try { + ContentResolver contentResolver = getReactApplicationContext().getContentResolver(); + Uri uri = Uri.parse(mUri); + InputStream is = contentResolver.openInputStream(uri); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Base64OutputStream b64os = new Base64OutputStream(baos, Base64.DEFAULT); + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + try { + while ((bytesRead = is.read(buffer)) > -1) { + b64os.write(buffer, 0, bytesRead); + } + mSuccess.invoke(baos.toString()); + } catch (IOException e) { + mError.invoke(e.getMessage()); + } finally { + closeQuietly(is); + closeQuietly(b64os); // this also closes baos + } + } catch (FileNotFoundException e) { + mError.invoke(e.getMessage()); + } + } + } + + private static void closeQuietly(Closeable closeable) { + try { + closeable.close(); + } catch (IOException e) { + // shhh + } + } +} 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 b817f55ac..9933b41d5 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java +++ b/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java @@ -19,6 +19,8 @@ import com.facebook.react.bridge.NativeModule; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.modules.appstate.AppStateModule; 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.dialog.DialogModule; import com.facebook.react.modules.datepicker.DatePickerDialogModule; @@ -69,6 +71,8 @@ public class MainReactPackage implements ReactPackage { new DatePickerDialogModule(reactContext), new DialogModule(reactContext), new FrescoModule(reactContext), + new ImageEditingManager(reactContext), + new ImageStoreManager(reactContext), new IntentModule(reactContext), new LocationModule(reactContext), new NetworkingModule(reactContext),