|
1 | 1 | import Foundation |
| 2 | +import Combine |
2 | 3 | import Capacitor |
| 4 | +import IONFileTransferLib |
3 | 5 |
|
4 | | -/** |
5 | | - * Please read the Capacitor iOS Plugin Development Guide |
6 | | - * here: https://capacitorjs.com/docs/plugins/ios |
7 | | - */ |
| 6 | +private enum Action: String { |
| 7 | + case download |
| 8 | + case upload |
| 9 | +} |
| 10 | + |
| 11 | +/// A Capacitor plugin that enables file upload and download using the IONFileTransferLib. |
| 12 | +/// |
| 13 | +/// This plugin provides two main JavaScript-exposed methods: `uploadFile` and `downloadFile`. |
| 14 | +/// Internally, it uses Combine to observe progress and results, and bridges data using CAPPluginCall. |
8 | 15 | @objc(FileTransferPlugin) |
9 | 16 | public class FileTransferPlugin: CAPPlugin, CAPBridgedPlugin { |
10 | 17 | public let identifier = "FileTransferPlugin" |
11 | 18 | public let jsName = "FileTransfer" |
12 | 19 | public let pluginMethods: [CAPPluginMethod] = [ |
13 | | - CAPPluginMethod(name: "echo", returnType: CAPPluginReturnPromise) |
| 20 | + .init(selector: #selector(downloadFile), returnType: CAPPluginReturnPromise), |
| 21 | + .init(selector: #selector(uploadFile), returnType: CAPPluginReturnPromise) |
14 | 22 | ] |
15 | | - private let implementation = FileTransfer() |
| 23 | + private lazy var manager: IONFLTRManager = .init() |
| 24 | + private lazy var cancellables: Set<AnyCancellable> = [] |
| 25 | + |
| 26 | + /// Downloads a file from the provided URL to the specified local path. |
| 27 | + /// |
| 28 | + /// - Parameter call: The Capacitor call containing `url`, `path`, and optional HTTP options. |
| 29 | + @objc func downloadFile(_ call: CAPPluginCall) { |
| 30 | + do { |
| 31 | + let (serverURL, fileURL, shouldTrackProgress, httpOptions) = try validateAndPrepare(call: call, action: .download) |
| 32 | + |
| 33 | + try manager.downloadFile( |
| 34 | + fromServerURL: serverURL, |
| 35 | + toFileURL: fileURL, |
| 36 | + withHttpOptions: httpOptions |
| 37 | + ).sink( |
| 38 | + receiveCompletion: handleCompletion(call: call), |
| 39 | + receiveValue: handleReceiveValue( |
| 40 | + call: call, |
| 41 | + type: .download, |
| 42 | + url: serverURL.absoluteString, |
| 43 | + path: fileURL.path, |
| 44 | + shouldTrackProgress: shouldTrackProgress |
| 45 | + ) |
| 46 | + ).store(in: &cancellables) |
| 47 | + } catch { |
| 48 | + sendError(error, call: call) |
| 49 | + } |
| 50 | + } |
| 51 | + |
| 52 | + /// Uploads a file from the provided path to the specified server URL. |
| 53 | + /// |
| 54 | + /// - Parameter call: The Capacitor call containing `url`, `path`, `fileKey`, and optional HTTP options. |
| 55 | + @objc func uploadFile(_ call: CAPPluginCall) { |
| 56 | + do { |
| 57 | + let (serverURL, fileURL, shouldTrackProgress, httpOptions) = try validateAndPrepare(call: call, action: .upload) |
| 58 | + let chunkedMode = call.getBool("chunkedMode", false) |
| 59 | + let mimeType = call.getString("mimeType") |
| 60 | + let fileKey = call.getString("fileKey") ?? "file" |
| 61 | + let uploadOptions = IONFLTRUploadOptions( |
| 62 | + chunkedMode: chunkedMode, |
| 63 | + mimeType: mimeType, |
| 64 | + fileKey: fileKey |
| 65 | + ) |
| 66 | + |
| 67 | + try manager.uploadFile( |
| 68 | + fromFileURL: fileURL, |
| 69 | + toServerURL: serverURL, |
| 70 | + withUploadOptions: uploadOptions, |
| 71 | + andHttpOptions: httpOptions |
| 72 | + ).sink( |
| 73 | + receiveCompletion: handleCompletion(call: call), |
| 74 | + receiveValue: handleReceiveValue( |
| 75 | + call: call, |
| 76 | + type: .upload, |
| 77 | + url: serverURL.absoluteString, |
| 78 | + path: fileURL.path, |
| 79 | + shouldTrackProgress: shouldTrackProgress |
| 80 | + ) |
| 81 | + ).store(in: &cancellables) |
| 82 | + } catch { |
| 83 | + sendError(error, call: call) |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | + /// Validates parameters from the call and prepares transfer-related data. |
| 88 | + /// |
| 89 | + /// - Parameters: |
| 90 | + /// - call: The plugin call. |
| 91 | + /// - action: The type of action (`upload` or `download`). |
| 92 | + /// - Throws: An error if validation fails. |
| 93 | + /// - Returns: Tuple containing server URL, file URL, progress flag, and HTTP options. |
| 94 | + private func validateAndPrepare(call: CAPPluginCall, action: Action) throws -> (URL, URL, Bool, IONFLTRHttpOptions) { |
| 95 | + guard let url = call.getString("url") else { |
| 96 | + throw FileTransferError.urlEmpty |
| 97 | + } |
| 98 | + |
| 99 | + guard let path = call.getString("path") else { |
| 100 | + throw FileTransferError.invalidParameters |
| 101 | + } |
| 102 | + |
| 103 | + guard let serverURL = URL(string: url) else { |
| 104 | + throw FileTransferError.invalidServerUrl(url: url) |
| 105 | + } |
| 106 | + |
| 107 | + guard let fileURL = URL(string: path) else { |
| 108 | + throw FileTransferError.invalidParameters |
| 109 | + } |
| 110 | + |
| 111 | + let shouldTrackProgress = call.getBool("progress", false) |
| 112 | + let headers = call.getObject("headers") ?? JSObject() |
| 113 | + let params = call.getObject("params") ?? JSObject() |
| 114 | + |
| 115 | + let httpOptions = IONFLTRHttpOptions( |
| 116 | + method: call.getString("method") ?? defaultHTTPMethod(for: action), |
| 117 | + params: extractParams(from: params), |
| 118 | + headers: extractHeaders(from: headers), |
| 119 | + timeout: call.getInt("connectTimeout", 60000) / 1000, // Timeouts in iOS are in seconds. So read the value in millis and divide by 1000 |
| 120 | + disableRedirects: call.getBool("disableRedirects", false), |
| 121 | + shouldEncodeUrlParams: call.getBool("shouldEncodeUrlParams", true) |
| 122 | + ) |
| 123 | + |
| 124 | + return (serverURL, fileURL, shouldTrackProgress, httpOptions) |
| 125 | + } |
| 126 | + |
| 127 | + /// Provides the default HTTP method for the given action. |
| 128 | + private func defaultHTTPMethod(for action: Action) -> String { |
| 129 | + switch action { |
| 130 | + case .download: |
| 131 | + return "GET" |
| 132 | + case .upload: |
| 133 | + return "POST" |
| 134 | + } |
| 135 | + } |
| 136 | + |
| 137 | + /// Converts a JSObject to a string dictionary used for headers. |
| 138 | + private func extractHeaders(from jsObject: JSObject) -> [String: String] { |
| 139 | + var result = [String: String]() |
| 140 | + for (key, value) in jsObject { |
| 141 | + result[key] = value as? String ?? "" |
| 142 | + } |
| 143 | + return result |
| 144 | + } |
16 | 145 |
|
17 | | - @objc func echo(_ call: CAPPluginCall) { |
18 | | - let value = call.getString("value") ?? "" |
19 | | - call.resolve([ |
20 | | - "value": implementation.echo(value) |
21 | | - ]) |
| 146 | + /// Converts a JSObject to a dictionary of arrays, supporting both string and string-array values. |
| 147 | + private func extractParams(from jsObject: JSObject) -> [String: [String]] { |
| 148 | + var result: [String: [String]] = [:] |
| 149 | + for (key, value) in jsObject { |
| 150 | + if let stringValue = value as? String { |
| 151 | + result[key] = [stringValue] |
| 152 | + } else if let arrayValue = value as? [Any] { |
| 153 | + let stringArray = arrayValue.compactMap { $0 as? String } |
| 154 | + if !stringArray.isEmpty { |
| 155 | + result[key] = stringArray |
| 156 | + } |
| 157 | + } |
| 158 | + } |
| 159 | + return result |
| 160 | + } |
| 161 | + |
| 162 | + /// Handles completion of the upload or download Combine pipeline. |
| 163 | + private func handleCompletion(call: CAPPluginCall) -> (Subscribers.Completion<Error>) -> Void { |
| 164 | + return { completion in |
| 165 | + if case let .failure(error) = completion { |
| 166 | + self.sendError(error, call: call) |
| 167 | + } |
| 168 | + } |
| 169 | + } |
| 170 | + |
| 171 | + /// Handles received value from the Combine stream. |
| 172 | + /// |
| 173 | + /// - Parameters: |
| 174 | + /// - call: The original plugin call. |
| 175 | + /// - type: Whether it's an upload or download. |
| 176 | + /// - url: The source or destination URL as string. |
| 177 | + /// - path: The file path used in the transfer. |
| 178 | + /// - shouldTrackProgress: Whether progress events should be emitted. |
| 179 | + private func handleReceiveValue( |
| 180 | + call: CAPPluginCall, |
| 181 | + type: Action, |
| 182 | + url: String, |
| 183 | + path: String, |
| 184 | + shouldTrackProgress: Bool |
| 185 | + ) -> (IONFLTRTransferResult) -> Void { |
| 186 | + return { result in |
| 187 | + if case let .ongoing(status) = result { |
| 188 | + if shouldTrackProgress { |
| 189 | + let progressData: JSObject = [ |
| 190 | + "type": type.rawValue, |
| 191 | + "url": url, |
| 192 | + "bytes": status.bytes, |
| 193 | + "contentLength": status.contentLength, |
| 194 | + "lengthComputable": status.lengthComputable |
| 195 | + ] |
| 196 | + self.notifyListeners("progress", data: progressData) |
| 197 | + } |
| 198 | + } else if case let .complete(data) = result { |
| 199 | + if type == .download { |
| 200 | + let response: JSObject = [ |
| 201 | + "path": path |
| 202 | + ] |
| 203 | + call.resolve(response) |
| 204 | + } else { |
| 205 | + let response: JSObject = [ |
| 206 | + "bytesSent": data.totalBytes, |
| 207 | + "responseCode": data.responseCode, |
| 208 | + "response": data.responseBody ?? "", |
| 209 | + "headers": data.headers.reduce(into: JSObject()) { result, entry in |
| 210 | + result[entry.key] = entry.value |
| 211 | + } |
| 212 | + ] |
| 213 | + call.resolve(response) |
| 214 | + } |
| 215 | + } |
| 216 | + } |
| 217 | + } |
| 218 | + |
| 219 | + /// Sends an error response back to the JavaScript layer. |
| 220 | + /// |
| 221 | + /// - Parameters: |
| 222 | + /// - error: The error that occurred. |
| 223 | + /// - call: The plugin call to reject. |
| 224 | + private func sendError(_ error: Error, call: CAPPluginCall) { |
| 225 | + let pluginError: FileTransferError |
| 226 | + switch error { |
| 227 | + case let error as FileTransferError: |
| 228 | + pluginError = error |
| 229 | + case let error as IONFLTRException: |
| 230 | + pluginError = error.toFileTransferError() |
| 231 | + default: |
| 232 | + pluginError = .genericError(cause: error) |
| 233 | + } |
| 234 | + call.reject(pluginError.description, pluginError.code, pluginError.cause) |
22 | 235 | } |
23 | 236 | } |
0 commit comments