Skip to content

Commit

Permalink
feat(lib): save photos or videos to an album
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
SimonErm authored and bartolkaruza committed Aug 14, 2019
1 parent 344b3a9 commit dc00a4f
Show file tree
Hide file tree
Showing 7 changed files with 157 additions and 58 deletions.
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---
Expand All @@ -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"`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,8 @@ public String getName() {
* @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);
}

Expand All @@ -110,21 +110,40 @@ private static class SaveToCameraRoll extends GuardedAsyncTask<Void, Void> {
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
protected void doInBackgroundGuarded(Void... params) {
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;
Expand Down
100 changes: 67 additions & 33 deletions ios/RNCCameraRollManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ @implementation RCTConvert (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)

Expand All @@ -46,7 +46,7 @@ + (PHFetchOptions *)PHFetchOptionsFromMediaType:(NSString *)mediaType
{
// 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];
Expand Down Expand Up @@ -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];
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
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);
Expand All @@ -136,11 +140,41 @@ static void requestPhotoLibraryAccess(RCTPromiseRejectBlock reject, PhotosAuthor
}
}];
};
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) {
Expand All @@ -149,7 +183,7 @@ static void requestPhotoLibraryAccess(RCTPromiseRejectBlock reject, PhotosAuthor
}

inputImage = image;
saveBlock();
saveWithOptions();
}];
}
};
Expand Down Expand Up @@ -192,23 +226,23 @@ static void RCTResolvePromise(RCTPromiseResolveBlock resolve,
NSString *const groupTypes = [[RCTConvert NSString:params[@"groupTypes"]] lowercaseString];
NSString *const mediaType = [RCTConvert NSString:params[@"assetType"]];
NSArray<NSString *> *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<NSDictionary<NSString *, id> *> *assets = [NSMutableArray new];

// Filter collection name ("group")
PHFetchOptions *const collectionFetchOptions = [PHFetchOptions new];
collectionFetchOptions.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"endDate" ascending:NO]];
Expand Down Expand Up @@ -335,19 +369,19 @@ static void RCTResolvePromise(RCTPromiseResolveBlock resolve,
{
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()
Expand Down
43 changes: 25 additions & 18 deletions js/CameraRoll.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* @format
*/
'use strict';

import {Platform} from 'react-native';
import RNCCameraRoll from './nativeInterface';

const invariant = require('fbjs/lib/invariant');
Expand Down Expand Up @@ -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.
*
Expand All @@ -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>) {
Expand All @@ -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`.
Expand All @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions js/__tests__/CameraRollTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
17 changes: 16 additions & 1 deletion js/__tests__/__snapshots__/CameraRollTest.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
],
]
`;
Loading

0 comments on commit dc00a4f

Please sign in to comment.