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, "singleQuote": true,
"trailingComma": "all", "trailingComma": "all",
"bracketSpacing": false, "bracketSpacing": false,
"jsxBracketSameLine": true, "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. | | 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. * `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: * `groupTypes` : {string} : Specifies which group types to filter the results to. Valid values are:
* `Album` * `Album`
* `All` // default * `All` // default
@ -165,9 +165,13 @@ Returns a Promise with photo identifier objects from the local camera roll of th
* `All` * `All`
* `Videos` * `Videos`
* `Photos` // default * `Photos` // default
* `mimeTypes` : {Array} : Filter by mimetype (e.g. image/jpeg). * `mimeTypes` : {Array} : Filter by mimetype (e.g. image/jpeg). Note that using this will reduce performance slightly on iOS.
* `fromTime` : {timestamp} : Filter from date added. * `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` : {timestamp} : Filter to date added. * `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: 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} * `group_name`: {string}
* `image`: {object} : An object with the following shape: * `image`: {object} : An object with the following shape:
* `uri`: {string} * `uri`: {string}
* `filename`: {string} * `filename`: {string | null} : Only set if the `include` parameter contains `filename`.
* `height`: {number} * `height`: {number}
* `width`: {number} * `width`: {number}
* `fileSize`: {number} * `fileSize`: {number | null} : Only set if the `include` parameter contains `fileSize`.
* `isStored`: {boolean} * `isStored`: {boolean}
* `playableDuration`: {number} * `playableDuration`: {number}
* `timestamp`: {number} * `timestamp`: {number} : Timestamp in seconds.
* `location`: {object} : An object with the following shape: * `location`: {object | null} : Only set if the `include` parameter contains `location`. An object with the following shape:
* `latitude`: {number} * `latitude`: {number}
* `longitude`: {number} * `longitude`: {number}
* `altitude`: {number} * `altitude`: {number}
@ -233,8 +237,10 @@ render() {
</View> </View>
); );
} }
``` ```
--- ---
### `deletePhotos()` ### `deletePhotos()`
```javascript ```javascript

View File

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

View File

@ -57,7 +57,7 @@ export default class CameraRollExample extends React.Component<Props, State> {
render() { render() {
return ( return (
<View> <View style={styles.flex1}>
<View style={styles.header}> <View style={styles.header}>
<Switch <Switch
onValueChange={this._onSwitchChange} onValueChange={this._onSwitchChange}
@ -107,7 +107,7 @@ export default class CameraRollExample extends React.Component<Props, State> {
onPress={this.loadAsset.bind(this, asset)}> onPress={this.loadAsset.bind(this, asset)}>
<View style={styles.row}> <View style={styles.row}>
<Image source={{uri: asset.node.image.uri}} style={imageStyle} /> <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 style={styles.url}>{asset.node.image.uri}</Text>
<Text>{locationStr}</Text> <Text>{locationStr}</Text>
<Text>{asset.node.group_name}</Text> <Text>{asset.node.group_name}</Text>
@ -135,7 +135,6 @@ export default class CameraRollExample extends React.Component<Props, State> {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
header: { header: {
marginTop: 44,
padding: 20, padding: 20,
width: Dimensions.get('window').width, width: Dimensions.get('window').width,
}, },
@ -150,7 +149,7 @@ const styles = StyleSheet.create({
image: { image: {
margin: 4, margin: 4,
}, },
info: { flex1: {
flex: 1, 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) { if (toTime > 0) {
NSDate* toDate = [NSDate dateWithTimeIntervalSince1970:toTime/1000]; NSDate* toDate = [NSDate dateWithTimeIntervalSince1970:toTime/1000];
[format addObject:@"creationDate < %@"]; [format addObject:@"creationDate <= %@"];
[arguments addObject:toDate]; [arguments addObject:toDate];
} }
@ -255,6 +255,11 @@ RCT_EXPORT_METHOD(getPhotos:(NSDictionary *)params
NSUInteger const fromTime = [RCTConvert NSInteger:params[@"fromTime"]]; NSUInteger const fromTime = [RCTConvert NSInteger:params[@"fromTime"]];
NSUInteger const toTime = [RCTConvert NSInteger:params[@"toTime"]]; NSUInteger const toTime = [RCTConvert NSInteger:params[@"toTime"]];
NSArray<NSString *> *const mimeTypes = [RCTConvert NSStringArray:params[@"mimeTypes"]]; 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 // If groupTypes is "all", we want to fetch the SmartAlbum "all photos". Otherwise, all
// other groupTypes values require the "album" collection type. // 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 // Predicate for fetching assets within a collection
PHFetchOptions *const assetFetchOptions = [RCTConvert PHFetchOptionsFromMediaType:mediaType fromTime:fromTime toTime:toTime]; 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]]; assetFetchOptions.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate" ascending:NO]];
BOOL __block foundAfter = NO; BOOL __block foundAfter = NO;
@ -277,7 +294,6 @@ RCT_EXPORT_METHOD(getPhotos:(NSDictionary *)params
collectionFetchOptions.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"endDate" ascending:NO]]; collectionFetchOptions.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"endDate" ascending:NO]];
if (groupName != nil) { if (groupName != nil) {
collectionFetchOptions.predicate = [NSPredicate predicateWithFormat:@"localizedTitle = %@", groupName]; collectionFetchOptions.predicate = [NSPredicate predicateWithFormat:@"localizedTitle = %@", groupName];
} }
BOOL __block stopCollections_; BOOL __block stopCollections_;
@ -286,34 +302,47 @@ RCT_EXPORT_METHOD(getPhotos:(NSDictionary *)params
requestPhotoLibraryAccess(reject, ^{ requestPhotoLibraryAccess(reject, ^{
void (^collectAsset)(PHAsset*, NSUInteger, BOOL*) = ^(PHAsset * _Nonnull asset, NSUInteger assetIdx, BOOL * _Nonnull stopAssets) { void (^collectAsset)(PHAsset*, NSUInteger, BOOL*) = ^(PHAsset * _Nonnull asset, NSUInteger assetIdx, BOOL * _Nonnull stopAssets) {
NSString *const uri = [NSString stringWithFormat:@"ph://%@", [asset localIdentifier]]; NSString *const uri = [NSString stringWithFormat:@"ph://%@", [asset localIdentifier]];
if (afterCursor && !foundAfter) { NSString *_Nullable originalFilename = NULL;
if ([afterCursor isEqualToString:uri]) { PHAssetResource *_Nullable resource = NULL;
foundAfter = YES; NSNumber* fileSize = [NSNumber numberWithInt:0];
}
return; // skip until we get to the first one 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 // WARNING: If you add any code to `collectAsset` that may skip adding an
NSArray<PHAssetResource *> *const assetResources = [PHAssetResource assetResourcesForAsset:asset]; // asset to the `assets` output array, you should do it inside this
if (![assetResources firstObject]) { // block and ensure the logic for `collectAssetMayOmitAsset` above is
return; // updated
} if (collectAssetMayOmitAsset) {
PHAssetResource *const _Nonnull resource = [assetResources firstObject]; if (afterCursor && !foundAfter) {
if ([afterCursor isEqualToString:uri]) {
if ([mimeTypes count] > 0) { foundAfter = YES;
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;
} }
}]; 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" ? @"audio"
: @"unknown"))); : @"unknown")));
CLLocation *const loc = asset.location; CLLocation *const loc = asset.location;
NSString *const origFilename = resource.originalFilename;
// A note on isStored: in the previous code that used ALAssets, isStored // 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 (?). // 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, @"group_name": currentCollectionName,
@"image": @{ @"image": @{
@"uri": uri, @"uri": uri,
@"filename": origFilename, @"filename": (includeFilename && originalFilename ? originalFilename : [NSNull null]),
@"height": @([asset pixelHeight]), @"height": @([asset pixelHeight]),
@"width": @([asset pixelWidth]), @"width": @([asset pixelWidth]),
@"fileSize": [resource valueForKey:@"fileSize"], @"fileSize": (includeFileSize ? fileSize : [NSNull null]),
@"isStored": @YES, // this field doesn't seem to exist on android @"isStored": @YES, // this field doesn't seem to exist on android
@"playableDuration": @([asset duration]) // fractional seconds @"playableDuration": @([asset duration]) // fractional seconds
}, },
@"timestamp": @(asset.creationDate.timeIntervalSince1970), @"timestamp": @(asset.creationDate.timeIntervalSince1970),
@"location": (loc ? @{ @"location": (includeLocation && loc ? @{
@"latitude": @(loc.coordinate.latitude), @"latitude": @(loc.coordinate.latitude),
@"longitude": @(loc.coordinate.longitude), @"longitude": @(loc.coordinate.longitude),
@"altitude": @(loc.altitude), @"altitude": @(loc.altitude),
@"heading": @(loc.course), @"heading": @(loc.course),
@"speed": @(loc.speed), // speed in m/s @"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 GroupTypes = $Keys<typeof GROUP_TYPES_OPTIONS>;
export type Include = 'filename' | 'fileSize' | 'location';
/** /**
* Shape of the param arg for the `getPhotos` function. * Shape of the param arg for the `getPhotos` function.
*/ */
@ -63,10 +65,26 @@ export type GetPhotosParams = {
*/ */
assetType?: $Keys<typeof ASSET_TYPE_OPTIONS>, 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). * Filter by mimetype (e.g. image/jpeg).
*/ */
mimeTypes?: Array<string>, 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 = { export type PhotoIdentifier = {
@ -74,22 +92,22 @@ export type PhotoIdentifier = {
type: string, type: string,
group_name: string, group_name: string,
image: { image: {
filename: string, filename: string | null,
uri: string, uri: string,
height: number, height: number,
width: number, width: number,
fileSize: number, fileSize: number | null,
isStored?: boolean, isStored?: boolean,
playableDuration: number, playableDuration: number,
}, },
timestamp: number, timestamp: number,
location?: { location: {
latitude?: number, latitude?: number,
longitude?: number, longitude?: number,
altitude?: number, altitude?: number,
heading?: number, heading?: number,
speed?: number, speed?: number,
}, } | null,
}, },
}; };
@ -114,6 +132,7 @@ export type Album = {
title: string, title: string,
count: number, count: number,
}; };
/** /**
* `CameraRoll` provides access to the local camera roll or photo library. * `CameraRoll` provides access to the local camera roll or photo library.
* *
@ -186,6 +205,18 @@ class CameraRoll {
): Promise<Album[]> { ): Promise<Album[]> {
return RNCCameraRoll.getAlbums(params); 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 * Returns a Promise with photo identifier objects from the local camera
* roll of the device matching shape defined by `getPhotosReturnChecker`. * 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 * See https://facebook.github.io/react-native/docs/cameraroll.html#getphotos
*/ */
static getPhotos(params: GetPhotosParams): Promise<PhotoIdentifiersPage> { static getPhotos(params: GetPhotosParams): Promise<PhotoIdentifiersPage> {
if (!params.assetType) { params = CameraRoll.getParamsWithDefaults(params);
params.assetType = ASSET_TYPE_OPTIONS.All; const promise = RNCCameraRoll.getPhotos(params);
}
if (!params.groupTypes && Platform.OS !== 'android') {
params.groupTypes = GROUP_TYPES_OPTIONS.All;
}
if (arguments.length > 1) { if (arguments.length > 1) {
console.warn( console.warn(
'CameraRoll.getPhotos(tag, success, error) is deprecated. Use the returned Promise instead', 'CameraRoll.getPhotos(tag, success, error) is deprecated. Use the returned Promise instead',
); );
let successCallback = arguments[1]; let successCallback = arguments[1];
const errorCallback = arguments[2] || (() => {}); 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", "@babel/runtime": "^7.9.6",
"@react-native-community/eslint-config": "^1.1.0", "@react-native-community/eslint-config": "^1.1.0",
"@semantic-release/git": "7.0.8", "@semantic-release/git": "7.0.8",
"@types/react-native": "^0.62.10",
"babel-core": "^7.0.0-bridge.0", "babel-core": "^7.0.0-bridge.0",
"babel-jest": "^26.0.1", "babel-jest": "^26.0.1",
"babel-plugin-module-resolver": "^3.2.0", "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": { "compilerOptions": {
"target": "es5", "target": "es5",
"module": "commonjs", "module": "commonjs",
@ -16,10 +16,10 @@
"skipLibCheck": true, "skipLibCheck": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"lib": ["es2015", "es2016", "esnext", "dom"] "lib": ["es2015", "es2016", "esnext", "dom"],
"allowSyntheticDefaultImports": true,
"noEmit": true,
"resolveJsonModule": true
}, },
"exclude": [ "exclude": ["node_modules", "**/*.spec.ts"]
"node_modules",
"**/*.spec.ts"
]
} }

View File

@ -3,8 +3,6 @@
* *
* This source code is licensed under the MIT license found in the * This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*
* @format
*/ */
declare namespace CameraRoll { declare namespace CameraRoll {
@ -19,47 +17,107 @@ declare namespace CameraRoll {
type AssetType = 'All' | 'Videos' | 'Photos'; 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 { interface GetPhotosParams {
/**
* The number of photos wanted in reverse order of the photo application
* (i.e. most recent first).
*/
first: number; 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; after?: string;
/**
* Specifies which group types to filter the results to.
*/
groupTypes?: GroupType; groupTypes?: GroupType;
/**
* Specifies filter on group names, like 'Recent Photos' or custom album
* titles.
*/
groupName?: string; groupName?: string;
/**
* Specifies filter on asset type
*/
assetType?: AssetType; 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; 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; 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 { interface PhotoIdentifier {
node: { node: {
type: string, type: string;
group_name: string, group_name: string;
image: { image: {
filename: string, /** Only set if the `include` parameter contains `filename`. */
uri: string, filename: string | null;
height: number, uri: string;
width: number, height: number;
fileSize: number, width: number;
isStored?: boolean, /** Only set if the `include` parameter contains `fileSize`. */
playableDuration: number, fileSize: number | null;
}, isStored?: boolean;
timestamp: number, playableDuration: number;
location?: { };
latitude?: number, /** Timestamp in seconds. */
longitude?: number, timestamp: number;
altitude?: number, /** Only set if the `include` parameter contains `location`. */
heading?: number, location: {
speed?: number, latitude?: number;
}, longitude?: number;
altitude?: number;
heading?: number;
speed?: number;
} | null;
}; };
} }
interface PhotoIdentifiersPage { interface PhotoIdentifiersPage {
edges: Array<PhotoIdentifier>; edges: Array<PhotoIdentifier>;
page_info: { page_info: {
has_next_page: boolean, has_next_page: boolean;
start_cursor?: string, start_cursor?: string;
end_cursor?: string, end_cursor?: string;
}; };
} }
@ -73,37 +131,43 @@ declare namespace CameraRoll {
} }
type SaveToCameraRollOptions = { type SaveToCameraRollOptions = {
type?: 'photo' | 'video' | 'auto', type?: 'photo' | 'video' | 'auto';
album?: string, album?: string;
}; };
/** /**
* `CameraRoll.saveImageWithTag()` is deprecated. Use `CameraRoll.saveToCameraRoll()` instead. * `CameraRoll.saveImageWithTag()` is deprecated. Use `CameraRoll.saveToCameraRoll()` instead.
*/ */
function saveImageWithTag(tag: string): Promise<string>; function saveImageWithTag(tag: string): Promise<string>;
/** /**
* Delete a photo from the camera roll or media library. photoUris is an array of photo uri's. * 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>; 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>;
/** /**
* Saves the photo or video to the camera roll or photo library. * Saves the photo or video to the camera roll or photo library.
*/ */
function save(tag: string, options?: SaveToCameraRollOptions): Promise<string> function saveToCameraRoll(
tag: string,
type?: 'photo' | 'video',
): Promise<string>;
/** /**
* Returns a Promise with photo identifier objects from the local camera * Saves the photo or video to the camera roll or photo library.
* roll of the device matching shape defined by `getPhotosReturnChecker`. */
*/ function save(
function getPhotos(params: GetPhotosParams): Promise<PhotoIdentifiersPage>; 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; export = CameraRoll;

View File

@ -1598,6 +1598,26 @@
resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.0.0.tgz#dc85454b953178cc6043df5208b9e949b54a3bc4" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.0.0.tgz#dc85454b953178cc6043df5208b9e949b54a3bc4"
integrity sha512-/rM+sWiuOZ5dvuVzV37sUuklsbg+JPOP8d+nNFlo2ZtfpzPiPvh1/gc8liWOLBqe+sR+ZM7guPaIcTt6UZTo7Q== 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": "@types/stack-utils@^1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
@ -3157,6 +3177,11 @@ cssstyle@^2.2.0:
dependencies: dependencies:
cssom "~0.3.6" 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: currently-unhandled@^0.4.1:
version "0.4.1" version "0.4.1"
resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"