-
-
Notifications
You must be signed in to change notification settings - Fork 1k
Add custom Capacitor plugin-socket for raw TCP support #4471
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
haslinghuis
wants to merge
52
commits into
betaflight:master
Choose a base branch
from
haslinghuis:capacitor-plugin-socket
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+529
−19
Open
Changes from all commits
Commits
Show all changes
52 commits
Select commit
Hold shift + click to select a range
3a0e458
Add custom Capacitor plugin-socket
haslinghuis e2410fb
Rabbit fixes
haslinghuis 5aa1a7c
Refactor SocketPlugin.java
haslinghuis f714316
Move receive operation to background thread
haslinghuis e63b21c
update isConnected flag in closeResources catch block
haslinghuis ff28cfe
Handle null return from readLine
haslinghuis 255484d
guard against missing or null port parameter
haslinghuis 1b1a048
fix inconsistent error handling in receive method
haslinghuis 068744a
Rabbit keeps nitpicking
haslinghuis aa7a9ab
Suggested by rabbit
haslinghuis 9ad33dc
Add getStatus
haslinghuis 60b2bb2
Allow new connect attempt
haslinghuis 95d942f
Replace file with rabbit suggestion again
haslinghuis 6f04b91
Add back getStatus
haslinghuis a81ccc1
Add getStatus to web.ts too
haslinghuis f2b99a1
Update message
haslinghuis 0bcbdc0
Change data stream
haslinghuis b714641
Continuous reading
haslinghuis db98063
Data Format
haslinghuis 6bd5da0
Guard against null PluginCall in handleCommunicationError
haslinghuis fcc8cb4
Reset state to DISCONNECTED after disconnect failure
haslinghuis dd8f1f6
Indentation
haslinghuis b165b2b
Integrated android implementation
haslinghuis 65a9499
Adding events to TCP
haslinghuis 324cd54
Refactor
haslinghuis 0865065
Remove newline in java implementation
haslinghuis b387113
text is undefined in dataReceived handler, causing runtime errors
haslinghuis 995ba1d
review fixes
haslinghuis 887e1b4
Fix bytesSent
haslinghuis f322c15
Fix url:port and add conversion
haslinghuis f3d768d
Fix bytesSent
haslinghuis bfedd14
Fix
haslinghuis cb86998
Move return outside condition
haslinghuis a1916af
Use new Thread instead of getBridge().getExecutor()
haslinghuis f383af5
Fix undefined at load
haslinghuis 999fd45
Port is optional
haslinghuis a308c1b
Actually use the plugin
haslinghuis c18bbd1
Fix state remains stuck in ERROR after failed connect
haslinghuis 8fa9a5b
Ignore port if not provided by user
haslinghuis ad6e0d5
Fix data format
haslinghuis 190e166
Fix disconnect initiated on user request
haslinghuis 89e0a98
Fix disconnect initiated on user request II
haslinghuis 9d1daca
Fix disconnect initiated on user request III
haslinghuis f72bab4
Rename
haslinghuis d6ffbf1
Add missing file
haslinghuis f7b24ca
Reduce formatting I
haslinghuis e8d8a61
Address review comments
haslinghuis 4ec8897
Fix save and reboot closing UX
haslinghuis 68cd624
Default to port 80
haslinghuis 74009d3
Thanks to CapnBry for providing default port
haslinghuis 3033a25
Some nitpicks
haslinghuis 7ccf61a
Some nitpicks II
haslinghuis File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
327 changes: 327 additions & 0 deletions
327
android/app/src/main/java/betaflight/configurator/protocols/tcp/BetaflightTcpPlugin.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,327 @@ | ||
| package betaflight.configurator.protocols.tcp; | ||
|
|
||
| import android.util.Base64; | ||
| 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.ByteArrayOutputStream; | ||
| import java.io.IOException; | ||
| import java.io.InputStream; | ||
| import java.io.OutputStream; | ||
| import java.net.InetSocketAddress; | ||
| import java.net.Socket; | ||
| import java.util.Arrays; | ||
| import java.util.concurrent.atomic.AtomicReference; | ||
| import java.util.concurrent.locks.ReentrantLock; | ||
|
|
||
| /** | ||
| * Capacitor plugin that provides raw TCP socket functionality with thread safety, | ||
| * robust resource management, and comprehensive error handling. | ||
| */ | ||
| @CapacitorPlugin(name = "BetaflightTcp") | ||
| public class BetaflightTcpPlugin extends Plugin { | ||
| private static final String TAG = "BetaflightTcp"; | ||
|
|
||
| // 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<ConnectionState> state = new AtomicReference<>(ConnectionState.DISCONNECTED); | ||
| private final ReentrantLock socketLock = new ReentrantLock(); | ||
| private final ReentrantLock writerLock = new ReentrantLock(); | ||
|
|
||
| private Socket socket; | ||
| private InputStream input; | ||
| private OutputStream output; | ||
| private Thread readerThread; | ||
| private volatile boolean readerRunning = false; | ||
|
|
||
| @PluginMethod | ||
| public void connect(final PluginCall call) { | ||
| call.setKeepAlive(true); | ||
| final String ip = call.getString("ip"); | ||
|
|
||
| Integer portObj = call.getInt("port"); | ||
| final int port = (portObj != null) ? portObj : -1; | ||
|
|
||
| if (ip == null || ip.isEmpty()) { | ||
| call.reject(ERROR_IP_REQUIRED); | ||
| call.setKeepAlive(false); | ||
| return; | ||
| } | ||
|
|
||
| if (!compareAndSetState(ConnectionState.DISCONNECTED, ConnectionState.CONNECTING)) { | ||
| call.reject(ERROR_ALREADY_CONNECTED); | ||
| call.setKeepAlive(false); | ||
| return; | ||
| } | ||
|
|
||
|
|
||
| new Thread(() -> { | ||
| socketLock.lock(); | ||
| try { | ||
| socket = new Socket(); | ||
| InetSocketAddress address = new InetSocketAddress(ip, port); | ||
| socket.connect(address, DEFAULT_TIMEOUT_MS); | ||
| socket.setSoTimeout(DEFAULT_TIMEOUT_MS); | ||
|
|
||
| input = socket.getInputStream(); | ||
| output = socket.getOutputStream(); | ||
|
|
||
| state.set(ConnectionState.CONNECTED); | ||
| JSObject result = new JSObject(); | ||
| result.put("success", true); | ||
| call.resolve(result); | ||
| Log.d(TAG, "Connected to " + ip + (port != -1 ? (":" + port) : "")); | ||
|
|
||
| startReaderThread(); | ||
| } catch (Exception e) { | ||
| state.set(ConnectionState.ERROR); | ||
| closeResourcesInternal(); | ||
| state.set(ConnectionState.DISCONNECTED); | ||
| call.reject("Connection failed: " + e.getMessage()); | ||
| Log.e(TAG, "Connection failed", e); | ||
| } finally { | ||
| socketLock.unlock(); | ||
| call.setKeepAlive(false); | ||
| } | ||
| }).start(); | ||
| } | ||
|
|
||
| @PluginMethod | ||
| public void send(final PluginCall call) { | ||
| String data = call.getString("data"); | ||
| if (data == null || data.isEmpty()) { | ||
| call.reject(ERROR_DATA_REQUIRED); | ||
| return; | ||
| } | ||
| if (state.get() != ConnectionState.CONNECTED) { | ||
| call.reject(ERROR_NOT_CONNECTED); | ||
| return; | ||
| } | ||
| call.setKeepAlive(true); | ||
|
|
||
| new Thread(() -> { | ||
| writerLock.lock(); | ||
| try { | ||
| if (output == null || state.get() != ConnectionState.CONNECTED) { | ||
| call.reject(ERROR_CONNECTION_LOST); | ||
| return; | ||
| } | ||
| byte[] payload = Base64.decode(data, Base64.NO_WRAP); | ||
| output.write(payload); | ||
| output.flush(); | ||
|
|
||
| JSObject result = new JSObject(); | ||
| result.put("success", true); | ||
| call.resolve(result); | ||
| Log.d(TAG, "Sent " + payload.length + " bytes"); | ||
| } catch (Exception e) { | ||
| handleCommunicationError(e, "Send failed", call); | ||
| } finally { | ||
| writerLock.unlock(); | ||
| call.setKeepAlive(false); | ||
| } | ||
| }).start(); | ||
| } | ||
|
|
||
| @PluginMethod | ||
| public void receive(final PluginCall call) { | ||
| // Deprecated by continuous reader (Task 2) | ||
| JSObject result = new JSObject(); | ||
| result.put("data", ""); | ||
| call.reject("Continuous read active. Listen for 'dataReceived' events instead."); | ||
| } | ||
|
|
||
| @PluginMethod | ||
| 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); | ||
|
|
||
| new Thread(() -> { | ||
| socketLock.lock(); | ||
| try { | ||
| 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); | ||
| // Ensure cleanup completes even on error | ||
| try { | ||
| closeResourcesInternal(); | ||
| } catch (Exception ce) { | ||
| Log.e(TAG, "Cleanup error during disconnect", ce); | ||
| } | ||
| call.reject("Disconnect failed: " + e.getMessage()); | ||
| Log.e(TAG, "Disconnect failed", e); | ||
| // Reset to a clean disconnected state after handling error | ||
| state.set(ConnectionState.DISCONNECTED); | ||
| } finally { | ||
| socketLock.unlock(); | ||
| call.setKeepAlive(false); | ||
| } | ||
| }).start(); | ||
| } | ||
|
|
||
| @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(); | ||
| try { | ||
| 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 startReaderThread() { | ||
| if (readerThread != null && readerThread.isAlive()) return; | ||
| readerRunning = true; | ||
| readerThread = new Thread(() -> { | ||
| Log.d(TAG, "Reader thread started"); | ||
| try { | ||
| byte[] buf = new byte[4096]; | ||
| while (readerRunning && state.get() == ConnectionState.CONNECTED && input != null) { | ||
| int read = input.read(buf); | ||
| if (read == -1) { | ||
| notifyDisconnectFromPeer(); | ||
| break; | ||
| } | ||
| if (read > 0) { | ||
| byte[] chunk = Arrays.copyOf(buf, read); | ||
| String b64 = Base64.encodeToString(chunk, Base64.NO_WRAP); | ||
| JSObject payload = new JSObject(); | ||
| payload.put("data", b64); | ||
| notifyListeners("dataReceived", payload); | ||
| } | ||
| } | ||
| } catch (Exception e) { | ||
| if (readerRunning) { | ||
| Log.e(TAG, "Reader thread error", e); | ||
| JSObject err = new JSObject(); | ||
| err.put("error", e.getMessage()); | ||
| notifyListeners("dataReceivedError", err); | ||
| handleCommunicationError(e, "Receive failed", null); | ||
| } | ||
| } finally { | ||
| Log.d(TAG, "Reader thread stopped"); | ||
| } | ||
| }, "SocketReaderThread"); | ||
| readerThread.start(); | ||
| } | ||
|
|
||
| private void notifyDisconnectFromPeer() { | ||
| Log.d(TAG, "Peer closed connection"); | ||
| JSObject evt = new JSObject(); | ||
| evt.put("reason", "peer_closed"); | ||
| notifyListeners("connectionClosed", evt); | ||
| socketLock.lock(); | ||
| try { | ||
| state.set(ConnectionState.ERROR); | ||
| closeResourcesInternal(); | ||
| state.set(ConnectionState.DISCONNECTED); | ||
| } finally { | ||
| socketLock.unlock(); | ||
| } | ||
| } | ||
|
|
||
| private void stopReaderThread() { | ||
| readerRunning = false; | ||
| if (readerThread != null) { | ||
| try { | ||
| readerThread.interrupt(); | ||
| readerThread.join(500); | ||
| } catch (InterruptedException ignored) {} | ||
| readerThread = null; | ||
| } | ||
| } | ||
|
|
||
| private void closeResourcesInternal() { | ||
| stopReaderThread(); | ||
| if (input != null) { try { input.close(); } catch (IOException e) { Log.e(TAG, "Error closing input stream", e); } finally { input = null; } } | ||
| if (output != null) { try { output.close(); } catch (IOException e) { Log.e(TAG, "Error closing output stream", e); } finally { output = 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); | ||
|
|
||
| String fullMsg = message + ": " + (error != null ? error.getMessage() : "unknown error"); | ||
| if (call != null) { | ||
| call.reject(fullMsg); | ||
| } else { | ||
| // No PluginCall available (e.g., background reader thread). Log the error. | ||
| Log.e(TAG, fullMsg, error); | ||
| // Optionally notify listeners (commented to avoid duplicate notifications): | ||
| // JSObject err = new JSObject(); | ||
| // err.put("error", fullMsg); | ||
| // notifyListeners("socketError", err); | ||
| } | ||
| 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)"; | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.