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 diff --git a/package-lock.json b/package-lock.json index 6a6718dd1..27d407663 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,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", @@ -4635,6 +4636,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", @@ -10581,6 +10586,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 c3c31c4f5..3b049cc7a 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "cordova-plugin-advanced-http": { "ANDROIDBLACKLISTSECURESOCKETPROTOCOLS": "SSLv3,TLSv1" }, + "cordova-plugin-websocket": {}, "com.foxdebug.acode.exec": {} }, "platforms": [ @@ -76,6 +77,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..e2086e762 --- /dev/null +++ b/src/plugins/websocket/README.md @@ -0,0 +1,101 @@ +# 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 +* ✅ `listClients()` to list active connections +* ✅ Support for protocols +* ✅ Support for Custom Headers. +* ✅ Compatible with Cordova for Android + +--- + +## Usage + +### Import + +```javascript +const WebSocketPlugin = cordova.websocket; +``` + +### Connect to WebSocket + +```javascript +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); + 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, headers)` + + * 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. + * **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(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). + +--- + +### 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). +* `listClients()` is useful for debugging and management. +--- \ 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..9f5ab5351 --- /dev/null +++ b/src/plugins/websocket/src/android/WebSocketInstance.java @@ -0,0 +1,194 @@ +package com.foxdebug.websocket; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import org.apache.cordova.*; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Iterator; +import java.util.concurrent.TimeUnit; + +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"; + + private WebSocket webSocket; + private CallbackContext callbackContext; + 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, 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) + .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++) { + 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 (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 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 String close() { + Log.d("WebSocketInstance", "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); + } + + @Override + 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, 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); + } + + // 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 + 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(@NonNull WebSocket webSocket, int code, @NonNull String reason) { + this.readyState = 3; // CLOSED + 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(), 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()); + } + + 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); + } 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..67e9e359c --- /dev/null +++ b/src/plugins/websocket/src/android/WebSocketPlugin.java @@ -0,0 +1,107 @@ +package com.foxdebug.websocket; + +import android.util.Log; + +import org.apache.cordova.*; +import org.json.*; + +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 ConcurrentHashMap instances = new ConcurrentHashMap<>(); + 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 { + 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; + + 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(); + Log.i("WebSocketPlugin", "cleaned up... on destroy"); + } +} diff --git a/src/plugins/websocket/www/websocket.js b/src/plugins/websocket/www/websocket.js new file mode 100644 index 000000000..7d1630acc --- /dev/null +++ b/src/plugins/websocket/www/websocket.js @@ -0,0 +1,148 @@ +var exec = require('cordova/exec'); +/** + * Whether to log debug messages + */ +let DEBUG = false; + +const logIfDebug = (...args) => { + if (DEBUG) { + console.log(...args); + } +}; + +class WebSocketInstance extends EventTarget { + constructor(url, instanceId) { + super(); + this.instanceId = instanceId; + this.extensions = ''; + this.readyState = WebSocketInstance.CONNECTING; + this.onopen = null; + this.onmessage = null; + 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(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 === 'close') { + this.readyState = WebSocketInstance.CLOSED; + 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); + } + }, null, "WebSocketPlugin", "registerListener", [this.instanceId]); + } + + send(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(`Unsupported message type: ${typeof message}`); + } + } + + /** + * 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(() => 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]); + } +} + +WebSocketInstance.CONNECTING = 0; +WebSocketInstance.OPEN = 1; +WebSocketInstance.CLOSING = 2; +WebSocketInstance.CLOSED = 3; + +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, binaryType, headers]); + }); +}; + +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) => { + 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}`); + } + }); +}; + +/** + * 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, DEBUG };