Skip to content

Commit e087231

Browse files
committed
feat(ios): integrate PHPickerViewController for photo library on iOS 14+
- Does not need any permissions for reading images - The PHPickerViewController class is an alternative to UIImagePickerController. PHPickerViewController improves stability and reliability, and includes several benefits to developers and users, such as the following: - Deferred image loading and recovery UI - Reliable handling of large and complex assets, like RAW and panoramic images - User-selectable assets that aren’t available for UIImagePickerController - Configuration of the picker to display only Live Photos - Availability of PHLivePhoto objects without library access - Stricter validations against invalid inputs - See documentation of PHPickerViewController: https://developer.apple.com/documentation/photosui/phpickerviewcontroller?language=objc - Added tests for PHPickerViewController in `CameraTest.m`
1 parent 8864262 commit e087231

File tree

3 files changed

+375
-24
lines changed

3 files changed

+375
-24
lines changed

src/ios/CDVCamera.h

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
#import <CoreLocation/CoreLocation.h>
2222
#import <CoreLocation/CLLocationManager.h>
2323
#import <Cordova/CDVPlugin.h>
24+
// Since iOS 14, we can use PHPickerViewController to select images from the photo library
25+
#if __has_include(<PhotosUI/PhotosUI.h>)
26+
#import <PhotosUI/PhotosUI.h>
27+
#endif
2428

