diff --git a/packages/capacitor-plugin/.gitignore b/packages/capacitor-plugin/.gitignore index 77a9815..3052ff6 100644 --- a/packages/capacitor-plugin/.gitignore +++ b/packages/capacitor-plugin/.gitignore @@ -16,12 +16,9 @@ DerivedData/ .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc - # macOS files .DS_Store - - # Based on Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore # Built application files @@ -67,4 +64,4 @@ captures #*.jks # External native build folder generated in Android Studio 2.2 and later -.externalNativeBuild \ No newline at end of file +.externalNativeBuild diff --git a/packages/capacitor-plugin/CapacitorFileTransfer.podspec b/packages/capacitor-plugin/CapacitorFileTransfer.podspec index 1e8c5f7..9aad5e8 100644 --- a/packages/capacitor-plugin/CapacitorFileTransfer.podspec +++ b/packages/capacitor-plugin/CapacitorFileTransfer.podspec @@ -12,9 +12,7 @@ Pod::Spec.new do |s| s.source = { :git => package['repository']['url'], :tag => s.version.to_s } s.source_files = 'ios/Sources/FileTransferPlugin/*.{swift,h,m,c,cc,mm,cpp}' s.ios.deployment_target = '14.0' - #s.dependency 'FileTransferLib', spec='~> 1.0' - # temporary xcframeowrk dependency - TODO update to official pod (commented line above) once published - s.vendored_frameworks = 'ios/Sources/*/IONFileTransferLib.xcframework' s.dependency 'Capacitor' + s.dependency 'IONFileTransferLib', spec='~> 1.0' s.swift_version = '5.1' end diff --git a/packages/capacitor-plugin/Package.swift b/packages/capacitor-plugin/Package.swift index 8cf0bb6..0c5c5cd 100644 --- a/packages/capacitor-plugin/Package.swift +++ b/packages/capacitor-plugin/Package.swift @@ -10,14 +10,14 @@ let package = Package( targets: ["FileTransferPlugin"]) ], dependencies: [ - .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "7.0.0") + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "7.1.0") ], targets: [ .binaryTarget( name: "IONFileTransferLib", // url: "https://github.com/ionic-team/ion-ios-filetransfer/releases/download/1.0.0/IONFileTransferLib.zip", // checksum: "" // sha-256 - path: "./ios/Sources/FileTransferPlugin/IONFileTransferLib.xcframework" + path: "./ios/Sources/FileTransferPlugin/IONFileTransferLib.zip" ), .target( name: "FileTransferPlugin", diff --git a/packages/capacitor-plugin/README.md b/packages/capacitor-plugin/README.md index 9c5e71d..9fc234e 100644 --- a/packages/capacitor-plugin/README.md +++ b/packages/capacitor-plugin/README.md @@ -65,7 +65,7 @@ Perform an HTTP request to upload a file to a server ### addListener('progress', ...) ```typescript -addListener(eventName: 'progress', listenerFunc: (progress: ProgressStatus) => void) => Promise +addListener(eventName: "progress", listenerFunc: (progress: ProgressStatus) => void) => Promise ``` Add a listener to file transfer (download or upload) progress events. diff --git a/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/FileTransfer.swift b/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/FileTransfer.swift deleted file mode 100644 index 6c34346..0000000 --- a/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/FileTransfer.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -@objc public class FileTransfer: NSObject { - @objc public func echo(_ value: String) -> String { - print(value) - return value - } -} diff --git a/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/FileTransferErrors.swift b/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/FileTransferErrors.swift new file mode 100644 index 0000000..3f0bcae --- /dev/null +++ b/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/FileTransferErrors.swift @@ -0,0 +1,182 @@ +import Foundation +import Capacitor +import IONFileTransferLib + +/// A structured error type used in file transfer operations. +/// +/// `FileTransferError` represents various error states that can occur during file uploads and downloads, +/// including validation issues, connection problems, HTTP response errors, and file system errors. +struct FileTransferError: Error { + + /// A error code in the format `OS-PLUG-FLTR-XXXX`. + let code: String + + /// A human-readable error message. + let message: String + + /// The source URL or path related to the error, if available. + var source: String? + + /// The target URL or path related to the error, if available. + var target: String? + + /// The HTTP status code, if the error is related to a network response. + let httpStatus: Int? + + /// The response body returned by the server, if any. + let body: String? + + /// The response headers returned by the server, if any. + let headers: [String: String]? + + /// The underlying error that caused this error, if any. + let cause: Error? + + /// Creates a new `FileTransferError` with the given values. + /// + /// - Parameters: + /// - code: A numeric code that will be formatted internally. + /// - message: A human-readable message describing the error. + /// - source: The original input source, such as a URL or path. + /// - target: The intended destination, such as a URL or path. + /// - httpStatus: Optional HTTP status code if error was a network response. + /// - body: Optional response body. + /// - headers: Optional response headers. + /// - cause: Optional underlying error. + init( + code: Int, + message: String, + source: String? = nil, + target: String? = nil, + httpStatus: Int? = nil, + body: String? = nil, + headers: [String: String]? = nil, + cause: Error? = nil + ) { + self.code = String(format: "OS-PLUG-FLTR-%04d", code) + self.message = message + self.source = source + self.target = target + self.httpStatus = httpStatus + self.body = body + self.headers = headers + self.cause = cause + } + + /// A dictionary representation of the error for use in JavaScript or other serialization contexts. + /// + /// This includes the code, message, and optional metadata such as HTTP status, + /// headers, body, and exception description. + var errorInfo: JSObject { + var info: JSObject = [ + "code": code, + "message": message + ] + if let httpStatus = httpStatus { info["httpStatus"] = httpStatus } + if let body = body { info["body"] = body } + if let headers = headers { + let headersObj: JSObject = headers.reduce(into: [:]) { result, pair in + result[pair.key] = pair.value + } + info["headers"] = headersObj + } + if let cause = cause { info["exception"] = cause.localizedDescription } + + return info + } +} + +// MARK: - Static Constructors + +extension FileTransferError { + + static func invalidParameters(_ message: String? = nil) -> FileTransferError { + .init(code: 5, message: message ?? "The method's input parameters aren't valid.") + } + + static func invalidServerUrl(_ url: String?) -> FileTransferError { + .init( + code: 6, + message: (url?.isEmpty ?? true) + ? "URL to connect to is either null or empty." + : "Invalid server URL was provided - \(url!)", + source: url + ) + } + + static func fileDoesNotExist() -> FileTransferError { + .init(code: 8, message: "Operation failed because file does not exist.") + } + + static func connectionError() -> FileTransferError { + .init(code: 9, message: "Failed to connect to server.") + } + + static func notModified() -> FileTransferError { + .init( + code: 10, + message: "The server responded with HTTP 304 – Not Modified. If you want to avoid this, check your headers related to HTTP caching.", + httpStatus: 304 + ) + } + + static func genericError( + cause: Error? = nil, + message: String? = nil, + responseCode: Int? = nil, + responseBody: String? = nil, + headers: [String: String]? = nil + ) -> FileTransferError { + .init( + code: 11, + message: message ?? "The operation failed with an error.", + httpStatus: responseCode, + body: responseBody, + headers: headers, + cause: cause + ) + } +} + +// MARK: - IONFLTRException Mapping + +extension IONFLTRException { + + /// Converts an `IONFLTRException` to a corresponding `FileTransferError`. + /// + /// This method maps specific cases of `IONFLTRException` to their + /// equivalent `FileTransferError` cases, providing a unified error + /// representation for file transfer operations. + /// + /// - Returns: A `FileTransferError` instance representing the exception. + func toFileTransferError() -> FileTransferError { + switch self { + case .invalidPath: + return FileTransferError.invalidParameters() + case .emptyURL: + return FileTransferError.invalidServerUrl(nil) + case .invalidURL(let url): + return FileTransferError.invalidServerUrl(url) + case .fileDoesNotExist: + return FileTransferError.fileDoesNotExist() + case .cannotCreateDirectory: + return FileTransferError.genericError(cause: self) + case .httpError(let responseCode, let responseBody, let headers): + return responseCode == 304 + ? FileTransferError.notModified() + : FileTransferError.genericError( + cause: self, + message: "HTTP error: \(responseCode) - \(self.description)", + responseCode: responseCode, + responseBody: responseBody, + headers: headers + ) + case .connectionError: + return FileTransferError.connectionError() + case .transferError: + return FileTransferError.genericError(cause: self) + case .unknownError: + return FileTransferError.genericError(cause: self) + } + } +} diff --git a/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/FileTransferPlugin.swift b/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/FileTransferPlugin.swift index d4f8564..06f82a7 100644 --- a/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/FileTransferPlugin.swift +++ b/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/FileTransferPlugin.swift @@ -1,23 +1,246 @@ import Foundation +import Combine import Capacitor +import IONFileTransferLib +import QuartzCore -/** - * Please read the Capacitor iOS Plugin Development Guide - * here: https://capacitorjs.com/docs/plugins/ios - */ +private enum Action: String { + case download + case upload +} + +/// A Capacitor plugin that enables file upload and download using the IONFileTransferLib. +/// +/// This plugin provides two main JavaScript-exposed methods: `uploadFile` and `downloadFile`. +/// Internally, it uses Combine to observe progress and results, and bridges data using CAPPluginCall. @objc(FileTransferPlugin) public class FileTransferPlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "FileTransferPlugin" public let jsName = "FileTransfer" public let pluginMethods: [CAPPluginMethod] = [ - CAPPluginMethod(name: "echo", returnType: CAPPluginReturnPromise) + .init(selector: #selector(downloadFile), returnType: CAPPluginReturnPromise), + .init(selector: #selector(uploadFile), returnType: CAPPluginReturnPromise) ] - private let implementation = FileTransfer() + private lazy var manager: IONFLTRManager = .init() + private lazy var cancellables: Set = [] + private var lastProgressReportTime = CACurrentMediaTime() + private let progressUpdateInterval: TimeInterval = 0.1 // 100ms + + /// Downloads a file from the provided URL to the specified local path. + /// + /// - Parameter call: The Capacitor call containing `url`, `path`, and optional HTTP options. + @objc func downloadFile(_ call: CAPPluginCall) { + do { + let (serverURL, fileURL, shouldTrackProgress, httpOptions) = try validateAndPrepare(call: call, action: .download) + + try manager.downloadFile( + fromServerURL: serverURL, + toFileURL: fileURL, + withHttpOptions: httpOptions + ).sink( + receiveCompletion: handleCompletion(call: call, source: serverURL.absoluteString, target: fileURL.absoluteString), + receiveValue: handleReceiveValue( + call: call, + type: .download, + url: serverURL.absoluteString, + path: fileURL.path, + shouldTrackProgress: shouldTrackProgress + ) + ).store(in: &cancellables) + } catch { + call.sendError(error, source: call.getString("url"), target: call.getString("path")) + } + } + + /// Uploads a file from the provided path to the specified server URL. + /// + /// - Parameter call: The Capacitor call containing `url`, `path`, `fileKey`, and optional HTTP options. + @objc func uploadFile(_ call: CAPPluginCall) { + do { + let (serverURL, fileURL, shouldTrackProgress, httpOptions) = try validateAndPrepare(call: call, action: .upload) + let chunkedMode = call.getBool("chunkedMode", false) + let mimeType = call.getString("mimeType") + let fileKey = call.getString("fileKey") ?? "file" + let uploadOptions = IONFLTRUploadOptions( + chunkedMode: chunkedMode, + mimeType: mimeType, + fileKey: fileKey + ) + + try manager.uploadFile( + fromFileURL: fileURL, + toServerURL: serverURL, + withUploadOptions: uploadOptions, + andHttpOptions: httpOptions + ).sink( + receiveCompletion: handleCompletion(call: call, source: fileURL.absoluteString, target: serverURL.absoluteString), + receiveValue: handleReceiveValue( + call: call, + type: .upload, + url: serverURL.absoluteString, + path: fileURL.path, + shouldTrackProgress: shouldTrackProgress + ) + ).store(in: &cancellables) + } catch { + call.sendError(error, source: call.getString("path"), target: call.getString("url")) + } + } + + /// Validates parameters from the call and prepares transfer-related data. + /// + /// - Parameters: + /// - call: The plugin call. + /// - action: The type of action (`upload` or `download`). + /// - Throws: An error if validation fails. + /// - Returns: Tuple containing server URL, file URL, progress flag, and HTTP options. + private func validateAndPrepare(call: CAPPluginCall, action: Action) throws -> (URL, URL, Bool, IONFLTRHttpOptions) { + guard let url = call.getString("url") else { + throw FileTransferError.invalidServerUrl(nil) + } + + guard let serverURL = URL(string: url) else { + throw FileTransferError.invalidServerUrl(url) + } + + guard let path = call.getString("path") else { + throw FileTransferError.invalidParameters("Path is required.") + } + + guard let fileURL = URL(string: path) else { + throw FileTransferError.invalidParameters("Path is invalid.") + } + + let shouldTrackProgress = call.getBool("progress", false) + let headers = call.getObject("headers") ?? JSObject() + let params = call.getObject("params") ?? JSObject() + + let httpOptions = IONFLTRHttpOptions( + method: call.getString("method") ?? defaultHTTPMethod(for: action), + params: extractParams(from: params), + headers: extractHeaders(from: headers), + timeout: call.getInt("connectTimeout", call.getInt("readTimeout", 60000)) / 1000, // Timeouts in iOS are in seconds. So read the value in millis and divide by 1000 + disableRedirects: call.getBool("disableRedirects", false), + shouldEncodeUrlParams: call.getBool("shouldEncodeUrlParams", true) + ) + + return (serverURL, fileURL, shouldTrackProgress, httpOptions) + } + + /// Provides the default HTTP method for the given action. + private func defaultHTTPMethod(for action: Action) -> String { + switch action { + case .download: + return "GET" + case .upload: + return "POST" + } + } + + /// Converts a JSObject to a string dictionary used for headers. + private func extractHeaders(from jsObject: JSObject) -> [String: String] { + return jsObject.reduce(into: [String: String]()) { result, pair in + if let stringValue = pair.value as? String { + result[pair.key] = stringValue + } + } + } + + /// Converts a JSObject to a dictionary of arrays, supporting both string and string-array values. + private func extractParams(from jsObject: JSObject) -> [String: [String]] { + var result: [String: [String]] = [:] + for (key, value) in jsObject { + if let stringValue = value as? String { + result[key] = [stringValue] + } else if let arrayValue = value as? [Any] { + let stringArray = arrayValue.compactMap { $0 as? String } + if !stringArray.isEmpty { + result[key] = stringArray + } + } + } + return result + } + + /// Handles completion of the upload or download Combine pipeline. + private func handleCompletion(call: CAPPluginCall, source: String, target: String) -> (Subscribers.Completion) -> Void { + return { completion in + if case let .failure(error) = completion { + call.sendError(error, source: source, target: target) + } + } + } + + /// Handles received value from the Combine stream. + /// + /// - Parameters: + /// - call: The original plugin call. + /// - type: Whether it's an upload or download. + /// - url: The source or destination URL as string. + /// - path: The file path used in the transfer. + /// - shouldTrackProgress: Whether progress events should be emitted. + private func handleReceiveValue( + call: CAPPluginCall, + type: Action, + url: String, + path: String, + shouldTrackProgress: Bool + ) -> (IONFLTRTransferResult) -> Void { + return { result in + switch result { + case .ongoing(let status): + guard shouldTrackProgress else { return } + let current = CACurrentMediaTime() + if current - self.lastProgressReportTime < self.progressUpdateInterval { + return + } + self.lastProgressReportTime = current + let progressData: JSObject = [ + "type": type.rawValue, + "url": url, + "bytes": status.bytes, + "contentLength": status.contentLength, + "lengthComputable": status.lengthComputable + ] + self.notifyListeners("progress", data: progressData) + + case .complete(let data): + switch type { + case .download: + let response: JSObject = [ + "path": path + ] + call.resolve(response) + + case .upload: + let response: JSObject = [ + "bytesSent": data.totalBytes, + "responseCode": data.responseCode, + "response": data.responseBody ?? "", + "headers": data.headers.reduce(into: JSObject()) { result, entry in + result[entry.key] = entry.value + } + ] + call.resolve(response) + } + } + } + } +} - @objc func echo(_ call: CAPPluginCall) { - let value = call.getString("value") ?? "" - call.resolve([ - "value": implementation.echo(value) - ]) +extension CAPPluginCall { + func sendError(_ error: Error, source: String?, target: String?) { + var pluginError: FileTransferError + switch error { + case let error as FileTransferError: + pluginError = error + case let error as IONFLTRException: + pluginError = error.toFileTransferError() + default: + pluginError = .genericError(cause: error) + } + pluginError.source = source + pluginError.target = target + self.reject(pluginError.message, pluginError.code, nil, pluginError.errorInfo) } } diff --git a/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/IONFileTransferLib.zip b/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/IONFileTransferLib.zip new file mode 100644 index 0000000..210d651 Binary files /dev/null and b/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/IONFileTransferLib.zip differ diff --git a/packages/example-app/ios/App/Podfile b/packages/example-app/ios/App/Podfile index b03cc82..f6b526f 100644 --- a/packages/example-app/ios/App/Podfile +++ b/packages/example-app/ios/App/Podfile @@ -18,6 +18,10 @@ end target 'App' do capacitor_pods # Add your Pods here + # TODO: Remove this line when IONFileTransferLib is available as a Cocoapod, + # as this lib will be installed via the dependency of FileTransferPlugin + # ../../../capacitor-plugin/CapacitorFileTransfer.podspec + pod 'IONFileTransferLib', :path => '../../../../../ion-ios-filetransfer' end post_install do |installer| diff --git a/packages/example-app/ios/App/Podfile.lock b/packages/example-app/ios/App/Podfile.lock index 528a7c2..64d1967 100644 --- a/packages/example-app/ios/App/Podfile.lock +++ b/packages/example-app/ios/App/Podfile.lock @@ -2,27 +2,39 @@ PODS: - Capacitor (7.0.0): - CapacitorCordova - CapacitorCordova (7.0.0) + - CapacitorFilesystem (7.0.1): + - Capacitor - CapacitorFileTransfer (0.0.1): - Capacitor + - IONFileTransferLib (~> 1.0) + - IONFileTransferLib (1.0.0) DEPENDENCIES: - "Capacitor (from `../../node_modules/@capacitor/ios`)" - "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" + - "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)" - CapacitorFileTransfer (from `../../../capacitor-plugin`) + - IONFileTransferLib (from `../../../../../ion-ios-filetransfer`) EXTERNAL SOURCES: Capacitor: :path: "../../node_modules/@capacitor/ios" CapacitorCordova: :path: "../../node_modules/@capacitor/ios" + CapacitorFilesystem: + :path: "../../node_modules/@capacitor/filesystem" CapacitorFileTransfer: :path: "../../../capacitor-plugin" + IONFileTransferLib: + :path: "../../../../../ion-ios-filetransfer" SPEC CHECKSUMS: Capacitor: 82d1f3b4480d66b5996814f74500dcbc0908558c CapacitorCordova: 345f93b7edd121db98e4ec20ac94d6d7bcaf7e48 - CapacitorFileTransfer: 3dbb65e62ec691038f6d1c6b2d9292201df8d6b4 + CapacitorFilesystem: e6261c410436f54908c11f94336c5b58286b1db0 + CapacitorFileTransfer: aedca25ebaa6c51a11cb79129c31c5d9bbcc0c56 + IONFileTransferLib: 35b5117dfbbfc010f7e8b4f173b922e3d12f94a6 -PODFILE CHECKSUM: 67e964fd8368ed79b4e7f73c342a4f102674cc74 +PODFILE CHECKSUM: 279d80553abc3e96f0f21ab6cb7b41e8c55c662e COCOAPODS: 1.16.2 diff --git a/packages/example-app/package.json b/packages/example-app/package.json index 309dd54..71ca5c4 100644 --- a/packages/example-app/package.json +++ b/packages/example-app/package.json @@ -9,7 +9,7 @@ ], "scripts": { "start": "vite", - "build": "vite build", + "build": "vite build && npx cap sync", "preview": "vite preview" }, "dependencies": { @@ -25,4 +25,4 @@ }, "author": "", "license": "ISC" -} \ No newline at end of file +} diff --git a/packages/example-app/src/js/file-transfer-app.js b/packages/example-app/src/js/file-transfer-app.js index ded1e2b..b19dec0 100644 --- a/packages/example-app/src/js/file-transfer-app.js +++ b/packages/example-app/src/js/file-transfer-app.js @@ -335,12 +335,35 @@ window.customElements.define( } } + async saveSelectedFile(file) { + if (!file) return; + + const base64 = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result; + resolve(result.split(',')[1]); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + + const savedFile = await Filesystem.writeFile({ + path: file.name, + data: base64, + directory: Directory.Cache, + }); + + return savedFile.uri; + } + async handleUpload() { try { const url = this.getUploadUrl(); const file = this.shadowRoot.querySelector('#fileInput').files[0]; + const fileUrl = await this.saveSelectedFile(file); - if (!url || !file) { + if (!url || !fileUrl) { this.showError('Please provide both URL and file'); return; } @@ -354,7 +377,7 @@ window.customElements.define( // Upload file const result = await FileTransfer.uploadFile({ url, - path: `${Capacitor.getPlatform() === 'web' ? '' : '/storage/emulated/0/Download/'}` + file.name, + path: `${Capacitor.getPlatform() === 'web' ? '' : fileUrl}`, blob: file, progress: uploadProgress.checked, }); @@ -404,4 +427,4 @@ window.customElements.define( this.shadowRoot.querySelector('#response').textContent = `Error: ${message}`; } } -); \ No newline at end of file +);