diff --git a/CameraRollManager.java b/CameraRollManager.java new file mode 100644 index 000000000..289a045bf --- /dev/null +++ b/CameraRollManager.java @@ -0,0 +1,444 @@ +/** + * 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.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.res.AssetFileDescriptor; +import android.database.Cursor; +import android.graphics.BitmapFactory; +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.Images; +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.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; + +// TODO #6015104: rename to something less iOSish +/** + * {@link NativeModule} that allows JS to interact with the photos on the device (i.e. + * {@link MediaStore.Images}). + */ +public class CameraRollManager extends ReactContextBaseJavaModule { + + private static final String TAG = "Catalyst/CameraRollManager"; + + public static final boolean IS_JELLY_BEAN_OR_LATER = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN; + + private static final String[] PROJECTION; + static { + if (IS_JELLY_BEAN_OR_LATER) { + PROJECTION = new String[] { + Images.Media._ID, + Images.Media.MIME_TYPE, + Images.Media.BUCKET_DISPLAY_NAME, + Images.Media.DATE_TAKEN, + Images.Media.WIDTH, + Images.Media.HEIGHT, + Images.Media.LONGITUDE, + Images.Media.LATITUDE + }; + } else { + PROJECTION = new String[] { + Images.Media._ID, + Images.Media.MIME_TYPE, + Images.Media.BUCKET_DISPLAY_NAME, + Images.Media.DATE_TAKEN, + Images.Media.LONGITUDE, + Images.Media.LATITUDE + }; + } + } + + private static final String SELECTION_BUCKET = Images.Media.BUCKET_DISPLAY_NAME + " = ?"; + private static final String SELECTION_DATE_TAKEN = Images.Media.DATE_TAKEN + " < ?"; + + public CameraRollManager(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + public String getName() { + return "RKCameraRollManager"; + } + + @Override + public Map getConstants() { + return Collections.emptyMap(); + } + + /** + * 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 success callback to be invoked on successful save to gallery; the only argument passed + * to this callback is the MediaStore content:// URI of the new image. + * @param error callback to be invoked on error (e.g. can't copy file, external storage not + * available etc.) + */ + @ReactMethod + public void saveImageWithTag(String uri, final Callback success, final Callback error) { + new SaveImageTag(getReactApplicationContext(), Uri.parse(uri), success, error) + .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + private static class SaveImageTag extends GuardedAsyncTask { + + private final Context mContext; + private final Uri mUri; + private final Callback mSuccess; + private final Callback mError; + + public SaveImageTag(ReactContext context, Uri uri, Callback success, Callback error) { + super(context); + mContext = context; + mUri = uri; + mSuccess = success; + mError = error; + } + + @Override + protected void doInBackgroundGuarded(Void... params) { + File source = new File(mUri.getPath()); + FileChannel input = null, output = null; + try { + File pictures = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES); + pictures.mkdirs(); + if (!pictures.isDirectory()) { + mError.invoke("External storage pictures directory not available"); + return; + } + File dest = new File(pictures, 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(pictures, 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) { + mSuccess.invoke(uri.toString()); + } else { + mError.invoke("Could not add image to gallery"); + } + } + }); + } catch (IOException e) { + mError.invoke(e.getMessage()); + } 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); + } + } + } + } + } + + /** + * Get photos from {@link MediaStore.Images}, most recent first. + * + * @param params a map containing the following keys: + * + * @param success the callback to be called when the photos are loaded; for a format of the + * parameters passed to this callback, see {@code getPhotosReturnChecker} in CameraRoll.js + * @param error the callback to be called on error + */ + @ReactMethod + public void getPhotos(final ReadableMap params, final Callback success, Callback error) { + int first = params.getInt("first"); + String after = params.hasKey("after") ? params.getString("after") : null; + String groupName = params.hasKey("groupName") ? params.getString("groupName") : 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, + groupName, + mimeTypes, + success, + error) + .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + private static class GetPhotosTask extends GuardedAsyncTask { + private final Context mContext; + private final int mFirst; + private final @Nullable String mAfter; + private final @Nullable String mGroupName; + private final @Nullable ReadableArray mMimeTypes; + private final Callback mSuccess; + private final Callback mError; + + private GetPhotosTask( + ReactContext context, + int first, + @Nullable String after, + @Nullable String groupName, + @Nullable ReadableArray mimeTypes, + Callback success, + Callback error) { + super(context); + mContext = context; + mFirst = first; + mAfter = after; + mGroupName = groupName; + mMimeTypes = mimeTypes; + mSuccess = success; + mError = error; + } + + @Override + protected void doInBackgroundGuarded(Void... params) { + StringBuilder selection = new StringBuilder("1"); + List selectionArgs = new ArrayList<>(); + if (!TextUtils.isEmpty(mAfter)) { + selection.append(" AND " + SELECTION_DATE_TAKEN); + selectionArgs.add(mAfter); + } + if (!TextUtils.isEmpty(mGroupName)) { + selection.append(" AND " + SELECTION_BUCKET); + selectionArgs.add(mGroupName); + } + if (mMimeTypes != null && mMimeTypes.size() > 0) { + selection.append(" AND " + Images.Media.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 { + Cursor photos = resolver.query( + Images.Media.EXTERNAL_CONTENT_URI, + PROJECTION, + selection.toString(), + selectionArgs.toArray(new String[selectionArgs.size()]), + Images.Media.DATE_TAKEN + " DESC, " + Images.Media.DATE_MODIFIED + " DESC LIMIT " + + (mFirst + 1)); // set LIMIT to first + 1 so that we know how to populate page_info + if (photos == null) { + mError.invoke("Could not get photos"); + } else { + try { + putEdges(resolver, photos, response, mFirst); + putPageInfo(photos, response, mFirst); + } finally { + photos.close(); + mSuccess.invoke(response); + } + } + } catch (SecurityException e) { + mError.invoke("Could not get photos: need READ_EXTERNAL_STORAGE permission"); + } + } + } + + 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(Images.Media.DATE_TAKEN))); + } + response.putMap("page_info", pageInfo); + } + + private static void putEdges( + ContentResolver resolver, + Cursor photos, + WritableMap response, + int limit) { + WritableArray edges = new WritableNativeArray(); + photos.moveToFirst(); + int idIndex = photos.getColumnIndex(Images.Media._ID); + int mimeTypeIndex = photos.getColumnIndex(Images.Media.MIME_TYPE); + int groupNameIndex = photos.getColumnIndex(Images.Media.BUCKET_DISPLAY_NAME); + int dateTakenIndex = photos.getColumnIndex(Images.Media.DATE_TAKEN); + int widthIndex = IS_JELLY_BEAN_OR_LATER ? photos.getColumnIndex(Images.Media.WIDTH) : -1; + int heightIndex = IS_JELLY_BEAN_OR_LATER ? photos.getColumnIndex(Images.Media.HEIGHT) : -1; + int longitudeIndex = photos.getColumnIndex(Images.Media.LONGITUDE); + int latitudeIndex = photos.getColumnIndex(Images.Media.LATITUDE); + + for (int i = 0; i < limit && !photos.isAfterLast(); i++) { + WritableMap edge = new WritableNativeMap(); + WritableMap node = new WritableNativeMap(); + boolean imageInfoSuccess = + putImageInfo(resolver, photos, node, idIndex, widthIndex, heightIndex); + if (imageInfoSuccess) { + putBasicNodeInfo(photos, node, mimeTypeIndex, groupNameIndex, dateTakenIndex); + putLocationInfo(photos, node, longitudeIndex, latitudeIndex); + + edge.putMap("node", node); + edges.pushMap(edge); + } 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("edges", edges); + } + + private static void putBasicNodeInfo( + Cursor photos, + WritableMap node, + int mimeTypeIndex, + int groupNameIndex, + int dateTakenIndex) { + node.putString("type", photos.getString(mimeTypeIndex)); + node.putString("group_name", photos.getString(groupNameIndex)); + node.putDouble("timestamp", photos.getLong(dateTakenIndex) / 1000d); + } + + private static boolean putImageInfo( + ContentResolver resolver, + Cursor photos, + WritableMap node, + int idIndex, + int widthIndex, + int heightIndex) { + WritableMap image = new WritableNativeMap(); + Uri photoUri = Uri.withAppendedPath( + Images.Media.EXTERNAL_CONTENT_URI, + photos.getString(idIndex)); + image.putString("uri", photoUri.toString()); + float width = -1; + float height = -1; + if (IS_JELLY_BEAN_OR_LATER) { + width = photos.getInt(widthIndex); + height = photos.getInt(heightIndex); + } + if (width <= 0 || height <= 0) { + try { + AssetFileDescriptor photoDescriptor = resolver.openAssetFileDescriptor(photoUri, "r"); + BitmapFactory.Options options = new BitmapFactory.Options(); + // Set inJustDecodeBounds to true so we don't actually load the Bitmap, but only get its + // dimensions instead. + options.inJustDecodeBounds = true; + BitmapFactory.decodeFileDescriptor(photoDescriptor.getFileDescriptor(), null, options); + photoDescriptor.close(); + + width = options.outWidth; + height = options.outHeight; + } catch (IOException e) { + FLog.e(TAG, "Could not get width/height for " + photoUri.toString(), e); + return false; + } + } + image.putDouble("width", width); + image.putDouble("height", height); + node.putMap("image", image); + return true; + } + + private static void putLocationInfo( + Cursor photos, + WritableMap node, + int longitudeIndex, + int latitudeIndex) { + double longitude = photos.getDouble(longitudeIndex); + double latitude = photos.getDouble(latitudeIndex); + if (longitude > 0 || latitude > 0) { + WritableMap location = new WritableNativeMap(); + location.putDouble("longitude", longitude); + location.putDouble("latitude", latitude); + node.putMap("location", location); + } + } + +}