com.dylanvann.cameraroll.CameraRollManager.java Source code

Java tutorial

Introduction

Here is the source code for com.dylanvann.cameraroll.CameraRollManager.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.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;

import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Environment;
import android.provider.MediaStore;
import android.provider.MediaStore.Files;
import android.provider.MediaStore.Files.FileColumns;
import android.provider.MediaStore.Images;
import android.provider.MediaStore.Video;
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.ReactMethod;
import com.facebook.react.bridge.JSApplicationIllegalArgumentException;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.bridge.WritableNativeArray;
import com.facebook.react.bridge.WritableNativeMap;
import com.facebook.react.common.ReactConstants;
import com.facebook.react.module.annotations.ReactModule;

/**
 * {@link NativeModule} that allows JS to interact with the photos on the device (i.e.
 * {@link MediaStore.Images}).
 */
@ReactModule(name = "CameraRoll")
public class CameraRollManager extends ReactContextBaseJavaModule {

    private static final String ERROR_UNABLE_TO_LOAD = "E_UNABLE_TO_LOAD";
    private static final String ERROR_UNABLE_TO_LOAD_PERMISSION = "E_UNABLE_TO_LOAD_PERMISSION";
    private static final String ERROR_UNABLE_TO_SAVE = "E_UNABLE_TO_SAVE";

    @Override
    public String getName() {
        return "CameraRoll";
    }

    public static final boolean IS_JELLY_BEAN_OR_LATER = Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN;

    private static final String[] FILES_PROJECTION = new String[] { FileColumns._ID, FileColumns.DATE_MODIFIED,
            FileColumns.MIME_TYPE, FileColumns.MEDIA_TYPE, FileColumns.WIDTH, FileColumns.HEIGHT,
            Video.VideoColumns.DURATION, MediaStore.MediaColumns.DISPLAY_NAME, Video.Media.BUCKET_ID,
            Video.Media.BUCKET_DISPLAY_NAME, };

    private static final String SELECTION_BUCKET = Images.Media.BUCKET_ID + " = ?";
    private static final String SELECTION_DATE_MODIFIED = FileColumns.DATE_MODIFIED + " < ?";

    private static final String SELECTION_IS_MEDIA = "(" + FileColumns.MEDIA_TYPE + "="
            + FileColumns.MEDIA_TYPE_IMAGE + " OR " + FileColumns.MEDIA_TYPE + "=" + FileColumns.MEDIA_TYPE_VIDEO
            + ")";

    public CameraRollManager(ReactApplicationContext reactContext) {
        super(reactContext);
    }

    /**
     * Save an image to the gallery (i.e. {@link MediaStore.Images}). This copies the original file
     * from wherever it may be to the external storage pictures directory, so that it can be scanned
     * by the MediaScanner.
     *
     * @param uri the file:// URI of the image to save
     * @param promise to be resolved or rejected
     */
    @ReactMethod
    public void saveToCameraRoll(String uri, String type, Promise promise) {
        MediaType parsedType = type.equals("video") ? MediaType.VIDEO : MediaType.PHOTO;
        new SaveToCameraRoll(getReactApplicationContext(), Uri.parse(uri), parsedType, promise)
                .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
    }

    private enum MediaType {
        PHOTO, VIDEO
    };

    private static class SaveToCameraRoll extends GuardedAsyncTask<Void, Void> {

        private final Context mContext;
        private final Uri mUri;
        private final Promise mPromise;
        private final MediaType mType;

        public SaveToCameraRoll(ReactContext context, Uri uri, MediaType type, Promise promise) {
            super(context);
            mContext = context;
            mUri = uri;
            mPromise = promise;
            mType = type;
        }

