Skip to content

Commit 4383aef

Browse files
committed
feat: add ios implementation
1 parent 72b291b commit 4383aef

File tree

11 files changed

+355
-36
lines changed

11 files changed

+355
-36
lines changed

packages/capacitor-plugin/.gitignore

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,9 @@ DerivedData/
1616
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
1717
.netrc
1818

19-
2019
# macOS files
2120
.DS_Store
2221

23-
24-
2522
# Based on Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
2623

2724
# Built application files
@@ -67,4 +64,4 @@ captures
6764
#*.jks
6865

6966
# External native build folder generated in Android Studio 2.2 and later
70-
.externalNativeBuild
67+
.externalNativeBuild

packages/capacitor-plugin/CapacitorFileTransfer.podspec

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@ Pod::Spec.new do |s|
1212
s.source = { :git => package['repository']['url'], :tag => s.version.to_s }
1313
s.source_files = 'ios/Sources/FileTransferPlugin/*.{swift,h,m,c,cc,mm,cpp}'
1414
s.ios.deployment_target = '14.0'
15-
#s.dependency 'FileTransferLib', spec='~> 1.0'
16-
# temporary xcframeowrk dependency - TODO update to official pod (commented line above) once published
17-
s.vendored_frameworks = 'ios/Sources/*/IONFileTransferLib.xcframework'
1815
s.dependency 'Capacitor'
16+
s.dependency 'IONFileTransferLib', spec='~> 1.0'
1917
s.swift_version = '5.1'
2018
end

packages/capacitor-plugin/Package.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@ let package = Package(
1010
targets: ["FileTransferPlugin"])
1111
],
1212
dependencies: [
13-
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "7.0.0")
13+
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", branch: "main")
1414
],
1515
targets: [
1616
.binaryTarget(
1717
name: "IONFileTransferLib",
1818
// url: "https://github.com/ionic-team/ion-ios-filetransfer/releases/download/1.0.0/IONFileTransferLib.zip",
1919
// checksum: "<compute_checksum>" // sha-256
20-
path: "./ios/Sources/FileTransferPlugin/IONFileTransferLib.xcframework"
20+
path: "./ios/Sources/FileTransferPlugin/IONFileTransferLib.zip"
2121
),
2222
.target(
2323
name: "FileTransferPlugin",

packages/capacitor-plugin/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Perform an HTTP request to upload a file to a server
6565
### addListener('progress', ...)
6666

6767
```typescript
68-
addListener(eventName: 'progress', listenerFunc: (progress: ProgressStatus) => void) => Promise<PluginListenerHandle>
68+
addListener(eventName: "progress", listenerFunc: (progress: ProgressStatus) => void) => Promise<PluginListenerHandle>
6969
```
7070

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

packages/capacitor-plugin/ios/Sources/FileTransferPlugin/FileTransfer.swift

Lines changed: 0 additions & 8 deletions
This file was deleted.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import Foundation
2+
import IONFileTransferLib
3+
4+
enum FileTransferError: Error {
5+
case invalidParameters
6+
case invalidServerUrl(url: String)
7+
case urlEmpty
8+
case permissionDenied
9+
case fileDoesNotExist(cause: Error?)
10+
case connectionError(cause: Error?)
11+
case notModified(responseCode: Int, responseBody: String?, headers: [String: String]?)
12+
case genericError(cause: Error?)
13+
14+
var code: String {
15+
let code: Int
16+
switch self {
17+
case .invalidParameters: code = 5
18+
case .invalidServerUrl: code = 6
19+
case .urlEmpty: code = 6
20+
case .permissionDenied: code = 7
21+
case .fileDoesNotExist: code = 8
22+
case .connectionError: code = 9
23+
case .notModified: code = 10
24+
case .genericError: code = 11
25+
}
26+
return String(format: "OS-PLUG-FLTR-%04d", code)
27+
}
28+
29+
var description: String {
30+
switch self {
31+
case .invalidParameters: "The method's input parameters aren't valid."
32+
case .invalidServerUrl(url: let url): "Invalid server URL was provided - \(url)"
33+
case .urlEmpty: "URL to connect to is either null or empty."
34+
case .permissionDenied: "Unable to perform operation, user denied permission request."
35+
case .fileDoesNotExist: "Operation failed because file does not exist."
36+
case .connectionError: "Failed to connect to server."
37+
case .notModified: "The server responded with HTTP 304 – Not Modified. If you want to avoid this, check your headers related to HTTP caching."
38+
case .genericError: "The operation failed with an error."
39+
}
40+
}
41+
42+
var cause: Error? {
43+
switch self {
44+
case .invalidParameters: return nil
45+
case .invalidServerUrl: return nil
46+
case .urlEmpty: return nil
47+
case .permissionDenied: return nil
48+
case .fileDoesNotExist(cause: let cause): return cause
49+
case .connectionError(cause: let cause): return cause
50+
case .notModified: return nil
51+
case .genericError(cause: let cause): return cause
52+
}
53+
}
54+
}
55+
56+
extension IONFLTRException {
57+
func toFileTransferError() -> FileTransferError {
58+
switch self {
59+
case .invalidPath(_):
60+
return .invalidParameters
61+
case .emptyURL(_):
62+
return .urlEmpty
63+
case .invalidURL(let url):
64+
return .invalidServerUrl(url: url)
65+
case .fileDoesNotExist(let cause):
66+
return .fileDoesNotExist(cause: cause)
67+
case .cannotCreateDirectory(_, let cause):
68+
return .genericError(cause: cause)
69+
case .httpError(let responseCode, let responseBody, let headers):
70+
return responseCode == 304
71+
? .notModified(
72+
responseCode: responseCode,
73+
responseBody: responseBody,
74+
headers: headers
75+
) : .genericError(cause: nil)
76+
case .connectionError(let cause):
77+
return .connectionError(cause: cause)
78+
case .transferError(let cause):
79+
return .genericError(cause: cause)
80+
case .unknownError(let cause):
81+
return .genericError(cause: cause)
82+
}
83+
}
84+
}
Lines changed: 224 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,236 @@
11
import Foundation
2+
import Combine
23
import Capacitor
4+
import IONFileTransferLib
35

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.
815
@objc(FileTransferPlugin)
916
public class FileTransferPlugin: CAPPlugin, CAPBridgedPlugin {
1017
public let identifier = "FileTransferPlugin"
1118
public let jsName = "FileTransfer"
1219
public let pluginMethods: [CAPPluginMethod] = [
13-
CAPPluginMethod(name: "echo", returnType: CAPPluginReturnPromise)
20+
.init(selector: #selector(downloadFile), returnType: CAPPluginReturnPromise),
21+
.init(selector: #selector(uploadFile), returnType: CAPPluginReturnPromise)
1422
]
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+
}
16145

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

0 commit comments

Comments
 (0)