Skip to content

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

Open
wants to merge 1 commit into
base: feat/RMET-4046/implementation
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions packages/capacitor-plugin/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -67,4 +64,4 @@ captures
#*.jks

# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.externalNativeBuild
4 changes: 1 addition & 3 deletions packages/capacitor-plugin/CapacitorFileTransfer.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions packages/capacitor-plugin/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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", branch: "main")
],
targets: [
.binaryTarget(
name: "IONFileTransferLib",
// url: "https://github.com/ionic-team/ion-ios-filetransfer/releases/download/1.0.0/IONFileTransferLib.zip",
// checksum: "<compute_checksum>" // sha-256
path: "./ios/Sources/FileTransferPlugin/IONFileTransferLib.xcframework"
path: "./ios/Sources/FileTransferPlugin/IONFileTransferLib.zip"

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?

Copy link

@alexgerardojacinto alexgerardojacinto Apr 25, 2025

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?

),
.target(
name: "FileTransferPlugin",
Expand Down
2 changes: 1 addition & 1 deletion packages/capacitor-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<PluginListenerHandle>
addListener(eventName: "progress", listenerFunc: (progress: ProgressStatus) => void) => Promise<PluginListenerHandle>
```

Add a listener to file transfer (download or upload) progress events.
Expand Down

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 {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use a switch statement instead?

if shouldTrackProgress {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use a guard instead of this if to avoid nesting, like:

guard shouldTrackProgress else { return }

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)
}
}
Binary file not shown.
Loading
Loading