diff --git a/README.md b/README.md index 6a938ca29..1932c2c19 100644 --- a/README.md +++ b/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"`. diff --git a/android/src/main/java/com/reactnativecommunity/cameraroll/CameraRollModule.java b/android/src/main/java/com/reactnativecommunity/cameraroll/CameraRollModule.java index 104da7f3c..7c5fc40c2 100644 --- a/android/src/main/java/com/reactnativecommunity/cameraroll/CameraRollModule.java +++ b/android/src/main/java/com/reactnativecommunity/cameraroll/CameraRollModule.java @@ -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; diff --git a/ios/RNCCameraRollManager.m b/ios/RNCCameraRollManager.m index 52d557f16..8f199b997 100644 --- a/ios/RNCCameraRollManager.m +++ b/ios/RNCCameraRollManager.m @@ -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) @@ -46,7 +46,7 @@ RCT_ENUM_CONVERTER(PHAssetCollectionSubtype, (@{ { // This is not exhaustive in terms of supported media type predicates; more can be added in the future NSString *const lowercase = [mediaType lowercaseString]; - + if ([lowercase isEqualToString:@"photos"]) { PHFetchOptions *const options = [PHFetchOptions new]; options.predicate = [NSPredicate predicateWithFormat:@"mediaType = %d", PHAssetMediaTypeImage]; @@ -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(); }]; } }; @@ -192,23 +226,23 @@ RCT_EXPORT_METHOD(getPhotos:(NSDictionary *)params NSString *const groupTypes = [[RCTConvert NSString:params[@"groupTypes"]] lowercaseString]; NSString *const mediaType = [RCTConvert NSString:params[@"assetType"]]; NSArray *const mimeTypes = [RCTConvert NSStringArray:params[@"mimeTypes"]]; - + // If groupTypes is "all", we want to fetch the SmartAlbum "all photos". Otherwise, all // other groupTypes values require the "album" collection type. PHAssetCollectionType const collectionType = ([groupTypes isEqualToString:@"all"] ? PHAssetCollectionTypeSmartAlbum : PHAssetCollectionTypeAlbum); 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; BOOL __block hasNextPage = NO; BOOL __block resolvedPromise = NO; NSMutableArray *> *assets = [NSMutableArray new]; - + // Filter collection name ("group") PHFetchOptions *const collectionFetchOptions = [PHFetchOptions new]; collectionFetchOptions.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"endDate" ascending:NO]]; @@ -335,19 +369,19 @@ RCT_EXPORT_METHOD(deletePhotos:(NSArray*)assets { NSArray *assets_ = [RCTConvert NSURLArray:assets]; [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{ - PHFetchResult *fetched = - [PHAsset fetchAssetsWithALAssetURLs:assets_ options:nil]; - [PHAssetChangeRequest deleteAssets:fetched]; - } + PHFetchResult *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() diff --git a/js/CameraRoll.js b/js/CameraRoll.js index 6e4b2926b..1b8e3a7b3 100644 --- a/js/CameraRoll.js +++ b/js/CameraRoll.js @@ -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) { @@ -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 { + 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) { diff --git a/js/__tests__/CameraRollTest.js b/js/__tests__/CameraRollTest.js index a84382c64..c8642bc5a 100644 --- a/js/__tests__/CameraRollTest.js +++ b/js/__tests__/CameraRollTest.js @@ -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(); diff --git a/js/__tests__/__snapshots__/CameraRollTest.js.snap b/js/__tests__/__snapshots__/CameraRollTest.js.snap index fd40b5949..66917973b 100644 --- a/js/__tests__/__snapshots__/CameraRollTest.js.snap +++ b/js/__tests__/__snapshots__/CameraRollTest.js.snap @@ -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", + }, ], ] `; diff --git a/typings/CameraRoll.d.ts b/typings/CameraRoll.d.ts index 6641e0fc9..bd00c3914 100644 --- a/typings/CameraRoll.d.ts +++ b/typings/CameraRoll.d.ts @@ -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; + /** + * Saves the photo or video to the camera roll or photo library. + */ + function save(tag: string, options?: SaveToCameraRollOptions): Promise + /** * Returns a Promise with photo identifier objects from the local camera * roll of the device matching shape defined by `getPhotosReturnChecker`.