mirror of
https://github.com/RoboSats/robosats.git
synced 2025-07-23 16:33:22 +00:00
websockets
This commit is contained in:
@ -11,9 +11,15 @@ interface AndroidAppRobosats {
|
||||
generateRobohash: (uuid: string, initialString: string) => void;
|
||||
copyToClipboard: (value: string) => void;
|
||||
getTorStatus: (uuid: string) => void;
|
||||
openWS: (uuid: string, path: string) => void;
|
||||
sendWsMessage: (uuid: string, path: string, message: string) => void;
|
||||
}
|
||||
|
||||
class AndroidRobosats {
|
||||
private WSConnections: Record<string, (message?: string) => void> = {};
|
||||
private WSError: Record<string, () => void> = {};
|
||||
private WSClose: Record<string, () => void> = {};
|
||||
|
||||
private promises: Record<
|
||||
string,
|
||||
{
|
||||
@ -51,6 +57,35 @@ class AndroidRobosats {
|
||||
console.warn(`No promise found for UUID: ${uuid}`);
|
||||
}
|
||||
};
|
||||
|
||||
public registerWSConnection: (
|
||||
path: string,
|
||||
onMesage: (message?: string) => void,
|
||||
onClose: () => void,
|
||||
onError: () => void,
|
||||
) => void = (path, onMesage, onClose, onError) => {
|
||||
this.WSConnections[path] = onMesage;
|
||||
this.WSError[path] = onError;
|
||||
this.WSClose[path] = onClose;
|
||||
};
|
||||
|
||||
public removeWSConnection: (path: string) => void = (path) => {
|
||||
delete this.WSConnections[path];
|
||||
delete this.WSError[path];
|
||||
delete this.WSClose[path];
|
||||
};
|
||||
|
||||
public onWSMessage: (path: string, message: string) => void = (path, message) => {
|
||||
this.WSConnections[path](message);
|
||||
};
|
||||
|
||||
public onWsError: (path: string) => void = (path) => {
|
||||
this.WSError[path]();
|
||||
};
|
||||
|
||||
public onWsClose: (path: string) => void = (path) => {
|
||||
this.WSClose[path]();
|
||||
};
|
||||
}
|
||||
|
||||
export default AndroidRobosats;
|
||||
|
@ -0,0 +1,73 @@
|
||||
import { WebsocketState, type WebsocketClient, type WebsocketConnection } from '..';
|
||||
import WebsocketWebClient from '../WebsocketWebClient';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
class WebsocketConnectionAndroid implements WebsocketConnection {
|
||||
constructor(path: string) {
|
||||
this.path = path;
|
||||
window.AndroidRobosats?.registerWSConnection(
|
||||
path,
|
||||
(message) => {
|
||||
this.wsMessagePromises.forEach((f) => f({ data: message }));
|
||||
},
|
||||
() => {
|
||||
this.wsClosePromises.forEach((f) => f());
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
|
||||
private readonly path: string;
|
||||
|
||||
private readonly wsMessagePromises: Array<(message: object) => void> = [];
|
||||
private readonly wsClosePromises: Array<() => void> = [];
|
||||
|
||||
public send: (message: string) => void = (message: string) => {
|
||||
const uuid: string = uuidv4();
|
||||
window.AndroidAppRobosats?.sendWsMessage(uuid, this.path, message);
|
||||
};
|
||||
|
||||
public close: () => void = () => {
|
||||
window.AndroidRobosats?.removeWSConnection(this.path);
|
||||
};
|
||||
|
||||
public onMessage: (event: (message: object) => void) => void = (event) => {
|
||||
this.wsMessagePromises.push(event);
|
||||
};
|
||||
|
||||
public onClose: (event: () => void) => void = (event) => {
|
||||
this.wsClosePromises.push(event);
|
||||
};
|
||||
|
||||
public onError: (event: (error: object) => void) => void = (_event) => {
|
||||
// Not implemented
|
||||
};
|
||||
|
||||
public getReadyState: () => number = () => WebsocketState.OPEN;
|
||||
}
|
||||
|
||||
class WebsocketAndroidClient implements WebsocketClient {
|
||||
public useProxy = true;
|
||||
|
||||
private readonly webClient: WebsocketWebClient = new WebsocketWebClient();
|
||||
|
||||
public open: (path: string) => Promise<WebsocketConnection> = async (path) => {
|
||||
if (!this.useProxy) return await this.webClient.open(path);
|
||||
|
||||
return new Promise<WebsocketConnectionAndroid>((resolve, reject) => {
|
||||
const uuid: string = uuidv4();
|
||||
window.AndroidAppRobosats?.openWS(uuid, path);
|
||||
window.AndroidRobosats?.storePromise(
|
||||
uuid,
|
||||
() => {
|
||||
resolve(new WebsocketConnectionAndroid(path));
|
||||
},
|
||||
() => {
|
||||
reject(new Error('Failed to establish a websocket connection.'));
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default WebsocketAndroidClient;
|
@ -1,3 +1,4 @@
|
||||
import WebsocketAndroidClient from './WebsocketAndroidClient';
|
||||
import WebsocketNativeClient from './WebsocketNativeClient';
|
||||
import WebsocketWebClient from './WebsocketWebClient';
|
||||
|
||||
@ -23,7 +24,11 @@ export interface WebsocketClient {
|
||||
}
|
||||
|
||||
function getWebsocketClient(): WebsocketClient {
|
||||
if (window.navigator.userAgent.includes('robosats')) {
|
||||
if (window.navigator.userAgent.includes('AndroidRobosats')) {
|
||||
// If userAgent has "AndroidRobosats", we assume the app is running inside of the
|
||||
// WebView of the Kotlin RoboSats Android app.
|
||||
return new WebsocketAndroidClient();
|
||||
} else if (window.navigator.userAgent.includes('robosats')) {
|
||||
// If userAgent has "RoboSats", we assume the app is running inside of the
|
||||
// react-native-web view of the RoboSats Android app.
|
||||
return new WebsocketNativeClient();
|
||||
|
@ -70,6 +70,7 @@ dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.kmp.tor)
|
||||
// Add the KMP Tor binary dependency (contains the native .so files)
|
||||
implementation(libs.kmp.tor.binary)
|
||||
|
@ -203,7 +203,7 @@ class MainActivity : AppCompatActivity() {
|
||||
val proxyPort = System.getProperty("http.proxyPort")?.toIntOrNull()
|
||||
?: throw SecurityException("Missing or invalid proxy port in system properties")
|
||||
|
||||
Log.d("TorProxy", "Using proxy settings: $proxyHost:$proxyPort")
|
||||
Log.d("WebViewProxy", "Using proxy settings: $proxyHost:$proxyPort")
|
||||
|
||||
// Success - now configure WebViewClient and load URL on UI thread
|
||||
runOnUiThread {
|
||||
@ -243,7 +243,7 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
val urlString = request.url.toString()
|
||||
Log.d("TorProxy", "Intercepting request: $urlString")
|
||||
Log.d("WebViewProxy", "Intercepting request: $urlString")
|
||||
|
||||
// Block all external requests that aren't to .onion domains or local files
|
||||
if (!isAllowedRequest(urlString)) {
|
||||
@ -274,7 +274,7 @@ class MainActivity : AppCompatActivity() {
|
||||
)
|
||||
|
||||
if (isOnionDomain) {
|
||||
Log.d("TorProxy", "Handling .onion domain with SOCKS proxy: $urlString")
|
||||
Log.d("WebViewProxy", "Handling .onion domain with SOCKS proxy: $urlString")
|
||||
}
|
||||
|
||||
// If it's a local file, return it directly
|
||||
@ -345,7 +345,7 @@ class MainActivity : AppCompatActivity() {
|
||||
val mimeType = connection.contentType ?: "text/plain"
|
||||
val encoding = connection.contentEncoding ?: "UTF-8"
|
||||
|
||||
Log.d("TorProxy", "Successfully proxied request to $url (HTTP ${connection.responseCode})")
|
||||
Log.d("WebViewProxy", "Successfully proxied request to $url (HTTP ${connection.responseCode})")
|
||||
|
||||
// Get the correct input stream based on response code
|
||||
val inputStream = if (responseCode >= 400) {
|
||||
@ -409,7 +409,7 @@ class MainActivity : AppCompatActivity() {
|
||||
} else {
|
||||
// For non-HTTP connections (rare)
|
||||
val inputStream = connection.getInputStream()
|
||||
Log.d("TorProxy", "Successfully established non-HTTP connection to $url")
|
||||
Log.d("WebViewProxy", "Successfully established non-HTTP connection to $url")
|
||||
return WebResourceResponse(
|
||||
"application/octet-stream",
|
||||
"UTF-8",
|
||||
@ -417,7 +417,7 @@ class MainActivity : AppCompatActivity() {
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("TorProxy", "Error proxying request: $urlString - ${e.message}", e)
|
||||
Log.e("WebViewProxy", "Error proxying request: $urlString - ${e.message}", e)
|
||||
|
||||
// For security, block the request rather than falling back to system handling
|
||||
return WebResourceResponse("text/plain", "UTF-8", null)
|
||||
|
@ -1,15 +1,23 @@
|
||||
package com.robosats
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import android.widget.Toast
|
||||
import com.robosats.tor.TorKmpManager.getTorKmpObject
|
||||
import android.annotation.SuppressLint
|
||||
import android.text.TextUtils
|
||||
import java.util.UUID
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.OkHttpClient.Builder
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okio.ByteString
|
||||
import java.util.Objects
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.regex.Pattern
|
||||
import okhttp3.Request.Builder as RequestBuilder
|
||||
|
||||
/**
|
||||
* Provides a secure bridge between JavaScript and native Android code.
|
||||
@ -20,6 +28,7 @@ import java.util.regex.Pattern
|
||||
class WebAppInterface(private val context: Context, private val webView: WebView) {
|
||||
private val TAG = "WebAppInterface"
|
||||
private val roboIdentities = RoboIdentities()
|
||||
private val webSockets: MutableMap<String?, WebSocket?> = HashMap<String?, WebSocket?>()
|
||||
|
||||
// Security patterns for input validation
|
||||
private val UUID_PATTERN = Pattern.compile("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", Pattern.CASE_INSENSITIVE)
|
||||
@ -40,61 +49,6 @@ class WebAppInterface(private val context: Context, private val webView: WebView
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a string contains only safe characters and is within length limits
|
||||
*/
|
||||
private fun isValidInput(input: String?, maxLength: Int = MAX_INPUT_LENGTH): Boolean {
|
||||
if (input == null || input.isEmpty() || input.length > maxLength) {
|
||||
return false
|
||||
}
|
||||
return SAFE_STRING_PATTERN.matcher(input).matches()
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a string is a valid UUID
|
||||
*/
|
||||
private fun isValidUuid(uuid: String?): Boolean {
|
||||
if (uuid == null || uuid.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
return UUID_PATTERN.matcher(uuid).matches()
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely evaluates JavaScript, escaping any potentially dangerous characters
|
||||
*/
|
||||
private fun safeEvaluateJavascript(script: String) {
|
||||
// Remove any null bytes which could be used to trick the JS interpreter
|
||||
val sanitizedScript = script.replace("\u0000", "")
|
||||
|
||||
webView.post {
|
||||
try {
|
||||
webView.evaluateJavascript(sanitizedScript, null)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error evaluating JavaScript: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely encodes a string for use in JavaScript
|
||||
*/
|
||||
private fun encodeForJavaScript(input: String): String {
|
||||
return input.replace("\\", "\\\\")
|
||||
.replace("'", "\\'")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("<", "\\u003C")
|
||||
.replace(">", "\\u003E")
|
||||
.replace("&", "\\u0026")
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a robot name from the given message
|
||||
* @param uuid A unique identifier for the JavaScript Promise
|
||||
* @param message The input message to generate a robot name from
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun generateRoboname(uuid: String, message: String) {
|
||||
// Validate inputs before processing
|
||||
@ -119,37 +73,6 @@ class WebAppInterface(private val context: Context, private val webView: WebView
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to safely resolve a JavaScript Promise
|
||||
*/
|
||||
private fun resolvePromise(uuid: String, result: String) {
|
||||
if (!isValidUuid(uuid)) {
|
||||
Log.e(TAG, "Invalid UUID for promise resolution: $uuid")
|
||||
return
|
||||
}
|
||||
|
||||
val encodedResult = encodeForJavaScript(result)
|
||||
safeEvaluateJavascript("javascript:window.AndroidRobosats.onResolvePromise('$uuid', '$encodedResult')")
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to safely reject a JavaScript Promise
|
||||
*/
|
||||
private fun rejectPromise(uuid: String, errorMessage: String) {
|
||||
if (!isValidUuid(uuid)) {
|
||||
Log.e(TAG, "Invalid UUID for promise rejection: $uuid")
|
||||
return
|
||||
}
|
||||
|
||||
val encodedError = encodeForJavaScript(errorMessage)
|
||||
safeEvaluateJavascript("javascript:window.AndroidRobosats.onRejectPromise('$uuid', '$encodedError')")
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a robot hash from the given message
|
||||
* @param uuid A unique identifier for the JavaScript Promise
|
||||
* @param message The input message to generate a robot hash from
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun generateRobohash(uuid: String, message: String) {
|
||||
// Validate inputs before processing
|
||||
@ -174,10 +97,6 @@ class WebAppInterface(private val context: Context, private val webView: WebView
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to the clipboard
|
||||
* @param message The text to copy to the clipboard
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun copyToClipboard(message: String) {
|
||||
// Validate input
|
||||
@ -211,10 +130,6 @@ class WebAppInterface(private val context: Context, private val webView: WebView
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current Tor connection status
|
||||
* @param uuid A unique identifier for the JavaScript Promise
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun getTorStatus(uuid: String) {
|
||||
// Validate UUID
|
||||
@ -234,4 +149,165 @@ class WebAppInterface(private val context: Context, private val webView: WebView
|
||||
rejectPromise(uuid, "Error retrieving Tor status")
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun openWS(uuid: String, path: String) {
|
||||
// Validate UUID
|
||||
if (!isValidUuid(uuid)) {
|
||||
Log.e(TAG, "Invalid UUID for getTorStatus: $uuid")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Log.d(TAG, "WebSocket opening: " + path)
|
||||
val client: OkHttpClient = Builder()
|
||||
.connectTimeout(60, TimeUnit.SECONDS) // Set connection timeout
|
||||
.readTimeout(30, TimeUnit.SECONDS) // Set read timeout
|
||||
.proxy(getTorKmpObject().proxy)
|
||||
.build()
|
||||
|
||||
|
||||
// Create a request for the WebSocket connection
|
||||
val request: Request = RequestBuilder()
|
||||
.url(path) // Replace with your WebSocket URL
|
||||
.build()
|
||||
|
||||
|
||||
// Create a WebSocket listener
|
||||
val listener: WebSocketListener = object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
Log.d(TAG, "WebSocket opened: " + response.message)
|
||||
resolvePromise(uuid, "true")
|
||||
synchronized(webSockets) {
|
||||
webSockets.put(
|
||||
path,
|
||||
webSocket
|
||||
) // Store the WebSocket instance with its URL
|
||||
resolvePromise(uuid, path)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
onWsMessage(path, text)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||
onWsMessage(path, bytes.hex())
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.d(TAG, "WebSocket closing: " + reason)
|
||||
onWsClose(path)
|
||||
synchronized(webSockets) {
|
||||
webSockets.remove(path) // Remove the WebSocket instance by URL
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
webSocket: WebSocket,
|
||||
t: Throwable,
|
||||
response: Response?
|
||||
) {
|
||||
Log.d(TAG, "WebSocket error: " + t.message)
|
||||
onWsError(path)
|
||||
rejectPromise(uuid, "false")
|
||||
}
|
||||
}
|
||||
|
||||
client.newWebSocket(request, listener)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error connecting to WebSocket", e)
|
||||
rejectPromise(uuid, "Error connecting Tor WebSocket")
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun sendWsMessage(uuid: String, path: String, message: String) {
|
||||
// Validate UUID
|
||||
if (!isValidUuid(uuid)) {
|
||||
Log.e(TAG, "Invalid UUID for getTorStatus: $uuid")
|
||||
return
|
||||
}
|
||||
|
||||
val websocket = webSockets.get(path)
|
||||
if (websocket != null) {
|
||||
websocket.send(message)
|
||||
resolvePromise(uuid, "true")
|
||||
} else {
|
||||
rejectPromise(uuid, "Error sending WebSocket message")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onWsMessage(path: String?, message: String?) {
|
||||
safeEvaluateJavascript("javascript:window.AndroidRobosats.onWSMessage('$path', '$message')")
|
||||
}
|
||||
|
||||
private fun onWsError(path: String?) {
|
||||
Log.d(TAG, "WebSocket error: $path")
|
||||
safeEvaluateJavascript("javascript:window.AndroidRobosats.onWsError('$path')")
|
||||
}
|
||||
|
||||
private fun onWsClose(path: String?) {
|
||||
Log.d(TAG, "WebSocket close: $path")
|
||||
safeEvaluateJavascript("javascript:window.AndroidRobosats.onWsClose('$path')")
|
||||
}
|
||||
|
||||
private fun resolvePromise(uuid: String, result: String) {
|
||||
if (!isValidUuid(uuid)) {
|
||||
Log.e(TAG, "Invalid UUID for promise resolution: $uuid")
|
||||
return
|
||||
}
|
||||
|
||||
val encodedResult = encodeForJavaScript(result)
|
||||
safeEvaluateJavascript("javascript:window.AndroidRobosats.onResolvePromise('$uuid', '$encodedResult')")
|
||||
}
|
||||
|
||||
private fun rejectPromise(uuid: String, errorMessage: String) {
|
||||
if (!isValidUuid(uuid)) {
|
||||
Log.e(TAG, "Invalid UUID for promise rejection: $uuid")
|
||||
return
|
||||
}
|
||||
|
||||
val encodedError = encodeForJavaScript(errorMessage)
|
||||
safeEvaluateJavascript("javascript:window.AndroidRobosats.onRejectPromise('$uuid', '$encodedError')")
|
||||
}
|
||||
|
||||
private fun isValidInput(input: String?, maxLength: Int = MAX_INPUT_LENGTH): Boolean {
|
||||
if (input == null || input.isEmpty() || input.length > maxLength) {
|
||||
return false
|
||||
}
|
||||
return SAFE_STRING_PATTERN.matcher(input).matches()
|
||||
}
|
||||
|
||||
private fun isValidUuid(uuid: String?): Boolean {
|
||||
if (uuid == null || uuid.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
return UUID_PATTERN.matcher(uuid).matches()
|
||||
}
|
||||
|
||||
private fun safeEvaluateJavascript(script: String) {
|
||||
// Remove any null bytes which could be used to trick the JS interpreter
|
||||
val sanitizedScript = script.replace("\u0000", "")
|
||||
|
||||
webView.post {
|
||||
try {
|
||||
webView.evaluateJavascript(sanitizedScript, null)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error evaluating JavaScript: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun encodeForJavaScript(input: String): String {
|
||||
return input.replace("\\", "\\\\")
|
||||
.replace("'", "\\'")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("<", "\\u003C")
|
||||
.replace(">", "\\u003E")
|
||||
.replace("&", "\\u0026")
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ activity = "1.10.1"
|
||||
constraintlayout = "2.2.1"
|
||||
kmpTor= "4.8.10-0-1.4.5"
|
||||
kmpTorBinary= "4.8.10-0"
|
||||
okhttp = "5.0.0-alpha.14"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@ -23,6 +24,7 @@ androidx-activity = { group = "androidx.activity", name = "activity", version.re
|
||||
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
||||
kmp-tor = { group = "io.matthewnelson.kotlin-components", name = "kmp-tor", version.ref = "kmpTor" }
|
||||
kmp-tor-binary = { group = "io.matthewnelson.kotlin-components", name = "kmp-tor-binary-extract-android", version.ref = "kmpTorBinary" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
Reference in New Issue
Block a user