Files
react-native-cameraroll/android/src/main/java/com/reactnativecommunity/cameraroll/CameraRollModule.java
T

580 lines
21 KiB
Java
Raw Normal View History

2016-01-14 04:31:51 -08:00
/**
2018-09-11 15:27:47 -07:00
* Copyright (c) Facebook, Inc. and its affiliates.
2016-01-14 04:31:51 -08:00
*
2018-02-16 18:24:55 -08:00
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
2016-01-14 04:31:51 -08:00
*/
2019-02-24 12:29:24 +08:00
package com.reactnativecommunity.cameraroll;
2016-01-14 04:31:51 -08:00
import android.content.ContentResolver;
import android.content.ContentUris;
2016-01-14 04:31:51 -08:00
import android.content.Context;
import android.content.res.AssetFileDescriptor;
import android.database.Cursor;
import android.graphics.BitmapFactory;
import android.media.MediaMetadataRetriever;
2016-01-14 04:31:51 -08:00
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Environment;
import android.provider.MediaStore;
import android.provider.MediaStore.Images;
import android.text.TextUtils;
2016-01-14 04:31:51 -08:00
import com.facebook.common.logging.FLog;
import com.facebook.react.bridge.GuardedAsyncTask;
2017-08-02 19:59:54 -07:00
import com.facebook.react.bridge.JSApplicationIllegalArgumentException;
import com.facebook.react.bridge.NativeModule;
2016-01-21 08:07:01 -08:00
import com.facebook.react.bridge.Promise;
2016-01-14 04:31:51 -08:00
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.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;
2017-08-02 19:59:54 -07:00
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.List;
2016-01-14 04:31:51 -08:00
import javax.annotation.Nullable;
2016-01-14 04:31:51 -08:00
/**
* {@link NativeModule} that allows JS to interact with the photos and videos on the device (i.e.
2016-01-14 04:31:51 -08:00
* {@link MediaStore.Images}).
*/
@ReactModule(name = CameraRollModule.NAME)
public class CameraRollModule extends ReactContextBaseJavaModule {
2016-01-14 04:31:51 -08:00
2019-03-03 14:39:57 +08:00
public static final String NAME = "RNCCameraRoll";
2017-01-31 04:49:58 -08:00
2016-01-21 08:07:01 -08:00
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";
private static final String ERROR_UNABLE_TO_DELETE = "E_UNABLE_TO_DELETE";
private static final String ERROR_UNABLE_TO_FILTER = "E_UNABLE_TO_FILTER";
private static final String ASSET_TYPE_PHOTOS = "Photos";
private static final String ASSET_TYPE_VIDEOS = "Videos";
private static final String ASSET_TYPE_ALL = "All";
private static final String[] PROJECTION = {
Images.Media._ID,
Images.Media.MIME_TYPE,
Images.Media.BUCKET_DISPLAY_NAME,
Images.Media.DATE_TAKEN,
MediaStore.MediaColumns.WIDTH,
MediaStore.MediaColumns.HEIGHT,
Images.Media.LONGITUDE,
Images.Media.LATITUDE,
MediaStore.MediaColumns.DATA
};
2016-01-14 04:31:51 -08:00
private static final String SELECTION_BUCKET = Images.Media.BUCKET_DISPLAY_NAME + " = ?";
private static final String SELECTION_DATE_TAKEN = Images.Media.DATE_TAKEN + " < ?";
public CameraRollModule(ReactApplicationContext reactContext) {
2016-01-14 04:31:51 -08:00
super(reactContext);
}
2016-08-12 15:49:13 -07:00
@Override
public String getName() {
2017-01-31 04:49:58 -08:00
return NAME;
2016-08-12 15:49:13 -07:00
}
2016-01-14 04:31:51 -08:00
/**
* 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
2016-01-21 08:07:01 -08:00
* @param promise to be resolved or rejected
2016-01-14 04:31:51 -08:00
*/
@ReactMethod
public void saveToCameraRoll(String uri, ReadableMap options, Promise promise) {
new SaveToCameraRoll(getReactApplicationContext(), Uri.parse(uri), options, promise)
2016-01-14 04:31:51 -08:00
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
2016-06-07 16:37:48 -07:00
private static class SaveToCameraRoll extends GuardedAsyncTask<Void, Void> {
2016-01-14 04:31:51 -08:00
private final Context mContext;
private final Uri mUri;
2016-01-21 08:07:01 -08:00
private final Promise mPromise;
private final ReadableMap mOptions;
2016-01-14 04:31:51 -08:00
public SaveToCameraRoll(ReactContext context, Uri uri, ReadableMap options, Promise promise) {
2016-01-14 04:31:51 -08:00
super(context);
mContext = context;
mUri = uri;
2016-01-21 08:07:01 -08:00
mPromise = promise;
mOptions = options;
2016-01-14 04:31:51 -08:00
}
@Override
protected void doInBackgroundGuarded(Void... params) {
File source = new File(mUri.getPath());
FileChannel input = null, output = null;
try {
File environment = Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DCIM);
File exportDir;
if (!"".equals(mOptions.getString("album"))) {
exportDir = new File(environment, mOptions.getString("album"));
if (!exportDir.exists() && !exportDir.mkdirs()) {
mPromise.reject(ERROR_UNABLE_TO_LOAD, "Album Directory not created. Did you request WRITE_EXTERNAL_STORAGE?");
return;
}
} else {
exportDir = environment;
}
2016-06-07 16:37:48 -07:00
if (!exportDir.isDirectory()) {
mPromise.reject(ERROR_UNABLE_TO_LOAD, "External media storage directory not available");
2016-01-14 04:31:51 -08:00
return;
}
2016-06-07 16:37:48 -07:00
File dest = new File(exportDir, source.getName());
2016-01-14 04:31:51 -08:00
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()) {
2016-06-07 16:37:48 -07:00
dest = new File(exportDir, sourceName + "_" + (n++) + sourceExt);
2016-01-14 04:31:51 -08:00
}
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) {
2016-01-21 08:07:01 -08:00
mPromise.resolve(uri.toString());
2016-01-14 04:31:51 -08:00
} else {
2016-01-26 10:29:47 -08:00
mPromise.reject(ERROR_UNABLE_TO_SAVE, "Could not add image to gallery");
2016-01-14 04:31:51 -08:00
}
}
});
} catch (IOException e) {
2016-01-21 08:07:01 -08:00
mPromise.reject(e);
2016-01-14 04:31:51 -08:00
} 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>
* <li>
* assetType (optional): chooses between either photos or videos from the camera roll.
* Valid values are "Photos" or "Videos". Defaults to photos.
* </li>
2016-01-14 04:31:51 -08:00
* </ul>
2016-01-21 08:07:01 -08:00
* @param promise the Promise to be resolved when the photos are loaded; for a format of the
2016-01-14 04:31:51 -08:00
* parameters passed to this callback, see {@code getPhotosReturnChecker} in CameraRoll.js
*/
@ReactMethod
2016-01-21 08:07:01 -08:00
public void getPhotos(final ReadableMap params, final Promise promise) {
2016-01-14 04:31:51 -08:00
int first = params.getInt("first");
String after = params.hasKey("after") ? params.getString("after") : null;
String groupName = params.hasKey("groupName") ? params.getString("groupName") : null;
String assetType = params.hasKey("assetType") ? params.getString("assetType") : ASSET_TYPE_PHOTOS;
2016-01-14 04:31:51 -08:00
ReadableArray mimeTypes = params.hasKey("mimeTypes")
? params.getArray("mimeTypes")
: null;
new GetMediaTask(
2016-01-14 04:31:51 -08:00
getReactApplicationContext(),
first,
after,
groupName,
mimeTypes,
assetType,
2016-01-21 08:07:01 -08:00
promise)
2016-01-14 04:31:51 -08:00
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
private static class GetMediaTask extends GuardedAsyncTask<Void, Void> {
2016-01-14 04:31:51 -08:00
private final Context mContext;
private final int mFirst;
private final @Nullable String mAfter;
private final @Nullable String mGroupName;
private final @Nullable ReadableArray mMimeTypes;
2016-01-21 08:07:01 -08:00
private final Promise mPromise;
private final String mAssetType;
2016-01-14 04:31:51 -08:00
private GetMediaTask(
2016-01-14 04:31:51 -08:00
ReactContext context,
int first,
@Nullable String after,
@Nullable String groupName,
@Nullable ReadableArray mimeTypes,
String assetType,
2016-01-21 08:07:01 -08:00
Promise promise) {
2016-01-14 04:31:51 -08:00
super(context);
mContext = context;
mFirst = first;
mAfter = after;
mGroupName = groupName;
mMimeTypes = mimeTypes;
2016-01-21 08:07:01 -08:00
mPromise = promise;
mAssetType = assetType;
2016-01-14 04:31:51 -08:00
}
@Override
protected void doInBackgroundGuarded(Void... params) {
StringBuilder selection = new StringBuilder("1");
List<String> selectionArgs = new ArrayList<>();
if (!TextUtils.isEmpty(mGroupName)) {
selection.append(" AND " + SELECTION_BUCKET);
selectionArgs.add(mGroupName);
}
if (mAssetType.equals(ASSET_TYPE_PHOTOS)) {
selection.append(" AND " + MediaStore.Files.FileColumns.MEDIA_TYPE + " = "
+ MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE);
} else if (mAssetType.equals(ASSET_TYPE_VIDEOS)) {
selection.append(" AND " + MediaStore.Files.FileColumns.MEDIA_TYPE + " = "
+ MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO);
} else if (mAssetType.equals(ASSET_TYPE_ALL)) {
selection.append(" AND " + MediaStore.Files.FileColumns.MEDIA_TYPE + " IN ("
+ MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO + ","
+ MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE + ")");
} else {
mPromise.reject(
ERROR_UNABLE_TO_FILTER,
"Invalid filter option: '" + mAssetType + "'. Expected one of '"
+ ASSET_TYPE_PHOTOS + "', '" + ASSET_TYPE_VIDEOS + "' or '" + ASSET_TYPE_ALL + "'."
);
return;
}
2016-01-14 04:31:51 -08:00
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(), ")");
}
2016-01-14 04:31:51 -08:00
WritableMap response = new WritableNativeMap();
ContentResolver resolver = mContext.getContentResolver();
2016-01-14 04:31:51 -08:00
try {
// set LIMIT to first + 1 so that we know how to populate page_info
String limit = "limit=" + (mFirst + 1);
if (!TextUtils.isEmpty(mAfter)) {
limit = "limit=" + mAfter + "," + (mFirst + 1);
}
Cursor media = resolver.query(
MediaStore.Files.getContentUri("external").buildUpon().encodedQuery(limit).build(),
2016-01-14 04:31:51 -08:00
PROJECTION,
selection.toString(),
selectionArgs.toArray(new String[selectionArgs.size()]),
Images.Media.DATE_ADDED + " DESC, " + Images.Media.DATE_MODIFIED + " DESC");
if (media == null) {
mPromise.reject(ERROR_UNABLE_TO_LOAD, "Could not get media");
2016-01-14 04:31:51 -08:00
} else {
try {
putEdges(resolver, media, response, mFirst);
putPageInfo(media, response, mFirst, !TextUtils.isEmpty(mAfter) ? Integer.parseInt(mAfter) : 0);
2016-01-14 04:31:51 -08:00
} finally {
media.close();
2016-01-21 08:07:01 -08:00
mPromise.resolve(response);
2016-01-14 04:31:51 -08:00
}
}
} catch (SecurityException e) {
2016-01-21 08:07:01 -08:00
mPromise.reject(
ERROR_UNABLE_TO_LOAD_PERMISSION,
"Could not get media: need READ_EXTERNAL_STORAGE permission",
2016-01-21 08:07:01 -08:00
e);
2016-01-14 04:31:51 -08:00
}
}
}
private static void putPageInfo(Cursor media, WritableMap response, int limit, int offset) {
2016-01-14 04:31:51 -08:00
WritableMap pageInfo = new WritableNativeMap();
pageInfo.putBoolean("has_next_page", limit < media.getCount());
if (limit < media.getCount()) {
2016-01-14 04:31:51 -08:00
pageInfo.putString(
"end_cursor",
Integer.toString(offset + limit)
);
2016-01-14 04:31:51 -08:00
}
response.putMap("page_info", pageInfo);
}
private static void putEdges(
ContentResolver resolver,
Cursor media,
2016-01-14 04:31:51 -08:00
WritableMap response,
int limit) {
2016-01-14 04:31:51 -08:00
WritableArray edges = new WritableNativeArray();
media.moveToFirst();
int idIndex = media.getColumnIndex(Images.Media._ID);
int mimeTypeIndex = media.getColumnIndex(Images.Media.MIME_TYPE);
int groupNameIndex = media.getColumnIndex(Images.Media.BUCKET_DISPLAY_NAME);
int dateTakenIndex = media.getColumnIndex(Images.Media.DATE_TAKEN);
int widthIndex = media.getColumnIndex(MediaStore.MediaColumns.WIDTH);
int heightIndex = media.getColumnIndex(MediaStore.MediaColumns.HEIGHT);
int longitudeIndex = media.getColumnIndex(Images.Media.LONGITUDE);
int latitudeIndex = media.getColumnIndex(Images.Media.LATITUDE);
int dataIndex = media.getColumnIndex(MediaStore.MediaColumns.DATA);
2016-01-14 04:31:51 -08:00
for (int i = 0; i < limit && !media.isAfterLast(); i++) {
2016-01-14 04:31:51 -08:00
WritableMap edge = new WritableNativeMap();
WritableMap node = new WritableNativeMap();
boolean imageInfoSuccess =
putImageInfo(resolver, media, node, idIndex, widthIndex, heightIndex, dataIndex, mimeTypeIndex);
2016-01-14 04:31:51 -08:00
if (imageInfoSuccess) {
putBasicNodeInfo(media, node, mimeTypeIndex, groupNameIndex, dateTakenIndex);
putLocationInfo(media, node, longitudeIndex, latitudeIndex);
2016-01-14 04:31:51 -08:00
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--;
}
media.moveToNext();
2016-01-14 04:31:51 -08:00
}
response.putArray("edges", edges);
}
private static void putBasicNodeInfo(
Cursor media,
2016-01-14 04:31:51 -08:00
WritableMap node,
int mimeTypeIndex,
int groupNameIndex,
int dateTakenIndex) {
node.putString("type", media.getString(mimeTypeIndex));
node.putString("group_name", media.getString(groupNameIndex));
node.putDouble("timestamp", media.getLong(dateTakenIndex) / 1000d);
2016-01-14 04:31:51 -08:00
}
private static boolean putImageInfo(
ContentResolver resolver,
Cursor media,
2016-01-14 04:31:51 -08:00
WritableMap node,
int idIndex,
int widthIndex,
2017-08-02 19:59:54 -07:00
int heightIndex,
int dataIndex,
int mimeTypeIndex) {
2016-01-14 04:31:51 -08:00
WritableMap image = new WritableNativeMap();
Uri photoUri = Uri.parse("file://" + media.getString(dataIndex));
File file = new File(media.getString(dataIndex));
String strFileName = file.getName();
2016-01-14 04:31:51 -08:00
image.putString("uri", photoUri.toString());
image.putString("filename", strFileName);
float width = media.getInt(widthIndex);
float height = media.getInt(heightIndex);
2017-08-15 11:36:44 -07:00
String mimeType = media.getString(mimeTypeIndex);
if (mimeType != null
&& mimeType.startsWith("video")) {
2016-01-14 04:31:51 -08:00
try {
AssetFileDescriptor photoDescriptor = resolver.openAssetFileDescriptor(photoUri, "r");
2017-08-15 11:36:44 -07:00
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
retriever.setDataSource(photoDescriptor.getFileDescriptor());
2017-10-09 22:34:40 -07:00
try {
if (width <= 0 || height <= 0) {
width =
Integer.parseInt(
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH));
height =
Integer.parseInt(
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT));
}
int timeInMillisec =
Integer.parseInt(
2017-10-09 22:34:40 -07:00
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION));
int playableDuration = timeInMillisec / 1000;
image.putInt("playableDuration", playableDuration);
} catch (NumberFormatException e) {
FLog.e(
ReactConstants.TAG,
"Number format exception occurred while trying to fetch video metadata for "
+ photoUri.toString(),
e);
return false;
} finally {
retriever.release();
photoDescriptor.close();
}
} catch (Exception e) {
2017-08-15 11:36:44 -07:00
FLog.e(ReactConstants.TAG, "Could not get video metadata for " + photoUri.toString(), e);
return false;
}
}
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);
width = options.outWidth;
height = options.outHeight;
2016-01-14 04:31:51 -08:00
photoDescriptor.close();
} catch (IOException e) {
2016-01-26 10:29:47 -08:00
FLog.e(ReactConstants.TAG, "Could not get width/height for " + photoUri.toString(), e);
2016-01-14 04:31:51 -08:00
return false;
}
}
image.putDouble("width", width);
image.putDouble("height", height);
node.putMap("image", image);
2016-01-14 04:31:51 -08:00
return true;
}
private static void putLocationInfo(
Cursor media,
2016-01-14 04:31:51 -08:00
WritableMap node,
int longitudeIndex,
int latitudeIndex) {
double longitude = media.getDouble(longitudeIndex);
double latitude = media.getDouble(latitudeIndex);
2016-01-14 04:31:51 -08:00
if (longitude > 0 || latitude > 0) {
WritableMap location = new WritableNativeMap();
location.putDouble("longitude", longitude);
location.putDouble("latitude", latitude);
node.putMap("location", location);
}
}
/**
* Delete a set of images.
*
* @param uris array of file:// URIs of the images to delete
* @param promise to be resolved
*/
@ReactMethod
public void deletePhotos(ReadableArray uris, Promise promise) {
if (uris.size() == 0) {
promise.reject(ERROR_UNABLE_TO_DELETE, "Need at least one URI to delete");
} else {
new DeletePhotos(getReactApplicationContext(), uris, promise)
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
}
private static class DeletePhotos extends GuardedAsyncTask<Void, Void> {
private final Context mContext;
private final ReadableArray mUris;
private final Promise mPromise;
public DeletePhotos(ReactContext context, ReadableArray uris, Promise promise) {
super(context);
mContext = context;
mUris = uris;
mPromise = promise;
}
@Override
protected void doInBackgroundGuarded(Void... params) {
ContentResolver resolver = mContext.getContentResolver();
// Set up the projection (we only need the ID)
String[] projection = { MediaStore.Images.Media._ID };
// Match on the file path
String innerWhere = "?";
for (int i = 1; i < mUris.size(); i++) {
innerWhere += ", ?";
}
String selection = MediaStore.Images.Media.DATA + " IN (" + innerWhere + ")";
// Query for the ID of the media matching the file path
Uri queryUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
String[] selectionArgs = new String[mUris.size()];
for (int i = 0; i < mUris.size(); i++) {
Uri uri = Uri.parse(mUris.getString(i));
selectionArgs[i] = uri.getPath();
}
Cursor cursor = resolver.query(queryUri, projection, selection, selectionArgs, null);
int deletedCount = 0;
while (cursor.moveToNext()) {
long id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID));
Uri deleteUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
if (resolver.delete(deleteUri, null, null) == 1) {
deletedCount++;
}
}
cursor.close();
if (deletedCount == mUris.size()) {
mPromise.resolve(null);
} else {
mPromise.reject(ERROR_UNABLE_TO_DELETE,
"Could not delete all media, only deleted " + deletedCount + " photos.");
}
}
}
2016-01-14 04:31:51 -08:00
}