1 // Copyright 2022-present 650 Industries. All rights reserved.
2 
3 import UIKit
4 import PhotosUI
5 import ExpoModulesCore
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     picker.fixCannotMoveEditingBox()
106 
107     if sourceType == .camera {
108 #if targetEnvironment(simulator)
109       return pickingContext.promise.reject(CameraUnavailableOnSimulatorException())
110 #else
111       picker.sourceType = .camera
112       picker.cameraDevice = options.cameraType == .front ? .front : .rear
113 #endif
114     }
115 
116     if sourceType == .photoLibrary {
117       picker.sourceType = .photoLibrary
118     }
119 
120     picker.mediaTypes = options.mediaTypes.toArray()
121     picker.videoExportPreset = options.videoExportPreset.toAVAssetExportPreset()
122     picker.videoQuality = options.videoQuality.toQualityType()
123     picker.videoMaximumDuration = options.videoMaxDuration
124 
125     if options.allowsEditing {
126       picker.allowsEditing = options.allowsEditing
127       if options.videoMaxDuration > 600 {
128         return pickingContext.promise.reject(MaxDurationWhileEditingExceededException())
129       }
130       if options.videoMaxDuration == 0 {
131         picker.videoMaximumDuration = 600.0
132       }
133     }
134 
135     presentPickerUI(picker, pickingContext: pickingContext)
136   }
137 
138   @available(iOS 14, *)
launchMultiSelectPickernull139   private func launchMultiSelectPicker(pickingContext: PickingContext) {
140     var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
141     let options = pickingContext.options
142 
143     // selection limit = 1 --> single selection, reflects the old picker behavior
144     configuration.selectionLimit = options.allowsMultipleSelection ? options.selectionLimit : SINGLE_SELECTION
145     configuration.filter = options.mediaTypes.toPickerFilter()
146     if #available(iOS 14, *) {
147       configuration.preferredAssetRepresentationMode = options.preferredAssetRepresentationMode.toAssetRepresentationMode()
148     }
149     if #available(iOS 15, *) {
150       configuration.selection = options.orderedSelection ? .ordered : .default
151     }
152 
153     let picker = PHPickerViewController(configuration: configuration)
154 
155     presentPickerUI(picker, pickingContext: pickingContext)
156   }
157 
presentPickerUInull158   private func presentPickerUI(_ picker: PickerUIController, pickingContext context: PickingContext) {
159     guard let currentViewController = self.appContext?.utilities?.currentViewController() else {
160       return context.promise.reject(MissingCurrentViewControllerException())
161     }
162 
163     picker.modalPresentationStyle = context.options.presentationStyle.toPresentationStyle()
164     picker.setResultHandler(context.imagePickerHandler)
165 
166     // Store picking context as we're navigating to the different view controller (starting asynchronous flow)
167     self.currentPickingContext = context
168     currentViewController.present(picker, animated: true, completion: nil)
169   }
170 
171   // MARK: - OnMediaPickingResultHandler
172 
didCancelPickingnull173   func didCancelPicking() {
174     self.currentPickingContext?.promise.resolve(ImagePickerResponse(assets: nil, canceled: true))
175     self.currentPickingContext = nil
176   }
177 
178   @available(iOS 14, *)
didPickMultipleMedianull179   func didPickMultipleMedia(selection: [PHPickerResult]) {
180     guard let options = self.currentPickingContext?.options,
181           let promise = self.currentPickingContext?.promise else {
182       log.error("Picking operation context has been lost.")
183       return
184     }
185     guard let fileSystem = self.appContext?.fileSystem else {
186       return promise.reject(FileSystemModuleNotFoundException())
187     }
188 
189     let mediaHandler = MediaHandler(fileSystem: fileSystem,
190                                     options: options)
191 
192     // Clean up the currently stored picking context
193     self.currentPickingContext = nil
194 
195     mediaHandler.handleMultipleMedia(selection) { result -> Void in
196       switch result {
197       case .failure(let error): return promise.reject(error)
198       case .success(let response): return promise.resolve(response)
199       }
200     }
201   }
202 
didPickMedianull203   func didPickMedia(mediaInfo: MediaInfo) {
204     guard let options = self.currentPickingContext?.options,
205           let promise = self.currentPickingContext?.promise else {
206       log.error("Picking operation context has been lost.")
207       return
208     }
209     guard let fileSystem = self.appContext?.fileSystem else {
210       return promise.reject(FileSystemModuleNotFoundException())
211     }
212 
213     // Clean up the currently stored picking context
214     self.currentPickingContext = nil
215 
216     let mediaHandler = MediaHandler(fileSystem: fileSystem,
217                                     options: options)
218     mediaHandler.handleMedia(mediaInfo) { result -> Void in
219       switch result {
220       case .failure(let error): return promise.reject(error)
221       case .success(let response): return promise.resolve(response)
222       }
223     }
224   }
225 }
226