1 // Copyright 2023-present 650 Industries. All rights reserved.
2 
3 import ExpoModulesCore
4 
5 private let EVENT_DOWNLOAD_PROGRESS = "expo-file-system.downloadProgress"
6 private let EVENT_UPLOAD_PROGRESS = "expo-file-system.uploadProgress"
7 
8 public final class FileSystemModule: Module {
9   private lazy var sessionTaskDispatcher = EXSessionTaskDispatcher(sessionHandler: ExpoAppDelegate.getSubscriberOfType(FileSystemBackgroundSessionHandler.self))
10   private lazy var taskHandlersManager = EXTaskHandlersManager()
11 
12   private lazy var backgroundSession = createUrlSession(type: .background, delegate: sessionTaskDispatcher)
13   private lazy var foregroundSession = createUrlSession(type: .foreground, delegate: sessionTaskDispatcher)
14 
15   private var documentDirectory: URL? {
16     return appContext?.config.documentDirectory
17   }
18 
19   private var cacheDirectory: URL? {
20     return appContext?.config.cacheDirectory
21   }
22 
23   // swiftlint:disable:next cyclomatic_complexity
definitionnull24   public func definition() -> ModuleDefinition {
25     Name("ExponentFileSystem")
26 
27     Constants {
28       return [
29         "documentDirectory": documentDirectory?.absoluteString,
30         "cacheDirectory": cacheDirectory?.absoluteString,
31         "bundleDirectory": Bundle.main.bundlePath
32       ]
33     }
34 
35     Events(EVENT_DOWNLOAD_PROGRESS, EVENT_UPLOAD_PROGRESS)
36 
37     AsyncFunction("getInfoAsync") { (url: URL, options: InfoOptions, promise: Promise) in
38       switch url.scheme {
39       case "file":
40         EXFileSystemLocalFileHandler.getInfoForFile(url, withOptions: options.toDictionary(), resolver: promise.resolver, rejecter: promise.legacyRejecter)
41       case "assets-library", "ph":
42         EXFileSystemAssetLibraryHandler.getInfoForFile(url, withOptions: options.toDictionary(), resolver: promise.resolver, rejecter: promise.legacyRejecter)
43       default:
44         throw UnsupportedSchemeException(url.scheme)
45       }
46     }
47 
48     AsyncFunction("readAsStringAsync") { (url: URL, options: ReadingOptions) -> String in
49       try ensurePathPermission(appContext, path: url.path, flag: .read)
50 
51       if options.encoding == .base64 {
52         return try readFileAsBase64(path: url.path, options: options)
53       }
54       do {
55         return try String(contentsOfFile: url.path, encoding: options.encoding.toStringEncoding() ?? .utf8)
56       } catch {
57         throw FileNotReadableException(url.path)
58       }
59     }
60 
61     AsyncFunction("writeAsStringAsync") { (url: URL, string: String, options: WritingOptions) in
62       try ensurePathPermission(appContext, path: url.path, flag: .write)
63 
64       if options.encoding == .base64 {
65         try writeFileAsBase64(path: url.path, string: string)
66         return
67       }
68       do {
69         try string.write(toFile: url.path, atomically: true, encoding: options.encoding.toStringEncoding() ?? .utf8)
70       } catch {
71         throw FileNotWritableException(url.path)
72           .causedBy(error)
73       }
74     }
75 
76     AsyncFunction("deleteAsync") { (url: URL, options: DeletingOptions) in
77       guard url.isFileURL else {
78         throw InvalidFileUrlException(url)
79       }
80       try removeFile(path: url.path, idempotent: options.idempotent)
81     }
82 
83     AsyncFunction("moveAsync") { (options: RelocatingOptions) in
84       let (fromUrl, toUrl) = try options.asTuple()
85 
86       guard fromUrl.isFileURL else {
87         throw InvalidFileUrlException(fromUrl)
88       }
89       guard toUrl.isFileURL else {
90         throw InvalidFileUrlException(toUrl)
91       }
92 
93       try ensurePathPermission(appContext, path: fromUrl.appendingPathComponent("..").path, flag: .write)
94       try ensurePathPermission(appContext, path: toUrl.path, flag: .write)
95       try removeFile(path: toUrl.path, idempotent: true)
96       try FileManager.default.moveItem(atPath: fromUrl.path, toPath: toUrl.path)
97     }
98 
99     AsyncFunction("copyAsync") { (options: RelocatingOptions, promise: Promise) in
100       let (fromUrl, toUrl) = try options.asTuple()
101 
102       try ensurePathPermission(appContext, path: fromUrl.path, flag: .read)
103       try ensurePathPermission(appContext, path: toUrl.path, flag: .write)
104 
105       if fromUrl.scheme == "file" {
106         EXFileSystemLocalFileHandler.copy(from: fromUrl, to: toUrl, resolver: promise.resolver, rejecter: promise.legacyRejecter)
107       } else if ["ph", "assets-library"].contains(fromUrl.scheme) {
108         EXFileSystemAssetLibraryHandler.copy(from: fromUrl, to: toUrl, resolver: promise.resolver, rejecter: promise.legacyRejecter)
109       } else {
110         throw InvalidFileUrlException(fromUrl)
111       }
112     }
113 
114     AsyncFunction("makeDirectoryAsync") { (url: URL, options: MakeDirectoryOptions) in
115       guard url.isFileURL else {
116         throw InvalidFileUrlException(url)
117       }
118 
119       try ensurePathPermission(appContext, path: url.path, flag: .write)
120       try FileManager.default.createDirectory(at: url, withIntermediateDirectories: options.intermediates, attributes: nil)
121     }
122 
123     AsyncFunction("readDirectoryAsync") { (url: URL) -> [String] in
124       guard url.isFileURL else {
125         throw InvalidFileUrlException(url)
126       }
127       try ensurePathPermission(appContext, path: url.path, flag: .read)
128 
129       return try FileManager.default.contentsOfDirectory(atPath: url.path)
130     }
131 
132     AsyncFunction("downloadAsync") { (sourceUrl: URL, localUrl: URL, options: DownloadOptions, promise: Promise) in
133       try ensureFileDirectoryExists(localUrl)
134       try ensurePathPermission(appContext, path: localUrl.path, flag: .write)
135 
136       let session = options.sessionType == .background ? backgroundSession : foregroundSession
137       let request = createUrlRequest(url: sourceUrl, headers: options.headers)
138       let downloadTask = session.downloadTask(with: request)
139       let taskDelegate = EXSessionDownloadTaskDelegate(
140         resolve: promise.resolver,
141         reject: promise.legacyRejecter,
142         localUrl: localUrl,
143         shouldCalculateMd5: options.md5
144       )
145 
146       sessionTaskDispatcher.register(taskDelegate, for: downloadTask)
147       downloadTask.resume()
148     }
149 
150     AsyncFunction("uploadAsync") { (targetUrl: URL, localUrl: URL, options: UploadOptions, promise: Promise) in
151       guard localUrl.isFileURL else {
152         throw InvalidFileUrlException(localUrl)
153       }
154       guard FileManager.default.fileExists(atPath: localUrl.path) else {
155         throw FileNotExistsException(localUrl.path)
156       }
157       let session = options.sessionType == .background ? backgroundSession : foregroundSession
158       let task = createUploadTask(session: session, targetUrl: targetUrl, sourceUrl: localUrl, options: options)
159       let taskDelegate = EXSessionUploadTaskDelegate(resolve: promise.resolver, reject: promise.legacyRejecter)
160 
161       sessionTaskDispatcher.register(taskDelegate, for: task)
162       task.resume()
163     }
164 
165     AsyncFunction("uploadTaskStartAsync") { (targetUrl: URL, localUrl: URL, uuid: String, options: UploadOptions, promise: Promise) in
166       let session = options.sessionType == .background ? backgroundSession : foregroundSession
167       let task = createUploadTask(session: session, targetUrl: targetUrl, sourceUrl: localUrl, options: options)
168       let onSend: EXUploadDelegateOnSendCallback = { [weak self] _, _, totalBytesSent, totalBytesExpectedToSend in
169         self?.sendEvent(EVENT_UPLOAD_PROGRESS, [
170           "uuid": uuid,
171           "data": [
172             "totalBytesSent": totalBytesSent,
173             "totalBytesExpectedToSend": totalBytesExpectedToSend
174           ]
175         ])
176       }
177       let taskDelegate = EXSessionCancelableUploadTaskDelegate(
178         resolve: promise.resolver,
179         reject: promise.legacyRejecter,
180         onSendCallback: onSend,
181         resumableManager: taskHandlersManager,
182         uuid: uuid
183       )
184 
185       sessionTaskDispatcher.register(taskDelegate, for: task)
186       taskHandlersManager.register(task, uuid: uuid)
187       task.resume()
188     }
189 
190     // swiftlint:disable:next line_length closure_body_length
191     AsyncFunction("downloadResumableStartAsync") { (sourceUrl: URL, localUrl: URL, uuid: String, options: DownloadOptions, resumeDataString: String?, promise: Promise) in
192       try ensureFileDirectoryExists(localUrl)
193       try ensurePathPermission(appContext, path: localUrl.path, flag: .write)
194 
195       let session = options.sessionType == .background ? backgroundSession : foregroundSession
196       let resumeData = resumeDataString != nil ? Data(base64Encoded: resumeDataString ?? "") : nil
197       let onWrite: EXDownloadDelegateOnWriteCallback = { [weak self] _, _, totalBytesWritten, totalBytesExpectedToWrite in
198         self?.sendEvent(EVENT_DOWNLOAD_PROGRESS, [
199           "uuid": uuid,
200           "data": [
201             "totalBytesWritten": totalBytesWritten,
202             "totalBytesExpectedToWrite": totalBytesExpectedToWrite
203           ]
204         ])
205       }
206       let task: URLSessionDownloadTask
207 
208       if let resumeDataString, let resumeData = Data(base64Encoded: resumeDataString) {
209         task = session.downloadTask(withResumeData: resumeData)
210       } else {
211         let request = createUrlRequest(url: sourceUrl, headers: options.headers)
212         task = session.downloadTask(with: request)
213       }
214 
215       let taskDelegate = EXSessionResumableDownloadTaskDelegate(
216         resolve: promise.resolver,
217         reject: promise.legacyRejecter,
218         localUrl: localUrl,
219         shouldCalculateMd5: options.md5,
220         onWriteCallback: onWrite,
221         resumableManager: taskHandlersManager,
222         uuid: uuid
223       )
224 
225       sessionTaskDispatcher.register(taskDelegate, for: task)
226       taskHandlersManager.register(task, uuid: uuid)
227       task.resume()
228     }
229 
230     AsyncFunction("downloadResumablePauseAsync") { (id: String) -> [String: String?] in
231       guard let task = taskHandlersManager.downloadTask(forId: id) else {
232         throw DownloadTaskNotFoundException(id)
233       }
234       let resumeData = await task.cancelByProducingResumeData()
235 
236       return [
237         "resumeData": resumeData?.base64EncodedString()
238       ]
239     }
240 
241     AsyncFunction("networkTaskCancelAsync") { (id: String) in
242       taskHandlersManager.task(forId: id)?.cancel()
243     }
244 
245     AsyncFunction("getFreeDiskStorageAsync") { () -> Int in
246       let resourceValues = try getResourceValues(from: documentDirectory, forKeys: [.volumeAvailableCapacityKey])
247 
248       guard let availableCapacity = resourceValues?.volumeAvailableCapacity else {
249         throw CannotDetermineDiskCapacity()
250       }
251       return availableCapacity
252     }
253 
254     AsyncFunction("getTotalDiskCapacityAsync") { () -> Int in
255       let resourceValues = try getResourceValues(from: documentDirectory, forKeys: [.volumeTotalCapacityKey])
256 
257       guard let totalCapacity = resourceValues?.volumeTotalCapacity else {
258         throw CannotDetermineDiskCapacity()
259       }
260       return totalCapacity
261     }
262   }
263 }
264