com.dylanvann.cameraroll.ImageEditingManager.java Source code

Java tutorial

Introduction

Here is the source code for com.dylanvann.cameraroll.ImageEditingManager.java

Source

/**
 * 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.dylanvann.cameraroll;

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;
    }
}