import AVFoundation import FLAnimatedImage import MobileCoreServices import Photos import PhotosUI import UIKit protocol PhotoSelectionControllerDelegate: class { func photoSelectionCanceled() func didFailWithError(_ error: String) func didFailToGetPermission(_ message: String) func didUserSelectImage(_ image: SelectedImage) func didUserSelectAnimatedImage(_ image: AnimatedImage) func didPhotoSelectionCompleted() } typealias FileData = (data: Data, type: CacheFileType) typealias SelectedImage = (image: UIImage, type: CacheFileType) typealias AnimatedImage = (image: FLAnimatedImage, type: CacheFileType) typealias ImageFetchClosure = (_ image: UIImage?) -> Void typealias PhotoAuthStatus = (isAllowed: Bool, isLimited: Bool) final class PhotoSelectionController: NSObject, PhotoSelectionControllerProtocol { weak var delegate: PhotoSelectionControllerDelegate? var phManager: PHImageManager = PHImageManager.default() var library: PHPhotoLibrary = PHPhotoLibrary.shared() // MARK: - Life cycle deinit { if #available(iOS 13, *) { library.unregisterAvailabilityObserver(self) } library.unregisterChangeObserver(self) } override init() { super.init() if #available(iOS 13, *) { library.register(self as PHPhotoLibraryAvailabilityObserver) } library.register(self as PHPhotoLibraryChangeObserver) } func checkCameraAccess(at vc: UIViewController, completion: @escaping TypeClosure<Bool>) { guard AVCaptureDevice.default(for: .video) != nil else { delegate?.didFailWithError(Localizable.errorCameraNotAvailable()) completion(false) return } switch AVCaptureDevice.authorizationStatus(for: .video) { case .denied, .restricted: delegate?.didFailToGetPermission(Localizable.accessErrorCamera()) completion(false) case .authorized: completion(true) case .notDetermined: AVCaptureDevice.requestAccess(for: .video) { [weak self] success in if !success { self?.delegate?.didFailToGetPermission(Localizable.accessErrorCamera()) } completion(success) } @unknown default: break } } func checkPhotosAccess(at vc: UIViewController, completion: @escaping TypeClosure<PhotoAuthStatus>) { let status: PHAuthorizationStatus if #available(iOS 14.0, *) { status = PHPhotoLibrary.authorizationStatus(for: .readWrite) } else { status = PHPhotoLibrary.authorizationStatus() } switch status { case .authorized: completion((isAllowed: true, isLimited: false)) case .limited: if #available(iOS 14.0, *) { completion((isAllowed: true, isLimited: true)) } else { completion((isAllowed: true, isLimited: true)) } completion((isAllowed: true, isLimited: true)) case .denied, .restricted: delegate?.didFailToGetPermission(Localizable.accessErrorPhotos()) completion((isAllowed: false, isLimited: false)) case .notDetermined: requestPhotoLibraryAuthorization(at: vc, completion: completion) @unknown default: break } } func requestPhotoLibraryAuthorization(at vc: UIViewController, completion: @escaping TypeClosure<PhotoAuthStatus>) { if #available(iOS 14.0, *) { PHPhotoLibrary.requestAuthorization(for: .readWrite) { [weak self] status in self?.handlePhotoLibraryAuthorizationStatus(at: vc, status: status, completion: completion) } } else { PHPhotoLibrary.requestAuthorization { [weak self] status in self?.handlePhotoLibraryAuthorizationStatus(at: vc, status: status, completion: completion) } } } private func handlePhotoLibraryAuthorizationStatus(at vc: UIViewController, status: PHAuthorizationStatus, completion: @escaping TypeClosure<PhotoAuthStatus>) { switch status { case .authorized: completion((isAllowed: true, isLimited: false)) case .limited: if #available(iOS 14.0, *) { completion((isAllowed: true, isLimited: true)) } else { completion((isAllowed: true, isLimited: true)) } case .denied, .restricted: delegate?.didFailToGetPermission(Localizable.accessErrorPhotos()) completion((isAllowed: false, isLimited: false)) case .notDetermined: break // won't happen but still @unknown default: break } } } // MARK: - PHPhotoLibraryChangeObserver extension PhotoSelectionController: PHPhotoLibraryChangeObserver { func photoLibraryDidChange(_ changeInstance: PHChange) { // NOTE: - For cases while you present custom UI - trigger updates methods from here } } // MARK: - PHPhotoLibraryAvailabilityObserver extension PhotoSelectionController: PHPhotoLibraryAvailabilityObserver { @available(iOS 13, *) func photoLibraryDidBecomeUnavailable(_ photoLibrary: PHPhotoLibrary) { // NOTE: - For cases while you present custom UI - trigger updates methods from here } } // MARK: - Image picker extension PhotoSelectionController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { picker.dismiss(animated: true, completion: nil) delegate?.photoSelectionCanceled() } func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { // NOTE: image captured by device camera do not have image URL guard let imageURL = info[UIImagePickerController.InfoKey.imageURL] as? URL else { handleCapturedImage(picker, info: info, imageType: .jpeg) return } guard let type = CacheFileType(rawValue: imageURL.pathExtension) else { let message: String = "Unknown file type value" LoggerService.logErrorWithTrace(message) return } handleCapturedImage(picker, info: info, imageType: type) } private func handleCapturedImage(_ picker: UIImagePickerController, info: [UIImagePickerController.InfoKey: Any], imageType: CacheFileType) { var selectedImage: UIImage? let editedImage = info[.editedImage] as? UIImage if editedImage?.jpegData(compressionQuality: 1.0) == nil { // Image was NOT edited guard let image = info[.originalImage] as? UIImage else { let message: String = "Unable to get image" LoggerService.logErrorWithTrace(message) return } selectedImage = image } else { // Image was edited guard let image = info[.editedImage] as? UIImage else { let message: String = "Unable to get image" LoggerService.logErrorWithTrace(message) return } selectedImage = image } guard let image = selectedImage else { let message: String = "Image wasn't provided" LoggerService.logErrorWithTrace(message) return } guard imageType == .gif else { delegate?.didUserSelectImage(SelectedImage(image: image, type: imageType)) picker.dismiss(animated: true) { [weak self] in self?.delegate?.didPhotoSelectionCompleted() } return } guard let imgUrl = info[UIImagePickerController.InfoKey.imageURL] as? URL else { let message: String = "Unable to get image asset" LoggerService.logErrorWithTrace(message) return } do { let data = try Data(contentsOf: imgUrl) guard let animImage = FLAnimatedImage(animatedGIFData: data) else { let message: String = "Unable to create animated image" LoggerService.logErrorWithTrace(message) return } delegate?.didUserSelectAnimatedImage(AnimatedImage(image: animImage, type: imageType)) picker.dismiss(animated: true) { [weak self] in self?.delegate?.didPhotoSelectionCompleted() } } catch { let message: String = error.localizedDescription LoggerService.logErrorWithTrace(message) } } private func requestGIFData(_ asset: PHAsset, completion: @escaping ImageFetchClosure) { let options = PHImageRequestOptions() options.isNetworkAccessAllowed = false options.isSynchronous = true options.resizeMode = .exact options.deliveryMode = .highQualityFormat options.version = .original phManager.requestImageData(for: asset, options: options, resultHandler: { imageData, UTI, _, _ in guard let uti = UTI else { let message: String = "Unable to get image UTI" LoggerService.logErrorWithTrace(message) return } let isGif = UTTypeConformsTo(uti as CFString, kUTTypeGIF) guard let data = imageData, isGif else{ let message: String = "Unable to get GIF image" LoggerService.logErrorWithTrace(message) return } let image = UIImage(data: data) completion(image) }) } } // MARK: - PHPickerViewControllerDelegate extension PhotoSelectionController: PHPickerViewControllerDelegate { // NOTE: - Single photo selection handling @available(iOS 14, *) func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true, completion: { [weak self] in self?.executeOnMain { let data: DataUpdateInfo = ModuleTypeDeinitMessage(type: PHPickerViewController.self).toDataUpdate() AppDelegate.shared.notifyObservers(about: .moduleTypeDeinit, with: data) } }) // Empty results - user canceled photo selection guard results.isEmpty else { guard let itemProvider = results.first?.itemProvider else { let error: String = Localizable.imageErrorUnableToGetFile() self.failedToLoadImage(with: error) return } guard itemProvider.canLoadObject(ofClass: UIImage.self) else { let error: String = Localizable.imageErrorUnableToGetFile() self.failedToLoadImage(with: error) return } let _ = itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, error in guard let err = error else { guard let img = image as? UIImage else { let error: String = Localizable.imageErrorUnableToGetFile() self?.failedToLoadImage(with: error) return } let selectedImage: SelectedImage = (image: img, type: .png) self?.executeOnMain { [weak self] in self?.delegate?.didUserSelectImage(selectedImage) self?.delegate?.didPhotoSelectionCompleted() } return } self?.failedToLoadImage(with: err.localizedDescription) } return } delegate?.photoSelectionCanceled() } private func failedToLoadImage(with error: String) { executeOnMain { [weak self] in self?.delegate?.didFailWithError(error) self?.delegate?.photoSelectionCanceled() } } }