From 41f1b3a5f22588df7429eddb2c262e8903e4b39d Mon Sep 17 00:00:00 2001 From: koalasat Date: Wed, 16 Jul 2025 11:32:52 +0200 Subject: [PATCH] TS <> Korlin communication --- frontend/package-lock.json | 14 ++++ frontend/package.json | 1 + frontend/src/geo/{Native.js => Mobile.js} | 0 frontend/src/i18n/{Native.js => Mobile.js} | 0 frontend/src/services/Android/index.ts | 28 ++++++++ .../src/services/Roboidentities/Android.ts | 4 ++ .../RoboidentitiesAndroidClient/index.ts | 44 +++++++++++++ .../System/SystemAndroidClient/index.ts | 55 ++++++++++++++++ frontend/src/services/System/index.ts | 4 ++ frontend/webpack.config.ts | 65 +++++++++++++++++-- .../com/koalasat/robosats/MainActivity.kt | 6 ++ .../koalasat/robosats/tor/WebAppInterface.kt | 18 +++++ 12 files changed, 235 insertions(+), 4 deletions(-) rename frontend/src/geo/{Native.js => Mobile.js} (100%) rename frontend/src/i18n/{Native.js => Mobile.js} (100%) create mode 100644 frontend/src/services/Android/index.ts create mode 100644 frontend/src/services/Roboidentities/Android.ts create mode 100644 frontend/src/services/Roboidentities/RoboidentitiesAndroidClient/index.ts create mode 100644 frontend/src/services/System/SystemAndroidClient/index.ts create mode 100644 mobile_new/app/src/main/java/com/koalasat/robosats/tor/WebAppInterface.kt diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cf4f18fa..507ec9fb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -51,6 +51,7 @@ "reconnecting-websocket": "^4.4.0", "robo-identities-wasm": "^0.1.0", "simple-plist": "^1.3.1", + "uuid": "^11.1.0", "webln": "^0.3.2", "websocket": "^1.0.35" }, @@ -17013,6 +17014,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 74386488..903e5d90 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -98,6 +98,7 @@ "reconnecting-websocket": "^4.4.0", "robo-identities-wasm": "^0.1.0", "simple-plist": "^1.3.1", + "uuid": "^11.1.0", "webln": "^0.3.2", "websocket": "^1.0.35" } diff --git a/frontend/src/geo/Native.js b/frontend/src/geo/Mobile.js similarity index 100% rename from frontend/src/geo/Native.js rename to frontend/src/geo/Mobile.js diff --git a/frontend/src/i18n/Native.js b/frontend/src/i18n/Mobile.js similarity index 100% rename from frontend/src/i18n/Native.js rename to frontend/src/i18n/Mobile.js diff --git a/frontend/src/services/Android/index.ts b/frontend/src/services/Android/index.ts new file mode 100644 index 00000000..f33b702a --- /dev/null +++ b/frontend/src/services/Android/index.ts @@ -0,0 +1,28 @@ +declare global { + interface Window { + AndroidAppRobosats?: AndroidAppRobosats; + AndroidRobosats?: AndroidRobosats; + RobosatsSettings: 'web-basic' | 'web-pro' | 'selfhosted-basic' | 'selfhosted-pro'; + } +} + +interface AndroidAppRobosats { + generateRoboname: (uuid: string, initialString: string) => void; +} + +class AndroidRobosats { + private promises: Record) => void> = {}; + + public storePromise: ( + uuid: string, + promise: (value: string | PromiseLike) => void, + ) => void = (uuid, promise) => { + this.promises[uuid] = promise; + }; + + public onResolvePromise: (uuid: string, response: string) => void = (uuid, respone) => { + this.promises[uuid](respone); + }; +} + +export default AndroidRobosats; diff --git a/frontend/src/services/Roboidentities/Android.ts b/frontend/src/services/Roboidentities/Android.ts new file mode 100644 index 00000000..ffa1be5b --- /dev/null +++ b/frontend/src/services/Roboidentities/Android.ts @@ -0,0 +1,4 @@ +import RoboidentitiesAndroidClient from './RoboidentitiesAndroidClient'; +import { type RoboidentitiesClient } from './type'; + +export const roboidentitiesClient: RoboidentitiesClient = new RoboidentitiesAndroidClient(); diff --git a/frontend/src/services/Roboidentities/RoboidentitiesAndroidClient/index.ts b/frontend/src/services/Roboidentities/RoboidentitiesAndroidClient/index.ts new file mode 100644 index 00000000..12cdbd83 --- /dev/null +++ b/frontend/src/services/Roboidentities/RoboidentitiesAndroidClient/index.ts @@ -0,0 +1,44 @@ +import { type RoboidentitiesClient } from '../type'; +import { v4 as uuidv4 } from 'uuid'; + +class RoboidentitiesAndroidClient implements RoboidentitiesClient { + private robonames: Record = {}; + private robohashes: Record = {}; + + public generateRoboname: (initialString: string) => Promise = async (initialString) => { + if (this.robonames[initialString]) { + return this.robonames[initialString]; + } else { + const result = await new Promise((resolve) => { + const uuid: string = uuidv4(); + window.AndroidAppRobosats?.generateRoboname(uuid, initialString); + window.AndroidRobosats?.storePromise(uuid, resolve); + }); + + this.robonames[initialString] = result; + + return result; + } + }; + + public generateRobohash: (initialString: string, size: 'small' | 'large') => Promise = + async (initialString, size) => { + const key = `${initialString};${size === 'small' ? 80 : 256}`; + + if (this.robohashes[key]) { + return this.robohashes[key]; + } else { + const response = await window.NativeRobosats?.postMessage({ + category: 'roboidentities', + type: 'robohash', + detail: key, + }); + const result: string = response ? Object.values(response)[0] : ''; + const image: string = `data:image/png;base64,${result}`; + this.robohashes[key] = image; + return image; + } + }; +} + +export default RoboidentitiesAndroidClient; diff --git a/frontend/src/services/System/SystemAndroidClient/index.ts b/frontend/src/services/System/SystemAndroidClient/index.ts new file mode 100644 index 00000000..b0d7410b --- /dev/null +++ b/frontend/src/services/System/SystemAndroidClient/index.ts @@ -0,0 +1,55 @@ +import { type SystemClient } from '..'; +import AndroidRobosats from '../../Android'; + +class SystemAndroidClient implements SystemClient { + constructor() { + window.AndroidRobosats = new AndroidRobosats(); + } + + public loading = false; + + // TODO + public copyToClipboard: (value: string) => void = () => {}; + + // Cookies + public getCookie: (key: string) => string = (key) => { + let cookieValue = null; + if (document?.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + // Does this cookie string begin with the key we want? + if (cookie.substring(0, key.length + 1) === key + '=') { + cookieValue = decodeURIComponent(cookie.substring(key.length + 1)); + break; + } + } + } + + return cookieValue ?? ''; + }; + + public setCookie: (key: string, value: string) => void = (key, value) => { + document.cookie = `${key}=${value};path=/;SameSite=None;Secure`; + }; + + public deleteCookie: (key: string) => void = (key) => { + document.cookie = `${key}= ;path=/; expires = Thu, 01 Jan 1970 00:00:00 GMT`; + }; + + // Local storage + public getItem: (key: string) => string = (key) => { + const value = window.localStorage.getItem(key); + return value ?? ''; + }; + + public setItem: (key: string, value: string) => void = (key, value) => { + window.localStorage.setItem(key, value); + }; + + public deleteItem: (key: string) => void = (key) => { + window.localStorage.removeItem(key); + }; +} + +export default SystemAndroidClient; diff --git a/frontend/src/services/System/index.ts b/frontend/src/services/System/index.ts index 743f3850..4d31d34b 100644 --- a/frontend/src/services/System/index.ts +++ b/frontend/src/services/System/index.ts @@ -1,6 +1,7 @@ import SystemNativeClient from './SystemNativeClient'; import SystemWebClient from './SystemWebClient'; import SystemDesktopClient from './SystemDesktopClient'; +import SystemAndroidClient from './SystemAndroidClient'; export interface SystemClient { loading: boolean; @@ -21,6 +22,9 @@ function getSystemClient(): SystemClient { } else if (window.navigator.userAgent.includes('Electron')) { // If userAgent has "Electron", we assume the app is running inside of an Electron app. return new SystemDesktopClient(); + } else if (window.navigator.userAgent.includes('AndroidRobosats')) { + // If userAgent has "AndroidRobosats", we assume the app is running inside the Kotlin webview of the RoboSats Android app. + return new SystemAndroidClient(); } else { // Otherwise, we assume the app is running in a web browser. return new SystemWebClient(); diff --git a/frontend/webpack.config.ts b/frontend/webpack.config.ts index cb1d02d9..c6aa5780 100644 --- a/frontend/webpack.config.ts +++ b/frontend/webpack.config.ts @@ -152,7 +152,7 @@ const configNode: Configuration = { ], }; -const configMobile: Configuration = { +const configNative: Configuration = { ...config, module: { ...config.module, @@ -163,7 +163,7 @@ const configMobile: Configuration = { loader: 'file-replace-loader', options: { condition: 'if-replacement-exists', - replacement: path.resolve(__dirname, 'src/i18n/Native.js'), + replacement: path.resolve(__dirname, 'src/i18n/Mobile.js'), async: true, }, }, @@ -172,7 +172,7 @@ const configMobile: Configuration = { loader: 'file-replace-loader', options: { condition: 'if-replacement-exists', - replacement: path.resolve(__dirname, 'src/geo/Native.js'), + replacement: path.resolve(__dirname, 'src/geo/Mobile.js'), async: true, }, }, @@ -236,6 +236,63 @@ const configMobile: Configuration = { }, }, }), + ], +}; + +const configAndroid: Configuration = { + ...config, + module: { + ...config.module, + rules: [ + ...(config?.module?.rules || []), + { + test: path.resolve(__dirname, 'src/i18n/Web.js'), + loader: 'file-replace-loader', + options: { + condition: 'if-replacement-exists', + replacement: path.resolve(__dirname, 'src/i18n/Mobile.js'), + async: true, + }, + }, + { + test: path.resolve(__dirname, 'src/geo/Web.js'), + loader: 'file-replace-loader', + options: { + condition: 'if-replacement-exists', + replacement: path.resolve(__dirname, 'src/geo/Mobile.js'), + async: true, + }, + }, + { + test: path.resolve(__dirname, 'src/services/Roboidentities/Web.ts'), + loader: 'file-replace-loader', + options: { + condition: 'if-replacement-exists', + replacement: path.resolve(__dirname, 'src/services/Roboidentities/Android.ts'), + async: true, + }, + }, + { + test: path.resolve(__dirname, 'src/components/RobotAvatar/placeholder.json'), + loader: 'file-replace-loader', + options: { + condition: 'if-replacement-exists', + replacement: path.resolve( + __dirname, + 'src/components/RobotAvatar/placeholder_highres.json', + ), + async: true, + }, + }, + ], + }, + output: { + path: path.resolve(__dirname, '../mobile_new/app/src/main/assets/static/frontend'), + filename: `main.v${version}.[contenthash].js`, + clean: true, + publicPath: './static/frontend/', + }, + plugins: [ new HtmlWebpackPlugin({ template: path.resolve(__dirname, 'templates/frontend/index.ejs'), templateParameters: { @@ -293,4 +350,4 @@ const configMobile: Configuration = { ], }; -export default [configNode, configMobile]; +export default [configNode, configNative, configAndroid]; diff --git a/mobile_new/app/src/main/java/com/koalasat/robosats/MainActivity.kt b/mobile_new/app/src/main/java/com/koalasat/robosats/MainActivity.kt index 9a5f71e1..e250eb89 100644 --- a/mobile_new/app/src/main/java/com/koalasat/robosats/MainActivity.kt +++ b/mobile_new/app/src/main/java/com/koalasat/robosats/MainActivity.kt @@ -6,6 +6,7 @@ import android.os.Bundle import android.util.Log import android.webkit.* import androidx.appcompat.app.AppCompatActivity +import com.koalasat.robosats.tor.WebAppInterface import com.robosats.tor.TorKmp import com.robosats.tor.TorKmpManager import java.net.InetSocketAddress @@ -394,6 +395,11 @@ class MainActivity : AppCompatActivity() { } } + webView.settings.userAgentString = "AndroidRobosats" + + // Add the JavaScript interface + webView.addJavascriptInterface(WebAppInterface(this, webView), "AndroidAppRobosats") + // Now it's safe to load the local HTML file webView.loadUrl("file:///android_asset/index.html") } diff --git a/mobile_new/app/src/main/java/com/koalasat/robosats/tor/WebAppInterface.kt b/mobile_new/app/src/main/java/com/koalasat/robosats/tor/WebAppInterface.kt new file mode 100644 index 00000000..f9c685f7 --- /dev/null +++ b/mobile_new/app/src/main/java/com/koalasat/robosats/tor/WebAppInterface.kt @@ -0,0 +1,18 @@ +package com.koalasat.robosats.tor + +import android.content.Context +import android.webkit.JavascriptInterface +import android.webkit.WebView +import android.widget.Toast + +class WebAppInterface(private val context: Context, private val webView: WebView) { + @JavascriptInterface + fun generateRoboname(uuid: String, message: String) { + // Handle the message received from JavaScript + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + + webView.post { + webView.evaluateJavascript("javascript:window.AndroidRobosats.onResolvePromise('${uuid}', '${message}')", null) + } + } +}