-
-
Notifications
You must be signed in to change notification settings - Fork 961
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
16
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.
+340
−1
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
eb322fa
Add custom Capacitor plugin-socket
haslinghuis acfa06d
Rabbit fixes
haslinghuis e337188
Refactor SocketPlugin.java
haslinghuis bf1924a
Move receive operation to background thread
haslinghuis f55e7be
update isConnected flag in closeResources catch block
haslinghuis cfefb6a
Handle null return from readLine
haslinghuis 39715ab
guard against missing or null port parameter
haslinghuis e777360
fix inconsistent error handling in receive method
haslinghuis 6e8146c
Rabbit keeps nitpicking
haslinghuis 7c25def
Suggested by rabbit
haslinghuis df2a2a4
Add getStatus
haslinghuis 338cb36
Allow new connect attempt
haslinghuis 1758893
Replace file with rabbit suggestion again
haslinghuis 2d9ff82
Add back getStatus
haslinghuis 7717b87
Add getStatus to web.ts too
haslinghuis 6e6dd70
Update message
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
There are no files selected for viewing
10 changes: 9 additions & 1 deletion
10
android/app/src/main/java/betaflight/configurator/MainActivity.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 |
---|---|---|
@@ -1,5 +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); | ||
} | ||
} |
264 changes: 264 additions & 0 deletions
264
android/app/src/main/java/betaflight/configurator/plugin/SocketPlugin.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,264 @@ | ||
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.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 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<ConnectionState> 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; | ||
|
||
@PluginMethod | ||
public void connect(final PluginCall call) { | ||
call.setKeepAlive(true); | ||
String ip = call.getString("ip"); | ||
int port = call.getInt("port", -1); | ||
|
||
if (ip == null || ip.isEmpty()) { | ||
call.reject(ERROR_IP_REQUIRED); | ||
call.setKeepAlive(false); | ||
return; | ||
} | ||
if (port < MIN_PORT || port > MAX_PORT) { | ||
call.reject(ERROR_INVALID_PORT); | ||
call.setKeepAlive(false); | ||
return; | ||
} | ||
if (!compareAndSetState(ConnectionState.DISCONNECTED, ConnectionState.CONNECTING)) { | ||
call.reject(ERROR_ALREADY_CONNECTED); | ||
call.setKeepAlive(false); | ||
return; | ||
} | ||
|
||
getBridge().getExecutor().execute(() -> { | ||
socketLock.lock(); | ||
try { | ||
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())); | ||
|
||
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) { | ||
state.set(ConnectionState.ERROR); | ||
closeResourcesInternal(); | ||
call.reject("Connection failed: " + e.getMessage()); | ||
Log.e(TAG, "Connection failed", e); | ||
} finally { | ||
socketLock.unlock(); | ||
call.setKeepAlive(false); | ||
} | ||
}); | ||
} | ||
haslinghuis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
@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); | ||
|
||
getBridge().getExecutor().execute(() -> { | ||
writerLock.lock(); | ||
try { | ||
if (writer == null || state.get() != ConnectionState.CONNECTED) { | ||
call.reject(ERROR_CONNECTION_LOST); | ||
return; | ||
} | ||
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) { | ||
handleCommunicationError(e, "Send failed", call); | ||
} finally { | ||
writerLock.unlock(); | ||
call.setKeepAlive(false); | ||
} | ||
}); | ||
} | ||
|
||
@PluginMethod | ||
public void receive(final PluginCall call) { | ||
if (state.get() != ConnectionState.CONNECTED || reader == null) { | ||
call.reject(ERROR_NOT_CONNECTED); | ||
return; | ||
} | ||
call.setKeepAlive(true); | ||
|
||
getBridge().getExecutor().execute(() -> { | ||
try { | ||
String data = reader.readLine(); | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if (data == null) { | ||
handleCommunicationError(new IOException("End of stream"), ERROR_CONNECTION_CLOSED, call); | ||
return; | ||
} | ||
JSObject result = new JSObject(); | ||
result.put("data", data); | ||
call.resolve(result); | ||
Log.d(TAG, "Received data: " + truncateForLog(data)); | ||
} catch (Exception e) { | ||
handleCommunicationError(e, "Receive failed", call); | ||
} finally { | ||
call.setKeepAlive(false); | ||
} | ||
}); | ||
} | ||
|
||
@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); | ||
|
||
getBridge().getExecutor().execute(() -> { | ||
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); | ||
call.reject("Disconnect failed: " + e.getMessage()); | ||
Log.e(TAG, "Disconnect failed", e); | ||
} finally { | ||
socketLock.unlock(); | ||
call.setKeepAlive(false); | ||
} | ||
}); | ||
} | ||
|
||
@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 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)"; | ||
} | ||
} |
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,21 @@ | ||
{ | ||
"name": "capacitor-plugin-socket", | ||
"version": "1.0.0", | ||
"description": "A Capacitor plugin for handling raw TCP sockets.", | ||
"main": "dist/index.js", | ||
"types": "dist/index.d.ts", | ||
"files": ["dist/*", "package.json", "README.md"], | ||
"scripts": { | ||
"clean": "rimraf dist", | ||
"build": "npm run clean && tsc -p tsconfig.json" | ||
}, | ||
"keywords": ["capacitor", "plugin", "tcp", "socket"], | ||
"author": "Betaflight <dev.betaflight.com>", | ||
"license": "MIT", | ||
"peerDependencies": { | ||
"@capacitor/core": "^5.0.0" | ||
}, | ||
"devDependencies": { | ||
"typescript": "^5.0.0" | ||
} | ||
} |
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,7 @@ | ||
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 }>; | ||
getStatus(): Promise<{ connected: boolean, state: string }>; | ||
} |
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,8 @@ | ||
import { registerPlugin } from '@capacitor/core'; | ||
|
||
const SocketPlugin = registerPlugin('SocketPlugin', { | ||
web: () => import('./web').then(m => new m.SocketPluginWeb()), | ||
}); | ||
|
||
export * from './definitions'; | ||
export { SocketPlugin }; |
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,31 @@ | ||
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(`${this.NOT_SUPPORTED_MESSAGE} Cannot connect to ${options.ip}:${options.port}`); | ||
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 }; | ||
} | ||
|
||
async getStatus(): Promise<{ connected: boolean; state: string }> { | ||
console.log('Web implementation does not support raw TCP sockets.'); | ||
return { connected: false, state: 'disconnected' }; | ||
} | ||
} |
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.