import Foundation import Capacitor import Photos import PhotosUI @objc(CAPCameraPlugin) public class CameraPlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "CAPCameraPlugin" public let jsName = "Camera" public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "getPhoto", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "pickImages", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "pickLimitedLibraryPhotos", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getLimitedLibraryPhotos", returnType: CAPPluginReturnPromise) ] private var call: CAPPluginCall? private var settings = CameraSettings() private let defaultSource = CameraSource.prompt private let defaultDirection = CameraDirection.rear private var multiple = false private var imageCounter = 0 @objc override public func checkPermissions(_ call: CAPPluginCall) { var result: [String: Any] = [:] for permission in CameraPermissionType.allCases { let state: String switch permission { case .camera: state = AVCaptureDevice.authorizationStatus(for: .video).authorizationState case .photos: if #available(iOS 14, *) { state = PHPhotoLibrary.authorizationStatus(for: .readWrite).authorizationState } else { state = PHPhotoLibrary.authorizationStatus().authorizationState } } result[permission.rawValue] = state } call.resolve(result) } @objc override public func requestPermissions(_ call: CAPPluginCall) { // get the list of desired types, if passed let typeList = call.getArray("permissions", String.self)?.compactMap({ (type) -> CameraPermissionType? in return CameraPermissionType(rawValue: type) }) ?? [] // otherwise check everything let permissions: [CameraPermissionType] = (typeList.count > 0) ? typeList : CameraPermissionType.allCases // request the permissions let group = DispatchGroup() for permission in permissions { switch permission { case .camera: group.enter() AVCaptureDevice.requestAccess(for: .video) { _ in group.leave() } case .photos: group.enter() if #available(iOS 14, *) { PHPhotoLibrary.requestAuthorization(for: .readWrite) { (_) in group.leave() } } else { PHPhotoLibrary.requestAuthorization({ (_) in group.leave() }) } } } group.notify(queue: DispatchQueue.main) { [weak self] in self?.checkPermissions(call) } } @objc func pickLimitedLibraryPhotos(_ call: CAPPluginCall) { if #available(iOS 14, *) { PHPhotoLibrary.requestAuthorization(for: .readWrite) { (granted) in if granted == .limited { if let viewController = self.bridge?.viewController { if #available(iOS 15, *) { PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: viewController) { _ in self.getLimitedLibraryPhotos(call) } } else { PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: viewController) call.resolve([ "photos": [] ]) } } } else { call.resolve([ "photos": [] ]) } } } else { call.unavailable("Not available on iOS 13") } } @objc func getLimitedLibraryPhotos(_ call: CAPPluginCall) { if #available(iOS 14, *) { PHPhotoLibrary.requestAuthorization(for: .readWrite) { (granted) in if granted == .limited { self.call = call DispatchQueue.global(qos: .utility).async { let assets = PHAsset.fetchAssets(with: .image, options: nil) var processedImages: [ProcessedImage] = [] let imageManager = PHImageManager.default() let options = PHImageRequestOptions() options.deliveryMode = .highQualityFormat let group = DispatchGroup() if assets.count > 0 { for index in 0...(assets.count - 1) { let asset = assets.object(at: index) let fullSize = CGSize(width: asset.pixelWidth, height: asset.pixelHeight) group.enter() imageManager.requestImage(for: asset, targetSize: fullSize, contentMode: .default, options: options) { image, _ in guard let image = image else { group.leave() return } processedImages.append(self.processedImage(from: image, with: asset.imageData)) group.leave() } } } group.notify(queue: .global(qos: .utility)) { [weak self] in self?.returnImages(processedImages) } } } else { call.resolve([ "photos": [] ]) } } } else { call.unavailable("Not available on iOS 13") } } @objc func getPhoto(_ call: CAPPluginCall) { self.multiple = false self.call = call self.settings = cameraSettings(from: call) // Make sure they have all the necessary info.plist settings if let missingUsageDescription = checkUsageDescriptions() { CAPLog.print("⚡️ ", self.pluginId, "-", missingUsageDescription) call.reject(missingUsageDescription) return } DispatchQueue.main.async { switch self.settings.source { case .prompt: self.showPrompt() case .camera: self.showCamera() case .photos: self.showPhotos() } } } @objc func pickImages(_ call: CAPPluginCall) { self.multiple = true self.call = call self.settings = cameraSettings(from: call) DispatchQueue.main.async { self.showPhotos() } } private func checkUsageDescriptions() -> String? { if let dict = Bundle.main.infoDictionary { for key in CameraPropertyListKeys.allCases where dict[key.rawValue] == nil { return key.missingMessage } } return nil } private func cameraSettings(from call: CAPPluginCall) -> CameraSettings { var settings = CameraSettings() settings.jpegQuality = min(abs(CGFloat(call.getFloat("quality") ?? 100.0)) / 100.0, 1.0) settings.allowEditing = call.getBool("allowEditing") ?? false settings.source = CameraSource(rawValue: call.getString("source") ?? defaultSource.rawValue) ?? defaultSource settings.direction = CameraDirection(rawValue: call.getString("direction") ?? defaultDirection.rawValue) ?? defaultDirection if let typeString = call.getString("resultType"), let type = CameraResultType(rawValue: typeString) { settings.resultType = type } settings.saveToGallery = call.getBool("saveToGallery") ?? false // Get the new image dimensions if provided settings.width = CGFloat(call.getInt("width") ?? 0) settings.height = CGFloat(call.getInt("height") ?? 0) if settings.width > 0 || settings.height > 0 { // We resize only if a dimension was provided settings.shouldResize = true } settings.shouldCorrectOrientation = call.getBool("correctOrientation") ?? true settings.userPromptText = CameraPromptText(title: call.getString("promptLabelHeader"), photoAction: call.getString("promptLabelPhoto"), cameraAction: call.getString("promptLabelPicture"), cancelAction: call.getString("promptLabelCancel")) if let styleString = call.getString("presentationStyle"), styleString == "popover" { settings.presentationStyle = .popover } else { settings.presentationStyle = .fullScreen } return settings } } // public delegate methods extension CameraPlugin: UIImagePickerControllerDelegate, UINavigationControllerDelegate, UIPopoverPresentationControllerDelegate { public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { picker.dismiss(animated: true) self.call?.reject("User cancelled photos app") } public func popoverPresentationControllerDidDismissPopover(_ popoverPresentationController: UIPopoverPresentationController) { self.call?.reject("User cancelled photos app") } public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { self.call?.reject("User cancelled photos app") } public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { picker.dismiss(animated: true) { if let processedImage = self.processImage(from: info) { self.returnProcessedImage(processedImage) } else { self.call?.reject("Error processing image") } } } } @available(iOS 14, *) extension CameraPlugin: PHPickerViewControllerDelegate { public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true, completion: nil) guard !results.isEmpty else { self.call?.reject("User cancelled photos app") return } self.fetchProcessedImages(from: results) { [weak self] processedImageArray in guard let processedImageArray else { self?.call?.reject("Error loading image") return } if self?.multiple == true { self?.returnImages(processedImageArray) } else if var processedImage = processedImageArray.first { processedImage.flags = .gallery self?.returnProcessedImage(processedImage) } } } private func fetchProcessedImages(from pickerResultArray: [PHPickerResult], accumulating: [ProcessedImage] = [], _ completionHandler: @escaping ([ProcessedImage]?) -> Void) { func loadImage(from pickerResult: PHPickerResult, _ completionHandler: @escaping (UIImage?) -> Void) { let itemProvider = pickerResult.itemProvider if itemProvider.canLoadObject(ofClass: UIImage.self) { // extract the image itemProvider.loadObject(ofClass: UIImage.self) { itemProviderReading, _ in completionHandler(itemProviderReading as? UIImage) } } else { // extract the image's data representation itemProvider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { data, _ in guard let data else { return completionHandler(nil) } completionHandler(UIImage(data: data)) } } } guard let currentPickerResult = pickerResultArray.first else { return completionHandler(accumulating) } loadImage(from: currentPickerResult) { [weak self] loadedImage in guard let self, let loadedImage else { return completionHandler(nil) } var asset: PHAsset? if let assetId = currentPickerResult.assetIdentifier { asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject } let newElement = self.processedImage(from: loadedImage, with: asset?.imageData) self.fetchProcessedImages( from: Array(pickerResultArray.dropFirst()), accumulating: accumulating + [newElement], completionHandler ) } } } private extension CameraPlugin { func returnImage(_ processedImage: ProcessedImage, isSaved: Bool) { guard let jpeg = processedImage.generateJPEG(with: settings.jpegQuality) else { self.call?.reject("Unable to convert image to jpeg") return } if settings.resultType == CameraResultType.uri || multiple { guard let fileURL = try? saveTemporaryImage(jpeg), let webURL = bridge?.portablePath(fromLocalURL: fileURL) else { call?.reject("Unable to get portable path to file") return } if self.multiple { call?.resolve([ "photos": [[ "path": fileURL.absoluteString, "exif": processedImage.exifData, "webPath": webURL.absoluteString, "format": "jpeg" ]] ]) return } call?.resolve([ "path": fileURL.absoluteString, "exif": processedImage.exifData, "webPath": webURL.absoluteString, "format": "jpeg", "saved": isSaved ]) } else if settings.resultType == CameraResultType.base64 { self.call?.resolve([ "base64String": jpeg.base64EncodedString(), "exif": processedImage.exifData, "format": "jpeg", "saved": isSaved ]) } else if settings.resultType == CameraResultType.dataURL { call?.resolve([ "dataUrl": "data:image/jpeg;base64," + jpeg.base64EncodedString(), "exif": processedImage.exifData, "format": "jpeg", "saved": isSaved ]) } } func returnImages(_ processedImages: [ProcessedImage]) { var photos: [PluginCallResultData] = [] for processedImage in processedImages { guard let jpeg = processedImage.generateJPEG(with: settings.jpegQuality) else { self.call?.reject("Unable to convert image to jpeg") return } guard let fileURL = try? saveTemporaryImage(jpeg), let webURL = bridge?.portablePath(fromLocalURL: fileURL) else { call?.reject("Unable to get portable path to file") return } photos.append([ "path": fileURL.absoluteString, "exif": processedImage.exifData, "webPath": webURL.absoluteString, "format": "jpeg" ]) } call?.resolve([ "photos": photos ]) } func returnProcessedImage(_ processedImage: ProcessedImage) { // conditionally save the image if settings.saveToGallery && (processedImage.flags.contains(.edited) == true || processedImage.flags.contains(.gallery) == false) { _ = ImageSaver(image: processedImage.image) { error in var isSaved = false if error == nil { isSaved = true } self.returnImage(processedImage, isSaved: isSaved) } } else { self.returnImage(processedImage, isSaved: false) } } func showPrompt() { // Build the action sheet let alert = UIAlertController(title: settings.userPromptText.title, message: nil, preferredStyle: UIAlertController.Style.actionSheet) alert.addAction(UIAlertAction(title: settings.userPromptText.photoAction, style: .default, handler: { [weak self] (_: UIAlertAction) in self?.showPhotos() })) alert.addAction(UIAlertAction(title: settings.userPromptText.cameraAction, style: .default, handler: { [weak self] (_: UIAlertAction) in self?.showCamera() })) alert.addAction(UIAlertAction(title: settings.userPromptText.cancelAction, style: .cancel, handler: { [weak self] (_: UIAlertAction) in self?.call?.reject("User cancelled photos app") })) self.setCenteredPopover(alert) self.bridge?.viewController?.present(alert, animated: true, completion: nil) } func showCamera() { // check if we have a camera if (bridge?.isSimEnvironment ?? false) || !UIImagePickerController.isSourceTypeAvailable(UIImagePickerController.SourceType.camera) { CAPLog.print("⚡️ ", self.pluginId, "-", "Camera not available in simulator") call?.reject("Camera not available while running in Simulator") return } // check for permission let authStatus = AVCaptureDevice.authorizationStatus(for: .video) if authStatus == .restricted || authStatus == .denied { call?.reject("User denied access to camera") return } // we either already have permission or can prompt AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in if granted { DispatchQueue.main.async { self?.presentCameraPicker() } } else { self?.call?.reject("User denied access to camera") } } } func showPhotos() { // check for permission let authStatus = PHPhotoLibrary.authorizationStatus() if authStatus == .restricted || authStatus == .denied { call?.reject("User denied access to photos") return } // we either already have permission or can prompt if authStatus == .authorized { presentSystemAppropriateImagePicker() } else { PHPhotoLibrary.requestAuthorization({ [weak self] (status) in if status == PHAuthorizationStatus.authorized { DispatchQueue.main.async { [weak self] in self?.presentSystemAppropriateImagePicker() } } else { self?.call?.reject("User denied access to photos") } }) } } func presentCameraPicker() { let picker = UIImagePickerController() picker.delegate = self picker.allowsEditing = self.settings.allowEditing // select the input picker.sourceType = .camera if settings.direction == .rear, UIImagePickerController.isCameraDeviceAvailable(.rear) { picker.cameraDevice = .rear } else if settings.direction == .front, UIImagePickerController.isCameraDeviceAvailable(.front) { picker.cameraDevice = .front } // present picker.modalPresentationStyle = settings.presentationStyle if settings.presentationStyle == .popover { picker.popoverPresentationController?.delegate = self setCenteredPopover(picker) } bridge?.viewController?.present(picker, animated: true, completion: nil) } func presentSystemAppropriateImagePicker() { if #available(iOS 14, *) { presentPhotoPicker() } else { presentImagePicker() } } func presentImagePicker() { let picker = UIImagePickerController() picker.delegate = self picker.allowsEditing = self.settings.allowEditing // select the input picker.sourceType = .photoLibrary // present picker.modalPresentationStyle = settings.presentationStyle if settings.presentationStyle == .popover { picker.popoverPresentationController?.delegate = self setCenteredPopover(picker) } bridge?.viewController?.present(picker, animated: true, completion: nil) } @available(iOS 14, *) func presentPhotoPicker() { var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) configuration.selectionLimit = self.multiple ? (self.call?.getInt("limit") ?? 0) : 1 configuration.filter = .images let picker = PHPickerViewController(configuration: configuration) picker.delegate = self // present picker.modalPresentationStyle = settings.presentationStyle if settings.presentationStyle == .popover { picker.popoverPresentationController?.delegate = self setCenteredPopover(picker) } bridge?.viewController?.present(picker, animated: true, completion: nil) } func saveTemporaryImage(_ data: Data) throws -> URL { var url: URL repeat { imageCounter += 1 url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("photo-\(imageCounter).jpg") } while FileManager.default.fileExists(atPath: url.path) try data.write(to: url, options: .atomic) return url } func processImage(from info: [UIImagePickerController.InfoKey: Any]) -> ProcessedImage? { var selectedImage: UIImage? var flags: PhotoFlags = [] // get the image if let edited = info[UIImagePickerController.InfoKey.editedImage] as? UIImage { selectedImage = edited // use the edited version flags = flags.union([.edited]) } else if let original = info[UIImagePickerController.InfoKey.originalImage] as? UIImage { selectedImage = original // use the original version } guard let image = selectedImage else { return nil } var metadata: [String: Any] = [:] // get the image's metadata from the picker or from the photo album if let photoMetadata = info[UIImagePickerController.InfoKey.mediaMetadata] as? [String: Any] { metadata = photoMetadata } else { flags = flags.union([.gallery]) } if let asset = info[UIImagePickerController.InfoKey.phAsset] as? PHAsset { metadata = asset.imageData } // get the result var result = processedImage(from: image, with: metadata) result.flags = flags return result } func processedImage(from image: UIImage, with metadata: [String: Any]?) -> ProcessedImage { var result = ProcessedImage(image: image, metadata: metadata ?? [:]) // resizing the image only makes sense if we have real values to which to constrain it if settings.shouldResize, settings.width > 0 || settings.height > 0 { result.image = result.image.reformat(to: CGSize(width: settings.width, height: settings.height)) result.overwriteMetadataOrientation(to: 1) } else if settings.shouldCorrectOrientation { // resizing implicitly reformats the image so this is only needed if we aren't resizing result.image = result.image.reformat() result.overwriteMetadataOrientation(to: 1) } return result } }