feat(lib): save photos or videos to an album

* add option to specify album in saveToCameraRoll and move the optional type param to options

* check platform before setting default value for group types to prevent exception

* adjust typings

* update invariant message

* format code

* extract new implementation to function to avoid breaking change

* format code

* add missing spaces

* fix(lib): add accidentally removed savedphotos back to the enum to prevent crash

* chore(lib): formatting

* chore(lib): add doc for the new save method
This commit is contained in:
SimonErm 2019-08-14 21:00:20 +02:00 committed by Bartol Karuza
parent 344b3a93a4
commit dc00a4f115
7 changed files with 157 additions and 58 deletions

View File

@ -71,6 +71,7 @@ On Android permission is required to read the external storage. Add below line t
### Methods ### Methods
* [`saveToCameraRoll`](#savetocameraroll) * [`saveToCameraRoll`](#savetocameraroll)
* [`save`](#save)
* [`getPhotos`](#getphotos) * [`getPhotos`](#getphotos)
--- ---
@ -79,13 +80,21 @@ On Android permission is required to read the external storage. Add below line t
## Methods ## Methods
### `save()`
Saves the photo or video of a particular type to an album.
```javascript
CameraRoll.save(tag, { type, album })
```
### `saveToCameraRoll()` ### `saveToCameraRoll()`
```javascript ```javascript
CameraRoll.saveToCameraRoll(tag, [type]); CameraRoll.saveToCameraRoll(tag, [type]);
``` ```
Saves the photo or video to the camera roll or photo library. Saves the photo or video to the photo library.
On Android, the tag must be a local image or video URI, such as `"file:///sdcard/img.png"`. On Android, the tag must be a local image or video URI, such as `"file:///sdcard/img.png"`.

View File

@ -100,8 +100,8 @@ public class CameraRollModule extends ReactContextBaseJavaModule {
* @param promise to be resolved or rejected * @param promise to be resolved or rejected
*/ */
@ReactMethod @ReactMethod
public void saveToCameraRoll(String uri, String type, Promise promise) { public void saveToCameraRoll(String uri, ReadableMap options, Promise promise) {
new SaveToCameraRoll(getReactApplicationContext(), Uri.parse(uri), promise) new SaveToCameraRoll(getReactApplicationContext(), Uri.parse(uri), options, promise)
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
} }
@ -110,12 +110,14 @@ public class CameraRollModule extends ReactContextBaseJavaModule {
private final Context mContext; private final Context mContext;
private final Uri mUri; private final Uri mUri;
private final Promise mPromise; private final Promise mPromise;
private final ReadableMap mOptions;
public SaveToCameraRoll(ReactContext context, Uri uri, Promise promise) { public SaveToCameraRoll(ReactContext context, Uri uri, ReadableMap options, Promise promise) {
super(context); super(context);
mContext = context; mContext = context;
mUri = uri; mUri = uri;
mPromise = promise; mPromise = promise;
mOptions = options;
} }
@Override @Override
@ -123,8 +125,25 @@ public class CameraRollModule extends ReactContextBaseJavaModule {
File source = new File(mUri.getPath()); File source = new File(mUri.getPath());
FileChannel input = null, output = null; FileChannel input = null, output = null;
try { try {
File exportDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM); File environment;
exportDir.mkdirs(); if ("mov".equals(mOptions.getString("type"))) {
environment = Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_MOVIES);
} else {
environment = Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_PICTURES);
}
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;
}
if (!exportDir.isDirectory()) { if (!exportDir.isDirectory()) {
mPromise.reject(ERROR_UNABLE_TO_LOAD, "External media storage directory not available"); mPromise.reject(ERROR_UNABLE_TO_LOAD, "External media storage directory not available");
return; return;

View File

@ -33,7 +33,7 @@ RCT_ENUM_CONVERTER(PHAssetCollectionSubtype, (@{
@"library": @(PHAssetCollectionSubtypeSmartAlbumUserLibrary), @"library": @(PHAssetCollectionSubtypeSmartAlbumUserLibrary),
@"photo-stream": @(PHAssetCollectionSubtypeAlbumMyPhotoStream), // incorrect, but legacy @"photo-stream": @(PHAssetCollectionSubtypeAlbumMyPhotoStream), // incorrect, but legacy
@"photostream": @(PHAssetCollectionSubtypeAlbumMyPhotoStream), @"photostream": @(PHAssetCollectionSubtypeAlbumMyPhotoStream),
@"saved-photos": @(PHAssetCollectionSubtypeAny), // incorrect, but legacy @"saved-photos": @(PHAssetCollectionSubtypeAny), // incorrect, but legacy correspondence in PHAssetCollectionSubtype
@"savedphotos": @(PHAssetCollectionSubtypeAny), // This was ALAssetsGroupSavedPhotos, seems to have no direct correspondence in PHAssetCollectionSubtype @"savedphotos": @(PHAssetCollectionSubtypeAny), // This was ALAssetsGroupSavedPhotos, seems to have no direct correspondence in PHAssetCollectionSubtype
}), PHAssetCollectionSubtypeAny, integerValue) }), PHAssetCollectionSubtypeAny, integerValue)
@ -98,36 +98,40 @@ static void requestPhotoLibraryAccess(RCTPromiseRejectBlock reject, PhotosAuthor
} }
RCT_EXPORT_METHOD(saveToCameraRoll:(NSURLRequest *)request RCT_EXPORT_METHOD(saveToCameraRoll:(NSURLRequest *)request
type:(NSString *)type options:(NSDictionary *)options
resolve:(RCTPromiseResolveBlock)resolve resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject) reject:(RCTPromiseRejectBlock)reject)
{ {
__block PHObjectPlaceholder *placeholder;
// We load images and videos differently. // We load images and videos differently.
// Images have many custom loaders which can load images from ALAssetsLibrary URLs, PHPhotoLibrary // Images have many custom loaders which can load images from ALAssetsLibrary URLs, PHPhotoLibrary
// URLs, `data:` URIs, etc. Video URLs are passed directly through for now; it may be nice to support // URLs, `data:` URIs, etc. Video URLs are passed directly through for now; it may be nice to support
// more ways of loading videos in the future. // more ways of loading videos in the future.
__block NSURL *inputURI = nil; __block NSURL *inputURI = nil;
__block UIImage *inputImage = nil; __block UIImage *inputImage = nil;
__block PHFetchResult *photosAsset;
__block PHAssetCollection *collection;
__block PHObjectPlaceholder *placeholder;
void (^saveBlock)(void) = ^void() { void (^saveBlock)(void) = ^void() {
// performChanges and the completionHandler are called on // performChanges and the completionHandler are called on
// arbitrary threads, not the main thread - this is safe // arbitrary threads, not the main thread - this is safe
// for now since all JS is queued and executed on a single thread. // for now since all JS is queued and executed on a single thread.
// We should reevaluate this if that assumption changes. // We should reevaluate this if that assumption changes.
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{ [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
PHAssetChangeRequest *changeRequest; PHAssetChangeRequest *assetRequest ;
if ([options[@"type"] isEqualToString:@"video"]) {
// Defaults to "photo". `type` is an optional param. assetRequest = [PHAssetChangeRequest creationRequestForAssetFromVideoAtFileURL:inputURI];
if ([type isEqualToString:@"video"]) {
changeRequest = [PHAssetChangeRequest creationRequestForAssetFromVideoAtFileURL:inputURI];
} else { } else {
changeRequest = [PHAssetChangeRequest creationRequestForAssetFromImage:inputImage]; assetRequest = [PHAssetChangeRequest creationRequestForAssetFromImage:inputImage];
} }
placeholder = [assetRequest placeholderForCreatedAsset];
placeholder = [changeRequest placeholderForCreatedAsset]; if (![options[@"album"] isEqualToString:@""]) {
} completionHandler:^(BOOL success, NSError * _Nullable error) { photosAsset = [PHAsset fetchAssetsInAssetCollection:collection options:nil];
PHAssetCollectionChangeRequest *albumChangeRequest = [PHAssetCollectionChangeRequest changeRequestForAssetCollection:collection assets:photosAsset];
[albumChangeRequest addAssets:@[placeholder]];
}
} completionHandler:^(BOOL success, NSError *error) {
if (success) { if (success) {
NSString *uri = [NSString stringWithFormat:@"ph://%@", [placeholder localIdentifier]]; NSString *uri = [NSString stringWithFormat:@"ph://%@", [placeholder localIdentifier]];
resolve(uri); resolve(uri);
@ -136,11 +140,41 @@ RCT_EXPORT_METHOD(saveToCameraRoll:(NSURLRequest *)request
} }
}]; }];
}; };
void (^saveWithOptions)(void) = ^void() {
if (![options[@"album"] isEqualToString:@""]) {
PHFetchOptions *fetchOptions = [[PHFetchOptions alloc] init];
fetchOptions.predicate = [NSPredicate predicateWithFormat:@"title = %@", options[@"album"] ];
collection = [PHAssetCollection fetchAssetCollectionsWithType:PHAssetCollectionTypeAlbum
subtype:PHAssetCollectionSubtypeAny
options:fetchOptions].firstObject;
// Create the album
if (!collection) {
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
PHAssetCollectionChangeRequest *createAlbum = [PHAssetCollectionChangeRequest creationRequestForAssetCollectionWithTitle:options[@"album"]];
placeholder = [createAlbum placeholderForCreatedAssetCollection];
} completionHandler:^(BOOL success, NSError *error) {
if (success) {
PHFetchResult *collectionFetchResult = [PHAssetCollection fetchAssetCollectionsWithLocalIdentifiers:@[placeholder.localIdentifier]
options:nil];
collection = collectionFetchResult.firstObject;
saveBlock();
} else {
reject(kErrorUnableToSave, nil, error);
}
}];
} else {
saveBlock();
}
} else {
saveBlock();
}
};
void (^loadBlock)(void) = ^void() { void (^loadBlock)(void) = ^void() {
if ([type isEqualToString:@"video"]) { if ([options[@"type"] isEqualToString:@"video"]) {
inputURI = request.URL; inputURI = request.URL;
saveBlock(); saveWithOptions();
} else { } else {
[self.bridge.imageLoader loadImageWithURLRequest:request callback:^(NSError *error, UIImage *image) { [self.bridge.imageLoader loadImageWithURLRequest:request callback:^(NSError *error, UIImage *image) {
if (error) { if (error) {
@ -149,7 +183,7 @@ RCT_EXPORT_METHOD(saveToCameraRoll:(NSURLRequest *)request
} }
inputImage = image; inputImage = image;
saveBlock(); saveWithOptions();
}]; }];
} }
}; };
@ -201,7 +235,7 @@ RCT_EXPORT_METHOD(getPhotos:(NSDictionary *)params
PHAssetCollectionSubtype const collectionSubtype = [RCTConvert PHAssetCollectionSubtype:groupTypes]; PHAssetCollectionSubtype const collectionSubtype = [RCTConvert PHAssetCollectionSubtype:groupTypes];
// Predicate for fetching assets within a collection // Predicate for fetching assets within a collection
PHFetchOptions *const assetFetchOptions = [RCTConvert PHFetchOptionsFromMediaType:mediaType]; PHFetchOptions *const assetFetchOptions = [RCTConvert PHFetcihOptionsFromMediaType:mediaType];
assetFetchOptions.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate" ascending:NO]]; assetFetchOptions.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate" ascending:NO]];
BOOL __block foundAfter = NO; BOOL __block foundAfter = NO;
@ -335,19 +369,19 @@ RCT_EXPORT_METHOD(deletePhotos:(NSArray<NSString *>*)assets
{ {
NSArray<NSURL *> *assets_ = [RCTConvert NSURLArray:assets]; NSArray<NSURL *> *assets_ = [RCTConvert NSURLArray:assets];
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{ [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
PHFetchResult<PHAsset *> *fetched = PHFetchResult<PHAsset *> *fetched =
[PHAsset fetchAssetsWithALAssetURLs:assets_ options:nil]; [PHAsset fetchAssetsWithALAssetURLs:assets_ options:nil];
[PHAssetChangeRequest deleteAssets:fetched]; [PHAssetChangeRequest deleteAssets:fetched];
} }
completionHandler:^(BOOL success, NSError *error) { completionHandler:^(BOOL success, NSError *error) {
if (success == YES) { if (success == YES) {
resolve(@(success)); resolve(@(success));
}
else {
reject(@"Couldn't delete", @"Couldn't delete assets", error);
}
} }
]; else {
reject(@"Couldn't delete", @"Couldn't delete assets", error);
}
}
];
} }
static void checkPhotoLibraryConfig() static void checkPhotoLibraryConfig()

