1 import ExpoModulesCore
2 import UIKit
3 import MobileCoreServices
4 
5 struct PickingContext {
6   let promise: Promise
7   let options: DocumentPickerOptions
8   let delegate: DocumentPickingDelegate
9 }
10 
11 public class DocumentPickerModule: Module, PickingResultHandler {
12   private var pickingContext: PickingContext?
13 
definitionnull14   public func definition() -> ModuleDefinition {
15     Name("ExpoDocumentPicker")
16 
17     AsyncFunction("getDocumentAsync") { (options: DocumentPickerOptions, promise: Promise) in
18       if pickingContext != nil {
19         throw PickingInProgressException()
20       }
21 
22       guard let currentVc = appContext?.utilities?.currentViewController() else {
23         throw MissingViewControllerException()
24       }
25 
26       let documentPickerVC = createDocumentPicker(with: options)
27       let pickerDelegate = DocumentPickingDelegate(resultHandler: self)
28 
29       pickingContext = PickingContext(promise: promise, options: options, delegate: pickerDelegate)
30 
31       documentPickerVC.delegate = pickerDelegate
32       documentPickerVC.presentationController?.delegate = pickerDelegate
33       documentPickerVC.allowsMultipleSelection = options.multiple
34 
35       if UIDevice.current.userInterfaceIdiom == .pad {
36         let viewFrame = currentVc.view.frame
37         documentPickerVC.popoverPresentationController?.sourceRect = CGRect(
38           x: viewFrame.midX,
39           y: viewFrame.maxY,
40           width: 0,
41           height: 0
42         )
43         documentPickerVC.popoverPresentationController?.sourceView = currentVc.view
44         documentPickerVC.modalPresentationStyle = .pageSheet
45       }
46       currentVc.present(documentPickerVC, animated: true)
47     }.runOnQueue(.main)
48   }
49 
didPickDocumentsAtnull50   func didPickDocumentsAt(urls: [URL]) {
51     guard let options = self.pickingContext?.options,
52     let promise = self.pickingContext?.promise else {
53       log.error("Picking context has been lost.")
54       return
55     }
56     pickingContext = nil
57 
58     do {
59       if options.multiple {
60         let assets = try urls.map {
61           try readDocumentDetails(
62             documentUrl: $0,
63             copy: options.copyToCacheDirectory
64           )
65         }
66         promise.resolve(DocumentPickerResponse(assets: assets))
67       } else {
68         let asset = try readDocumentDetails(
69           documentUrl: urls[0],
70           copy: options.copyToCacheDirectory
71         )
72         promise.resolve(DocumentPickerResponse(assets: [asset]))
73       }
74     } catch {
75       promise.reject(error)
76     }
77   }
78 
didCancelPickingnull79   func didCancelPicking() {
80     guard let context = pickingContext else {
81       log.error("Picking context lost")
82       return
83     }
84 
85     pickingContext = nil
86     context.promise.resolve(DocumentPickerResponse(canceled: true))
87   }
88 
getFileSizenull89   private func getFileSize(path: URL) -> Int? {
90     guard let resource = try? path.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey]) else {
91       return 0
92     }
93 
94     if let isDirectory = resource.isDirectory {
95       if !isDirectory {
96         return resource.fileSize
97       }
98     }
99 
100     guard let contents = try? FileManager.default.contentsOfDirectory(atPath: path.absoluteString) else {
101       return 0
102     }
103 
104     let folderSize = contents.reduce(0) { currentSize, file in
105       let fileSize = getFileSize(path: path.appendingPathComponent(file)) ?? 0
106       return currentSize + fileSize
107     }
108 
109     return folderSize
110   }
111 
readDocumentDetailsnull112   private func readDocumentDetails(documentUrl: URL, copy: Bool) throws -> DocumentInfo {
113     var newUrl = documentUrl
114 
115     guard let fileSystem = self.appContext?.fileSystem else {
116       throw Exceptions.FileSystemModuleNotFound()
117     }
118 
119     guard let fileSize = try? getFileSize(path: documentUrl) else {
120       throw InvalidFileException()
121     }
122 
123     if copy {
124       let cacheDirURL = URL(fileURLWithPath: fileSystem.cachesDirectory)
125       let directory = cacheDirURL.appendingPathComponent("DocumentPicker", isDirectory: true).path
126       let fileExtension = "." + documentUrl.pathExtension
127       let path = fileSystem.generatePath(inDirectory: directory, withExtension: fileExtension)
128       newUrl = URL(fileURLWithPath: path)
129 
130       try FileManager.default.copyItem(at: documentUrl, to: newUrl)
131     }
132 
133     let mimeType = self.getMimeType(from: documentUrl.pathExtension)
134 
135     return DocumentInfo(
136       uri: newUrl.absoluteString,
137       name: documentUrl.lastPathComponent,
138       size: fileSize,
139       mimeType: mimeType
140     )
141   }
142 
getMimeTypenull143   private func getMimeType(from pathExtension: String) -> String? {
144     if #available(iOS 14, *) {
145       return UTType(filenameExtension: pathExtension)?.preferredMIMEType
146     } else {
147       if let uti = UTTypeCreatePreferredIdentifierForTag(
148         kUTTagClassFilenameExtension,
149         pathExtension as NSString, nil
150       )?.takeRetainedValue() {
151         if let mimetype = UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType)?.takeRetainedValue() {
152           return mimetype as String
153         }
154       }
155       return nil
156     }
157   }
158 
159   @available(iOS 14.0, *)
toUTTypenull160   private func toUTType(mimeType: String) -> UTType? {
161     switch mimeType {
162     case "*/*":
163       return UTType.item
164     case "image/*":
165       return UTType.image
166     case "video/*":
167       return UTType.video
168     case "audio/*":
169       return UTType.audio
170     case "text/*":
171       return UTType.text
172     default:
173       return UTType(mimeType: mimeType)
174     }
175   }
176 
toUTInull177   private func toUTI(mimeType: String) -> String {
178     var uti: CFString
179 
180     switch mimeType {
181     case "*/*":
182       uti = kUTTypeItem
183     case "image/*":
184       uti = kUTTypeImage
185     case "video/*":
186       uti = kUTTypeVideo
187     case "audio/*":
188       uti = kUTTypeAudio
189     case "text/*":
190       uti = kUTTypeText
191     default:
192       if let ref = UTTypeCreatePreferredIdentifierForTag(
193         kUTTagClassMIMEType,
194         mimeType as CFString,
195         nil
196       )?.takeRetainedValue() {
197         uti = ref
198       } else {
199         uti = kUTTypeItem
200       }
201     }
202     return uti as String
203   }
204 
createDocumentPickernull205   private func createDocumentPicker(with options: DocumentPickerOptions) -> UIDocumentPickerViewController {
206     if #available(iOS 14.0, *) {
207       let utTypes = options.type.compactMap { toUTType(mimeType: $0) }
208       return UIDocumentPickerViewController(
209         forOpeningContentTypes: utTypes,
210         asCopy: true
211       )
212     } else {
213       let utiTypes = options.type.map { toUTI(mimeType: $0) }
214       return UIDocumentPickerViewController(
215         documentTypes: utiTypes,
216         in: .import
217       )
218     }
219   }
220 }
221