diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000000..495c5038ea --- /dev/null +++ b/android/build.gradle @@ -0,0 +1 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java index 6f04cc30ef..58931780a2 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java +++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/CapacitorHttpUrlConnection.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.zip.GZIPOutputStream; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLSocketFactory; import org.json.JSONException; @@ -191,6 +192,7 @@ public void setRequestBody(PluginCall call, JSValue body) throws JSONException, */ public void setRequestBody(PluginCall call, JSValue body, String bodyType) throws JSONException, IOException { String contentType = connection.getRequestProperty("Content-Type"); + Boolean gzipCompression = call.getBoolean("gzipCompression", false); String dataString = ""; if (contentType == null || contentType.isEmpty()) return; @@ -207,7 +209,7 @@ public void setRequestBody(PluginCall call, JSValue body, String bodyType) throw } else if (body == null) { dataString = call.getString("data"); } - this.writeRequestBody(dataString != null ? dataString : ""); + this.writeRequestBody(dataString != null ? dataString : "", gzipCompression); } else if (bodyType != null && bodyType.equals("file")) { try (DataOutputStream os = new DataOutputStream(connection.getOutputStream())) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -221,12 +223,12 @@ public void setRequestBody(PluginCall call, JSValue body, String bodyType) throw this.writeObjectRequestBody(obj); } catch (Exception e) { // Body is not a valid JSON, treat it as an already formatted string - this.writeRequestBody(body.toString()); + this.writeRequestBody(body.toString(), false); } } else if (bodyType != null && bodyType.equals("formData")) { this.writeFormDataRequestBody(contentType, body.toJSArray()); } else { - this.writeRequestBody(body.toString()); + this.writeRequestBody(body.toString(), gzipCompression); } } @@ -235,10 +237,17 @@ public void setRequestBody(PluginCall call, JSValue body, String bodyType) throw * * @param body The string value to write to the connection stream. */ - private void writeRequestBody(String body) throws IOException { - try (DataOutputStream os = new DataOutputStream(connection.getOutputStream())) { - os.write(body.getBytes(StandardCharsets.UTF_8)); - os.flush(); + private void writeRequestBody(String body, boolean gzipCompression) throws IOException { + if (gzipCompression) { + try (GZIPOutputStream gos = new GZIPOutputStream(connection.getOutputStream())) { + gos.write(body.getBytes(StandardCharsets.UTF_8)); + gos.flush(); + } + } else { + try (DataOutputStream dos = new DataOutputStream(connection.getOutputStream())) { + dos.write(body.getBytes(StandardCharsets.UTF_8)); + dos.flush(); + } } } diff --git a/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java index 4bd5df66de..29043084ef 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java +++ b/android/capacitor/src/main/java/com/getcapacitor/plugin/util/HttpRequestHandler.java @@ -382,6 +382,7 @@ public static JSObject request(PluginCall call, String httpMethod, Bridge bridge Integer readTimeout = call.getInt("readTimeout"); Boolean disableRedirects = call.getBoolean("disableRedirects"); Boolean shouldEncode = call.getBoolean("shouldEncodeUrlParams", true); + Boolean gzipCompression = call.getBoolean("gzipCompression", false); ResponseType responseType = ResponseType.parse(call.getString("responseType")); String dataType = call.getString("dataType"); @@ -389,6 +390,11 @@ public static JSObject request(PluginCall call, String httpMethod, Bridge bridge boolean isHttpMutate = method.equals("DELETE") || method.equals("PATCH") || method.equals("POST") || method.equals("PUT"); + // Set gzip header if compression is enabled + if (gzipCompression) { + headers.put("Content-Encoding", "gzip"); + } + URL url = new URL(urlString); HttpURLConnectionBuilder connectionBuilder = new HttpURLConnectionBuilder() .setUrl(url) diff --git a/core/src/core-plugins.ts b/core/src/core-plugins.ts index 8d39acfed6..cf38439497 100644 --- a/core/src/core-plugins.ts +++ b/core/src/core-plugins.ts @@ -1,3 +1,5 @@ +import * as Pako from 'pako'; + import type { Plugin } from './definitions'; import { registerPlugin } from './global'; import { WebPlugin } from './web-plugin'; @@ -208,6 +210,11 @@ export interface HttpOptions { * special handling in the native layer */ dataType?: 'file' | 'formData'; + /** + * Use this option if you need a gzip compression of the data payload + * A compatible consumer interface must be ensured. The default is _false_. + */ + gzipCompression?: boolean; } export interface HttpParams { @@ -318,7 +325,15 @@ export const buildRequestInit = ( // If body is already a string, then pass it through as-is. if (typeof options.data === 'string') { - output.body = options.data; + if (options?.gzipCompression && options.headers) { + options.headers['Content-Encoding'] = 'gzip'; + output.headers = options.headers; + + const gzippedData: Uint8Array = Pako.gzip(options.data); + output.body = gzippedData.buffer; + } else { + output.body = options.data; + } } // Build request initializers based off of content-type else if (type.includes('application/x-www-form-urlencoded')) { diff --git a/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj b/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj index 31e0252124..76637eaf1e 100644 --- a/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj +++ b/ios/Capacitor/Capacitor.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 501CBAA71FC0A723009B0D4D /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 501CBAA61FC0A723009B0D4D /* WebKit.framework */; }; 50503EE91FC08595003606DC /* Capacitor.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50503EDF1FC08594003606DC /* Capacitor.framework */; }; 50503EEE1FC08595003606DC /* CapacitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50503EED1FC08595003606DC /* CapacitorTests.swift */; }; + 5405D8B329C0949F008F945E /* Data+Gzip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5405D8B129C0949F008F945E /* Data+Gzip.swift */; }; 6214934725509C3F006C36F9 /* CAPInstanceConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6214934625509C3F006C36F9 /* CAPInstanceConfiguration.swift */; }; 621ECCB72542045900D3D615 /* CAPBridgedJSTypes.m in Sources */ = {isa = PBXBuildFile; fileRef = 621ECCB42542045900D3D615 /* CAPBridgedJSTypes.m */; }; 621ECCB82542045900D3D615 /* CAPBridgedJSTypes.h in Headers */ = {isa = PBXBuildFile; fileRef = 621ECCB62542045900D3D615 /* CAPBridgedJSTypes.h */; settings = {ATTRIBUTES = (Private, ); }; }; @@ -160,6 +161,7 @@ 50503EDF1FC08594003606DC /* Capacitor.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Capacitor.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 50503EE81FC08595003606DC /* CapacitorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CapacitorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 50503EED1FC08595003606DC /* CapacitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapacitorTests.swift; sourceTree = ""; }; + 5405D8B129C0949F008F945E /* Data+Gzip.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Gzip.swift"; sourceTree = ""; }; 6214934625509C3F006C36F9 /* CAPInstanceConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CAPInstanceConfiguration.swift; sourceTree = ""; }; 621ECCB42542045900D3D615 /* CAPBridgedJSTypes.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CAPBridgedJSTypes.m; sourceTree = ""; }; 621ECCB62542045900D3D615 /* CAPBridgedJSTypes.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CAPBridgedJSTypes.h; sourceTree = ""; }; @@ -408,6 +410,7 @@ 62959AEA2524DA7700A3D7F1 /* Plugins */ = { isa = PBXGroup; children = ( + 5405D8B129C0949F008F945E /* Data+Gzip.swift */, A327E6B428DB8B2900CA8B0A /* CapacitorHttp.swift */, A327E6B528DB8B2900CA8B0A /* CapacitorUrlRequest.swift */, A327E6B228DB8B2800CA8B0A /* HttpRequestHandler.swift */, @@ -692,6 +695,7 @@ 62959B362524DA7800A3D7F1 /* CAPBridgeViewController.swift in Sources */, 621ECCB72542045900D3D615 /* CAPBridgedJSTypes.m in Sources */, 62959B402524DA7800A3D7F1 /* TmpViewController.swift in Sources */, + 5405D8B329C0949F008F945E /* Data+Gzip.swift in Sources */, 621ECCD6254205BD00D3D615 /* CAPBridgeProtocol.swift in Sources */, 62D43AF02581817500673C24 /* WKWebView+Capacitor.swift in Sources */, 62959B432524DA7800A3D7F1 /* Data+Capacitor.swift in Sources */, diff --git a/ios/Capacitor/Capacitor/Plugins/CapacitorUrlRequest.swift b/ios/Capacitor/Capacitor/Plugins/CapacitorUrlRequest.swift index 51331e3be7..7b56635628 100644 --- a/ios/Capacitor/Capacitor/Plugins/CapacitorUrlRequest.swift +++ b/ios/Capacitor/Capacitor/Plugins/CapacitorUrlRequest.swift @@ -185,11 +185,24 @@ open class CapacitorUrlRequest: NSObject, URLSessionTaskDelegate { } } - public func setRequestBody(_ body: JSValue, _ dataType: String? = nil) throws { + private func getRequestDataAsGzip(_ body: JSValue) throws -> Data? { + // string to Data + let dataBody = try getRequestDataAsString(body) + + // gzip compression + let compressedData: Data = try dataBody.gzipped() + return compressedData + } + + public func setRequestBody(_ body: JSValue, _ gzipCompression: Bool, _ dataType: String? = nil) throws { let contentType = self.getRequestHeader("Content-Type") as? String if contentType != nil { - request.httpBody = try getRequestData(body, contentType!, dataType) + if gzipCompression { + request.httpBody = try getRequestDataAsGzip(body) + } else { + request.httpBody = try getRequestData(body, contentType!, dataType) + } } } diff --git a/ios/Capacitor/Capacitor/Plugins/Data+Gzip.swift b/ios/Capacitor/Capacitor/Plugins/Data+Gzip.swift new file mode 100644 index 0000000000..1520f6137f --- /dev/null +++ b/ios/Capacitor/Capacitor/Plugins/Data+Gzip.swift @@ -0,0 +1,281 @@ +// +// Data+Gzip.swift +// + +/* + The MIT License (MIT) + + © 2014-2020 1024jp + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import struct Foundation.Data + +#if os(Linux) +import zlibLinux +#else +import zlib +#endif + +/// Compression level whose rawValue is based on the zlib's constants. +public struct CompressionLevel: RawRepresentable { + + /// Compression level in the range of `0` (no compression) to `9` (maximum compression). + public let rawValue: Int32 + + public static let noCompression = CompressionLevel(Z_NO_COMPRESSION) + public static let bestSpeed = CompressionLevel(Z_BEST_SPEED) + public static let bestCompression = CompressionLevel(Z_BEST_COMPRESSION) + + public static let defaultCompression = CompressionLevel(Z_DEFAULT_COMPRESSION) + + public init(rawValue: Int32) { + + self.rawValue = rawValue + } + + public init(_ rawValue: Int32) { + + self.rawValue = rawValue + } + +} + +/// Errors on gzipping/gunzipping based on the zlib error codes. +public struct GzipError: Swift.Error { + // cf. http://www.zlib.net/manual.html + + public enum Kind: Equatable { + /// The stream structure was inconsistent. + /// + /// - underlying zlib error: `Z_STREAM_ERROR` (-2) + case stream + + /// The input data was corrupted + /// (input stream not conforming to the zlib format or incorrect check value). + /// + /// - underlying zlib error: `Z_DATA_ERROR` (-3) + case data + + /// There was not enough memory. + /// + /// - underlying zlib error: `Z_MEM_ERROR` (-4) + case memory + + /// No progress is possible or there was not enough room in the output buffer. + /// + /// - underlying zlib error: `Z_BUF_ERROR` (-5) + case buffer + + /// The zlib library version is incompatible with the version assumed by the caller. + /// + /// - underlying zlib error: `Z_VERSION_ERROR` (-6) + case version + + /// An unknown error occurred. + /// + /// - parameter code: return error by zlib + case unknown(code: Int) + } + + /// Error kind. + public let kind: Kind + + /// Returned message by zlib. + public let message: String + + internal init(code: Int32, msg: UnsafePointer?) { + + self.message = { + guard let msg = msg, let message = String(validatingUTF8: msg) else { + return "Unknown gzip error" + } + return message + }() + + self.kind = { + switch code { + case Z_STREAM_ERROR: + return .stream + case Z_DATA_ERROR: + return .data + case Z_MEM_ERROR: + return .memory + case Z_BUF_ERROR: + return .buffer + case Z_VERSION_ERROR: + return .version + default: + return .unknown(code: Int(code)) + } + }() + } + + public var localizedDescription: String { + + return self.message + } + +} + +extension Data { + + /// Whether the receiver is compressed in gzip format. + public var isGzipped: Bool { + + return self.starts(with: [0x1f, 0x8b]) // check magic number + } + + /// Create a new `Data` instance by compressing the receiver using zlib. + /// Throws an error if compression failed. + /// + /// - Parameter level: Compression level. + /// - Returns: Gzip-compressed `Data` instance. + /// - Throws: `GzipError` + public func gzipped(level: CompressionLevel = .defaultCompression) throws -> Data { + + guard !self.isEmpty else { + return Data() + } + + var stream = z_stream() + var status: Int32 + + status = deflateInit2_(&stream, level.rawValue, Z_DEFLATED, MAX_WBITS + 16, MAX_MEM_LEVEL, Z_DEFAULT_STRATEGY, ZLIB_VERSION, Int32(DataSize.stream)) + + guard status == Z_OK else { + // deflateInit2 returns: + // Z_VERSION_ERROR The zlib library version is incompatible with the version assumed by the caller. + // Z_MEM_ERROR There was not enough memory. + // Z_STREAM_ERROR A parameter is invalid. + + throw GzipError(code: status, msg: stream.msg) + } + + var data = Data(capacity: DataSize.chunk) + repeat { + if Int(stream.total_out) >= data.count { + data.count += DataSize.chunk + } + + let inputCount = self.count + let outputCount = data.count + + self.withUnsafeBytes { (inputPointer: UnsafeRawBufferPointer) in + stream.next_in = UnsafeMutablePointer(mutating: inputPointer.bindMemory(to: Bytef.self).baseAddress!).advanced(by: Int(stream.total_in)) + stream.avail_in = uint(inputCount) - uInt(stream.total_in) + + data.withUnsafeMutableBytes { (outputPointer: UnsafeMutableRawBufferPointer) in + stream.next_out = outputPointer.bindMemory(to: Bytef.self).baseAddress!.advanced(by: Int(stream.total_out)) + stream.avail_out = uInt(outputCount) - uInt(stream.total_out) + + status = deflate(&stream, Z_FINISH) + + stream.next_out = nil + } + + stream.next_in = nil + } + + } while stream.avail_out == 0 + + guard deflateEnd(&stream) == Z_OK, status == Z_STREAM_END else { + throw GzipError(code: status, msg: stream.msg) + } + + data.count = Int(stream.total_out) + + return data + } + + /// Create a new `Data` instance by decompressing the receiver using zlib. + /// Throws an error if decompression failed. + /// + /// - Returns: Gzip-decompressed `Data` instance. + /// - Throws: `GzipError` + public func gunzipped() throws -> Data { + + guard !self.isEmpty else { + return Data() + } + + var stream = z_stream() + var status: Int32 + + status = inflateInit2_(&stream, MAX_WBITS + 32, ZLIB_VERSION, Int32(DataSize.stream)) + + guard status == Z_OK else { + // inflateInit2 returns: + // Z_VERSION_ERROR The zlib library version is incompatible with the version assumed by the caller. + // Z_MEM_ERROR There was not enough memory. + // Z_STREAM_ERROR A parameters are invalid. + + throw GzipError(code: status, msg: stream.msg) + } + + var data = Data(capacity: self.count * 2) + repeat { + if Int(stream.total_out) >= data.count { + data.count += self.count / 2 + } + + let inputCount = self.count + let outputCount = data.count + + self.withUnsafeBytes { (inputPointer: UnsafeRawBufferPointer) in + stream.next_in = UnsafeMutablePointer(mutating: inputPointer.bindMemory(to: Bytef.self).baseAddress!).advanced(by: Int(stream.total_in)) + stream.avail_in = uint(inputCount) - uInt(stream.total_in) + + data.withUnsafeMutableBytes { (outputPointer: UnsafeMutableRawBufferPointer) in + stream.next_out = outputPointer.bindMemory(to: Bytef.self).baseAddress!.advanced(by: Int(stream.total_out)) + stream.avail_out = uInt(outputCount) - uInt(stream.total_out) + + status = inflate(&stream, Z_SYNC_FLUSH) + + stream.next_out = nil + } + + stream.next_in = nil + } + + } while status == Z_OK + + guard inflateEnd(&stream) == Z_OK, status == Z_STREAM_END else { + // inflate returns: + // Z_DATA_ERROR The input data was corrupted (input stream not conforming to the zlib format or incorrect check value). + // Z_STREAM_ERROR The stream structure was inconsistent (for example if next_in or next_out was NULL). + // Z_MEM_ERROR There was not enough memory. + // Z_BUF_ERROR No progress is possible or there was not enough room in the output buffer when Z_FINISH is used. + + throw GzipError(code: status, msg: stream.msg) + } + + data.count = Int(stream.total_out) + + return data + } + +} + +private enum DataSize { + + static let chunk = 1 << 14 + static let stream = MemoryLayout.size +} diff --git a/ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift b/ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift index ea990d3cc1..52c876465f 100644 --- a/ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift +++ b/ios/Capacitor/Capacitor/Plugins/HttpRequestHandler.swift @@ -146,12 +146,13 @@ open class HttpRequestHandler { guard var urlString = call.getString("url") else { throw URLError(.badURL) } let method = httpMethod ?? call.getString("method", "GET") - let headers = (call.getObject("headers") ?? [:]) as [String: Any] + var headers = (call.getObject("headers") ?? [:]) as [String: Any] let params = (call.getObject("params") ?? [:]) as [String: Any] let responseType = call.getString("responseType") ?? "text" let connectTimeout = call.getDouble("connectTimeout") let readTimeout = call.getDouble("readTimeout") let dataType = call.getString("dataType") ?? "any" + let gzipCompression = call.getBool("gzipCompression") ?? false if urlString == urlString.removingPercentEncoding { guard let encodedUrlString = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { throw URLError(.badURL) } @@ -165,6 +166,10 @@ open class HttpRequestHandler { .openConnection() .build() + if gzipCompression { + headers["Content-Encoding"] = "gzip" + } + request.setRequestHeaders(headers) // Timeouts in iOS are in seconds. So read the value in millis and divide by 1000 @@ -173,7 +178,7 @@ open class HttpRequestHandler { if let data = call.options["data"] as? JSValue { do { - try request.setRequestBody(data, dataType) + try request.setRequestBody(data, gzipCompression, dataType) } catch { // Explicitly reject if the http request body was not set successfully, // so as to not send a known malformed request, and to provide the developer with additional context. diff --git a/package.json b/package.json index d542272ab4..dfa0ef4623 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,10 @@ "rimraf": "^4.4.1", "semver": "^7.3.7", "swiftlint": "^1.0.1", - "tar": "^6.1.11" + "tar": "^6.1.11", + "@types/pako": "^2.0.0" + }, + "dependencies": { + "pako": "^2.1.0" } }