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
This commit is contained in:
Constantin Piber
2025-06-20 18:21:25 +02:00
committed by GitHub
parent 0d109cdd4f
commit 0b021e6c42

View File

@@ -87,8 +87,10 @@ import java.io.File
import java.io.IOException import java.io.IOException
import java.util.concurrent.Executor import java.util.concurrent.Executor
import kotlin.collections.Map import kotlin.collections.Map
import kotlin.reflect.KClass
import kotlin.reflect.KFunction import kotlin.reflect.KFunction
import kotlin.reflect.full.declaredMemberFunctions import kotlin.reflect.full.declaredMemberFunctions
import kotlin.reflect.jvm.javaMethod
class KcefWebViewProvider( class KcefWebViewProvider(
private val view: WebView, private val view: WebView,
@@ -97,6 +99,7 @@ class KcefWebViewProvider(
private var viewClient = WebViewClient() private var viewClient = WebViewClient()
private var chromeClient = WebChromeClient() private var chromeClient = WebChromeClient()
private val mappings: MutableList<FunctionMapping> = mutableListOf() private val mappings: MutableList<FunctionMapping> = mutableListOf()
private val urlHttpMapping: MutableMap<String, String> = mutableMapOf()
private var kcefClient: KCEFClient? = null private var kcefClient: KCEFClient? = null
private var browser: KCEFBrowser? = null private var browser: KCEFBrowser? = null
@@ -203,7 +206,7 @@ class KcefWebViewProvider(
Log.w(TAG, "Load error ($failedUrl) [$errorCode]: $errorText") Log.w(TAG, "Load error ($failedUrl) [$errorCode]: $errorText")
// TODO: translate correctly // TODO: translate correctly
handler.post { 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 = val js =
""" """
window.${it.interfaceName} = window.${it.interfaceName} || {} 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) => { return new Promise((resolve, reject) => {
window.${QUERY_FN}({ window.${QUERY_FN}({
request: JSON.stringify({ request: JSON.stringify({
functionName: ${Json.encodeToString(it.functionName)}, functionName: ${Json.encodeToString(it.functionName)},
interfaceName: ${Json.encodeToString(it.interfaceName)}, interfaceName: ${Json.encodeToString(it.interfaceName)},
args: Array.from(arguments), args,
}), }),
persistent: false, persistent: false,
onSuccess: resolve, onSuccess: resolve,
@@ -263,13 +267,16 @@ class KcefWebViewProvider(
}?.let { }?.let {
handler.post { handler.post {
try { 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 // NOTE: first argument is
// implicitly this // implicitly this
val retval = it.fn.call(it.obj, *invoke.args) val retval = it.fn.call(it.obj, *invoke.args)
callback.success(retval.toString()) callback.success(retval.toString())
} catch (e: Exception) { } 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) callback.failure(0, e.message)
} }
} }
@@ -279,34 +286,19 @@ class KcefWebViewProvider(
} }
} }
private inner class WebResponseResourceHandler( private abstract class ArrayResponseResourceHandler : CefResourceHandlerAdapter() {
val webResponse: WebResourceResponse, protected var resolvedData: ByteArray? = null
) : CefResourceHandlerAdapter() { protected var readOffset = 0
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
}
override fun getResponseHeaders( override fun getResponseHeaders(
response: CefResponse, response: CefResponse,
responseLength: IntRef, responseLength: IntRef,
redirectUrl: StringRef, redirectUrl: StringRef,
) { ) {
webResponse.responseHeaders.forEach { response.setHeaderByName(it.key, it.value, true) }
responseLength.set(resolvedData?.size ?: 0) responseLength.set(resolvedData?.size ?: 0)
response.status = 200
response.statusText = "OK"
response.mimeType = "text/html"
} }
override fun readResponse( override fun readResponse(
@@ -324,7 +316,49 @@ class KcefWebViewProvider(
data.copyInto(dataOut, startIndex = readOffset, endIndex = readOffset + bytesToTransfer) data.copyInto(dataOut, startIndex = readOffset, endIndex = readOffset + bytesToTransfer)
bytesRead.set(bytesToTransfer) bytesRead.set(bytesToTransfer)
readOffset += 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, view,
CefWebResourceRequest(request, frame, false), CefWebResourceRequest(request, frame, false),
) )
if (response == null) {
// prefer user's response override
urlHttpMapping.get(request.url)?.let {
return HtmlResponseResourceHandler(it)
}
}
response ?: return null response ?: return null
return WebResponseResourceHandler(response) return WebResponseResourceHandler(response)
} }
@@ -398,6 +438,7 @@ class KcefWebViewProvider(
javaScriptInterfaces: Map<String, Any>?, javaScriptInterfaces: Map<String, Any>?,
privateBrowsing: Boolean, privateBrowsing: Boolean,
) { ) {
Log.v(TAG, "KcefWebViewProvider: initialize")
destroy() destroy()
kcefClient = kcefClient =
KCEF.newClientBlocking().apply { KCEF.newClientBlocking().apply {
@@ -534,16 +575,27 @@ class KcefWebViewProvider(
browser?.close(true) browser?.close(true)
browser?.dispose() browser?.dispose()
chromeClient.onProgressChanged(view, 0) chromeClient.onProgressChanged(view, 0)
browser = browser =
kcefClient!! (
.createBrowserWithHtml( baseUrl?.let { url ->
data, urlHttpMapping.put(url, data)
baseUrl ?: KCEFBrowser.BLANK_URI, kcefClient!!.createBrowser(
CefRendering.OFFSCREEN, url,
).apply { CefRendering.OFFSCREEN,
// NOTE: Without this, we don't seem to be receiving any events )
createImmediately()
} }
?: 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") Log.d(TAG, "Page loaded from data at base URL $baseUrl")
} }
@@ -555,7 +607,7 @@ class KcefWebViewProvider(
script.removePrefix("javascript:"), script.removePrefix("javascript:"),
{ {
Log.v(TAG, "JS returned: $it") 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, obj: Any,
interfaceName: String, interfaceName: String,
) { ) {
val cls = obj::class val cls = obj::class as KClass<Any>
mappings.addAll( mappings.addAll(
cls.declaredMemberFunctions.map { cls.declaredMemberFunctions.map {
// This is ridiculous, but necessary, otherwise "public final" throws
it.javaMethod?.isAccessible = true
val map = FunctionMapping(interfaceName, it.name, obj, it) val map = FunctionMapping(interfaceName, it.name, obj, it)
Log.v(TAG, "Exposing: " + map.toNice()) Log.v(TAG, "Exposing: ${map.toNice()}")
map map
}, },
) )