        @Override
        protected void doInBackgroundGuarded(Void... params) {
            File source = new File(mUri.getPath());
            FileChannel input = null, output = null;
            try {
                File exportDir = (mType == MediaType.PHOTO)
                        ? Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
                        : Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES);
                exportDir.mkdirs();
                if (!exportDir.isDirectory()) {
                    mPromise.reject(ERROR_UNABLE_TO_LOAD, "External media storage directory not available");
                    return;
                }
                File dest = new File(exportDir, source.getName());
                int n = 0;
                String fullSourceName = source.getName();
                String sourceName, sourceExt;
                if (fullSourceName.indexOf('.') >= 0) {
                    sourceName = fullSourceName.substring(0, fullSourceName.lastIndexOf('.'));
                    sourceExt = fullSourceName.substring(fullSourceName.lastIndexOf('.'));
                } else {
                    sourceName = fullSourceName;
                    sourceExt = "";
                }
                while (!dest.createNewFile()) {
                    dest = new File(exportDir, sourceName + "_" + (n++) + sourceExt);
                }
                input = new FileInputStream(source).getChannel();
                output = new FileOutputStream(dest).getChannel();
                output.transferFrom(input, 0, input.size());
                input.close();
                output.close();

                MediaScannerConnection.scanFile(mContext, new String[] { dest.getAbsolutePath() }, null,
                        new MediaScannerConnection.OnScanCompletedListener() {
                            @Override
                            public void onScanCompleted(String path, Uri uri) {
                                if (uri != null) {
                                    mPromise.resolve(uri.toString());
                                } else {
                                    mPromise.reject(ERROR_UNABLE_TO_SAVE, "Could not add image to gallery");
                                }
                            }
                        });
            } catch (IOException e) {
                mPromise.reject(e);
            } finally {
                if (input != null && input.isOpen()) {
                    try {
                        input.close();
                    } catch (IOException e) {
                        FLog.e(ReactConstants.TAG, "Could not close input channel", e);
                    }
                }
                if (output != null && output.isOpen()) {
                    try {
                        output.close();
                    } catch (IOException e) {
                        FLog.e(ReactConstants.TAG, "Could not close output channel", e);
                    }
                }
            }
        }
    }

    @ReactMethod
    public void getAlbums(final ReadableMap params, final Promise promise) {
        new GetAlbumsTask(getReactApplicationContext(), promise).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
    }

    private static class GetAlbumsTask extends GuardedAsyncTask<Void, Void> {
        private final Context mContext;
        private final Promise mPromise;

        private GetAlbumsTask(ReactContext context, Promise promise) {
            super(context);
            mContext = context;
            mPromise = promise;
        }

        @Override
        protected void doInBackgroundGuarded(Void... params) {
            StringBuilder selection = new StringBuilder("1");
            List<String> selectionArgs = new ArrayList<>();
            selection.append(" AND " + SELECTION_IS_MEDIA);
            WritableMap response = new WritableNativeMap();
            ContentResolver resolver = mContext.getContentResolver();
            try {
                Uri filesContentUri = Files.getContentUri("external");
                Cursor photosCursor = resolver.query(filesContentUri, FILES_PROJECTION, selection.toString(),
                        selectionArgs.toArray(new String[selectionArgs.size()]),
                        FileColumns.DATE_MODIFIED + " DESC, " + FileColumns.DATE_MODIFIED);
                if (photosCursor == null) {
                    mPromise.reject(ERROR_UNABLE_TO_LOAD, "Could not get photos");
                } else {
                    try {
                        putAlbums(resolver, photosCursor, response);
                    } finally {
                        photosCursor.close();
                        mPromise.resolve(response);
                    }
                }
            } catch (SecurityException e) {
                mPromise.reject(ERROR_UNABLE_TO_LOAD_PERMISSION,
                        "Could not get photos: need READ_EXTERNAL_STORAGE permission", e);
            }
        }
    }

    private static void putAlbums(ContentResolver resolver, Cursor cursor, WritableMap response) {
        WritableArray albums = new WritableNativeArray();
        int bucketIdIndex = cursor.getColumnIndex(Video.VideoColumns.BUCKET_ID);
        int bucketNameIndex = cursor.getColumnIndex(Video.VideoColumns.BUCKET_DISPLAY_NAME);
        int idIndex = cursor.getColumnIndex(FileColumns._ID);
        int mimeTypeIndex = cursor.getColumnIndex(FileColumns.MIME_TYPE);
        int mediaTypeIndex = cursor.getColumnIndex(FileColumns.MEDIA_TYPE);
        int dateModifiedIndex = cursor.getColumnIndex(FileColumns.DATE_MODIFIED);
        int widthIndex = IS_JELLY_BEAN_OR_LATER ? cursor.getColumnIndex(FileColumns.WIDTH) : -1;
        int heightIndex = IS_JELLY_BEAN_OR_LATER ? cursor.getColumnIndex(FileColumns.HEIGHT) : -1;
        HashMap<String, WritableMap> albumsMap = new HashMap<>();
        String assetCountKey = "assetCount";
        if (cursor.moveToFirst()) {
            {
                WritableMap album = new WritableNativeMap();
                album.putInt(assetCountKey, cursor.getCount());
                WritableArray previewAssets = new WritableNativeArray();
                WritableMap asset = new WritableNativeMap();
                putAssetInfo(resolver, cursor, asset, mediaTypeIndex, idIndex, widthIndex, heightIndex,
                        mimeTypeIndex, dateModifiedIndex);
                previewAssets.pushMap(asset);
                album.putArray("previewAssets", previewAssets);
                albumsMap.put("-1", album);
            }

            while (cursor.moveToNext()) {
                String albumId = cursor.getString(bucketIdIndex);
                if (!albumsMap.containsKey(albumId)) {
                    WritableMap album = new WritableNativeMap();
                    String albumName = cursor.getString(bucketNameIndex);
                    album.putString("id", albumId);
                    album.putString("title", albumName);
                    album.putInt(assetCountKey, 1);
                    WritableArray previewAssets = new WritableNativeArray();
                    WritableMap asset = new WritableNativeMap();
                    putAssetInfo(resolver, cursor, asset, mediaTypeIndex, idIndex, widthIndex, heightIndex,
                            mimeTypeIndex, dateModifiedIndex);
                    previewAssets.pushMap(asset);
                    album.putArray("previewAssets", previewAssets);
                    albumsMap.put(albumId, album);
                } else {
                    WritableMap album = albumsMap.get(albumId);
                    int count = album.getInt(assetCountKey);
                    album.putInt(assetCountKey, count + 1);
                }
            }
            Collection<WritableMap> albumsCollection = albumsMap.values();
            for (WritableMap album : albumsCollection) {
                albums.pushMap(album);
            }
        }
        response.putArray("albums", albums);
    }

    /**
     * Get photos from {@link MediaStore.Images}, most recent first.
     *
     * @param params a map containing the following keys:
     *        <ul>
     *          <li>first (mandatory): a number representing the number of photos to fetch</li>
     *          <li>
     *            after (optional): a cursor that matches page_info[end_cursor] returned by a
     *            previous call to {@link #getPhotos}
     *          </li>
     *          <li>groupName (optional): an album name</li>
     *          <li>
     *            mimeType (optional): restrict returned images to a specific mimetype (e.g.
     *            image/jpeg)
     *          </li>
     *        </ul>
     * @param promise the Promise to be resolved when the photos are loaded; for a format of the
     *        parameters passed to this callback, see {@code getPhotosReturnChecker} in CameraRoll.js
     */
    @ReactMethod
    public void getPhotos(final ReadableMap params, final Promise promise) {
        int first = params.getInt("first");
        String after = params.hasKey("after") ? params.getString("after") : null;
        String albumId = params.hasKey("albumId") ? params.getString("albumId") : null;
        ReadableArray mimeTypes = params.hasKey("mimeTypes") ? params.getArray("mimeTypes") : null;
        if (params.hasKey("groupTypes")) {
            throw new JSApplicationIllegalArgumentException("groupTypes is not supported on Android");
        }

        new GetPhotosTask(getReactApplicationContext(), first, after, albumId, mimeTypes, promise)
                .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
    }

    private static class GetPhotosTask extends GuardedAsyncTask<Void, Void> {
        private final Context mContext;
        private final int mFirst;
        private final @Nullable String mAfter;
        private final @Nullable String mAlbumId;
        private final @Nullable ReadableArray mMimeTypes;
        private final Promise mPromise;

        private GetPhotosTask(ReactContext context, int first, @Nullable String after, @Nullable String albumId,
                @Nullable ReadableArray mimeTypes, Promise promise) {
            super(context);
            mContext = context;
            mFirst = first;
            mAfter = after;
            mAlbumId = albumId;
            mMimeTypes = mimeTypes;
            mPromise = promise;
        }

        @Override
        protected void doInBackgroundGuarded(Void... params) {
            StringBuilder selection = new StringBuilder("1");
            List<String> selectionArgs = new ArrayList<>();
            selection.append(" AND " + SELECTION_IS_MEDIA);
            if (!TextUtils.isEmpty(mAfter)) {
                selection.append(" AND " + SELECTION_DATE_MODIFIED);
                selectionArgs.add(mAfter);
            }
            if (!TextUtils.isEmpty(mAlbumId)) {
                selection.append(" AND " + SELECTION_BUCKET);
                selectionArgs.add(mAlbumId);
            }
            if (mMimeTypes != null && mMimeTypes.size() > 0) {
                selection.append(" AND " + FileColumns.MIME_TYPE + " IN (");
                for (int i = 0; i < mMimeTypes.size(); i++) {
                    selection.append("?,");
                    selectionArgs.add(mMimeTypes.getString(i));
                }
                selection.replace(selection.length() - 1, selection.length(), ")");
            }
            WritableMap response = new WritableNativeMap();
            ContentResolver resolver = mContext.getContentResolver();
            // using LIMIT in the sortOrder is not explicitly supported by the SDK (which does not support
            // setting a limit at all), but it works because this specific ContentProvider is backed by
            // an SQLite DB and forwards parameters to it without doing any parsing / validation.
            try {
                Uri filesContentUri = Files.getContentUri("external");
                Cursor photosCursor = resolver.query(filesContentUri, FILES_PROJECTION, selection.toString(),
                        selectionArgs.toArray(new String[selectionArgs.size()]),
                        // set LIMIT to first + 1 so that we know how to populate page_info
                        FileColumns.DATE_MODIFIED + " DESC, " + FileColumns.DATE_MODIFIED + " DESC LIMIT "
                                + (mFirst + 1));
                if (photosCursor == null) {
                    mPromise.reject(ERROR_UNABLE_TO_LOAD, "Could not get photos");
                } else {
                    try {
                        putAssets(resolver, photosCursor, response, mFirst);
                        putPageInfo(photosCursor, response, mFirst);
                    } finally {
                        photosCursor.close();
                        mPromise.resolve(response);
                    }
                }
            } catch (SecurityException e) {
                mPromise.reject(ERROR_UNABLE_TO_LOAD_PERMISSION,
                        "Could not get photos: need READ_EXTERNAL_STORAGE permission", e);
            }
        }
    }

    private static void putPageInfo(Cursor photos, WritableMap response, int limit) {
        WritableMap pageInfo = new WritableNativeMap();
        pageInfo.putBoolean("has_next_page", limit < photos.getCount());
        if (limit < photos.getCount()) {
            photos.moveToPosition(limit - 1);
            pageInfo.putString("end_cursor", photos.getString(photos.getColumnIndex(FileColumns.DATE_MODIFIED)));
        }
        response.putMap("page_info", pageInfo);
    }

    private static void putAssets(ContentResolver resolver, Cursor photos, WritableMap response, int limit) {
        WritableArray assets = new WritableNativeArray();
        photos.moveToFirst();
        int idIndex = photos.getColumnIndex(FileColumns._ID);
        int mimeTypeIndex = photos.getColumnIndex(FileColumns.MIME_TYPE);
        int mediaTypeIndex = photos.getColumnIndex(FileColumns.MEDIA_TYPE);
        int dateModifiedIndex = photos.getColumnIndex(FileColumns.DATE_MODIFIED);
        int widthIndex = IS_JELLY_BEAN_OR_LATER ? photos.getColumnIndex(FileColumns.WIDTH) : -1;
        int heightIndex = IS_JELLY_BEAN_OR_LATER ? photos.getColumnIndex(FileColumns.HEIGHT) : -1;

        for (int i = 0; i < limit && !photos.isAfterLast(); i++) {
            WritableMap asset = new WritableNativeMap();
            boolean imageInfoSuccess = putAssetInfo(resolver, photos, asset, mediaTypeIndex, idIndex, widthIndex,
                    heightIndex, mimeTypeIndex, dateModifiedIndex);
            if (imageInfoSuccess) {
                assets.pushMap(asset);
            } else {
                // we skipped an image because we couldn't get its details (e.g. width/height), so we
                // decrement i in order to correctly reach the limit, if the cursor has enough rows
                i--;
            }
            photos.moveToNext();
        }
        response.putArray("assets", assets);
    }

    private static boolean putAssetInfo(ContentResolver resolver, Cursor photos, WritableMap asset,
            int mediaTypeIndex, int idIndex, int widthIndex, int heightIndex, int mimeTypeIndex,
            int dateAddedIndex) {
        boolean isVideo = photos.getInt(mediaTypeIndex) == FileColumns.MEDIA_TYPE_VIDEO;
        if (isVideo) {
            // Add the actual source of the video as a property.
            Uri sourceUri = Uri.withAppendedPath(Video.Media.EXTERNAL_CONTENT_URI, photos.getString(idIndex));
            asset.putString("source", sourceUri.toString());
            asset.putString("duration", photos.getString(photos.getColumnIndex(Video.VideoColumns.DURATION)));

            long videoId = photos.getLong(idIndex);
            // Attempt to trigger the MediaScanner to generate the thumbnail before
            // we try to get its uri.
            MediaStore.Video.Thumbnails.getThumbnail(resolver, videoId, Video.Thumbnails.MINI_KIND, null);
            String[] projection = { Video.Thumbnails.DATA, };
            // Get the thumbnail info for this video id.
            Cursor videoThumbnailCursor = resolver.query(Video.Thumbnails.EXTERNAL_CONTENT_URI, projection,
                    Video.Thumbnails.VIDEO_ID + "=?", new String[] { String.valueOf(videoId) }, null);
            boolean hasThumb = videoThumbnailCursor != null && videoThumbnailCursor.moveToFirst();
            // If there is no thumbnail then the media will be returned, but with no uri.
            if (hasThumb) {
                int pathIndex = videoThumbnailCursor.getColumnIndex(Video.Thumbnails.DATA);
                String uri = videoThumbnailCursor.getString(pathIndex);
                // Return a url with file:///storage for React Native to use.
                asset.putString("uri", "file://" + uri);
                videoThumbnailCursor.close();
            }
        } else {
            Uri photoUri = Uri.withAppendedPath(Images.Media.EXTERNAL_CONTENT_URI, photos.getString(idIndex));
            asset.putString("uri", photoUri.toString());
            asset.putString("source", photoUri.toString());
        }

        float width = photos.getInt(widthIndex);
        float height = photos.getInt(heightIndex);
        String mediaType = isVideo ? "video" : "photo";
        asset.putDouble("width", width);
        asset.putDouble("height", height);
        asset.putString("fileName", photos.getString(photos.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME)));
        asset.putString("mimeType", photos.getString(mimeTypeIndex));
        asset.putString("mediaType", mediaType);
        asset.putDouble("creationDate", photos.getLong(dateAddedIndex));
        asset.putString("id", photos.getString(idIndex));
        return true;
    }
}