From 55aab1d2541c0657c0cb4730bc72719831cda36f Mon Sep 17 00:00:00 2001 From: UnschooledGamer Date: Fri, 30 May 2025 21:07:10 +0530 Subject: [PATCH 01/14] feat: :sparkles: Native Websocket Plugin (uses okhttp) --- package-lock.json | 11 ++ package.json | 4 +- src/plugins/websocket/README.md | 86 +++++++++++++ src/plugins/websocket/package.json | 22 ++++ src/plugins/websocket/plugin.xml | 21 ++++ .../src/android/WebSocketInstance.java | 114 ++++++++++++++++++ .../src/android/WebSocketPlugin.java | 63 ++++++++++ src/plugins/websocket/www/websocket.js | 54 +++++++++ 8 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 src/plugins/websocket/README.md create mode 100644 src/plugins/websocket/package.json create mode 100644 src/plugins/websocket/plugin.xml create mode 100644 src/plugins/websocket/src/android/WebSocketInstance.java create mode 100644 src/plugins/websocket/src/android/WebSocketPlugin.java create mode 100644 src/plugins/websocket/www/websocket.js diff --git a/package-lock.json b/package-lock.json index 3b29d793a..22f9578ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "cordova-plugin-server": "file:src/plugins/server", "cordova-plugin-sftp": "file:src/plugins/sftp", "cordova-plugin-system": "file:src/plugins/system", + "cordova-plugin-websocket": "file:src/plugins/websocket", "css-loader": "^7.1.2", "mini-css-extract-plugin": "^2.9.0", "path-browserify": "^1.0.1", @@ -4630,6 +4631,10 @@ "resolved": "src/plugins/system", "link": true }, + "node_modules/cordova-plugin-websocket": { + "resolved": "src/plugins/websocket", + "link": true + }, "node_modules/cordova-serve": { "version": "4.0.1", "license": "Apache-2.0", @@ -10570,6 +10575,12 @@ "version": "1.0.3", "dev": true, "license": "ISC" + }, + "src/plugins/websocket": { + "name": "cordova-plugin-websocket", + "version": "0.0.1", + "dev": true, + "license": "Apache-2.0" } } } diff --git a/package.json b/package.json index 681723b6e..25a4f0911 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "cordova-plugin-system": {}, "cordova-plugin-advanced-http": { "ANDROIDBLACKLISTSECURESOCKETPROTOCOLS": "SSLv3,TLSv1" - } + }, + "cordova-plugin-websocket": {} }, "platforms": [ "android" @@ -74,6 +75,7 @@ "cordova-plugin-server": "file:src/plugins/server", "cordova-plugin-sftp": "file:src/plugins/sftp", "cordova-plugin-system": "file:src/plugins/system", + "cordova-plugin-websocket": "file:src/plugins/websocket", "css-loader": "^7.1.2", "mini-css-extract-plugin": "^2.9.0", "path-browserify": "^1.0.1", diff --git a/src/plugins/websocket/README.md b/src/plugins/websocket/README.md new file mode 100644 index 000000000..4f1c20e33 --- /dev/null +++ b/src/plugins/websocket/README.md @@ -0,0 +1,86 @@ +# Cordova Plugin: OkHttp WebSocket + +A Cordova plugin that uses [OkHttp](https://square.github.io/okhttp/) to provide WebSocket support in your Cordova app. +It aims to mimic the [WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) in JavaScript, with additional features. + +## Features + +* ✅ WebSocket API-like interface +* ✅ Event support: `onopen`, `onmessage`, `onerror`, `onclose` +* ✅ `extensions` and `readyState` properties +* ✅ Support for protocols +* ✅ Compatible with Cordova for Android + + +--- + +## Usage + +### Import + +```javascript +const WebSocketPlugin = cordova.plugins.websocket; +``` + +### Connect to WebSocket + +```javascript +WebSocketPlugin.connect("wss://example.com/socket", ["protocol1", "protocol2"]) + .then(ws => { + ws.onopen = (e) => console.log("Connected!", e); + ws.onmessage = (e) => console.log("Message:", e.data); + ws.onerror = (e) => console.error("Error:", e); + ws.onclose = (e) => console.log("Closed:", e); + + ws.send("Hello from Cordova!"); + ws.close(); + }) + .catch(err => console.error("WebSocket connection failed:", err)); +``` + +--- + +## API Reference + +### Methods + +* `WebSocketPlugin.connect(url, protocols)` + + * Connects to a WebSocket server. + * `url`: The WebSocket server URL. + * `protocols`: (Optional) An array of subprotocol strings. + * Returns: A Promise that resolves to a `WebSocketInstance`. + +* `WebSocketInstance.send(message)` + + * Sends a message to the server. + * Throws an error if the connection is not open. + +* `WebSocketInstance.close()` + + * Closes the connection. + +--- + +### Properties of `WebSocketInstance` + +* `onopen`: Event listener for connection open. +* `onmessage`: Event listener for messages received. +* `onclose`: Event listener for connection close. +* `onerror`: Event listener for errors. +* `readyState`: (number) The state of the connection. + + * 0 (`CONNECTING`): Socket created, not yet open. + * 1 (`OPEN`): Connection is open and ready. + * 2 (`CLOSING`): Connection is closing. + * 3 (`CLOSED`): Connection is closed or couldn't be opened. +* `extensions`: (string) Extensions negotiated by the server (usually empty or a list). + +--- + +## Notes + +* Only supported on Android (via OkHttp). +* Make sure to handle connection lifecycle properly (close sockets when done). + +--- \ No newline at end of file diff --git a/src/plugins/websocket/package.json b/src/plugins/websocket/package.json new file mode 100644 index 000000000..6277a9c93 --- /dev/null +++ b/src/plugins/websocket/package.json @@ -0,0 +1,22 @@ +{ + "name": "cordova-plugin-websocket", + "version": "0.0.1", + "description": "This cordova plugin is created to use WebSocket (client) in web/js.", + "cordova": { + "id": "cordova-plugin-websocket", + "platforms": [ + "android" + ] + }, + "keywords": [ + "cordova", + "websocket", + "cordova-android", + "ws" + ], + "author": "Acode-Foundation (created by UnschooledGamer)", + "license": "Apache-2.0", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} \ No newline at end of file diff --git a/src/plugins/websocket/plugin.xml b/src/plugins/websocket/plugin.xml new file mode 100644 index 000000000..ad7b68421 --- /dev/null +++ b/src/plugins/websocket/plugin.xml @@ -0,0 +1,21 @@ + + + cordova-plugin-websocket + Cordova Websocket + MIT + cordova,ws,WebSocket + + + + + + + + + + + + + + + diff --git a/src/plugins/websocket/src/android/WebSocketInstance.java b/src/plugins/websocket/src/android/WebSocketInstance.java new file mode 100644 index 000000000..f29f741a6 --- /dev/null +++ b/src/plugins/websocket/src/android/WebSocketInstance.java @@ -0,0 +1,114 @@ +package com.foxdebug.websocket; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import org.apache.cordova.*; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.concurrent.TimeUnit; + +import okhttp3.*; + +public class WebSocketInstance extends WebSocketListener { + private WebSocket webSocket; + private CallbackContext callbackContext; + private CordovaInterface cordova; + private String instanceId; + private String extensions = ""; + private int readyState = 0; // CONNECTING + + public WebSocketInstance(String url, JSONArray protocols, CordovaInterface cordova, String instanceId) { + this.cordova = cordova; + this.instanceId = instanceId; + + OkHttpClient client = new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .build(); + + Request.Builder requestBuilder = new Request.Builder().url(url); + if (protocols != null) { + StringBuilder protocolHeader = new StringBuilder(); + for (int i = 0; i < protocols.length(); i++) { + protocolHeader.append(protocols.optString(i)).append(","); + } + if (protocolHeader.length() > 0) { + protocolHeader.setLength(protocolHeader.length() - 1); + requestBuilder.addHeader("Sec-WebSocket-Protocol", protocolHeader.toString()); + } + } + + client.newWebSocket(requestBuilder.build(), this); + } + + public void setCallback(CallbackContext callbackContext) { + this.callbackContext = callbackContext; + PluginResult result = new PluginResult(PluginResult.Status.NO_RESULT); + result.setKeepCallback(true); + callbackContext.sendPluginResult(result); + } + + public void send(String message) { + if (webSocket != null) { + webSocket.send(message); + } + } + + public void close() { + if (webSocket != null) { + readyState = 2; // CLOSING + webSocket.close(1000, "Normal closure"); + Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received close() action call"); + } + + Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received close() action call, ignoring... as webSocket is null (not present)"); + } + + @Override + public void onOpen(@NonNull WebSocket webSocket, Response response) { + this.webSocket = webSocket; + this.readyState = 1; // OPEN + this.extensions = response.headers("Sec-WebSocket-Extensions").toString(); + Log.i("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Opened" + "received extensions=" + this.extensions); + sendEvent("open", null); + } + + @Override + public void onMessage(@NonNull WebSocket webSocket, String text) { + sendEvent("message", text); + Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Received message: " + text); + } + + @Override + public void onClosing(WebSocket webSocket, int code, String reason) { + this.readyState = 2; // CLOSING + sendEvent("close", reason); + Log.i("WebSocketInstance", "websocket instanceId=" + this.instanceId + " is Closing code: " + code + " reason: " + reason); + } + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { + this.readyState = 3; // CLOSED + sendEvent("error", t.getMessage()); + Log.e("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Error: " + t.getMessage()); + } + + private void sendEvent(String type, String data) { + if (callbackContext != null) { + try { + JSONObject event = new JSONObject(); + event.put("type", type); + event.put("extensions", this.extensions); + event.put("readyState", this.readyState); + if (data != null) event.put("data", data); + PluginResult result = new PluginResult(PluginResult.Status.OK, event); + result.setKeepCallback(true); + callbackContext.sendPluginResult(result); + } catch (Exception e) { + Log.e("WebSocketInstance", "Error sending event", e); + } + } + } +} diff --git a/src/plugins/websocket/src/android/WebSocketPlugin.java b/src/plugins/websocket/src/android/WebSocketPlugin.java new file mode 100644 index 000000000..456e0f827 --- /dev/null +++ b/src/plugins/websocket/src/android/WebSocketPlugin.java @@ -0,0 +1,63 @@ +package com.foxdebug.websocket; + +import org.apache.cordova.*; +import org.json.*; + +import java.util.HashMap; +import java.util.UUID; + +// @TODO: plugin init & plugin destroy(closing okhttp clients) lifecycles. +public class WebSocketPlugin extends CordovaPlugin { + private static final HashMap instances = new HashMap<>(); + + @Override + public boolean execute(String action, JSONArray args, final CallbackContext callbackContext) throws JSONException { + switch (action) { + case "connect": + String url = args.optString(0); + JSONArray protocols = args.optJSONArray(1); + String id = UUID.randomUUID().toString(); + WebSocketInstance instance = new WebSocketInstance(url, protocols, cordova, id); + instances.put(id, instance); + callbackContext.success(id); + return true; + + case "send": + String instanceId = args.optString(0); + String message = args.optString(1); + WebSocketInstance inst = instances.get(instanceId); + if (inst != null) { + inst.send(message); + callbackContext.success(); + } else { + callbackContext.error("Invalid instance ID"); + } + return true; + + case "close": + instanceId = args.optString(0); + inst = instances.get(instanceId); + if (inst != null) { + inst.close(); + instances.remove(instanceId); + callbackContext.success(); + } else { + callbackContext.error("Invalid instance ID"); + } + return true; + + case "registerListener": + instanceId = args.optString(0); + inst = instances.get(instanceId); + if (inst != null) { + inst.setCallback(callbackContext); + } else { + callbackContext.error("Invalid instance ID"); + } + return true; + + default: + return false; + } + } +} diff --git a/src/plugins/websocket/www/websocket.js b/src/plugins/websocket/www/websocket.js new file mode 100644 index 000000000..0a1b240a2 --- /dev/null +++ b/src/plugins/websocket/www/websocket.js @@ -0,0 +1,54 @@ +var exec = require('cordova/exec'); + +class WebSocketInstance { + constructor(url, instanceId) { + this.instanceId = instanceId; + this.extensions = ''; + this.readyState = WebSocketInstance.CONNECTING; + this.onopen = null; + this.onmessage = null; + this.onclose = null; + this.onerror = null; + this.url = url; + + exec((event) => { + if (event.type === 'open') { + this.readyState = WebSocketInstance.OPEN; + this.extensions = event.extensions || ''; + if (this.onopen) this.onopen(event); + } + if (event.type === 'message' && this.onmessage) this.onmessage(event); + if (event.type === 'close') { + this.readyState = WebSocketInstance.CLOSED; + if (this.onclose) this.onclose(event); + } + if (event.type === 'error' && this.onerror) this.onerror(event); + }, null, "WebSocketPlugin", "registerListener", [this.instanceId]); + } + + send(message) { + if (this.readyState === WebSocketInstance.OPEN) { + exec(null, null, "WebSocketPlugin", "send", [this.instanceId, message]); + } else { + throw new Error("WebSocket is not open"); + } + } + + close() { + this.readyState = WebSocketInstance.CLOSING; + exec(null, null, "WebSocketPlugin", "close", [this.instanceId]); + } +} + +WebSocketInstance.CONNECTING = 0; +WebSocketInstance.OPEN = 1; +WebSocketInstance.CLOSING = 2; +WebSocketInstance.CLOSED = 3; + +const connect = function(url, protocols = null) { + return new Promise((resolve, reject) => { + exec(instanceId => resolve(new WebSocketInstance(url, instanceId)), reject, "WebSocketPlugin", "connect", [url, protocols]); + }); +}; + +module.exports = { connect }; From 71481d67d5a85059091347789937224093036ff2 Mon Sep 17 00:00:00 2001 From: UnschooledGamer Date: Fri, 30 May 2025 21:27:41 +0530 Subject: [PATCH 02/14] update: readme of cordova websocket plugin for importing it. --- src/plugins/websocket/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/plugins/websocket/README.md b/src/plugins/websocket/README.md index 4f1c20e33..80ae922fa 100644 --- a/src/plugins/websocket/README.md +++ b/src/plugins/websocket/README.md @@ -11,7 +11,6 @@ It aims to mimic the [WebSocket API](https://developer.mozilla.org/en-US/docs/We * ✅ Support for protocols * ✅ Compatible with Cordova for Android - --- ## Usage @@ -19,7 +18,7 @@ It aims to mimic the [WebSocket API](https://developer.mozilla.org/en-US/docs/We ### Import ```javascript -const WebSocketPlugin = cordova.plugins.websocket; +const WebSocketPlugin = cordova.websocket; ``` ### Connect to WebSocket From 410e1df74ccfb9005699511d6d65d2b015dd6224 Mon Sep 17 00:00:00 2001 From: Emmanuel Lobo <76094069+UnschooledGamer@users.noreply.github.com> Date: Sat, 31 May 2025 12:21:40 +0530 Subject: [PATCH 03/14] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../websocket/src/android/WebSocketInstance.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/plugins/websocket/src/android/WebSocketInstance.java b/src/plugins/websocket/src/android/WebSocketInstance.java index f29f741a6..1cb0b63d6 100644 --- a/src/plugins/websocket/src/android/WebSocketInstance.java +++ b/src/plugins/websocket/src/android/WebSocketInstance.java @@ -61,9 +61,9 @@ public void close() { readyState = 2; // CLOSING webSocket.close(1000, "Normal closure"); Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received close() action call"); + } else { + Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received close() action call, ignoring... as webSocket is null (not present)"); } - - Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received close() action call, ignoring... as webSocket is null (not present)"); } @Override @@ -84,10 +84,16 @@ public void onMessage(@NonNull WebSocket webSocket, String text) { @Override public void onClosing(WebSocket webSocket, int code, String reason) { this.readyState = 2; // CLOSING - sendEvent("close", reason); Log.i("WebSocketInstance", "websocket instanceId=" + this.instanceId + " is Closing code: " + code + " reason: " + reason); } + @Override + public void onClosed(WebSocket webSocket, int code, String reason) { + this.readyState = 3; // CLOSED + sendEvent("close", reason); + Log.i("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Closed code: " + code + " reason: " + reason); + } + @Override public void onFailure(WebSocket webSocket, Throwable t, Response response) { this.readyState = 3; // CLOSED From 86856b789477a2465a255aae9ccc37a257fe5ff5 Mon Sep 17 00:00:00 2001 From: UnschooledGamer Date: Sat, 31 May 2025 19:30:34 +0530 Subject: [PATCH 04/14] feat: enhance WebSocket plugin with custom headers and improved close functionality - Updated `WebSocketPlugin.connect` to accept custom headers. - Enhanced `WebSocketInstance.close` method to allow specifying close code and reason. - Added `WebSocketPlugin.listClients` method to list active WebSocket instances. - Updated JavaScript interface to support new features and improved documentation. --- src/plugins/websocket/README.md | 26 ++++++++--- .../src/android/WebSocketInstance.java | 43 +++++++++++++----- .../src/android/WebSocketPlugin.java | 35 +++++++++++++-- src/plugins/websocket/www/websocket.js | 45 ++++++++++++++++--- 4 files changed, 126 insertions(+), 23 deletions(-) diff --git a/src/plugins/websocket/README.md b/src/plugins/websocket/README.md index 80ae922fa..e2086e762 100644 --- a/src/plugins/websocket/README.md +++ b/src/plugins/websocket/README.md @@ -8,7 +8,9 @@ It aims to mimic the [WebSocket API](https://developer.mozilla.org/en-US/docs/We * ✅ WebSocket API-like interface * ✅ Event support: `onopen`, `onmessage`, `onerror`, `onclose` * ✅ `extensions` and `readyState` properties +* ✅ `listClients()` to list active connections * ✅ Support for protocols +* ✅ Support for Custom Headers. * ✅ Compatible with Cordova for Android --- @@ -24,7 +26,7 @@ const WebSocketPlugin = cordova.websocket; ### Connect to WebSocket ```javascript -WebSocketPlugin.connect("wss://example.com/socket", ["protocol1", "protocol2"]) +WebSocketPlugin.connect("wss://example.com/socket", ["protocol1", "protocol2"], headers) .then(ws => { ws.onopen = (e) => console.log("Connected!", e); ws.onmessage = (e) => console.log("Message:", e.data); @@ -43,21 +45,35 @@ WebSocketPlugin.connect("wss://example.com/socket", ["protocol1", "protocol2"]) ### Methods -* `WebSocketPlugin.connect(url, protocols)` +* `WebSocketPlugin.connect(url, protocols, headers)` * Connects to a WebSocket server. * `url`: The WebSocket server URL. * `protocols`: (Optional) An array of subprotocol strings. - * Returns: A Promise that resolves to a `WebSocketInstance`. + * `headers` (object, optional): Custom headers as key-value pairs. + * **Returns:** A Promise that resolves to a `WebSocketInstance`. +* `WebSocketPlugin.listClients()` + * Lists all stored webSocket instance IDs. + * **Returns:** `Promise`that resolves to an array of `instanceId` strings. + +* `WebSocketPlugin.send(instanceId, message)` + * same as `WebSocketInstance.send(message)` but needs `instanceId`. + * **Returns:** `Promise` that resolves. + +* `WebSocketPlugin.close(instanceId, code, reason)` + * same as `WebSocketInstance.close(code, reason)` but needs `instanceId`. + * **Returns:** `Promise` that resolves. * `WebSocketInstance.send(message)` * Sends a message to the server. * Throws an error if the connection is not open. -* `WebSocketInstance.close()` +* `WebSocketInstance.close(code, reason)` * Closes the connection. + * `code`: (Optional) If unspecified, a close code for the connection is automatically set: to 1000 for a normal closure, or otherwise to [another standard value in the range 1001-1015](https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1) that indicates the actual reason the connection was closed. + * `reason`: A string providing a [custom WebSocket connection close reason](https://www.rfc-editor.org/rfc/rfc6455.html#section-7.1.6) (a concise human-readable prose explanation for the closure). The value must be no longer than 123 bytes (encoded in UTF-8). --- @@ -81,5 +97,5 @@ WebSocketPlugin.connect("wss://example.com/socket", ["protocol1", "protocol2"]) * Only supported on Android (via OkHttp). * Make sure to handle connection lifecycle properly (close sockets when done). - +* `listClients()` is useful for debugging and management. --- \ No newline at end of file diff --git a/src/plugins/websocket/src/android/WebSocketInstance.java b/src/plugins/websocket/src/android/WebSocketInstance.java index 1cb0b63d6..5423f7032 100644 --- a/src/plugins/websocket/src/android/WebSocketInstance.java +++ b/src/plugins/websocket/src/android/WebSocketInstance.java @@ -8,27 +8,44 @@ import org.json.JSONArray; import org.json.JSONObject; +import java.util.Iterator; import java.util.concurrent.TimeUnit; import okhttp3.*; public class WebSocketInstance extends WebSocketListener { + private static final int DEFAULT_CLOSE_CODE = 1000; + private static final String DEFAULT_CLOSE_REASON = "Normal closure"; + private WebSocket webSocket; private CallbackContext callbackContext; - private CordovaInterface cordova; - private String instanceId; + private final CordovaInterface cordova; + private final String instanceId; private String extensions = ""; private int readyState = 0; // CONNECTING - public WebSocketInstance(String url, JSONArray protocols, CordovaInterface cordova, String instanceId) { + // okHttpMainClient parameter is used. To have a single main client(singleton), with per-websocket configuration using newBuilder method. + public WebSocketInstance(String url, JSONArray protocols, JSONObject headers, OkHttpClient okHttpMainClient, CordovaInterface cordova, String instanceId) { this.cordova = cordova; this.instanceId = instanceId; - OkHttpClient client = new OkHttpClient.Builder() + OkHttpClient client = okHttpMainClient.newBuilder() .connectTimeout(10, TimeUnit.SECONDS) .build(); Request.Builder requestBuilder = new Request.Builder().url(url); + + // custom headers support. + if (headers != null) { + Iterator keys = headers.keys(); + while (keys.hasNext()) { + String key = keys.next(); + String value = headers.optString(key); + requestBuilder.addHeader(key, value); + } + } + + // adds Sec-WebSocket-Protocol header if protocols is present. if (protocols != null) { StringBuilder protocolHeader = new StringBuilder(); for (int i = 0; i < protocols.length(); i++) { @@ -56,16 +73,22 @@ public void send(String message) { } } - public void close() { + public void close(int code, String reason) { if (webSocket != null) { readyState = 2; // CLOSING - webSocket.close(1000, "Normal closure"); + webSocket.close(code, reason); Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received close() action call"); } else { Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received close() action call, ignoring... as webSocket is null (not present)"); } } + public void close() { + Log.d("WebSocketInstance", "WebSocket instanceId=" + this.instanceId + " close() called with no arguments. Using defaults."); + // Calls the more specific version with default values + close(DEFAULT_CLOSE_CODE, DEFAULT_CLOSE_REASON); + } + @Override public void onOpen(@NonNull WebSocket webSocket, Response response) { this.webSocket = webSocket; @@ -76,26 +99,26 @@ public void onOpen(@NonNull WebSocket webSocket, Response response) { } @Override - public void onMessage(@NonNull WebSocket webSocket, String text) { + public void onMessage(@NonNull WebSocket webSocket, @NonNull String text) { sendEvent("message", text); Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Received message: " + text); } @Override - public void onClosing(WebSocket webSocket, int code, String reason) { + public void onClosing(@NonNull WebSocket webSocket, int code, @NonNull String reason) { this.readyState = 2; // CLOSING Log.i("WebSocketInstance", "websocket instanceId=" + this.instanceId + " is Closing code: " + code + " reason: " + reason); } @Override - public void onClosed(WebSocket webSocket, int code, String reason) { + public void onClosed(@NonNull WebSocket webSocket, int code, @NonNull String reason) { this.readyState = 3; // CLOSED sendEvent("close", reason); Log.i("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Closed code: " + code + " reason: " + reason); } @Override - public void onFailure(WebSocket webSocket, Throwable t, Response response) { + public void onFailure(@NonNull WebSocket webSocket, Throwable t, Response response) { this.readyState = 3; // CLOSED sendEvent("error", t.getMessage()); Log.e("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Error: " + t.getMessage()); diff --git a/src/plugins/websocket/src/android/WebSocketPlugin.java b/src/plugins/websocket/src/android/WebSocketPlugin.java index 456e0f827..14cd7a154 100644 --- a/src/plugins/websocket/src/android/WebSocketPlugin.java +++ b/src/plugins/websocket/src/android/WebSocketPlugin.java @@ -6,9 +6,17 @@ import java.util.HashMap; import java.util.UUID; -// @TODO: plugin init & plugin destroy(closing okhttp clients) lifecycles. +import okhttp3.OkHttpClient; + +// TODO: plugin init & plugin destroy(closing okhttp clients) lifecycles. (✅) public class WebSocketPlugin extends CordovaPlugin { private static final HashMap instances = new HashMap<>(); + public OkHttpClient okHttpMainClient = null; + + @Override + protected void pluginInitialize() { + this.okHttpMainClient = new OkHttpClient(); + } @Override public boolean execute(String action, JSONArray args, final CallbackContext callbackContext) throws JSONException { @@ -16,8 +24,9 @@ public boolean execute(String action, JSONArray args, final CallbackContext call case "connect": String url = args.optString(0); JSONArray protocols = args.optJSONArray(1); + JSONObject headers = args.optJSONObject(2); String id = UUID.randomUUID().toString(); - WebSocketInstance instance = new WebSocketInstance(url, protocols, cordova, id); + WebSocketInstance instance = new WebSocketInstance(url, protocols, headers, this.okHttpMainClient, cordova, id); instances.put(id, instance); callbackContext.success(id); return true; @@ -36,9 +45,11 @@ public boolean execute(String action, JSONArray args, final CallbackContext call case "close": instanceId = args.optString(0); + int code = args.optInt(1); + String reason = args.optString(2); inst = instances.get(instanceId); if (inst != null) { - inst.close(); + inst.close(code, reason); instances.remove(instanceId); callbackContext.success(); } else { @@ -56,8 +67,26 @@ public boolean execute(String action, JSONArray args, final CallbackContext call } return true; + case "listClients": + JSONArray clientIds = new JSONArray(); + for (String clientId : instances.keySet()) { + clientIds.put(clientId); + } + callbackContext.success(clientIds); + return true; default: return false; } } + + @Override + public void onDestroy() { + // clear all. + for (WebSocketInstance instance : instances.values()) { + // Closing them gracefully. + instance.close(); + } + instances.clear(); + okHttpMainClient.dispatcher().executorService().shutdown(); + } } diff --git a/src/plugins/websocket/www/websocket.js b/src/plugins/websocket/www/websocket.js index 0a1b240a2..2c19a15de 100644 --- a/src/plugins/websocket/www/websocket.js +++ b/src/plugins/websocket/www/websocket.js @@ -34,9 +34,15 @@ class WebSocketInstance { } } - close() { + /** + * Closes the WebSocket connection. + * + * @param {number} code The status code explaining why the connection is being closed. + * @param {string} reason A human-readable string explaining why the connection is being closed. + */ + close(code, reason) { this.readyState = WebSocketInstance.CLOSING; - exec(null, null, "WebSocketPlugin", "close", [this.instanceId]); + exec(null, null, "WebSocketPlugin", "close", [this.instanceId, code, reason]); } } @@ -45,10 +51,39 @@ WebSocketInstance.OPEN = 1; WebSocketInstance.CLOSING = 2; WebSocketInstance.CLOSED = 3; -const connect = function(url, protocols = null) { +const connect = function(url, protocols = null, headers = null) { return new Promise((resolve, reject) => { - exec(instanceId => resolve(new WebSocketInstance(url, instanceId)), reject, "WebSocketPlugin", "connect", [url, protocols]); + exec(instanceId => resolve(new WebSocketInstance(url, instanceId)), reject, "WebSocketPlugin", "connect", [url, protocols, headers]); }); }; -module.exports = { connect }; +const listClients = function() { + return new Promise((resolve, reject) => { + exec(resolve, reject, "WebSocketPlugin", "listClients", []); + }); +}; + +/** Utility functions, in-case you lost the websocketInstance returned from the connect function */ + +const send = function(instanceId, message) { + return new Promise((resolve, reject) => { + exec(resolve, reject, "WebSocketPlugin", "send", [instanceId, message]); + }); +}; + +/** + * Closes the WebSocket connection. + * + * @param {string} instanceId The ID of the WebSocketInstance to close. + * @param {number} [code] (optional) The status code explaining why the connection is being closed. + * @param {string} [reason] (optional) A human-readable string explaining why the connection is being closed. + * + * @returns {Promise} A promise that resolves when the close operation has completed. + */ +const close = function(instanceId, code, reason) { + return new Promise((resolve, reject) => { + exec(resolve, reject, "WebSocketPlugin", "close", [instanceId, code, reason]); + }); +}; + +module.exports = { connect, listClients, send, close }; From 537df1f3245362ec4d820641a73003c395108d50 Mon Sep 17 00:00:00 2001 From: UnschooledGamer Date: Sat, 31 May 2025 21:39:56 +0530 Subject: [PATCH 05/14] feat: improve WebSocketInstance and WebSocketPlugin with enhanced logging and error handling - Updated `WebSocketInstance.send` and `close` methods to include detailed logging for better debugging. - Modified `WebSocketPlugin` to handle close operation errors and provide appropriate feedback to the callback context. - Ensured proper binding of event handlers in the JavaScript WebSocket interface for consistent context handling. --- .../src/android/WebSocketInstance.java | 35 ++++++++++++++----- .../src/android/WebSocketPlugin.java | 21 ++++++++--- src/plugins/websocket/www/websocket.js | 8 ++--- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/src/plugins/websocket/src/android/WebSocketInstance.java b/src/plugins/websocket/src/android/WebSocketInstance.java index 5423f7032..4174442a1 100644 --- a/src/plugins/websocket/src/android/WebSocketInstance.java +++ b/src/plugins/websocket/src/android/WebSocketInstance.java @@ -68,25 +68,42 @@ public void setCallback(CallbackContext callbackContext) { } public void send(String message) { - if (webSocket != null) { - webSocket.send(message); + if (this.webSocket != null) { + this.webSocket.send(message); + Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received send() action call, sending message=" + message); + } else { + Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received send() action call, ignoring... as webSocket is null (not present)"); } } - public void close(int code, String reason) { - if (webSocket != null) { - readyState = 2; // CLOSING - webSocket.close(code, reason); - Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received close() action call"); + public String close(int code, String reason) { + if (this.webSocket != null) { + this.readyState = 2; // CLOSING + try { + boolean result = this.webSocket.close(code, reason); + Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received close() action call"); + + // if a graceful shutdown was already underway... + // or if the web socket is already closed or canceled. do nothing. + if(!result) { + return null; + } + } catch (Exception e) { + return e.getMessage(); + } + + return null; } else { Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received close() action call, ignoring... as webSocket is null (not present)"); + // TODO: finding a better way of telling it wasn't successful. + return ""; } } - public void close() { + public String close() { Log.d("WebSocketInstance", "WebSocket instanceId=" + this.instanceId + " close() called with no arguments. Using defaults."); // Calls the more specific version with default values - close(DEFAULT_CLOSE_CODE, DEFAULT_CLOSE_REASON); + return close(DEFAULT_CLOSE_CODE, DEFAULT_CLOSE_REASON); } @Override diff --git a/src/plugins/websocket/src/android/WebSocketPlugin.java b/src/plugins/websocket/src/android/WebSocketPlugin.java index 14cd7a154..d620380d0 100644 --- a/src/plugins/websocket/src/android/WebSocketPlugin.java +++ b/src/plugins/websocket/src/android/WebSocketPlugin.java @@ -1,5 +1,7 @@ package com.foxdebug.websocket; +import android.util.Log; + import org.apache.cordova.*; import org.json.*; @@ -35,6 +37,7 @@ public boolean execute(String action, JSONArray args, final CallbackContext call String instanceId = args.optString(0); String message = args.optString(1); WebSocketInstance inst = instances.get(instanceId); + Log.d("WebSocketPlugin", "send called"); if (inst != null) { inst.send(message); callbackContext.success(); @@ -45,12 +48,21 @@ public boolean execute(String action, JSONArray args, final CallbackContext call case "close": instanceId = args.optString(0); - int code = args.optInt(1); - String reason = args.optString(2); + // defaults code to 1000 & reason to "Normal closure" + int code = args.optInt(1, 1000); + String reason = args.optString(2, "Normal closure"); inst = instances.get(instanceId); if (inst != null) { - inst.close(code, reason); - instances.remove(instanceId); + String error = inst.close(code, reason); + + if(error == null) { + instances.remove(instanceId); + callbackContext.success(); + return true; + } else if(!error.isEmpty()) { + // if error is empty means the websocket is not ready/open. + callbackContext.error(error); + } callbackContext.success(); } else { callbackContext.error("Invalid instance ID"); @@ -88,5 +100,6 @@ public void onDestroy() { } instances.clear(); okHttpMainClient.dispatcher().executorService().shutdown(); + Log.i("WebSocketPlugin", "cleaned up... on destroy"); } } diff --git a/src/plugins/websocket/www/websocket.js b/src/plugins/websocket/www/websocket.js index 2c19a15de..e63a15a94 100644 --- a/src/plugins/websocket/www/websocket.js +++ b/src/plugins/websocket/www/websocket.js @@ -15,14 +15,14 @@ class WebSocketInstance { if (event.type === 'open') { this.readyState = WebSocketInstance.OPEN; this.extensions = event.extensions || ''; - if (this.onopen) this.onopen(event); + if (this.onopen) this.onopen.bind(this)(event); } - if (event.type === 'message' && this.onmessage) this.onmessage(event); + if (event.type === 'message' && this.onmessage) this.onmessage.bind(this)(event); if (event.type === 'close') { this.readyState = WebSocketInstance.CLOSED; - if (this.onclose) this.onclose(event); + if (this.onclose) this.onclose.bind(this)(event); } - if (event.type === 'error' && this.onerror) this.onerror(event); + if (event.type === 'error' && this.onerror) this.onerror.bind(this)(event); }, null, "WebSocketPlugin", "registerListener", [this.instanceId]); } From bf906181c7dfbb67d331b8c37d6f682463f99eb5 Mon Sep 17 00:00:00 2001 From: Emmanuel Lobo <76094069+UnschooledGamer@users.noreply.github.com> Date: Sat, 31 May 2025 22:54:42 +0530 Subject: [PATCH 06/14] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/plugins/websocket/src/android/WebSocketPlugin.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/websocket/src/android/WebSocketPlugin.java b/src/plugins/websocket/src/android/WebSocketPlugin.java index d620380d0..07506b9f6 100644 --- a/src/plugins/websocket/src/android/WebSocketPlugin.java +++ b/src/plugins/websocket/src/android/WebSocketPlugin.java @@ -62,8 +62,8 @@ public boolean execute(String action, JSONArray args, final CallbackContext call } else if(!error.isEmpty()) { // if error is empty means the websocket is not ready/open. callbackContext.error(error); + return true; } - callbackContext.success(); } else { callbackContext.error("Invalid instance ID"); } From d6862a62682330190a267396a2977f893bfeafb5 Mon Sep 17 00:00:00 2001 From: UnschooledGamer Date: Sun, 1 Jun 2025 11:59:40 +0530 Subject: [PATCH 07/14] refactor: replace HashMap with ConcurrentHashMap for thread-safe WebSocket instances management --- src/plugins/websocket/src/android/WebSocketPlugin.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plugins/websocket/src/android/WebSocketPlugin.java b/src/plugins/websocket/src/android/WebSocketPlugin.java index 07506b9f6..057843531 100644 --- a/src/plugins/websocket/src/android/WebSocketPlugin.java +++ b/src/plugins/websocket/src/android/WebSocketPlugin.java @@ -7,12 +7,13 @@ import java.util.HashMap; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import okhttp3.OkHttpClient; // TODO: plugin init & plugin destroy(closing okhttp clients) lifecycles. (✅) public class WebSocketPlugin extends CordovaPlugin { - private static final HashMap instances = new HashMap<>(); + private static final ConcurrentHashMap instances = new ConcurrentHashMap<>(); public OkHttpClient okHttpMainClient = null; @Override From 48a831b13e893429c584e0bf65f745d905e4cd85 Mon Sep 17 00:00:00 2001 From: UnschooledGamer Date: Sun, 1 Jun 2025 13:22:03 +0530 Subject: [PATCH 08/14] feat: enhance WebSocket close event handling - Updated `WebSocketInstance.onClosed` to send a structured close event with code and reason. - Modified JavaScript event handling to properly receive and process the close event data. --- .../websocket/src/android/WebSocketInstance.java | 10 +++++++++- src/plugins/websocket/www/websocket.js | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/plugins/websocket/src/android/WebSocketInstance.java b/src/plugins/websocket/src/android/WebSocketInstance.java index 4174442a1..33fcec9c4 100644 --- a/src/plugins/websocket/src/android/WebSocketInstance.java +++ b/src/plugins/websocket/src/android/WebSocketInstance.java @@ -6,6 +6,7 @@ import org.apache.cordova.*; import org.json.JSONArray; +import org.json.JSONException; import org.json.JSONObject; import java.util.Iterator; @@ -130,8 +131,15 @@ public void onClosing(@NonNull WebSocket webSocket, int code, @NonNull String re @Override public void onClosed(@NonNull WebSocket webSocket, int code, @NonNull String reason) { this.readyState = 3; // CLOSED - sendEvent("close", reason); Log.i("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Closed code: " + code + " reason: " + reason); + JSONObject closedEvent = new JSONObject(); + try { + closedEvent.put("code", code); + closedEvent.put("reason", reason); + } catch (JSONException e) { + Log.e("WebSocketInstance", "Error creating close event", e); + } + sendEvent("close", closedEvent.toString()); } @Override diff --git a/src/plugins/websocket/www/websocket.js b/src/plugins/websocket/www/websocket.js index e63a15a94..9553cc12e 100644 --- a/src/plugins/websocket/www/websocket.js +++ b/src/plugins/websocket/www/websocket.js @@ -20,7 +20,7 @@ class WebSocketInstance { if (event.type === 'message' && this.onmessage) this.onmessage.bind(this)(event); if (event.type === 'close') { this.readyState = WebSocketInstance.CLOSED; - if (this.onclose) this.onclose.bind(this)(event); + if (this.onclose) this.onclose.bind(this)({ code: event?.data?.code, reason: event?.data?.reason, type: event.type }); } if (event.type === 'error' && this.onerror) this.onerror.bind(this)(event); }, null, "WebSocketPlugin", "registerListener", [this.instanceId]); From 4f27c2bed9027f6d2eff3f3d6494d79e620b0c4e Mon Sep 17 00:00:00 2001 From: UnschooledGamer Date: Mon, 2 Jun 2025 21:39:38 +0530 Subject: [PATCH 09/14] feat: enhance WebSocketInstance with binary message support and improved event handling - Added support for binary messages in `WebSocketInstance` with a new `onMessage` method for handling `ByteString`. - Updated `sendEvent` method to include a flag indicating if the message is binary. - Modified JavaScript interface to handle binary data and added logging for better debugging. - Updated `WebSocketPlugin.connect` to accept a `binaryType` parameter for configuration. --- .../src/android/WebSocketInstance.java | 36 ++++++-- .../src/android/WebSocketPlugin.java | 3 +- src/plugins/websocket/www/websocket.js | 85 ++++++++++++++++--- 3 files changed, 104 insertions(+), 20 deletions(-) diff --git a/src/plugins/websocket/src/android/WebSocketInstance.java b/src/plugins/websocket/src/android/WebSocketInstance.java index 33fcec9c4..7db312aed 100644 --- a/src/plugins/websocket/src/android/WebSocketInstance.java +++ b/src/plugins/websocket/src/android/WebSocketInstance.java @@ -23,12 +23,15 @@ public class WebSocketInstance extends WebSocketListener { private final CordovaInterface cordova; private final String instanceId; private String extensions = ""; + private String protocol = ""; + private String binaryType = ""; private int readyState = 0; // CONNECTING // okHttpMainClient parameter is used. To have a single main client(singleton), with per-websocket configuration using newBuilder method. - public WebSocketInstance(String url, JSONArray protocols, JSONObject headers, OkHttpClient okHttpMainClient, CordovaInterface cordova, String instanceId) { + public WebSocketInstance(String url, JSONArray protocols, JSONObject headers, String binaryType, OkHttpClient okHttpMainClient, CordovaInterface cordova, String instanceId) { this.cordova = cordova; this.instanceId = instanceId; + this.binaryType = binaryType; OkHttpClient client = okHttpMainClient.newBuilder() .connectTimeout(10, TimeUnit.SECONDS) @@ -112,14 +115,33 @@ public void onOpen(@NonNull WebSocket webSocket, Response response) { this.webSocket = webSocket; this.readyState = 1; // OPEN this.extensions = response.headers("Sec-WebSocket-Extensions").toString(); + this.protocol = response.header("Sec-WebSocket-Protocol"); Log.i("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Opened" + "received extensions=" + this.extensions); - sendEvent("open", null); + sendEvent("open", null, false); } @Override public void onMessage(@NonNull WebSocket webSocket, @NonNull String text) { - sendEvent("message", text); Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Received message: " + text); + sendEvent("message", text, false); + } + + // This is called when the Websocket server sends a binary(type 0x2) message. + @Override + public void onMessage(@NonNull WebSocket webSocket, @NonNull ByteString bytes) { + Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Received message(bytes): " + bytes.toString()); + + try { + if ("arraybuffer".equals(this.binaryType)) { + String base64 = bytes.base64(); + sendEvent("message", base64, true); + } else { + sendEvent("message", bytes.utf8(), true); + } + } catch (Exception e) { + Log.e("WebSocketInstance", "Error sending message", e); + } + } @Override @@ -139,24 +161,26 @@ public void onClosed(@NonNull WebSocket webSocket, int code, @NonNull String rea } catch (JSONException e) { Log.e("WebSocketInstance", "Error creating close event", e); } - sendEvent("close", closedEvent.toString()); + sendEvent("close", closedEvent.toString(), false); } @Override public void onFailure(@NonNull WebSocket webSocket, Throwable t, Response response) { this.readyState = 3; // CLOSED - sendEvent("error", t.getMessage()); + sendEvent("error", t.getMessage(), false); Log.e("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Error: " + t.getMessage()); } - private void sendEvent(String type, String data) { + private void sendEvent(String type, String data, boolean isBinary) { if (callbackContext != null) { try { JSONObject event = new JSONObject(); event.put("type", type); event.put("extensions", this.extensions); event.put("readyState", this.readyState); + event.put("isBinary", isBinary ? true : false); if (data != null) event.put("data", data); + Log.d("WebSocketInstance", "sending event: " + type + " eventObj " + event.toString()); PluginResult result = new PluginResult(PluginResult.Status.OK, event); result.setKeepCallback(true); callbackContext.sendPluginResult(result); diff --git a/src/plugins/websocket/src/android/WebSocketPlugin.java b/src/plugins/websocket/src/android/WebSocketPlugin.java index 057843531..67e9e359c 100644 --- a/src/plugins/websocket/src/android/WebSocketPlugin.java +++ b/src/plugins/websocket/src/android/WebSocketPlugin.java @@ -28,8 +28,9 @@ public boolean execute(String action, JSONArray args, final CallbackContext call String url = args.optString(0); JSONArray protocols = args.optJSONArray(1); JSONObject headers = args.optJSONObject(2); + String binaryType = args.optString(3, null); String id = UUID.randomUUID().toString(); - WebSocketInstance instance = new WebSocketInstance(url, protocols, headers, this.okHttpMainClient, cordova, id); + WebSocketInstance instance = new WebSocketInstance(url, protocols, headers, binaryType, this.okHttpMainClient, cordova, id); instances.put(id, instance); callbackContext.success(id); return true; diff --git a/src/plugins/websocket/www/websocket.js b/src/plugins/websocket/www/websocket.js index 9553cc12e..7d1630acc 100644 --- a/src/plugins/websocket/www/websocket.js +++ b/src/plugins/websocket/www/websocket.js @@ -1,7 +1,18 @@ var exec = require('cordova/exec'); +/** + * Whether to log debug messages + */ +let DEBUG = false; + +const logIfDebug = (...args) => { + if (DEBUG) { + console.log(...args); + } +}; -class WebSocketInstance { +class WebSocketInstance extends EventTarget { constructor(url, instanceId) { + super(); this.instanceId = instanceId; this.extensions = ''; this.readyState = WebSocketInstance.CONNECTING; @@ -10,27 +21,66 @@ class WebSocketInstance { this.onclose = null; this.onerror = null; this.url = url; + this.binaryType = ''; // empty as Default is string. exec((event) => { + logIfDebug(`[Cordova WebSocket - ID=${this.instanceId}] Event from native:`, event); + if (event.type === 'open') { this.readyState = WebSocketInstance.OPEN; this.extensions = event.extensions || ''; - if (this.onopen) this.onopen.bind(this)(event); + if (this.onopen) this.onopen(event); + this.dispatchEvent(new Event('open')); + } + + if (event.type === 'message') { + let msgData = event.data; + if (event.isBinary && this.binaryType === 'arraybuffer') { + let binary = atob(msgData); + let bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + msgData = bytes.buffer; + } + logIfDebug(`[Cordova WebSocket - ID=${this.instanceId}] msg Event:`, event, msgData); + const msgEvent = new MessageEvent('message', { data: msgData }); + if (this.onmessage) this.onmessage(msgEvent); + this.dispatchEvent(msgEvent); } - if (event.type === 'message' && this.onmessage) this.onmessage.bind(this)(event); + if (event.type === 'close') { this.readyState = WebSocketInstance.CLOSED; - if (this.onclose) this.onclose.bind(this)({ code: event?.data?.code, reason: event?.data?.reason, type: event.type }); + const closeEvent = new CloseEvent('close', { code: event.data?.code, reason: event.data?.reason }); + if (this.onclose) this.onclose(closeEvent); + this.dispatchEvent(closeEvent); + } + + if (event.type === 'error') { + const errorEvent = new Event('error', { message: event?.data }); + if (this.onerror) this.onerror(errorEvent); + this.dispatchEvent(errorEvent); } - if (event.type === 'error' && this.onerror) this.onerror.bind(this)(event); }, null, "WebSocketPlugin", "registerListener", [this.instanceId]); } send(message) { - if (this.readyState === WebSocketInstance.OPEN) { - exec(null, null, "WebSocketPlugin", "send", [this.instanceId, message]); + if (this.readyState !== WebSocketInstance.OPEN) { + throw new Error(`WebSocket is not open/connected`); + } + + let finalMessage = null; + if (message instanceof ArrayBuffer || ArrayBuffer.isView(message)) { + const uint8Array = message instanceof ArrayBuffer ? new Uint8Array(message) : message; + finalMessage = btoa(String.fromCharCode.apply(null, uint8Array)); + + exec(() => logIfDebug(`[Cordova WebSocket - ID=${this.instanceId}] Sent message:`, finalMessage), (err) => console.error(`[Cordova WebSocket - ID=${this.instanceId}] Send error:`, err), "WebSocketPlugin", "send", [this.instanceId, finalMessage]); + } else if (typeof message === 'string') { + finalMessage = message; + + exec(() => logIfDebug(`[Cordova WebSocket - ID=${this.instanceId}] Sent message:`, finalMessage), (err) => console.error(`[Cordova WebSocket - ID=${this.instanceId}] Send error:`, err), "WebSocketPlugin", "send", [this.instanceId, finalMessage]); } else { - throw new Error("WebSocket is not open"); + throw new Error(`Unsupported message type: ${typeof message}`); } } @@ -42,7 +92,7 @@ class WebSocketInstance { */ close(code, reason) { this.readyState = WebSocketInstance.CLOSING; - exec(null, null, "WebSocketPlugin", "close", [this.instanceId, code, reason]); + exec(() => logIfDebug(`[Cordova WebSocket - ID=${this.instanceId}] Close requested`, code, reason), (err) => console.error(`[Cordova WebSocket - ID=${this.instanceId}] Close error`, err), "WebSocketPlugin", "close", [this.instanceId, code, reason]); } } @@ -51,9 +101,9 @@ WebSocketInstance.OPEN = 1; WebSocketInstance.CLOSING = 2; WebSocketInstance.CLOSED = 3; -const connect = function(url, protocols = null, headers = null) { +const connect = function(url, protocols = null, headers = null, binaryType) { return new Promise((resolve, reject) => { - exec(instanceId => resolve(new WebSocketInstance(url, instanceId)), reject, "WebSocketPlugin", "connect", [url, protocols, headers]); + exec(instanceId => resolve(new WebSocketInstance(url, instanceId)), reject, "WebSocketPlugin", "connect", [url, protocols, binaryType, headers]); }); }; @@ -67,7 +117,16 @@ const listClients = function() { const send = function(instanceId, message) { return new Promise((resolve, reject) => { - exec(resolve, reject, "WebSocketPlugin", "send", [instanceId, message]); + if (typeof message === 'string') { + exec(resolve, reject, "WebSocketPlugin", "send", [instanceId, message]); + } else if (message instanceof ArrayBuffer || ArrayBuffer.isView(message)) { + const uint8Array = message instanceof ArrayBuffer ? new Uint8Array(message) : message; + const base64Message = btoa(String.fromCharCode.apply(null, uint8Array)); + + exec(resolve, reject, "WebSocketPlugin", "send", [instanceId, base64Message]); + } else { + reject(`Unsupported message type: ${typeof message}`); + } }); }; @@ -86,4 +145,4 @@ const close = function(instanceId, code, reason) { }); }; -module.exports = { connect, listClients, send, close }; +module.exports = { connect, listClients, send, close, DEBUG }; From 874a48755e340e2911fb481253d2eb1dee90f85a Mon Sep 17 00:00:00 2001 From: Emmanuel Lobo <76094069+UnschooledGamer@users.noreply.github.com> Date: Tue, 3 Jun 2025 06:52:21 +0000 Subject: [PATCH 10/14] fix: add missing import for ByteString in WebSocketInstance Fixes ` error: name clash: class WebSocketInstance has two methods with the same erasure, yet neither overrides the other public void onMessage(@NonNull WebSocket webSocket, @NonNull String text) { ^ first method: onMessage(WebSocket,ByteString) in WebSocketInstance second method: onMessage(WebSocket,ByteString) in WebSocketListener` --- src/plugins/websocket/src/android/WebSocketInstance.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/plugins/websocket/src/android/WebSocketInstance.java b/src/plugins/websocket/src/android/WebSocketInstance.java index 7db312aed..9f5ab5351 100644 --- a/src/plugins/websocket/src/android/WebSocketInstance.java +++ b/src/plugins/websocket/src/android/WebSocketInstance.java @@ -14,6 +14,8 @@ import okhttp3.*; +import okio.ByteString; + public class WebSocketInstance extends WebSocketListener { private static final int DEFAULT_CLOSE_CODE = 1000; private static final String DEFAULT_CLOSE_REASON = "Normal closure"; From 7b65cb4482d65d8acfe8d0c50c77ce3de77e4fcd Mon Sep 17 00:00:00 2001 From: Emmanuel Lobo <76094069+UnschooledGamer@users.noreply.github.com> Date: Tue, 3 Jun 2025 06:52:33 +0000 Subject: [PATCH 11/14] feat: add devcontainer configuration with Android SDK support --- .devcontainer/devcontainer.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..1489d285f --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,6 @@ +{ + "image": "mcr.microsoft.com/devcontainers/universal:2", + "features": { + "ghcr.io/nordcominc/devcontainer-features/android-sdk:1": {} + } +} \ No newline at end of file From 2f98b4e8f2e9e01807a3afddabd8ddff9aa314eb Mon Sep 17 00:00:00 2001 From: UnschooledGamer Date: Fri, 6 Jun 2025 21:48:00 +0530 Subject: [PATCH 12/14] feat: enhance WebSocket plugin with binary message support and improved API - Updated `WebSocketPlugin.connect` to accept an optional `binaryType` parameter. - Modified `WebSocketInstance.send` method to handle both string and binary messages. - Enhanced JavaScript interface to support binary message sending and receiving. - Improved event handling for binary messages in both Java and JavaScript layers. - Added logging for better debugging of message sending and receiving. --- src/plugins/websocket/README.md | 58 +++++-- .../src/android/WebSocketInstance.java | 68 +++++--- .../src/android/WebSocketPlugin.java | 154 ++++++++++-------- src/plugins/websocket/www/websocket.js | 56 +++++-- 4 files changed, 221 insertions(+), 115 deletions(-) diff --git a/src/plugins/websocket/README.md b/src/plugins/websocket/README.md index e2086e762..5805a0142 100644 --- a/src/plugins/websocket/README.md +++ b/src/plugins/websocket/README.md @@ -45,32 +45,38 @@ WebSocketPlugin.connect("wss://example.com/socket", ["protocol1", "protocol2"], ### Methods -* `WebSocketPlugin.connect(url, protocols, headers)` - +* `WebSocketPlugin.connect(url, protocols, headers, binaryType)` * Connects to a WebSocket server. * `url`: The WebSocket server URL. * `protocols`: (Optional) An array of subprotocol strings. - * `headers` (object, optional): Custom headers as key-value pairs. + * `headers`: (Optional) Custom headers as key-value pairs. + * `binaryType`: (Optional) Initial binary type setting. * **Returns:** A Promise that resolves to a `WebSocketInstance`. + * `WebSocketPlugin.listClients()` * Lists all stored webSocket instance IDs. - * **Returns:** `Promise`that resolves to an array of `instanceId` strings. - -* `WebSocketPlugin.send(instanceId, message)` - * same as `WebSocketInstance.send(message)` but needs `instanceId`. - * **Returns:** `Promise` that resolves. + * **Returns:** `Promise` that resolves to an array of `instanceId` strings. + +* `WebSocketPlugin.send(instanceId, message, binary)` + * Sends a message to the server using an instance ID. + * `instanceId`: The ID of the WebSocket instance. + * `message`: The message to send (string or ArrayBuffer/ArrayBufferView). + * `binary`: (Optional) Whether to send the message as binary, accepts `boolean` + * **Returns:** `Promise` that resolves when the message is sent. * `WebSocketPlugin.close(instanceId, code, reason)` * same as `WebSocketInstance.close(code, reason)` but needs `instanceId`. * **Returns:** `Promise` that resolves. -* `WebSocketInstance.send(message)` +### WebSocketInstance Methods +* `WebSocketInstance.send(message, binary)` * Sends a message to the server. + * `message`: The message to send (string or ArrayBuffer/ArrayBufferView). + * `binary`: (Optional) Whether to send the message as binary. accepts `boolean` * Throws an error if the connection is not open. * `WebSocketInstance.close(code, reason)` - * Closes the connection. * `code`: (Optional) If unspecified, a close code for the connection is automatically set: to 1000 for a normal closure, or otherwise to [another standard value in the range 1001-1015](https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1) that indicates the actual reason the connection was closed. * `reason`: A string providing a [custom WebSocket connection close reason](https://www.rfc-editor.org/rfc/rfc6455.html#section-7.1.6) (a concise human-readable prose explanation for the closure). The value must be no longer than 123 bytes (encoded in UTF-8). @@ -84,12 +90,40 @@ WebSocketPlugin.connect("wss://example.com/socket", ["protocol1", "protocol2"], * `onclose`: Event listener for connection close. * `onerror`: Event listener for errors. * `readyState`: (number) The state of the connection. - * 0 (`CONNECTING`): Socket created, not yet open. * 1 (`OPEN`): Connection is open and ready. * 2 (`CLOSING`): Connection is closing. * 3 (`CLOSED`): Connection is closed or couldn't be opened. -* `extensions`: (string) Extensions negotiated by the server (usually empty or a list). +* `extensions`: (string) Extensions negotiated by the server. +* `binaryType`: (string) Type of binary data to use ('arraybuffer' or '' (binary payload returned as strings.)). +* `url`: (string) The WebSocket server URL. +* `instanceId`: (string) Unique identifier for this WebSocket instance. + +### Event Handling + +`WebSocketInstance` extends `EventTarget`, providing standard event handling methods: + +* `addEventListener(type, listener)`: Registers an event listener. +* `removeEventListener(type, listener)`: Removes an event listener. +* `dispatchEvent(event)`: Dispatches an event to the object. + +Example of using event listeners: +```javascript +const ws = await WebSocketPlugin.connect("wss://example.com/socket"); + +// Using on* properties +ws.onmessage = (event) => console.log("Message:", event.data); + +// Using addEventListener +ws.addEventListener('message', (event) => console.log("Message:", event.data)); +``` + +### Constants + +* `WebSocketInstance.CONNECTING`: 0 +* `WebSocketInstance.OPEN`: 1 +* `WebSocketInstance.CLOSING`: 2 +* `WebSocketInstance.CLOSED`: 3 --- diff --git a/src/plugins/websocket/src/android/WebSocketInstance.java b/src/plugins/websocket/src/android/WebSocketInstance.java index 9f5ab5351..e8f1254cf 100644 --- a/src/plugins/websocket/src/android/WebSocketInstance.java +++ b/src/plugins/websocket/src/android/WebSocketInstance.java @@ -1,5 +1,6 @@ package com.foxdebug.websocket; +import android.util.Base64; import android.util.Log; import androidx.annotation.NonNull; @@ -17,6 +18,7 @@ import okio.ByteString; public class WebSocketInstance extends WebSocketListener { + private static final String TAG = "WebSocketInstance"; private static final int DEFAULT_CLOSE_CODE = 1000; private static final String DEFAULT_CLOSE_REASON = "Normal closure"; @@ -73,21 +75,34 @@ public void setCallback(CallbackContext callbackContext) { callbackContext.sendPluginResult(result); } - public void send(String message) { + public void send(String message, boolean isBinary) { if (this.webSocket != null) { + Log.d(TAG, "websocket instanceId=" + this.instanceId + " received send(..., isBinary=" + isBinary + ") action call, sending message=" + message); + if(isBinary) { + this.sendBinary(message); + return; + } this.webSocket.send(message); - Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received send() action call, sending message=" + message); } else { - Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received send() action call, ignoring... as webSocket is null (not present)"); + Log.d(TAG, "websocket instanceId=" + this.instanceId + " received send(..., isBinary=" + isBinary + ") ignoring... as webSocket is null (not present/connected)"); } } + /** + * Sends bytes as the data of a binary (type 0x2) message. + * @param base64Data Binary Data received from JS bridge encoded as base64 String + */ + private void sendBinary(String base64Data) { + byte[] data = Base64.decode(base64Data, Base64.DEFAULT); + this.webSocket.send(ByteString.of(data)); + } + public String close(int code, String reason) { if (this.webSocket != null) { this.readyState = 2; // CLOSING try { boolean result = this.webSocket.close(code, reason); - Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received close() action call"); + Log.d(TAG, "websocket instanceId=" + this.instanceId + " received close() action call, code=" + code + " reason=" + reason + " close method result: " + result); // if a graceful shutdown was already underway... // or if the web socket is already closed or canceled. do nothing. @@ -100,14 +115,14 @@ public String close(int code, String reason) { return null; } else { - Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " received close() action call, ignoring... as webSocket is null (not present)"); + Log.d(TAG, "websocket instanceId=" + this.instanceId + " received close() action call, ignoring... as webSocket is null (not present)"); // TODO: finding a better way of telling it wasn't successful. return ""; } } public String close() { - Log.d("WebSocketInstance", "WebSocket instanceId=" + this.instanceId + " close() called with no arguments. Using defaults."); + Log.d(TAG, "WebSocket instanceId=" + this.instanceId + " close() called with no arguments. Using defaults."); // Calls the more specific version with default values return close(DEFAULT_CLOSE_CODE, DEFAULT_CLOSE_REASON); } @@ -118,30 +133,30 @@ public void onOpen(@NonNull WebSocket webSocket, Response response) { this.readyState = 1; // OPEN this.extensions = response.headers("Sec-WebSocket-Extensions").toString(); this.protocol = response.header("Sec-WebSocket-Protocol"); - Log.i("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Opened" + "received extensions=" + this.extensions); - sendEvent("open", null, false); + Log.i(TAG, "websocket instanceId=" + this.instanceId + " Opened" + "received extensions=" + this.extensions); + sendEvent("open", null, false, false); } @Override public void onMessage(@NonNull WebSocket webSocket, @NonNull String text) { - Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Received message: " + text); - sendEvent("message", text, false); + Log.d(TAG, "websocket instanceId=" + this.instanceId + " Received message: " + text); + sendEvent("message", text, false, false); } // This is called when the Websocket server sends a binary(type 0x2) message. @Override public void onMessage(@NonNull WebSocket webSocket, @NonNull ByteString bytes) { - Log.d("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Received message(bytes): " + bytes.toString()); + Log.d(TAG, "websocket instanceId=" + this.instanceId + " Received message(bytes/binary payload): " + bytes.toString()); try { if ("arraybuffer".equals(this.binaryType)) { String base64 = bytes.base64(); - sendEvent("message", base64, true); + sendEvent("message", base64, true, false); } else { - sendEvent("message", bytes.utf8(), true); + sendEvent("message", bytes.utf8(), true, true); } } catch (Exception e) { - Log.e("WebSocketInstance", "Error sending message", e); + Log.e(TAG, "Error sending message", e); } } @@ -149,45 +164,50 @@ public void onMessage(@NonNull WebSocket webSocket, @NonNull ByteString bytes) { @Override public void onClosing(@NonNull WebSocket webSocket, int code, @NonNull String reason) { this.readyState = 2; // CLOSING - Log.i("WebSocketInstance", "websocket instanceId=" + this.instanceId + " is Closing code: " + code + " reason: " + reason); + Log.i(TAG, "websocket instanceId=" + this.instanceId + " is Closing code: " + code + " reason: " + reason); } @Override public void onClosed(@NonNull WebSocket webSocket, int code, @NonNull String reason) { this.readyState = 3; // CLOSED - Log.i("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Closed code: " + code + " reason: " + reason); + Log.i(TAG, "websocket instanceId=" + this.instanceId + " Closed code: " + code + " reason: " + reason); JSONObject closedEvent = new JSONObject(); try { closedEvent.put("code", code); closedEvent.put("reason", reason); } catch (JSONException e) { - Log.e("WebSocketInstance", "Error creating close event", e); + Log.e(TAG, "Error creating close event", e); } - sendEvent("close", closedEvent.toString(), false); + sendEvent("close", closedEvent.toString(), false, false); } @Override public void onFailure(@NonNull WebSocket webSocket, Throwable t, Response response) { this.readyState = 3; // CLOSED - sendEvent("error", t.getMessage(), false); - Log.e("WebSocketInstance", "websocket instanceId=" + this.instanceId + " Error: " + t.getMessage()); + sendEvent("error", t.getMessage(), false, false); + Log.e(TAG, "websocket instanceId=" + this.instanceId + " Error: " + t.getMessage()); + } + + public void setBinaryType(String binaryType) { + this.binaryType = binaryType; } - private void sendEvent(String type, String data, boolean isBinary) { + private void sendEvent(String type, String data, boolean isBinary, boolean parseAsText) { if (callbackContext != null) { try { JSONObject event = new JSONObject(); event.put("type", type); event.put("extensions", this.extensions); event.put("readyState", this.readyState); - event.put("isBinary", isBinary ? true : false); + event.put("isBinary", isBinary); + event.put("parseAsText", parseAsText); if (data != null) event.put("data", data); - Log.d("WebSocketInstance", "sending event: " + type + " eventObj " + event.toString()); + Log.d(TAG, "sending event: " + type + " eventObj " + event.toString()); PluginResult result = new PluginResult(PluginResult.Status.OK, event); result.setKeepCallback(true); callbackContext.sendPluginResult(result); } catch (Exception e) { - Log.e("WebSocketInstance", "Error sending event", e); + Log.e(TAG, "Error sending event", e); } } } diff --git a/src/plugins/websocket/src/android/WebSocketPlugin.java b/src/plugins/websocket/src/android/WebSocketPlugin.java index 67e9e359c..eb318e6e2 100644 --- a/src/plugins/websocket/src/android/WebSocketPlugin.java +++ b/src/plugins/websocket/src/android/WebSocketPlugin.java @@ -5,7 +5,6 @@ import org.apache.cordova.*; import org.json.*; -import java.util.HashMap; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -23,74 +22,95 @@ protected void pluginInitialize() { @Override public boolean execute(String action, JSONArray args, final CallbackContext callbackContext) throws JSONException { - switch (action) { - case "connect": - String url = args.optString(0); - JSONArray protocols = args.optJSONArray(1); - JSONObject headers = args.optJSONObject(2); - String binaryType = args.optString(3, null); - String id = UUID.randomUUID().toString(); - WebSocketInstance instance = new WebSocketInstance(url, protocols, headers, binaryType, this.okHttpMainClient, cordova, id); - instances.put(id, instance); - callbackContext.success(id); - return true; - - case "send": - String instanceId = args.optString(0); - String message = args.optString(1); - WebSocketInstance inst = instances.get(instanceId); - Log.d("WebSocketPlugin", "send called"); - if (inst != null) { - inst.send(message); - callbackContext.success(); - } else { - callbackContext.error("Invalid instance ID"); - } - return true; - - case "close": - instanceId = args.optString(0); - // defaults code to 1000 & reason to "Normal closure" - int code = args.optInt(1, 1000); - String reason = args.optString(2, "Normal closure"); - inst = instances.get(instanceId); - if (inst != null) { - String error = inst.close(code, reason); - - if(error == null) { - instances.remove(instanceId); - callbackContext.success(); - return true; - } else if(!error.isEmpty()) { - // if error is empty means the websocket is not ready/open. - callbackContext.error(error); - return true; - } - } else { - callbackContext.error("Invalid instance ID"); - } - return true; - - case "registerListener": - instanceId = args.optString(0); - inst = instances.get(instanceId); - if (inst != null) { - inst.setCallback(callbackContext); - } else { - callbackContext.error("Invalid instance ID"); - } - return true; + cordova.getThreadPool().execute(new Runnable() { + @Override + public void run() { + + switch (action) { + case "connect": + String url = args.optString(0); + JSONArray protocols = args.optJSONArray(1); + JSONObject headers = args.optJSONObject(2); + String binaryType = args.optString(3, null); + String id = UUID.randomUUID().toString(); + WebSocketInstance instance = new WebSocketInstance(url, protocols, headers, binaryType, okHttpMainClient, cordova, id); + instances.put(id, instance); + callbackContext.success(id); + return; + + case "send": + String instanceId = args.optString(0); + String message = args.optString(1); + boolean isBinary = args.optBoolean(2, false); + + WebSocketInstance inst = instances.get(instanceId); + Log.d("WebSocketPlugin", "send called"); + if (inst != null) { + inst.send(message, isBinary); + callbackContext.success(); + } else { + callbackContext.error("Invalid instance ID"); + } + return; - case "listClients": - JSONArray clientIds = new JSONArray(); - for (String clientId : instances.keySet()) { - clientIds.put(clientId); + case "close": + instanceId = args.optString(0); + // defaults code to 1000 & reason to "Normal closure" + int code = args.optInt(1, 1000); + String reason = args.optString(2, "Normal closure"); + inst = instances.get(instanceId); + if (inst != null) { + String error = inst.close(code, reason); + + if(error == null) { + instances.remove(instanceId); + callbackContext.success(); + return; + } else if(!error.isEmpty()) { + // if error is empty means the websocket is not ready/open. + callbackContext.error(error); + return; + } + } else { + callbackContext.error("Invalid instance ID"); + } + return; + + case "registerListener": + instanceId = args.optString(0); + inst = instances.get(instanceId); + if (inst != null) { + inst.setCallback(callbackContext); + } else { + callbackContext.error("Invalid instance ID"); + } + return; + + case "setBinaryType": + instanceId = args.optString(0); + String type = args.optString(1); + + inst = instances.get(instanceId); + if (inst != null) { + inst.setBinaryType(type); + } else { + Log.d("WebSocketPlugin", "setBinaryType called for instanceId=" + instanceId + " but It's not found. ignoring...."); + } + return; + + case "listClients": + JSONArray clientIds = new JSONArray(); + for (String clientId : instances.keySet()) { + clientIds.put(clientId); + } + callbackContext.success(clientIds); + return; + default: + return; } - callbackContext.success(clientIds); - return true; - default: - return false; - } + } + }); + return true; } @Override diff --git a/src/plugins/websocket/www/websocket.js b/src/plugins/websocket/www/websocket.js index 7d1630acc..f1d1e93b1 100644 --- a/src/plugins/websocket/www/websocket.js +++ b/src/plugins/websocket/www/websocket.js @@ -5,13 +5,14 @@ var exec = require('cordova/exec'); let DEBUG = false; const logIfDebug = (...args) => { - if (DEBUG) { + console.log("DEBUG flag -> ", cordova.websocket.DEBUG) + if (cordova.websocket.DEBUG) { console.log(...args); } }; class WebSocketInstance extends EventTarget { - constructor(url, instanceId) { + constructor(url, instanceId, binaryType) { super(); this.instanceId = instanceId; this.extensions = ''; @@ -21,7 +22,8 @@ class WebSocketInstance extends EventTarget { this.onclose = null; this.onerror = null; this.url = url; - this.binaryType = ''; // empty as Default is string. + // NOTE: blob is not supported currently. + this._binaryType = binaryType ? binaryType : ''; // empty as Default is string (Same Plugins might require this behavior) exec((event) => { logIfDebug(`[Cordova WebSocket - ID=${this.instanceId}] Event from native:`, event); @@ -35,7 +37,9 @@ class WebSocketInstance extends EventTarget { if (event.type === 'message') { let msgData = event.data; - if (event.isBinary && this.binaryType === 'arraybuffer') { + // parseAsText solely takes care of the state of binaryType, + // sometimes, syncing binaryType to Java side might take longer. it's there to not wrongly pass normal string as base64. + if (event.isBinary && this.binaryType === 'arraybuffer' && !event.parseAsText) { let binary = atob(msgData); let bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { @@ -44,7 +48,10 @@ class WebSocketInstance extends EventTarget { msgData = bytes.buffer; } logIfDebug(`[Cordova WebSocket - ID=${this.instanceId}] msg Event:`, event, msgData); - const msgEvent = new MessageEvent('message', { data: msgData }); + const msgEvent = new MessageEvent('message', { data: msgData }); + + Object.defineProperty(msgEvent, "binary", { enumerable: true, value: event.isBinary }) + if (this.onmessage) this.onmessage(msgEvent); this.dispatchEvent(msgEvent); } @@ -64,7 +71,23 @@ class WebSocketInstance extends EventTarget { }, null, "WebSocketPlugin", "registerListener", [this.instanceId]); } - send(message) { + get binaryType() { + return this._binaryType || ''; + } + + set binaryType(type) { + // blob isn't supported but checked as browser compatibility, & it default to empty string + if (type === 'blob' || type === 'arraybuffer' || type === '') { + this._binaryType = type !== 'blob' ? type : ''; + + exec(null, null, "WebSocketPlugin", "setBinaryType", [this.instanceId, type]); + } else { + console.warn('Invalid binaryType, expected "blob" or "arraybuffer"'); + } + } + + + send(message, binary) { if (this.readyState !== WebSocketInstance.OPEN) { throw new Error(`WebSocket is not open/connected`); } @@ -73,12 +96,18 @@ class WebSocketInstance extends EventTarget { if (message instanceof ArrayBuffer || ArrayBuffer.isView(message)) { const uint8Array = message instanceof ArrayBuffer ? new Uint8Array(message) : message; finalMessage = btoa(String.fromCharCode.apply(null, uint8Array)); - - exec(() => logIfDebug(`[Cordova WebSocket - ID=${this.instanceId}] Sent message:`, finalMessage), (err) => console.error(`[Cordova WebSocket - ID=${this.instanceId}] Send error:`, err), "WebSocketPlugin", "send", [this.instanceId, finalMessage]); + + // set to true as it's the data of a binary (type 0x2) message. + binary = true; + + exec(() => logIfDebug(`[Cordova WebSocket - ID=${this.instanceId}] Sent message(binary payload):`, finalMessage), (err) => console.error(`[Cordova WebSocket - ID=${this.instanceId}] Send error:`, err), "WebSocketPlugin", "send", [this.instanceId, finalMessage, binary]); } else if (typeof message === 'string') { finalMessage = message; + + // maybe a String to be sent as Binary (if it's true) + if(binary) finalMessage = btoa(message) - exec(() => logIfDebug(`[Cordova WebSocket - ID=${this.instanceId}] Sent message:`, finalMessage), (err) => console.error(`[Cordova WebSocket - ID=${this.instanceId}] Send error:`, err), "WebSocketPlugin", "send", [this.instanceId, finalMessage]); + exec(() => logIfDebug(`[Cordova WebSocket - ID=${this.instanceId}] Sent message(binary=${binary}):`, finalMessage), (err) => console.error(`[Cordova WebSocket - ID=${this.instanceId}] Send error:`, err), "WebSocketPlugin", "send", [this.instanceId, finalMessage, binary]); } else { throw new Error(`Unsupported message type: ${typeof message}`); } @@ -115,15 +144,18 @@ const listClients = function() { /** Utility functions, in-case you lost the websocketInstance returned from the connect function */ -const send = function(instanceId, message) { +const send = function(instanceId, message, binary) { return new Promise((resolve, reject) => { if (typeof message === 'string') { - exec(resolve, reject, "WebSocketPlugin", "send", [instanceId, message]); + + if(binary) message = btoa(message); + + exec(resolve, reject, "WebSocketPlugin", "send", [instanceId, message, binary]); } else if (message instanceof ArrayBuffer || ArrayBuffer.isView(message)) { const uint8Array = message instanceof ArrayBuffer ? new Uint8Array(message) : message; const base64Message = btoa(String.fromCharCode.apply(null, uint8Array)); - exec(resolve, reject, "WebSocketPlugin", "send", [instanceId, base64Message]); + exec(resolve, reject, "WebSocketPlugin", "send", [instanceId, base64Message, true]); } else { reject(`Unsupported message type: ${typeof message}`); } From d60f21eeada1d9f708f64fad08ee9836b6803997 Mon Sep 17 00:00:00 2001 From: UnschooledGamer Date: Mon, 9 Jun 2025 21:38:58 +0530 Subject: [PATCH 13/14] feat: improve WebSocket instance management on close event - Added logic to remove the WebSocket instance from the plugin after a close event is received. - Introduced a new static method `removeInstance` in `WebSocketPlugin` to handle instance removal. --- src/plugins/websocket/src/android/WebSocketInstance.java | 2 ++ src/plugins/websocket/src/android/WebSocketPlugin.java | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/plugins/websocket/src/android/WebSocketInstance.java b/src/plugins/websocket/src/android/WebSocketInstance.java index e8f1254cf..1a1a50875 100644 --- a/src/plugins/websocket/src/android/WebSocketInstance.java +++ b/src/plugins/websocket/src/android/WebSocketInstance.java @@ -179,6 +179,8 @@ public void onClosed(@NonNull WebSocket webSocket, int code, @NonNull String rea Log.e(TAG, "Error creating close event", e); } sendEvent("close", closedEvent.toString(), false, false); + // remove instance after receiving close. + WebSocketPlugin.removeInstance(this.instanceId); } @Override diff --git a/src/plugins/websocket/src/android/WebSocketPlugin.java b/src/plugins/websocket/src/android/WebSocketPlugin.java index eb318e6e2..c8fb832f3 100644 --- a/src/plugins/websocket/src/android/WebSocketPlugin.java +++ b/src/plugins/websocket/src/android/WebSocketPlugin.java @@ -63,7 +63,6 @@ public void run() { String error = inst.close(code, reason); if(error == null) { - instances.remove(instanceId); callbackContext.success(); return; } else if(!error.isEmpty()) { @@ -124,4 +123,8 @@ public void onDestroy() { okHttpMainClient.dispatcher().executorService().shutdown(); Log.i("WebSocketPlugin", "cleaned up... on destroy"); } + + public static void removeInstance(String instanceId) { + instances.remove(instanceId); + } } From 2277e409afd44774ee74e91db6025dfb25bf0a30 Mon Sep 17 00:00:00 2001 From: UnschooledGamer Date: Tue, 10 Jun 2025 21:35:17 +0530 Subject: [PATCH 14/14] fix: ensure WebSocket closes properly on closing event - Added a call to `this.webSocket.close(code, reason)` in the `onClosing` method to ensure the WebSocket instance closes with the specified code and reason. Ref: https://github.com/square/okhttp/issues/6510#issuecomment-761434780 --- src/plugins/websocket/src/android/WebSocketInstance.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plugins/websocket/src/android/WebSocketInstance.java b/src/plugins/websocket/src/android/WebSocketInstance.java index 1a1a50875..dd9241aea 100644 --- a/src/plugins/websocket/src/android/WebSocketInstance.java +++ b/src/plugins/websocket/src/android/WebSocketInstance.java @@ -165,6 +165,7 @@ public void onMessage(@NonNull WebSocket webSocket, @NonNull ByteString bytes) { public void onClosing(@NonNull WebSocket webSocket, int code, @NonNull String reason) { this.readyState = 2; // CLOSING Log.i(TAG, "websocket instanceId=" + this.instanceId + " is Closing code: " + code + " reason: " + reason); + this.webSocket.close(code, reason); } @Override