chromium/android_webview/java/src/org/chromium/android_webview/AwContentsClient.java

// Copyright 2012 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.android_webview;

import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.Picture;
import android.net.Uri;
import android.net.http.SslError;
import android.os.Looper;
import android.os.Message;
import android.provider.Browser;
import android.text.TextUtils;
import android.view.KeyEvent;
import android.view.View;
import android.webkit.WebChromeClient;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import org.chromium.android_webview.common.Lifetime;
import org.chromium.android_webview.permission.AwPermissionRequest;
import org.chromium.android_webview.safe_browsing.AwSafeBrowsingResponse;
import org.chromium.base.Callback;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.metrics.ScopedSysTraceEvent;
import org.chromium.components.embedder_support.util.UrlConstants;
import org.chromium.components.embedder_support.util.WebResourceResponseInfo;
import org.chromium.content_public.common.ContentUrlConstants;

import java.security.Principal;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;

/**
 * Base-class that an AwContents embedder derives from to receive callbacks.
 * For any other callbacks we need to make transformations of (e.g. adapt parameters
 * or perform filtering) we can provide final overrides for methods here, and then introduce
 * new abstract methods that the our own client must implement.
 * i.e.: all methods in this class should either be final, or abstract.
 */
@Lifetime.WebView
public abstract class AwContentsClient {
    private static final String TAG = "AwContentsClient";
    private final AwContentsClientCallbackHelper mCallbackHelper;

    // Last background color reported from the renderer. Holds the sentinal value INVALID_COLOR
    // if not valid.
    private int mCachedRendererBackgroundColor = INVALID_COLOR;
    // Holds the last known page title. {@link ContentViewClient#onUpdateTitle} is unreliable,
    // particularly for navigating backwards and forwards in the history stack. Instead, the last
    // known document title is kept here, and the clients gets updated whenever the value has
    // actually changed. Blink also only sends updates when the document title have changed,
    // so behaviours are consistent.
    private String mTitle = "";

    private static final int INVALID_COLOR = 0;

    private static final Pattern FILE_ANDROID_ASSET_PATTERN =
            Pattern.compile("^file:///android_(asset|res)/.*");

    public AwContentsClient() {
        this(Looper.myLooper());
    }

    /**
     *
     * See {@link android.webkit.WebChromeClient}. */
    public interface CustomViewCallback {
        /* See {@link android.webkit.WebChromeClient}. */
        public void onCustomViewHidden();
    }

    // Alllow injection of the callback thread, for testing.
    public AwContentsClient(Looper looper) {
        try (ScopedSysTraceEvent e =
                ScopedSysTraceEvent.scoped("AwContentsClient.constructorOneArg")) {
            mCallbackHelper = new AwContentsClientCallbackHelper(looper, this);
        }
    }

    final AwContentsClientCallbackHelper getCallbackHelper() {
        return mCallbackHelper;
    }

    final int getCachedRendererBackgroundColor() {
        assert isCachedRendererBackgroundColorValid();
        return mCachedRendererBackgroundColor;
    }

    final boolean isCachedRendererBackgroundColorValid() {
        return mCachedRendererBackgroundColor != INVALID_COLOR;
    }

    final void onBackgroundColorChanged(int color) {
        // Avoid storing the sentinal INVALID_COLOR (note that both 0 and 1 are both
        // fully transparent so this transpose makes no visible difference).
        mCachedRendererBackgroundColor = color == INVALID_COLOR ? 1 : color;
    }

    // --------------------------------------------------------------------------------------------
    //             WebView specific methods that map directly to WebViewClient / WebChromeClient
    // --------------------------------------------------------------------------------------------

    /** Parameters for the {@link AwContentsClient#shouldInterceptRequest} method. */
    public static class AwWebResourceRequest {
        // Prefer using other constructors over this one.
        public AwWebResourceRequest() {}

        public AwWebResourceRequest(
                String url,
                boolean isOutermostMainFrame,
                boolean hasUserGesture,
                String method,
                @Nullable HashMap<String, String> requestHeaders) {
            this.url = url;
            this.isOutermostMainFrame = isOutermostMainFrame;
            this.hasUserGesture = hasUserGesture;
            // Note: we intentionally let isRedirect default initialize to false. This is because we
            // don't always know if this request is associated with a redirect or not.
            this.method = method;
            this.requestHeaders = requestHeaders;
        }

