From f56c705fdd3aa3952c4c14697b0481aa9523ee96 Mon Sep 17 00:00:00 2001 From: Mofe Ejegi Date: Sat, 12 Sep 2026 11:28:41 +0100 Subject: [PATCH 1/2] Fix #347: escape JS bridge callback payloads WebViewJsBridge.onCallback spliced the handler's payload straight into a single-quoted JavaScript literal, so any payload carrying a quote, a backslash or a line break produced an invalid script and the web side's callback never ran. Render the payload with the JSON encoder already on the classpath. JSON string escaping is a subset of JavaScript's, so the encoded form is a valid double-quoted literal and the callback receives exactly the string the handler passed. U+2028 and U+2029 are escaped as well, since JavaScript treated them as line terminators before ES2019. Adds the module's first commonTest, covering quotes, backslashes, line terminators, the JSON payload from the issue and the assembled script. --- webview/build.gradle.kts | 4 ++ .../webview/jsbridge/WebViewJsBridge.kt | 30 ++++++++++++- .../webview/jsbridge/WebViewJsBridgeTest.kt | 44 +++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 webview/src/commonTest/kotlin/com/multiplatform/webview/jsbridge/WebViewJsBridgeTest.kt diff --git a/webview/build.gradle.kts b/webview/build.gradle.kts index 06f9beeb..6b88e470 100644 --- a/webview/build.gradle.kts +++ b/webview/build.gradle.kts @@ -67,6 +67,10 @@ kotlin { implementation(libs.kotlin.serialization.json) } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + androidMain.dependencies { api(libs.android.activity.compose) api(libs.android.webkit) diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/jsbridge/WebViewJsBridge.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/jsbridge/WebViewJsBridge.kt index c4e5fb18..922793d1 100644 --- a/webview/src/commonMain/kotlin/com/multiplatform/webview/jsbridge/WebViewJsBridge.kt +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/jsbridge/WebViewJsBridge.kt @@ -5,6 +5,8 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import com.multiplatform.webview.web.IWebView import com.multiplatform.webview.web.WebViewNavigator +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json /** * Created By Kevin Zou On 2023/10/31 @@ -39,9 +41,35 @@ open class WebViewJsBridge( private fun onCallback( data: String, callbackId: Int, - ) = webView?.evaluateJavaScript("window.$jsBridgeName.onCallback($callbackId, '$data')") + ) = webView?.evaluateJavaScript(callbackScript(jsBridgeName, callbackId, data)) } +/** + * The script that hands [data] to the web side's pending callback [callbackId]. + * + * [data] is spliced in as a JavaScript string literal rather than pasted between quotes, so + * quotes, backslashes and line breaks in the payload reach the callback unchanged instead of + * breaking the script. + */ +internal fun callbackScript( + jsBridgeName: String, + callbackId: Int, + data: String, +): String = "window.$jsBridgeName.onCallback($callbackId, ${data.toJsStringLiteral()})" + +/** + * Renders this string as a JavaScript string literal. + * + * JSON string escaping is a subset of JavaScript's, so the JSON encoding is a valid + * double-quoted literal. U+2028 and U+2029 are escaped as well: JSON allows them raw, but + * JavaScript treated them as line terminators before ES2019. + */ +internal fun String.toJsStringLiteral(): String = + Json + .encodeToString(this) + .replace("\u2028", "\\u2028") + .replace("\u2029", "\\u2029") + /** * Create a [WebViewJsBridge] that is remembered across Compositions. */ diff --git a/webview/src/commonTest/kotlin/com/multiplatform/webview/jsbridge/WebViewJsBridgeTest.kt b/webview/src/commonTest/kotlin/com/multiplatform/webview/jsbridge/WebViewJsBridgeTest.kt new file mode 100644 index 00000000..dc69b1f6 --- /dev/null +++ b/webview/src/commonTest/kotlin/com/multiplatform/webview/jsbridge/WebViewJsBridgeTest.kt @@ -0,0 +1,44 @@ +package com.multiplatform.webview.jsbridge + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class WebViewJsBridgeTest { + @Test + fun plainTextIsQuoted() { + assertEquals("\"hello\"", "hello".toJsStringLiteral()) + } + + @Test + fun quotesAndBackslashesAreEscaped() { + assertEquals( + "\"it's \\\"quoted\\\" and \\\\ escaped\"", + "it's \"quoted\" and \\ escaped".toJsStringLiteral(), + ) + } + + @Test + fun lineTerminatorsAreEscaped() { + val literal = "line 1\nline 2\r\n\u2028\u2029".toJsStringLiteral() + assertEquals("\"line 1\\nline 2\\r\\n\\u2028\\u2029\"", literal) + assertFalse(literal.any { it == '\n' || it == '\r' || it == '\u2028' || it == '\u2029' }) + } + + @Test + fun jsonPayloadKeepsItsOwnEscapes() { + // #347: a JSON payload whose string field holds a newline. The encoder wrote that + // newline as the two characters `\n`; the literal must carry both so JSON.parse on + // the web side sees an escape sequence rather than a raw control character. + val payload = """{"text":"line 1\nline 2"}""" + assertEquals("\"{\\\"text\\\":\\\"line 1\\\\nline 2\\\"}\"", payload.toJsStringLiteral()) + } + + @Test + fun callbackScriptSplicesTheLiteral() { + assertEquals( + "window.kmpJsBridge.onCallback(7, \"it's\\ndone\")", + callbackScript("kmpJsBridge", 7, "it's\ndone"), + ) + } +} From 7d8492e20c79ad6d510f7af6aced483dd0185322 Mon Sep 17 00:00:00 2001 From: Mofe Ejegi Date: Sat, 12 Sep 2026 12:47:54 +0100 Subject: [PATCH 2/2] Fix #347: Make the sample's Greet handler on Android reply on the main thread --- .../com/kevinnzou/sample/jsbridge/GreetJsMessageHandler.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/jsbridge/GreetJsMessageHandler.kt b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/jsbridge/GreetJsMessageHandler.kt index 85447ce2..6c651476 100644 --- a/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/jsbridge/GreetJsMessageHandler.kt +++ b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/jsbridge/GreetJsMessageHandler.kt @@ -27,7 +27,7 @@ class GreetJsMessageHandler : IJsMessageHandler { } val param = processParams(message) val data = GreetModel("KMP Received ${param.message}") - callback(dataToJsonString(data)) + navigator?.coroutineScope?.launch { callback(dataToJsonString(data)) } // EventBus.post(NavigationEvent()) navigator?.coroutineScope?.launch { FlowEventBus.publishEvent(NavigationEvent())