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
* [`saveToCameraRoll`](#savetocameraroll)
* [`save`](#save)
* [`getPhotos`](#getphotos)
---
@ -79,13 +80,21 @@ On Android permission is required to read the external storage. Add below line t
## Methods
### `save()`
Saves the photo or video of a particular type to an album.
```javascript
CameraRoll.save(tag, { type, album })
```
### `saveToCameraRoll()`
```javascript
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"`.

View File

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

View File

@ -33,7 +33,7 @@ RCT_ENUM_CONVERTER(PHAssetCollectionSubtype, (@{
@"library": @(PHAssetCollectionSubtypeSmartAlbumUserLibrary),
@"photo-stream": @(PHAssetCollectionSubtypeAlbumMyPhotoStream), // incorrect, but legacy
@"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
}), PHAssetCollectionSubtypeAny, integerValue)
@ -98,36 +98,40 @@ static void requestPhotoLibraryAccess(RCTPromiseRejectBlock reject, PhotosAuthor
}
RCT_EXPORT_METHOD(saveToCameraRoll:(NSURLRequest *)request
type:(NSString *)type
options:(NSDictionary *)options
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)
{
__block PHObjectPlaceholder *placeholder;
// We load images and videos differently.
// 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
// more ways of loading videos in the future.
__block NSURL *inputURI = nil;
__block UIImage *inputImage = nil;
__block PHFetchResult *photosAsset;
__block PHAssetCollection *collection;
__block PHObjectPlaceholder *placeholder;
void (^saveBlock)(void) = ^void() {
// performChanges and the completionHandler are called on
// arbitrary threads, not the main thread - this is safe
// for now since all JS is queued and executed on a single thread.
// We should reevaluate this if that assumption changes.
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
PHAssetChangeRequest *changeRequest;
// Defaults to "photo". `type` is an optional param.
if ([type isEqualToString:@"video"]) {
changeRequest = [PHAssetChangeRequest creationRequestForAssetFromVideoAtFileURL:inputURI];
PHAssetChangeRequest *assetRequest ;
if ([options[@"type"] isEqualToString:@"video"]) {
assetRequest = [PHAssetChangeRequest creationRequestForAssetFromVideoAtFileURL:inputURI];
} else {
changeRequest = [PHAssetChangeRequest creationRequestForAssetFromImage:inputImage];
assetRequest = [PHAssetChangeRequest creationRequestForAssetFromImage:inputImage];
}
placeholder = [changeRequest placeholderForCreatedAsset];
} completionHandler:^(BOOL success, NSError * _Nullable error) {
placeholder = [assetRequest placeholderForCreatedAsset];
if (![options[@"album"] isEqualToString:@""]) {
photosAsset = [PHAsset fetchAssetsInAssetCollection:collection options:nil];
PHAssetCollectionChangeRequest *albumChangeRequest = [PHAssetCollectionChangeRequest changeRequestForAssetCollection:collection assets:photosAsset];
[albumChangeRequest addAssets:@[placeholder]];
}
} completionHandler:^(BOOL success, NSError *error) {
if (success) {
NSString *uri = [NSString stringWithFormat:@"ph://%@", [placeholder localIdentifier]];
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() {
if ([type isEqualToString:@"video"]) {
if ([options[@"type"] isEqualToString:@"video"]) {
inputURI = request.URL;
saveBlock();
saveWithOptions();
} else {
[self.bridge.imageLoader loadImageWithURLRequest:request callback:^(NSError *error, UIImage *image) {
if (error) {
@ -149,7 +183,7 @@ RCT_EXPORT_METHOD(saveToCameraRoll:(NSURLRequest *)request
}
inputImage = image;
saveBlock();
saveWithOptions();
}];
}
};
@ -201,7 +235,7 @@ RCT_EXPORT_METHOD(getPhotos:(NSDictionary *)params
PHAssetCollectionSubtype const collectionSubtype = [RCTConvert PHAssetCollectionSubtype:groupTypes];
// 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]];
BOOL __block foundAfter = NO;
@ -335,19 +369,19 @@ RCT_EXPORT_METHOD(deletePhotos:(NSArray<NSString *>*)assets
{
NSArray<NSURL *> *assets_ = [RCTConvert NSURLArray:assets];
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
PHFetchResult<PHAsset *> *fetched =
[PHAsset fetchAssetsWithALAssetURLs:assets_ options:nil];
[PHAssetChangeRequest deleteAssets:fetched];
}
PHFetchResult<PHAsset *> *fetched =
[PHAsset fetchAssetsWithALAssetURLs:assets_ options:nil];
[PHAssetChangeRequest deleteAssets:fetched];
}
completionHandler:^(BOOL success, NSError *error) {
if (success == YES) {
resolve(@(success));
}
else {
reject(@"Couldn't delete", @"Couldn't delete assets", error);
}
if (success == YES) {
resolve(@(success));
}
];
else {
reject(@"Couldn't delete", @"Couldn't delete assets", error);
}
}
];
}
static void checkPhotoLibraryConfig()

View File

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

View File

@ -25,6 +25,12 @@ describe('CameraRoll', () => {
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 () => {
await CameraRoll.getPhotos({first: 0});
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`] = `
Array [
Array [
"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.
@ -77,6 +81,11 @@ declare namespace CameraRoll {
*/
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
* roll of the device matching shape defined by `getPhotosReturnChecker`.