-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add ios implementation #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/RMET-4046/implementation
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
import Foundation | ||
import IONFileTransferLib | ||
|
||
enum FileTransferError: Error { | ||
case invalidParameters | ||
case invalidServerUrl(url: String) | ||
case urlEmpty | ||
case permissionDenied | ||
case fileDoesNotExist(cause: Error?) | ||
case connectionError(cause: Error?) | ||
case notModified(responseCode: Int, responseBody: String?, headers: [String: String]?) | ||
case genericError(cause: Error?) | ||
|
||
var code: String { | ||
let code: Int | ||
switch self { | ||
case .invalidParameters: code = 5 | ||
case .invalidServerUrl: code = 6 | ||
case .urlEmpty: code = 6 | ||
case .permissionDenied: code = 7 | ||
case .fileDoesNotExist: code = 8 | ||
case .connectionError: code = 9 | ||
case .notModified: code = 10 | ||
case .genericError: code = 11 | ||
} | ||
return String(format: "OS-PLUG-FLTR-%04d", code) | ||
} | ||
|
||
var description: String { | ||
switch self { | ||
case .invalidParameters: "The method's input parameters aren't valid." | ||
case .invalidServerUrl(url: let url): "Invalid server URL was provided - \(url)" | ||
case .urlEmpty: "URL to connect to is either null or empty." | ||
case .permissionDenied: "Unable to perform operation, user denied permission request." | ||
case .fileDoesNotExist: "Operation failed because file does not exist." | ||
case .connectionError: "Failed to connect to server." | ||
case .notModified: "The server responded with HTTP 304 – Not Modified. If you want to avoid this, check your headers related to HTTP caching." | ||
case .genericError: "The operation failed with an error." | ||
} | ||
} | ||
|
||
var cause: Error? { | ||
switch self { | ||
case .invalidParameters: return nil | ||
case .invalidServerUrl: return nil | ||
case .urlEmpty: return nil | ||
case .permissionDenied: return nil | ||
case .fileDoesNotExist(cause: let cause): return cause | ||
case .connectionError(cause: let cause): return cause | ||
case .notModified: return nil | ||
case .genericError(cause: let cause): return cause | ||
} | ||
} | ||
} | ||
|
||
extension IONFLTRException { | ||
func toFileTransferError() -> FileTransferError { | ||
switch self { | ||
case .invalidPath(_): | ||
return .invalidParameters | ||
case .emptyURL(_): | ||
return .urlEmpty | ||
case .invalidURL(let url): | ||
return .invalidServerUrl(url: url) | ||
case .fileDoesNotExist(let cause): | ||
return .fileDoesNotExist(cause: cause) | ||
case .cannotCreateDirectory(_, let cause): | ||
return .genericError(cause: cause) | ||
case .httpError(let responseCode, let responseBody, let headers): | ||
return responseCode == 304 | ||
? .notModified( | ||
responseCode: responseCode, | ||
responseBody: responseBody, | ||
headers: headers | ||
) : .genericError(cause: nil) | ||
case .connectionError(let cause): | ||
return .connectionError(cause: cause) | ||
case .transferError(let cause): | ||
return .genericError(cause: cause) | ||
case .unknownError(let cause): | ||
return .genericError(cause: cause) | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,23 +1,236 @@ | ||
import Foundation | ||
import Combine | ||
import Capacitor | ||
import IONFileTransferLib | ||
|
||
/** | ||
* 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<AnyCancellable> = [] | ||
|
||
/// 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), | ||
receiveValue: handleReceiveValue( | ||
call: call, | ||
type: .download, | ||
url: serverURL.absoluteString, | ||
path: fileURL.path, | ||
shouldTrackProgress: shouldTrackProgress | ||
) | ||
).store(in: &cancellables) | ||
} catch { | ||
sendError(error, call: call) | ||
} | ||
} | ||
|
||
/// 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), | ||
receiveValue: handleReceiveValue( | ||
call: call, | ||
type: .upload, | ||
url: serverURL.absoluteString, | ||
path: fileURL.path, | ||
shouldTrackProgress: shouldTrackProgress | ||
) | ||
).store(in: &cancellables) | ||
} catch { | ||
sendError(error, call: call) | ||
} | ||
} | ||
|
||
/// 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.urlEmpty | ||
} | ||
|
||
guard let path = call.getString("path") else { | ||
throw FileTransferError.invalidParameters | ||
} | ||
|
||
guard let serverURL = URL(string: url) else { | ||
throw FileTransferError.invalidServerUrl(url: url) | ||
} | ||
|
||
guard let fileURL = URL(string: path) else { | ||
throw FileTransferError.invalidParameters | ||
} | ||
|
||
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", 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] { | ||
var result = [String: String]() | ||
for (key, value) in jsObject { | ||
result[key] = value as? String ?? "" | ||
} | ||
return result | ||
} | ||
|
||
@objc func echo(_ call: CAPPluginCall) { | ||
let value = call.getString("value") ?? "" | ||
call.resolve([ | ||
"value": implementation.echo(value) | ||
]) | ||
/// 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) -> (Subscribers.Completion<Error>) -> Void { | ||
return { completion in | ||
if case let .failure(error) = completion { | ||
self.sendError(error, call: call) | ||
} | ||
} | ||
} | ||
|
||
/// 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 | ||
if case let .ongoing(status) = result { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we use a |
||
if shouldTrackProgress { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could use a
|
||
let progressData: JSObject = [ | ||
"type": type.rawValue, | ||
"url": url, | ||
"bytes": status.bytes, | ||
"contentLength": status.contentLength, | ||
"lengthComputable": status.lengthComputable | ||
] | ||
self.notifyListeners("progress", data: progressData) | ||
} | ||
} else if case let .complete(data) = result { | ||
if type == .download { | ||
let response: JSObject = [ | ||
"path": path | ||
] | ||
call.resolve(response) | ||
} else { | ||
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) | ||
} | ||
} | ||
} | ||
} | ||
|
||
/// Sends an error response back to the JavaScript layer. | ||
/// | ||
/// - Parameters: | ||
/// - error: The error that occurred. | ||
/// - call: The plugin call to reject. | ||
private func sendError(_ error: Error, call: CAPPluginCall) { | ||
let pluginError: FileTransferError | ||
switch error { | ||
case let error as FileTransferError: | ||
pluginError = error | ||
case let error as IONFLTRException: | ||
pluginError = error.toFileTransferError() | ||
default: | ||
pluginError = .genericError(cause: error) | ||
} | ||
call.reject(pluginError.description, pluginError.code, pluginError.cause) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why are we using the .zip instead of the xcframework? Is this temporary?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was trying to build the example-app locally but it fails because of the reference to the IONFileTransferLib library, so it may be related with this.
Do you have an .ipa of the app that I can use to test? Or is there a build of an OutSystems app that I can use?