From dee6eba5ff81d548de883bcce78b5a70875769ef Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Fri, 17 Apr 2026 22:04:05 -0700 Subject: [PATCH] SELF-2650: Add WebView Ethereum bridge PoC with common bridge, Android/iOS implementations, and UI (#1989) * Add WebView ethereum bridge PoC to kmp minipay sample * fix pipelines * pr feedback * fix --- .../webview/PlatformWebViewBridge.android.kt | 127 ++++++++++ .../commonMain/kotlin/xyz/self/minipay/App.kt | 6 + .../xyz/self/minipay/screens/HomeScreen.kt | 15 ++ .../minipay/screens/WebViewBridgeScreen.kt | 48 ++++ .../self/minipay/webview/EthereumBridge.kt | 239 ++++++++++++++++++ .../webview/PlatformWebViewBridge.ios.kt | 137 ++++++++++ 6 files changed, 572 insertions(+) create mode 100644 packages/kmp-minipay-sample/composeApp/src/androidMain/kotlin/xyz/self/minipay/webview/PlatformWebViewBridge.android.kt create mode 100644 packages/kmp-minipay-sample/composeApp/src/commonMain/kotlin/xyz/self/minipay/screens/WebViewBridgeScreen.kt create mode 100644 packages/kmp-minipay-sample/composeApp/src/commonMain/kotlin/xyz/self/minipay/webview/EthereumBridge.kt create mode 100644 packages/kmp-minipay-sample/composeApp/src/iosMain/kotlin/xyz/self/minipay/webview/PlatformWebViewBridge.ios.kt diff --git a/packages/kmp-minipay-sample/composeApp/src/androidMain/kotlin/xyz/self/minipay/webview/PlatformWebViewBridge.android.kt b/packages/kmp-minipay-sample/composeApp/src/androidMain/kotlin/xyz/self/minipay/webview/PlatformWebViewBridge.android.kt new file mode 100644 index 000000000..291058426 --- /dev/null +++ b/packages/kmp-minipay-sample/composeApp/src/androidMain/kotlin/xyz/self/minipay/webview/PlatformWebViewBridge.android.kt @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +package xyz.self.minipay.screens + +import android.annotation.SuppressLint +import android.content.Context +import android.webkit.JavascriptInterface +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import kotlinx.serialization.json.Json +import org.json.JSONObject +import xyz.self.minipay.webview.BRIDGE_DEMO_HTML +import xyz.self.minipay.webview.BridgeMethodException +import xyz.self.minipay.webview.ETHEREUM_BRIDGE_CHANNEL +import xyz.self.minipay.webview.ETHEREUM_BRIDGE_STUB +import xyz.self.minipay.webview.MethodRegistry +import xyz.self.minipay.webview.ProviderError +import xyz.self.minipay.webview.ProviderErrorCodes +import xyz.self.minipay.webview.ProviderRequest +import xyz.self.minipay.webview.ProviderResponse + +private val json = Json { ignoreUnknownKeys = true } + +@Composable +actual fun PlatformWebViewBridge(registry: MethodRegistry) { + AndroidView( + modifier = Modifier, + factory = { context -> createWebView(context = context, registry = registry) }, + ) +} + +@SuppressLint("SetJavaScriptEnabled") +private fun createWebView( + context: Context, + registry: MethodRegistry, +): WebView { + val bridge = AndroidEthereumBridge(registry) + + return WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + webViewClient = + object : WebViewClient() { + override fun onPageFinished( + view: WebView, + url: String?, + ) { + view.evaluateJavascript(ETHEREUM_BRIDGE_STUB, null) + } + } + addJavascriptInterface(bridge, ETHEREUM_BRIDGE_CHANNEL) + bridge.attach(this) + loadDataWithBaseURL("https://localhost/", BRIDGE_DEMO_HTML, "text/html", "utf-8", null) + } +} + +private class AndroidEthereumBridge( + private val registry: MethodRegistry, +) { + private val pendingRequests = mutableMapOf Unit>() + private var webView: WebView? = null + + fun attach(webView: WebView) { + this.webView = webView + } + + @JavascriptInterface + fun postMessage(requestJson: String) { + val request = + try { + json.decodeFromString(requestJson) + } catch (_: Exception) { + val fallbackId = requestJson.substringAfter("\"id\":\"", "").substringBefore("\"", "") + if (fallbackId.isNotEmpty()) { + respond( + ProviderResponse( + id = fallbackId, + error = + ProviderError( + code = ProviderErrorCodes.INVALID_PARAMS, + message = "Invalid request payload", + ), + ), + ) + } + return + } + + pendingRequests[request.id] = { response -> sendResponseToJs(response) } + + val response = + try { + registry.dispatch(request) + } catch (exception: BridgeMethodException) { + ProviderResponse(id = request.id, error = exception.providerError) + } catch (exception: Exception) { + ProviderResponse( + id = request.id, + error = + ProviderError( + code = ProviderErrorCodes.INTERNAL_ERROR, + message = exception.message ?: "Internal error", + ), + ) + } + + respond(response) + } + + private fun respond(response: ProviderResponse) { + val callback = pendingRequests.remove(response.id) ?: return + callback(response) + } + + private fun sendResponseToJs(response: ProviderResponse) { + val payload = json.encodeToString(ProviderResponse.serializer(), response) + val escaped = JSONObject.quote(payload) + webView?.post { + webView?.evaluateJavascript("window.__selfEthereumResolve($escaped);", null) + } + } +} diff --git a/packages/kmp-minipay-sample/composeApp/src/commonMain/kotlin/xyz/self/minipay/App.kt b/packages/kmp-minipay-sample/composeApp/src/commonMain/kotlin/xyz/self/minipay/App.kt index 16a1b927f..68b4a8435 100644 --- a/packages/kmp-minipay-sample/composeApp/src/commonMain/kotlin/xyz/self/minipay/App.kt +++ b/packages/kmp-minipay-sample/composeApp/src/commonMain/kotlin/xyz/self/minipay/App.kt @@ -11,6 +11,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import xyz.self.minipay.screens.HomeScreen import xyz.self.minipay.screens.ResultScreen +import xyz.self.minipay.screens.WebViewBridgeScreen import xyz.self.minipay.theme.MiniPayTheme import xyz.self.sdk.api.SelfSdk @@ -31,6 +32,7 @@ fun App(sdk: SelfSdk? = null, platformContext: Any? = null) { viewModel = viewModel, onVerify = { viewModel.launchVerification(platformContext) }, onNavigateToResult = { navController.navigate("result") }, + onOpenWebViewBridge = { navController.navigate("webview-bridge") }, ) } @@ -47,6 +49,10 @@ fun App(sdk: SelfSdk? = null, platformContext: Any? = null) { }, ) } + + composable("webview-bridge") { + WebViewBridgeScreen() + } } } } diff --git a/packages/kmp-minipay-sample/composeApp/src/commonMain/kotlin/xyz/self/minipay/screens/HomeScreen.kt b/packages/kmp-minipay-sample/composeApp/src/commonMain/kotlin/xyz/self/minipay/screens/HomeScreen.kt index cd81e9837..095290ec5 100644 --- a/packages/kmp-minipay-sample/composeApp/src/commonMain/kotlin/xyz/self/minipay/screens/HomeScreen.kt +++ b/packages/kmp-minipay-sample/composeApp/src/commonMain/kotlin/xyz/self/minipay/screens/HomeScreen.kt @@ -31,6 +31,7 @@ fun HomeScreen( viewModel: MainViewModel, onVerify: () -> Unit, onNavigateToResult: () -> Unit, + onOpenWebViewBridge: () -> Unit, ) { // Navigate to result when screen changes LaunchedEffect(viewModel.currentScreen) { @@ -125,6 +126,20 @@ fun HomeScreen( ) } + Button( + onClick = onOpenWebViewBridge, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(12.dp), + ) { + Text( + text = "Open WebView Bridge PoC", + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(modifier = Modifier.height(24.dp)) } } diff --git a/packages/kmp-minipay-sample/composeApp/src/commonMain/kotlin/xyz/self/minipay/screens/WebViewBridgeScreen.kt b/packages/kmp-minipay-sample/composeApp/src/commonMain/kotlin/xyz/self/minipay/screens/WebViewBridgeScreen.kt new file mode 100644 index 000000000..d827838a8 --- /dev/null +++ b/packages/kmp-minipay-sample/composeApp/src/commonMain/kotlin/xyz/self/minipay/screens/WebViewBridgeScreen.kt @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +package xyz.self.minipay.screens + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import xyz.self.minipay.webview.BridgeMethodException +import xyz.self.minipay.webview.MethodRegistry +import xyz.self.minipay.webview.ProviderError +import xyz.self.minipay.webview.ProviderErrorCodes + +@Composable +fun WebViewBridgeScreen() { + val registry = + remember { + MethodRegistry().apply { + registerMethod("demo_echo") { params -> + Result.success( + buildJsonObject { + put("ok", JsonPrimitive(true)) + put("echo", params ?: JsonNull) + }, + ) + } + + registerMethod("demo_reject") { + Result.failure( + BridgeMethodException( + ProviderError( + code = ProviderErrorCodes.INVALID_PARAMS, + message = "demo_reject always fails", + ), + ), + ) + } + } + } + + PlatformWebViewBridge(registry = registry) +} + +@Composable +expect fun PlatformWebViewBridge(registry: MethodRegistry) diff --git a/packages/kmp-minipay-sample/composeApp/src/commonMain/kotlin/xyz/self/minipay/webview/EthereumBridge.kt b/packages/kmp-minipay-sample/composeApp/src/commonMain/kotlin/xyz/self/minipay/webview/EthereumBridge.kt new file mode 100644 index 000000000..0a9ce5c2f --- /dev/null +++ b/packages/kmp-minipay-sample/composeApp/src/commonMain/kotlin/xyz/self/minipay/webview/EthereumBridge.kt @@ -0,0 +1,239 @@ +// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +package xyz.self.minipay.webview + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +object ProviderErrorCodes { + const val UNKNOWN_METHOD = 4200 + const val INVALID_PARAMS = -32602 + const val INTERNAL_ERROR = -32603 +} + +@Serializable +data class ProviderError( + val code: Int, + val message: String, + val data: JsonElement? = null, +) + +@Serializable +data class ProviderRequest( + val id: String, + val method: String, + val params: JsonElement? = null, +) + +@Serializable +data class ProviderResponse( + val id: String, + val result: JsonElement? = null, + val error: ProviderError? = null, +) + +typealias MethodHandler = (params: JsonElement?) -> Result + + +class BridgeMethodException( + val providerError: ProviderError, +) : Exception(providerError.message) + +class MethodRegistry { + private val handlers = mutableMapOf() + + fun registerMethod( + name: String, + handler: MethodHandler, + ) { + handlers[name] = handler + } + + fun dispatch(request: ProviderRequest): ProviderResponse { + val handler = + handlers[request.method] + ?: return request.errorResponse( + ProviderError( + code = ProviderErrorCodes.UNKNOWN_METHOD, + message = "Unsupported method: ${request.method}", + ), + ) + + return handler(request.params) + .fold( + onSuccess = { result -> ProviderResponse(id = request.id, result = result) }, + onFailure = { throwable -> + val providerError = + if (throwable is BridgeMethodException) { + throwable.providerError + } else { + ProviderError( + code = ProviderErrorCodes.INTERNAL_ERROR, + message = throwable.message ?: "Internal error", + ) + } + request.errorResponse(providerError) + }, + ) + } + + private fun ProviderRequest.errorResponse(error: ProviderError): ProviderResponse = + ProviderResponse(id = id, error = error) +} + +const val ETHEREUM_BRIDGE_CHANNEL = "SelfEthereumBridge" + +const val ETHEREUM_BRIDGE_STUB = + """ + (() => { + if (window.ethereum && window.ethereum.__selfBridgeReady) { + return; + } + + const pending = new Map(); + let nextRequestId = 1; + + const rejectWithProviderError = (reject, error) => { + reject({ + code: error?.code ?? -32603, + message: error?.message ?? 'Internal error', + data: error?.data ?? null, + }); + }; + + window.__selfEthereumResolve = (responseJson) => { + let response; + try { + response = JSON.parse(responseJson); + } catch (_error) { + return; + } + + const entry = pending.get(response.id); + if (!entry) { + return; + } + + pending.delete(response.id); + + if (response.error) { + rejectWithProviderError(entry.reject, response.error); + return; + } + + entry.resolve(response.result ?? null); + }; + + const sendToNative = (payload) => { + if (window.webkit?.messageHandlers?.SelfEthereumBridge) { + window.webkit.messageHandlers.SelfEthereumBridge.postMessage(payload); + return; + } + + if (window.SelfEthereumBridge?.postMessage) { + window.SelfEthereumBridge.postMessage(payload); + return; + } + + throw new Error('Native bridge is unavailable'); + }; + + window.ethereum = { + __selfBridgeReady: true, + request(args) { + return new Promise((resolve, reject) => { + const { method, params } = args || {}; + if (!method || typeof method !== 'string') { + rejectWithProviderError(reject, { + code: -32602, + message: 'Invalid params: method must be a string', + }); + return; + } + + const id = String(nextRequestId++); + pending.set(id, { resolve, reject }); + + try { + sendToNative(JSON.stringify({ id, method, params: params ?? null })); + } catch (error) { + pending.delete(id); + rejectWithProviderError(reject, { + code: -32603, + message: error?.message || 'Failed to call native bridge', + }); + } + }); + }, + }; + })(); + """ + +const val BRIDGE_DEMO_HTML = + """ + + + + + + MiniPay Bridge PoC + + +

MiniPay Bridge PoC

+

Uses window.ethereum.request through native bridge.

+ + + + +

+
+        
+      
+    
+    """
diff --git a/packages/kmp-minipay-sample/composeApp/src/iosMain/kotlin/xyz/self/minipay/webview/PlatformWebViewBridge.ios.kt b/packages/kmp-minipay-sample/composeApp/src/iosMain/kotlin/xyz/self/minipay/webview/PlatformWebViewBridge.ios.kt
new file mode 100644
index 000000000..cb004919b
--- /dev/null
+++ b/packages/kmp-minipay-sample/composeApp/src/iosMain/kotlin/xyz/self/minipay/webview/PlatformWebViewBridge.ios.kt
@@ -0,0 +1,137 @@
+// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
+// SPDX-License-Identifier: BUSL-1.1
+// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
+
+package xyz.self.minipay.screens
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.interop.UIKitView
+import kotlin.native.ref.WeakReference
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.readValue
+import kotlinx.serialization.builtins.serializer
+import kotlinx.serialization.json.Json
+import platform.CoreGraphics.CGRectZero
+import platform.darwin.NSObject
+import platform.WebKit.WKScriptMessage
+import platform.WebKit.WKScriptMessageHandlerProtocol
+import platform.WebKit.WKUserContentController
+import platform.WebKit.WKUserScript
+import platform.WebKit.WKUserScriptInjectionTime.WKUserScriptInjectionTimeAtDocumentStart
+import platform.WebKit.WKWebView
+import platform.WebKit.WKWebViewConfiguration
+import xyz.self.minipay.webview.BRIDGE_DEMO_HTML
+import xyz.self.minipay.webview.BridgeMethodException
+import xyz.self.minipay.webview.ETHEREUM_BRIDGE_CHANNEL
+import xyz.self.minipay.webview.ETHEREUM_BRIDGE_STUB
+import xyz.self.minipay.webview.MethodRegistry
+import xyz.self.minipay.webview.ProviderError
+import xyz.self.minipay.webview.ProviderErrorCodes
+import xyz.self.minipay.webview.ProviderRequest
+import xyz.self.minipay.webview.ProviderResponse
+
+private val json = Json { ignoreUnknownKeys = true }
+
+@OptIn(ExperimentalForeignApi::class)
+@Composable
+actual fun PlatformWebViewBridge(registry: MethodRegistry) {
+    UIKitView(
+        modifier = Modifier,
+        factory = {
+            val bridge = IosEthereumBridge(registry)
+            val userContentController = WKUserContentController()
+            userContentController.addScriptMessageHandler(
+                WeakMessageHandlerProxy(bridge),
+                ETHEREUM_BRIDGE_CHANNEL,
+            )
+            userContentController.addUserScript(
+                WKUserScript(
+                    source = ETHEREUM_BRIDGE_STUB,
+                    injectionTime = WKUserScriptInjectionTimeAtDocumentStart,
+                    forMainFrameOnly = true,
+                ),
+            )
+
+            val config = WKWebViewConfiguration()
+            config.userContentController = userContentController
+
+            WKWebView(frame = CGRectZero.readValue(), configuration = config).apply {
+                bridge.attach(this)
+                loadHTMLString(BRIDGE_DEMO_HTML, baseURL = null)
+            }
+        },
+        update = {},
+    )
+}
+
+@OptIn(ExperimentalForeignApi::class)
+private class IosEthereumBridge(
+    private val registry: MethodRegistry,
+) : NSObject(), WKScriptMessageHandlerProtocol {
+    private val pendingRequests = mutableMapOf Unit>()
+    private var webView: WKWebView? = null
+
+    fun attach(webView: WKWebView) {
+        this.webView = webView
+    }
+
+    override fun userContentController(
+        userContentController: WKUserContentController,
+        didReceiveScriptMessage: WKScriptMessage,
+    ) {
+        val requestJson = didReceiveScriptMessage.body.toString()
+        val request =
+            try {
+                json.decodeFromString(requestJson)
+            } catch (_: Exception) {
+                return
+            }
+
+        pendingRequests[request.id] = { response -> sendResponseToJs(response) }
+
+        val response =
+            try {
+                registry.dispatch(request)
+            } catch (exception: BridgeMethodException) {
+                ProviderResponse(id = request.id, error = exception.providerError)
+            } catch (exception: Exception) {
+                ProviderResponse(
+                    id = request.id,
+                    error =
+                        ProviderError(
+                            code = ProviderErrorCodes.INTERNAL_ERROR,
+                            message = exception.message ?: "Internal error",
+                        ),
+                )
+            }
+
+        respond(response)
+    }
+
+    private fun respond(response: ProviderResponse) {
+        val callback = pendingRequests.remove(response.id) ?: return
+        callback(response)
+    }
+
+    private fun sendResponseToJs(response: ProviderResponse) {
+        val responseJson = json.encodeToString(ProviderResponse.serializer(), response)
+        val jsStringLiteral = json.encodeToString(String.serializer(), responseJson)
+        val script = "window.__selfEthereumResolve($jsStringLiteral);"
+        webView?.evaluateJavaScript(script, completionHandler = null)
+    }
+}
+
+@OptIn(ExperimentalForeignApi::class, kotlin.experimental.ExperimentalNativeApi::class)
+private class WeakMessageHandlerProxy(
+    handler: WKScriptMessageHandlerProtocol,
+) : NSObject(), WKScriptMessageHandlerProtocol {
+    private val weakHandler = WeakReference(handler)
+
+    override fun userContentController(
+        userContentController: WKUserContentController,
+        didReceiveScriptMessage: WKScriptMessage,
+    ) {
+        weakHandler.get()?.userContentController(userContentController, didReceiveScriptMessage)
+    }
+}