From 5cadb6c9cdd989d8a77f715fe579e61a0e483e1f Mon Sep 17 00:00:00 2001 From: Jan Bayer Date: Mon, 9 Feb 2026 10:54:20 +0100 Subject: [PATCH 1/3] feat(android): Implement headers in return object from downloadFile call on android --- .../capacitorjs/plugins/filetransfer/FileTransferPlugin.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/capacitor-plugin/android/src/main/java/com/capacitorjs/plugins/filetransfer/FileTransferPlugin.kt b/packages/capacitor-plugin/android/src/main/java/com/capacitorjs/plugins/filetransfer/FileTransferPlugin.kt index 0388462..8027981 100644 --- a/packages/capacitor-plugin/android/src/main/java/com/capacitorjs/plugins/filetransfer/FileTransferPlugin.kt +++ b/packages/capacitor-plugin/android/src/main/java/com/capacitorjs/plugins/filetransfer/FileTransferPlugin.kt @@ -185,6 +185,13 @@ class FileTransferPlugin : Plugin() { val response = JSObject().apply { put("path", filePath) + result.data.headers?.let { headers -> + val headersObject = JSObject() + for (header in headers) { + headersObject.put(header.key, JSArray(header.value)) + } + put("headers", headersObject); + } } call.resolve(response) } From d1267db16a8f5febadfb1e879a63c8288d9e2983 Mon Sep 17 00:00:00 2001 From: Jan Bayer Date: Mon, 9 Feb 2026 10:55:12 +0100 Subject: [PATCH 2/3] feat(android): Allow specifying body in downloadFile on android --- .../filetransfer/FileTransferPlugin.kt | 4 +- .../filetransfer/RequestBodyTransform.kt | 114 ++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 packages/capacitor-plugin/android/src/main/java/com/capacitorjs/plugins/filetransfer/RequestBodyTransform.kt diff --git a/packages/capacitor-plugin/android/src/main/java/com/capacitorjs/plugins/filetransfer/FileTransferPlugin.kt b/packages/capacitor-plugin/android/src/main/java/com/capacitorjs/plugins/filetransfer/FileTransferPlugin.kt index 8027981..2fc7784 100644 --- a/packages/capacitor-plugin/android/src/main/java/com/capacitorjs/plugins/filetransfer/FileTransferPlugin.kt +++ b/packages/capacitor-plugin/android/src/main/java/com/capacitorjs/plugins/filetransfer/FileTransferPlugin.kt @@ -6,6 +6,7 @@ import android.media.MediaScannerConnection import android.os.Build import android.os.Environment import androidx.core.net.toUri +import com.getcapacitor.JSArray import com.getcapacitor.JSObject import com.getcapacitor.PermissionState import com.getcapacitor.Plugin @@ -155,7 +156,8 @@ class FileTransferPlugin : Plugin() { val options = IONFLTRDownloadOptions( url = url, filePath = filePath, - httpOptions = httpOptions + httpOptions = httpOptions, + body = getRequestBody(call, httpOptions) ) controller.downloadFile(options) diff --git a/packages/capacitor-plugin/android/src/main/java/com/capacitorjs/plugins/filetransfer/RequestBodyTransform.kt b/packages/capacitor-plugin/android/src/main/java/com/capacitorjs/plugins/filetransfer/RequestBodyTransform.kt new file mode 100644 index 0000000..78dd6c3 --- /dev/null +++ b/packages/capacitor-plugin/android/src/main/java/com/capacitorjs/plugins/filetransfer/RequestBodyTransform.kt @@ -0,0 +1,114 @@ +package com.capacitorjs.plugins.filetransfer + +import android.os.Build +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import com.getcapacitor.JSValue +import com.getcapacitor.PluginCall +import io.ionic.libs.ionfiletransferlib.model.IONFLTRTransferHttpOptions +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.Base64 + +// This file is basically a conversion of https://github.com/ionic-team/capacitor/blob/main/android/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java#L192 +// to Kotlin and returning a ByteArray instead of directly writing into the output stream + +fun getRequestBody(call: PluginCall, http: IONFLTRTransferHttpOptions): ByteArray? { + val contentType = http.headers["Content-Type"] + if (contentType.isNullOrBlank()) return null + + val method = http.method + val isHttpMutate = + method == "DELETE" || method == "PATCH" || method == "POST" || method == "PUT" + if (!isHttpMutate) return null + + val body = JSValue(call, "data") + val bodyType = call.getString("dataType") + + if (contentType.contains("application/json")) { + return stringToRequestBody(body.toString()) + } else if (bodyType != null && bodyType == "file") { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Base64.getDecoder().decode(body.toString()) + } else { + android.util.Base64.decode(body.toString(), android.util.Base64.DEFAULT) + } + } else if (contentType.contains("application/x-www-form-urlencoded")) { + try { + val obj = body.toJSObject() + return objectToRequestBody(obj) + } catch (e: Exception) { + return stringToRequestBody(body.toString()) + } + } else if (bodyType != null && bodyType == "formData") { + return formDataToRequestBody(contentType, body.toJSArray()) + } else { + return stringToRequestBody(body.toString()) + } +} + +fun stringToRequestBody(from: String): ByteArray { + return from.toByteArray(Charsets.UTF_8) +} + +fun objectToRequestBody(from: JSObject): ByteArray { + val bytes = ByteArrayOutputStream() + DataOutputStream(bytes).use { os -> + val keys = from.keys() + for (key in keys) { + val d = from.get(key) + os.writeBytes(URLEncoder.encode(key, "UTF-8")) + os.writeBytes("=") + os.writeBytes(URLEncoder.encode(d.toString(), "UTF-8")) + + if (keys.hasNext()) { + os.writeBytes("&") + } + } + } + return bytes.toByteArray() +} + +fun formDataToRequestBody(contentType: String, entries: JSArray): ByteArray { + val bytes = ByteArrayOutputStream() + DataOutputStream(bytes).use { os -> + val boundary = contentType.split(";")[1].split("=")[1] + val lineEnd = "\r\n" + val twoHyphens = "--" + + for (e in entries.toList()) { + if (e is JSONObject) { + val type = e.getString("type") + val key = e.getString("key") + val value = e.getString("value") + if (type == "string") { + os.writeBytes(twoHyphens + boundary + lineEnd) + os.writeBytes("Content-Disposition: form-data; name=\"$key\"$lineEnd$lineEnd") + os.write(value.toByteArray(StandardCharsets.UTF_8)) + os.writeBytes(lineEnd) + } else if (type == "base64File") { + val fileName = e.getString("fileName") + val fileContentType = e.getString("contentType") + + os.writeBytes(twoHyphens + boundary + lineEnd) + os.writeBytes("Content-Disposition: form-data; name=\"$key\"; filename=\"$fileName\"$lineEnd") + os.writeBytes("Content-Type: $fileContentType$lineEnd") + os.writeBytes("Content-Transfer-Encoding: binary$lineEnd") + os.writeBytes(lineEnd) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + os.write(Base64.getDecoder().decode(value)) + } else { + os.write(android.util.Base64.decode(value, android.util.Base64.DEFAULT)) + } + + os.writeBytes(lineEnd) + } + } + } + } + return bytes.toByteArray() +} \ No newline at end of file From 07de832ec8c394ae226a18580171ff5b0e8edefa Mon Sep 17 00:00:00 2001 From: Jan Bayer Date: Mon, 9 Feb 2026 13:38:26 +0100 Subject: [PATCH 3/3] feat(ios): Include headers in response from iOS and include request body --- .../FileTransferPlugin.swift | 7 +- .../RequestBodyTransform.swift | 215 ++++++++++++++++++ 2 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 packages/capacitor-plugin/ios/Sources/FileTransferPlugin/RequestBodyTransform.swift diff --git a/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/FileTransferPlugin.swift b/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/FileTransferPlugin.swift index 011e2ea..1ed27a5 100644 --- a/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/FileTransferPlugin.swift +++ b/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/FileTransferPlugin.swift @@ -32,11 +32,14 @@ public class FileTransferPlugin: CAPPlugin, CAPBridgedPlugin { @objc func downloadFile(_ call: CAPPluginCall) { do { let prepData = try validateAndPrepare(call: call, action: .download) + var httpOptions = prepData.httpOptions + let reqData = try getRequestData(call: call, options: &httpOptions) try manager.downloadFile( fromServerURL: prepData.serverURL, toFileURL: prepData.fileURL, - withHttpOptions: prepData.httpOptions + withHttpOptions: httpOptions, + body: reqData ).sink( receiveCompletion: handleCompletion(call: call, source: prepData.serverURL.absoluteString, target: prepData.fileURL.absoluteString), receiveValue: handleReceiveValue( @@ -225,7 +228,7 @@ public class FileTransferPlugin: CAPPlugin, CAPBridgedPlugin { let result: JSObject = { switch type { case .download: - return ["path": path] + return ["path": path, "headers": data.headers as JSObject] case .upload: return [ "bytesSent": data.totalBytes, diff --git a/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/RequestBodyTransform.swift b/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/RequestBodyTransform.swift new file mode 100644 index 0000000..8ff4819 --- /dev/null +++ b/packages/capacitor-plugin/ios/Sources/FileTransferPlugin/RequestBodyTransform.swift @@ -0,0 +1,215 @@ +import Capacitor +import IONFileTransferLib + +// Basically a reimplementation of certain parts of CapacitorUrlRequest +// that does not need to be used with the latter. + +public enum RequestBodyError: Error { + case serializationError(String?) +} + +public struct RequestData { + let body: Data + let additionalHeaders: [String: String] + + init(body: Data) { + self.body = body + self.additionalHeaders = [:] + } + init(body: Data, additionalHeaders: [String: String]) { + self.body = body + self.additionalHeaders = additionalHeaders + } +} + +public func getRequestData( + call: CAPPluginCall, + options: inout IONFLTRHttpOptions +) throws -> Data? { + guard let body = call.options["data"] as? JSValue else { + return nil + } + let bodyType = call.getString("dataType") + guard let contentType = options.headers["Content-Type"] else { + return nil + } + guard let reqData = try getRequestData(body, contentType, bodyType) else { + return nil + } + + options.headers.merge(reqData.additionalHeaders, uniquingKeysWith: { _, new in new }) + + return reqData.body +} + +public func getRequestData( + _ body: JSValue, + _ contentType: String, + _ dataType: String? = nil +) throws -> RequestData? { + if dataType == "file" { + guard let stringData = body as? String else { + throw RequestBodyError.serializationError( + "[ data ] argument could not be parsed as string" + ) + } + guard let data = Data(base64Encoded: stringData) else {return nil} + return RequestData(body: data) + } else if dataType == "formData" { + return try getRequestDataFromFormData(body, contentType) + } + + // If data can be parsed directly as a string, return that without processing. + if let strVal = try? getRequestDataAsString(body) { + return strVal + } else if contentType.contains("application/json") { + return try getRequestDataAsJson(body) + } else if contentType.contains("application/x-www-form-urlencoded") { + return try getRequestDataAsFormUrlEncoded(body) + } else if contentType.contains("multipart/form-data") { + return try getRequestDataAsMultipartFormData(body, contentType) + } else { + throw RequestBodyError.serializationError( + "[ data ] argument could not be parsed for content type [ \(contentType) ]" + ) + } +} + +public func getRequestDataAsString(_ data: JSValue) throws -> RequestData { + guard let stringData = data as? String else { + throw RequestBodyError.serializationError( + "[ data ] argument could not be parsed as string" + ) + } + return RequestData(body: Data(stringData.utf8)) +} + +public func getRequestDataFromFormData(_ data: JSValue, _ contentType: String) + throws -> RequestData? +{ + guard let list = data as? JSArray else { + // Throw, other data types explicitly not supported. + throw RequestBodyError.serializationError( + "Data must be an array for FormData" + ) + } + var requestHeaders: [String: String] = [:] + var data = Data() + var boundary = UUID().uuidString + if contentType.contains("="), + let contentBoundary = contentType.components(separatedBy: "=").last + { + boundary = contentBoundary + } else { + let contentType = "multipart/form-data; boundary=\(boundary)" + requestHeaders["Content-Type"] = contentType + } + for entry in list { + guard let item = entry as? [String: String] else { + throw RequestBodyError.serializationError( + "Data must be an array for FormData" + ) + } + + let type = item["type"] + let key = item["key"] + let value = item["value"]! + + if type == "base64File" { + let fileName = item["fileName"] + let fileContentType = item["contentType"] + + data.append("--\(boundary)\r\n".data(using: .utf8)!) + data.append( + "Content-Disposition: form-data; name=\"\(key!)\"; filename=\"\(fileName!)\"\r\n" + .data(using: .utf8)! + ) + data.append( + "Content-Type: \(fileContentType!)\r\n".data(using: .utf8)! + ) + data.append( + "Content-Transfer-Encoding: binary\r\n".data(using: .utf8)! + ) + data.append("\r\n".data(using: .utf8)!) + + data.append(Data(base64Encoded: value)!) + + data.append("\r\n".data(using: .utf8)!) + } else if type == "string" { + data.append("--\(boundary)\r\n".data(using: .utf8)!) + data.append( + "Content-Disposition: form-data; name=\"\(key!)\"\r\n".data( + using: .utf8 + )! + ) + data.append("\r\n".data(using: .utf8)!) + data.append(value.data(using: .utf8)!) + data.append("\r\n".data(using: .utf8)!) + } + } + data.append("--\(boundary)--\r\n".data(using: .utf8)!) + + return RequestData.init(body: data, additionalHeaders: requestHeaders) +} + +public func getRequestDataAsJson(_ data: JSValue) throws -> RequestData? { + // We need to check if the JSON is valid before attempting to serialize, as JSONSerialization.data will not throw an exception that can be caught, and will cause the application to crash if it fails. + if JSONSerialization.isValidJSONObject(data) { + return RequestData(body: try JSONSerialization.data(withJSONObject: data)) + } else { + throw RequestBodyError.serializationError("[ data ] argument for request of content-type [ application/json ] must be serializable to JSON") + } +} + +public func getRequestDataAsFormUrlEncoded(_ data: JSValue) throws -> RequestData? { + var components = URLComponents() + components.queryItems = [] + + guard let obj = data as? JSObject else { + // Throw, other data types explicitly not supported + throw RequestBodyError.serializationError("[ data ] argument for request with content-type [ multipart/form-data ] may only be a plain javascript object") + } + + let allowed = CharacterSet(charactersIn: "-._*").union(.alphanumerics) + + obj.keys.forEach { (key: String) in + let value = obj[key] as? String ?? "" + components.queryItems?.append(URLQueryItem(name: key.addingPercentEncoding(withAllowedCharacters: allowed)?.replacingOccurrences(of: "%20", with: "+") ?? key, value: value.addingPercentEncoding(withAllowedCharacters: allowed)?.replacingOccurrences(of: "%20", with: "+"))) + } + + if components.query != nil { + return RequestData(body: Data(components.query!.utf8)) + } + + return nil + } + + public func getRequestDataAsMultipartFormData(_ data: JSValue, _ contentType: String) throws -> RequestData { + guard let obj = data as? JSObject else { + // Throw, other data types explicitly not supported. + throw RequestBodyError.serializationError("[ data ] argument for request with content-type [ application/x-www-form-urlencoded ] may only be a plain javascript object") + } + + var additionalHeaders: [String: String] = [:] + + let strings: [String: String] = obj.compactMapValues { any in + any as? String + } + + var data = Data() + var boundary = UUID().uuidString + if contentType.contains("="), let contentBoundary = contentType.components(separatedBy: "=").last { + boundary = contentBoundary + } else { + let contentType = "multipart/form-data; boundary=\(boundary)" + additionalHeaders["Content-Type"] = contentType + } + strings.forEach { key, value in + data.append("\r\n--\(boundary)\r\n".data(using: .utf8)!) + data.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!) + data.append(value.data(using: .utf8)!) + } + data.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + + return RequestData(body: data, additionalHeaders: additionalHeaders) + }