2529
enum CDVDestinationType {
2630
DestinationTypeDataUrl = 0,
@@ -78,12 +82,21 @@ typedef NSUInteger CDVMediaType;
7882
@end
7983

8084
// ======================================================================= //
81-
85+
// Since iOS 14, we can use PHPickerViewController to select images from the photo library
86+
#if __has_include(<PhotosUI/PhotosUI.h>)
87+
@interface CDVCamera : CDVPlugin <UIImagePickerControllerDelegate,
88+
UINavigationControllerDelegate,
89+
UIPopoverControllerDelegate,
90+
CLLocationManagerDelegate,
91+
PHPickerViewControllerDelegate>
92+
{}
93+
#else
8294
@interface CDVCamera : CDVPlugin <UIImagePickerControllerDelegate,
8395
UINavigationControllerDelegate,
8496
UIPopoverControllerDelegate,
8597
CLLocationManagerDelegate>
8698
{}
99+
#endif
87100

88101
@property (strong) CDVCameraPicker* pickerController;
89102
@property (strong) NSMutableDictionary *metadata;

src/ios/CDVCamera.m

Lines changed: 229 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ Licensed to the Apache Software Foundation (ASF) under one
3030
#import <MobileCoreServices/UTCoreTypes.h>
3131
#import <objc/message.h>
3232
#import <Photos/Photos.h>
33+
// Since iOS 14, we can use PHPickerViewController to select images from the photo library
34+
#if __has_include(<PhotosUI/PhotosUI.h>)
35+
#import <PhotosUI/PhotosUI.h>
36+
#endif
3337

3438
#ifndef __CORDOVA_4_0_0
3539
#import <Cordova/NSData+Base64.h>
@@ -184,26 +188,40 @@ - (void)takePicture:(CDVInvokedUrlCommand*)command
184188
}
185189
}];
186190
} else {
187-
[weakSelf options:pictureOptions requestPhotoPermissions:^(BOOL granted) {
188-
if (!granted) {
189-
// Denied; show an alert
190-
dispatch_async(dispatch_get_main_queue(), ^{
191-
UIAlertController *alertController = [UIAlertController alertControllerWithTitle:[[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleDisplayName"] message:NSLocalizedString(@"Access to the camera roll has been prohibited; please enable it in the Settings to continue.", nil) preferredStyle:UIAlertControllerStyleAlert];
192-
[alertController addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK", nil) style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
193-
[weakSelf sendNoPermissionResult:command.callbackId];
194-
}]];
195-
[alertController addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Settings", nil) style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
196-
[[UIApplication sharedApplication] openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString]];
197-
[weakSelf sendNoPermissionResult:command.callbackId];
198-
}]];
199-
[weakSelf.viewController presentViewController:alertController animated:YES completion:nil];
200-
});
201-
} else {
202-
dispatch_async(dispatch_get_main_queue(), ^{
203-
[weakSelf showCameraPicker:command.callbackId withOptions:pictureOptions];
204-
});
205-
}
206-
}];
191+
// For photo library on iOS 14+, PHPickerViewController doesn't require permissions
192+
// Only request permissions if we're on iOS < 14 or need UIImagePickerController
193+
BOOL needsPermissionCheck = YES;
194+
if (@available(iOS 14, *)) {
195+
needsPermissionCheck = NO; // PHPickerViewController will be used, no permission needed
196+
}
197+
198+
if (needsPermissionCheck) {
199+
[weakSelf options:pictureOptions requestPhotoPermissions:^(BOOL granted) {
200+
if (!granted) {
201+
// Denied; show an alert
202+
dispatch_async(dispatch_get_main_queue(), ^{
203+
UIAlertController *alertController = [UIAlertController alertControllerWithTitle:[[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleDisplayName"] message:NSLocalizedString(@"Access to the camera roll has been prohibited; please enable it in the Settings to continue.", nil) preferredStyle:UIAlertControllerStyleAlert];
204+
[alertController addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK", nil) style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
205+
[weakSelf sendNoPermissionResult:command.callbackId];
206+
}]];
207+
[alertController addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Settings", nil) style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
208+
[[UIApplication sharedApplication] openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString]];
209+
[weakSelf sendNoPermissionResult:command.callbackId];
210+
}]];
211+
[weakSelf.viewController presentViewController:alertController animated:YES completion:nil];
212+
});
213+
} else {
214+
dispatch_async(dispatch_get_main_queue(), ^{
215+
[weakSelf showCameraPicker:command.callbackId withOptions:pictureOptions];
216+
});
217+
}
218+
}];
219+
} else {
220+
// iOS 14+ with PHPickerViewController - no permission needed, show picker directly
221+
dispatch_async(dispatch_get_main_queue(), ^{
222+
[weakSelf showCameraPicker:command.callbackId withOptions:pictureOptions];
223+
});
224+
}
207225
}
208226
}];
209227
}
@@ -212,6 +230,15 @@ - (void)showCameraPicker:(NSString*)callbackId withOptions:(CDVPictureOptions *)
212230
{
213231
// Perform UI operations on the main thread
214232
dispatch_async(dispatch_get_main_queue(), ^{
233+
// Use PHPickerViewController for photo library on iOS 14+
234+
#if __has_include(<PhotosUI/PhotosUI.h>)
235+
if (pictureOptions.sourceType == UIImagePickerControllerSourceTypePhotoLibrary) {
236+
[self showPHPicker:callbackId withOptions:pictureOptions];
237+
return;
238+
}
239+
#endif
240+
241+
// Fallback to UIImagePickerController for camera or older iOS versions
215242
CDVCameraPicker* cameraPicker = [CDVCameraPicker createFromPictureOptions:pictureOptions];
216243
self.pickerController = cameraPicker;
217244

@@ -242,6 +269,40 @@ - (void)showCameraPicker:(NSString*)callbackId withOptions:(CDVPictureOptions *)
242269
});
243270
}
244271

272+
// Since iOS 14, we can use PHPickerViewController to select images from the photo library
273+
#if __has_include(<PhotosUI/PhotosUI.h>)
274+
- (void)showPHPicker:(NSString*)callbackId withOptions:(CDVPictureOptions*)pictureOptions API_AVAILABLE(ios(14))
275+
{
276+
PHPickerConfiguration *config = [[PHPickerConfiguration alloc] init];
277+
278+
// Configure filter based on media type
279+
if (pictureOptions.mediaType == MediaTypeVideo) {
280+
config.filter = [PHPickerFilter videosFilter];
281+
} else if (pictureOptions.mediaType == MediaTypeAll) {
282+
config.filter = [PHPickerFilter anyFilterMatchingSubfilters:@[
283+
[PHPickerFilter imagesFilter],
284+
[PHPickerFilter videosFilter]
285+
]];
286+
} else {
287+
config.filter = [PHPickerFilter imagesFilter];
288+
}
289+
290+
config.selectionLimit = 1;
291+
config.preferredAssetRepresentationMode = PHPickerConfigurationAssetRepresentationModeCurrent;
292+
293+
PHPickerViewController *picker = [[PHPickerViewController alloc] initWithConfiguration:config];
294+
picker.delegate = self;
295+
296+
// Store callback ID and options
297+
objc_setAssociatedObject(picker, "callbackId", callbackId, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
298+
objc_setAssociatedObject(picker, "pictureOptions", pictureOptions, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
299+
300+
[self.viewController presentViewController:picker animated:YES completion:^{
301+
self.hasPendingOperation = NO;
302+
}];
303+
}
304+
#endif
305+
245306
- (void)sendNoPermissionResult:(NSString*)callbackId
246307
{
247308
CDVPluginResult* result = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsString:@"has no access to camera"]; // error callback expects string ATM
@@ -898,6 +959,154 @@ - (void)imagePickerControllerReturnImageResult
898959
}
899960
}
900961

