mirror of
https://github.com/status-im/react-native-cameraroll.git
synced 2025-01-12 19:04:13 +00:00
57af9ecbe0
Summary: This PR adds the ability to export videos to the CameraRoll on both Android and iOS (previously only photos were possible, at least on iOS). The API has changed as follows: ``` // old saveImageWithTag(tag: string): Promise<string> // new saveToCameraRoll(tag: string, type?: 'photo' | 'video'): Promise<string> ``` if no `type` parameter is passed, `video` is inferred if the tag ends with ".mov" or ".mp4", otherwise `photo` is assumed. I've left in the `saveImageWithTag` method for now with a deprecation warning. **Test plan (required)** I created the following very simple app to test exporting photos and videos to the CameraRoll, and ran it on both iOS and Android. The functionality works as intended on both platforms. ```js // index.js /** * Sample React Native App * https://github.com/facebook/react-native * flow */ import React, { Component } from 'react'; import { AppRegistry, StyleSheet, Text, View, CameraRoll, } from 'react-native'; import FS fro Closes https://github.com/facebook/react-native/pull/7988 Differential Revision: D3401251 Pulled By: nicklockwood fbshipit-source-id: af3fc24e6fa5b84ac377e9173f3709c6f9795f20
445 lines
16 KiB
Java
445 lines
16 KiB
Java
/**
|
|
* 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.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;
|
|
|
|
// 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 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";
|
|
|
|
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<String, Object> 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 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 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,
|
|
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 mGroupName;
|
|
private final @Nullable ReadableArray mMimeTypes;
|
|
private final Promise mPromise;
|
|
|
|
private GetPhotosTask(
|
|
ReactContext context,
|
|
int first,
|
|
@Nullable String after,
|
|
@Nullable String groupName,
|
|
@Nullable ReadableArray mimeTypes,
|
|
Promise promise) {
|
|
super(context);
|
|
mContext = context;
|
|
mFirst = first;
|
|
mAfter = after;
|
|
mGroupName = groupName;
|
|
mMimeTypes = mimeTypes;
|
|
mPromise = promise;
|
|
}
|
|
|
|
@Override
|
|
protected void doInBackgroundGuarded(Void... params) {
|
|
StringBuilder selection = new StringBuilder("1");
|
|
List<String> 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) {
|
|
mPromise.reject(ERROR_UNABLE_TO_LOAD, "Could not get photos");
|
|
} else {
|
|
try {
|
|
putEdges(resolver, photos, response, mFirst);
|
|
putPageInfo(photos, response, mFirst);
|
|
} finally {
|
|
photos.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(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(ReactConstants.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);
|
|
}
|
|
}
|
|
|
|
}
|