MediaPicker.swift (7968B)
1 // 2 // ImagePicker.swift 3 // damus 4 // 5 // Created by Swift on 3/31/23. 6 // 7 8 import UIKit 9 import SwiftUI 10 import PhotosUI 11 12 enum MediaPickerEntry { 13 case editPictureControl 14 case postView 15 } 16 17 struct MediaPicker: UIViewControllerRepresentable { 18 19 @Environment(\.presentationMode) 20 @Binding private var presentationMode 21 let mediaPickerEntry: MediaPickerEntry 22 23 let onMediaSelected: (() -> Void)? 24 let onMediaPicked: (PreUploadedMedia) -> Void 25 26 init(mediaPickerEntry: MediaPickerEntry, onMediaSelected: (() -> Void)? = nil, onMediaPicked: @escaping (PreUploadedMedia) -> Void) { 27 self.mediaPickerEntry = mediaPickerEntry 28 self.onMediaSelected = onMediaSelected 29 self.onMediaPicked = onMediaPicked 30 } 31 32 final class Coordinator: NSObject, PHPickerViewControllerDelegate { 33 var parent: MediaPicker 34 35 // properties used for returning medias in the same order as picking 36 let dispatchGroup: DispatchGroup = DispatchGroup() 37 var orderIds: [String] = [] 38 var orderMap: [String: PreUploadedMedia] = [:] 39 40 init(_ parent: MediaPicker) { 41 self.parent = parent 42 } 43 44 func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { 45 if results.isEmpty { 46 self.parent.presentationMode.dismiss() 47 } 48 49 // When user dismiss the upload confirmation and re-adds again, reset orderIds and orderMap 50 orderIds.removeAll() 51 orderMap.removeAll() 52 53 for result in results { 54 55 let orderId = result.assetIdentifier ?? UUID().uuidString 56 orderIds.append(orderId) 57 dispatchGroup.enter() 58 59 if result.itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { 60 result.itemProvider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { (item, error) in 61 guard let url = item as? URL else { return } 62 63 if(url.pathExtension == "gif") { 64 // GIFs do not natively support location metadata (See https://superuser.com/a/556320 and https://www.w3.org/Graphics/GIF/spec-gif89a.txt) 65 // It is better to avoid any GPS data processing at all, as it can cause the image to be converted to JPEG. 66 // Therefore, we should load the file directtly and deliver it as "already processed". 67 68 // Load the data for the GIF image 69 // - Don't load it as an UIImage since that can only get exported into JPEG/PNG 70 // - Don't load it as a file representation because it gets deleted before the upload can occur 71 _ = result.itemProvider.loadDataRepresentation(for: .gif, completionHandler: { imageData, error in 72 guard let imageData else { return } 73 let destinationURL = generateUniqueTemporaryMediaURL(fileExtension: "gif") 74 do { 75 try imageData.write(to: destinationURL) 76 Task { 77 await self.chooseMedia(.processed_image(destinationURL), orderId: orderId) 78 } 79 } 80 catch { 81 Log.error("Failed to write GIF image data from Photo picker into a local copy", for: .image_uploading) 82 } 83 }) 84 } 85 else if canGetSourceTypeFromUrl(url: url) { 86 // Media was not taken from camera 87 self.attemptAcquireResourceAndChooseMedia( 88 url: url, 89 fallback: processImage, 90 unprocessedEnum: {.unprocessed_image($0)}, 91 processedEnum: {.processed_image($0)}, 92 orderId: orderId) 93 } else { 94 // Media was taken from camera 95 result.itemProvider.loadObject(ofClass: UIImage.self) { (image, error) in 96 if let image = image as? UIImage, error == nil { 97 self.chooseMedia(.uiimage(image), orderId: orderId) 98 } 99 } 100 } 101 } 102 } else if result.itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { 103 result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { (url, error) in 104 guard let url, error == nil else { return } 105 106 self.attemptAcquireResourceAndChooseMedia( 107 url: url, 108 fallback: processVideo, 109 unprocessedEnum: {.unprocessed_video($0)}, 110 processedEnum: {.processed_video($0)}, orderId: orderId 111 ) 112 } 113 } 114 } 115 116 dispatchGroup.notify(queue: .main) { [weak self] in 117 guard let self = self else { return } 118 var arrMedia: [PreUploadedMedia] = [] 119 for id in self.orderIds { 120 if let media = self.orderMap[id] { 121 arrMedia.append(media) 122 self.parent.onMediaPicked(media) 123 } 124 } 125 } 126 } 127 128 129 private func chooseMedia(_ media: PreUploadedMedia, orderId: String) { 130 self.parent.onMediaSelected?() 131 self.orderMap[orderId] = media 132 self.dispatchGroup.leave() 133 } 134 135 private func attemptAcquireResourceAndChooseMedia(url: URL, fallback: (URL) -> URL?, unprocessedEnum: (URL) -> PreUploadedMedia, processedEnum: (URL) -> PreUploadedMedia, orderId: String) { 136 if url.startAccessingSecurityScopedResource() { 137 // Have permission from system to use url out of scope 138 print("Acquired permission to security scoped resource") 139 self.chooseMedia(unprocessedEnum(url), orderId: orderId) 140 } else { 141 // Need to copy URL to non-security scoped location 142 guard let newUrl = fallback(url) else { return } 143 self.chooseMedia(processedEnum(newUrl), orderId: orderId) 144 } 145 } 146 147 } 148 149 func makeCoordinator() -> Coordinator { 150 Coordinator(self) 151 } 152 153 func makeUIViewController(context: Context) -> PHPickerViewController { 154 var configuration = PHPickerConfiguration(photoLibrary: .shared()) 155 switch mediaPickerEntry { 156 case .postView: 157 configuration.selectionLimit = 0 // allows multiple media selection 158 configuration.filter = .any(of: [.images, .videos]) 159 configuration.selection = .ordered // images are returned in the order they were selected + numbered badge displayed 160 case .editPictureControl: 161 configuration.selectionLimit = 1 // allows one media selection 162 configuration.filter = .images // allows image only 163 } 164 let picker = PHPickerViewController(configuration: configuration) 165 picker.delegate = context.coordinator as any PHPickerViewControllerDelegate 166 return picker 167 } 168 169 func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) { 170 } 171 }