View File

@ -8,7 +8,7 @@
* @format * @format
*/ */
'use strict'; 'use strict';
import {Platform} from 'react-native';
import RNCCameraRoll from './nativeInterface'; import RNCCameraRoll from './nativeInterface';
const invariant = require('fbjs/lib/invariant'); const invariant = require('fbjs/lib/invariant');
@ -100,7 +100,10 @@ export type PhotoIdentifiersPage = {
end_cursor?: string, end_cursor?: string,
}, },
}; };
export type SaveToCameraRollOptions = {
type?: 'photo' | 'video' | 'auto',
album?: string,
};
/** /**
* `CameraRoll` provides access to the local camera roll or photo library. * `CameraRoll` provides access to the local camera roll or photo library.
* *
@ -117,7 +120,7 @@ class CameraRoll {
console.warn( console.warn(
'`CameraRoll.saveImageWithTag()` is deprecated. Use `CameraRoll.saveToCameraRoll()` instead.', '`CameraRoll.saveImageWithTag()` is deprecated. Use `CameraRoll.saveToCameraRoll()` instead.',
); );
return this.saveToCameraRoll(tag, 'photo'); return this.saveToCameraRoll(tag, {type: 'photo'});
} }
static deletePhotos(photos: Array<string>) { static deletePhotos(photos: Array<string>) {
@ -128,31 +131,35 @@ class CameraRoll {
* Saves the photo or video to the camera roll or photo library. * Saves the photo or video to the camera roll or photo library.
* *
*/ */
static saveToCameraRoll( static save(
tag: string, tag: string,
type?: 'photo' | 'video', options: SaveToCameraRollOptions = {},
): Promise<string> { ): Promise<string> {
let {type = 'auto', album = ''} = options;
invariant( invariant(
typeof tag === 'string', typeof tag === 'string',
'CameraRoll.saveToCameraRoll must be a valid string.', 'CameraRoll.saveToCameraRoll must be a valid string.',
); );
invariant( invariant(
type === 'photo' || type === 'video' || type === undefined, options.type === 'photo' ||
`The second argument to saveToCameraRoll must be 'photo' or 'video'. You passed ${type || options.type === 'video' ||
options.type === 'auto' ||
options.type === undefined,
`The second argument to saveToCameraRoll must be 'photo' or 'video' or 'auto'. You passed ${type ||
'unknown'}`, 'unknown'}`,
); );
if (type === 'auto') {
let mediaType = 'photo'; if (['mov', 'mp4'].indexOf(tag.split('.').slice(-1)[0]) >= 0) {
if (type) { type = 'video';
mediaType = type; } else {
} else if (['mov', 'mp4'].indexOf(tag.split('.').slice(-1)[0]) >= 0) { type = 'photo';
mediaType = 'video'; }
} }
return RNCCameraRoll.saveToCameraRoll(tag, {type, album});
return RNCCameraRoll.saveToCameraRoll(tag, mediaType); }
static saveToCameraRoll(tag: string, type?: photo | video) {
CameraRoll.save(tag, {type});
} }
/** /**
* Returns a Promise with photo identifier objects from the local camera * Returns a Promise with photo identifier objects from the local camera
* roll of the device matching shape defined by `getPhotosReturnChecker`. * roll of the device matching shape defined by `getPhotosReturnChecker`.
@ -163,7 +170,7 @@ class CameraRoll {
if (!params.assetType) { if (!params.assetType) {
params.assetType = ASSET_TYPE_OPTIONS.All; params.assetType = ASSET_TYPE_OPTIONS.All;
} }
if (!params.groupTypes) { if (!params.groupTypes && Platform.OS !== 'android') {
params.groupTypes = GROUP_TYPES_OPTIONS.All; params.groupTypes = GROUP_TYPES_OPTIONS.All;
} }
if (arguments.length > 1) { if (arguments.length > 1) {

View File

@ -25,6 +25,12 @@ describe('CameraRoll', () => {
expect(NativeModule.saveToCameraRoll.mock.calls).toMatchSnapshot(); expect(NativeModule.saveToCameraRoll.mock.calls).toMatchSnapshot();
}); });
it('Should call save', async () => {
await CameraRoll.save('a tag', {type:'photo'});
expect(NativeModule.saveToCameraRoll.mock.calls).toMatchSnapshot();
});
it('Should call getPhotos', async () => { it('Should call getPhotos', async () => {
await CameraRoll.getPhotos({first: 0}); await CameraRoll.getPhotos({first: 0});
expect(NativeModule.getPhotos.mock.calls).toMatchSnapshot(); expect(NativeModule.getPhotos.mock.calls).toMatchSnapshot();

View File

@ -22,11 +22,26 @@ Array [
] ]
`; `;
exports[`CameraRoll Should call save 1`] = `
Array [
Array [
"a tag",
Object {
"album": "",
"type": "photo",
},
],
]
`;
exports[`CameraRoll Should call saveToCameraRoll 1`] = ` exports[`CameraRoll Should call saveToCameraRoll 1`] = `
Array [ Array [
Array [ Array [
"a tag", "a tag",
"photo", Object {
"album": "",
"type": "photo",
},
], ],
] ]
`; `;

View File

@ -60,6 +60,10 @@ declare namespace CameraRoll {
}; };
} }
type SaveToCameraRollOptions = {
type?: 'photo' | 'video' | 'auto',
album?: string,
};
/** /**
* `CameraRoll.saveImageWithTag()` is deprecated. Use `CameraRoll.saveToCameraRoll()` instead. * `CameraRoll.saveImageWithTag()` is deprecated. Use `CameraRoll.saveToCameraRoll()` instead.
@ -77,6 +81,11 @@ declare namespace CameraRoll {
*/ */
function saveToCameraRoll(tag: string, type?: 'photo' | 'video'): Promise<string>; function saveToCameraRoll(tag: string, type?: 'photo' | 'video'): Promise<string>;
/**
* Saves the photo or video to the camera roll or photo library.
*/
function save(tag: string, options?: SaveToCameraRollOptions): Promise<string>
/** /**
* Returns a Promise with photo identifier objects from the local camera * Returns a Promise with photo identifier objects from the local camera
* roll of the device matching shape defined by `getPhotosReturnChecker`. * roll of the device matching shape defined by `getPhotosReturnChecker`.