From 016e3ee72d105b7e3f9eda8a7579696e6bcc6f2e Mon Sep 17 00:00:00 2001 From: koalasat Date: Tue, 12 Nov 2024 15:55:30 +0100 Subject: [PATCH] Websockets on Tor android --- .../src/components/SettingsForm/index.tsx | 3 +- .../TradeBox/EncryptedChat/index.tsx | 2 +- frontend/src/models/Settings.model.ts | 2 + frontend/src/services/Native/index.d.ts | 1 + frontend/src/services/Native/index.ts | 4 +- .../Websocket/WebsocketNativeClient/index.ts | 87 ++++++++++++++++ .../Websocket/WebsocketWebClient/index.ts | 2 + frontend/src/services/Websocket/index.ts | 15 ++- .../src/services/api/ApiNativeClient/index.ts | 8 +- mobile/App.tsx | 32 +++++- .../java/com/robosats/modules/TorModule.java | 99 +++++++++++++++++++ mobile/native/TorModule.ts | 3 + mobile/services/Tor/index.ts | 36 +++++++ 13 files changed, 283 insertions(+), 11 deletions(-) create mode 100644 frontend/src/services/Websocket/WebsocketNativeClient/index.ts diff --git a/frontend/src/components/SettingsForm/index.tsx b/frontend/src/components/SettingsForm/index.tsx index 1d268bef..4e8636db 100644 --- a/frontend/src/components/SettingsForm/index.tsx +++ b/frontend/src/components/SettingsForm/index.tsx @@ -29,6 +29,7 @@ import { import { systemClient } from '../../services/System'; import { TorIcon } from '../Icons'; import { apiClient } from '../../services/api'; +import { websocketClient } from '../../services/Websocket'; interface SettingsFormProps { dense?: boolean; @@ -198,7 +199,6 @@ const SettingsForm = ({ dense = false }: SettingsFormProps): JSX.Element => { { @@ -249,6 +249,7 @@ const SettingsForm = ({ dense = false }: SettingsFormProps): JSX.Element => { setSettings({ ...settings, useProxy }); systemClient.setItem('settings_use_proxy', String(useProxy)); apiClient.useProxy = useProxy; + websocketClient.useProxy = useProxy; }} > diff --git a/frontend/src/components/TradeBox/EncryptedChat/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/index.tsx index de08a760..fe2203c7 100644 --- a/frontend/src/components/TradeBox/EncryptedChat/index.tsx +++ b/frontend/src/components/TradeBox/EncryptedChat/index.tsx @@ -35,7 +35,7 @@ const EncryptedChat: React.FC = ({ messages, status, }: Props): JSX.Element => { - const [turtleMode, setTurtleMode] = useState(window.ReactNativeWebView !== undefined); + const [turtleMode, setTurtleMode] = useState(false); return turtleMode ? ( { + const path: string = event?.detail?.path; + const message: string = event?.detail?.message; + if (path && message && path === this.path) { + this.wsMessagePromises.forEach((fn) => fn({ data: message })); + } + }); + } + + private readonly path: string; + + private readonly wsMessagePromises: ((message: any) => void)[] = []; + private readonly wsClosePromises: (() => void)[] = []; + + public send: (message: string) => void = (message: string) => { + window.NativeRobosats?.postMessage({ + category: 'ws', + type: 'send', + path: this.path, + message, + }); + }; + + public close: () => void = () => { + window.NativeRobosats?.postMessage({ + category: 'ws', + type: 'close', + path: this.path, + }).then((response) => { + if (response.connection) { + this.wsClosePromises.forEach((fn) => fn()); + } else { + new Error('Failed to close websocket connection.'); + } + }); + }; + + public onMessage: (event: (message: any) => void) => void = (event) => { + this.wsMessagePromises.push(event); + }; + + public onClose: (event: () => void) => void = (event) => { + this.wsClosePromises.push(event); + }; + + public onError: (event: (error: any) => void) => void = (_event) => { + // Not implemented + }; + + public getReadyState: () => number = () => WebsocketState.OPEN; +} + +class WebsocketNativeClient 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 await new Promise((resolve, reject) => { + window.NativeRobosats?.postMessage({ + category: 'ws', + type: 'open', + path, + }) + .then((response) => { + if (response.connection) { + resolve(new WebsocketConnectionNative(path)); + } else { + reject(new Error('Failed to establish a websocket connection.')); + } + }) + .catch(() => { + reject(new Error('Failed to establish a websocket connection.')); + }); + }); + }; +} + +export default WebsocketNativeClient; diff --git a/frontend/src/services/Websocket/WebsocketWebClient/index.ts b/frontend/src/services/Websocket/WebsocketWebClient/index.ts index 5de02aa6..cfb9823f 100644 --- a/frontend/src/services/Websocket/WebsocketWebClient/index.ts +++ b/frontend/src/services/Websocket/WebsocketWebClient/index.ts @@ -39,6 +39,8 @@ class WebsocketConnectionWeb implements WebsocketConnection { } class WebsocketWebClient implements WebsocketClient { + public useProxy = false; + public open: (path: string) => Promise = async (path) => { return await new Promise((resolve, reject) => { try { diff --git a/frontend/src/services/Websocket/index.ts b/frontend/src/services/Websocket/index.ts index bae22f21..564b70a3 100644 --- a/frontend/src/services/Websocket/index.ts +++ b/frontend/src/services/Websocket/index.ts @@ -1,3 +1,4 @@ +import WebsocketNativeClient from './WebsocketNativeClient'; import WebsocketWebClient from './WebsocketWebClient'; export const WebsocketState = { @@ -17,7 +18,19 @@ export interface WebsocketConnection { } export interface WebsocketClient { + useProxy: boolean; open: (path: string) => Promise; } -export const websocketClient: WebsocketClient = new WebsocketWebClient(); +function getWebsocketClient(): WebsocketClient { + 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(); + } else { + // Otherwise, we assume the app is running in a web browser. + return new WebsocketWebClient(); + } +} + +export const websocketClient: WebsocketClient = getWebsocketClient(); diff --git a/frontend/src/services/api/ApiNativeClient/index.ts b/frontend/src/services/api/ApiNativeClient/index.ts index ea927cb8..3cbbb369 100644 --- a/frontend/src/services/api/ApiNativeClient/index.ts +++ b/frontend/src/services/api/ApiNativeClient/index.ts @@ -7,8 +7,6 @@ class ApiNativeClient implements ApiClient { private readonly webClient: ApiClient = new ApiWebClient(); - private readonly assetsPromises = new Map>(); - private readonly getHeaders: (auth?: Auth) => HeadersInit = (auth) => { let headers = { 'Content-Type': 'application/json', @@ -44,9 +42,9 @@ class ApiNativeClient implements ApiClient { }; public put: (baseUrl: string, path: string, body: object) => Promise = async ( - baseUrl, - path, - body, + _baseUrl, + _path, + _body, ) => { return await new Promise((resolve, _reject) => { resolve({}); diff --git a/mobile/App.tsx b/mobile/App.tsx index e73e7fd0..59218caf 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -41,6 +41,13 @@ const App = () => { detail: payload.torStatus, }); }); + DeviceEventEmitter.addListener('WsMessage', (payload) => { + injectMessage({ + category: 'ws', + type: 'wsMessage', + detail: payload, + }); + }); }, []); useEffect(() => { @@ -123,7 +130,30 @@ const App = () => { const onMessage = async (event: WebViewMessageEvent) => { const data = JSON.parse(event.nativeEvent.data); - if (data.category === 'http') { + if (data.category === 'ws') { + TorModule.getTorStatus(); + if (data.type === 'open') { + torClient + .wsOpen(data.path) + .then((connection: boolean) => { + injectMessageResolve(data.id, { connection }); + }) + .catch((e) => onCatch(data.id, e)) + .finally(TorModule.getTorStatus); + } else if (data.type === 'send') { + torClient + .wsSend(data.path, data.message) + .catch((e) => onCatch(data.id, e)) + .finally(TorModule.getTorStatus); + } else if (data.type === 'close') { + torClient + .wsClose(data.path) + .then((connection: boolean) => { + injectMessageResolve(data.id, { connection }); + }) + .finally(TorModule.getTorStatus); + } + } else if (data.category === 'http') { TorModule.getTorStatus(); if (data.type === 'get') { torClient diff --git a/mobile/android/app/src/main/java/com/robosats/modules/TorModule.java b/mobile/android/app/src/main/java/com/robosats/modules/TorModule.java index 8e145554..0dcf6c53 100644 --- a/mobile/android/app/src/main/java/com/robosats/modules/TorModule.java +++ b/mobile/android/app/src/main/java/com/robosats/modules/TorModule.java @@ -19,6 +19,8 @@ import org.json.JSONException; import org.json.JSONObject; import java.io.IOException; +import java.util.HashMap; +import java.util.Map; import java.util.Objects; import java.util.concurrent.TimeUnit; @@ -30,10 +32,15 @@ import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +import okio.ByteString; public class TorModule extends ReactContextBaseJavaModule { private ReactApplicationContext context; + private static final Map webSockets = new HashMap<>(); + public TorModule(ReactApplicationContext reactContext) { context = reactContext; TorKmp torKmpManager = new TorKmp((Application) context.getApplicationContext()); @@ -45,6 +52,81 @@ public class TorModule extends ReactContextBaseJavaModule { return "TorModule"; } + @ReactMethod + public void sendWsSend(String path, String message, final Promise promise) { + if (webSockets.get(path) != null) { + Objects.requireNonNull(webSockets.get(path)).send(message); + promise.resolve(true); + } else { + promise.resolve(false); + } + } + + @ReactMethod + public void sendWsClose(String path, final Promise promise) { + if (webSockets.get(path) != null) { + Objects.requireNonNull(webSockets.get(path)).close(1000, "Closing connection"); + promise.resolve(true); + } else { + promise.resolve(false); + } + } + + @ReactMethod + public void sendWsOpen(String path, final Promise promise) { + Log.d("Tormodule", "WebSocket opening: " + path); + OkHttpClient client = new OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) // Set connection timeout + .readTimeout(30, TimeUnit.SECONDS) // Set read timeout + .proxy(TorKmpManager.INSTANCE.getTorKmpObject().getProxy()) + .build(); + + // Create a request for the WebSocket connection + Request request = new Request.Builder() + .url(path) // Replace with your WebSocket URL + .build(); + + // Create a WebSocket listener + WebSocketListener listener = new WebSocketListener() { + @Override + public void onOpen(@NonNull WebSocket webSocket, Response response) { + Log.d("Tormodule", "WebSocket opened: " + response.message()); + promise.resolve(true); + synchronized (webSockets) { + webSockets.put(path, webSocket); // Store the WebSocket instance with its URL + } + } + + @Override + public void onMessage(@NonNull WebSocket webSocket, @NonNull String text) { + Log.d("Tormodule", "WebSocket Message received: " + text); + onWsMessage(path, text); + } + + @Override + public void onMessage(@NonNull WebSocket webSocket, ByteString bytes) { + Log.d("Tormodule", "WebSocket Message received: " + bytes.hex()); + onWsMessage(path, bytes.hex()); + } + + @Override + public void onClosing(@NonNull WebSocket webSocket, int code, @NonNull String reason) { + Log.d("Tormodule", "WebSocket closing: " + reason); + synchronized (webSockets) { + webSockets.remove(path); // Remove the WebSocket instance by URL + } + } + + @Override + public void onFailure(@NonNull WebSocket webSocket, Throwable t, Response response) { + Log.d("Tormodule", "WebSocket error: " + t.getMessage()); + promise.resolve(false); + } + }; + + client.newWebSocket(request, listener); + } + @ReactMethod public void sendRequest(String action, String url, String headers, String body, final Promise promise) throws JSONException, UninitializedPropertyAccessException { OkHttpClient client = new OkHttpClient.Builder() @@ -160,4 +242,21 @@ public class TorModule extends ReactContextBaseJavaModule { .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) .emit("TorNewIdentity", payload); } + + private void onWsMessage(String path, String message) { + WritableMap payload = Arguments.createMap(); + payload.putString("message", message); + payload.putString("path", path); + context + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit("WsMessage", payload); + } + + private void onWsError(String path) { + WritableMap payload = Arguments.createMap(); + payload.putString("path", path); + context + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit("WsError", payload); + } } diff --git a/mobile/native/TorModule.ts b/mobile/native/TorModule.ts index d071aeee..dd249e1f 100644 --- a/mobile/native/TorModule.ts +++ b/mobile/native/TorModule.ts @@ -5,6 +5,9 @@ interface TorModuleInterface { start: () => void; restart: () => void; getTorStatus: () => void; + sendWsOpen: (path: string) => Promise; + sendWsClose: (path: string) => Promise; + sendWsSend: (path: string, message: string) => Promise; sendRequest: (action: string, url: string, headers: string, body: string) => Promise; } diff --git a/mobile/services/Tor/index.ts b/mobile/services/Tor/index.ts index 3636ba9b..7d1144d3 100644 --- a/mobile/services/Tor/index.ts +++ b/mobile/services/Tor/index.ts @@ -52,6 +52,42 @@ class TorClient { } }); }; + + public wsOpen: (path: string) => Promise = async (path) => { + return await new Promise((resolve, reject) => { + try { + TorModule.sendWsOpen(path).then((response) => { + resolve(response); + }); + } catch (error) { + reject(error); + } + }); + }; + + public wsClose: (path: string) => Promise = async (path) => { + return await new Promise((resolve, reject) => { + try { + TorModule.sendWsClose(path).then((response) => { + resolve(response); + }); + } catch (error) { + reject(error); + } + }); + }; + + public wsSend: (path: string, message: string) => Promise = async (path, message) => { + return await new Promise((resolve, reject) => { + try { + TorModule.sendWsSend(path, message).then((response) => { + resolve(response); + }); + } catch (error) { + reject(error); + } + }); + }; } export default TorClient;