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()) 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"), + ) + } +}