feat: Added `include` parameter to getPhotos to let users tradeoff performance by omitting metadata (#178)

* Created getPhotosFast function, fixed inconsistent getPhotos toDate logic

* Fixed wrong param name, added better typechecking

* Added example, renamed allowEmptyFilenames to skipGettingFilenames

* Removed `after` from getPhotosFast docs

* Redid implementation based on a new `include` param

* Updated API to use include parameter

* Fixed flow checking by converting index.ts to Typescript

* Unformatted README.md

* Unformatted README.md

* Unformatted README.md

* Added .prettierignore and ignored README.md for now

* Made example/index.js not checked by Flow

* Updated README.md to include notes in the outputs too

* Made inclusion of fields consistent, addressed other feedback

* Renamed GetPhotosFastParams back to GetPhotosParams

* Updated documentation to reflect nullable types

* Updated typings and documentation for fromTime, toTime

* Updated to fix `hasNextPage` being incorrectly false in some cases
This commit is contained in:
Harry Yu 2020-06-16 02:07:25 -07:00 committed by GitHub
parent b777e2bc57
commit e54a6afa8b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 627 additions and 162 deletions

1
.prettierignore Normal file
View File

@ -0,0 +1 @@
README.md

View File

@ -1,8 +1,9 @@
{
"requirePragma": true,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": false,
"jsxBracketSameLine": true,
"parser": "flow"
}
"overrides": [
{"files": ["*.js"], "options": {"parser": "flow", "requirePragma": true}}
]
}

View File

