From eb322fa92bf9331f757a12a11806965cf048fe0b Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Wed, 14 May 2025 20:45:46 +0200 Subject: [PATCH 01/16] Add custom Capacitor plugin-socket --- .../betaflight/configurator/MainActivity.java | 1 + .../configurator/plugin/SocketPlugin.java | 72 +++++++++++++++++++ capacitor-plugin-socket/package.json | 16 +++++ capacitor-plugin-socket/src/definitions.ts | 6 ++ capacitor-plugin-socket/src/index.ts | 8 +++ capacitor-plugin-socket/src/web.ts | 24 +++++++ 6 files changed, 127 insertions(+) create mode 100644 android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java create mode 100644 capacitor-plugin-socket/package.json create mode 100644 capacitor-plugin-socket/src/definitions.ts create mode 100644 capacitor-plugin-socket/src/index.ts create mode 100644 capacitor-plugin-socket/src/web.ts diff --git a/android/app/src/main/java/betaflight/configurator/MainActivity.java b/android/app/src/main/java/betaflight/configurator/MainActivity.java index b82248760d..1ae427259d 100644 --- a/android/app/src/main/java/betaflight/configurator/MainActivity.java +++ b/android/app/src/main/java/betaflight/configurator/MainActivity.java @@ -1,5 +1,6 @@ package betaflight.configurator; import com.getcapacitor.BridgeActivity; +import betaflight.configurator.plugin.SocketPlugin; public class MainActivity extends BridgeActivity {} diff --git a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java new file mode 100644 index 0000000000..45ec8585b4 --- /dev/null +++ b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java @@ -0,0 +1,72 @@ +package betaflight.configurator.plugin; + +import android.util.Log; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.annotation.CapacitorPlugin; + +import java.io.*; +import java.net.Socket; + +@CapacitorPlugin(name = "SocketPlugin") +public class SocketPlugin extends Plugin { + private Socket socket; + private BufferedReader reader; + private BufferedWriter writer; + + @PluginMethod + public void connect(PluginCall call) { + String ip = call.getString("ip"); + int port = call.getInt("port"); + try { + socket = new Socket(ip, port); + reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); + writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())); + JSObject ret = new JSObject(); + ret.put("success", true); + call.resolve(ret); + } catch (Exception e) { + call.reject("Connection failed: " + e.getMessage()); + } + } + + @PluginMethod + public void send(PluginCall call) { + String data = call.getString("data"); + try { + writer.write(data); + writer.flush(); + JSObject ret = new JSObject(); + ret.put("success", true); + call.resolve(ret); + } catch (Exception e) { + call.reject("Send failed: " + e.getMessage()); + } + } + + @PluginMethod + public void receive(PluginCall call) { + try { + String data = reader.readLine(); + JSObject ret = new JSObject(); + ret.put("data", data); + call.resolve(ret); + } catch (Exception e) { + call.reject("Receive failed: " + e.getMessage()); + } + } + + @PluginMethod + public void disconnect(PluginCall call) { + try { + if (socket != null) socket.close(); + JSObject ret = new JSObject(); + ret.put("success", true); + call.resolve(ret); + } catch (Exception e) { + call.reject("Disconnect failed: " + e.getMessage()); + } + } +} diff --git a/capacitor-plugin-socket/package.json b/capacitor-plugin-socket/package.json new file mode 100644 index 0000000000..33474f77b7 --- /dev/null +++ b/capacitor-plugin-socket/package.json @@ -0,0 +1,16 @@ +{ + "name": "capacitor-plugin-socket", + "version": "1.0.0", + "description": "A Capacitor plugin for handling raw TCP sockets.", + "main": "index.js", + "scripts": { + "build": "tsc" + }, + "keywords": ["capacitor", "plugin", "tcp", "socket"], + "author": "Your Name", + "license": "MIT", + "devDependencies": { + "@capacitor/core": "latest", + "typescript": "^5.0.0" + } +} diff --git a/capacitor-plugin-socket/src/definitions.ts b/capacitor-plugin-socket/src/definitions.ts new file mode 100644 index 0000000000..5baf7a5f5a --- /dev/null +++ b/capacitor-plugin-socket/src/definitions.ts @@ -0,0 +1,6 @@ +export interface SocketPlugin { + connect(options: { ip: string; port: number }): Promise<{ success: boolean }>; + send(options: { data: string }): Promise<{ success: boolean }>; + receive(): Promise<{ data: string }>; + disconnect(): Promise<{ success: boolean }>; +} diff --git a/capacitor-plugin-socket/src/index.ts b/capacitor-plugin-socket/src/index.ts new file mode 100644 index 0000000000..255032f479 --- /dev/null +++ b/capacitor-plugin-socket/src/index.ts @@ -0,0 +1,8 @@ +import { registerPlugin } from '@capacitor/core'; + +const SocketPlugin = registerPlugin('SocketPlugin', { + web: () => import('./web').then(m => new m.SocketPluginWeb()), +}); + +export * from './definitions'; +export { SocketPlugin }; diff --git a/capacitor-plugin-socket/src/web.ts b/capacitor-plugin-socket/src/web.ts new file mode 100644 index 0000000000..7347aba52a --- /dev/null +++ b/capacitor-plugin-socket/src/web.ts @@ -0,0 +1,24 @@ +import { WebPlugin } from '@capacitor/core'; +import { SocketPlugin } from './definitions'; + +export class SocketPluginWeb extends WebPlugin implements SocketPlugin { + async connect(options: { ip: string; port: number }): Promise<{ success: boolean }> { + console.log('Web implementation does not support raw TCP sockets.', options); + return { success: false }; + } + + async send(options: { data: string }): Promise<{ success: boolean }> { + console.log('Web implementation does not support raw TCP sockets.', options); + return { success: false }; + } + + async receive(): Promise<{ data: string }> { + console.log('Web implementation does not support raw TCP sockets.'); + return { data: '' }; + } + + async disconnect(): Promise<{ success: boolean }> { + console.log('Web implementation does not support raw TCP sockets.'); + return { success: false }; + } +} From acfa06d8af5d0a73a41ece344f41e4fab58b2398 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Thu, 15 May 2025 20:56:00 +0200 Subject: [PATCH 02/16] Rabbit fixes --- .../betaflight/configurator/MainActivity.java | 9 ++- .../configurator/plugin/SocketPlugin.java | 69 ++++++++++++++++++- capacitor-plugin-socket/package.json | 13 ++-- 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/java/betaflight/configurator/MainActivity.java b/android/app/src/main/java/betaflight/configurator/MainActivity.java index 1ae427259d..b9d79a4def 100644 --- a/android/app/src/main/java/betaflight/configurator/MainActivity.java +++ b/android/app/src/main/java/betaflight/configurator/MainActivity.java @@ -1,6 +1,13 @@ package betaflight.configurator; +import android.os.Bundle; import com.getcapacitor.BridgeActivity; import betaflight.configurator.plugin.SocketPlugin; -public class MainActivity extends BridgeActivity {} +public class MainActivity extends BridgeActivity { + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + registerPlugin(SocketPlugin.class); + } +} diff --git a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java index 45ec8585b4..4fae37f904 100644 --- a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java @@ -10,31 +10,70 @@ import java.io.*; import java.net.Socket; +/** + * Capacitor plugin that provides raw TCP socket functionality. + * Implements methods to connect, send, receive, and disconnect. + */ @CapacitorPlugin(name = "SocketPlugin") public class SocketPlugin extends Plugin { private Socket socket; private BufferedReader reader; private BufferedWriter writer; + private boolean isConnected = false; @PluginMethod public void connect(PluginCall call) { String ip = call.getString("ip"); int port = call.getInt("port"); + + // Validate inputs + if (ip == null || ip.isEmpty()) { + call.reject("IP address is required"); + return; + } + + if (port <= 0 || port > 65535) { + call.reject("Invalid port number"); + return; + } + + // Prevent duplicate connections + if (socket != null && !socket.isClosed()) { + call.reject("Already connected; please disconnect first"); + return; + } + try { socket = new Socket(ip, port); + socket.setSoTimeout(30_000); // 30s timeout reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())); + isConnected = true; JSObject ret = new JSObject(); ret.put("success", true); call.resolve(ret); } catch (Exception e) { + closeResources(); call.reject("Connection failed: " + e.getMessage()); } } - @PluginMethod +@PluginMethod public void send(PluginCall call) { String data = call.getString("data"); + + // Validate input + if (data == null) { + call.reject("Data is required"); + return; + } + + // Check connection state + if (socket == null || socket.isClosed() || !isConnected) { + call.reject("Not connected to any server"); + return; + } + try { writer.write(data); writer.flush(); @@ -42,6 +81,8 @@ public void send(PluginCall call) { ret.put("success", true); call.resolve(ret); } catch (Exception e) { + closeResources(); + isConnected = false; call.reject("Send failed: " + e.getMessage()); } } @@ -61,7 +102,8 @@ public void receive(PluginCall call) { @PluginMethod public void disconnect(PluginCall call) { try { - if (socket != null) socket.close(); + closeResources(); + isConnected = false; JSObject ret = new JSObject(); ret.put("success", true); call.resolve(ret); @@ -69,4 +111,27 @@ public void disconnect(PluginCall call) { call.reject("Disconnect failed: " + e.getMessage()); } } + + /** + * Helper method to close all resources and clean up state + */ + private void closeResources() { + try { + if (reader != null) { + reader.close(); + reader = null; + } + if (writer != null) { + writer.close(); + writer = null; + } + if (socket != null) { + socket.close(); + socket = null; + } + } catch (IOException e) { + // Log but continue cleanup + getContext().getActivity().runOnUiThread(() -> + Log.e("SocketPlugin", "Error closing resources", e)); + } } diff --git a/capacitor-plugin-socket/package.json b/capacitor-plugin-socket/package.json index 33474f77b7..febb6268bd 100644 --- a/capacitor-plugin-socket/package.json +++ b/capacitor-plugin-socket/package.json @@ -2,15 +2,20 @@ "name": "capacitor-plugin-socket", "version": "1.0.0", "description": "A Capacitor plugin for handling raw TCP sockets.", - "main": "index.js", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": ["dist/*", "package.json", "README.md"], "scripts": { - "build": "tsc" + "clean": "rimraf dist", + "build": "npm run clean && tsc -p tsconfig.json" }, "keywords": ["capacitor", "plugin", "tcp", "socket"], - "author": "Your Name", + "author": "Betaflight ", "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^5.0.0" + }, "devDependencies": { - "@capacitor/core": "latest", "typescript": "^5.0.0" } } From e337188250630690b087a1e759a9c8eda417af0b Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 16 May 2025 13:09:56 +0200 Subject: [PATCH 03/16] Refactor SocketPlugin.java --- .../configurator/plugin/SocketPlugin.java | 67 ++++++++++++------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java index 4fae37f904..8c3d0bd12b 100644 --- a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java @@ -43,22 +43,25 @@ public void connect(PluginCall call) { return; } - try { - socket = new Socket(ip, port); - socket.setSoTimeout(30_000); // 30s timeout - reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); - writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())); - isConnected = true; - JSObject ret = new JSObject(); - ret.put("success", true); - call.resolve(ret); - } catch (Exception e) { - closeResources(); - call.reject("Connection failed: " + e.getMessage()); - } + // Run network operations on a background thread + getBridge().getExecutor().execute(() -> { + try { + socket = new Socket(ip, port); + socket.setSoTimeout(30_000); // 30s timeout + reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); + writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())); + isConnected = true; + JSObject ret = new JSObject(); + ret.put("success", true); + call.resolve(ret); + } catch (Exception e) { + closeResources(); + call.reject("Connection failed: " + e.getMessage()); + } + }); } -@PluginMethod + @PluginMethod public void send(PluginCall call) { String data = call.getString("data"); @@ -69,26 +72,38 @@ public void send(PluginCall call) { } // Check connection state - if (socket == null || socket.isClosed() || !isConnected) { + if (socket == null || socket.isClosed() || !isConnected || reader == null || writer == null) { call.reject("Not connected to any server"); return; } - try { - writer.write(data); - writer.flush(); - JSObject ret = new JSObject(); - ret.put("success", true); - call.resolve(ret); - } catch (Exception e) { - closeResources(); - isConnected = false; - call.reject("Send failed: " + e.getMessage()); - } + // Run write operation on a background thread and synchronize on writer + getBridge().getExecutor().execute(() -> { + try { + synchronized (writer) { + // Append newline for framing; adjust as needed for your protocol + writer.write(data); + writer.newLine(); + writer.flush(); + } + JSObject ret = new JSObject(); + ret.put("success", true); + call.resolve(ret); + } catch (Exception e) { + closeResources(); + isConnected = false; + call.reject("Send failed: " + e.getMessage()); + } + }); } @PluginMethod public void receive(PluginCall call) { + // Check connection state + if (socket == null || socket.isClosed() || !isConnected || reader == null) { + call.reject("Not connected to any server"); + return; + } try { String data = reader.readLine(); JSObject ret = new JSObject(); From bf1924afbec807f44dd3d9630c76260550c514f1 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 16 May 2025 13:48:37 +0200 Subject: [PATCH 04/16] Move receive operation to background thread --- .../configurator/plugin/SocketPlugin.java | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java index 8c3d0bd12b..fbbeba9a5f 100644 --- a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java @@ -104,14 +104,18 @@ public void receive(PluginCall call) { call.reject("Not connected to any server"); return; } - try { - String data = reader.readLine(); - JSObject ret = new JSObject(); - ret.put("data", data); - call.resolve(ret); - } catch (Exception e) { - call.reject("Receive failed: " + e.getMessage()); - } + + // Run read operation on a background thread to avoid blocking the UI + getBridge().getExecutor().execute(() -> { + try { + String data = reader.readLine(); + JSObject ret = new JSObject(); + ret.put("data", data); + call.resolve(ret); + } catch (Exception e) { + call.reject("Receive failed: " + e.getMessage()); + } + }); } @PluginMethod From f55e7bef1d368e328231370d815483cce7fd073b Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 16 May 2025 14:17:26 +0200 Subject: [PATCH 05/16] update isConnected flag in closeResources catch block --- .../configurator/plugin/SocketPlugin.java | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java index fbbeba9a5f..36b81d3374 100644 --- a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java @@ -135,22 +135,24 @@ public void disconnect(PluginCall call) { * Helper method to close all resources and clean up state */ private void closeResources() { - try { - if (reader != null) { - reader.close(); - reader = null; - } - if (writer != null) { - writer.close(); - writer = null; - } - if (socket != null) { - socket.close(); - socket = null; + try { + if (reader != null) { + reader.close(); + reader = null; + } + if (writer != null) { + writer.close(); + writer = null; + } + if (socket != null) { + socket.close(); + socket = null; + } + } catch (IOException e) { + // Log but continue cleanup + isConnected = false; + getContext().getActivity().runOnUiThread(() -> + Log.e("SocketPlugin", "Error closing resources", e)); } - } catch (IOException e) { - // Log but continue cleanup - getContext().getActivity().runOnUiThread(() -> - Log.e("SocketPlugin", "Error closing resources", e)); } } From cfefb6a630015e0f6d2c6b156ec3a1a89eb14ab9 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 16 May 2025 14:18:11 +0200 Subject: [PATCH 06/16] Handle null return from readLine --- .../java/betaflight/configurator/plugin/SocketPlugin.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java index 36b81d3374..bb6359d89e 100644 --- a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java @@ -109,6 +109,13 @@ public void receive(PluginCall call) { getBridge().getExecutor().execute(() -> { try { String data = reader.readLine(); + if (data == null) { + // Stream ended or connection closed by peer + closeResources(); + isConnected = false; + call.reject("Connection closed by peer"); + return; + } JSObject ret = new JSObject(); ret.put("data", data); call.resolve(ret); From 39715abfd04d8444794746e913a20ca262e42f0f Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 16 May 2025 14:24:56 +0200 Subject: [PATCH 07/16] guard against missing or null port parameter --- .../main/java/betaflight/configurator/plugin/SocketPlugin.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java index bb6359d89e..9ec34e2b9d 100644 --- a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java @@ -24,7 +24,8 @@ public class SocketPlugin extends Plugin { @PluginMethod public void connect(PluginCall call) { String ip = call.getString("ip"); - int port = call.getInt("port"); + // Use a default value (e.g., -1) to avoid NullPointerException if "port" is missing + int port = call.getInt("port", -1); // Validate inputs if (ip == null || ip.isEmpty()) { From e7773604e6c5c559b3e33bc1ac8c2cf9b54c28c0 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 16 May 2025 14:36:41 +0200 Subject: [PATCH 08/16] fix inconsistent error handling in receive method --- .../main/java/betaflight/configurator/plugin/SocketPlugin.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java index 9ec34e2b9d..2e5c766b48 100644 --- a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java @@ -121,6 +121,8 @@ public void receive(PluginCall call) { ret.put("data", data); call.resolve(ret); } catch (Exception e) { + closeResources(); + isConnected = false; call.reject("Receive failed: " + e.getMessage()); } }); From 6e8146c497a76e9fc2aebc728da67fb727b66c7f Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 16 May 2025 17:27:20 +0200 Subject: [PATCH 09/16] Rabbit keeps nitpicking --- .../configurator/plugin/SocketPlugin.java | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java index 2e5c766b48..a66b7d2705 100644 --- a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java @@ -77,15 +77,19 @@ public void send(PluginCall call) { call.reject("Not connected to any server"); return; } - // Run write operation on a background thread and synchronize on writer getBridge().getExecutor().execute(() -> { try { - synchronized (writer) { + BufferedWriter localWriter = writer; // capture after re-check + if (localWriter == null) { + call.reject("Connection lost"); + return; + } + synchronized (localWriter) { // Append newline for framing; adjust as needed for your protocol - writer.write(data); - writer.newLine(); - writer.flush(); + localWriter.write(data); + localWriter.newLine(); + localWriter.flush(); } JSObject ret = new JSObject(); ret.put("success", true); @@ -130,15 +134,17 @@ public void receive(PluginCall call) { @PluginMethod public void disconnect(PluginCall call) { - try { - closeResources(); - isConnected = false; - JSObject ret = new JSObject(); - ret.put("success", true); - call.resolve(ret); - } catch (Exception e) { - call.reject("Disconnect failed: " + e.getMessage()); - } + getBridge().getExecutor().execute(() -> { + try { + closeResources(); + isConnected = false; + JSObject ret = new JSObject(); + ret.put("success", true); + call.resolve(ret); + } catch (Exception e) { + call.reject("Disconnect failed: " + e.getMessage()); + } + }); } /** @@ -151,6 +157,7 @@ private void closeResources() { reader = null; } if (writer != null) { + writer.flush(); writer.close(); writer = null; } From 7c25def3b7bace94b7601b6ebe6ff7ed5b4a7a91 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 16 May 2025 19:25:29 +0200 Subject: [PATCH 10/16] Suggested by rabbit --- .../configurator/plugin/SocketPlugin.java | 267 ++++++++++++------ 1 file changed, 174 insertions(+), 93 deletions(-) diff --git a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java index a66b7d2705..5b068d96a2 100644 --- a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java @@ -1,175 +1,256 @@ package betaflight.configurator.plugin; import android.util.Log; - import com.getcapacitor.JSObject; import com.getcapacitor.Plugin; import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; import com.getcapacitor.annotation.CapacitorPlugin; - -import java.io.*; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.net.InetSocketAddress; import java.net.Socket; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; /** - * Capacitor plugin that provides raw TCP socket functionality. - * Implements methods to connect, send, receive, and disconnect. + * Capacitor plugin that provides raw TCP socket functionality with thread safety, + * robust resource management, and comprehensive error handling. */ @CapacitorPlugin(name = "SocketPlugin") public class SocketPlugin extends Plugin { + private static final String TAG = "SocketPlugin"; + + // Error messages + private static final String ERROR_IP_REQUIRED = "IP address is required"; + private static final String ERROR_INVALID_PORT = "Invalid port number"; + private static final String ERROR_ALREADY_CONNECTED = "Already connected; please disconnect first"; + private static final String ERROR_NOT_CONNECTED = "Not connected to any server"; + private static final String ERROR_DATA_REQUIRED = "Data is required"; + private static final String ERROR_CONNECTION_LOST = "Connection lost"; + private static final String ERROR_CONNECTION_CLOSED = "Connection closed by peer"; + + // Connection settings + private static final int DEFAULT_TIMEOUT_MS = 30_000; + private static final int MIN_PORT = 1; + private static final int MAX_PORT = 65535; + + private enum ConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED, + DISCONNECTING, + ERROR + } + + // Thread-safe state and locks + private final AtomicReference state = new AtomicReference<>(ConnectionState.DISCONNECTED); + private final ReentrantLock socketLock = new ReentrantLock(); + private final ReentrantLock writerLock = new ReentrantLock(); + private Socket socket; private BufferedReader reader; private BufferedWriter writer; - private boolean isConnected = false; @PluginMethod - public void connect(PluginCall call) { + public void connect(final PluginCall call) { + call.setKeepAlive(true); String ip = call.getString("ip"); - // Use a default value (e.g., -1) to avoid NullPointerException if "port" is missing int port = call.getInt("port", -1); - // Validate inputs if (ip == null || ip.isEmpty()) { - call.reject("IP address is required"); + call.reject(ERROR_IP_REQUIRED); + call.setKeepAlive(false); return; } - - if (port <= 0 || port > 65535) { - call.reject("Invalid port number"); + if (port < MIN_PORT || port > MAX_PORT) { + call.reject(ERROR_INVALID_PORT); + call.setKeepAlive(false); return; } - - // Prevent duplicate connections - if (socket != null && !socket.isClosed()) { - call.reject("Already connected; please disconnect first"); + if (!compareAndSetState(ConnectionState.DISCONNECTED, ConnectionState.CONNECTING)) { + call.reject(ERROR_ALREADY_CONNECTED); + call.setKeepAlive(false); return; } - // Run network operations on a background thread getBridge().getExecutor().execute(() -> { + socketLock.lock(); try { - socket = new Socket(ip, port); - socket.setSoTimeout(30_000); // 30s timeout + socket = new Socket(); + socket.connect(new InetSocketAddress(ip, port), DEFAULT_TIMEOUT_MS); + socket.setSoTimeout(DEFAULT_TIMEOUT_MS); + reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())); - isConnected = true; - JSObject ret = new JSObject(); - ret.put("success", true); - call.resolve(ret); + + state.set(ConnectionState.CONNECTED); + JSObject result = new JSObject(); + result.put("success", true); + call.resolve(result); + Log.d(TAG, "Connected to " + ip + ":" + port); } catch (Exception e) { - closeResources(); + state.set(ConnectionState.ERROR); + closeResourcesInternal(); call.reject("Connection failed: " + e.getMessage()); + Log.e(TAG, "Connection failed", e); + } finally { + socketLock.unlock(); + call.setKeepAlive(false); } }); } @PluginMethod - public void send(PluginCall call) { + public void send(final PluginCall call) { String data = call.getString("data"); - - // Validate input - if (data == null) { - call.reject("Data is required"); + if (data == null || data.isEmpty()) { + call.reject(ERROR_DATA_REQUIRED); return; } - - // Check connection state - if (socket == null || socket.isClosed() || !isConnected || reader == null || writer == null) { - call.reject("Not connected to any server"); + if (state.get() != ConnectionState.CONNECTED) { + call.reject(ERROR_NOT_CONNECTED); return; } - // Run write operation on a background thread and synchronize on writer + call.setKeepAlive(true); + getBridge().getExecutor().execute(() -> { + writerLock.lock(); try { - BufferedWriter localWriter = writer; // capture after re-check - if (localWriter == null) { - call.reject("Connection lost"); + if (writer == null || state.get() != ConnectionState.CONNECTED) { + call.reject(ERROR_CONNECTION_LOST); return; } - synchronized (localWriter) { - // Append newline for framing; adjust as needed for your protocol - localWriter.write(data); - localWriter.newLine(); - localWriter.flush(); - } - JSObject ret = new JSObject(); - ret.put("success", true); - call.resolve(ret); + writer.write(data); + writer.newLine(); + writer.flush(); + + JSObject result = new JSObject(); + result.put("success", true); + call.resolve(result); + Log.d(TAG, "Sent data: " + truncateForLog(data)); } catch (Exception e) { - closeResources(); - isConnected = false; - call.reject("Send failed: " + e.getMessage()); + handleCommunicationError(e, "Send failed", call); + } finally { + writerLock.unlock(); + call.setKeepAlive(false); } }); } @PluginMethod - public void receive(PluginCall call) { - // Check connection state - if (socket == null || socket.isClosed() || !isConnected || reader == null) { - call.reject("Not connected to any server"); + public void receive(final PluginCall call) { + if (state.get() != ConnectionState.CONNECTED || reader == null) { + call.reject(ERROR_NOT_CONNECTED); return; } + call.setKeepAlive(true); - // Run read operation on a background thread to avoid blocking the UI getBridge().getExecutor().execute(() -> { try { String data = reader.readLine(); if (data == null) { - // Stream ended or connection closed by peer - closeResources(); - isConnected = false; - call.reject("Connection closed by peer"); + handleCommunicationError(new IOException("End of stream"), ERROR_CONNECTION_CLOSED, call); return; } - JSObject ret = new JSObject(); - ret.put("data", data); - call.resolve(ret); + JSObject result = new JSObject(); + result.put("data", data); + call.resolve(result); + Log.d(TAG, "Received data: " + truncateForLog(data)); } catch (Exception e) { - closeResources(); - isConnected = false; - call.reject("Receive failed: " + e.getMessage()); + handleCommunicationError(e, "Receive failed", call); + } finally { + call.setKeepAlive(false); } }); } @PluginMethod - public void disconnect(PluginCall call) { + public void disconnect(final PluginCall call) { + ConnectionState current = state.get(); + if (current == ConnectionState.DISCONNECTED) { + JSObject result = new JSObject(); + result.put("success", true); + call.resolve(result); + return; + } + if (!compareAndSetState(current, ConnectionState.DISCONNECTING)) { + call.reject("Invalid state for disconnect: " + current); + return; + } + call.setKeepAlive(true); + getBridge().getExecutor().execute(() -> { + socketLock.lock(); try { - closeResources(); - isConnected = false; - JSObject ret = new JSObject(); - ret.put("success", true); - call.resolve(ret); + closeResourcesInternal(); + state.set(ConnectionState.DISCONNECTED); + JSObject result = new JSObject(); + result.put("success", true); + call.resolve(result); + Log.d(TAG, "Disconnected successfully"); } catch (Exception e) { + state.set(ConnectionState.ERROR); call.reject("Disconnect failed: " + e.getMessage()); + Log.e(TAG, "Disconnect failed", e); + } finally { + socketLock.unlock(); + call.setKeepAlive(false); } }); } - /** - * Helper method to close all resources and clean up state - */ - private void closeResources() { + @Override + protected void handleOnDestroy() { + socketLock.lock(); try { - if (reader != null) { - reader.close(); - reader = null; - } - if (writer != null) { - writer.flush(); - writer.close(); - writer = null; - } - if (socket != null) { - socket.close(); - socket = null; - } - } catch (IOException e) { - // Log but continue cleanup - isConnected = false; - getContext().getActivity().runOnUiThread(() -> - Log.e("SocketPlugin", "Error closing resources", e)); + state.set(ConnectionState.DISCONNECTING); + closeResourcesInternal(); + state.set(ConnectionState.DISCONNECTED); + } catch (Exception e) { + Log.e(TAG, "Error cleaning up resources on destroy", e); + } finally { + socketLock.unlock(); + } + super.handleOnDestroy(); + } + + private void closeResourcesInternal() { + if (reader != null) { + try { reader.close(); } catch (IOException e) { Log.e(TAG, "Error closing reader", e);} finally { reader = null; } } + if (writer != null) { + try { writer.flush(); writer.close(); } catch (IOException e) { Log.e(TAG, "Error closing writer", e);} finally { writer = null; } + } + if (socket != null) { + try { socket.close(); } catch (IOException e) { Log.e(TAG, "Error closing socket", e);} finally { socket = null; } + } + } + + private void handleCommunicationError(Exception error, String message, PluginCall call) { + socketLock.lock(); + try { + state.set(ConnectionState.ERROR); + closeResourcesInternal(); + state.set(ConnectionState.DISCONNECTED); + call.reject(message + ": " + error.getMessage()); + Log.e(TAG, message, error); + } finally { + socketLock.unlock(); + } + } + + private boolean compareAndSetState(ConnectionState expected, ConnectionState newState) { + return state.compareAndSet(expected, newState); + } + + private String truncateForLog(String data) { + if (data == null) return "null"; + final int maxLen = 100; + if (data.length() <= maxLen) return data; + return data.substring(0, maxLen) + "... (" + data.length() + " chars)"; } } From df2a2a43ee8c56a225524de29b0141bcb328db10 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 16 May 2025 20:02:05 +0200 Subject: [PATCH 11/16] Add getStatus --- .../java/betaflight/configurator/plugin/SocketPlugin.java | 8 ++++++++ capacitor-plugin-socket/src/definitions.ts | 1 + 2 files changed, 9 insertions(+) diff --git a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java index 5b068d96a2..c9a87bea36 100644 --- a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java @@ -203,6 +203,14 @@ public void disconnect(final PluginCall call) { }); } + @PluginMethod + public void getStatus(final PluginCall call) { + JSObject result = new JSObject(); + result.put("connected", state.get() == ConnectionState.CONNECTED); + result.put("state", state.get().toString()); + call.resolve(result); + } + @Override protected void handleOnDestroy() { socketLock.lock(); diff --git a/capacitor-plugin-socket/src/definitions.ts b/capacitor-plugin-socket/src/definitions.ts index 5baf7a5f5a..2194dc50d4 100644 --- a/capacitor-plugin-socket/src/definitions.ts +++ b/capacitor-plugin-socket/src/definitions.ts @@ -3,4 +3,5 @@ export interface SocketPlugin { send(options: { data: string }): Promise<{ success: boolean }>; receive(): Promise<{ data: string }>; disconnect(): Promise<{ success: boolean }>; + getStatus(): Promise<{ connected: boolean, state: string }>; } From 338cb36d7ed8a4f51e6b294bc0a40b454db327da Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 16 May 2025 20:06:58 +0200 Subject: [PATCH 12/16] Allow new connect attempt --- .../main/java/betaflight/configurator/plugin/SocketPlugin.java | 1 + 1 file changed, 1 insertion(+) diff --git a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java index c9a87bea36..4513bf09b0 100644 --- a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java @@ -95,6 +95,7 @@ public void connect(final PluginCall call) { } catch (Exception e) { state.set(ConnectionState.ERROR); closeResourcesInternal(); + state.set(ConnectionState.DISCONNECTED); // allow a new connect() attempt call.reject("Connection failed: " + e.getMessage()); Log.e(TAG, "Connection failed", e); } finally { From 1758893513686e98aa411ef16c13bbe698b61b9a Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 16 May 2025 22:12:53 +0200 Subject: [PATCH 13/16] Replace file with rabbit suggestion again --- .../configurator/plugin/SocketPlugin.java | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java index 4513bf09b0..23b29c0552 100644 --- a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java @@ -95,7 +95,6 @@ public void connect(final PluginCall call) { } catch (Exception e) { state.set(ConnectionState.ERROR); closeResourcesInternal(); - state.set(ConnectionState.DISCONNECTED); // allow a new connect() attempt call.reject("Connection failed: " + e.getMessage()); Log.e(TAG, "Connection failed", e); } finally { @@ -204,14 +203,6 @@ public void disconnect(final PluginCall call) { }); } - @PluginMethod - public void getStatus(final PluginCall call) { - JSObject result = new JSObject(); - result.put("connected", state.get() == ConnectionState.CONNECTED); - result.put("state", state.get().toString()); - call.resolve(result); - } - @Override protected void handleOnDestroy() { socketLock.lock(); @@ -229,13 +220,13 @@ protected void handleOnDestroy() { private void closeResourcesInternal() { if (reader != null) { - try { reader.close(); } catch (IOException e) { Log.e(TAG, "Error closing reader", e);} finally { reader = null; } + try { reader.close(); } catch (IOException e) { Log.e(TAG, "Error closing reader", e); } finally { reader = null; } } if (writer != null) { - try { writer.flush(); writer.close(); } catch (IOException e) { Log.e(TAG, "Error closing writer", e);} finally { writer = null; } + try { writer.flush(); writer.close(); } catch (IOException e) { Log.e(TAG, "Error closing writer", e); } finally { writer = null; } } if (socket != null) { - try { socket.close(); } catch (IOException e) { Log.e(TAG, "Error closing socket", e);} finally { socket = null; } + try { socket.close(); } catch (IOException e) { Log.e(TAG, "Error closing socket", e); } finally { socket = null; } } } From 2d9ff821f775213e247cef80091fa7a01238f6e8 Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 16 May 2025 22:19:14 +0200 Subject: [PATCH 14/16] Add back getStatus --- .../java/betaflight/configurator/plugin/SocketPlugin.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java index 23b29c0552..cf341605a9 100644 --- a/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java +++ b/android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.java @@ -203,6 +203,14 @@ public void disconnect(final PluginCall call) { }); } + @PluginMethod + public void getStatus(final PluginCall call) { + JSObject result = new JSObject(); + result.put("connected", state.get() == ConnectionState.CONNECTED); + result.put("state", state.get().toString()); + call.resolve(result); + } + @Override protected void handleOnDestroy() { socketLock.lock(); From 7717b87326d46584cf0dec221020a3e8640a84ae Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Fri, 16 May 2025 22:35:53 +0200 Subject: [PATCH 15/16] Add getStatus to web.ts too --- capacitor-plugin-socket/src/web.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/capacitor-plugin-socket/src/web.ts b/capacitor-plugin-socket/src/web.ts index 7347aba52a..4765d1758f 100644 --- a/capacitor-plugin-socket/src/web.ts +++ b/capacitor-plugin-socket/src/web.ts @@ -21,4 +21,9 @@ export class SocketPluginWeb extends WebPlugin implements SocketPlugin { console.log('Web implementation does not support raw TCP sockets.'); return { success: false }; } + + async getStatus(): Promise<{ connected: boolean; state: string }> { + console.log('Web implementation does not support raw TCP sockets.'); + return { connected: false, state: 'disconnected' }; + } } From 6e6dd70c962a85019ac2358700500d5d71e339cb Mon Sep 17 00:00:00 2001 From: Mark Haslinghuis Date: Mon, 19 May 2025 16:40:17 +0200 Subject: [PATCH 16/16] Update message --- capacitor-plugin-socket/src/web.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/capacitor-plugin-socket/src/web.ts b/capacitor-plugin-socket/src/web.ts index 4765d1758f..6c93db8d53 100644 --- a/capacitor-plugin-socket/src/web.ts +++ b/capacitor-plugin-socket/src/web.ts @@ -2,8 +2,10 @@ import { WebPlugin } from '@capacitor/core'; import { SocketPlugin } from './definitions'; export class SocketPluginWeb extends WebPlugin implements SocketPlugin { + private readonly NOT_SUPPORTED_MESSAGE = 'Web implementation does not support raw TCP sockets.'; + async connect(options: { ip: string; port: number }): Promise<{ success: boolean }> { - console.log('Web implementation does not support raw TCP sockets.', options); + console.log(`${this.NOT_SUPPORTED_MESSAGE} Cannot connect to ${options.ip}:${options.port}`); return { success: false }; }