        public AwWebResourceRequest(
                String url,
                boolean isOutermostMainFrame,
                boolean hasUserGesture,
                String method,
                @NonNull String[] requestHeaderNames,
                @NonNull String[] requestHeaderValues) {
            this(
                    url,
                    isOutermostMainFrame,
                    hasUserGesture,
                    method,
                    new HashMap<String, String>(requestHeaderValues.length));
            for (int i = 0; i < requestHeaderNames.length; ++i) {
                this.requestHeaders.put(requestHeaderNames[i], requestHeaderValues[i]);
            }
        }

        // Url of the request.
        public String url;
        // Is this for the outermost main frame or a subframe?
        public boolean isOutermostMainFrame;
        // Was a gesture associated with the request? Don't trust can easily be spoofed.
        public boolean hasUserGesture;
        // Was it a result of a server-side redirect?
        public boolean isRedirect;
        // Method used (GET/POST/OPTIONS)
        public String method;
        // Headers that would have been sent to server.
        public HashMap<String, String> requestHeaders;
    }

    /** Parameters for {@link AwContentsClient#onReceivedError} method. */
    public static class AwWebResourceError {
        public @WebviewErrorCode int errorCode = WebviewErrorCode.ERROR_UNKNOWN;
        public String description;
    }

    /** Allow default implementations in chromium code. */
    public abstract boolean hasWebViewClient();

    public abstract void getVisitedHistory(Callback<String[]> callback);

    public abstract void doUpdateVisitedHistory(String url, boolean isReload);

    public abstract void onProgressChanged(int progress);

    public abstract WebResourceResponseInfo shouldInterceptRequest(AwWebResourceRequest request);

    public abstract boolean shouldOverrideKeyEvent(KeyEvent event);

    public abstract boolean shouldOverrideUrlLoading(AwWebResourceRequest request);

    public abstract void onLoadResource(String url);

    public abstract void onUnhandledKeyEvent(KeyEvent event);

    public abstract boolean onConsoleMessage(AwConsoleMessage consoleMessage);

    public abstract void onReceivedHttpAuthRequest(
            AwHttpAuthHandler handler, String host, String realm);

    public abstract void onReceivedSslError(Callback<Boolean> callback, SslError error);

    public abstract void onReceivedClientCertRequest(
            final AwContentsClientBridge.ClientCertificateRequestCallback callback,
            final String[] keyTypes,
            final Principal[] principals,
            final String host,
            final int port);

    public abstract void onReceivedLoginRequest(String realm, String account, String args);

    public abstract void onFormResubmission(Message dontResend, Message resend);

    public abstract void onDownloadStart(
            String url,
            String userAgent,
            String contentDisposition,
            String mimeType,
            long contentLength);

    public final boolean shouldIgnoreNavigation(
            Context context,
            String url,
            boolean isOutermostMainFrame,
            boolean hasUserGesture,
            @Nullable HashMap<String, String> requestHeaders,
            boolean isRedirect) {
        AwContentsClientCallbackHelper.CancelCallbackPoller poller =
                mCallbackHelper.getCancelCallbackPoller();
        if (poller != null && poller.shouldCancelAllCallbacks()) return false;

        if (hasWebViewClient()) {
            // Note: only GET requests can be overridden, so we hardcode the method.
            AwWebResourceRequest request =
                    new AwWebResourceRequest(
                            url, isOutermostMainFrame, hasUserGesture, "GET", requestHeaders);
            request.isRedirect = isRedirect;
            return shouldOverrideUrlLoading(request);
        }

        return sendBrowsingIntent(context, url, hasUserGesture, isRedirect);
    }

