From ec33d328aff18ef63fb387d7fa889e86023aa630 Mon Sep 17 00:00:00 2001 From: Harry Yu Date: Mon, 22 Jun 2020 22:45:20 -0700 Subject: [PATCH] feat: Added imageSize and playableDuration to `include` param, deletes isStored (#187) BREAKING CHANGE: imageSize and playableDuration are no longer included by default to improve performance --- README.md | 15 +- .../cameraroll/CameraRollModule.java | 214 +++++++++++++----- example/js/GetPhotosPerformanceExample.tsx | 5 +- ios/RNCCameraRollManager.m | 18 +- js/CameraRoll.js | 8 +- typings/CameraRoll.d.ts | 15 +- 6 files changed, 187 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index 411d68b..270d3ba 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,8 @@ Returns a Promise with photo identifier objects from the local camera roll of th * `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. + * `imageSize` : Ensures `image.width` and `image.height` are available in each node. This has a small performance impact on Android. + * `playableDuration` : Ensures `image.playableDuration` is available in each node. This has a medium peformance impact on Android. Returns a Promise which when resolved will be of the following shape: @@ -215,13 +217,12 @@ 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 | null} : Only set if the `include` parameter contains `filename`. - * `height`: {number} - * `width`: {number} - * `fileSize`: {number | null} : Only set if the `include` parameter contains `fileSize`. - * `isStored`: {boolean} - * `playableDuration`: {number} - * `timestamp`: {number} : Timestamp in seconds. + * `filename`: {string | null} : Only set if the `include` parameter contains `filename` + * `height`: {number | null} : Only set if the `include` parameter contains `imageSize` + * `width`: {number | null} : Only set if the `include` parameter contains `imageSize` + * `fileSize`: {number | null} : Only set if the `include` parameter contains `fileSize` + * `playableDuration`: {number | null} : Only set for videos if the `include` parameter contains `playableDuration`. Will be null for images. + * `timestamp`: {number} * `location`: {object | null} : Only set if the `include` parameter contains `location`. An object with the following shape: * `latitude`: {number} * `longitude`: {number} diff --git a/android/src/main/java/com/reactnativecommunity/cameraroll/CameraRollModule.java b/android/src/main/java/com/reactnativecommunity/cameraroll/CameraRollModule.java index 76f2bc4..299bb7f 100644 --- a/android/src/main/java/com/reactnativecommunity/cameraroll/CameraRollModule.java +++ b/android/src/main/java/com/reactnativecommunity/cameraroll/CameraRollModule.java @@ -43,6 +43,7 @@ import com.facebook.react.module.annotations.ReactModule; import java.io.File; import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.nio.channels.FileChannel; @@ -77,6 +78,8 @@ public class CameraRollModule extends ReactContextBaseJavaModule { 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 INCLUDE_IMAGE_SIZE = "imageSize"; + private static final String INCLUDE_PLAYABLE_DURATION = "playableDuration"; private static final String[] PROJECTION = { Images.Media._ID, @@ -506,13 +509,16 @@ public class CameraRollModule extends ReactContextBaseJavaModule { boolean includeLocation = include.contains(INCLUDE_LOCATION); boolean includeFilename = include.contains(INCLUDE_FILENAME); boolean includeFileSize = include.contains(INCLUDE_FILE_SIZE); + boolean includeImageSize = include.contains(INCLUDE_IMAGE_SIZE); + boolean includePlayableDuration = include.contains(INCLUDE_PLAYABLE_DURATION); for (int i = 0; i < limit && !media.isAfterLast(); i++) { WritableMap edge = new WritableNativeMap(); WritableMap node = new WritableNativeMap(); boolean imageInfoSuccess = putImageInfo(resolver, media, node, widthIndex, heightIndex, sizeIndex, dataIndex, - mimeTypeIndex, includeFilename, includeFileSize); + mimeTypeIndex, includeFilename, includeFileSize, includeImageSize, + includePlayableDuration); if (imageInfoSuccess) { putBasicNodeInfo(media, node, mimeTypeIndex, groupNameIndex, dateTakenIndex); putLocationInfo(media, node, dataIndex, includeLocation); @@ -540,6 +546,10 @@ public class CameraRollModule extends ReactContextBaseJavaModule { node.putDouble("timestamp", media.getLong(dateTakenIndex) / 1000d); } + /** + * @return Whether we successfully fetched all the information about the image that we were asked + * to include + */ private static boolean putImageInfo( ContentResolver resolver, Cursor media, @@ -550,70 +560,19 @@ public class CameraRollModule extends ReactContextBaseJavaModule { int dataIndex, int mimeTypeIndex, boolean includeFilename, - boolean includeFileSize) { + boolean includeFileSize, + boolean includeImageSize, + boolean includePlayableDuration) { WritableMap image = new WritableNativeMap(); Uri photoUri = Uri.parse("file://" + media.getString(dataIndex)); image.putString("uri", photoUri.toString()); - float width = media.getInt(widthIndex); - float height = media.getInt(heightIndex); String mimeType = media.getString(mimeTypeIndex); - if (mimeType != null - && mimeType.startsWith("video")) { - try { - AssetFileDescriptor photoDescriptor = resolver.openAssetFileDescriptor(photoUri, "r"); - MediaMetadataRetriever retriever = new MediaMetadataRetriever(); - retriever.setDataSource(photoDescriptor.getFileDescriptor()); - - try { - if (width <= 0 || height <= 0) { - width = - Integer.parseInt( - retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)); - height = - Integer.parseInt( - retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)); - } - int timeInMillisec = - Integer.parseInt( - retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)); - int playableDuration = timeInMillisec / 1000; - image.putInt("playableDuration", playableDuration); - } catch (NumberFormatException e) { - FLog.e( - ReactConstants.TAG, - "Number format exception occurred while trying to fetch video metadata for " - + photoUri.toString(), - e); - return false; - } finally { - retriever.release(); - photoDescriptor.close(); - } - } catch (Exception e) { - FLog.e(ReactConstants.TAG, "Could not get video metadata for " + photoUri.toString(), e); - return false; - } - } - - if (width <= 0 || height <= 0) { - try { - AssetFileDescriptor photoDescriptor = resolver.openAssetFileDescriptor(photoUri, "r"); - BitmapFactory.Options options = new BitmapFactory.Options(); - // Set inJustDecodeBounds to true so we don't actually load the Bitmap, but only get its - // dimensions instead. - options.inJustDecodeBounds = true; - BitmapFactory.decodeFileDescriptor(photoDescriptor.getFileDescriptor(), null, options); - width = options.outWidth; - height = options.outHeight; - photoDescriptor.close(); - } catch (IOException e) { - FLog.e(ReactConstants.TAG, "Could not get width/height for " + photoUri.toString(), e); - return false; - } - } - image.putDouble("width", width); - image.putDouble("height", height); + boolean isVideo = mimeType != null && mimeType.startsWith("video"); + boolean putImageSizeSuccess = putImageSize(resolver, media, image, widthIndex, heightIndex, + photoUri, isVideo, includeImageSize); + boolean putPlayableDurationSuccess = putPlayableDuration(resolver, image, photoUri, isVideo, + includePlayableDuration); if (includeFilename) { File file = new File(media.getString(dataIndex)); @@ -630,8 +589,138 @@ public class CameraRollModule extends ReactContextBaseJavaModule { } node.putMap("image", image); + return putImageSizeSuccess && putPlayableDurationSuccess; + } - return true; + /** + * @return Whether we succeeded in fetching and putting the playableDuration + */ + private static boolean putPlayableDuration( + ContentResolver resolver, + WritableMap image, + Uri photoUri, + boolean isVideo, + boolean includePlayableDuration) { + image.putNull("playableDuration"); + + if (!includePlayableDuration || !isVideo) { + return true; + } + + boolean success = true; + @Nullable Integer playableDuration = null; + @Nullable AssetFileDescriptor photoDescriptor = null; + try { + photoDescriptor = resolver.openAssetFileDescriptor(photoUri, "r"); + } catch (FileNotFoundException e) { + success = false; + FLog.e(ReactConstants.TAG, "Could not open asset file " + photoUri.toString(), e); + } + + if (photoDescriptor != null) { + MediaMetadataRetriever retriever = new MediaMetadataRetriever(); + retriever.setDataSource(photoDescriptor.getFileDescriptor()); + try { + int timeInMillisec = + Integer.parseInt( + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)); + playableDuration = timeInMillisec / 1000; + } catch (NumberFormatException e) { + success = false; + FLog.e( + ReactConstants.TAG, + "Number format exception occurred while trying to fetch video metadata for " + + photoUri.toString(), + e); + } + retriever.release(); + } + + if (photoDescriptor != null) { + try { + photoDescriptor.close(); + } catch (IOException e) { + // Do nothing. We can't handle this, and this is usually a system problem + } + } + + if (playableDuration != null) { + image.putInt("playableDuration", playableDuration); + } + + return success; + } + + private static boolean putImageSize( + ContentResolver resolver, + Cursor media, + WritableMap image, + int widthIndex, + int heightIndex, + Uri photoUri, + boolean isVideo, + boolean includeImageSize) { + image.putNull("width"); + image.putNull("height"); + + if (!includeImageSize) { + return true; + } + + boolean success = true; + int width = media.getInt(widthIndex); + int height = media.getInt(heightIndex); + + if (width <= 0 || height <= 0) { + @Nullable AssetFileDescriptor photoDescriptor = null; + try { + photoDescriptor = resolver.openAssetFileDescriptor(photoUri, "r"); + } catch (FileNotFoundException e) { + success = false; + FLog.e(ReactConstants.TAG, "Could not open asset file " + photoUri.toString(), e); + } + + if (photoDescriptor != null) { + if (isVideo) { + MediaMetadataRetriever retriever = new MediaMetadataRetriever(); + retriever.setDataSource(photoDescriptor.getFileDescriptor()); + try { + width = + Integer.parseInt( + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)); + height = + Integer.parseInt( + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)); + } catch (NumberFormatException e) { + success = false; + FLog.e( + ReactConstants.TAG, + "Number format exception occurred while trying to fetch video metadata for " + + photoUri.toString(), + e); + } + retriever.release(); + } else { + BitmapFactory.Options options = new BitmapFactory.Options(); + // Set inJustDecodeBounds to true so we don't actually load the Bitmap, but only get its + // dimensions instead. + options.inJustDecodeBounds = true; + BitmapFactory.decodeFileDescriptor(photoDescriptor.getFileDescriptor(), null, options); + width = options.outWidth; + height = options.outHeight; + } + } + + try { + photoDescriptor.close(); + } catch (IOException e) { + // Do nothing. We can't handle this, and this is usually a system problem + } + } + + image.putInt("width", width); + image.putInt("height", height); + return success; } private static void putLocationInfo( @@ -639,8 +728,9 @@ public class CameraRollModule extends ReactContextBaseJavaModule { WritableMap node, int dataIndex, boolean includeLocation) { + node.putNull("location"); + if (!includeLocation) { - node.putNull("location"); return; } diff --git a/example/js/GetPhotosPerformanceExample.tsx b/example/js/GetPhotosPerformanceExample.tsx index 964d9c5..7a2ab19 100644 --- a/example/js/GetPhotosPerformanceExample.tsx +++ b/example/js/GetPhotosPerformanceExample.tsx @@ -21,14 +21,14 @@ interface State { * with `this.first()` before using. */ firstStr: string; - /** `after` passed into `getPhotos`. Not passed if empty */ - after: string; } const includeValues: CameraRoll.Include[] = [ 'filename', 'fileSize', 'location', + 'imageSize', + 'playableDuration', ]; /** @@ -45,7 +45,6 @@ export default class GetPhotosPerformanceExample extends React.PureComponent< output: null, include: [], firstStr: '1000', - after: '', }; first = () => { diff --git a/ios/RNCCameraRollManager.m b/ios/RNCCameraRollManager.m index db2ed18..06f6fa3 100644 --- a/ios/RNCCameraRollManager.m +++ b/ios/RNCCameraRollManager.m @@ -260,6 +260,8 @@ RCT_EXPORT_METHOD(getPhotos:(NSDictionary *)params BOOL __block includeFilename = [include indexOfObject:@"filename"] != NSNotFound; BOOL __block includeFileSize = [include indexOfObject:@"fileSize"] != NSNotFound; BOOL __block includeLocation = [include indexOfObject:@"location"] != NSNotFound; + BOOL __block includeImageSize = [include indexOfObject:@"imageSize"] != NSNotFound; + BOOL __block includePlayableDuration = [include indexOfObject:@"playableDuration"] != NSNotFound; // If groupTypes is "all", we want to fetch the SmartAlbum "all photos". Otherwise, all // other groupTypes values require the "album" collection type. @@ -366,13 +368,6 @@ RCT_EXPORT_METHOD(getPhotos:(NSDictionary *)params : @"unknown"))); CLLocation *const loc = asset.location; - // 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 (?). - // To get the "isStored" information and filename, we would need to actually request the - // image data from the image manager. Those operations could get really expensive and - // would definitely utilize the disk too much. - // Thus, this field is actually not reliable. - // Note that Android also does not return the `isStored` field at all. [assets addObject:@{ @"node": @{ @"type": assetMediaTypeLabel, // TODO: switch to mimeType? @@ -380,11 +375,12 @@ RCT_EXPORT_METHOD(getPhotos:(NSDictionary *)params @"image": @{ @"uri": uri, @"filename": (includeFilename && originalFilename ? originalFilename : [NSNull null]), - @"height": @([asset pixelHeight]), - @"width": @([asset pixelWidth]), + @"height": (includeImageSize ? @([asset pixelHeight]) : [NSNull null]), + @"width": (includeImageSize ? @([asset pixelWidth]) : [NSNull null]), @"fileSize": (includeFileSize ? fileSize : [NSNull null]), - @"isStored": @YES, // this field doesn't seem to exist on android - @"playableDuration": @([asset duration]) // fractional seconds + @"playableDuration": (includePlayableDuration && asset.mediaType != PHAssetMediaTypeImage + ? @([asset duration]) // fractional seconds + : [NSNull null]) }, @"timestamp": @(asset.creationDate.timeIntervalSince1970), @"location": (includeLocation && loc ? @{ diff --git a/js/CameraRoll.js b/js/CameraRoll.js index 89512f4..a7a0f12 100644 --- a/js/CameraRoll.js +++ b/js/CameraRoll.js @@ -31,7 +31,12 @@ const ASSET_TYPE_OPTIONS = { export type GroupTypes = $Keys; -export type Include = 'filename' | 'fileSize' | 'location'; +export type Include = + | 'filename' + | 'fileSize' + | 'location' + | 'imageSize' + | 'playableDuration'; /** * Shape of the param arg for the `getPhotos` function. @@ -97,7 +102,6 @@ export type PhotoIdentifier = { height: number, width: number, fileSize: number | null, - isStored?: boolean, playableDuration: number, }, timestamp: number, diff --git a/typings/CameraRoll.d.ts b/typings/CameraRoll.d.ts index ba0a8d0..7e254e8 100644 --- a/typings/CameraRoll.d.ts +++ b/typings/CameraRoll.d.ts @@ -23,7 +23,11 @@ declare namespace CameraRoll { /** 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'; + | 'location' + /** Ensures the image width and height are included. Has a small performance hit on Android */ + | 'imageSize' + /** Ensures the image playableDuration is included. Has a medium performance hit on Android */ + | 'playableDuration'; /** * Shape of the param arg for the `getPhotosFast` function. @@ -92,12 +96,17 @@ declare namespace CameraRoll { /** Only set if the `include` parameter contains `filename`. */ filename: string | null; uri: string; + /** Only set if the `include` parameter contains `imageSize`. */ height: number; + /** Only set if the `include` parameter contains `imageSize`. */ width: number; /** Only set if the `include` parameter contains `fileSize`. */ fileSize: number | null; - isStored?: boolean; - playableDuration: number; + /** + * Only set if the `include` parameter contains `playableDuration`. + * Will be null for images. + */ + playableDuration: number | null; }; /** Timestamp in seconds. */ timestamp: number;