mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
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
This commit is contained in:
@@ -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<String, (ProviderResponse) -> Unit>()
|
||||
private var webView: WebView? = null
|
||||
|
||||
fun attach(webView: WebView) {
|
||||
this.webView = webView
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun postMessage(requestJson: String) {
|
||||
val request =
|
||||
try {
|
||||
json.decodeFromString<ProviderRequest>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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<JsonElement?>
|
||||
|
||||
|
||||
class BridgeMethodException(
|
||||
val providerError: ProviderError,
|
||||
) : Exception(providerError.message)
|
||||
|
||||
class MethodRegistry {
|
||||
private val handlers = mutableMapOf<String, MethodHandler>()
|
||||
|
||||
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 =
|
||||
"""
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>MiniPay Bridge PoC</title>
|
||||
</head>
|
||||
<body style="font-family: sans-serif; padding: 16px;">
|
||||
<h2>MiniPay Bridge PoC</h2>
|
||||
<p>Uses <code>window.ethereum.request</code> through native bridge.</p>
|
||||
<button onclick="runEcho()">demo_echo</button>
|
||||
<button onclick="runReject()">demo_reject</button>
|
||||
<button onclick="runUnknown()">foo (unknown)</button>
|
||||
<button onclick="runConcurrent()">concurrent demo</button>
|
||||
<pre id="output" style="margin-top: 16px; white-space: pre-wrap;"></pre>
|
||||
|
||||
<script>
|
||||
const output = document.getElementById('output');
|
||||
const log = (label, value) => {
|
||||
output.textContent += `${'$'}{label}: ${'$'}{JSON.stringify(value)}\\n`;
|
||||
};
|
||||
|
||||
async function runEcho() {
|
||||
try {
|
||||
const result = await window.ethereum.request({
|
||||
method: 'demo_echo',
|
||||
params: { from: 'html', time: Date.now() },
|
||||
});
|
||||
log('demo_echo resolved', result);
|
||||
} catch (error) {
|
||||
log('demo_echo rejected', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function runReject() {
|
||||
try {
|
||||
const result = await window.ethereum.request({ method: 'demo_reject' });
|
||||
log('demo_reject resolved', result);
|
||||
} catch (error) {
|
||||
log('demo_reject rejected', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function runUnknown() {
|
||||
try {
|
||||
const result = await window.ethereum.request({ method: 'foo' });
|
||||
log('foo resolved', result);
|
||||
} catch (error) {
|
||||
log('foo rejected', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function runConcurrent() {
|
||||
const [echo, unknown] = await Promise.allSettled([
|
||||
window.ethereum.request({ method: 'demo_echo', params: { mode: 'parallel' } }),
|
||||
window.ethereum.request({ method: 'foo' }),
|
||||
]);
|
||||
log('concurrent demo_echo', echo);
|
||||
log('concurrent foo', unknown);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
@@ -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<String, (ProviderResponse) -> 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<ProviderRequest>(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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user