diff --git a/Examples/UIExplorer/CameraRollExample.ios.js b/Examples/UIExplorer/CameraRollExample.js similarity index 75% rename from Examples/UIExplorer/CameraRollExample.ios.js rename to Examples/UIExplorer/CameraRollExample.js index d1f3e96c9..d43156072 100644 --- a/Examples/UIExplorer/CameraRollExample.ios.js +++ b/Examples/UIExplorer/CameraRollExample.js @@ -15,24 +15,25 @@ */ 'use strict'; -var React = require('react-native'); -var { +const React = require('react-native'); +const { CameraRoll, Image, SliderIOS, StyleSheet, - SwitchIOS, + Switch, Text, View, TouchableOpacity } = React; -var CameraRollView = require('./CameraRollView.ios'); -var AssetScaledImageExampleView = require('./AssetScaledImageExample'); +const CameraRollView = require('./CameraRollView'); -var CAMERA_ROLL_VIEW = 'camera_roll_view'; +const AssetScaledImageExampleView = require('./AssetScaledImageExample'); -var CameraRollExample = React.createClass({ +const CAMERA_ROLL_VIEW = 'camera_roll_view'; + +const CameraRollExample = React.createClass({ getInitialState() { return { @@ -45,7 +46,7 @@ var CameraRollExample = React.createClass({ render() { return ( - {(this.state.bigImages ? 'Big' : 'Small') + ' Images'} @@ -65,18 +66,20 @@ var CameraRollExample = React.createClass({ }, loadAsset(asset){ - this.props.navigator.push({ - title: 'Camera Roll Image', - component: AssetScaledImageExampleView, - backButtonTitle: 'Back', - passProps: { asset: asset }, - }); + if (this.props.navigator) { + this.props.navigator.push({ + title: 'Camera Roll Image', + component: AssetScaledImageExampleView, + backButtonTitle: 'Back', + passProps: { asset: asset }, + }); + } }, _renderImage(asset) { - var imageSize = this.state.bigImages ? 150 : 75; - var imageStyle = [styles.image, {width: imageSize, height: imageSize}]; - var location = asset.node.location.longitude ? + const imageSize = this.state.bigImages ? 150 : 75; + const imageStyle = [styles.image, {width: imageSize, height: imageSize}]; + const location = asset.node.location.longitude ? JSON.stringify(asset.node.location) : 'Unknown location'; return ( @@ -97,9 +100,9 @@ var CameraRollExample = React.createClass({ }, _onSliderChange(value) { - var options = CameraRoll.GroupTypesOptions; - var index = Math.floor(value * options.length * 0.99); - var groupTypes = options[index]; + const options = CameraRoll.GroupTypesOptions; + const index = Math.floor(value * options.length * 0.99); + const groupTypes = options[index]; if (groupTypes !== this.state.groupTypes) { this.setState({groupTypes: groupTypes}); } @@ -111,7 +114,7 @@ var CameraRollExample = React.createClass({ } }); -var styles = StyleSheet.create({ +const styles = StyleSheet.create({ row: { flexDirection: 'row', flex: 1, diff --git a/Examples/UIExplorer/CameraRollView.ios.js b/Examples/UIExplorer/CameraRollView.js similarity index 97% rename from Examples/UIExplorer/CameraRollView.ios.js rename to Examples/UIExplorer/CameraRollView.js index 74507aa2c..a8824d048 100644 --- a/Examples/UIExplorer/CameraRollView.ios.js +++ b/Examples/UIExplorer/CameraRollView.js @@ -22,6 +22,7 @@ var { CameraRoll, Image, ListView, + Platform, StyleSheet, View, } = React; @@ -139,6 +140,10 @@ var CameraRollView = React.createClass({ groupTypes: this.props.groupTypes, assetType: this.props.assetType, }; + if (Platform.OS === "android") { + // not supported in android + delete fetchParams.groupTypes; + } if (this.state.lastCursor) { fetchParams.after = this.state.lastCursor; } diff --git a/Examples/UIExplorer/UIExplorerList.android.js b/Examples/UIExplorer/UIExplorerList.android.js index 607f581fb..1976128b1 100644 --- a/Examples/UIExplorer/UIExplorerList.android.js +++ b/Examples/UIExplorer/UIExplorerList.android.js @@ -43,6 +43,7 @@ var APIS = [ require('./AccessibilityAndroidExample.android'), require('./AlertExample').AlertExample, require('./BorderExample'), + require('./CameraRollExample'), require('./ClipboardExample'), require('./GeolocationExample'), require('./IntentAndroidExample.android'), diff --git a/Examples/UIExplorer/UIExplorerList.ios.js b/Examples/UIExplorer/UIExplorerList.ios.js index 512b033ac..0c1e9e2e7 100644 --- a/Examples/UIExplorer/UIExplorerList.ios.js +++ b/Examples/UIExplorer/UIExplorerList.ios.js @@ -66,7 +66,7 @@ var APIS = [ require('./AppStateIOSExample'), require('./AsyncStorageExample'), require('./BorderExample'), - require('./CameraRollExample.ios'), + require('./CameraRollExample'), require('./ClipboardExample'), require('./GeolocationExample'), require('./LayoutExample'), diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/camera/CameraRollManager.java b/ReactAndroid/src/main/java/com/facebook/react/modules/camera/CameraRollManager.java new file mode 100644 index 000000000..289a045bf --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/camera/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: + *
    + *
  • first (mandatory): a number representing the number of photos to fetch
  • + *
  • + * after (optional): a cursor that matches page_info[end_cursor] returned by a + * previous call to {@link #getPhotos} + *
  • + *
  • groupName (optional): an album name
  • + *
  • + * mimeType (optional): restrict returned images to a specific mimetype (e.g. + * image/jpeg) + *
  • + *
+ * @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); + } + } + +} 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 e636cd1c0..6d98ea60a 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java +++ b/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java @@ -17,6 +17,7 @@ import com.facebook.react.ReactPackage; import com.facebook.react.bridge.JavaScriptModule; import com.facebook.react.bridge.NativeModule; import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.modules.camera.CameraRollManager; import com.facebook.react.modules.dialog.DialogModule; import com.facebook.react.modules.fresco.FrescoModule; import com.facebook.react.modules.intent.IntentModule; @@ -57,6 +58,7 @@ public class MainReactPackage implements ReactPackage { public List createNativeModules(ReactApplicationContext reactContext) { return Arrays.asList( new AsyncStorageModule(reactContext), + new CameraRollManager(reactContext), new ClipboardModule(reactContext), new DialogModule(reactContext), new FrescoModule(reactContext),