@connectedbits/react-native-image-picker 2.4.16-rc3 → 2.4.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,27 @@
1
1
  #import "ImagePickerManager.h"
2
2
  #import <React/RCTConvert.h>
3
- #import <AssetsLibrary/AssetsLibrary.h>
3
+ #import <React/RCTUtils.h>
4
4
  #import <AVFoundation/AVFoundation.h>
5
+ #import <CoreLocation/CoreLocation.h>
6
+ #import <ImageIO/ImageIO.h>
5
7
  #import <Photos/Photos.h>
6
- #import <React/RCTUtils.h>
8
+ #import <PhotosUI/PhotosUI.h>
9
+ #import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
7
10
 
8
- @import MobileCoreServices;
11
+ // NOTE: This module deliberately does not use AssetsLibrary (ALAssetsLibrary). It was
12
+ // deprecated in iOS 9 and, as of iOS 27.2, crashes on first use
13
+ // (-[PLManagedObjectContext setIsBackingALAssetsLibrary:]: unrecognized selector).
14
+ //
15
+ // - Camera capture uses UIImagePickerController (still the supported system camera UI).
16
+ // - Library selection uses PHPickerViewController, which needs no photo library permission.
17
+ // Location is read from the picked file's EXIF first, then from the PHAsset when the app
18
+ // already has (full or limited) photo library access.
9
19
 
10
- @interface ImagePickerManager ()
20
+ @interface ImagePickerManager () <PHPickerViewControllerDelegate>
11
21
 
12
22
  @property (nonatomic, strong) UIImagePickerController *picker;
13
23
  @property (nonatomic, strong) RCTResponseSenderBlock callback;
14
- @property (nonatomic, strong) NSDictionary *defaultOptions;
15
- @property (nonatomic, retain) NSDictionary *options;
16
- @property (nonatomic, retain) NSMutableDictionary *response;
24
+ @property (nonatomic, strong) NSDictionary *options;
17
25
  @property (nonatomic, strong) NSArray *customButtons;
18
26
 
19
27
  @end
@@ -22,6 +30,11 @@
22
30
 
23
31
  RCT_EXPORT_MODULE();
24
32
 
33
+ + (BOOL)requiresMainQueueSetup
34
+ {
35
+ return NO;
36
+ }
37
+
25
38
  RCT_EXPORT_METHOD(launchCamera:(NSDictionary *)options callback:(RCTResponseSenderBlock)callback)