@ -151,7 +151,7 @@ Returns a Promise with photo identifier objects from the local camera roll of th
| params | object | Yes | Expects a params with the shape described below. |
* `first` : {number} : The number of photos wanted in reverse order of the photo application (i.e. most recent first for SavedPhotos). Required.
* `after` : {string} : A cursor that matches `page_info { end_cursor }` returned from a previous call to `getPhotos`.
* `after` : {string} : A cursor that matches `page_info { end_cursor }` returned from a previous call to `getPhotos`. Note that using this will reduce performance slightly on iOS. An alternative is just using the `fromTime` and `toTime` filters, which have no such impact.
* `groupTypes` : {string} : Specifies which group types to filter the results to. Valid values are:
* `Album`
* `All` // default
@ -165,9 +165,13 @@ Returns a Promise with photo identifier objects from the local camera roll of th
* `All`
* `Videos`
* `Photos` // default
* `mimeTypes` : {Array} : Filter by mimetype (e.g. image/jpeg).
* `fromTime` : {timestamp} : Filter from date added.
* `toTime` : {timestamp} : Filter to date added.
* `mimeTypes` : {Array} : Filter by mimetype (e.g. image/jpeg). Note that using this will reduce performance slightly on iOS.
* `fromTime` : {number} : Filter by creation time with a timestamp in milliseconds. This time is exclusive, so we'll select all photos with `timestamp > fromTime`.
* `toTime` : {number} : Filter by creation time with a timestamp in milliseconds. This time is inclusive, so we'll select all photos with `timestamp <= toTime`.
* `include` : {Array} : Whether to include some fields that are slower to fetch
* `filename` : Ensures `image.filename` is available in each node. This has a large performance impact on iOS.
* `fileSize` : Ensures `image.fileSize` is available in each node. This has a large performance impact on iOS.
* `location`: Ensures `location` is available in each node. This has a large performance impact on Android.
Returns a Promise which when resolved will be of the following shape:
@ -177,14 +181,14 @@ Returns a Promise which when resolved will be of the following shape:
* `group_name`: {string}
* `image`: {object} : An object with the following shape:
* `uri`: {string}
* `filename`: {string}
* `filename`: {string | null} : Only set if the `include` parameter contains `filename`.
* `height`: {number}
* `width`: {number}
* `fileSize`: {number}
* `fileSize`: {number | null} : Only set if the `include` parameter contains `fileSize`.
* `isStored`: {boolean}
* `playableDuration`: {number}
* `timestamp`: {number}
* `location`: {object} : An object with the following shape:
* `timestamp`: {number} : Timestamp in seconds.
* `location`: {object | null} : Only set if the `include` parameter contains `location`. An object with the following shape:
* `latitude`: {number}
* `longitude`: {number}
* `altitude`: {number}
@ -233,8 +237,10 @@ render() {
</View>
);
}
```
```
---
### `deletePhotos()`
```javascript

View File

@ -47,9 +47,11 @@ import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
import java.util.Set;
import javax.annotation.Nullable;
@ -72,6 +74,10 @@ public class CameraRollModule extends ReactContextBaseJavaModule {
private static final String ASSET_TYPE_VIDEOS = "Videos";
private static final String ASSET_TYPE_ALL = "All";
private static final String INCLUDE_FILENAME = "filename";
private static final String INCLUDE_FILE_SIZE = "fileSize";
private static final String INCLUDE_LOCATION = "location";
private static final String[] PROJECTION = {
Images.Media._ID,
Images.Media.MIME_TYPE,
@ -247,6 +253,7 @@ public class CameraRollModule extends ReactContextBaseJavaModule {
ReadableArray mimeTypes = params.hasKey("mimeTypes")
? params.getArray("mimeTypes")
: null;
ReadableArray include = params.hasKey("include") ? params.getArray("include") : null;
new GetMediaTask(
getReactApplicationContext(),
@ -257,6 +264,7 @@ public class CameraRollModule extends ReactContextBaseJavaModule {
assetType,
fromTime,
toTime,
include,
promise)
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
@ -271,6 +279,7 @@ public class CameraRollModule extends ReactContextBaseJavaModule {
private final String mAssetType;
private final long mFromTime;
private final long mToTime;
private final Set<String> mInclude;
private GetMediaTask(
ReactContext context,
@ -281,6 +290,7 @@ public class CameraRollModule extends ReactContextBaseJavaModule {
String assetType,
long fromTime,
long toTime,
@Nullable ReadableArray include,
Promise promise) {
super(context);
mContext = context;
@ -292,6 +302,24 @@ public class CameraRollModule extends ReactContextBaseJavaModule {
mAssetType = assetType;
mFromTime = fromTime;
mToTime = toTime;
mInclude = createSetFromIncludeArray(include);
}
private static Set<String> createSetFromIncludeArray(@Nullable ReadableArray includeArray) {
Set<String> includeSet = new HashSet<>();
if (includeArray == null) {
return includeSet;
}
for (int i = 0; i < includeArray.size(); i++) {
@Nullable String includeItem = includeArray.getString(i);
if (includeItem != null) {
includeSet.add(includeItem);
}
}
return includeSet;
}
@Override
@ -362,7 +390,7 @@ public class CameraRollModule extends ReactContextBaseJavaModule {
mPromise.reject(ERROR_UNABLE_TO_LOAD, "Could not get media");
} else {
try {
putEdges(resolver, media, response, mFirst);
putEdges(resolver, media, response, mFirst, mInclude);
putPageInfo(media, response, mFirst, !TextUtils.isEmpty(mAfter) ? Integer.parseInt(mAfter) : 0);
} finally {
media.close();
@ -463,10 +491,10 @@ public class CameraRollModule extends ReactContextBaseJavaModule {
ContentResolver resolver,
Cursor media,
WritableMap response,
int limit) {
int limit,
Set<String> include) {
WritableArray edges = new WritableNativeArray();
media.moveToFirst();
int idIndex = media.getColumnIndex(Images.Media._ID);
int mimeTypeIndex = media.getColumnIndex(Images.Media.MIME_TYPE);
int groupNameIndex = media.getColumnIndex(Images.Media.BUCKET_DISPLAY_NAME);
int dateTakenIndex = media.getColumnIndex(Images.Media.DATE_TAKEN);
@ -475,14 +503,19 @@ public class CameraRollModule extends ReactContextBaseJavaModule {
int sizeIndex = media.getColumnIndex(MediaStore.MediaColumns.SIZE);
int dataIndex = media.getColumnIndex(MediaStore.MediaColumns.DATA);
boolean includeLocation = include.contains(INCLUDE_LOCATION);
boolean includeFilename = include.contains(INCLUDE_FILENAME);
boolean includeFileSize = include.contains(INCLUDE_FILE_SIZE);
for (int i = 0; i < limit && !media.isAfterLast(); i++) {
WritableMap edge = new WritableNativeMap();
WritableMap node = new WritableNativeMap();
boolean imageInfoSuccess =
putImageInfo(resolver, media, node, idIndex, widthIndex, heightIndex, sizeIndex, dataIndex, mimeTypeIndex);
putImageInfo(resolver, media, node, widthIndex, heightIndex, sizeIndex, dataIndex,
mimeTypeIndex, includeFilename, includeFileSize);
if (imageInfoSuccess) {
putBasicNodeInfo(media, node, mimeTypeIndex, groupNameIndex, dateTakenIndex);
putLocationInfo(media, node, dataIndex);
putLocationInfo(media, node, dataIndex, includeLocation);
edge.putMap("node", node);
edges.pushMap(edge);
@ -511,22 +544,18 @@ public class CameraRollModule extends ReactContextBaseJavaModule {
ContentResolver resolver,
Cursor media,
WritableMap node,
int idIndex,
int widthIndex,
int heightIndex,
int sizeIndex,
int dataIndex,
int mimeTypeIndex) {
int mimeTypeIndex,
boolean includeFilename,
boolean includeFileSize) {
WritableMap image = new WritableNativeMap();
Uri photoUri = Uri.parse("file://" + media.getString(dataIndex));
File file = new File(media.getString(dataIndex));
String strFileName = file.getName();
image.putString("uri", photoUri.toString());
image.putString("filename", strFileName);
float width = media.getInt(widthIndex);
float height = media.getInt(heightIndex);
long fileSize = media.getLong(sizeIndex);
String mimeType = media.getString(mimeTypeIndex);
if (mimeType != null
@ -585,7 +614,21 @@ public class CameraRollModule extends ReactContextBaseJavaModule {
}
image.putDouble("width", width);
image.putDouble("height", height);
image.putDouble("fileSize", fileSize);
if (includeFilename) {
File file = new File(media.getString(dataIndex));
String strFileName = file.getName();
image.putString("filename", strFileName);
} else {
image.putNull("filename");
}
if (includeFileSize) {
image.putDouble("fileSize", media.getLong(sizeIndex));
} else {
image.putNull("fileSize");
}
node.putMap("image", image);
return true;
@ -594,7 +637,13 @@ public class CameraRollModule extends ReactContextBaseJavaModule {
private static void putLocationInfo(
Cursor media,
WritableMap node,
int dataIndex) {
int dataIndex,
boolean includeLocation) {
if (!includeLocation) {
node.putNull("location");
return;
}
try {
// location details are no longer indexed for privacy reasons using string Media.LATITUDE, Media.LONGITUDE
// we manually obtain location metadata using ExifInterface#getLatLong(float[]).

View File

@ -1,32 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @flow
*/
import React, {Component} from 'react';
import {StyleSheet, View} from 'react-native';
import CameraRollExample from './js/CameraRollExample';
type Props = {};
export default class App extends Component<Props> {
render() {
return (
<View style={styles.container}>
<CameraRollExample />
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F5FCFF',
},
});

16
example/App.tsx Normal file
View File

@ -0,0 +1,16 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React, {Component} from 'react';
import ExampleContainer from './js/ExampleContainer';
export default class App extends Component {
render() {
return <ExampleContainer />;
}
}

View File

@ -5,7 +5,6 @@
* LICENSE file in the root directory of this source tree.
*
* @format
* @flow
*/
import {AppRegistry} from 'react-native';
import App from './App';

View File

@ -57,7 +57,7 @@ export default class CameraRollExample extends React.Component<Props, State> {
render() {
return (
<View>
<View style={styles.flex1}>
<View style={styles.header}>
<Switch
onValueChange={this._onSwitchChange}
@ -107,7 +107,7 @@ export default class CameraRollExample extends React.Component<Props, State> {
onPress={this.loadAsset.bind(this, asset)}>
<View style={styles.row}>
<Image source={{uri: asset.node.image.uri}} style={imageStyle} />
<View style={styles.info}>
<View style={styles.flex1}>
<Text style={styles.url}>{asset.node.image.uri}</Text>
<Text>{locationStr}</Text>
<Text>{asset.node.group_name}</Text>
@ -135,7 +135,6 @@ export default class CameraRollExample extends React.Component<Props, State> {
const styles = StyleSheet.create({
header: {
marginTop: 44,
padding: 20,
width: Dimensions.get('window').width,
},
@ -150,7 +149,7 @@ const styles = StyleSheet.create({
image: {
margin: 4,
},
info: {
flex1: {
flex: 1,
},
});

View File

@ -0,0 +1,104 @@
import * as React from 'react';
import {
SafeAreaView,
StyleSheet,
View,
Button,
Modal,
TouchableWithoutFeedback,
} from 'react-native';
// @ts-ignore: CameraRollExample has no typings in same folder
import CameraRollExample from './CameraRollExample';
import GetPhotosPerformanceExample from './GetPhotosPerformanceExample';
interface Props {}
interface State {
showChangeExampleModal: boolean;
currentExampleIndex: number;
}
interface Example {
label: string;
Component: React.ComponentType;
}
const examples: Example[] = [
{
label: 'CameraRollExample',
Component: CameraRollExample,
},
{
label: 'GetPhotosPerformanceExample',
Component: GetPhotosPerformanceExample,
},
];
/**
* Container for displaying and switching between multiple examples.
*
* Shows a button which opens up a Modal to switch between examples, as well
* as the current example itself.
*/
export default class ExamplesContainer extends React.Component<Props, State> {
state: State = {showChangeExampleModal: false, currentExampleIndex: 0};
render() {
const {currentExampleIndex} = this.state;
return (
<SafeAreaView style={styles.flex1}>
<Button
title="Change example"
onPress={() => this.setState({showChangeExampleModal: true})}
/>
{this._renderChangeExampleModal()}
<View style={styles.flex1}>
{React.createElement(examples[currentExampleIndex].Component)}
</View>
</SafeAreaView>
);
}
_renderChangeExampleModal() {
const {showChangeExampleModal} = this.state;
return (
<Modal visible={showChangeExampleModal} transparent>
<TouchableWithoutFeedback
onPress={() => this.setState({showChangeExampleModal: false})}>
<View style={styles.modalScrim}>
<SafeAreaView>
<View style={styles.modalInner}>
{examples.map((example, index) => (
<Button
key={example.label}
title={example.label}
onPress={() =>
this.setState({
currentExampleIndex: index,
showChangeExampleModal: false,
})
}
/>
))}
</View>
</SafeAreaView>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
}
const styles = StyleSheet.create({
modalScrim: {
flex: 1,
backgroundColor: '#00000080',
},
flex1: {
flex: 1,
},
modalInner: {
margin: 20,
backgroundColor: '#fff',
},
});

View File

@ -0,0 +1,175 @@
import * as React from 'react';
import {
StyleSheet,
View,
Button,
Text,
Switch,
TextInput,
Keyboard,
} from 'react-native';
// @ts-ignore: CameraRollExample has no typings in same folder
import CameraRoll from '../../js/CameraRoll';
interface State {
fetchingPhotos: boolean;
timeTakenMillis: number | null;
output: CameraRoll.PhotoIdentifiersPage | null;
include: CameraRoll.Include[];
/**
* `first` argument passed into `getPhotos`, but as a string. Validate it
* with `this.first()` before using.
*/
firstStr: string;
/** `after` passed into `getPhotos`. Not passed if empty */
after: string;
}
const includeValues: CameraRoll.Include[] = [
'filename',
'fileSize',
'location',
];
/**
* Example for testing performance differences between `getPhotos` and
* `getPhotosFast`
*/
export default class GetPhotosPerformanceExample extends React.PureComponent<
{},
State
> {
state: State = {
fetchingPhotos: false,
timeTakenMillis: null,
output: null,
include: [],
firstStr: '1000',
after: '',
};
first = () => {
const first = parseInt(this.state.firstStr, 10);
if (first < 0 || !Number.isInteger(first)) {
return null;
}
return first;
};
startFetchingPhotos = async () => {
const {include} = this.state;
const first = this.first();
if (first === null) {
return;
}
this.setState({fetchingPhotos: true});
Keyboard.dismiss();
const params: CameraRoll.GetPhotosParams = {first, include};
const startTime = Date.now();
const output: CameraRoll.PhotoIdentifiersPage = await CameraRoll.getPhotos(
params,
);
const endTime = Date.now();
this.setState({
output,
timeTakenMillis: endTime - startTime,
fetchingPhotos: false,
});
};
handleIncludeChange = (
includeValue: CameraRoll.Include,
changedTo: boolean,
) => {
if (changedTo === false) {
const include = this.state.include.filter(
value => value !== includeValue,
);
this.setState({include});
} else {
const include = [...this.state.include, includeValue];
this.setState({include});
}
};
render() {
const {
fetchingPhotos,
timeTakenMillis,
output,
include,
firstStr,
} = this.state;
const first = this.first();
return (
<View style={styles.container}>
{includeValues.map(includeValue => (
<View key={includeValue} style={styles.inputRow}>
<Text>{includeValue}</Text>
<Switch
value={include.includes(includeValue)}
onValueChange={(changedTo: boolean) =>
this.handleIncludeChange(includeValue, changedTo)
}
/>
</View>
))}
<View style={styles.inputRow}>
<Text>
first
{first === null && (
<Text style={styles.error}> (enter a positive number)</Text>
)}
</Text>
<TextInput
value={firstStr}
onChangeText={(text: string) => this.setState({firstStr: text})}
style={[styles.textInput, first === null && styles.textInputError]}
/>
</View>
<Button
disabled={fetchingPhotos}
title={`Run getPhotos on ${first} photos`}
onPress={this.startFetchingPhotos}
/>
{timeTakenMillis !== null && (
<Text>Time taken: {timeTakenMillis} ms</Text>
)}
<View>
<Text>Output</Text>
</View>
<TextInput
value={JSON.stringify(output, null, 2)}
multiline
style={styles.outputBox}
/>
</View>
);
}
}
const styles = StyleSheet.create({
container: {flex: 1, padding: 8},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 2,
},
textInput: {
borderColor: '#ccc',
borderWidth: 1,
paddingVertical: 4,
paddingHorizontal: 8,
width: 150,
},
error: {color: '#f00'},
textInputError: {borderColor: '#f00'},
outputBox: {
flex: 1,
borderColor: '#ccc',
borderWidth: 1,
padding: 8,
},
});

View File

@ -70,7 +70,7 @@ RCT_ENUM_CONVERTER(PHAssetCollectionSubtype, (@{
}
if (toTime > 0) {
NSDate* toDate = [NSDate dateWithTimeIntervalSince1970:toTime/1000];
[format addObject:@"creationDate < %@"];
[format addObject:@"creationDate <= %@"];
[arguments addObject:toDate];
}
@ -255,6 +255,11 @@ RCT_EXPORT_METHOD(getPhotos:(NSDictionary *)params
NSUInteger const fromTime = [RCTConvert NSInteger:params[@"fromTime"]];
NSUInteger const toTime = [RCTConvert NSInteger:params[@"toTime"]];
NSArray<NSString *> *const mimeTypes = [RCTConvert NSStringArray:params[@"mimeTypes"]];
NSArray<NSString *> *const include = [RCTConvert NSStringArray:params[@"include"]];
BOOL __block includeFilename = [include indexOfObject:@"filename"] != NSNotFound;
BOOL __block includeFileSize = [include indexOfObject:@"fileSize"] != NSNotFound;
BOOL __block includeLocation = [include indexOfObject:@"location"] != NSNotFound;
// If groupTypes is "all", we want to fetch the SmartAlbum "all photos". Otherwise, all
// other groupTypes values require the "album" collection type.
@ -265,6 +270,18 @@ RCT_EXPORT_METHOD(getPhotos:(NSDictionary *)params
// Predicate for fetching assets within a collection
PHFetchOptions *const assetFetchOptions = [RCTConvert PHFetchOptionsFromMediaType:mediaType fromTime:fromTime toTime:toTime];
// We can directly set the limit if we guarantee every image fetched will be
// added to the output array within the `collectAsset` block
BOOL collectAssetMayOmitAsset = !!afterCursor || [mimeTypes count] > 0;
if (!collectAssetMayOmitAsset) {
// We set the fetchLimit to first + 1 so that `hasNextPage` will be set
// correctly:
// - If the user set `first: 10` and there are 11 photos, `hasNextPage`
// will be set to true below inside of `collectAsset`
// - If the user set `first: 10` and there are 10 photos, `hasNextPage`
// will not be set, as expected
assetFetchOptions.fetchLimit = first + 1;
}
assetFetchOptions.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate" ascending:NO]];
BOOL __block foundAfter = NO;
@ -277,7 +294,6 @@ RCT_EXPORT_METHOD(getPhotos:(NSDictionary *)params
collectionFetchOptions.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"endDate" ascending:NO]];
if (groupName != nil) {
collectionFetchOptions.predicate = [NSPredicate predicateWithFormat:@"localizedTitle = %@", groupName];
}
BOOL __block stopCollections_;
@ -286,34 +302,47 @@ RCT_EXPORT_METHOD(getPhotos:(NSDictionary *)params
requestPhotoLibraryAccess(reject, ^{
void (^collectAsset)(PHAsset*, NSUInteger, BOOL*) = ^(PHAsset * _Nonnull asset, NSUInteger assetIdx, BOOL * _Nonnull stopAssets) {
NSString *const uri = [NSString stringWithFormat:@"ph://%@", [asset localIdentifier]];
if (afterCursor && !foundAfter) {
if ([afterCursor isEqualToString:uri]) {
foundAfter = YES;
}
return; // skip until we get to the first one
NSString *_Nullable originalFilename = NULL;
PHAssetResource *_Nullable resource = NULL;
NSNumber* fileSize = [NSNumber numberWithInt:0];
if (includeFilename || includeFileSize || [mimeTypes count] > 0) {
// Get underlying resources of an asset - this includes files as well as details about edited PHAssets
// This is required for the filename and mimeType filtering
NSArray<PHAssetResource *> *const assetResources = [PHAssetResource assetResourcesForAsset:asset];
resource = [assetResources firstObject];
originalFilename = resource.originalFilename;
fileSize = [resource valueForKey:@"fileSize"];
}
// Get underlying resources of an asset - this includes files as well as details about edited PHAssets
NSArray<PHAssetResource *> *const assetResources = [PHAssetResource assetResourcesForAsset:asset];
if (![assetResources firstObject]) {
return;
}
PHAssetResource *const _Nonnull resource = [assetResources firstObject];
if ([mimeTypes count] > 0) {
CFStringRef const uti = (__bridge CFStringRef _Nonnull)(resource.uniformTypeIdentifier);
NSString *const mimeType = (NSString *)CFBridgingRelease(UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType));
BOOL __block mimeTypeFound = NO;
[mimeTypes enumerateObjectsUsingBlock:^(NSString * _Nonnull mimeTypeFilter, NSUInteger idx, BOOL * _Nonnull stop) {
if ([mimeType isEqualToString:mimeTypeFilter]) {
mimeTypeFound = YES;
*stop = YES;
// WARNING: If you add any code to `collectAsset` that may skip adding an
// asset to the `assets` output array, you should do it inside this
// block and ensure the logic for `collectAssetMayOmitAsset` above is
// updated
if (collectAssetMayOmitAsset) {
if (afterCursor && !foundAfter) {
if ([afterCursor isEqualToString:uri]) {
foundAfter = YES;
}
}];
return; // skip until we get to the first one
}
if (!mimeTypeFound) {
return;
if ([mimeTypes count] > 0 && resource) {
CFStringRef const uti = (__bridge CFStringRef _Nonnull)(resource.uniformTypeIdentifier);
NSString *const mimeType = (NSString *)CFBridgingRelease(UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType));
BOOL __block mimeTypeFound = NO;
[mimeTypes enumerateObjectsUsingBlock:^(NSString * _Nonnull mimeTypeFilter, NSUInteger idx, BOOL * _Nonnull stop) {
if ([mimeType isEqualToString:mimeTypeFilter]) {
mimeTypeFound = YES;
*stop = YES;
}
}];
if (!mimeTypeFound) {
return;
}
}
}
@ -336,7 +365,6 @@ RCT_EXPORT_METHOD(getPhotos:(NSDictionary *)params
? @"audio"
: @"unknown")));
CLLocation *const loc = asset.location;
NSString *const origFilename = resource.originalFilename;
// A note on isStored: in the previous code that used ALAssets, isStored
// was always set to YES, probably because iCloud-synced images were never returned (?).
@ -351,21 +379,21 @@ RCT_EXPORT_METHOD(getPhotos:(NSDictionary *)params
@"group_name": currentCollectionName,
@"image": @{
@"uri": uri,
@"filename": origFilename,
@"filename": (includeFilename && originalFilename ? originalFilename : [NSNull null]),
@"height": @([asset pixelHeight]),
@"width": @([asset pixelWidth]),
@"fileSize": [resource valueForKey:@"fileSize"],
@"fileSize": (includeFileSize ? fileSize : [NSNull null]),
@"isStored": @YES, // this field doesn't seem to exist on android
@"playableDuration": @([asset duration]) // fractional seconds
},
@"timestamp": @(asset.creationDate.timeIntervalSince1970),
@"location": (loc ? @{
@"location": (includeLocation && loc ? @{
@"latitude": @(loc.coordinate.latitude),
@"longitude": @(loc.coordinate.longitude),
@"altitude": @(loc.altitude),
@"heading": @(loc.course),
@"speed": @(loc.speed), // speed in m/s
} : @{})
} : [NSNull null])
}
}];
};

View File

@ -31,6 +31,8 @@ const ASSET_TYPE_OPTIONS = {
export type GroupTypes = $Keys<typeof GROUP_TYPES_OPTIONS>;
export type Include = 'filename' | 'fileSize' | 'location';
/**
* Shape of the param arg for the `getPhotos` function.
*/
@ -63,10 +65,26 @@ export type GetPhotosParams = {
*/
assetType?: $Keys<typeof ASSET_TYPE_OPTIONS>,
/**
* Earliest time to get photos from. A timestamp in milliseconds. Exclusive.
*/
fromTime?: number,
/**
* Latest time to get photos from. A timestamp in milliseconds. Inclusive.
*/
toTime?: Number,
/**
* Filter by mimetype (e.g. image/jpeg).
*/
mimeTypes?: Array<string>,
/**
* Specific fields in the output that we want to include, even though they
* might have some performance impact.
*/
include?: Include[],
};
export type PhotoIdentifier = {
@ -74,22 +92,22 @@ export type PhotoIdentifier = {
type: string,
group_name: string,
image: {
filename: string,
filename: string | null,
uri: string,
height: number,
width: number,
fileSize: number,
fileSize: number | null,
isStored?: boolean,
playableDuration: number,
},
timestamp: number,
location?: {
location: {
latitude?: number,
longitude?: number,
altitude?: number,
heading?: number,
speed?: number,
},
} | null,
},
};
@ -114,6 +132,7 @@ export type Album = {
title: string,
count: number,
};
/**
* `CameraRoll` provides access to the local camera roll or photo library.
*
@ -186,6 +205,18 @@ class CameraRoll {
): Promise<Album[]> {
return RNCCameraRoll.getAlbums(params);
}
static getParamsWithDefaults(params: GetPhotosParams): GetPhotosParams {
const newParams = {...params};
if (!newParams.assetType) {
newParams.assetType = ASSET_TYPE_OPTIONS.All;
}
if (!newParams.groupTypes && Platform.OS !== 'android') {
newParams.groupTypes = GROUP_TYPES_OPTIONS.All;
}
return newParams;
}
/**
* Returns a Promise with photo identifier objects from the local camera
* roll of the device matching shape defined by `getPhotosReturnChecker`.
@ -193,21 +224,19 @@ class CameraRoll {
* See https://facebook.github.io/react-native/docs/cameraroll.html#getphotos
*/
static getPhotos(params: GetPhotosParams): Promise<PhotoIdentifiersPage> {
if (!params.assetType) {
params.assetType = ASSET_TYPE_OPTIONS.All;
}
if (!params.groupTypes && Platform.OS !== 'android') {
params.groupTypes = GROUP_TYPES_OPTIONS.All;
}
params = CameraRoll.getParamsWithDefaults(params);
const promise = RNCCameraRoll.getPhotos(params);
if (arguments.length > 1) {
console.warn(
'CameraRoll.getPhotos(tag, success, error) is deprecated. Use the returned Promise instead',
);
let successCallback = arguments[1];
const errorCallback = arguments[2] || (() => {});
RNCCameraRoll.getPhotos(params).then(successCallback, errorCallback);
promise.then(successCallback, errorCallback);
}
return RNCCameraRoll.getPhotos(params);
return promise;
}
}

View File

@ -46,6 +46,7 @@
"@babel/runtime": "^7.9.6",
"@react-native-community/eslint-config": "^1.1.0",
"@semantic-release/git": "7.0.8",
"@types/react-native": "^0.62.10",
"babel-core": "^7.0.0-bridge.0",
"babel-jest": "^26.0.1",
"babel-plugin-module-resolver": "^3.2.0",

View File

@ -1,5 +1,5 @@
{
"include": ["typings/**/*.d.ts"],
"include": ["typings/**/*.d.ts", "example/**/*.ts", "example/**/*.tsx"],
"compilerOptions": {
"target": "es5",
"module": "commonjs",
@ -16,10 +16,10 @@
"skipLibCheck": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"lib": ["es2015", "es2016", "esnext", "dom"]
"lib": ["es2015", "es2016", "esnext", "dom"],
"allowSyntheticDefaultImports": true,
"noEmit": true,
"resolveJsonModule": true
},
"exclude": [
"node_modules",
"**/*.spec.ts"
]
"exclude": ["node_modules", "**/*.spec.ts"]
}

View File

@ -3,8 +3,6 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
declare namespace CameraRoll {
@ -19,47 +17,107 @@ declare namespace CameraRoll {
type AssetType = 'All' | 'Videos' | 'Photos';
type Include =
/** Ensures the filename is included. Has a large performance hit on iOS */
| 'filename'
/** Ensures the fileSize is included. Has a large performance hit on iOS */
| 'fileSize'
/** Ensures the location is included. Has a medium performance hit on Android */
| 'location';
/**
* Shape of the param arg for the `getPhotosFast` function.
*/
interface GetPhotosParams {
/**
* The number of photos wanted in reverse order of the photo application
* (i.e. most recent first).
*/
first: number;
/**
* A cursor that matches `page_info { end_cursor }` returned from a previous
* call to `getPhotos`. Note that using this will reduce performance
* slightly on iOS. An alternative is just using the `fromTime` and `toTime`
* filters, which have no such impact.
*/
after?: string;
/**
* Specifies which group types to filter the results to.
*/
groupTypes?: GroupType;
/**
* Specifies filter on group names, like 'Recent Photos' or custom album
* titles.
*/
groupName?: string;
/**
* Specifies filter on asset type
*/
assetType?: AssetType;
mimeTypes?: Array<string>;
/**
* Filter by creation time with a timestamp in milliseconds. This time is
* exclusive, so we'll select all photos with `timestamp > fromTime`.
*/
fromTime?: number;
/**
* Filter by creation time with a timestamp in milliseconds. This time is
* inclusive, so we'll select all photos with `timestamp <= toTime`.
*/
toTime?: number;
/**
* Filter by mimetype (e.g. image/jpeg). Note that using this will reduce
* performance slightly on iOS.
*/
mimeTypes?: Array<string>;
/**
* Specific fields in the output that we want to include, even though they
* might have some performance impact.
*/
include?: Include[];
}
interface PhotoIdentifier {
node: {
type: string,
group_name: string,
type: string;
group_name: string;
image: {
filename: string,
uri: string,
height: number,
width: number,
fileSize: number,
isStored?: boolean,
playableDuration: number,
},
timestamp: number,
location?: {
latitude?: number,
longitude?: number,
altitude?: number,
heading?: number,
speed?: number,
},
/** Only set if the `include` parameter contains `filename`. */
filename: string | null;
uri: string;
height: number;
width: number;
/** Only set if the `include` parameter contains `fileSize`. */
fileSize: number | null;
isStored?: boolean;
playableDuration: number;
};
/** Timestamp in seconds. */
timestamp: number;
/** Only set if the `include` parameter contains `location`. */
location: {
latitude?: number;
longitude?: number;
altitude?: number;
heading?: number;
speed?: number;
} | null;
};
}
interface PhotoIdentifiersPage {
edges: Array<PhotoIdentifier>;
page_info: {
has_next_page: boolean,
start_cursor?: string,
end_cursor?: string,
has_next_page: boolean;
start_cursor?: string;
end_cursor?: string;
};
}
@ -73,37 +131,43 @@ declare namespace CameraRoll {
}
type SaveToCameraRollOptions = {
type?: 'photo' | 'video' | 'auto',
album?: string,
type?: 'photo' | 'video' | 'auto';
album?: string;
};
/**
* `CameraRoll.saveImageWithTag()` is deprecated. Use `CameraRoll.saveToCameraRoll()` instead.
*/
function saveImageWithTag(tag: string): Promise<string>;
/**
* `CameraRoll.saveImageWithTag()` is deprecated. Use `CameraRoll.saveToCameraRoll()` instead.
*/
function saveImageWithTag(tag: string): Promise<string>;
/**
* Delete a photo from the camera roll or media library. photoUris is an array of photo uri's.
*/
function deletePhotos(photoUris: Array<string>): Promise<boolean>;
/**
* Saves the photo or video to the camera roll or photo library.
*/
function saveToCameraRoll(tag: string, type?: 'photo' | 'video'): Promise<string>;
/**
* Delete a photo from the camera roll or media library. photoUris is an array of photo uri's.
*/
function deletePhotos(photoUris: Array<string>): Promise<boolean>;
/**
* Saves the photo or video to the camera roll or photo library.
*/
function save(tag: string, options?: SaveToCameraRollOptions): Promise<string>
/**
* Saves the photo or video to the camera roll or photo library.
*/
function saveToCameraRoll(
tag: string,
type?: 'photo' | 'video',
): Promise<string>;
/**
* Returns a Promise with photo identifier objects from the local camera
* roll of the device matching shape defined by `getPhotosReturnChecker`.
*/
function getPhotos(params: GetPhotosParams): Promise<PhotoIdentifiersPage>;
/**
* Saves the photo or video to the camera roll or photo library.
*/
function save(
tag: string,
options?: SaveToCameraRollOptions,
): Promise<string>;
function getAlbums(params: GetAlbumsParams): Promise<Album[]>;
/**
* Returns a Promise with photo identifier objects from the local camera
* roll of the device matching shape defined by `getPhotosReturnChecker`.
*/
function getPhotos(params: GetPhotosParams): Promise<PhotoIdentifiersPage>;
function getAlbums(params: GetAlbumsParams): Promise<Album[]>;
}
export = CameraRoll;

View File

@ -1598,6 +1598,26 @@
resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.0.0.tgz#dc85454b953178cc6043df5208b9e949b54a3bc4"
integrity sha512-/rM+sWiuOZ5dvuVzV37sUuklsbg+JPOP8d+nNFlo2ZtfpzPiPvh1/gc8liWOLBqe+sR+ZM7guPaIcTt6UZTo7Q==
"@types/prop-types@*":
version "15.7.3"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7"
integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==
"@types/react-native@^0.62.10":
version "0.62.10"
resolved "https://registry.yarnpkg.com/@types/react-native/-/react-native-0.62.10.tgz#82c481df21db4e7460755dc3fc7091e333a1d2bd"
integrity sha512-QR4PGrzZ3IvRIHlScyIPuv2GV8tD/BMICZz514KGvn3KHbh0mLphHHemtHZC1o8u4xM5LxwVpMpMYHQ+ncSfag==
dependencies:
"@types/react" "*"
"@types/react@*":
version "16.9.35"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.35.tgz#a0830d172e8aadd9bd41709ba2281a3124bbd368"
integrity sha512-q0n0SsWcGc8nDqH2GJfWQWUOmZSJhXV64CjVN5SvcNti3TdEaA3AH0D8DwNmMdzjMAC/78tB8nAZIlV8yTz+zQ==
dependencies:
"@types/prop-types" "*"
csstype "^2.2.0"
"@types/stack-utils@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
@ -3157,6 +3177,11 @@ cssstyle@^2.2.0:
dependencies:
cssom "~0.3.6"
csstype@^2.2.0:
version "2.6.10"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.10.tgz#e63af50e66d7c266edb6b32909cfd0aabe03928b"
integrity sha512-D34BqZU4cIlMCY93rZHbrq9pjTAQJ3U8S8rfBqjwHxkGPThWFjzZDQpgMJY0QViLxth6ZKYiwFBo14RdN44U/w==
currently-unhandled@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"