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
This commit is contained in:
Constantin Piber
2025-10-17 18:07:18 +02:00
committed by GitHub
parent 0585000cf3
commit 5be4d2a104
4 changed files with 411 additions and 9 deletions

View File

@@ -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:
* <pre class="prettyprint">
* permissionRequest.grant(permissionRequest.getResources()) // This is wrong!!!
* </pre>
* 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();
}

View File

@@ -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<String, String> 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.
*
* <p class="note"><b>Note:</b> 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.
*
*
* <p class="note"><b>Note:</b> 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<String, String> responseHeaders, InputStream data) {
this(mimeType, encoding, data);
setStatusCodeAndReasonPhrase(statusCode, reasonPhrase);
setResponseHeaders(responseHeaders);
}
/**
* Sets the resource response's MIME type, for example &quot;text/html&quot;.
*
* @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 &quot;UTF-8&quot;. 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<String, String> headers) {
checkImmutable();
mResponseHeaders = headers;
}
/**
* Gets the headers for the resource response.
*
* @return The headers for the resource response.
*/
public Map<String, String> 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<String, String> 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");
}
}

View File

@@ -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<String> {
val retVal = mutableListOf<String>()
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<String>) {
// 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<String, Any>?,
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,