From 0b021e6c42024d15a9311fd70861b79f18339cf2 Mon Sep 17 00:00:00 2001 From: Constantin Piber <59023762+cpiber@users.noreply.github.com> Date: Fri, 20 Jun 2025 18:21:25 +0200 Subject: [PATCH] Increase WebView compatibility (#1451) * LoadData: Use regular load but intercept request The method we used before, `createBrowserWithHtml`, is implemented by KCEF. This method creates a `file://` url and adds handlers for that. Instead, use regular `createBrowser` and intercept the request later on. This has the effect of creating the page with the correct origin, while still setting the requested HTML instead of live data. This is important for scripts due to CORS. Also fixes a mistake in the ResourceRequestHandler, where (a) the status was not set, resulting in ABORT, (b) the return value of `readResponse` was correct (`false` too early) and (c) the callback was unnecessarily called on the MainLoop. Based on https://stackoverflow.com/a/52423252/ * Convince the compiler we're doing it right Invoking "public final" methods would fail. Not sure why this only happens for some extensions, but it does. We need to tell the compiler we're sure we have access to it, for some reason... * JS: Invoke result handler on the loop Some extensions call WebView methods on the result, so this should be on the same loop as the WebView itself * JS: Await arguments * Fix using wrong URL property for errors --- .../webkit/KcefWebViewProvider.kt | 130 +++++++++++++----- 1 file changed, 92 insertions(+), 38 deletions(-) diff --git a/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/KcefWebViewProvider.kt b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/KcefWebViewProvider.kt index 8e3ccb95..5a5921c4 100644 --- a/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/KcefWebViewProvider.kt +++ b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/KcefWebViewProvider.kt @@ -87,8 +87,10 @@ import java.io.File import java.io.IOException import java.util.concurrent.Executor import kotlin.collections.Map +import kotlin.reflect.KClass import kotlin.reflect.KFunction import kotlin.reflect.full.declaredMemberFunctions +import kotlin.reflect.jvm.javaMethod class KcefWebViewProvider( private val view: WebView, @@ -97,6 +99,7 @@ class KcefWebViewProvider( private var viewClient = WebViewClient() private var chromeClient = WebChromeClient() private val mappings: MutableList = mutableListOf() + private val urlHttpMapping: MutableMap = mutableMapOf() private var kcefClient: KCEFClient? = null private var browser: KCEFBrowser? = null @@ -203,7 +206,7 @@ class KcefWebViewProvider( Log.w(TAG, "Load error ($failedUrl) [$errorCode]: $errorText") // TODO: translate correctly handler.post { - viewClient.onReceivedError(view, WebViewClient.ERROR_UNKNOWN, errorText, url) + viewClient.onReceivedError(view, WebViewClient.ERROR_UNKNOWN, errorText, frame.url) } } @@ -217,13 +220,14 @@ class KcefWebViewProvider( val js = """ window.${it.interfaceName} = window.${it.interfaceName} || {} - window.${it.interfaceName}.${it.functionName} = function() { + window.${it.interfaceName}.${it.functionName} = async function() { + const args = await Promise.all(Array.from(arguments)); return new Promise((resolve, reject) => { window.${QUERY_FN}({ request: JSON.stringify({ functionName: ${Json.encodeToString(it.functionName)}, interfaceName: ${Json.encodeToString(it.interfaceName)}, - args: Array.from(arguments), + args, }), persistent: false, onSuccess: resolve, @@ -263,13 +267,16 @@ class KcefWebViewProvider( }?.let { handler.post { try { - Log.v(TAG, "Received request to invoke ${it.toNice()}") + Log.v( + TAG, + "Received request to invoke ${it.toNice()} with ${invoke.args.size} args", + ) // NOTE: first argument is // implicitly this val retval = it.fn.call(it.obj, *invoke.args) callback.success(retval.toString()) } catch (e: Exception) { - Log.w(TAG, "JS-invoke on ${it.toNice()} failed: $e") + Log.w(TAG, "JS-invoke on ${it.toNice()} failed:", e) callback.failure(0, e.message) } } @@ -279,34 +286,19 @@ class KcefWebViewProvider( } } - private inner class WebResponseResourceHandler( - val webResponse: WebResourceResponse, - ) : CefResourceHandlerAdapter() { - private var resolvedData: ByteArray? = null - private var readOffset = 0 - - override fun processRequest( - request: CefRequest, - callback: CefCallback, - ): Boolean { - Log.v(TAG, "Handling request from client's response for ${request.url}") - handler.post { - try { - resolvedData = webResponse.data.readAllBytes() - } catch (e: IOException) { - } - callback.Continue() - } - return true - } + private abstract class ArrayResponseResourceHandler : CefResourceHandlerAdapter() { + protected var resolvedData: ByteArray? = null + protected var readOffset = 0 override fun getResponseHeaders( response: CefResponse, responseLength: IntRef, redirectUrl: StringRef, ) { - webResponse.responseHeaders.forEach { response.setHeaderByName(it.key, it.value, true) } responseLength.set(resolvedData?.size ?: 0) + response.status = 200 + response.statusText = "OK" + response.mimeType = "text/html" } override fun readResponse( @@ -324,7 +316,49 @@ class KcefWebViewProvider( data.copyInto(dataOut, startIndex = readOffset, endIndex = readOffset + bytesToTransfer) bytesRead.set(bytesToTransfer) readOffset += bytesToTransfer - return readOffset < data.size + return bytesToTransfer != 0 + } + } + + private inner class WebResponseResourceHandler( + val webResponse: WebResourceResponse, + ) : ArrayResponseResourceHandler() { + override fun processRequest( + request: CefRequest, + callback: CefCallback, + ): Boolean { + Log.v(TAG, "Handling request from client's response for ${request.url}") + try { + resolvedData = webResponse.data.readAllBytes() + } catch (e: IOException) { + } + callback.Continue() + return true + } + + override fun getResponseHeaders( + response: CefResponse, + responseLength: IntRef, + redirectUrl: StringRef, + ) { + super.getResponseHeaders(response, responseLength, redirectUrl) + webResponse.responseHeaders.forEach { response.setHeaderByName(it.key, it.value, true) } + response.status = webResponse.statusCode + response.mimeType = webResponse.mimeType + } + } + + private inner class HtmlResponseResourceHandler( + val html: String, + ) : ArrayResponseResourceHandler() { + override fun processRequest( + request: CefRequest, + callback: CefCallback, + ): Boolean { + Log.v(TAG, "Handling request from HTML cache for ${request.url}") + resolvedData = html.toByteArray() + callback.Continue() + return true } } @@ -361,6 +395,12 @@ class KcefWebViewProvider( view, CefWebResourceRequest(request, frame, false), ) + if (response == null) { + // prefer user's response override + urlHttpMapping.get(request.url)?.let { + return HtmlResponseResourceHandler(it) + } + } response ?: return null return WebResponseResourceHandler(response) } @@ -398,6 +438,7 @@ class KcefWebViewProvider( javaScriptInterfaces: Map?, privateBrowsing: Boolean, ) { + Log.v(TAG, "KcefWebViewProvider: initialize") destroy() kcefClient = KCEF.newClientBlocking().apply { @@ -534,16 +575,27 @@ class KcefWebViewProvider( browser?.close(true) browser?.dispose() chromeClient.onProgressChanged(view, 0) + browser = - kcefClient!! - .createBrowserWithHtml( - data, - baseUrl ?: KCEFBrowser.BLANK_URI, - CefRendering.OFFSCREEN, - ).apply { - // NOTE: Without this, we don't seem to be receiving any events - createImmediately() + ( + baseUrl?.let { url -> + urlHttpMapping.put(url, data) + kcefClient!!.createBrowser( + url, + CefRendering.OFFSCREEN, + ) } + ?: run { + kcefClient!!.createBrowserWithHtml( + data, + KCEFBrowser.BLANK_URI, + CefRendering.OFFSCREEN, + ) + } + ).apply { + // NOTE: Without this, we don't seem to be receiving any events + createImmediately() + } Log.d(TAG, "Page loaded from data at base URL $baseUrl") } @@ -555,7 +607,7 @@ class KcefWebViewProvider( script.removePrefix("javascript:"), { Log.v(TAG, "JS returned: $it") - it?.let { resultCallback.onReceiveValue(it) } + it?.let { handler.post { resultCallback.onReceiveValue(it) } } }, ) } @@ -721,11 +773,13 @@ class KcefWebViewProvider( obj: Any, interfaceName: String, ) { - val cls = obj::class + val cls = obj::class as KClass mappings.addAll( cls.declaredMemberFunctions.map { + // This is ridiculous, but necessary, otherwise "public final" throws + it.javaMethod?.isAccessible = true val map = FunctionMapping(interfaceName, it.name, obj, it) - Log.v(TAG, "Exposing: " + map.toNice()) + Log.v(TAG, "Exposing: ${map.toNice()}") map }, )