    private static boolean sendBrowsingIntent(
            Context context, String url, boolean hasUserGesture, boolean isRedirect) {
        if (!hasUserGesture && !isRedirect) {
            Log.w(TAG, "Denied starting an intent without a user gesture, URI %s", url);
            return true;
        }

        // Treat some URLs as internal, always open them in the WebView:
        // * about: scheme URIs
        // * chrome:// scheme URIs
        // * file:///android_asset/ or file:///android_res/ URIs
        if (url.startsWith(ContentUrlConstants.ABOUT_URL_SHORT_PREFIX)
                || url.startsWith(UrlConstants.CHROME_URL_PREFIX)
                || FILE_ANDROID_ASSET_PATTERN.matcher(url).matches()) {
            return false;
        }

        Intent intent;
        // Perform generic parsing of the URI to turn it into an Intent.
        try {
            intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME);
        } catch (Exception ex) {
            Log.w(TAG, "Bad URI %s", url, ex);
            return false;
        }
        // Sanitize the Intent, ensuring web pages can not bypass browser
        // security (only access to BROWSABLE activities).
        intent.addCategory(Intent.CATEGORY_BROWSABLE);
        intent.setComponent(null);
        Intent selector = intent.getSelector();
        if (selector != null) {
            selector.addCategory(Intent.CATEGORY_BROWSABLE);
            selector.setComponent(null);
        }

