/**
* 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.annotation.SuppressLint;
import android.content.Context;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Matrix;
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.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;
import com.facebook.react.common.ReactConstants;
import com.facebook.react.module.annotations.ReactModule;
/**
* Native module that provides image cropping functionality.
*/
@ReactModule(name = "RKImageEditingManager")
public class ImageEditingManager extends ReactContextBaseJavaModule {
private static final List<String> 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 "RKImageEditingManager";
}
@Override
public Map<String, Object> 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<Void, Void> {
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<Void, Void> {
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);
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();
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 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 > targetWidth || width > targetHeight) {
int halfHeight = height / 2;
int halfWidth = width / 2;
while ((halfWidth / inSampleSize) >= targetWidth
&& (halfHeight / inSampleSize) >= targetHeight) {
inSampleSize *= 2;
}
}
return inSampleSize;
}
}