1 // Copyright 2022-present 650 Industries. All rights reserved.
2 
3 import UIKit
4 import PhotosUI
5 import ABI47_0_0ExpoModulesCore
6 
7 typealias MediaInfo = [UIImagePickerController.InfoKey: Any]
8 
9 /**
10  Helper struct storing single picking operation context variables that have their own non-sharable state.
11  */
12 struct PickingContext {
13   let promise: Promise
14   let options: ImagePickerOptions
15   let imagePickerHandler: ImagePickerHandler
16 }
17 
18 enum OperationType {
19   case ask
20   case get
21 }
22 
23 public class ImagePickerModule: Module, OnMediaPickingResultHandler {
definitionnull24   public func definition() -> ModuleDefinition {
25     // TODO: (@bbarthec) change to "ExpoImagePicker" and propagate to other platforms
26     Name("ExponentImagePicker")
27 
28     OnCreate {
29       self.appContext?.permissions?.register([
30         CameraPermissionRequester(),
31         MediaLibraryPermissionRequester(),
32         MediaLibraryWriteOnlyPermissionRequester()
33       ])
34     }
35 
36     AsyncFunction("getCameraPermissionsAsync", { (promise: Promise) in
37       self.handlePermissionRequest(requesterClass: CameraPermissionRequester.self, operationType: .get, promise: promise)
38     })
39 
40     AsyncFunction("getMediaLibraryPermissionsAsync", { (writeOnly: Bool, promise: Promise) in
41       self.handlePermissionRequest(requesterClass: self.getMediaLibraryPermissionRequester(writeOnly), operationType: .get, promise: promise)
42     })
43 
44     AsyncFunction("requestCameraPermissionsAsync", { (promise: Promise) in
45       self.handlePermissionRequest(requesterClass: CameraPermissionRequester.self, operationType: .ask, promise: promise)
46     })
47 
48     AsyncFunction("requestMediaLibraryPermissionsAsync", { (writeOnly: Bool, promise: Promise) in
49       self.handlePermissionRequest(requesterClass: self.getMediaLibraryPermissionRequester(writeOnly), operationType: .ask, promise: promise)
50     })
51 
52     AsyncFunction("launchCameraAsync", { (options: ImagePickerOptions, promise: Promise) -> Void in
53       guard let permissions = self.appContext?.permissions else {
54         return promise.reject(PermissionsModuleNotFoundException())
55       }
56 
57       guard permissions.hasGrantedPermission(usingRequesterClass: CameraPermissionRequester.self) else {
58         return promise.reject(MissingCameraPermissionException())
59       }
60 
61       self.launchImagePicker(sourceType: .camera, options: options, promise: promise)
62     })
63     .runOnQueue(DispatchQueue.main)
64 
65     AsyncFunction("launchImageLibraryAsync", { (options: ImagePickerOptions, promise: Promise) in
66       self.launchImagePicker(sourceType: .photoLibrary, options: options, promise: promise)
67     })
68     .runOnQueue(DispatchQueue.main)
69   }
70 
71   private var currentPickingContext: PickingContext?
72 
handlePermissionRequestnull73   private func handlePermissionRequest(requesterClass: AnyClass, operationType: OperationType, promise: Promise) {
74     guard let permissions = self.appContext?.permissions else {
75       return promise.reject(PermissionsModuleNotFoundException())
76     }
77     switch operationType {
78     case .get: permissions.getPermissionUsingRequesterClass(requesterClass, resolve: promise.resolver, reject: promise.legacyRejecter)
79     case .ask: permissions.askForPermission(usingRequesterClass: requesterClass, resolve: promise.resolver, reject: promise.legacyRejecter)
80     }
81   }
82 
getMediaLibraryPermissionRequesternull83   private func getMediaLibraryPermissionRequester(_ writeOnly: Bool) -> AnyClass {
84     return writeOnly ? MediaLibraryWriteOnlyPermissionRequester.self : MediaLibraryPermissionRequester.self
85   }
86 
launchImagePickernull87   private func launchImagePicker(sourceType: UIImagePickerController.SourceType, options: ImagePickerOptions, promise: Promise) {
88     let imagePickerDelegate = ImagePickerHandler(onMediaPickingResultHandler: self, hideStatusBarWhenPresented: options.allowsEditing && !options.allowsMultipleSelection)
89 
90     let pickingContext = PickingContext(promise: promise,
91                                         options: options,
92                                         imagePickerHandler: imagePickerDelegate)
93 
94     if #available(iOS 14, *), !options.allowsEditing && sourceType != .camera {
95       self.launchMultiSelectPicker(pickingContext: pickingContext)
96     } else {
97       self.launchLegacyImagePicker(sourceType: sourceType, pickingContext: pickingContext)
98     }
99   }
100 
launchLegacyImagePickernull101   private func launchLegacyImagePicker(sourceType: UIImagePickerController.SourceType, pickingContext: PickingContext) {
102     let options = pickingContext.options
103 
104     let picker = UIImagePickerController()
105 
106     if sourceType == .camera {
107 #if targetEnvironment(simulator)
108       return pickingContext.promise.reject(CameraUnavailableOnSimulatorException())
109 #else
110       picker.sourceType = .camera
111       picker.cameraDevice = options.cameraType == .front ? .front : .rear
112 #endif
113     }
114 
115     if sourceType == .photoLibrary {
116       picker.sourceType = .photoLibrary
117     }
118 
119     picker.mediaTypes = options.mediaTypes.toArray()
120     picker.videoExportPreset = options.videoExportPreset.toAVAssetExportPreset()
121     picker.videoQuality = options.videoQuality.toQualityType()
122     picker.videoMaximumDuration = options.videoMaxDuration
123 
124     if options.allowsEditing {
125       picker.allowsEditing = options.allowsEditing
126       if options.videoMaxDuration > 600 {
127         return pickingContext.promise.reject(MaxDurationWhileEditingExceededException())
128       }
129       if options.videoMaxDuration == 0 {
130         picker.videoMaximumDuration = 600.0
131       }
132     }
133 
134     presentPickerUI(picker, pickingContext: pickingContext)
135   }
136 
137   @available(iOS 14, *)
launchMultiSelectPickernull138   private func launchMultiSelectPicker(pickingContext: PickingContext) {
139     var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
140     let options = pickingContext.options
141 
142     // selection limit = 1 --> single selection, reflects the old picker behavior
143     configuration.selectionLimit = options.allowsMultipleSelection ? options.selectionLimit : SINGLE_SELECTION
144     configuration.filter = options.mediaTypes.toPickerFilter()
145     if #available(iOS 15, *) {
146       configuration.selection = options.orderedSelection ? .ordered : .default
147     }
148 
149     let picker = PHPickerViewController(configuration: configuration)
150 
151     presentPickerUI(picker, pickingContext: pickingContext)
152   }
153 
presentPickerUInull154   private func presentPickerUI(_ picker: PickerUIController, pickingContext context: PickingContext) {
155     guard let currentViewController = self.appContext?.utilities?.currentViewController() else {
156       return context.promise.reject(MissingCurrentViewControllerException())
157     }
158 
159     picker.modalPresentationStyle = context.options.presentationStyle.toPresentationStyle()
160     picker.setResultHandler(context.imagePickerHandler)
161 
162     // Store picking context as we're navigating to the different view controller (starting asynchronous flow)
163     self.currentPickingContext = context
164     currentViewController.present(picker, animated: true, completion: nil)
165   }
166 
167   // MARK: - OnMediaPickingResultHandler
168 
didCancelPickingnull169   func didCancelPicking() {
170     self.currentPickingContext?.promise.resolve(ImagePickerResponse(assets: nil, canceled: true))
171     self.currentPickingContext = nil
172   }
173 
174   @available(iOS 14, *)
didPickMultipleMedianull175   func didPickMultipleMedia(selection: [PHPickerResult]) {
176     guard let options = self.currentPickingContext?.options,
177           let promise = self.currentPickingContext?.promise else {
178       log.error("Picking operation context has been lost.")
179       return
180     }
181     guard let fileSystem = self.appContext?.fileSystem else {
182       return promise.reject(FileSystemModuleNotFoundException())
183     }
184 
185     let mediaHandler = MediaHandler(fileSystem: fileSystem,
186                                     options: options)
187 
188     // Clean up the currently stored picking context
189     self.currentPickingContext = nil
190 
191     mediaHandler.handleMultipleMedia(selection) { result -> Void in
192       switch result {
193       case .failure(let error): return promise.reject(error)
194       case .success(let response): return promise.resolve(response)
195       }
196     }
197   }
198 
didPickMedianull199   func didPickMedia(mediaInfo: MediaInfo) {
200     guard let options = self.currentPickingContext?.options,
201           let promise = self.currentPickingContext?.promise else {
202       log.error("Picking operation context has been lost.")
203       return
204     }
205     guard let fileSystem = self.appContext?.fileSystem else {
206       return promise.reject(FileSystemModuleNotFoundException())
207     }
208 
209     // Clean up the currently stored picking context
210     self.currentPickingContext = nil
211 
212     let mediaHandler = MediaHandler(fileSystem: fileSystem,
213                                     options: options)
214     mediaHandler.handleMedia(mediaInfo) { result -> Void in
215       switch result {
216       case .failure(let error): return promise.reject(error)
217       case .success(let response): return promise.resolve(response)
218       }
219     }
220   }
221 }
222