This is an automated email from the ASF dual-hosted git repository. manuelbeck pushed a commit to branch pr-phpickerviewcontroller-since-ios-14 in repository https://gitbox.apache.org/repos/asf/cordova-plugin-camera.git
commit e087231d6d5897f407099e588da2a2c1f15ff649 Author: Manuel Beck <[email protected]> AuthorDate: Thu Dec 4 00:20:03 2025 +0100 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` --- src/ios/CDVCamera.h | 15 +- src/ios/CDVCamera.m | 249 +++++++++++++++++++-- .../CDVCameraTest/CDVCameraLibTests/CameraTest.m | 135 ++++++++++- 3 files changed, 375 insertions(+), 24 deletions(-) diff --git a/src/ios/CDVCamera.h b/src/ios/CDVCamera.h index 647ffab..cbc634d 100644 --- a/src/ios/CDVCamera.h +++ b/src/ios/CDVCamera.h @@ -21,6 +21,10 @@ #import <CoreLocation/CoreLocation.h> #import <CoreLocation/CLLocationManager.h> #import <Cordova/CDVPlugin.h> +// Since iOS 14, we can use PHPickerViewController to select images from the photo library +#if __has_include(<PhotosUI/PhotosUI.h>) +#import <PhotosUI/PhotosUI.h> +#endif enum CDVDestinationType { DestinationTypeDataUrl = 0, @@ -78,12 +82,21 @@ typedef NSUInteger CDVMediaType; @end // ======================================================================= // - +// Since iOS 14, we can use PHPickerViewController to select images from the photo library +#if __has_include(<PhotosUI/PhotosUI.h>) +@interface CDVCamera : CDVPlugin <UIImagePickerControllerDelegate, + UINavigationControllerDelegate, + UIPopoverControllerDelegate, + CLLocationManagerDelegate, + PHPickerViewControllerDelegate> +{} +#else @interface CDVCamera : CDVPlugin <UIImagePickerControllerDelegate, UINavigationControllerDelegate, UIPopoverControllerDelegate, CLLocationManagerDelegate> {} +#endif @property (strong) CDVCameraPicker* pickerController; @property (strong) NSMutableDictionary *metadata; diff --git a/src/ios/CDVCamera.m b/src/ios/CDVCamera.m index 9fb6c73..e19881e 100644 --- a/src/ios/CDVCamera.m +++ b/src/ios/CDVCamera.m @@ -30,6 +30,10 @@ #import <MobileCoreServices/UTCoreTypes.h> #import <objc/message.h> #import <Photos/Photos.h> +// Since iOS 14, we can use PHPickerViewController to select images from the photo library +#if __has_include(<PhotosUI/PhotosUI.h>) +#import <PhotosUI/PhotosUI.h> +#endif #ifndef __CORDOVA_4_0_0 #import <Cordova/NSData+Base64.h> @@ -184,26 +188,40 @@ static NSString* MIME_JPEG = @"image/jpeg"; } }]; } else { - [weakSelf options:pictureOptions requestPhotoPermissions:^(BOOL granted) { - if (!granted) { - // Denied; show an alert - dispatch_async(dispatch_get_main_queue(), ^{ - 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]; - [alertController addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK", nil) style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [weakSelf sendNoPermissionResult:command.callbackId]; - }]]; - [alertController addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Settings", nil) style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [[UIApplication sharedApplication] openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString]]; - [weakSelf sendNoPermissionResult:command.callbackId]; - }]]; - [weakSelf.viewController presentViewController:alertController animated:YES completion:nil]; - }); - } else { - dispatch_async(dispatch_get_main_queue(), ^{ - [weakSelf showCameraPicker:command.callbackId withOptions:pictureOptions]; - }); - } - }]; + // For photo library on iOS 14+, PHPickerViewController doesn't require permissions + // Only request permissions if we're on iOS < 14 or need UIImagePickerController + BOOL needsPermissionCheck = YES; + if (@available(iOS 14, *)) { + needsPermissionCheck = NO; // PHPickerViewController will be used, no permission needed + } + + if (needsPermissionCheck) { + [weakSelf options:pictureOptions requestPhotoPermissions:^(BOOL granted) { + if (!granted) { + // Denied; show an alert + dispatch_async(dispatch_get_main_queue(), ^{ + 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]; + [alertController addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK", nil) style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { + [weakSelf sendNoPermissionResult:command.callbackId]; + }]]; + [alertController addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Settings", nil) style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { + [[UIApplication sharedApplication] openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString]]; + [weakSelf sendNoPermissionResult:command.callbackId]; + }]]; + [weakSelf.viewController presentViewController:alertController animated:YES completion:nil]; + }); + } else { + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf showCameraPicker:command.callbackId withOptions:pictureOptions]; + }); + } + }]; + } else { + // iOS 14+ with PHPickerViewController - no permission needed, show picker directly + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf showCameraPicker:command.callbackId withOptions:pictureOptions]; + }); + } } }]; } @@ -212,6 +230,15 @@ static NSString* MIME_JPEG = @"image/jpeg"; { // Perform UI operations on the main thread dispatch_async(dispatch_get_main_queue(), ^{ + // Use PHPickerViewController for photo library on iOS 14+ +#if __has_include(<PhotosUI/PhotosUI.h>) + if (pictureOptions.sourceType == UIImagePickerControllerSourceTypePhotoLibrary) { + [self showPHPicker:callbackId withOptions:pictureOptions]; + return; + } +#endif + + // Fallback to UIImagePickerController for camera or older iOS versions CDVCameraPicker* cameraPicker = [CDVCameraPicker createFromPictureOptions:pictureOptions]; self.pickerController = cameraPicker; @@ -242,6 +269,40 @@ static NSString* MIME_JPEG = @"image/jpeg"; }); } +// Since iOS 14, we can use PHPickerViewController to select images from the photo library +#if __has_include(<PhotosUI/PhotosUI.h>) +- (void)showPHPicker:(NSString*)callbackId withOptions:(CDVPictureOptions*)pictureOptions API_AVAILABLE(ios(14)) +{ + PHPickerConfiguration *config = [[PHPickerConfiguration alloc] init]; + + // Configure filter based on media type + if (pictureOptions.mediaType == MediaTypeVideo) { + config.filter = [PHPickerFilter videosFilter]; + } else if (pictureOptions.mediaType == MediaTypeAll) { + config.filter = [PHPickerFilter anyFilterMatchingSubfilters:@[ + [PHPickerFilter imagesFilter], + [PHPickerFilter videosFilter] + ]]; + } else { + config.filter = [PHPickerFilter imagesFilter]; + } + + config.selectionLimit = 1; + config.preferredAssetRepresentationMode = PHPickerConfigurationAssetRepresentationModeCurrent; + + PHPickerViewController *picker = [[PHPickerViewController alloc] initWithConfiguration:config]; + picker.delegate = self; + + // Store callback ID and options + objc_setAssociatedObject(picker, "callbackId", callbackId, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + objc_setAssociatedObject(picker, "pictureOptions", pictureOptions, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + + [self.viewController presentViewController:picker animated:YES completion:^{ + self.hasPendingOperation = NO; + }]; +} +#endif + - (void)sendNoPermissionResult:(NSString*)callbackId { CDVPluginResult* result = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsString:@"has no access to camera"]; // error callback expects string ATM @@ -898,6 +959,154 @@ static NSString* MIME_JPEG = @"image/jpeg"; } } +#if __has_include(<PhotosUI/PhotosUI.h>) +// PHPickerViewController Delegate Methods (iOS 14+) +- (void)picker:(PHPickerViewController *)picker didFinishPicking:(NSArray<PHPickerResult *> *)results API_AVAILABLE(ios(14)) +{ + NSString *callbackId = objc_getAssociatedObject(picker, "callbackId"); + CDVPictureOptions *pictureOptions = objc_getAssociatedObject(picker, "pictureOptions"); + + __weak CDVCamera* weakSelf = self; + + [picker dismissViewControllerAnimated:YES completion:^{ + if (results.count == 0) { + // User cancelled + CDVPluginResult* result = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsString:@"No Image Selected"]; + [weakSelf.commandDelegate sendPluginResult:result callbackId:callbackId]; + weakSelf.hasPendingOperation = NO; + return; + } + + PHPickerResult *pickerResult = results.firstObject; + + // Check if it's a video + if ([pickerResult.itemProvider hasItemConformingToTypeIdentifier:UTTypeMovie.identifier]) { + [pickerResult.itemProvider loadFileRepresentationForTypeIdentifier:UTTypeMovie.identifier completionHandler:^(NSURL * _Nullable url, NSError * _Nullable error) { + if (error) { + CDVPluginResult* result = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsString:[error localizedDescription]]; + [weakSelf.commandDelegate sendPluginResult:result callbackId:callbackId]; + weakSelf.hasPendingOperation = NO; + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + NSString* videoPath = [weakSelf createTmpVideo:[url path]]; + CDVPluginResult* result = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsString:videoPath]; + [weakSelf.commandDelegate sendPluginResult:result callbackId:callbackId]; + weakSelf.hasPendingOperation = NO; + }); + }]; + } + // Handle image + else if ([pickerResult.itemProvider canLoadObjectOfClass:[UIImage class]]) { + [pickerResult.itemProvider loadObjectOfClass:[UIImage class] completionHandler:^(__kindof id<NSItemProviderReading> _Nullable object, NSError * _Nullable error) { + if (error) { + CDVPluginResult* result = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsString:[error localizedDescription]]; + [weakSelf.commandDelegate sendPluginResult:result callbackId:callbackId]; + weakSelf.hasPendingOperation = NO; + return; + } + + UIImage *image = (UIImage *)object; + + // Get asset identifier to fetch metadata + NSString *assetIdentifier = pickerResult.assetIdentifier; + + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf processPHPickerImage:image assetIdentifier:assetIdentifier callbackId:callbackId options:pictureOptions]; + }); + }]; + } + }]; +} + +- (void)processPHPickerImage:(UIImage*)image assetIdentifier:(NSString*)assetIdentifier callbackId:(NSString*)callbackId options:(CDVPictureOptions*)options API_AVAILABLE(ios(14)) +{ + __weak CDVCamera* weakSelf = self; + + // Fetch metadata if asset identifier is available + if (assetIdentifier) { + PHFetchResult *result = [PHAsset fetchAssetsWithLocalIdentifiers:@[assetIdentifier] options:nil]; + PHAsset *asset = result.firstObject; + + if (asset) { + PHImageRequestOptions *imageOptions = [[PHImageRequestOptions alloc] init]; + imageOptions.synchronous = YES; + imageOptions.networkAccessAllowed = YES; + + [[PHImageManager defaultManager] requestImageDataForAsset:asset options:imageOptions resultHandler:^(NSData * _Nullable imageData, NSString * _Nullable dataUTI, UIImageOrientation orientation, NSDictionary * _Nullable info) { + NSDictionary *metadata = nil; + if (imageData) { + metadata = [weakSelf convertImageMetadata:imageData]; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf finalizePHPickerImage:image metadata:metadata callbackId:callbackId options:options]; + }); + }]; + return; + } + } + + // No metadata available + [self finalizePHPickerImage:image metadata:nil callbackId:callbackId options:options]; +} + +- (void)finalizePHPickerImage:(UIImage*)image metadata:(NSDictionary*)metadata callbackId:(NSString*)callbackId options:(CDVPictureOptions*)options API_AVAILABLE(ios(14)) +{ + // Process image according to options + UIImage *processedImage = image; + + if (options.correctOrientation) { + processedImage = [processedImage imageCorrectedForCaptureOrientation]; + } + + if ((options.targetSize.width > 0) && (options.targetSize.height > 0)) { + if (options.cropToSize) { + processedImage = [processedImage imageByScalingAndCroppingForSize:options.targetSize]; + } else { + processedImage = [processedImage imageByScalingNotCroppingForSize:options.targetSize]; + } + } + + // Create info dictionary similar to UIImagePickerController + NSMutableDictionary *info = [NSMutableDictionary dictionary]; + [info setObject:processedImage forKey:UIImagePickerControllerOriginalImage]; + if (metadata) { + [info setObject:metadata forKey:@"UIImagePickerControllerMediaMetadata"]; + } + + // Store metadata for processing + if (metadata) { + self.metadata = [[NSMutableDictionary alloc] init]; + + NSMutableDictionary* EXIFDictionary = [[metadata objectForKey:(NSString*)kCGImagePropertyExifDictionary] mutableCopy]; + if (EXIFDictionary) { + [self.metadata setObject:EXIFDictionary forKey:(NSString*)kCGImagePropertyExifDictionary]; + } + + NSMutableDictionary* TIFFDictionary = [[metadata objectForKey:(NSString*)kCGImagePropertyTIFFDictionary] mutableCopy]; + if (TIFFDictionary) { + [self.metadata setObject:TIFFDictionary forKey:(NSString*)kCGImagePropertyTIFFDictionary]; + } + + NSMutableDictionary* GPSDictionary = [[metadata objectForKey:(NSString*)kCGImagePropertyGPSDictionary] mutableCopy]; + if (GPSDictionary) { + [self.metadata setObject:GPSDictionary forKey:(NSString*)kCGImagePropertyGPSDictionary]; + } + } + + __weak CDVCamera* weakSelf = self; + + // Process and return result + [self resultForImage:options info:info completion:^(CDVPluginResult* res) { + [weakSelf.commandDelegate sendPluginResult:res callbackId:callbackId]; + weakSelf.hasPendingOperation = NO; + weakSelf.pickerController = nil; + }]; +} +#endif + @end @implementation CDVCameraPicker diff --git a/tests/ios/CDVCameraTest/CDVCameraLibTests/CameraTest.m b/tests/ios/CDVCameraTest/CDVCameraLibTests/CameraTest.m index d74cdba..bf76a86 100644 --- a/tests/ios/CDVCameraTest/CDVCameraLibTests/CameraTest.m +++ b/tests/ios/CDVCameraTest/CDVCameraLibTests/CameraTest.m @@ -22,6 +22,7 @@ #import "CDVCamera.h" #import "UIImage+CropScaleOrientation.h" #import <MobileCoreServices/UTCoreTypes.h> +#import <PhotosUI/PhotosUI.h> @interface CameraTest : XCTestCase @@ -37,6 +38,10 @@ - (UIImage*)retrieveImage:(NSDictionary*)info options:(CDVPictureOptions*)options; - (CDVPluginResult*)resultForImage:(CDVPictureOptions*)options info:(NSDictionary*)info; - (CDVPluginResult*)resultForVideo:(NSDictionary*)info; +- (void)showPHPicker:(NSString*)callbackId withOptions:(CDVPictureOptions*)pictureOptions API_AVAILABLE(ios(14)); +- (void)processPHPickerImage:(UIImage*)image assetIdentifier:(NSString*)assetIdentifier callbackId:(NSString*)callbackId options:(CDVPictureOptions*)options API_AVAILABLE(ios(14)); +- (void)finalizePHPickerImage:(UIImage*)image metadata:(NSDictionary*)metadata callbackId:(NSString*)callbackId options:(CDVPictureOptions*)options API_AVAILABLE(ios(14)); +- (NSDictionary*)convertImageMetadata:(NSData*)imageData; @end @@ -125,7 +130,7 @@ CDVPictureOptions* pictureOptions; CDVCameraPicker* picker; - // Souce is Camera, and image type + // Source is Camera, and image type - Camera always uses UIImagePickerController popoverOptions = @{ @"x" : @1, @"y" : @2, @"width" : @3, @"height" : @4, @"popoverWidth": @200, @"popoverHeight": @300 }; args = @[ @@ -157,7 +162,7 @@ XCTAssertEqual(picker.cameraDevice, pictureOptions.cameraDirection); } - // Souce is not Camera, and all media types + // Source is Photo Library, and all media types - On iOS 14+ uses PHPicker, below uses UIImagePickerController args = @[ @(49), @@ -187,7 +192,7 @@ XCTAssertEqualObjects(picker.mediaTypes, [UIImagePickerController availableMediaTypesForSourceType:picker.sourceType]); } - // Souce is not Camera, and either Image or Movie media type + // Source is Photo Library, and either Image or Movie media type - On iOS 14+ uses PHPicker args = @[ @(49), @@ -508,4 +513,128 @@ // TODO: usesGeolocation is not tested } +- (void) testPHPickerAvailability API_AVAILABLE(ios(14)) +{ + if (@available(iOS 14, *)) { + // Test that PHPickerViewController is available on iOS 14+ + XCTAssertNotNil([PHPickerViewController class]); + + PHPickerConfiguration *config = [[PHPickerConfiguration alloc] init]; + XCTAssertNotNil(config); + + config.filter = [PHPickerFilter imagesFilter]; + XCTAssertNotNil(config.filter); + + PHPickerViewController *picker = [[PHPickerViewController alloc] initWithConfiguration:config]; + XCTAssertNotNil(picker); + } +} + +- (void) testPHPickerConfiguration API_AVAILABLE(ios(14)) +{ + if (@available(iOS 14, *)) { + // Test image filter configuration + PHPickerConfiguration *imageConfig = [[PHPickerConfiguration alloc] init]; + imageConfig.filter = [PHPickerFilter imagesFilter]; + imageConfig.selectionLimit = 1; + + XCTAssertNotNil(imageConfig); + XCTAssertEqual(imageConfig.selectionLimit, 1); + + // Test video filter configuration + PHPickerConfiguration *videoConfig = [[PHPickerConfiguration alloc] init]; + videoConfig.filter = [PHPickerFilter videosFilter]; + + XCTAssertNotNil(videoConfig.filter); + + // Test all media types configuration + PHPickerConfiguration *allConfig = [[PHPickerConfiguration alloc] init]; + allConfig.filter = [PHPickerFilter anyFilterMatchingSubfilters:@[ + [PHPickerFilter imagesFilter], + [PHPickerFilter videosFilter] + ]]; + + XCTAssertNotNil(allConfig.filter); + } +} + +- (void) testConvertImageMetadata +{ + // Create a test image + UIImage* testImage = [self createImage:CGRectMake(0, 0, 100, 100) orientation:UIImageOrientationUp]; + NSData* imageData = UIImageJPEGRepresentation(testImage, 1.0); + + XCTAssertNotNil(imageData); + + // Test metadata conversion + NSDictionary* metadata = [self.plugin convertImageMetadata:imageData]; + + // Metadata may be nil for generated images, but the method should not crash + // Real camera images would have EXIF data + XCTAssertTrue(metadata == nil || [metadata isKindOfClass:[NSDictionary class]]); +} + +- (void) testPHPickerDelegateConformance API_AVAILABLE(ios(14)) +{ + if (@available(iOS 14, *)) { + // Test that CDVCamera conforms to PHPickerViewControllerDelegate + XCTAssertTrue([self.plugin conformsToProtocol:@protocol(PHPickerViewControllerDelegate)]); + + // Test that the delegate method is implemented + SEL delegateSelector = @selector(picker:didFinishPicking:); + XCTAssertTrue([self.plugin respondsToSelector:delegateSelector]); + } +} + +- (void) testShowPHPickerMethod API_AVAILABLE(ios(14)) +{ + if (@available(iOS 14, *)) { + // Test that showPHPicker method exists + SEL showPHPickerSelector = @selector(showPHPicker:withOptions:); + XCTAssertTrue([self.plugin respondsToSelector:showPHPickerSelector]); + + // Test that processPHPickerImage method exists + SEL processSelector = @selector(processPHPickerImage:assetIdentifier:callbackId:options:); + XCTAssertTrue([self.plugin respondsToSelector:processSelector]); + + // Test that finalizePHPickerImage method exists + SEL finalizeSelector = @selector(finalizePHPickerImage:metadata:callbackId:options:); + XCTAssertTrue([self.plugin respondsToSelector:finalizeSelector]); + } +} + +- (void) testPictureOptionsForPHPicker +{ + NSArray* args; + CDVPictureOptions* options; + + // Test options configuration for photo library (which would use PHPicker on iOS 14+) + args = @[ + @(75), + @(DestinationTypeFileUri), + @(UIImagePickerControllerSourceTypePhotoLibrary), + @(800), + @(600), + @(EncodingTypeJPEG), + @(MediaTypePicture), + @NO, + @YES, + @NO, + [NSNull null], + @(UIImagePickerControllerCameraDeviceRear), + ]; + + CDVInvokedUrlCommand* command = [[CDVInvokedUrlCommand alloc] initWithArguments:args callbackId:@"dummy" className:@"myclassname" methodName:@"mymethodname"]; + options = [CDVPictureOptions createFromTakePictureArguments:command]; + + // Verify options are correctly set for photo library source + XCTAssertEqual(options.sourceType, (int)UIImagePickerControllerSourceTypePhotoLibrary); + XCTAssertEqual([options.quality intValue], 75); + XCTAssertEqual(options.destinationType, (int)DestinationTypeFileUri); + XCTAssertEqual(options.targetSize.width, 800); + XCTAssertEqual(options.targetSize.height, 600); + XCTAssertEqual(options.correctOrientation, YES); + XCTAssertEqual(options.mediaType, (int)MediaTypePicture); +} + @end --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