26
39
  {
27
40
  self.callback = callback;
@@ -45,29 +58,29 @@ RCT_EXPORT_METHOD(showImagePicker:(NSDictionary *)options callback:(RCTResponseS
45
58
 
46
59
  dispatch_async(dispatch_get_main_queue(), ^{
47
60
 
48
- NSString *title = [self.options valueForKey:@"title"];
49
- if ([title isEqual:[NSNull null]] || title.length == 0) {
61
+ NSString *title = [self stringOption:@"title"];
62
+ if (title.length == 0) {
50
63
  title = nil; // A more visually appealing UIAlertControl is displayed with a nil title rather than title = @""
51
64
  }
52
- NSString *cancelTitle = [self.options valueForKey:@"cancelButtonTitle"];
53
- NSString *takePhotoButtonTitle = [self.options valueForKey:@"takePhotoButtonTitle"];
54
- NSString *chooseFromLibraryButtonTitle = [self.options valueForKey:@"chooseFromLibraryButtonTitle"];
65
+ NSString *cancelTitle = [self stringOption:@"cancelButtonTitle"];
66
+ NSString *takePhotoButtonTitle = [self stringOption:@"takePhotoButtonTitle"];
67
+ NSString *chooseFromLibraryButtonTitle = [self stringOption:@"chooseFromLibraryButtonTitle"];
55
68
 
56
69
  UIAlertController *alertController = [UIAlertController alertControllerWithTitle:title message:nil preferredStyle:UIAlertControllerStyleActionSheet];
57
70
  alertController.view.tintColor = [RCTConvert UIColor:options[@"tintColor"]];
58
71
 
59
72
  UIAlertAction *cancelAction = [UIAlertAction actionWithTitle:cancelTitle style:UIAlertActionStyleCancel handler:^(UIAlertAction * action) {
60
- self.callback(@[@{@"didCancel": @YES}]); // Return callback for 'cancel' action (if is required)
73
+ [self sendResponse:@{@"didCancel": @YES}];
61
74
  }];
62
75
  [alertController addAction:cancelAction];
63
76
 
64
- if (![takePhotoButtonTitle isEqual:[NSNull null]] && takePhotoButtonTitle.length > 0) {
77
+ if (takePhotoButtonTitle.length > 0) {
65
78
  UIAlertAction *takePhotoAction = [UIAlertAction actionWithTitle:takePhotoButtonTitle style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) {
66
79
  [self actionHandler:action];
67
80
  }];
68
81
  [alertController addAction:takePhotoAction];
69
82
  }
70
- if (![chooseFromLibraryButtonTitle isEqual:[NSNull null]] && chooseFromLibraryButtonTitle.length > 0) {
83
+ if (chooseFromLibraryButtonTitle.length > 0) {
71
84
  UIAlertAction *chooseFromLibraryAction = [UIAlertAction actionWithTitle:chooseFromLibraryButtonTitle style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) {
72
85
  [self actionHandler:action];
73
86
  }];
@@ -75,11 +88,11 @@ RCT_EXPORT_METHOD(showImagePicker:(NSDictionary *)options callback:(RCTResponseS
75
88
  }
76
89
 
77
90
  // Add custom buttons to action sheet
78
- if ([self.options objectForKey:@"customButtons"] && [[self.options objectForKey:@"customButtons"] isKindOfClass:[NSArray class]]) {
91
+ if ([[self.options objectForKey:@"customButtons"] isKindOfClass:[NSArray class]]) {
79
92
  self.customButtons = [self.options objectForKey:@"customButtons"];
80
- for (NSString *button in self.customButtons) {
81
- NSString *title = [button valueForKey:@"title"];
82
- UIAlertAction *customAction = [UIAlertAction actionWithTitle:title style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) {
93
+ for (NSDictionary *button in self.customButtons) {
94
+ NSString *buttonTitle = [button valueForKey:@"title"];
95
+ UIAlertAction *customAction = [UIAlertAction actionWithTitle:buttonTitle style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) {
83
96
  [self actionHandler:action];
84
97
  }];
85
98
  [alertController addAction:customAction];
@@ -94,7 +107,7 @@ RCT_EXPORT_METHOD(showImagePicker:(NSDictionary *)options callback:(RCTResponseS
94
107
  alertController.popoverPresentationController.sourceView = root.view;
95
108
  alertController.popoverPresentationController.sourceRect = CGRectMake(root.view.bounds.size.width / 2.0, root.view.bounds.size.height, 1.0, 1.0);
96
109
 
97
- if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) {
110
+ if (UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad) {
98
111
  alertController.popoverPresentationController.permittedArrowDirections = 0;
99
112
  for (id subview in alertController.view.subviews) {
100
113
  if ([subview isMemberOfClass:[UIView class]]) {
@@ -115,19 +128,21 @@ RCT_EXPORT_METHOD(showImagePicker:(NSDictionary *)options callback:(RCTResponseS
115
128
  if (results.count > 0) {
116
129
  NSString *customButtonStr = [[results objectAtIndex:0] objectForKey:@"name"];
117
130
  if (customButtonStr) {
118
- self.callback(@[@{@"customButton": customButtonStr}]);
131
+ [self sendResponse:@{@"customButton": customButtonStr}];
119
132
  return;
120
133
  }
121
134
  }
122
135
 
123
- if ([action.title isEqualToString:[self.options valueForKey:@"takePhotoButtonTitle"]]) { // Take photo
136
+ if ([action.title isEqualToString:[self stringOption:@"takePhotoButtonTitle"]]) { // Take photo
124
137
  [self launchImagePicker:RNImagePickerTargetCamera];
125
138
  }
126
- else if ([action.title isEqualToString:[self.options valueForKey:@"chooseFromLibraryButtonTitle"]]) { // Choose from library
139
+ else if ([action.title isEqualToString:[self stringOption:@"chooseFromLibraryButtonTitle"]]) { // Choose from library
127
140
  [self launchImagePicker:RNImagePickerTargetLibrarySingleImage];
128
141
  }
129
142
  }
130
143
 
144
+ #pragma mark - Launching
145
+
131
146
  - (void)launchImagePicker:(RNImagePickerTarget)target options:(NSDictionary *)options
132
147
  {
133
148
  self.options = options;
@@ -136,508 +151,596 @@ RCT_EXPORT_METHOD(showImagePicker:(NSDictionary *)options callback:(RCTResponseS
136
151
 
137
152
  - (void)launchImagePicker:(RNImagePickerTarget)target
138
153
  {
139
- self.picker = [[UIImagePickerController alloc] init];
140
-
141
154
  if (target == RNImagePickerTargetCamera) {
142
- #if TARGET_IPHONE_SIMULATOR
143
- self.callback(@[@{@"error": @"Camera not available on simulator"}]);
144
- return;
145
- #else
146
- self.picker.sourceType = UIImagePickerControllerSourceTypeCamera;
147
- if ([[self.options objectForKey:@"cameraType"] isEqualToString:@"front"]) {
148
- self.picker.cameraDevice = UIImagePickerControllerCameraDeviceFront;
149
- }
150
- else { // "back"
151
- self.picker.cameraDevice = UIImagePickerControllerCameraDeviceRear;
152
- }
153
- #endif
155
+ [self launchCameraPicker];
156
+ } else {
157
+ [self launchLibraryPicker];
154
158
  }
155
- else { // RNImagePickerTargetLibrarySingleImage
156
- self.picker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary;
159
+ }
160
+
161
+ - (void)launchCameraPicker
162
+ {
163
+ #if TARGET_OS_SIMULATOR
164
+ [self sendResponse:@{@"error": @"Camera not available on simulator"}];
165
+ #else
166
+ if (![UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) {
167
+ [self sendResponse:@{@"error": @"Camera not available"}];
168
+ return;
157
169
  }
158
170
 
159
- if ([[self.options objectForKey:@"mediaType"] isEqualToString:@"video"]
160
- || [[self.options objectForKey:@"mediaType"] isEqualToString:@"mixed"]) {
171
+ UIImagePickerController *picker = [[UIImagePickerController alloc] init];
172
+ picker.sourceType = UIImagePickerControllerSourceTypeCamera;
173
+ if ([[self stringOption:@"cameraType"] isEqualToString:@"front"]) {
174
+ picker.cameraDevice = UIImagePickerControllerCameraDeviceFront;
175
+ } else {
176
+ picker.cameraDevice = UIImagePickerControllerCameraDeviceRear;
177
+ }
161
178
 
162
- if ([[self.options objectForKey:@"videoQuality"] isEqualToString:@"high"]) {
163
- self.picker.videoQuality = UIImagePickerControllerQualityTypeHigh;
164
- }
165
- else if ([[self.options objectForKey:@"videoQuality"] isEqualToString:@"low"]) {
166
- self.picker.videoQuality = UIImagePickerControllerQualityTypeLow;
167
- }
168
- else {
169
- self.picker.videoQuality = UIImagePickerControllerQualityTypeMedium;
179
+ NSString *mediaType = [self stringOption:@"mediaType"];
180
+ if ([mediaType isEqualToString:@"video"] || [mediaType isEqualToString:@"mixed"]) {
181
+ NSString *videoQuality = [self stringOption:@"videoQuality"];
182
+ if ([videoQuality isEqualToString:@"high"]) {
183
+ picker.videoQuality = UIImagePickerControllerQualityTypeHigh;
184
+ } else if ([videoQuality isEqualToString:@"low"]) {
185
+ picker.videoQuality = UIImagePickerControllerQualityTypeLow;
186
+ } else {
187
+ picker.videoQuality = UIImagePickerControllerQualityTypeMedium;
170
188
  }
171
189
 
172
190
  id durationLimit = [self.options objectForKey:@"durationLimit"];
173
- if (durationLimit) {
174
- self.picker.videoMaximumDuration = [durationLimit doubleValue];
175
- self.picker.allowsEditing = NO;
191
+ if ([durationLimit isKindOfClass:[NSNumber class]]) {
192
+ picker.videoMaximumDuration = [durationLimit doubleValue];
176
193
  }
177
194
  }
178
- if ([[self.options objectForKey:@"mediaType"] isEqualToString:@"video"]) {
179
- self.picker.mediaTypes = @[(NSString *)kUTTypeMovie];
180
- } else if ([[self.options objectForKey:@"mediaType"] isEqualToString:@"mixed"]) {
181
- self.picker.mediaTypes = @[(NSString *)kUTTypeMovie, (NSString *)kUTTypeImage];
195
+ if ([mediaType isEqualToString:@"video"]) {
196
+ picker.mediaTypes = @[UTTypeMovie.identifier];
197
+ } else if ([mediaType isEqualToString:@"mixed"]) {
198
+ picker.mediaTypes = @[UTTypeMovie.identifier, UTTypeImage.identifier];
182
199
  } else {
183
- self.picker.mediaTypes = @[(NSString *)kUTTypeImage];
200
+ picker.mediaTypes = @[UTTypeImage.identifier];
184
201
  }
185
202
 
186
- if ([[self.options objectForKey:@"allowsEditing"] boolValue]) {
187
- self.picker.allowsEditing = true;
188
- }
189
- self.picker.modalPresentationStyle = UIModalPresentationCurrentContext;
190
- self.picker.delegate = self;
203
+ picker.allowsEditing = [[self.options objectForKey:@"allowsEditing"] boolValue];
204
+ picker.modalPresentationStyle = UIModalPresentationFullScreen;
205
+ picker.delegate = self;
206
+ self.picker = picker;
191
207
 
192
- // Check permissions
193
- void (^showPickerViewController)() = ^void() {
208
+ [self checkCameraPermissions:^(BOOL granted) {
209
+ if (!granted) {
210
+ [self sendResponse:@{@"error": @"Camera permissions not granted"}];
211
+ return;
212
+ }
194
213
  dispatch_async(dispatch_get_main_queue(), ^{
195
- UIViewController *root = RCTPresentedViewController();
196
- [root presentViewController:self.picker animated:YES completion:nil];
214
+ [RCTPresentedViewController() presentViewController:picker animated:YES completion:nil];
197
215
  });
198
- };
199
-
200
- if (target == RNImagePickerTargetCamera) {
201
- [self checkCameraPermissions:^(BOOL granted) {
202
- if (!granted) {
203
- self.callback(@[@{@"error": @"Camera permissions not granted"}]);
204
- return;
205
- }
216
+ }];
217
+ #endif
218
+ }
206
219
 
207
- showPickerViewController();
208
- }];
220
+ - (void)launchLibraryPicker
221
+ {
222
+ // Passing the shared library gives us assetIdentifiers on results, which lets us fall back to
223
+ // PHAsset metadata (location, creation date, original filename) when the app has library access.
224
+ // PHPicker itself needs no permission, so we never block selection on authorization.
225
+ PHPickerConfiguration *configuration = [[PHPickerConfiguration alloc] initWithPhotoLibrary:[PHPhotoLibrary sharedPhotoLibrary]];
226
+ configuration.selectionLimit = 1;
227
+ // Hand back the asset as stored (no transcoding) so EXIF/GPS metadata and GIF frames are preserved.
228
+ configuration.preferredAssetRepresentationMode = PHPickerConfigurationAssetRepresentationModeCurrent;
229
+
230
+ NSString *mediaType = [self stringOption:@"mediaType"];
231
+ if ([mediaType isEqualToString:@"video"]) {
232
+ configuration.filter = [PHPickerFilter videosFilter];
233
+ } else if ([mediaType isEqualToString:@"mixed"]) {
234
+ configuration.filter = [PHPickerFilter anyFilterMatchingSubfilters:@[[PHPickerFilter imagesFilter], [PHPickerFilter videosFilter]]];
235
+ } else {
236
+ configuration.filter = [PHPickerFilter imagesFilter];
209
237
  }
210
- else { // RNImagePickerTargetLibrarySingleImage
211
- [self checkPhotosPermissions:^(BOOL granted) {
212
- if (!granted) {
213
- self.callback(@[@{@"error": @"Photo library permissions not granted"}]);
214
- return;
215
- }
216
238
 
217
- showPickerViewController();
218
- }];
219
- }
220
- }
239
+ PHPickerViewController *pickerViewController = [[PHPickerViewController alloc] initWithConfiguration:configuration];
240
+ pickerViewController.delegate = self;
221
241
 
222
- - (NSString * _Nullable)originalFilenameForAsset:(PHAsset * _Nullable)asset assetType:(PHAssetResourceType)type {
223
- if (!asset) { return nil; }
242
+ dispatch_async(dispatch_get_main_queue(), ^{
243
+ [RCTPresentedViewController() presentViewController:pickerViewController animated:YES completion:nil];
244
+ });
245
+ }
224
246
 
225
- PHAssetResource *originalResource;
226
- // Get the underlying resources for the PHAsset (PhotoKit)
227
- NSArray<PHAssetResource *> *pickedAssetResources = [PHAssetResource assetResourcesForAsset:asset];
247
+ #pragma mark - PHPickerViewControllerDelegate (library)
228
248
 
229
- // Find the original resource (underlying image) for the asset, which has the desired filename
230
- for (PHAssetResource *resource in pickedAssetResources) {
231
- if (resource.type == type) {
232
- originalResource = resource;
249
+ - (void)picker:(PHPickerViewController *)picker didFinishPicking:(NSArray<PHPickerResult *> *)results
250
+ {
251
+ NSDictionary *options = self.options;
252
+ [picker dismissViewControllerAnimated:YES completion:^{
253
+ PHPickerResult *result = results.firstObject;
254
+ if (!result) {
255
+ [self sendResponse:@{@"didCancel": @YES}];
256
+ return;
233
257
  }
258
+ [self handlePickerResult:result options:options];
259
+ }];
260
+ }
261
+
262
+ - (void)handlePickerResult:(PHPickerResult *)result options:(NSDictionary *)options
263
+ {
264
+ NSItemProvider *provider = result.itemProvider;
265
+ NSString *mediaType = [ImagePickerManager stringValue:options[@"mediaType"]];
266
+
267
+ BOOL isImage = [provider hasItemConformingToTypeIdentifier:UTTypeImage.identifier];
268
+ BOOL isMovie = [provider hasItemConformingToTypeIdentifier:UTTypeMovie.identifier];
269
+ BOOL treatAsVideo = isMovie && (!isImage || [mediaType isEqualToString:@"video"]);
270
+
271
+ if (!isImage && !isMovie) {
272
+ [self sendResponse:@{@"error": @"Unsupported media type"}];
273
+ return;
234
274
  }
235
275
 
236
- return originalResource.originalFilename;
276
+ if (treatAsVideo) {
277
+ [provider loadFileRepresentationForTypeIdentifier:UTTypeMovie.identifier completionHandler:^(NSURL * _Nullable url, NSError * _Nullable error) {
278
+ // The file at `url` is deleted once this handler returns, so copy it out synchronously.
279
+ [self handleLibraryVideoAtURL:url error:error assetIdentifier:result.assetIdentifier suggestedName:provider.suggestedName options:options];
280
+ }];
281
+ return;
282
+ }
283
+
284
+ BOOL isGIF = [provider hasItemConformingToTypeIdentifier:UTTypeGIF.identifier];
285
+ NSString *typeIdentifier = isGIF ? UTTypeGIF.identifier : UTTypeImage.identifier;
286
+ [provider loadFileRepresentationForTypeIdentifier:typeIdentifier completionHandler:^(NSURL * _Nullable url, NSError * _Nullable error) {
287
+ NSData *data = url ? [NSData dataWithContentsOfURL:url] : nil;
288
+ if (!data) {
289
+ [self sendResponse:@{@"error": error.localizedDescription ?: @"Could not read photo"}];
290
+ return;
291
+ }
292
+ NSString *fileName = [self fileNameFromSuggestedName:provider.suggestedName extension:url.pathExtension];
293
+ [self handleLibraryImageData:data isGIF:isGIF assetIdentifier:result.assetIdentifier fileName:fileName options:options];
294
+ }];
237
295
  }
238
296
 
239
- - (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary<NSString *,id> *)info
297
+ - (void)handleLibraryImageData:(NSData *)data
298
+ isGIF:(BOOL)isGIF
299
+ assetIdentifier:(NSString *)assetIdentifier
300
+ fileName:(NSString *)fileName
301
+ options:(NSDictionary *)options
240
302
  {
241
- dispatch_block_t dismissCompletionBlock = ^{
303
+ UIImage *image = [UIImage imageWithData:data];
304
+ if (!image) {
305
+ [self sendResponse:@{@"error": @"Could not read photo"}];
306
+ return;
307
+ }
242
308
 
243
- NSURL *imageURL = [info valueForKey:UIImagePickerControllerReferenceURL];
244
- NSString *mediaType = [info objectForKey:UIImagePickerControllerMediaType];
309
+ NSDictionary *properties = [ImagePickerManager imagePropertiesFromData:data];
310
+ PHAsset *asset = [ImagePickerManager fetchAssetWithIdentifier:assetIdentifier];
245
311
 
246
- NSString *fileName;
247
- if ([mediaType isEqualToString:(NSString *)kUTTypeImage]) {
248
- NSString *tempFileName = [[NSUUID UUID] UUIDString];
249
- if (imageURL && [[imageURL absoluteString] rangeOfString:@"ext=GIF"].location != NSNotFound) {
250
- fileName = [tempFileName stringByAppendingString:@".gif"];
251
- }
252
- else if ([[self.options objectForKey:@"imageFileType"] isEqualToString:@"png"]) {
253
- fileName = [tempFileName stringByAppendingString:@".png"];
254
- }
255
- else {
256
- fileName = [tempFileName stringByAppendingString:@".jpg"];
257
- }
258
- }
259
- else {
260
- NSURL *videoURL = info[UIImagePickerControllerMediaURL];
261
- fileName = videoURL.lastPathComponent;
262
- }
312
+ NSString *error = nil;
313
+ NSMutableDictionary *response;
314
+ if (isGIF) {
315
+ // GIFs break when resized, so pass the original bytes through untouched.
316
+ response = [self writeGIFData:data image:image options:options error:&error];
317
+ } else {
318
+ response = [self writeImage:image options:options error:&error];
319
+ }
320
+ if (!response) {
321
+ [self sendResponse:@{@"error": error ?: @"Could not save photo"}];
322
+ return;
323
+ }
263
324
 
264
- // We default to path to the temporary directory
265
- NSString *path = [[NSTemporaryDirectory()stringByStandardizingPath] stringByAppendingPathComponent:fileName];
266
-
267
- // If storage options are provided, we use the documents directory which is persisted
268
- if ([self.options objectForKey:@"storageOptions"] && [[self.options objectForKey:@"storageOptions"] isKindOfClass:[NSDictionary class]]) {
269
- NSDictionary *storageOptions = [self.options objectForKey:@"storageOptions"];
270
-
271
- NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
272
- NSString *documentsDirectory = [paths objectAtIndex:0];
273
- path = [documentsDirectory stringByAppendingPathComponent:fileName];
274
-
275
- // Creates documents subdirectory, if provided
276
- if ([storageOptions objectForKey:@"path"]) {
277
- NSString *newPath = [documentsDirectory stringByAppendingPathComponent:[storageOptions objectForKey:@"path"]];
278
- NSError *error;
279
- [[NSFileManager defaultManager] createDirectoryAtPath:newPath withIntermediateDirectories:YES attributes:nil error:&error];
280
- if (error) {
281
- NSLog(@"Error creating documents subdirectory: %@", error);
282
- self.callback(@[@{@"error": error.localizedFailureReason}]);
283
- return;
284
- }
285
- else {
286
- path = [newPath stringByAppendingPathComponent:fileName];
287
- }
288
- }
289
- }
325
+ // Location: prefer the file's own EXIF GPS, then fall back to the PHAsset.
326
+ CLLocation *location = [ImagePickerManager locationFromImageProperties:properties] ?: asset.location;
327
+ [ImagePickerManager applyLocation:location toResponse:response];
290
328
 
291
- // Create the response object
292
- self.response = [[NSMutableDictionary alloc] init];
329
+ NSDate *creationDate = [ImagePickerManager dateFromImageProperties:properties] ?: asset.creationDate;
330
+ if (creationDate) {
331
+ response[@"timestamp"] = [[ImagePickerManager ISO8601DateFormatter] stringFromDate:creationDate];
332
+ }
293
333
 
294
- if ([mediaType isEqualToString:(NSString *)kUTTypeImage]) { // PHOTOS
295
- UIImage *originalImage;
296
- if ([[self.options objectForKey:@"allowsEditing"] boolValue]) {
297
- originalImage = [info objectForKey:UIImagePickerControllerEditedImage];
298
- }
299
- else {
300
- originalImage = [info objectForKey:UIImagePickerControllerOriginalImage];
301
- }
302
-
303
- if (imageURL) {
304
- PHAsset *pickedAsset;
305
- if (@available(iOS 11.0, *)) {
306
- pickedAsset = [info objectForKey: UIImagePickerControllerPHAsset];
307
- } else {
308
- pickedAsset = [PHAsset fetchAssetsWithALAssetURLs:@[imageURL] options:nil].lastObject;
309
- }
310
-
311
- NSString *originalFilename = [self originalFilenameForAsset:pickedAsset assetType:PHAssetResourceTypePhoto];
312
- self.response[@"fileName"] = originalFilename ?: [NSNull null];
313
- if (pickedAsset.location) {
314
- self.response[@"latitude"] = @(pickedAsset.location.coordinate.latitude);
315
- self.response[@"longitude"] = @(pickedAsset.location.coordinate.longitude);
316
- }
317
- if (pickedAsset.creationDate) {
318
- self.response[@"timestamp"] = [[ImagePickerManager ISO8601DateFormatter] stringFromDate:pickedAsset.creationDate];
319
- }
320
- }
334
+ // fileName being present signals "picked from the library" (vs. a fresh camera capture) to callers.
335
+ NSString *originalFilename = [self originalFilenameForAsset:asset assetType:PHAssetResourceTypePhoto] ?: fileName;
336
+ response[@"fileName"] = originalFilename ?: [NSNull null];
337
+ if (asset) {
338
+ response[@"origURL"] = [@"ph://" stringByAppendingString:asset.localIdentifier];
339
+ } else if (assetIdentifier) {
340
+ response[@"origURL"] = [@"ph://" stringByAppendingString:assetIdentifier];
341
+ }
321
342
 
322
- // GIFs break when resized, so we handle them differently
323
- if (imageURL && [[imageURL absoluteString] rangeOfString:@"ext=GIF"].location != NSNotFound) {
324
- ALAssetsLibrary* assetsLibrary = [[ALAssetsLibrary alloc] init];
325
- [assetsLibrary assetForURL:imageURL resultBlock:^(ALAsset *asset) {
326
- ALAssetRepresentation *rep = [asset defaultRepresentation];
327
- const NSUInteger repSize = (NSUInteger)[rep size];
328
- Byte *buffer = (Byte*)malloc(repSize);
329
- NSUInteger buffered = [rep getBytes:buffer fromOffset:0.0 length:repSize error:nil];
330
- NSData *data = [NSData dataWithBytesNoCopy:buffer length:buffered freeWhenDone:YES];
331
- [data writeToFile:path atomically:YES];
332
-
333
- NSMutableDictionary *gifResponse = [[NSMutableDictionary alloc] init];
334
- [gifResponse setObject:@(originalImage.size.width) forKey:@"width"];
335
- [gifResponse setObject:@(originalImage.size.height) forKey:@"height"];
336
-
337
- [self populateResponse:gifResponse fromAsset:asset];
338
-
339
- BOOL vertical = (originalImage.size.width < originalImage.size.height) ? YES : NO;
340
- [gifResponse setObject:@(vertical) forKey:@"isVertical"];
341
-
342
- if (![[self.options objectForKey:@"noData"] boolValue]) {
343
- NSString *dataString = [data base64EncodedStringWithOptions:0];
344
- [gifResponse setObject:dataString forKey:@"data"];
345
- }
346
-
347
- NSURL *fileURL = [NSURL fileURLWithPath:path];
348
- [gifResponse setObject:[fileURL absoluteString] forKey:@"uri"];
349
-
350
- NSNumber *fileSizeValue = nil;
351
- NSError *fileSizeError = nil;
352
- [fileURL getResourceValue:&fileSizeValue forKey:NSURLFileSizeKey error:&fileSizeError];
353
- if (fileSizeValue){
354
- [gifResponse setObject:fileSizeValue forKey:@"fileSize"];
355
- }
356
- dispatch_async(dispatch_get_main_queue(), ^{
357
- self.callback(@[gifResponse]);
358
- });
359
- } failureBlock:^(NSError *error) {
360
- dispatch_async(dispatch_get_main_queue(), ^{
361
- self.callback(@[@{@"error": error.localizedFailureReason}]);
362
- });
363
- }];
364
- return;
365
- }
343
+ [self sendResponse:response];
344
+ }
366
345
 
367
- UIImage *editedImage = [self fixOrientation:originalImage]; // Rotate the image for upload to web
346
+ - (void)handleLibraryVideoAtURL:(NSURL *)url
347
+ error:(NSError *)loadError
348
+ assetIdentifier:(NSString *)assetIdentifier
349
+ suggestedName:(NSString *)suggestedName
350
+ options:(NSDictionary *)options
351
+ {
352
+ if (!url) {
353
+ [self sendResponse:@{@"error": loadError.localizedDescription ?: @"Could not read video"}];
354
+ return;
355
+ }
368
356
 
369
- // If needed, downscale image
370
- float maxWidth = editedImage.size.width;
371
- float maxHeight = editedImage.size.height;
372
- if ([self.options valueForKey:@"maxWidth"]) {
373
- maxWidth = [[self.options valueForKey:@"maxWidth"] floatValue];
374
- }
375
- if ([self.options valueForKey:@"maxHeight"]) {
376
- maxHeight = [[self.options valueForKey:@"maxHeight"] floatValue];
377
- }
378
- editedImage = [self downscaleImageIfNecessary:editedImage maxWidth:maxWidth maxHeight:maxHeight];
357
+ NSString *error = nil;
358
+ NSString *extension = url.pathExtension.length > 0 ? url.pathExtension : @"mov";
359
+ NSString *path = [self destinationPathWithExtension:extension options:options error:&error];
360
+ if (!path) {
361
+ [self sendResponse:@{@"error": error}];
362
+ return;
363
+ }
379
364
 
380
- NSData *data;
381
- NSString *mimeType;
382
- if ([[self.options objectForKey:@"imageFileType"] isEqualToString:@"png"]) {
383
- data = UIImagePNGRepresentation(editedImage);
384
- mimeType = @"image/png";
385
- }
386
- else {
387
- data = UIImageJPEGRepresentation(editedImage, [[self.options valueForKey:@"quality"] floatValue]);
388
- mimeType = @"image/jpeg";
389
- }
390
- [self.response setObject:mimeType forKey:@"type"];
391
- [data writeToFile:path atomically:YES];
365
+ NSURL *destinationURL = [NSURL fileURLWithPath:path];
366
+ NSError *copyError = nil;
367
+ [[NSFileManager defaultManager] removeItemAtURL:destinationURL error:nil];
368
+ if (![[NSFileManager defaultManager] copyItemAtURL:url toURL:destinationURL error:&copyError]) {
369
+ [self sendResponse:@{@"error": copyError.localizedDescription ?: @"Could not save video"}];
370
+ return;
371
+ }
372
+ [self applySkipBackupIfNeeded:path options:options];
392
373
 
393
- if (![[self.options objectForKey:@"noData"] boolValue]) {
394
- NSString *dataString = [data base64EncodedStringWithOptions:0]; // base64 encoded image string
395
- [self.response setObject:dataString forKey:@"data"];
396
- }
374
+ NSMutableDictionary *response = [NSMutableDictionary dictionary];
375
+ response[@"uri"] = destinationURL.absoluteString;
397
376
 
398
- BOOL vertical = (editedImage.size.width < editedImage.size.height) ? YES : NO;
399
- [self.response setObject:@(vertical) forKey:@"isVertical"];
400
- NSURL *fileURL = [NSURL fileURLWithPath:path];
401
- NSString *filePath = [fileURL absoluteString];
402
- [self.response setObject:filePath forKey:@"uri"];
377
+ PHAsset *asset = [ImagePickerManager fetchAssetWithIdentifier:assetIdentifier];
378
+ [ImagePickerManager applyLocation:asset.location toResponse:response];
379
+ if (asset.creationDate) {
380
+ response[@"timestamp"] = [[ImagePickerManager ISO8601DateFormatter] stringFromDate:asset.creationDate];
381
+ }
382
+ NSString *originalFilename = [self originalFilenameForAsset:asset assetType:PHAssetResourceTypeVideo]
383
+ ?: [self fileNameFromSuggestedName:suggestedName extension:url.pathExtension];
384
+ response[@"fileName"] = originalFilename ?: [NSNull null];
385
+ if (assetIdentifier) {
386
+ response[@"origURL"] = [@"ph://" stringByAppendingString:assetIdentifier];
387
+ }
403
388
 
404
- // add ref to the original image
405
- NSString *origURL = [imageURL absoluteString];
406
- if (origURL) {
407
- [self.response setObject:origURL forKey:@"origURL"];
408
- }
389
+ [self sendResponse:response];
390
+ }
409
391
 
410
- NSNumber *fileSizeValue = nil;
411
- NSError *fileSizeError = nil;
412
- [fileURL getResourceValue:&fileSizeValue forKey:NSURLFileSizeKey error:&fileSizeError];
413
- if (fileSizeValue){
414
- [self.response setObject:fileSizeValue forKey:@"fileSize"];
415
- }
392
+ #pragma mark - UIImagePickerControllerDelegate (camera)
416
393
 
417
- [self.response setObject:@(editedImage.size.width) forKey:@"width"];
418
- [self.response setObject:@(editedImage.size.height) forKey:@"height"];
419
-
420
- NSDictionary *storageOptions = [self.options objectForKey:@"storageOptions"];
421
- if (storageOptions && [[storageOptions objectForKey:@"cameraRoll"] boolValue] == YES && self.picker.sourceType == UIImagePickerControllerSourceTypeCamera) {
422
- ALAssetsLibrary *library = [[ALAssetsLibrary alloc] init];
423
- if ([[storageOptions objectForKey:@"waitUntilSaved"] boolValue]) {
424
- [library writeImageToSavedPhotosAlbum:originalImage.CGImage metadata:[info valueForKey:UIImagePickerControllerMediaMetadata] completionBlock:^(NSURL *assetURL, NSError *error) {
425
- if (error) {
426
- NSLog(@"Error while saving picture into photo album");
427
- } else {
428
- // when the image has been saved in the photo album
429
- if (assetURL) {
430
- PHAsset *capturedAsset = [PHAsset fetchAssetsWithALAssetURLs:@[assetURL] options:nil].lastObject;
431
- NSString *originalFilename = [self originalFilenameForAsset:capturedAsset assetType:PHAssetResourceTypePhoto];
432
- self.response[@"fileName"] = originalFilename ?: [NSNull null];
433
- // This implementation will never have a location for the captured image, it needs to be added manually with CoreLocation code here.
434
- if (capturedAsset.creationDate) {
435
- self.response[@"timestamp"] = [[ImagePickerManager ISO8601DateFormatter] stringFromDate:capturedAsset.creationDate];
436
- }
437
- }
438
- [self dispatchCallbackForUrl:assetURL withResponse:self.response];
439
- }
440
- }];
441
- } else {
442
- [library writeImageToSavedPhotosAlbum:originalImage.CGImage metadata:[info valueForKey:UIImagePickerControllerMediaMetadata] completionBlock:nil];
443
- }
444
- }
445
- }
446
- else { // VIDEO
447
- NSURL *videoRefURL = info[UIImagePickerControllerReferenceURL];
448
- NSURL *videoURL = info[UIImagePickerControllerMediaURL];
449
- NSURL *videoDestinationURL = [NSURL fileURLWithPath:path];
450
-
451
- if (videoRefURL) {
452
- PHAsset *pickedAsset = [PHAsset fetchAssetsWithALAssetURLs:@[videoRefURL] options:nil].lastObject;
453
- NSString *originalFilename = [self originalFilenameForAsset:pickedAsset assetType:PHAssetResourceTypeVideo];
454
- self.response[@"fileName"] = originalFilename ?: [NSNull null];
455
- if (pickedAsset.location) {
456
- self.response[@"latitude"] = @(pickedAsset.location.coordinate.latitude);
457
- self.response[@"longitude"] = @(pickedAsset.location.coordinate.longitude);
458
- }
459
- if (pickedAsset.creationDate) {
460
- self.response[@"timestamp"] = [[ImagePickerManager ISO8601DateFormatter] stringFromDate:pickedAsset.creationDate];
461
- }
394
+ - (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary<UIImagePickerControllerInfoKey, id> *)info
395
+ {
396
+ NSDictionary *options = self.options;
397
+ [picker dismissViewControllerAnimated:YES completion:^{
398
+ // Encoding a full-resolution capture is slow; keep it off the main thread.
399
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
400
+ NSString *mediaType = info[UIImagePickerControllerMediaType];
401
+ if ([mediaType isEqualToString:UTTypeMovie.identifier]) {
402
+ [self handleCapturedVideoWithInfo:info options:options];
403
+ } else {
404
+ [self handleCapturedImageWithInfo:info options:options];
462
405
  }
406
+ });
407
+ }];
408
+ }
463
409
 
464
- if ([videoURL.URLByResolvingSymlinksInPath.path isEqualToString:videoDestinationURL.URLByResolvingSymlinksInPath.path] == NO) {
465
- NSFileManager *fileManager = [NSFileManager defaultManager];
410
+ - (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker
411
+ {
412
+ [picker dismissViewControllerAnimated:YES completion:^{
413
+ [self sendResponse:@{@"didCancel": @YES}];
414
+ }];
415
+ }
466
416
 
467
- // Delete file if it already exists
468
- if ([fileManager fileExistsAtPath:videoDestinationURL.path]) {
469
- [fileManager removeItemAtURL:videoDestinationURL error:nil];
470
- }
417
+ - (void)handleCapturedImageWithInfo:(NSDictionary<UIImagePickerControllerInfoKey, id> *)info options:(NSDictionary *)options
418
+ {
419
+ UIImage *originalImage = info[UIImagePickerControllerOriginalImage];
420
+ UIImage *image = originalImage;
421
+ if ([options[@"allowsEditing"] boolValue] && info[UIImagePickerControllerEditedImage]) {
422
+ image = info[UIImagePickerControllerEditedImage];
423
+ }
424
+ if (!image) {
425
+ [self sendResponse:@{@"error": @"Could not read photo"}];
426
+ return;
427
+ }
471
428
 
472
- if (videoURL) { // Protect against reported crash
473
- NSError *error = nil;
474
-
475
- // If we have write access to the source file, move it. Otherwise use copy.
476
- if ([fileManager isWritableFileAtPath:[videoURL path]]) {
477
- [fileManager moveItemAtURL:videoURL toURL:videoDestinationURL error:&error];
478
- } else {
479
- [fileManager copyItemAtURL:videoURL toURL:videoDestinationURL error:&error];
480
- }
481
-
482
- if (error) {
483
- self.callback(@[@{@"error": error.localizedFailureReason}]);
484
- return;
485
- }
486
- }
487
- }
429
+ NSString *error = nil;
430
+ NSMutableDictionary *response = [self writeImage:image options:options error:&error];
431
+ if (!response) {
432
+ [self sendResponse:@{@"error": error ?: @"Could not save photo"}];
433
+ return;
434
+ }
488
435
 
489
- [self.response setObject:videoDestinationURL.absoluteString forKey:@"uri"];
490
- if (videoRefURL.absoluteString) {
491
- [self.response setObject:videoRefURL.absoluteString forKey:@"origURL"];
492
- }
436
+ // The camera's metadata normally has no GPS (UIImagePickerController doesn't geotag), in which
437
+ // case callers fall back to the device's current location. fileName is intentionally left unset
438
+ // for captures so callers can tell them apart from library picks.
439
+ NSDictionary *metadata = info[UIImagePickerControllerMediaMetadata];
440
+ [ImagePickerManager applyLocation:[ImagePickerManager locationFromImageProperties:metadata] toResponse:response];
441
+ NSDate *captureDate = [ImagePickerManager dateFromImageProperties:metadata];
442
+ if (captureDate) {
443
+ response[@"timestamp"] = [[ImagePickerManager ISO8601DateFormatter] stringFromDate:captureDate];
444
+ }
493
445
 
494
- NSDictionary *storageOptions = [self.options objectForKey:@"storageOptions"];
495
- if (storageOptions && [[storageOptions objectForKey:@"cameraRoll"] boolValue] == YES && self.picker.sourceType == UIImagePickerControllerSourceTypeCamera) {
496
- ALAssetsLibrary *library = [[ALAssetsLibrary alloc] init];
497
- [library writeVideoAtPathToSavedPhotosAlbum:videoDestinationURL completionBlock:^(NSURL *assetURL, NSError *error) {
498
- if (error) {
499
- dispatch_async(dispatch_get_main_queue(), ^{
500
- self.callback(@[@{@"error": error.localizedFailureReason}]);
501
- });
502
- return;
503
- } else {
504
- NSLog(@"Save video succeed.");
505
- if ([[storageOptions objectForKey:@"waitUntilSaved"] boolValue]) {
506
- if (assetURL) {
507
- PHAsset *capturedAsset = [PHAsset fetchAssetsWithALAssetURLs:@[assetURL] options:nil].lastObject;
508
- NSString *originalFilename = [self originalFilenameForAsset:capturedAsset assetType:PHAssetResourceTypeVideo];
509
- self.response[@"fileName"] = originalFilename ?: [NSNull null];
510
- // This implementation will never have a location for the captured image, it needs to be added manually with CoreLocation code here.
511
- if (capturedAsset.creationDate) {
512
- self.response[@"timestamp"] = [[ImagePickerManager ISO8601DateFormatter] stringFromDate:capturedAsset.creationDate];
513
- }
514
- [self dispatchCallbackForUrl:assetURL withResponse:self.response];
515
- } else {
516
- [self dispatchCallbackForUrl:[info valueForKey:UIImagePickerControllerReferenceURL] withResponse:self.response];
517
- }
518
-
519
-
520
- }
521
- }
522
- }];
523
- }
446
+ NSDictionary *storageOptions = [ImagePickerManager storageOptionsFrom:options];
447
+ if (![storageOptions[@"cameraRoll"] boolValue]) {
448
+ [self sendResponse:response];
449
+ return;
450
+ }
451
+
452
+ NSData *photoData = [ImagePickerManager JPEGDataForImage:originalImage metadata:metadata];
453
+ BOOL waitUntilSaved = [storageOptions[@"waitUntilSaved"] boolValue];
454
+ [self saveToCameraRollPhotoData:photoData videoURL:nil completion:^(PHAsset *asset, NSError *saveError) {
455
+ if (saveError) {
456
+ NSLog(@"ImagePickerManager: error saving photo to camera roll: %@", saveError);
457
+ }
458
+ if (waitUntilSaved) {
459
+ [self applySavedAsset:asset assetType:PHAssetResourceTypePhoto toResponse:response];
460
+ [self sendResponse:response];
524
461
  }
462
+ }];
463
+ if (!waitUntilSaved) {
464
+ [self sendResponse:response];
465
+ }
466
+ }
525
467
 
526
- // If storage options are provided, check the skipBackup flag
527
- if ([self.options objectForKey:@"storageOptions"] && [[self.options objectForKey:@"storageOptions"] isKindOfClass:[NSDictionary class]]) {
528
- NSDictionary *storageOptions = [self.options objectForKey:@"storageOptions"];
468
+ - (void)handleCapturedVideoWithInfo:(NSDictionary<UIImagePickerControllerInfoKey, id> *)info options:(NSDictionary *)options
469
+ {
470
+ NSURL *videoURL = info[UIImagePickerControllerMediaURL];
471
+ if (!videoURL) {
472
+ [self sendResponse:@{@"error": @"Could not read video"}];
473
+ return;
474
+ }
529
475
 
530
- if ([[storageOptions objectForKey:@"skipBackup"] boolValue]) {
531
- [self addSkipBackupAttributeToItemAtPath:path]; // Don't back up the file to iCloud
532
- }
476
+ NSString *error = nil;
477
+ NSString *extension = videoURL.pathExtension.length > 0 ? videoURL.pathExtension : @"mov";
478
+ NSString *path = [self destinationPathWithExtension:extension options:options error:&error];
479
+ if (!path) {
480
+ [self sendResponse:@{@"error": error}];
481
+ return;
482
+ }
483
+ NSURL *destinationURL = [NSURL fileURLWithPath:path];
484
+
485
+ NSFileManager *fileManager = [NSFileManager defaultManager];
486
+ [fileManager removeItemAtURL:destinationURL error:nil];
487
+ NSError *moveError = nil;
488
+ BOOL moved;
489
+ if ([fileManager isWritableFileAtPath:videoURL.path]) {
490
+ moved = [fileManager moveItemAtURL:videoURL toURL:destinationURL error:&moveError];
491
+ } else {
492
+ moved = [fileManager copyItemAtURL:videoURL toURL:destinationURL error:&moveError];
493
+ }
494
+ if (!moved) {
495
+ [self sendResponse:@{@"error": moveError.localizedDescription ?: @"Could not save video"}];
496
+ return;
497
+ }
498
+ [self applySkipBackupIfNeeded:path options:options];
533
499
 
534
- if ([[storageOptions objectForKey:@"waitUntilSaved"] boolValue] == NO ||
535
- [[storageOptions objectForKey:@"cameraRoll"] boolValue] == NO ||
536
- self.picker.sourceType != UIImagePickerControllerSourceTypeCamera)
537
- {
538
- [self dispatchCallbackForUrl:[info valueForKey:UIImagePickerControllerReferenceURL] withResponse:self.response];
539
- }
500
+ NSMutableDictionary *response = [NSMutableDictionary dictionary];
501
+ response[@"uri"] = destinationURL.absoluteString;
502
+
503
+ NSDictionary *storageOptions = [ImagePickerManager storageOptionsFrom:options];
504
+ if (![storageOptions[@"cameraRoll"] boolValue]) {
505
+ [self sendResponse:response];
506
+ return;
507
+ }
508
+
509
+ BOOL waitUntilSaved = [storageOptions[@"waitUntilSaved"] boolValue];
510
+ [self saveToCameraRollPhotoData:nil videoURL:destinationURL completion:^(PHAsset *asset, NSError *saveError) {
511
+ if (saveError) {
512
+ NSLog(@"ImagePickerManager: error saving video to camera roll: %@", saveError);
540
513
  }
541
- else {
542
- [self dispatchCallbackForUrl:[info valueForKey:UIImagePickerControllerReferenceURL] withResponse:self.response];
514
+ if (waitUntilSaved) {
515
+ [self applySavedAsset:asset assetType:PHAssetResourceTypeVideo toResponse:response];
516
+ [self sendResponse:response];
543
517
  }
544
- };
518
+ }];
519
+ if (!waitUntilSaved) {
520
+ [self sendResponse:response];
521
+ }
522
+ }
545
523
 
546
- dispatch_async(dispatch_get_main_queue(), ^{
547
- [picker dismissViewControllerAnimated:YES completion:dismissCompletionBlock];
548
- });
524
+ #pragma mark - Camera roll
525
+
526
+ - (void)saveToCameraRollPhotoData:(NSData *)photoData
527
+ videoURL:(NSURL *)videoURL
528
+ completion:(void (^)(PHAsset *asset, NSError *error))completion
529
+ {
530
+ __block NSString *localIdentifier = nil;
531
+ [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
532
+ PHAssetCreationRequest *request = [PHAssetCreationRequest creationRequestForAsset];
533
+ if (photoData) {
534
+ [request addResourceWithType:PHAssetResourceTypePhoto data:photoData options:nil];
535
+ } else {
536
+ [request addResourceWithType:PHAssetResourceTypeVideo fileURL:videoURL options:nil];
537
+ }
538
+ localIdentifier = request.placeholderForCreatedAsset.localIdentifier;
539
+ } completionHandler:^(BOOL success, NSError * _Nullable error) {
540
+ // Fetching back requires read access; with add-only access this is simply nil.
541
+ PHAsset *asset = success ? [ImagePickerManager fetchAssetWithIdentifier:localIdentifier] : nil;
542
+ completion(asset, error);
543
+ }];
549
544
  }
550
545
 
551
- - (void) populateResponse:(NSMutableDictionary *) response fromAsset:(ALAsset *) asset {
552
- CLLocation * location = [asset valueForProperty:ALAssetPropertyLocation];
553
- if(location){
554
- response[@"latitude"] = @(location.coordinate.latitude);
555
- response[@"longitude"] = @(location.coordinate.longitude);
546
+ - (void)applySavedAsset:(PHAsset *)asset assetType:(PHAssetResourceType)type toResponse:(NSMutableDictionary *)response
547
+ {
548
+ if (!asset) {
549
+ return;
550
+ }
551
+ NSString *originalFilename = [self originalFilenameForAsset:asset assetType:type];
552
+ response[@"fileName"] = originalFilename ?: [NSNull null];
553
+ if (asset.creationDate) {
554
+ response[@"timestamp"] = [[ImagePickerManager ISO8601DateFormatter] stringFromDate:asset.creationDate];
556
555
  }
556
+ response[@"origURL"] = [@"ph://" stringByAppendingString:asset.localIdentifier];
557
557
  }
558
558
 
559
- - (void) dispatchCallbackForUrl: (NSURL *) url withResponse: (NSMutableDictionary *)response {
560
- if(!url){
561
- dispatch_async(dispatch_get_main_queue(), ^{
562
- self.callback(@[response]);
563
- });
559
+ #pragma mark - Writing images
560
+
561
+ - (NSMutableDictionary *)writeImage:(UIImage *)image options:(NSDictionary *)options error:(NSString **)error
562
+ {
563
+ BOOL png = [[ImagePickerManager stringValue:options[@"imageFileType"]] isEqualToString:@"png"];
564
+ NSString *path = [self destinationPathWithExtension:(png ? @"png" : @"jpg") options:options error:error];
565
+ if (!path) {
566
+ return nil;
564
567
  }
565
- if(response && !response[@"latitude"] && !response[@"longitude"]){
566
- ALAssetsLibrary * library = [[ALAssetsLibrary alloc] init];
567
- [library assetForURL:url resultBlock:^(ALAsset * asset){
568
- [self populateResponse:response fromAsset:asset];
569
- dispatch_async(dispatch_get_main_queue(), ^{
570
- self.callback(@[response]);
571
- });
572
- } failureBlock:^(NSError * error){
573
- dispatch_async(dispatch_get_main_queue(), ^{
574
- self.callback(@[response]);
575
- });
576
- }];
568
+
569
+ UIImage *editedImage = [self fixOrientation:image]; // Rotate the image for upload to web
570
+
571
+ // If needed, downscale image
572
+ CGFloat maxWidth = editedImage.size.width;
573
+ CGFloat maxHeight = editedImage.size.height;
574
+ if ([options[@"maxWidth"] isKindOfClass:[NSNumber class]]) {
575
+ maxWidth = [options[@"maxWidth"] floatValue];
576
+ }
577
+ if ([options[@"maxHeight"] isKindOfClass:[NSNumber class]]) {
578
+ maxHeight = [options[@"maxHeight"] floatValue];
579
+ }
580
+ editedImage = [self downscaleImageIfNecessary:editedImage maxWidth:maxWidth maxHeight:maxHeight];
581
+
582
+ NSData *data;
583
+ NSString *mimeType;
584
+ if (png) {
585
+ data = UIImagePNGRepresentation(editedImage);
586
+ mimeType = @"image/png";
577
587
  } else {
578
- dispatch_async(dispatch_get_main_queue(), ^{
579
- self.callback(@[response]);
580
- });
588
+ CGFloat quality = [options[@"quality"] isKindOfClass:[NSNumber class]] ? [options[@"quality"] floatValue] : 1.0;
589
+ data = UIImageJPEGRepresentation(editedImage, quality);
590
+ mimeType = @"image/jpeg";
591
+ }
592
+ if (!data || ![data writeToFile:path atomically:YES]) {
593
+ if (error) { *error = @"Could not save photo"; }
594
+ return nil;
595
+ }
596
+ [self applySkipBackupIfNeeded:path options:options];
597
+
598
+ NSMutableDictionary *response = [NSMutableDictionary dictionary];
599
+ response[@"type"] = mimeType;
600
+ if (![options[@"noData"] boolValue]) {
601
+ response[@"data"] = [data base64EncodedStringWithOptions:0];
581
602
  }
603
+ response[@"isVertical"] = @(editedImage.size.width < editedImage.size.height);
604
+ response[@"width"] = @(editedImage.size.width);
605
+ response[@"height"] = @(editedImage.size.height);
606
+ [self applyFileURL:[NSURL fileURLWithPath:path] toResponse:response];
607
+ return response;
582
608
  }
583
609
 
584
- - (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker
610
+ - (NSMutableDictionary *)writeGIFData:(NSData *)data image:(UIImage *)image options:(NSDictionary *)options error:(NSString **)error
611
+ {
612
+ NSString *path = [self destinationPathWithExtension:@"gif" options:options error:error];
613
+ if (!path) {
614
+ return nil;
615
+ }
616
+ if (![data writeToFile:path atomically:YES]) {
617
+ if (error) { *error = @"Could not save photo"; }
618
+ return nil;
619
+ }
620
+ [self applySkipBackupIfNeeded:path options:options];
621
+
622
+ NSMutableDictionary *response = [NSMutableDictionary dictionary];
623
+ response[@"type"] = @"image/gif";
624
+ if (![options[@"noData"] boolValue]) {
625
+ response[@"data"] = [data base64EncodedStringWithOptions:0];
626
+ }
627
+ response[@"isVertical"] = @(image.size.width < image.size.height);
628
+ response[@"width"] = @(image.size.width);
629
+ response[@"height"] = @(image.size.height);
630
+ [self applyFileURL:[NSURL fileURLWithPath:path] toResponse:response];
631
+ return response;
632
+ }
633
+
634
+ - (void)applyFileURL:(NSURL *)fileURL toResponse:(NSMutableDictionary *)response
635
+ {
636
+ response[@"uri"] = fileURL.absoluteString;
637
+ NSNumber *fileSizeValue = nil;
638
+ [fileURL getResourceValue:&fileSizeValue forKey:NSURLFileSizeKey error:nil];
639
+ if (fileSizeValue) {
640
+ response[@"fileSize"] = fileSizeValue;
641
+ }
642
+ }
643
+
644
+ // Temporary directory by default; the (persisted) documents directory when storageOptions are given.
645
+ - (NSString *)destinationPathWithExtension:(NSString *)extension options:(NSDictionary *)options error:(NSString **)error
646
+ {
647
+ NSString *fileName = [[[NSUUID UUID] UUIDString] stringByAppendingPathExtension:extension];
648
+ NSString *directory = [NSTemporaryDirectory() stringByStandardizingPath];
649
+
650
+ NSDictionary *storageOptions = [ImagePickerManager storageOptionsFrom:options];
651
+ if (storageOptions) {
652
+ directory = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject;
653
+ NSString *subpath = [ImagePickerManager stringValue:storageOptions[@"path"]];
654
+ if (subpath.length > 0) {
655
+ directory = [directory stringByAppendingPathComponent:subpath];
656
+ NSError *createError = nil;
657
+ if (![[NSFileManager defaultManager] createDirectoryAtPath:directory withIntermediateDirectories:YES attributes:nil error:&createError]) {
658
+ NSLog(@"Error creating documents subdirectory: %@", createError);
659
+ if (error) { *error = createError.localizedDescription ?: @"Could not create directory"; }
660
+ return nil;
661
+ }
662
+ }
663
+ }
664
+ return [directory stringByAppendingPathComponent:fileName];
665
+ }
666
+
667
+ - (void)applySkipBackupIfNeeded:(NSString *)path options:(NSDictionary *)options
668
+ {
669
+ if ([[ImagePickerManager storageOptionsFrom:options][@"skipBackup"] boolValue]) {
670
+ [self addSkipBackupAttributeToItemAtPath:path]; // Don't back up the file to iCloud
671
+ }
672
+ }
673
+
674
+ #pragma mark - Responses
675
+
676
+ // RCTResponseSenderBlock may only be invoked once; drop the callback as soon as it's used.
677
+ - (void)sendResponse:(NSDictionary *)response
585
678
  {
586
679
  dispatch_async(dispatch_get_main_queue(), ^{
587
- [picker dismissViewControllerAnimated:YES completion:^{
588
- self.callback(@[@{@"didCancel": @YES}]);
589
- }];
680
+ RCTResponseSenderBlock callback = self.callback;
681
+ self.callback = nil;
682
+ if (callback) {
683
+ callback(@[response]);
684
+ }
590
685
  });
591
686
  }
592
687
 
593
- #pragma mark - Helpers
688
+ - (NSString *)stringOption:(NSString *)key
689
+ {
690
+ return [ImagePickerManager stringValue:self.options[key]];
691
+ }
692
+
693
+ - (NSString *)fileNameFromSuggestedName:(NSString *)suggestedName extension:(NSString *)extension
694
+ {
695
+ if (suggestedName.length == 0) {
696
+ return nil;
697
+ }
698
+ if (extension.length == 0 || suggestedName.pathExtension.length > 0) {
699
+ return suggestedName;
700
+ }
701
+ return [suggestedName stringByAppendingPathExtension:extension];
702
+ }
703
+
704
+ - (NSString * _Nullable)originalFilenameForAsset:(PHAsset * _Nullable)asset assetType:(PHAssetResourceType)type {
705
+ if (!asset) { return nil; }
706
+
707
+ PHAssetResource *originalResource;
708
+ // Get the underlying resources for the PHAsset (PhotoKit)
709
+ NSArray<PHAssetResource *> *pickedAssetResources = [PHAssetResource assetResourcesForAsset:asset];
710
+
711
+ // Find the original resource (underlying image) for the asset, which has the desired filename
712
+ for (PHAssetResource *resource in pickedAssetResources) {
713
+ if (resource.type == type) {
714
+ originalResource = resource;
715
+ }
716
+ }
717
+
718
+ return originalResource.originalFilename;
719
+ }
720
+
721
+ #pragma mark - Permissions
594
722
 
595
723
  - (void)checkCameraPermissions:(void(^)(BOOL granted))callback
596
724
  {
597
725
  AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
598
726
  if (status == AVAuthorizationStatusAuthorized) {
599
727
  callback(YES);
600
- return;
601
- } else if (status == AVAuthorizationStatusNotDetermined){
728
+ } else if (status == AVAuthorizationStatusNotDetermined) {
602
729
  [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) {
603
730
  callback(granted);
604
- return;
605
731
  }];
606
732
  } else {
607
733
  callback(NO);
608
734
  }
609
735
  }
610
736
 
611
- - (void)checkPhotosPermissions:(void(^)(BOOL granted))callback
612
- {
613
- PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatus];
614
- if (status == PHAuthorizationStatusAuthorized) {
615
- callback(YES);
616
- return;
617
- } else if (status == PHAuthorizationStatusNotDetermined) {
618
- [PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
619
- if (status == PHAuthorizationStatusAuthorized) {
620
- callback(YES);
621
- return;
622
- }
623
- else {
624
- callback(NO);
625
- return;
626
- }
627
- }];
628
- }
629
- else {
630
- callback(NO);
631
- }
632
- }
737
+ #pragma mark - Image helpers
633
738
 
634
- - (UIImage*)downscaleImageIfNecessary:(UIImage*)image maxWidth:(float)maxWidth maxHeight:(float)maxHeight
739
+ - (UIImage*)downscaleImageIfNecessary:(UIImage*)image maxWidth:(CGFloat)maxWidth maxHeight:(CGFloat)maxHeight
635
740
  {
636
- UIImage* newImage = image;
637
-
638
741
  // Nothing to do here
639
742
  if (image.size.width <= maxWidth && image.size.height <= maxHeight) {
640
- return newImage;
743
+ return image;
641
744
  }
642
745
 
643
746
  CGSize scaledSize = CGSizeMake(image.size.width, image.size.height);
@@ -648,18 +751,21 @@ RCT_EXPORT_METHOD(showImagePicker:(NSDictionary *)options callback:(RCTResponseS
648
751
  scaledSize = CGSizeMake((maxHeight / scaledSize.height) * scaledSize.width, maxHeight);
649
752
  }
650
753
 
651
- // If the pixels are floats, it causes a white line in iOS8 and probably other versions too
754
+ // If the pixels are floats, it causes a white line
652
755
  scaledSize.width = (int)scaledSize.width;
653
756
  scaledSize.height = (int)scaledSize.height;
654
757
 
655
- UIGraphicsBeginImageContext(scaledSize); // this will resize
656
- [image drawInRect:CGRectMake(0, 0, scaledSize.width, scaledSize.height)];
657
- newImage = UIGraphicsGetImageFromCurrentImageContext();
758
+ // fixOrientation produces a scale-1 image, so points == pixels; render at scale 1 to match.
759
+ UIGraphicsImageRendererFormat *format = [UIGraphicsImageRendererFormat preferredFormat];
760
+ format.scale = 1;
761
+ UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:scaledSize format:format];
762
+ UIImage *newImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext * _Nonnull context) {
763
+ [image drawInRect:CGRectMake(0, 0, scaledSize.width, scaledSize.height)];
764
+ }];
658
765
  if (newImage == nil) {
659
766
  NSLog(@"could not scale image");
767
+ return image;
660
768
  }
661
- UIGraphicsEndImageContext();
662
-
663
769
  return newImage;
664
770
  }
665
771
 
@@ -712,6 +818,9 @@ RCT_EXPORT_METHOD(showImagePicker:(NSDictionary *)options callback:(RCTResponseS
712
818
  }
713
819
 
714
820
  CGContextRef ctx = CGBitmapContextCreate(NULL, srcImg.size.width, srcImg.size.height, CGImageGetBitsPerComponent(srcImg.CGImage), 0, CGImageGetColorSpace(srcImg.CGImage), CGImageGetBitmapInfo(srcImg.CGImage));
821
+ if (!ctx) {
822
+ return srcImg;
823
+ }
715
824
  CGContextConcatCTM(ctx, transform);
716
825
  switch (srcImg.imageOrientation) {
717
826
  case UIImageOrientationLeft:
@@ -754,6 +863,113 @@ RCT_EXPORT_METHOD(showImagePicker:(NSDictionary *)options callback:(RCTResponseS
754
863
 
755
864
  #pragma mark - Class Methods
756
865
 
866
+ + (NSString *)stringValue:(id)value
867
+ {
868
+ return [value isKindOfClass:[NSString class]] ? value : nil;
869
+ }
870
+
871
+ + (NSDictionary *)storageOptionsFrom:(NSDictionary *)options
872
+ {
873
+ id storageOptions = options[@"storageOptions"];
874
+ return [storageOptions isKindOfClass:[NSDictionary class]] ? storageOptions : nil;
875
+ }
876
+
877
+ // Only touches PhotoKit when the app already has read access, so this never triggers a permission prompt.
878
+ + (PHAsset *)fetchAssetWithIdentifier:(NSString *)identifier
879
+ {
880
+ if (identifier.length == 0) {
881
+ return nil;
882
+ }
883
+ PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatusForAccessLevel:PHAccessLevelReadWrite];
884
+ if (status != PHAuthorizationStatusAuthorized && status != PHAuthorizationStatusLimited) {
885
+ return nil;
886
+ }
887
+ return [PHAsset fetchAssetsWithLocalIdentifiers:@[identifier] options:nil].firstObject;
888
+ }
889
+
890
+ + (NSDictionary *)imagePropertiesFromData:(NSData *)data
891
+ {
892
+ CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL);
893
+ if (!source) {
894
+ return nil;
895
+ }
896
+ NSDictionary *properties = (__bridge_transfer NSDictionary *)CGImageSourceCopyPropertiesAtIndex(source, 0, NULL);
897
+ CFRelease(source);
898
+ return properties;
899
+ }
900
+
901
+ + (CLLocation *)locationFromImageProperties:(NSDictionary *)properties
902
+ {
903
+ NSDictionary *gps = properties[(NSString *)kCGImagePropertyGPSDictionary];
904
+ if (![gps isKindOfClass:[NSDictionary class]]) {
905
+ return nil;
906
+ }
907
+ NSNumber *latitude = gps[(NSString *)kCGImagePropertyGPSLatitude];
908
+ NSNumber *longitude = gps[(NSString *)kCGImagePropertyGPSLongitude];
909
+ if (![latitude isKindOfClass:[NSNumber class]] || ![longitude isKindOfClass:[NSNumber class]]) {
910
+ return nil;
911
+ }
912
+ double lat = latitude.doubleValue;
913
+ double lon = longitude.doubleValue;
914
+ if ([gps[(NSString *)kCGImagePropertyGPSLatitudeRef] isEqual:@"S"]) { lat = -lat; }
915
+ if ([gps[(NSString *)kCGImagePropertyGPSLongitudeRef] isEqual:@"W"]) { lon = -lon; }
916
+ if (lat == 0 && lon == 0) {
917
+ return nil;
918
+ }
919
+ return [[CLLocation alloc] initWithLatitude:lat longitude:lon];
920
+ }
921
+
922
+ + (void)applyLocation:(CLLocation *)location toResponse:(NSMutableDictionary *)response
923
+ {
924
+ if (!location) {
925
+ return;
926
+ }
927
+ response[@"latitude"] = @(location.coordinate.latitude);
928
+ response[@"longitude"] = @(location.coordinate.longitude);
929
+ }
930
+
931
+ + (NSDate *)dateFromImageProperties:(NSDictionary *)properties
932
+ {
933
+ NSDictionary *exif = properties[(NSString *)kCGImagePropertyExifDictionary];
934
+ if (![exif isKindOfClass:[NSDictionary class]]) {
935
+ return nil;
936
+ }
937
+ NSString *dateString = [self stringValue:exif[(NSString *)kCGImagePropertyExifDateTimeOriginal]];
938
+ if (dateString.length == 0) {
939
+ return nil;
940
+ }
941
+ NSString *offset = [self stringValue:exif[(NSString *)kCGImagePropertyExifOffsetTimeOriginal]];
942
+
943
+ NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
944
+ formatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
945
+ if (offset.length > 0) {
946
+ formatter.dateFormat = @"yyyy:MM:dd HH:mm:ssxxx";
947
+ return [formatter dateFromString:[dateString stringByAppendingString:offset]];
948
+ }
949
+ // No offset recorded: EXIF times are local to where the photo was taken; assume the device's zone.
950
+ formatter.dateFormat = @"yyyy:MM:dd HH:mm:ss";
951
+ formatter.timeZone = [NSTimeZone localTimeZone];
952
+ return [formatter dateFromString:dateString];
953
+ }
954
+
955
+ + (NSData *)JPEGDataForImage:(UIImage *)image metadata:(NSDictionary *)metadata
956
+ {
957
+ if (!image.CGImage) {
958
+ return UIImageJPEGRepresentation(image, 0.9);
959
+ }
960
+ NSMutableData *data = [NSMutableData data];
961
+ CGImageDestinationRef destination = CGImageDestinationCreateWithData((__bridge CFMutableDataRef)data, (__bridge CFStringRef)UTTypeJPEG.identifier, 1, NULL);
962
+ if (!destination) {
963
+ return UIImageJPEGRepresentation(image, 0.9);
964
+ }
965
+ NSMutableDictionary *properties = [metadata mutableCopy] ?: [NSMutableDictionary dictionary];
966
+ properties[(NSString *)kCGImageDestinationLossyCompressionQuality] = @0.9;
967
+ CGImageDestinationAddImage(destination, image.CGImage, (__bridge CFDictionaryRef)properties);
968
+ BOOL ok = CGImageDestinationFinalize(destination);
969
+ CFRelease(destination);
970
+ return ok ? data : UIImageJPEGRepresentation(image, 0.9);
971
+ }
972
+
757
973
  + (NSDateFormatter * _Nonnull)ISO8601DateFormatter {
758
974
  static NSDateFormatter *ISO8601DateFormatter;
759
975
  static dispatch_once_t onceToken;