        // Pass the package name as application ID so that the intent from the
        // same application can be opened in the same tab.
        intent.putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName());

        // Check whether the context is activity context.
        if (ContextUtils.activityFromContext(context) == null) {
            Log.w(TAG, "Cannot call startActivity on non-activity context.");
            return false;
        }

        try {
            context.startActivity(intent);
            return true;
        } catch (ActivityNotFoundException ex) {
            Log.w(TAG, "No application can handle %s", url);
        } catch (SecurityException ex) {
            // This can happen if the Activity is exported="true", guarded by a permission, and sets
            // up an intent filter matching this intent. This is a valid configuration for an
            // Activity, so instead of crashing, we catch the exception and do nothing. See
            // https://crbug.com/808494 and https://crbug.com/889300.
            Log.w(TAG, "SecurityException when starting intent for %s", url);
        }

        return false;
    }

    public static Uri[] parseFileChooserResult(int resultCode, Intent intent) {
        if (resultCode == Activity.RESULT_CANCELED) {
            return null;
        }
        Uri result = intent == null || resultCode != Activity.RESULT_OK ? null : intent.getData();

        Uri[] uris = null;
        if (result != null) {
            uris = new Uri[1];
            uris[0] = result;
        }
        return uris;
    }

    /** Type adaptation class for {@link android.webkit.FileChooserParams}. */
    public static class FileChooserParamsImpl {
        private int mMode;
        private String mAcceptTypes;
        private String mTitle;
        private String mDefaultFilename;
        private boolean mCapture;
        private static final Map<String, String> sAcceptTypesMapping;

        static {
            // It takes less code to loop over an array than to call put() N times.
            String[] tuples =
                    new String[] {
                        "application/*",
                        "application/*",
                        "audio/*",
                        "audio/*",
                        "font/*",
                        "font/*",
                        "image/*",
                        "image/*",
                        "text/*",
                        "text/*",
                        "video/*",
                        "video/*",
                        ".aac",
                        "audio/aac",
                        ".abw",
                        "application/x-abiword",
                        ".arc",
                        "application/x-freearc",
                        ".avif",
                        "image/avif",
                        ".avi",
                        "video/x-msvideo",
                        ".azw",
                        "application/vnd.amazon.ebook",
                        ".bin",
                        "application/octet-stream",
                        ".bmp",
                        "image/bmp",
                        ".bz",
                        "application/x-bzip",
                        ".bz2",
                        "application/x-bzip2",
                        ".cda",
                        "application/x-cdf",
                        ".csh",
                        "application/x-csh",
                        ".css",
                        "text/css",
                        ".csv",
                        "text/csv",
                        ".doc",
                        "application/msword",
                        ".docx",
                        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
                        ".eot",
                        "application/vnd.ms-fontobject",
                        ".epub",
                        "application/epub+zip",
                        ".gz",
                        "application/gzip",
                        ".gif",
                        "image/gif",
                        ".htm",
                        "text/html",
                        ".html",
                        "text/html",
                        ".ico",
                        "image/vnd.microsoft.icon",
                        ".ics",
                        "text/calendar",
                        ".jar",
                        "application/java-archive",
                        ".jpeg",
                        "image/jpeg",
                        ".jpg",
                        "image/jpeg",
                        ".js",
                        "text/javascript",
                        ".json",
                        "application/json",
                        ".jsonld",
                        "application/ld+json",
                        ".mid",
                        "audio/midi",
                        ".midi",
                        "audio/midi",
                        ".mjs",
                        "text/javascript",
                        ".mp3",
                        "audio/mpeg",
                        ".mp4",
                        "video/mp4",
                        ".mpeg",
                        "video/mpeg",
                        ".mpkg",
                        "application/vnd.apple.installer+xml",
                        ".odp",
                        "application/vnd.oasis.opendocument.presentation",
                        ".ods",
                        "application/vnd.oasis.opendocument.spreadsheet",
                        ".odt",
                        "application/vnd.oasis.opendocument.text",
                        ".oga",
                        "audio/ogg",
                        ".ogv",
                        "video/ogg",
                        ".ogx",
                        "application/ogg",
                        ".opus",
                        "audio/opus",
                        ".otf",
                        "font/otf",
                        ".png",
                        "image/png",
                        ".pdf",
                        "application/pdf",
                        ".php",
                        "application/x-httpd-php",
                        ".ppt",
                        "application/vnd.ms-powerpoint",
                        ".pptx",
                        "application/vnd.openxmlformats-officedocument.presentationml.presentation",
                        ".rar",
                        "application/vnd.rar",
                        ".rtf",
                        "application/rtf",
                        ".sh",
                        "application/x-sh",
                        ".svg",
                        "image/svg+xml",
                        ".swf",
                        "application/x-shockwave-flash",
                        ".tar",
                        "application/x-tar",
                        ".tif",
                        "image/tiff",
                        ".tiff",
                        "image/tiff",
                        ".ts",
                        "video/mp2t",
                        ".ttf",
                        "font/ttf",
                        ".txt",
                        "text/plain",
                        ".vsd",
                        "application/vnd.visio",
                        ".wav",
                        "audio/wav",
                        ".weba",
                        "audio/webm",
                        ".webm",
                        "video/webm",
                        ".webp",
                        "image/webp",
                        ".woff",
                        "font/woff",
                        ".woff2",
                        "font/woff2",
                        ".xhtml",
                        "application/xhtml+xml",
                        ".xls",
                        "application/vnd.ms-excel",
                        ".xlsx",
                        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
                        ".xml",
                        "application/xml",
                        ".xul",
                        "application/vnd.mozilla.xul+xml",
                        ".zip",
                        "application/zip",
                        ".3gp",
                        "video/3gpp",
                        ".3g2",
                        "video/3gpp2",
                        ".7z",
                        "application/x-7z-compressed",
                    };
            Map<String, String> map = new HashMap<String, String>(tuples.length / 2);
            for (int i = 0; i < tuples.length; i += 2) {
                map.put(tuples[i], tuples[i + 1]);
            }
            sAcceptTypesMapping = map;
        }

        public FileChooserParamsImpl(
                int mode,
                String acceptTypes,
                String title,
                String defaultFilename,
                boolean capture) {
            mMode = mode;
            mAcceptTypes = acceptTypes;
            mTitle = title;
            mDefaultFilename = defaultFilename;
            mCapture = capture;
        }

        public String getAcceptTypesString() {
            return mAcceptTypes;
        }

        public int getMode() {
            return mMode;
        }

        public String[] getAcceptTypes() {
            if (mAcceptTypes == null) {
                return new String[0];
            }
            return mAcceptTypes.split(",");
        }

        public boolean isCaptureEnabled() {
            return mCapture;
        }

        public CharSequence getTitle() {
            return mTitle;
        }

        public String getFilenameHint() {
            return mDefaultFilename;
        }

        public Intent createIntent() {
            String mimeType = "*/*";
            Intent i = new Intent(Intent.ACTION_GET_CONTENT);
            i.addCategory(Intent.CATEGORY_OPENABLE);
            if (getMode() == WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE) {
                i.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
            }
            if (mAcceptTypes != null && !mAcceptTypes.trim().isEmpty()) {
                String[] acceptTypesArray = getAcceptTypes();
                if (acceptTypesArray.length > 0) {
                    String[] mimeTypesToAccept = getMimeTypesToAccept(getAcceptTypes());
                    if (mimeTypesToAccept.length > 0) {
                        if (!mimeTypesToAccept[0].trim().isEmpty()) {
                            mimeType = mimeTypesToAccept[0];
                        }
                        i.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypesToAccept);
                    }
                }
            }
            i.setType(mimeType);
            return i;
        }

        /**
         * This method takes a list of types to accept, which could be file extensions, MIME types,
         * or a sub-category of MIME types such as image/*, video/*, etc., and returns a list of
         * MIME types.
         * @param acceptTypesList
         * @return An array of MIME types to accept in the file selector
         */
        private String[] getMimeTypesToAccept(String[] acceptTypesList) {
            ArrayList<String> acceptTypesArray = new ArrayList<String>();
            for (int i = 0; i < acceptTypesList.length; i++) {
                if (sAcceptTypesMapping.containsKey(acceptTypesList[i])) {
                    acceptTypesArray.add(sAcceptTypesMapping.get(acceptTypesList[i]));
                } else if (sAcceptTypesMapping.containsValue(acceptTypesList[i])) {
                    // can also directly use the MIME type in the accept HTML field
                    acceptTypesArray.add(acceptTypesList[i]);
                }
            }
            return acceptTypesArray.toArray(new String[acceptTypesArray.size()]);
        }
    }

    public abstract void showFileChooser(
            Callback<String[]> uploadFilePathsCallback, FileChooserParamsImpl fileChooserParams);

    public abstract void onGeolocationPermissionsShowPrompt(
            String origin, AwGeolocationPermissions.Callback callback);

    public abstract void onGeolocationPermissionsHidePrompt();

    public abstract void onPermissionRequest(AwPermissionRequest awPermissionRequest);

    public abstract void onPermissionRequestCanceled(AwPermissionRequest awPermissionRequest);

    public abstract void onScaleChangedScaled(float oldScale, float newScale);

    protected abstract void handleJsAlert(String url, String message, JsResultReceiver receiver);

    protected abstract void handleJsBeforeUnload(
            String url, String message, JsResultReceiver receiver);

    protected abstract void handleJsConfirm(String url, String message, JsResultReceiver receiver);

    protected abstract void handleJsPrompt(
            String url, String message, String defaultValue, JsPromptResultReceiver receiver);

    protected abstract boolean onCreateWindow(boolean isDialog, boolean isUserGesture);

    protected abstract void onCloseWindow();

    public abstract void onReceivedTouchIconUrl(String url, boolean precomposed);

    public abstract void onReceivedIcon(Bitmap bitmap);

    public abstract void onReceivedTitle(String title);

    protected abstract void onRequestFocus();

    protected abstract View getVideoLoadingProgressView();

    public abstract void onPageStarted(String url);

    public abstract void onPageFinished(String url);

    public abstract void onPageCommitVisible(String url);

    public abstract void onReceivedError(AwWebResourceRequest request, AwWebResourceError error);

    protected abstract void onSafeBrowsingHit(
            AwWebResourceRequest request,
            int threatType,
            Callback<AwSafeBrowsingResponse> callback);

    public abstract void onReceivedHttpError(
            AwWebResourceRequest request, WebResourceResponseInfo response);

    public abstract void onShowCustomView(View view, CustomViewCallback callback);

    public abstract void onHideCustomView();

    public abstract Bitmap getDefaultVideoPoster();

    // --------------------------------------------------------------------------------------------
    //                              Other WebView-specific methods
    // --------------------------------------------------------------------------------------------
    //
    public abstract void onFindResultReceived(
            int activeMatchOrdinal, int numberOfMatches, boolean isDoneCounting);

    /**
     * Called whenever there is a new content picture available.
     * @param picture New picture.
     */
    public abstract void onNewPicture(Picture picture);

    public final void updateTitle(String title, boolean forceNotification) {
        if (!forceNotification && TextUtils.equals(mTitle, title)) return;
        mTitle = title;
        mCallbackHelper.postOnReceivedTitle(mTitle);
    }

    public abstract void onRendererUnresponsive(AwRenderProcess renderProcess);

    public abstract void onRendererResponsive(AwRenderProcess renderProcess);

    public abstract boolean onRenderProcessGone(AwRenderProcessGoneDetail detail);
}