From 5be4d2a1044b0eaac8dba8fdf060ce1c4b4381e9 Mon Sep 17 00:00:00 2001 From: Constantin Piber <59023762+cpiber@users.noreply.github.com> Date: Fri, 17 Oct 2025 18:07:18 +0200 Subject: [PATCH] Minor bug fixes for Webview, Permission request support (#1723) * fix: Match URLs with trailing / * Handle permission requests and attempt to enable Widevine * Tie CEF loglevel to server debug logs * Lint * Add missing file Forgot to add in previous commits * Provide WebResourceResponse * Fix NullException if headers are not set * fix: Don't allow interception for initial page load fixes #1713 * Lint --- .../android/webkit/PermissionRequest.java | 98 +++++++ .../android/webkit/WebResourceResponse.java | 249 ++++++++++++++++++ .../webkit/KcefWebViewProvider.kt | 69 ++++- .../suwayomi/tachidesk/server/ServerSetup.kt | 4 +- 4 files changed, 411 insertions(+), 9 deletions(-) create mode 100644 AndroidCompat/src/main/java/android/webkit/PermissionRequest.java create mode 100644 AndroidCompat/src/main/java/android/webkit/WebResourceResponse.java diff --git a/AndroidCompat/src/main/java/android/webkit/PermissionRequest.java b/AndroidCompat/src/main/java/android/webkit/PermissionRequest.java new file mode 100644 index 000000000..ac145b1d8 --- /dev/null +++ b/AndroidCompat/src/main/java/android/webkit/PermissionRequest.java @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.webkit; + +import android.net.Uri; + +/** + * This class defines a permission request and is used when web content + * requests access to protected resources. The permission request related events + * are delivered via {@link WebChromeClient#onPermissionRequest} and + * {@link WebChromeClient#onPermissionRequestCanceled}. + * + * Either {@link #grant(String[]) grant()} or {@link #deny()} must be called in UI + * thread to respond to the request. + * + * New protected resources whose names are not defined here may be requested in + * future versions of WebView, even when running on an older Android release. To + * avoid unintentionally granting requests for new permissions, you should pass the + * specific permissions you intend to grant to {@link #grant(String[]) grant()}, + * and avoid writing code like this example: + *
+ * permissionRequest.grant(permissionRequest.getResources())  // This is wrong!!!
+ * 
+ * See the WebView's release notes for information about new protected resources. + */ +public abstract class PermissionRequest { + /** + * Resource belongs to video capture device, like camera. + */ + public final static String RESOURCE_VIDEO_CAPTURE = "android.webkit.resource.VIDEO_CAPTURE"; + /** + * Resource belongs to audio capture device, like microphone. + */ + public final static String RESOURCE_AUDIO_CAPTURE = "android.webkit.resource.AUDIO_CAPTURE"; + /** + * Resource belongs to protected media identifier. + * After the user grants this resource, the origin can use EME APIs to generate the license + * requests. + */ + public final static String RESOURCE_PROTECTED_MEDIA_ID = + "android.webkit.resource.PROTECTED_MEDIA_ID"; + /** + * Resource will allow sysex messages to be sent to or received from MIDI devices. These + * messages are privileged operations, e.g. modifying sound libraries and sampling data, or + * even updating the MIDI device's firmware. + * + * Permission may be requested for this resource in API levels 21 and above, if the Android + * device has been updated to WebView 45 or above. + */ + public final static String RESOURCE_MIDI_SYSEX = "android.webkit.resource.MIDI_SYSEX"; + + /** + * Call this method to get the origin of the web page which is trying to access + * the restricted resources. + * + * @return the origin of web content which attempt to access the restricted + * resources. + */ + public abstract Uri getOrigin(); + + /** + * Call this method to get the resources the web page is trying to access. + * + * @return the array of resources the web content wants to access. + */ + public abstract String[] getResources(); + + /** + * Call this method to grant origin the permission to access the given resources. + * The granted permission is only valid for this WebView. + * + * @param resources the resources granted to be accessed by origin, to grant + * request, the requested resources returned by {@link #getResources()} + * must be equals or a subset of granted resources. + * This parameter is designed to avoid granting permission by accident + * especially when new resources are requested by web content. + */ + public abstract void grant(String[] resources); + + /** + * Call this method to deny the request. + */ + public abstract void deny(); +} diff --git a/AndroidCompat/src/main/java/android/webkit/WebResourceResponse.java b/AndroidCompat/src/main/java/android/webkit/WebResourceResponse.java new file mode 100644 index 000000000..1d374118c --- /dev/null +++ b/AndroidCompat/src/main/java/android/webkit/WebResourceResponse.java @@ -0,0 +1,249 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.webkit; + +import android.annotation.SystemApi; +import android.os.Build; +import java.io.InputStream; +import java.io.StringBufferInputStream; +import java.util.Map; +import android.annotation.NonNull; + + +/** + * Encapsulates a resource response. Applications can return an instance of this + * class from {@link WebViewClient#shouldInterceptRequest} to provide a custom + * response when the WebView requests a particular resource. + */ +public class WebResourceResponse { + private boolean mImmutable; + private String mMimeType; + private String mEncoding; + private int mStatusCode; + private String mReasonPhrase; + private Map mResponseHeaders; + private InputStream mInputStream; + + /** + * Constructs a resource response with the given MIME type, character encoding, + * and input stream. Callers must implement {@link InputStream#read(byte[])} for + * the input stream. {@link InputStream#close()} will be called after the WebView + * has finished with the response. + * + *

Note: The MIME type and character encoding must + * be specified as separate parameters (for example {@code "text/html"} and + * {@code "utf-8"}), not a single value like the {@code "text/html; charset=utf-8"} + * format used in the HTTP Content-Type header. Do not use the value of a HTTP + * Content-Encoding header for {@code encoding}, as that header does not specify a + * character encoding. Content without a defined character encoding (for example + * image resources) should pass {@code null} for {@code encoding}. + * + * @param mimeType the resource response's MIME type, for example {@code "text/html"}. + * @param encoding the resource response's character encoding, for example {@code "utf-8"}. + * @param data the input stream that provides the resource response's data. Must not be a + * StringBufferInputStream. + */ + public WebResourceResponse(String mimeType, String encoding, + InputStream data) { + mMimeType = mimeType; + mEncoding = encoding; + setData(data); + } + + /** + * Constructs a resource response with the given parameters. Callers must implement + * {@link InputStream#read(byte[])} for the input stream. {@link InputStream#close()} will be + * called after the WebView has finished with the response. + * + * + *

Note: See {@link #WebResourceResponse(String,String,InputStream)} + * for details on what should be specified for {@code mimeType} and {@code encoding}. + * + * @param mimeType the resource response's MIME type, for example {@code "text/html"}. + * @param encoding the resource response's character encoding, for example {@code "utf-8"}. + * @param statusCode the status code needs to be in the ranges [100, 299], [400, 599]. + * Causing a redirect by specifying a 3xx code is not supported. + * @param reasonPhrase the phrase describing the status code, for example "OK". Must be + * non-empty. + * @param responseHeaders the resource response's headers represented as a mapping of header + * name -> header value. + * @param data the input stream that provides the resource response's data. Must not be a + * StringBufferInputStream. + */ + public WebResourceResponse(String mimeType, String encoding, int statusCode, + @NonNull String reasonPhrase, Map responseHeaders, InputStream data) { + this(mimeType, encoding, data); + setStatusCodeAndReasonPhrase(statusCode, reasonPhrase); + setResponseHeaders(responseHeaders); + } + + /** + * Sets the resource response's MIME type, for example "text/html". + * + * @param mimeType The resource response's MIME type + */ + public void setMimeType(String mimeType) { + checkImmutable(); + mMimeType = mimeType; + } + + /** + * Gets the resource response's MIME type. + * + * @return The resource response's MIME type + */ + public String getMimeType() { + return mMimeType; + } + + /** + * Sets the resource response's encoding, for example "UTF-8". This is used + * to decode the data from the input stream. + * + * @param encoding The resource response's encoding + */ + public void setEncoding(String encoding) { + checkImmutable(); + mEncoding = encoding; + } + + /** + * Gets the resource response's encoding. + * + * @return The resource response's encoding + */ + public String getEncoding() { + return mEncoding; + } + + /** + * Sets the resource response's status code and reason phrase. + * + * @param statusCode the status code needs to be in the ranges [100, 299], [400, 599]. + * Causing a redirect by specifying a 3xx code is not supported. + * @param reasonPhrase the phrase describing the status code, for example "OK". Must be + * non-empty. + */ + public void setStatusCodeAndReasonPhrase(int statusCode, @NonNull String reasonPhrase) { + checkImmutable(); + if (statusCode < 100) + throw new IllegalArgumentException("statusCode can't be less than 100."); + if (statusCode > 599) + throw new IllegalArgumentException("statusCode can't be greater than 599."); + if (statusCode > 299 && statusCode < 400) + throw new IllegalArgumentException("statusCode can't be in the [300, 399] range."); + if (reasonPhrase == null) + throw new IllegalArgumentException("reasonPhrase can't be null."); + if (reasonPhrase.trim().isEmpty()) + throw new IllegalArgumentException("reasonPhrase can't be empty."); + for (int i = 0; i < reasonPhrase.length(); i++) { + int c = reasonPhrase.charAt(i); + if (c > 0x7F) { + throw new IllegalArgumentException( + "reasonPhrase can't contain non-ASCII characters."); + } + } + mStatusCode = statusCode; + mReasonPhrase = reasonPhrase; + } + + /** + * Gets the resource response's status code. + * + * @return The resource response's status code. + */ + public int getStatusCode() { + return mStatusCode; + } + + /** + * Gets the description of the resource response's status code. + * + * @return The description of the resource response's status code. + */ + public String getReasonPhrase() { + return mReasonPhrase; + } + + /** + * Sets the headers for the resource response. + * + * @param headers Mapping of header name -> header value. + */ + public void setResponseHeaders(Map headers) { + checkImmutable(); + mResponseHeaders = headers; + } + + /** + * Gets the headers for the resource response. + * + * @return The headers for the resource response. + */ + public Map getResponseHeaders() { + return mResponseHeaders; + } + + /** + * Sets the input stream that provides the resource response's data. Callers + * must implement {@link InputStream#read(byte[])}. {@link InputStream#close()} + * will be called after the WebView has finished with the response. + * + * @param data the input stream that provides the resource response's data. Must not be a + * StringBufferInputStream. + */ + public void setData(InputStream data) { + checkImmutable(); + // If data is (or is a subclass of) StringBufferInputStream + if (data != null && StringBufferInputStream.class.isAssignableFrom(data.getClass())) { + throw new IllegalArgumentException("StringBufferInputStream is deprecated and must " + + "not be passed to a WebResourceResponse"); + } + mInputStream = data; + } + + /** + * Gets the input stream that provides the resource response's data. + * + * @return The input stream that provides the resource response's data + */ + public InputStream getData() { + return mInputStream; + } + + /** + * The internal version of the constructor that doesn't perform arguments checks. + * @hide + */ + @SystemApi + public WebResourceResponse(boolean immutable, String mimeType, String encoding, int statusCode, + String reasonPhrase, Map responseHeaders, InputStream data) { + mImmutable = immutable; + mMimeType = mimeType; + mEncoding = encoding; + mStatusCode = statusCode; + mReasonPhrase = reasonPhrase; + mResponseHeaders = responseHeaders; + mInputStream = data; + } + + private void checkImmutable() { + if (mImmutable) + throw new IllegalStateException("This WebResourceResponse instance is immutable"); + } +} + 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 c2f7c25fb..f26e8dcb2 100644 --- a/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/KcefWebViewProvider.kt +++ b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/KcefWebViewProvider.kt @@ -31,6 +31,7 @@ import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection import android.view.textclassifier.TextClassifier import android.webkit.DownloadListener +import android.webkit.PermissionRequest import android.webkit.RenderProcessGoneDetail import android.webkit.ValueCallback import android.webkit.WebBackForwardList @@ -62,11 +63,13 @@ import org.cef.browser.CefFrame import org.cef.browser.CefMessageRouter import org.cef.browser.CefRendering import org.cef.callback.CefCallback +import org.cef.callback.CefMediaAccessCallback import org.cef.callback.CefQueryCallback import org.cef.handler.CefDisplayHandlerAdapter import org.cef.handler.CefLoadHandler import org.cef.handler.CefLoadHandlerAdapter import org.cef.handler.CefMessageRouterHandlerAdapter +import org.cef.handler.CefPermissionHandler import org.cef.handler.CefRequestHandler import org.cef.handler.CefRequestHandlerAdapter import org.cef.handler.CefResourceHandler @@ -167,6 +170,30 @@ class KcefWebViewProvider( } } + private class CefPermissionRequest( + private val url: String, + private val permissionMask: Int, + private val callback: CefMediaAccessCallback, + ) : PermissionRequest() { + override fun getOrigin(): Uri = Uri.parse(url) + + override fun getResources(): Array { + val retVal = mutableListOf() + if ((permissionMask and (1 shl 0)) > 0) retVal.add(PermissionRequest.RESOURCE_AUDIO_CAPTURE) + if ((permissionMask and (1 shl 1)) > 0) retVal.add(PermissionRequest.RESOURCE_VIDEO_CAPTURE) + return retVal.toTypedArray() + } + + override fun grant(resources: Array) { + // TODO: respect given resource grant + callback.Continue(permissionMask) + } + + override fun deny() { + callback.Cancel() + } + } + private inner class DisplayHandler : CefDisplayHandlerAdapter() { override fun onConsoleMessage( browser: CefBrowser, @@ -363,7 +390,9 @@ class KcefWebViewProvider( Log.v(TAG, "Handling request from client's response for ${request.url}") try { resolvedData = webResponse.data.readAllBytes() + Log.v(TAG, "Resolved client response for ${resolvedData?.size} bytes") } catch (e: IOException) { + Log.w(TAG, "Failed to read client data", e) } callback.Continue() return true @@ -375,7 +404,7 @@ class KcefWebViewProvider( redirectUrl: StringRef, ) { super.getResponseHeaders(response, responseLength, redirectUrl) - webResponse.responseHeaders.forEach { response.setHeaderByName(it.key, it.value, true) } + webResponse.responseHeaders?.forEach { response.setHeaderByName(it.key, it.value, true) } response.status = webResponse.statusCode response.mimeType = webResponse.mimeType } @@ -424,15 +453,22 @@ class KcefWebViewProvider( frame: CefFrame, request: CefRequest, ): CefResourceHandler? { - // TODO: we should be calling this on the handler, since CEF calls us on its IO thread + val isInitialLoad = frame.url == "" && request.method == "GET" + Log.v(TAG, "Request ${request.method} ${request.url} is initial? $isInitialLoad") + // NOTE: we should be calling this on the handler, since CEF calls us on its IO thread + // but docs say "This method is called on a thread other than the UI thread" so should be fine val response = - viewClient.shouldInterceptRequest( - view, - CefWebResourceRequest(request, frame, false), - ) + if (isInitialLoad) { + null + } else { + viewClient.shouldInterceptRequest( + view, + CefWebResourceRequest(request, frame, false), + ) + } if (response == null) { // prefer user's response override - urlHttpMapping.get(request.url)?.let { + urlHttpMapping.get(request.url.trimEnd('/'))?.let { return HtmlResponseResourceHandler(it) } } @@ -469,6 +505,22 @@ class KcefWebViewProvider( } } + private inner class PermissionHandler : CefPermissionHandler { + override fun onRequestMediaAccessPermission( + browser: CefBrowser, + frame: CefFrame, + requesting_url: String, + requested_permissions: Int, + callback: CefMediaAccessCallback, + ): Boolean { + handler.post { + Log.v(TAG, "Checking permission for $requesting_url: $requested_permissions") + chromeClient.onPermissionRequest(CefPermissionRequest(requesting_url, requested_permissions, callback)) + } + return true + } + } + override fun init( javaScriptInterfaces: Map?, privateBrowsing: Boolean, @@ -480,6 +532,7 @@ class KcefWebViewProvider( addDisplayHandler(DisplayHandler()) addLoadHandler(LoadHandler()) addRequestHandler(RequestHandler()) + addPermissionHandler(PermissionHandler()) val config = CefMessageRouter.CefMessageRouterConfig() config.jsQueryFunction = QUERY_FN @@ -615,7 +668,7 @@ class KcefWebViewProvider( browser = ( baseUrl?.let { url -> - urlHttpMapping.put(url, data) + urlHttpMapping.put(url.trimEnd('/'), data) kcefClient!!.createBrowser( url, CefRendering.OFFSCREEN, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt index 7674c10dd..1b7200e1c 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt @@ -15,6 +15,7 @@ import com.typesafe.config.ConfigRenderOptions import com.typesafe.config.ConfigValue import com.typesafe.config.parser.ConfigDocument import dev.datlag.kcef.KCEF +import dev.datlag.kcef.KCEFBuilder.Settings.LogSeverity import eu.kanade.tachiyomi.App import eu.kanade.tachiyomi.createAppModule import eu.kanade.tachiyomi.network.NetworkHelper @@ -522,10 +523,11 @@ fun applicationSetup() { settings { windowlessRenderingEnabled = true cachePath = (Path(applicationDirs.dataRoot) / "cache/kcef").toString() + logSeverity = if (serverConfig.debugLogsEnabled.value) LogSeverity.Verbose else LogSeverity.Default } appHandler( KCEF.AppHandler( - arrayOf("--disable-gpu", "--off-screen-rendering-enabled", "--disable-dev-shm-usage"), + arrayOf("--disable-gpu", "--off-screen-rendering-enabled", "--disable-dev-shm-usage", "--enable-widevine-cdm"), ), )