962+
#if __has_include(<PhotosUI/PhotosUI.h>)
963+
// PHPickerViewController Delegate Methods (iOS 14+)
964+
- (void)picker:(PHPickerViewController *)picker didFinishPicking:(NSArray<PHPickerResult *> *)results API_AVAILABLE(ios(14))
965+
{
966+
NSString *callbackId = objc_getAssociatedObject(picker, "callbackId");
967+
CDVPictureOptions *pictureOptions = objc_getAssociatedObject(picker, "pictureOptions");
968+
969+
__weak CDVCamera* weakSelf = self;
970+
971+
[picker dismissViewControllerAnimated:YES completion:^{
972+
if (results.count == 0) {
973+
// User cancelled
974+
CDVPluginResult* result = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsString:@"No Image Selected"];
975+
[weakSelf.commandDelegate sendPluginResult:result callbackId:callbackId];
976+
weakSelf.hasPendingOperation = NO;
977+
return;
978+
}
979+
980+
PHPickerResult *pickerResult = results.firstObject;
981+
982+
// Check if it's a video
983+
if ([pickerResult.itemProvider hasItemConformingToTypeIdentifier:UTTypeMovie.identifier]) {
984+
[pickerResult.itemProvider loadFileRepresentationForTypeIdentifier:UTTypeMovie.identifier completionHandler:^(NSURL * _Nullable url, NSError * _Nullable error) {
985+
if (error) {
986+
CDVPluginResult* result = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsString:[error localizedDescription]];
987+
[weakSelf.commandDelegate sendPluginResult:result callbackId:callbackId];
988+
weakSelf.hasPendingOperation = NO;
989+
return;
990+
}
991+
992+
dispatch_async(dispatch_get_main_queue(), ^{
993+
NSString* videoPath = [weakSelf createTmpVideo:[url path]];
994+
CDVPluginResult* result = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsString:videoPath];
995+
[weakSelf.commandDelegate sendPluginResult:result callbackId:callbackId];
996+
weakSelf.hasPendingOperation = NO;
997+
});
998+
}];
999+
}
1000+
// Handle image
1001+
else if ([pickerResult.itemProvider canLoadObjectOfClass:[UIImage class]]) {
1002+
[pickerResult.itemProvider loadObjectOfClass:[UIImage class] completionHandler:^(__kindof id<NSItemProviderReading> _Nullable object, NSError * _Nullable error) {
1003+
if (error) {
1004+
CDVPluginResult* result = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsString:[error localizedDescription]];
1005+
[weakSelf.commandDelegate sendPluginResult:result callbackId:callbackId];
1006+
weakSelf.hasPendingOperation = NO;
1007+
return;
1008+
}
1009+
1010+
UIImage *image = (UIImage *)object;
1011+
1012+
// Get asset identifier to fetch metadata
1013+
NSString *assetIdentifier = pickerResult.assetIdentifier;
1014+
1015+
dispatch_async(dispatch_get_main_queue(), ^{
1016+
[weakSelf processPHPickerImage:image assetIdentifier:assetIdentifier callbackId:callbackId options:pictureOptions];
1017+
});
1018+
}];
1019+
}
1020+
}];
1021+
}
1022+
1023+
- (void)processPHPickerImage:(UIImage*)image assetIdentifier:(NSString*)assetIdentifier callbackId:(NSString*)callbackId options:(CDVPictureOptions*)options API_AVAILABLE(ios(14))
1024+
{
1025+
__weak CDVCamera* weakSelf = self;
1026+
1027+
// Fetch metadata if asset identifier is available
1028+
if (assetIdentifier) {
1029+
PHFetchResult *result = [PHAsset fetchAssetsWithLocalIdentifiers:@[assetIdentifier] options:nil];
1030+
PHAsset *asset = result.firstObject;
1031+
1032+
if (asset) {
1033+
PHImageRequestOptions *imageOptions = [[PHImageRequestOptions alloc] init];
1034+
imageOptions.synchronous = YES;
1035+
imageOptions.networkAccessAllowed = YES;
1036+
1037+
[[PHImageManager defaultManager] requestImageDataForAsset:asset options:imageOptions resultHandler:^(NSData * _Nullable imageData, NSString * _Nullable dataUTI, UIImageOrientation orientation, NSDictionary * _Nullable info) {
1038+
NSDictionary *metadata = nil;
1039+
if (imageData) {
1040+
metadata = [weakSelf convertImageMetadata:imageData];
1041+
}
1042+
1043+
dispatch_async(dispatch_get_main_queue(), ^{
1044+
[weakSelf finalizePHPickerImage:image metadata:metadata callbackId:callbackId options:options];
1045+
});
1046+
}];
1047+
return;
1048+
}
1049+
}
1050+
1051+
// No metadata available
1052+
[self finalizePHPickerImage:image metadata:nil callbackId:callbackId options:options];
1053+
}
1054+
1055+
- (void)finalizePHPickerImage:(UIImage*)image metadata:(NSDictionary*)metadata callbackId:(NSString*)callbackId options:(CDVPictureOptions*)options API_AVAILABLE(ios(14))
1056+
{
1057+
// Process image according to options
1058+
UIImage *processedImage = image;
1059+
1060+
if (options.correctOrientation) {
1061+
processedImage = [processedImage imageCorrectedForCaptureOrientation];
1062+
}
1063+
1064+
if ((options.targetSize.width > 0) && (options.targetSize.height > 0)) {
1065+
if (options.cropToSize) {
1066+
processedImage = [processedImage imageByScalingAndCroppingForSize:options.targetSize];
1067+
} else {
1068+
processedImage = [processedImage imageByScalingNotCroppingForSize:options.targetSize];
1069+
}
1070+
}
1071+
1072+
// Create info dictionary similar to UIImagePickerController
1073+
NSMutableDictionary *info = [NSMutableDictionary dictionary];
1074+
[info setObject:processedImage forKey:UIImagePickerControllerOriginalImage];
1075+
if (metadata) {
1076+
[info setObject:metadata forKey:@"UIImagePickerControllerMediaMetadata"];
1077+
}
1078+
1079+
// Store metadata for processing
1080+
if (metadata) {
1081+
self.metadata = [[NSMutableDictionary alloc] init];
1082+
1083+
NSMutableDictionary* EXIFDictionary = [[metadata objectForKey:(NSString*)kCGImagePropertyExifDictionary] mutableCopy];
1084+
if (EXIFDictionary) {
1085+
[self.metadata setObject:EXIFDictionary forKey:(NSString*)kCGImagePropertyExifDictionary];
1086+
}
1087+
1088+
NSMutableDictionary* TIFFDictionary = [[metadata objectForKey:(NSString*)kCGImagePropertyTIFFDictionary] mutableCopy];
1089+
if (TIFFDictionary) {
1090+
[self.metadata setObject:TIFFDictionary forKey:(NSString*)kCGImagePropertyTIFFDictionary];
1091+
}
1092+
1093+
NSMutableDictionary* GPSDictionary = [[metadata objectForKey:(NSString*)kCGImagePropertyGPSDictionary] mutableCopy];
1094+
if (GPSDictionary) {
1095+
[self.metadata setObject:GPSDictionary forKey:(NSString*)kCGImagePropertyGPSDictionary];
1096+
}
1097+
}
1098+
1099+
__weak CDVCamera* weakSelf = self;
1100+
1101+
// Process and return result
1102+
[self resultForImage:options info:info completion:^(CDVPluginResult* res) {
1103+
[weakSelf.commandDelegate sendPluginResult:res callbackId:callbackId];
1104+
weakSelf.hasPendingOperation = NO;
1105+
weakSelf.pickerController = nil;
1106+
}];
1107+
}
1108+
#endif
1109+
9011110
@end
9021111

9031112
@implementation CDVCameraPicker

0 commit comments

Comments
 (0)