diff --git a/README.md b/README.md index 9f7c203d..9cbed4c3 100644 --- a/README.md +++ b/README.md @@ -245,27 +245,94 @@ Column { val loadingState = state.loadingState if (loadingState is LoadingState.Loading) { LinearProgressIndicator( - progress = loadingState.progress, - modifier = Modifier.fillMaxWidth() + progress = loadingState.progress, + modifier = Modifier.fillMaxWidth() ) } - WebView( - state = state, - navigator = navigator - ) + WebView( + state = state, + navigator = navigator + ) +} +``` + +## Communication between WebView and Native + +Starting from version 1.8.0, this library provides a `WebViewJsBridge` to allow developers to +communicate between the WebView and Native. +Developers can use the JsBridge to register a handler to handle the message from the WebView. + +```kotlin +val jsBridge = rememberWebViewJsBridge() + +LaunchedEffect(jsBridge) { + jsBridge.register(GreetJsMessageHandler()) } ``` +The handler should implement the `IJsMessageHandler` interface. + +```kotlin +interface IJsMessageHandler { + fun methodName(): String + + fun canHandle(methodName: String) = methodName() == methodName + + fun handle( + message: JsMessage, + callback: (String) -> Unit, + ) + +} + +class GreetJsMessageHandler : IJsMessageHandler { + override fun methodName(): String { + return "Greet" + } + + override fun handle(message: JsMessage, callback: (String) -> Unit) { + Logger.i { + "Greet Handler Get Message: $message" + } + val param = processParams(message) + val data = GreetModel("KMM Received ${param.message}") + callback(dataToJsonString(data)) + } +} +``` + +Developers can use the `window.kmpJsBridge.callNative` to send a message to the Native. +It receives three parameters: + +* methodName: the name of the handler registered in the Native. +* params: the parameters to send to the Native. It needs to be a JSON string. +* callback: the callback function to handle the response from the Native. It receives a JSON string + as the parameter. Pass null if no callback is needed. + +```javascript +window.kmpJsBridge.callNative = function (methodName, params, callback) {} + +window.kmpJsBridge.callNative("Greet",JSON.stringify({message:"Hello"}), + function (data) { + document.getElementById("subtitle").innerText = data; + console.log("Greet from Native: " + data); + } +); +``` + ## WebSettings -Starting from version 1.3.0, this library allows users to customize web settings. -There are some common web settings that can be shared across different platforms, such as isJavaScriptEnabled and userAgent. + +Starting from version 1.3.0, this library allows users to customize web settings. +There are some common web settings that can be shared across different platforms, such as +isJavaScriptEnabled and userAgent. + ```kotlin class WebSettings { - var isJavaScriptEnabled = true + var isJavaScriptEnabled = true - var customUserAgentString: String? = null + var customUserAgentString: String? = null - /** + /** * Android platform specific settings */ val androidWebSettings = PlatformWebSettings.AndroidWebSettings() diff --git a/build.gradle.kts b/build.gradle.kts index 1b6ac51b..df0f0b60 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,12 +2,14 @@ plugins { // this is necessary to avoid the plugins to be loaded multiple times // in each subproject's classloader kotlin("multiplatform").apply(false) + kotlin("plugin.serialization").apply(false) id("com.android.application").apply(false) id("com.android.library").apply(false) id("org.jetbrains.compose").apply(false) id("org.jetbrains.dokka") id("com.vanniktech.maven.publish") version "0.25.3" apply false id("org.jlleitschuh.gradle.ktlint") version "11.6.1" + id("org.jetbrains.kotlin.plugin.atomicfu") version "1.9.20" } subprojects { diff --git a/gradle.properties b/gradle.properties index 68f01778..7350c497 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,7 +22,7 @@ compose.version=1.5.10 GROUP=io.github.kevinnzou POM_ARTIFACT_ID=compose-webview-multiplatform -VERSION_NAME=1.7.8 +VERSION_NAME=1.8.0-SNAPSHOT POM_NAME=Compose WebView Multiplatform POM_INCEPTION_YEAR=2023 diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index f1f3096c..9df94c05 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -2,6 +2,8 @@ plugins { kotlin("multiplatform") id("com.android.library") id("org.jetbrains.compose") + kotlin("plugin.serialization") + id("org.jetbrains.kotlin.plugin.atomicfu") } @OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class) @@ -29,6 +31,7 @@ kotlin { } sourceSets { + val coroutines = "1.7.3" val commonMain by getting { dependencies { implementation(compose.runtime) @@ -38,17 +41,22 @@ kotlin { implementation(compose.components.resources) implementation("co.touchlab:kermit:2.0.0-RC5") api(project(":webview")) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") + implementation("org.jetbrains.kotlinx:atomicfu:0.21.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") } } val androidMain by getting { dependencies { api("androidx.activity:activity-compose:1.7.2") api("androidx.appcompat:appcompat:1.6.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines") } } val desktopMain by getting { dependencies { implementation(compose.desktop.common) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:$coroutines") } } val commonTest by getting { diff --git a/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/HtmlWebViewSample.kt b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/HtmlWebViewSample.kt index 7c397309..5fa17318 100644 --- a/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/HtmlWebViewSample.kt +++ b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/HtmlWebViewSample.kt @@ -14,68 +14,37 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import co.touchlab.kermit.Logger +import com.kevinnzou.sample.eventbus.FlowEventBus +import com.kevinnzou.sample.eventbus.NavigationEvent +import com.kevinnzou.sample.jsbridge.GreetJsMessageHandler +import com.kevinnzou.sample.res.HtmlRes +import com.multiplatform.webview.jsbridge.WebViewJsBridge +import com.multiplatform.webview.jsbridge.rememberWebViewJsBridge import com.multiplatform.webview.util.KLogSeverity import com.multiplatform.webview.web.WebView +import com.multiplatform.webview.web.WebViewState import com.multiplatform.webview.web.rememberWebViewNavigator import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData +import kotlinx.coroutines.flow.filter /** * Created By Kevin Zou On 2023/9/8 */ @Composable internal fun BasicWebViewWithHTMLSample() { - val html = - """ - - - Compose WebView Multiplatform - - - - -

Compose WebView Multiplatform

-

Basic Html Test

- - - """.trimIndent() + val html = HtmlRes.html // val webViewState = rememberWebViewStateWithHTMLFile( // fileName = "index.html", // ) val webViewState = rememberWebViewStateWithHTMLData(html) - LaunchedEffect(Unit) { - webViewState.webSettings.apply { - zoomLevel = 1.0 - isJavaScriptEnabled = true - logSeverity = KLogSeverity.Debug - allowFileAccessFromFileURLs = true - allowUniversalAccessFromFileURLs = true - androidWebSettings.apply { - isAlgorithmicDarkeningAllowed = true - safeBrowsingEnabled = true - allowFileAccess = true - } - } - } val webViewNavigator = rememberWebViewNavigator() + val jsBridge = rememberWebViewJsBridge(webViewNavigator) var jsRes by mutableStateOf("Evaluate JavaScript") + LaunchedEffect(Unit) { + initWebView(webViewState) + initJsBridge(jsBridge) + } MaterialTheme { Box(Modifier.fillMaxSize()) { WebView( @@ -83,12 +52,19 @@ internal fun BasicWebViewWithHTMLSample() { modifier = Modifier.fillMaxSize(), captureBackPresses = false, navigator = webViewNavigator, + webViewJsBridge = jsBridge, ) Button( onClick = { webViewNavigator.evaluateJavaScript( """ document.getElementById("subtitle").innerText = "Hello from KMM!"; + window.kmpJsBridge.callNative("Greet",JSON.stringify({message: "Hello"}), + function (data) { + document.getElementById("subtitle").innerText = data; + console.log("Greet from Native: " + data); + } + ); callJS(); """.trimIndent(), ) { @@ -102,3 +78,32 @@ internal fun BasicWebViewWithHTMLSample() { } } } + +fun initWebView(webViewState: WebViewState) { + webViewState.webSettings.apply { + zoomLevel = 1.0 + isJavaScriptEnabled = true + logSeverity = KLogSeverity.Debug + allowFileAccessFromFileURLs = true + allowUniversalAccessFromFileURLs = true + androidWebSettings.apply { + isAlgorithmicDarkeningAllowed = true + safeBrowsingEnabled = true + allowFileAccess = true + } + } +} + +suspend fun initJsBridge(webViewJsBridge: WebViewJsBridge) { + webViewJsBridge.register(GreetJsMessageHandler()) + // EventBus.observe { +// Logger.d { +// "Received NavigationEvent" +// } +// } + FlowEventBus.events.filter { it is NavigationEvent }.collect { + Logger.d { + "Received NavigationEvent" + } + } +} diff --git a/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/eventbus/EventBus.kt b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/eventbus/EventBus.kt new file mode 100644 index 00000000..95e9f9e1 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/eventbus/EventBus.kt @@ -0,0 +1,60 @@ +package com.kevinnzou.sample.eventbus + +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.getAndUpdate +import kotlin.reflect.KClass + +/** + * Created By Kevin Zou On 2023/12/15 + */ + +typealias Observer = (Any) -> Unit + +object EventBus { + private val observers = atomic(mutableMapOf, List>()) + + fun observe( + clazz: KClass, + obs: (T) -> Unit, + ) { + if (!observers.value.containsKey(clazz)) { + observers.getAndUpdate { cur -> + cur.toMutableMap().also { upd -> + upd[clazz] = listOf(obs as Observer) + } + } + } else { + observers.getAndUpdate { cur -> + cur.toMutableMap().also { upd -> + upd[clazz] = upd[clazz]!! + listOf(obs as Observer) + } + } + } + } + + inline fun observe(noinline obs: (T) -> Unit) { + observe(T::class, obs) + } + + fun removeObserver( + clazz: KClass, + obs: (T) -> Unit, + ) { + observers.getAndUpdate { cur -> + cur.toMutableMap().also { upd -> + upd.remove(clazz) + } + } + } + + fun post( + clazz: KClass, + event: T, + ) { + observers.value[clazz]?.forEach { it.invoke(event) } + } + + inline fun post(event: T) { + post(T::class, event) + } +} diff --git a/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/eventbus/FlowEventBus.kt b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/eventbus/FlowEventBus.kt new file mode 100644 index 00000000..886ef967 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/eventbus/FlowEventBus.kt @@ -0,0 +1,16 @@ +package com.kevinnzou.sample.eventbus + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +/** + * Created By Kevin Zou On 2023/12/16 + */ +object FlowEventBus { + private val mEvents = MutableSharedFlow() + val events = mEvents.asSharedFlow() + + suspend fun publishEvent(event: IEvent) { + mEvents.emit(event) + } +} diff --git a/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/eventbus/IEvent.kt b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/eventbus/IEvent.kt new file mode 100644 index 00000000..b8d8a6b6 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/eventbus/IEvent.kt @@ -0,0 +1,6 @@ +package com.kevinnzou.sample.eventbus + +/** + * Created By Kevin Zou On 2023/12/16 + */ +interface IEvent diff --git a/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/eventbus/NavigationEvent.kt b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/eventbus/NavigationEvent.kt new file mode 100644 index 00000000..b13f8d64 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/eventbus/NavigationEvent.kt @@ -0,0 +1,6 @@ +package com.kevinnzou.sample.eventbus + +/** + * Created By Kevin Zou On 2023/12/15 + */ +class NavigationEvent : IEvent diff --git a/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/jsbridge/CustomWebViewJsBridge.kt b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/jsbridge/CustomWebViewJsBridge.kt new file mode 100644 index 00000000..2bbe7fb2 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/jsbridge/CustomWebViewJsBridge.kt @@ -0,0 +1,12 @@ +package com.kevinnzou.sample.jsbridge + +import com.multiplatform.webview.jsbridge.WebViewJsBridge + +/** + * Created By Kevin Zou On 2023/12/6 + */ +class CustomWebViewJsBridge : WebViewJsBridge() { + init { + register(GreetJsMessageHandler()) + } +} 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 new file mode 100644 index 00000000..29a9ae56 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/jsbridge/GreetJsMessageHandler.kt @@ -0,0 +1,38 @@ +package com.kevinnzou.sample.jsbridge + +import co.touchlab.kermit.Logger +import com.kevinnzou.sample.eventbus.FlowEventBus +import com.kevinnzou.sample.eventbus.NavigationEvent +import com.kevinnzou.sample.model.GreetModel +import com.multiplatform.webview.jsbridge.IJsMessageHandler +import com.multiplatform.webview.jsbridge.JsMessage +import com.multiplatform.webview.jsbridge.dataToJsonString +import com.multiplatform.webview.jsbridge.processParams +import com.multiplatform.webview.web.WebViewNavigator +import kotlinx.coroutines.launch + +/** + * Created By Kevin Zou On 2023/12/6 + */ +class GreetJsMessageHandler : IJsMessageHandler { + override fun methodName(): String { + return "Greet" + } + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit, + ) { + Logger.i { + "Greet Handler Get Message: $message" + } + val param = processParams(message) + val data = GreetModel("KMM Received ${param.message}") + callback(dataToJsonString(data)) +// EventBus.post(NavigationEvent()) + navigator?.coroutineScope?.launch { + FlowEventBus.publishEvent(NavigationEvent()) + } + } +} diff --git a/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/model/GreetModel.kt b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/model/GreetModel.kt new file mode 100644 index 00000000..affad370 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/model/GreetModel.kt @@ -0,0 +1,9 @@ +package com.kevinnzou.sample.model + +import kotlinx.serialization.Serializable + +/** + * Created By Kevin Zou On 2023/12/6 + */ +@Serializable +data class GreetModel(val message: String) diff --git a/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/res/HtmlRes.kt b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/res/HtmlRes.kt new file mode 100644 index 00000000..0d74043e --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/res/HtmlRes.kt @@ -0,0 +1,56 @@ +package com.kevinnzou.sample.res + +object HtmlRes { + val html = + """ + + + Compose WebView Multiplatform + + + + +

Compose WebView Multiplatform

+

Basic Html Test

+ + + + """.trimIndent() +} diff --git a/sample/shared/src/commonMain/resources/assets/index.html b/sample/shared/src/commonMain/resources/assets/index.html index 19273827..17bd845f 100644 --- a/sample/shared/src/commonMain/resources/assets/index.html +++ b/sample/shared/src/commonMain/resources/assets/index.html @@ -8,5 +8,6 @@

Compose WebView Multiplatform

Basic Html Test

+ \ No newline at end of file diff --git a/sample/shared/src/commonMain/resources/assets/script.js b/sample/shared/src/commonMain/resources/assets/script.js index eb6c9692..654feac3 100644 --- a/sample/shared/src/commonMain/resources/assets/script.js +++ b/sample/shared/src/commonMain/resources/assets/script.js @@ -1,3 +1,32 @@ function callJS() { return 'Response from JS'; +} + +function callNative() { + window.kmpJsBridge.callNative("Greet",JSON.stringify({message: "Hello"}), + function (data) { + document.getElementById("subtitle").innerText = data; + console.log("Greet from Native: " + data); + } + ); +} + +function callAndroid() { + window.androidJsBridge.call('1', 'callAndroid', '{"name":"callAndroid"}'); +} + +function callIOS() { + window.webkit.messageHandlers.iosJsBridge.postMessage("{\"id\":\"1\",\"methodName\":\"callIOS\",\"params\":\"{\\\"type\\\":\\\"1\\\"}\"}"); +} + +function callDesktop() { + window.cefQuery({ + request: "{\"id\":\"1\",\"methodName\":\"callIOS\",\"params\":\"{\\\"type\\\":\\\"1\\\"}\"}", + onSuccess: function(response) { + // 处理Java应用程序的响应 + }, + onFailure: function(errorCode, errorMessage) { + // 处理错误 + } + }); } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 852ec120..c8caf2c4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,6 +20,7 @@ pluginManagement { kotlin("jvm").version(kotlinVersion) kotlin("multiplatform").version(kotlinVersion) + kotlin("plugin.serialization").version(kotlinVersion) kotlin("android").version(kotlinVersion) id("com.android.application").version(agpVersion) diff --git a/webview/build.gradle.kts b/webview/build.gradle.kts index 85cdf2e1..0aa8371b 100644 --- a/webview/build.gradle.kts +++ b/webview/build.gradle.kts @@ -6,6 +6,7 @@ plugins { id("org.jetbrains.compose") id("org.jetbrains.dokka") id("com.vanniktech.maven.publish") + kotlin("plugin.serialization") } kotlin { @@ -43,6 +44,7 @@ kotlin { implementation(compose.components.resources) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") implementation("co.touchlab:kermit:2.0.0-RC5") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") } } val androidMain by getting { diff --git a/webview/src/androidMain/kotlin/com/multiplatform/webview/util/getPlatform.kt b/webview/src/androidMain/kotlin/com/multiplatform/webview/util/getPlatform.kt new file mode 100644 index 00000000..1d2f61ff --- /dev/null +++ b/webview/src/androidMain/kotlin/com/multiplatform/webview/util/getPlatform.kt @@ -0,0 +1,5 @@ +package com.multiplatform.webview.util + +internal actual fun getPlatform(): Platform { + return Platform.Android +} diff --git a/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AccompanistWebView.kt b/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AccompanistWebView.kt index 736fae91..9851e06c 100644 --- a/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AccompanistWebView.kt +++ b/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AccompanistWebView.kt @@ -15,8 +15,10 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView +import com.multiplatform.webview.jsbridge.WebViewJsBridge import com.multiplatform.webview.util.KLogger /** @@ -54,6 +56,7 @@ fun AccompanistWebView( modifier: Modifier = Modifier, captureBackPresses: Boolean = true, navigator: WebViewNavigator = rememberWebViewNavigator(), + webViewJsBridge: WebViewJsBridge? = null, onCreated: (WebView) -> Unit = {}, onDispose: (WebView) -> Unit = {}, client: AccompanistWebViewClient = remember { AccompanistWebViewClient() }, @@ -89,6 +92,7 @@ fun AccompanistWebView( Modifier, captureBackPresses, navigator, + webViewJsBridge, onCreated, onDispose, client, @@ -130,6 +134,7 @@ fun AccompanistWebView( modifier: Modifier = Modifier, captureBackPresses: Boolean = true, navigator: WebViewNavigator = rememberWebViewNavigator(), + webViewJsBridge: WebViewJsBridge? = null, onCreated: (WebView) -> Unit = {}, onDispose: (WebView) -> Unit = {}, client: AccompanistWebViewClient = remember { AccompanistWebViewClient() }, @@ -137,6 +142,7 @@ fun AccompanistWebView( factory: ((Context) -> WebView)? = null, ) { val webView = state.webView + val scope = rememberCoroutineScope() BackHandler(captureBackPresses && navigator.canGoBack) { webView?.goBack() @@ -189,7 +195,11 @@ fun AccompanistWebView( domStorageEnabled = it.domStorageEnabled } } - }.also { state.webView = AndroidWebView(it) } + }.also { + val androidWebView = AndroidWebView(it, scope, webViewJsBridge) + state.webView = androidWebView + webViewJsBridge?.webView = androidWebView + } }, modifier = modifier, onRelease = { @@ -242,6 +252,7 @@ open class AccompanistWebViewClient : WebViewClient() { "onPageFinished: $url" } state.loadingState = LoadingState.Finished + state.lastLoadedUrl = url } override fun doUpdateVisitedHistory( @@ -286,6 +297,7 @@ open class AccompanistWebViewClient : WebViewClient() { open class AccompanistWebChromeClient : WebChromeClient() { open lateinit var state: WebViewState internal set + private var lastLoadedUrl = "" override fun onReceivedTitle( view: WebView, @@ -293,9 +305,10 @@ open class AccompanistWebChromeClient : WebChromeClient() { ) { super.onReceivedTitle(view, title) KLogger.d { - "onReceivedTitle: $title" + "onReceivedTitle: $title url:${view.url}" } state.pageTitle = title + state.lastLoadedUrl = view.url ?: "" } override fun onReceivedIcon( @@ -311,7 +324,13 @@ open class AccompanistWebChromeClient : WebChromeClient() { newProgress: Int, ) { super.onProgressChanged(view, newProgress) - if (state.loadingState is LoadingState.Finished) return - state.loadingState = LoadingState.Loading(newProgress / 100.0f) + if (state.loadingState is LoadingState.Finished && view.url == lastLoadedUrl) return + state.loadingState = + if (newProgress == 100) { + LoadingState.Finished + } else { + LoadingState.Loading(newProgress / 100.0f) + } + lastLoadedUrl = view.url ?: "" } } diff --git a/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AndroidWebView.kt b/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AndroidWebView.kt index c21aa771..1e1e5b24 100644 --- a/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AndroidWebView.kt +++ b/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AndroidWebView.kt @@ -1,7 +1,12 @@ package com.multiplatform.webview.web +import android.webkit.JavascriptInterface import android.webkit.WebView +import com.multiplatform.webview.jsbridge.JsMessage +import com.multiplatform.webview.jsbridge.WebViewJsBridge import com.multiplatform.webview.util.KLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.serialization.json.Json /** * Created By Kevin Zou On 2023/9/5 @@ -10,7 +15,15 @@ import com.multiplatform.webview.util.KLogger /** * Android implementation of [IWebView] */ -class AndroidWebView(private val webView: WebView) : IWebView { +class AndroidWebView( + private val webView: WebView, + override var scope: CoroutineScope, + override var webViewJsBridge: WebViewJsBridge?, +) : IWebView { + init { + initWebView() + } + override fun canGoBack() = webView.canGoBack() override fun canGoForward() = webView.canGoForward() @@ -68,11 +81,47 @@ class AndroidWebView(private val webView: WebView) : IWebView { callback: ((String) -> Unit)?, ) { val androidScript = "javascript:$script" - KLogger.i { + KLogger.d { "evaluateJavaScript: $androidScript" } webView.post { webView.evaluateJavascript(androidScript, callback) } } + + override fun injectInitJS() { + if (webViewJsBridge == null) return + super.injectInitJS() + val callAndroid = + """ + window.kmpJsBridge.postMessage = function (message) { + window.androidJsBridge.call(message) + }; + """.trimIndent() + evaluateJavaScript(callAndroid) + } + + override fun injectJsBridge(webViewJsBridge: WebViewJsBridge) { + webView.addJavascriptInterface(this, "androidJsBridge") + } + + @JavascriptInterface + fun call(request: String) { + KLogger.d { "call from JS: $request" } + val message = Json.decodeFromString(request) + KLogger.d { + "call from JS: $message" + } + webViewJsBridge?.dispatch(message) + } + + @JavascriptInterface + fun callAndroid( + id: Int, + method: String, + params: String, + ) { + KLogger.d { "callAndroid call from JS: $id, $method, $params" } + webViewJsBridge?.dispatch(JsMessage(id, method, params)) + } } diff --git a/webview/src/androidMain/kotlin/com/multiplatform/webview/web/WebView.android.kt b/webview/src/androidMain/kotlin/com/multiplatform/webview/web/WebView.android.kt index 736d5d19..00ffbde4 100644 --- a/webview/src/androidMain/kotlin/com/multiplatform/webview/web/WebView.android.kt +++ b/webview/src/androidMain/kotlin/com/multiplatform/webview/web/WebView.android.kt @@ -2,6 +2,7 @@ package com.multiplatform.webview.web import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.multiplatform.webview.jsbridge.WebViewJsBridge /** * Android WebView implementation. @@ -12,6 +13,7 @@ actual fun ActualWebView( modifier: Modifier, captureBackPresses: Boolean, navigator: WebViewNavigator, + webViewJsBridge: WebViewJsBridge?, onCreated: () -> Unit, onDispose: () -> Unit, ) { @@ -20,6 +22,7 @@ actual fun ActualWebView( modifier, captureBackPresses, navigator, + webViewJsBridge, onCreated = { _ -> onCreated() }, onDispose = { _ -> onDispose() }, ) diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/jsbridge/IJsMessageHandler.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/jsbridge/IJsMessageHandler.kt new file mode 100644 index 00000000..61134b89 --- /dev/null +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/jsbridge/IJsMessageHandler.kt @@ -0,0 +1,28 @@ +package com.multiplatform.webview.jsbridge + +import com.multiplatform.webview.web.WebViewNavigator +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Created By Kevin Zou On 2023/10/31 + */ +interface IJsMessageHandler { + fun methodName(): String + + fun canHandle(methodName: String) = methodName() == methodName + + fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit, + ) +} + +inline fun IJsMessageHandler.processParams(message: JsMessage): T { + return Json.decodeFromString(message.params) +} + +inline fun IJsMessageHandler.dataToJsonString(res: T): String { + return Json.encodeToString(res) +} diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/jsbridge/JsMessage.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/jsbridge/JsMessage.kt new file mode 100644 index 00000000..93679f11 --- /dev/null +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/jsbridge/JsMessage.kt @@ -0,0 +1,13 @@ +package com.multiplatform.webview.jsbridge + +import kotlinx.serialization.Serializable + +/** + * Created By Kevin Zou On 2023/10/31 + */ +@Serializable +data class JsMessage( + val callbackId: Int, + val methodName: String, + val params: String, +) diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/jsbridge/JsMessageDispatcher.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/jsbridge/JsMessageDispatcher.kt new file mode 100644 index 00000000..dceff659 --- /dev/null +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/jsbridge/JsMessageDispatcher.kt @@ -0,0 +1,34 @@ +package com.multiplatform.webview.jsbridge + +import androidx.compose.runtime.Immutable +import com.multiplatform.webview.web.WebViewNavigator + +/** + * Created By Kevin Zou On 2023/10/31 + */ +@Immutable +internal class JsMessageDispatcher { + private val jsHandlerMap = mutableMapOf() + + fun registerJSHandler(handler: IJsMessageHandler) { + jsHandlerMap[handler.methodName()] = handler + } + + fun dispatch( + message: JsMessage, + navigator: WebViewNavigator? = null, + callback: (String) -> Unit, + ) { + jsHandlerMap[message.methodName]?.handle(message, navigator, callback) + } + + fun canHandle(id: String) = jsHandlerMap.containsKey(id) + + fun unregisterJSHandler(handler: IJsMessageHandler) { + jsHandlerMap.remove(handler.methodName()) + } + + fun clear() { + jsHandlerMap.clear() + } +} diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/jsbridge/WebViewJsBridge.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/jsbridge/WebViewJsBridge.kt new file mode 100644 index 00000000..a1d805c5 --- /dev/null +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/jsbridge/WebViewJsBridge.kt @@ -0,0 +1,44 @@ +package com.multiplatform.webview.jsbridge + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import com.multiplatform.webview.web.IWebView +import com.multiplatform.webview.web.WebViewNavigator + +/** + * Created By Kevin Zou On 2023/10/31 + */ +@Immutable +open class WebViewJsBridge(val navigator: WebViewNavigator? = null) { + private val jsMessageDispatcher = JsMessageDispatcher() + var webView: IWebView? = null + + fun register(handler: IJsMessageHandler) { + jsMessageDispatcher.registerJSHandler(handler) + } + + fun unregister(handler: IJsMessageHandler) { + jsMessageDispatcher.unregisterJSHandler(handler) + } + + fun clear() { + jsMessageDispatcher.clear() + } + + fun dispatch(message: JsMessage) { + jsMessageDispatcher.dispatch(message, navigator) { + onCallback(it, message.callbackId) + } + } + + private fun onCallback( + data: String, + callbackId: Int, + ) { + webView?.evaluateJavaScript("window.kmpJsBridge.onCallback($callbackId, '$data')") + } +} + +@Composable +fun rememberWebViewJsBridge(navigator: WebViewNavigator? = null): WebViewJsBridge = remember { WebViewJsBridge(navigator) } diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/util/KLogger.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/util/KLogger.kt index ef8c8b17..b0b1462b 100644 --- a/webview/src/commonMain/kotlin/com/multiplatform/webview/util/KLogger.kt +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/util/KLogger.kt @@ -20,6 +20,11 @@ internal object KLogger : Logger( fun setMinSeverity(severity: KLogSeverity) { mutableConfig.minSeverity = severity.toKermitSeverity() } + + // For iOS, it will not print out the log if the severity is upper than Debug in AS. + fun info(msg: () -> String) { + d { msg() } + } } enum class KLogSeverity { diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/util/Platform.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/util/Platform.kt new file mode 100644 index 00000000..d04b4315 --- /dev/null +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/util/Platform.kt @@ -0,0 +1,20 @@ +package com.multiplatform.webview.util + +/** + * Created By Kevin Zou On 2023/12/5 + */ +internal sealed class Platform { + data object Android : Platform() + + data object Desktop : Platform() + + data object IOS : Platform() + + fun isAndroid() = this is Android + + fun isDesktop() = this is Desktop + + fun isIOS() = this is IOS +} + +internal expect fun getPlatform(): Platform diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/web/IWebView.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/web/IWebView.kt index 02fc4e3e..42a93b40 100644 --- a/webview/src/commonMain/kotlin/com/multiplatform/webview/web/IWebView.kt +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/web/IWebView.kt @@ -1,5 +1,8 @@ package com.multiplatform.webview.web +import com.multiplatform.webview.jsbridge.WebViewJsBridge +import com.multiplatform.webview.util.KLogger +import kotlinx.coroutines.CoroutineScope import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.resource @@ -11,6 +14,10 @@ import org.jetbrains.compose.resources.resource * Interface for WebView */ interface IWebView { + var scope: CoroutineScope + + var webViewJsBridge: WebViewJsBridge? + /** * True when the web view is able to navigate backwards, false otherwise. */ @@ -145,4 +152,47 @@ interface IWebView { script: String, callback: ((String) -> Unit)? = null, ) + + fun injectInitJS() { + if (webViewJsBridge == null) return + KLogger.d { + "IWebView injectInitJS" + } + val initJs = + """ + window.kmpJsBridge = { + callbacks: {}, + callbackId: 0, + callNative: function (methodName, params, callback) { + var message = { + methodName: methodName, + params: params, + callbackId: callback ? window.kmpJsBridge.callbackId++ : -1 + }; + if (callback) { + window.kmpJsBridge.callbacks[message.callbackId] = callback; + console.log('add callback: ' + message.callbackId + ', ' + callback); + } + window.kmpJsBridge.postMessage(JSON.stringify(message)); + }, + onCallback: function (callbackId, data) { + var callback = window.kmpJsBridge.callbacks[callbackId]; + console.log('onCallback: ' + callbackId + ', ' + data + ', ' + callback); + if (callback) { + callback(data); + delete window.kmpJsBridge.callbacks[callbackId]; + } + } + }; + """.trimIndent() + evaluateJavaScript(initJs) + } + + fun injectJsBridge(webViewJsBridge: WebViewJsBridge) + + fun initWebView() { + webViewJsBridge?.apply { + injectJsBridge(this) + } + } } diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/web/WebView.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/web/WebView.kt index 0b713757..abc90461 100644 --- a/webview/src/commonMain/kotlin/com/multiplatform/webview/web/WebView.kt +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/web/WebView.kt @@ -1,9 +1,13 @@ package com.multiplatform.webview.web import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier +import com.multiplatform.webview.jsbridge.WebViewJsBridge +import com.multiplatform.webview.util.KLogger +import com.multiplatform.webview.util.getPlatform import org.jetbrains.compose.resources.ExperimentalResourceApi /** @@ -31,6 +35,7 @@ fun WebView( modifier: Modifier = Modifier, captureBackPresses: Boolean = true, navigator: WebViewNavigator = rememberWebViewNavigator(), + webViewJsBridge: WebViewJsBridge? = null, onCreated: () -> Unit = {}, onDispose: () -> Unit = {}, ) { @@ -39,6 +44,9 @@ fun WebView( webView?.let { wv -> LaunchedEffect(wv, navigator) { with(navigator) { + KLogger.d { + "wv.handleNavigationEvents()" + } wv.handleNavigationEvents() } } @@ -80,14 +88,33 @@ fun WebView( } } + // TODO WorkAround for Desktop not working issue. + if (webViewJsBridge != null && !getPlatform().isDesktop()) { + LaunchedEffect(state.loadingState, state.lastLoadedUrl) { + if (state.loadingState is LoadingState.Finished) { + webView?.injectInitJS() + } + } + } + ActualWebView( state = state, modifier = modifier, captureBackPresses = captureBackPresses, navigator = navigator, + webViewJsBridge = webViewJsBridge, onCreated = onCreated, onDispose = onDispose, ) + + DisposableEffect(Unit) { + onDispose { + KLogger.d { + "WebView DisposableEffect" + } + webViewJsBridge?.clear() + } + } } /** @@ -99,6 +126,7 @@ expect fun ActualWebView( modifier: Modifier = Modifier, captureBackPresses: Boolean = true, navigator: WebViewNavigator = rememberWebViewNavigator(), + webViewJsBridge: WebViewJsBridge? = null, onCreated: () -> Unit = {}, onDispose: () -> Unit = {}, ) diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/web/WebViewNavigator.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/web/WebViewNavigator.kt index ec38241c..44742bdc 100644 --- a/webview/src/commonMain/kotlin/com/multiplatform/webview/web/WebViewNavigator.kt +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/web/WebViewNavigator.kt @@ -24,7 +24,7 @@ import kotlinx.coroutines.withContext * @see [rememberWebViewNavigator] */ @Stable -class WebViewNavigator(private val coroutineScope: CoroutineScope) { +class WebViewNavigator(val coroutineScope: CoroutineScope) { /** * Sealed class for constraining possible navigation events. */ diff --git a/webview/src/desktopMain/kotlin/com/multiplatform/webview/cookie/DesktopCookieManager.kt b/webview/src/desktopMain/kotlin/com/multiplatform/webview/cookie/DesktopCookieManager.kt index 534a7d0d..ff481db0 100644 --- a/webview/src/desktopMain/kotlin/com/multiplatform/webview/cookie/DesktopCookieManager.kt +++ b/webview/src/desktopMain/kotlin/com/multiplatform/webview/cookie/DesktopCookieManager.kt @@ -28,11 +28,11 @@ object DesktopCookieManager : CookieManager { Date(cookie.expiresDate ?: System.currentTimeMillis()), ) val addedCookie = KCEFCookieManager.instance.setCookie(url, cefCookie) - KLogger.i(tag = "DesktopCookieManager") { "Added Cookie: $addedCookie" } + KLogger.d(tag = "DesktopCookieManager") { "Added Cookie: $addedCookie" } } override suspend fun getCookies(url: String): List { - KLogger.i(tag = "DesktopCookieManager") { "DesktopCookieManager getCookies: $url" } + KLogger.d(tag = "DesktopCookieManager") { "DesktopCookieManager getCookies: $url" } return KCEFCookieManager.instance.getCookiesWhile(url, true).map { Cookie( diff --git a/webview/src/desktopMain/kotlin/com/multiplatform/webview/util/getPlatform.kt b/webview/src/desktopMain/kotlin/com/multiplatform/webview/util/getPlatform.kt new file mode 100644 index 00000000..cd92a262 --- /dev/null +++ b/webview/src/desktopMain/kotlin/com/multiplatform/webview/util/getPlatform.kt @@ -0,0 +1,5 @@ +package com.multiplatform.webview.util + +internal actual fun getPlatform(): Platform { + return Platform.Desktop +} diff --git a/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/DesktopWebView.kt b/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/DesktopWebView.kt index 374b238e..c39fc98a 100644 --- a/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/DesktopWebView.kt +++ b/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/DesktopWebView.kt @@ -1,7 +1,16 @@ package com.multiplatform.webview.web +import com.multiplatform.webview.jsbridge.JsMessage +import com.multiplatform.webview.jsbridge.WebViewJsBridge import com.multiplatform.webview.util.KLogger import dev.datlag.kcef.KCEFBrowser +import kotlinx.coroutines.CoroutineScope +import kotlinx.serialization.json.Json +import org.cef.browser.CefBrowser +import org.cef.browser.CefFrame +import org.cef.browser.CefMessageRouter +import org.cef.callback.CefQueryCallback +import org.cef.handler.CefMessageRouterHandlerAdapter import org.cef.network.CefPostData import org.cef.network.CefPostDataElement import org.cef.network.CefRequest @@ -9,7 +18,15 @@ import org.cef.network.CefRequest /** * Created By Kevin Zou On 2023/9/12 */ -class DesktopWebView(private val webView: KCEFBrowser) : IWebView { +class DesktopWebView( + private val webView: KCEFBrowser, + override var scope: CoroutineScope, + override var webViewJsBridge: WebViewJsBridge?, +) : IWebView { + init { + initWebView() + } + override fun canGoBack() = webView.canGoBack() override fun canGoForward() = webView.canGoForward() @@ -89,4 +106,56 @@ class DesktopWebView(private val webView: KCEFBrowser) : IWebView { } } } + + override fun injectInitJS() { + if (webViewJsBridge == null) return + super.injectInitJS() + KLogger.d { + "DesktopWebView injectInitJS" + } + val callDesktop = + """ + window.kmpJsBridge.postMessage = function (message) { + window.cefQuery({request:message}); + }; + """.trimIndent() + evaluateJavaScript(callDesktop) + } + + override fun injectJsBridge(webViewJsBridge: WebViewJsBridge) { + KLogger.d { + "DesktopWebView injectJsBridge" + } + val router = CefMessageRouter.create() + val handler = + object : CefMessageRouterHandlerAdapter() { + override fun onQuery( + browser: CefBrowser?, + frame: CefFrame?, + queryId: Long, + request: String?, + persistent: Boolean, + callback: CefQueryCallback?, + ): Boolean { + if (request == null) { + return super.onQuery( + browser, + frame, + queryId, + request, + persistent, + callback, + ) + } + val message = Json.decodeFromString(request) + KLogger.d { + "onQuery Message: $message" + } + webViewJsBridge.dispatch(message) + return true + } + } + router.addHandler(handler, false) + webView.client.addMessageRouter(router) + } } diff --git a/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebEngineExt.kt b/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebEngineExt.kt index 834b3b96..4b0e2e0a 100644 --- a/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebEngineExt.kt +++ b/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebEngineExt.kt @@ -25,6 +25,7 @@ internal fun CefBrowser.addDisplayHandler(state: WebViewState) { frame: CefFrame?, url: String?, ) { + KLogger.d { "onAddressChange: $url" } state.lastLoadedUrl = getCurrentUrl() } @@ -41,7 +42,7 @@ internal fun CefBrowser.addDisplayHandler(state: WebViewState) { } else { -ln(abs(givenZoomLevel)) / ln(1.2) } - KLogger.d { "titleProperty: $title $realZoomLevel" } + KLogger.d { "titleProperty: $title" } zoomLevel = realZoomLevel state.pageTitle = title } @@ -85,16 +86,25 @@ internal fun CefBrowser.addLoadListener( ) { this.client.addLoadHandler( object : CefLoadHandler { + private var lastLoadedUrl = "" + override fun onLoadingStateChange( browser: CefBrowser?, isLoading: Boolean, canGoBack: Boolean, canGoForward: Boolean, ) { + KLogger.d { + "onLoadingStateChange: $url, $isLoading $canGoBack $canGoForward" + } if (isLoading) { state.loadingState = LoadingState.Initializing } else { state.loadingState = LoadingState.Finished + if (url != null && url != lastLoadedUrl) { + state.webView?.injectInitJS() + lastLoadedUrl = url + } } navigator.canGoBack = canGoBack navigator.canGoForward = canGoForward @@ -106,7 +116,9 @@ internal fun CefBrowser.addLoadListener( transitionType: CefRequest.TransitionType?, ) { KLogger.d { "Load Start ${browser?.url}" } + lastLoadedUrl = "" // clean last loaded url for reload to work state.loadingState = LoadingState.Loading(0F) + state.errorsForCurrentRequest.clear() } override fun onLoadEnd( @@ -129,8 +141,9 @@ internal fun CefBrowser.addLoadListener( failedUrl: String?, ) { state.loadingState = LoadingState.Finished - KLogger.e { - "Failed to load url: ${failedUrl}\n$errorText" + // TODO Error + KLogger.i { + "Failed to load url: $errorCode ${failedUrl}\n$errorText" } state.errorsForCurrentRequest.add( WebViewError( diff --git a/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebView.desktop.kt b/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebView.desktop.kt index 9157f48f..366d9db9 100644 --- a/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebView.desktop.kt +++ b/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebView.desktop.kt @@ -6,9 +6,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.awt.SwingPanel +import com.multiplatform.webview.jsbridge.WebViewJsBridge import dev.datlag.kcef.KCEF import dev.datlag.kcef.KCEFBrowser import org.cef.browser.CefRendering @@ -24,6 +26,7 @@ actual fun ActualWebView( modifier: Modifier, captureBackPresses: Boolean, navigator: WebViewNavigator, + webViewJsBridge: WebViewJsBridge?, onCreated: () -> Unit, onDispose: () -> Unit, ) { @@ -31,6 +34,7 @@ actual fun ActualWebView( state, modifier, navigator, + webViewJsBridge, onCreated = onCreated, onDispose = onDispose, ) @@ -45,6 +49,7 @@ fun DesktopWebView( state: WebViewState, modifier: Modifier, navigator: WebViewNavigator, + webViewJsBridge: WebViewJsBridge?, onCreated: () -> Unit, onDispose: () -> Unit, ) { @@ -61,6 +66,7 @@ fun DesktopWebView( } } } + val scope = rememberCoroutineScope() val fileContent by produceState("", state.content) { value = if (state.content is WebContent.File) { @@ -118,7 +124,9 @@ fun DesktopWebView( } } }?.also { - state.webView = DesktopWebView(it) + val desktopWebView = DesktopWebView(it, scope, webViewJsBridge) + state.webView = desktopWebView + webViewJsBridge?.webView = desktopWebView } browser?.let { diff --git a/webview/src/iosMain/kotlin/com/multiplatform/webview/jsbridge/WKJsMessageHandler.kt b/webview/src/iosMain/kotlin/com/multiplatform/webview/jsbridge/WKJsMessageHandler.kt new file mode 100644 index 00000000..986593b3 --- /dev/null +++ b/webview/src/iosMain/kotlin/com/multiplatform/webview/jsbridge/WKJsMessageHandler.kt @@ -0,0 +1,31 @@ +package com.multiplatform.webview.jsbridge + +import com.multiplatform.webview.util.KLogger +import kotlinx.serialization.json.Json +import platform.WebKit.WKScriptMessage +import platform.WebKit.WKScriptMessageHandlerProtocol +import platform.WebKit.WKUserContentController +import platform.darwin.NSObject + +/** + * Created By Kevin Zou On 2023/11/1 + */ +class WKJsMessageHandler(private val webViewJsBridge: WebViewJsBridge) : + WKScriptMessageHandlerProtocol, + NSObject() { + override fun userContentController( + userContentController: WKUserContentController, + didReceiveScriptMessage: WKScriptMessage, + ) { + val body = didReceiveScriptMessage.body + val method = didReceiveScriptMessage.name + KLogger.info { "didReceiveScriptMessage: $body, $method" } + (body as String).apply { + val message = Json.decodeFromString(body) + KLogger.info { + "WKJsMessageHandler: $message" + } + webViewJsBridge.dispatch(message) + } + } +} diff --git a/webview/src/iosMain/kotlin/com/multiplatform/webview/util/getPlatform.kt b/webview/src/iosMain/kotlin/com/multiplatform/webview/util/getPlatform.kt new file mode 100644 index 00000000..5255bfba --- /dev/null +++ b/webview/src/iosMain/kotlin/com/multiplatform/webview/util/getPlatform.kt @@ -0,0 +1,5 @@ +package com.multiplatform.webview.util + +internal actual fun getPlatform(): Platform { + return Platform.IOS +} diff --git a/webview/src/iosMain/kotlin/com/multiplatform/webview/web/IOSWebView.kt b/webview/src/iosMain/kotlin/com/multiplatform/webview/web/IOSWebView.kt index 8b38fa08..521e2c9b 100644 --- a/webview/src/iosMain/kotlin/com/multiplatform/webview/web/IOSWebView.kt +++ b/webview/src/iosMain/kotlin/com/multiplatform/webview/web/IOSWebView.kt @@ -1,10 +1,13 @@ package com.multiplatform.webview.web +import com.multiplatform.webview.jsbridge.WKJsMessageHandler +import com.multiplatform.webview.jsbridge.WebViewJsBridge import com.multiplatform.webview.util.KLogger import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.allocArrayOf import kotlinx.cinterop.memScoped +import kotlinx.coroutines.CoroutineScope import platform.Foundation.HTTPBody import platform.Foundation.HTTPMethod import platform.Foundation.NSBundle @@ -24,7 +27,15 @@ import platform.darwin.NSObjectMeta /** * iOS implementation of [IWebView] */ -class IOSWebView(private val wkWebView: WKWebView) : IWebView { +class IOSWebView( + private val wkWebView: WKWebView, + override var scope: CoroutineScope, + override var webViewJsBridge: WebViewJsBridge?, +) : IWebView { + init { + initWebView() + } + override fun canGoBack() = wkWebView.canGoBack override fun canGoForward() = wkWebView.canGoForward @@ -115,16 +126,40 @@ class IOSWebView(private val wkWebView: WKWebView) : IWebView { callback: ((String) -> Unit)?, ) { wkWebView.evaluateJavaScript(script) { result, error -> + if (callback == null) return@evaluateJavaScript if (error != null) { KLogger.e { "evaluateJavaScript error: $error" } - callback?.invoke(error.localizedDescription()) + callback.invoke(error.localizedDescription()) } else { - KLogger.i { "evaluateJavaScript result: $result" } - callback?.invoke(result?.toString() ?: "") + KLogger.info { "evaluateJavaScript result: $result" } + callback.invoke(result?.toString() ?: "") } } } + override fun injectInitJS() { + if (webViewJsBridge == null) return + KLogger.info { + "iOS WebView injectInitJS" + } + super.injectInitJS() + val callIOS = + """ + window.kmpJsBridge.postMessage = function (message) { + window.webkit.messageHandlers.iosJsBridge.postMessage(message); + }; + """.trimIndent() + evaluateJavaScript(callIOS) + } + + override fun injectJsBridge(webViewJsBridge: WebViewJsBridge) { + KLogger.info { "injectBridge" } + val jsMessageHandler = WKJsMessageHandler(webViewJsBridge) + wkWebView.configuration.userContentController.apply { + addScriptMessageHandler(jsMessageHandler, "iosJsBridge") + } + } + private class BundleMarker : NSObject() { companion object : NSObjectMeta() } diff --git a/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKNavigationDelegate.kt b/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKNavigationDelegate.kt index 65232348..9a752226 100644 --- a/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKNavigationDelegate.kt +++ b/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKNavigationDelegate.kt @@ -29,7 +29,7 @@ class WKNavigationDelegate( state.loadingState = LoadingState.Loading(0f) state.lastLoadedUrl = webView.URL.toString() state.errorsForCurrentRequest.clear() - KLogger.d { + KLogger.info { "didStartProvisionalNavigation" } } @@ -44,7 +44,7 @@ class WKNavigationDelegate( @Suppress("ktlint:standard:max-line-length") val script = "var meta = document.createElement('meta');meta.setAttribute('name', 'viewport');meta.setAttribute('content', 'width=device-width, initial-scale=${state.webSettings.zoomLevel}, maximum-scale=10.0, minimum-scale=0.1,user-scalable=yes');document.getElementsByTagName('head')[0].appendChild(meta);" webView.evaluateJavaScript(script) { _, _ -> } - KLogger.d { "didCommitNavigation" } + KLogger.info { "didCommitNavigation" } } /** @@ -59,7 +59,7 @@ class WKNavigationDelegate( state.loadingState = LoadingState.Finished navigator.canGoBack = webView.canGoBack navigator.canGoForward = webView.canGoForward - KLogger.d { "didFinishNavigation" } + KLogger.info { "didFinishNavigation" } } /** diff --git a/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKWebViewExt.kt b/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKWebViewExt.kt index e3c340fe..5ac96ab9 100644 --- a/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKWebViewExt.kt +++ b/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKWebViewExt.kt @@ -9,6 +9,14 @@ import platform.darwin.NSObject /** * Created By Kevin Zou On 2023/9/13 */ +val observedProgressList = + listOf( + "estimatedProgress", + "title", + "URL", + "canGoBack", + "canGoForward", + ) /** * Adds observers for the given properties @@ -39,3 +47,21 @@ fun WKWebView.removeObservers( this.removeObserver(observer, forKeyPath = it) } } + +@OptIn(ExperimentalForeignApi::class) +fun WKWebView.addProgressObservers(observer: NSObject) { + this.addObservers( + observer = observer, + properties = observedProgressList, + ) +} + +/** + * Removes observers for the given properties + */ +fun WKWebView.removeProgressObservers(observer: NSObject) { + this.removeObservers( + observer = observer, + properties = observedProgressList, + ) +} diff --git a/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKWebViewObserver.kt b/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKWebViewObserver.kt index 0616f5bc..ece27121 100644 --- a/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKWebViewObserver.kt +++ b/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKWebViewObserver.kt @@ -36,13 +36,13 @@ class WKWebViewObserver(private val state: WebViewState, private val navigator: } } else if (keyPath == "title") { val title = change?.get("new") as? String - KLogger.d { "Observe title Changed $title" } + KLogger.info { "Observe title Changed $title" } if (title != null) { state.pageTitle = title } } else if (keyPath == "URL") { val url = change?.get("new") as? NSURL - KLogger.d { "Observe URL Changed ${url?.absoluteString}" } + KLogger.info { "Observe URL Changed ${url?.absoluteString}" } if (url != null) { state.lastLoadedUrl = url.absoluteString } diff --git a/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WebView.ios.kt b/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WebView.ios.kt index 1099f582..1bc88840 100644 --- a/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WebView.ios.kt +++ b/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WebView.ios.kt @@ -2,8 +2,10 @@ package com.multiplatform.webview.web import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.interop.UIKitView +import com.multiplatform.webview.jsbridge.WebViewJsBridge import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.readValue import platform.CoreGraphics.CGRectZero @@ -21,6 +23,7 @@ actual fun ActualWebView( modifier: Modifier, captureBackPresses: Boolean, navigator: WebViewNavigator, + webViewJsBridge: WebViewJsBridge?, onCreated: () -> Unit, onDispose: () -> Unit, ) { @@ -29,6 +32,7 @@ actual fun ActualWebView( modifier = modifier, captureBackPresses = captureBackPresses, navigator = navigator, + webViewJsBridge = webViewJsBridge, onCreated = onCreated, onDispose = onDispose, ) @@ -44,6 +48,7 @@ fun IOSWebView( modifier: Modifier, captureBackPresses: Boolean, navigator: WebViewNavigator, + webViewJsBridge: WebViewJsBridge?, onCreated: () -> Unit, onDispose: () -> Unit, ) { @@ -55,6 +60,7 @@ fun IOSWebView( ) } val navigationDelegate = remember { WKNavigationDelegate(state, navigator) } + val scope = rememberCoroutineScope() UIKitView( factory = { @@ -75,34 +81,22 @@ fun IOSWebView( userInteractionEnabled = captureBackPresses allowsBackForwardNavigationGestures = captureBackPresses customUserAgent = state.webSettings.customUserAgentString - this.addObservers( + this.addProgressObservers( observer = observer, - properties = - listOf( - "estimatedProgress", - "title", - "URL", - "canGoBack", - "canGoForward", - ), ) this.navigationDelegate = navigationDelegate onCreated() - }.also { state.webView = IOSWebView(it) } + }.also { + val iosWebView = IOSWebView(it, scope, webViewJsBridge) + state.webView = iosWebView + webViewJsBridge?.webView = iosWebView + } }, modifier = modifier, onRelease = { state.webView = null - it.removeObservers( + it.removeProgressObservers( observer = observer, - properties = - listOf( - "estimatedProgress", - "title", - "URL", - "canGoBack", - "canGoForward", - ), ) it.navigationDelegate = null onDispose()