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:
parent
344b3a93a4
commit
dc00a4f115
11
README.md
11
README.md
|
@ -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"`.
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
]
|
||||
`;
|
||||
|
|
|
@ -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`.
|
||||
|
|
Loading…
Reference in New Issue