From 6bef6dfb1fd9fa71b5188b6017537752807af575 Mon Sep 17 00:00:00 2001 From: koalasat Date: Thu, 17 Jul 2025 12:41:54 +0200 Subject: [PATCH] websockets --- frontend/src/services/Android/index.ts | 35 +++ .../Websocket/WebsocketAndroidClient/index.ts | 73 +++++ frontend/src/services/Websocket/index.ts | 7 +- mobile_new/app/build.gradle.kts | 1 + .../main/java/com/robosats/MainActivity.kt | 12 +- .../main/java/com/robosats/WebAppInterface.kt | 270 +++++++++++------- mobile_new/gradle/libs.versions.toml | 2 + 7 files changed, 296 insertions(+), 104 deletions(-) create mode 100644 frontend/src/services/Websocket/WebsocketAndroidClient/index.ts diff --git a/frontend/src/services/Android/index.ts b/frontend/src/services/Android/index.ts index cb5fc712..a6a080c1 100644 --- a/frontend/src/services/Android/index.ts +++ b/frontend/src/services/Android/index.ts @@ -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 void> = {}; + private WSError: Record void> = {}; + private WSClose: Record 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; diff --git a/frontend/src/services/Websocket/WebsocketAndroidClient/index.ts b/frontend/src/services/Websocket/WebsocketAndroidClient/index.ts new file mode 100644 index 00000000..910e8cbb --- /dev/null +++ b/frontend/src/services/Websocket/WebsocketAndroidClient/index.ts @@ -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 = async (path) => { + if (!this.useProxy) return await this.webClient.open(path); + + return new Promise((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; diff --git a/frontend/src/services/Websocket/index.ts b/frontend/src/services/Websocket/index.ts index 9ae8646f..5a6ad5ce 100644 --- a/frontend/src/services/Websocket/index.ts +++ b/frontend/src/services/Websocket/index.ts @@ -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(); diff --git a/mobile_new/app/build.gradle.kts b/mobile_new/app/build.gradle.kts index a6066824..262fd95f 100644 --- a/mobile_new/app/build.gradle.kts +++ b/mobile_new/app/build.gradle.kts @@ -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) diff --git a/mobile_new/app/src/main/java/com/robosats/MainActivity.kt b/mobile_new/app/src/main/java/com/robosats/MainActivity.kt index 86323139..ed8cba8f 100644 --- a/mobile_new/app/src/main/java/com/robosats/MainActivity.kt +++ b/mobile_new/app/src/main/java/com/robosats/MainActivity.kt @@ -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) diff --git a/mobile_new/app/src/main/java/com/robosats/WebAppInterface.kt b/mobile_new/app/src/main/java/com/robosats/WebAppInterface.kt index 3da7e3b4..177cb812 100644 --- a/mobile_new/app/src/main/java/com/robosats/WebAppInterface.kt +++ b/mobile_new/app/src/main/java/com/robosats/WebAppInterface.kt @@ -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 = HashMap() // 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") + } + } diff --git a/mobile_new/gradle/libs.versions.toml b/mobile_new/gradle/libs.versions.toml index 68f7a0cb..b48b2c68 100644 --- a/mobile_new/gradle/libs.versions.toml +++ b/mobile_new/gradle/libs.versions.toml @@ -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" }