chromium/android_webview/java/src/org/chromium/android_webview/AwContentsClientBridge.java

// Copyright 2013 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.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.net.Uri;
import android.net.http.SslCertificate;
import android.net.http.SslError;

import org.jni_zero.CalledByNative;
import org.jni_zero.CalledByNativeUnchecked;
import org.jni_zero.JNINamespace;
import org.jni_zero.NativeMethods;

import org.chromium.android_webview.safe_browsing.AwSafeBrowsingConversionHelper;
import org.chromium.android_webview.safe_browsing.AwSafeBrowsingResponse;
import org.chromium.base.Callback;
import org.chromium.base.Log;
import org.chromium.base.TraceEvent;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.components.embedder_support.util.WebResourceResponseInfo;
import org.chromium.net.NetError;

import java.security.Principal;
import java.security.PrivateKey;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.security.auth.x500.X500Principal;

/**
 * This class handles the JNI communication logic for the the AwContentsClient class.
 * Both the Java and the native peers of AwContentsClientBridge are owned by the
 * corresponding AwContents instances. This class and its native peer are connected
 * via weak references. The native AwContentsClientBridge sets up and clear these weak
 * references.
 */
@JNINamespace("android_webview")
public class AwContentsClientBridge {
    private static final String TAG = "AwContentsCB";

    private AwContentsClient mClient;
    private Context mContext;
    // The native peer of this object.
    private long mNativeContentsClientBridge;

    private final ClientCertLookupTable mLookupTable;

    // Used for mocking this class in tests.
    protected AwContentsClientBridge(ClientCertLookupTable table) {
        mLookupTable = table;
    }

    public AwContentsClientBridge(
            Context context, AwContentsClient client, ClientCertLookupTable table) {
        assert client != null;
        mContext = context;
        mClient = client;
        mLookupTable = table;
    }

    /**
     * Callback to communicate clientcertificaterequest back to the AwContentsClientBridge.
     * The public methods should be called on UI thread.
     * A request can not be proceeded, ignored  or canceled more than once. Doing this
     * is a programming error and causes an exception.
     */
    public class ClientCertificateRequestCallback {

        private final int mId;
        private final String mHost;
        private final int mPort;
        private boolean mIsCalled;

        public ClientCertificateRequestCallback(int id, String host, int port) {
            mId = id;
            mHost = host;
            mPort = port;
        }

        public void proceed(final PrivateKey privateKey, final X509Certificate[] chain) {
            PostTask.runOrPostTask(
                    TaskTraits.UI_DEFAULT, () -> proceedOnUiThread(privateKey, chain));
        }

        public void ignore() {
            PostTask.runOrPostTask(TaskTraits.UI_DEFAULT, () -> ignoreOnUiThread());
        }

        public void cancel() {
            PostTask.runOrPostTask(TaskTraits.UI_DEFAULT, () -> cancelOnUiThread());
        }

        private void proceedOnUiThread(PrivateKey privateKey, X509Certificate[] chain) {
            checkIfCalled();

            if (privateKey == null || chain == null || chain.length == 0) {
                Log.w(TAG, "Empty client certificate chain?");
                provideResponse(null, null);
                return;
            }
            // Encode the certificate chain.
            byte[][] encodedChain = new byte[chain.length][];
            try {
                for (int i = 0; i < chain.length; ++i) {
                    encodedChain[i] = chain[i].getEncoded();
                }
            } catch (CertificateEncodingException e) {
                Log.w(TAG, "Could not retrieve encoded certificate chain: " + e);
                provideResponse(null, null);
                return;
            }
            mLookupTable.allow(mHost, mPort, privateKey, encodedChain);
            provideResponse(privateKey, encodedChain);
        }

        private void ignoreOnUiThread() {
            checkIfCalled();
            provideResponse(null, null);
        }

        private void cancelOnUiThread() {
            checkIfCalled();
            mLookupTable.deny(mHost, mPort);
            provideResponse(null, null);
        }

        private void checkIfCalled() {
            if (mIsCalled) {
                throw new IllegalStateException("The callback was already called.");
            }
            mIsCalled = true;
        }

        private void provideResponse(PrivateKey privateKey, byte[][] certChain) {
            if (mNativeContentsClientBridge == 0) return;
            AwContentsClientBridgeJni.get()
                    .provideClientCertificateResponse(
                            mNativeContentsClientBridge,
                            AwContentsClientBridge.this,
                            mId,
                            certChain,
                            privateKey);
        }
    }

    // Used by the native peer to set/reset a weak ref to the native peer.
    @CalledByNative
    private void setNativeContentsClientBridge(long nativeContentsClientBridge) {
        mNativeContentsClientBridge = nativeContentsClientBridge;
    }

    // If returns false, the request is immediately canceled, and any call to proceedSslError
    // has no effect. If returns true, the request should be canceled or proceeded using
    // proceedSslError().
    // Unlike the webview classic, we do not keep keep a database of certificates that
    // are allowed by the user, because this functionality is already handled via
    // ssl_policy in native layers.
    @CalledByNative
    private boolean allowCertificateError(
            int certError, byte[] derBytes, final String url, final int id) {
        final SslCertificate cert = SslUtil.getCertificateFromDerBytes(derBytes);
        if (cert == null) {
            // if the certificate or the client is null, cancel the request
            return false;
        }
        final SslError sslError = SslUtil.sslErrorFromNetErrorCode(certError, cert, url);
        final Callback<Boolean> callback =
                value ->
                        PostTask.runOrPostTask(
                                TaskTraits.UI_DEFAULT,
                                () -> proceedSslError(value.booleanValue(), id));
        // Post the application callback back to the current thread to ensure the application
        // callback is executed without any native code on the stack. This so that any exception
        // thrown by the application callback won't have to be propagated through a native call
        // stack.
        AwThreadUtils.postToCurrentLooper(() -> mClient.onReceivedSslError(callback, sslError));
        return true;
    }

    private void proceedSslError(boolean proceed, int id) {
        if (mNativeContentsClientBridge == 0) return;
        AwContentsClientBridgeJni.get()
                .proceedSslError(
                        mNativeContentsClientBridge, AwContentsClientBridge.this, proceed, id);
    }

    // Intentionally not private for testing the native peer of this class.
    @CalledByNative
    protected void selectClientCertificate(
            final int id,
            final String[] keyTypes,
            byte[][] encodedPrincipals,
            final String host,
            final int port) {
        assert mNativeContentsClientBridge != 0;
        ClientCertLookupTable.Cert cert = mLookupTable.getCertData(host, port);
        if (mLookupTable.isDenied(host, port)) {
            AwContentsClientBridgeJni.get()
                    .provideClientCertificateResponse(
                            mNativeContentsClientBridge,
                            AwContentsClientBridge.this,
                            id,
                            null,
                            null);
            return;
        }
        if (cert != null) {
            AwContentsClientBridgeJni.get()
                    .provideClientCertificateResponse(
                            mNativeContentsClientBridge,
                            AwContentsClientBridge.this,
                            id,
                            cert.mCertChain,
                            cert.mPrivateKey);
            return;
        }
        // Build the list of principals from encoded versions.
        Principal[] principals = null;
        if (encodedPrincipals.length > 0) {
            principals = new X500Principal[encodedPrincipals.length];
            for (int n = 0; n < encodedPrincipals.length; n++) {
                try {
                    principals[n] = new X500Principal(encodedPrincipals[n]);
                } catch (IllegalArgumentException e) {
                    Log.w(TAG, "Exception while decoding issuers list: " + e);
                    AwContentsClientBridgeJni.get()
                            .provideClientCertificateResponse(
                                    mNativeContentsClientBridge,
                                    AwContentsClientBridge.this,
                                    id,
                                    null,
                                    null);
                    return;
                }
            }
        }

        try (TraceEvent event =
                TraceEvent.scoped("WebView.APICallback.ON_RECEIVED_CLIENT_CERT_REQUEST")) {
            final ClientCertificateRequestCallback callback =
                    new ClientCertificateRequestCallback(id, host, port);
            mClient.onReceivedClientCertRequest(callback, keyTypes, principals, host, port);

            // Record UMA for onReceivedClientCertRequest.
            AwHistogramRecorder.recordCallbackInvocation(
                    AwHistogramRecorder.WebViewCallbackType.ON_RECEIVED_CLIENT_CERT_REQUEST);
        }
    }

    @CalledByNative
    private void handleJsAlert(final String url, final String message, final int id) {
        // Post the application callback back to the current thread to ensure the application
        // callback is executed without any native code on the stack. This so that any exception
        // thrown by the application callback won't have to be propagated through a native call
        // stack.
        AwThreadUtils.postToCurrentLooper(
                () -> {
                    JsResultHandler handler = new JsResultHandler(AwContentsClientBridge.this, id);
                    mClient.handleJsAlert(url, message, handler);
                });
    }

    @CalledByNative
    private void handleJsConfirm(final String url, final String message, final int id) {
        // Post the application callback back to the current thread to ensure the application
        // callback is executed without any native code on the stack. This so that any exception
        // thrown by the application callback won't have to be propagated through a native call
        // stack.
        AwThreadUtils.postToCurrentLooper(
                () -> {
                    JsResultHandler handler = new JsResultHandler(AwContentsClientBridge.this, id);
                    mClient.handleJsConfirm(url, message, handler);
                });
    }

    @CalledByNative
    private void handleJsPrompt(
            final String url, final String message, final String defaultValue, final int id) {
        // Post the application callback back to the current thread to ensure the application
        // callback is executed without any native code on the stack. This so that any exception
        // thrown by the application callback won't have to be propagated through a native call
        // stack.
        AwThreadUtils.postToCurrentLooper(
                () -> {
                    JsResultHandler handler = new JsResultHandler(AwContentsClientBridge.this, id);
                    mClient.handleJsPrompt(url, message, defaultValue, handler);
                });
    }

    @CalledByNative
    private void handleJsBeforeUnload(final String url, final String message, final int id) {
        // Post the application callback back to the current thread to ensure the application
        // callback is executed without any native code on the stack. This so that any exception
        // thrown by the application callback won't have to be propagated through a native call
        // stack.
        AwThreadUtils.postToCurrentLooper(
                () -> {
                    JsResultHandler handler = new JsResultHandler(AwContentsClientBridge.this, id);
                    mClient.handleJsBeforeUnload(url, message, handler);
                });
    }

    @CalledByNative
    private void newDownload(
            String url,
            String userAgent,
            String contentDisposition,
            String mimeType,
            long contentLength) {
        try (TraceEvent event = TraceEvent.scoped("WebView.APICallback.ON_DOWNLOAD_START")) {
            mClient.getCallbackHelper()
                    .postOnDownloadStart(
                            url, userAgent, contentDisposition, mimeType, contentLength);

            // Record UMA for onDownloadStart.
            AwHistogramRecorder.recordCallbackInvocation(
                    AwHistogramRecorder.WebViewCallbackType.ON_DOWNLOAD_START);
        }
    }

    @CalledByNative
    private void newLoginRequest(String realm, String account, String args) {
        try (TraceEvent event =
                TraceEvent.scoped("WebView.APICallback.ON_RECEIVED_LOGIN_REQUEST")) {
            mClient.getCallbackHelper().postOnReceivedLoginRequest(realm, account, args);

            // Record UMA for onReceivedLoginRequest.
            AwHistogramRecorder.recordCallbackInvocation(
                    AwHistogramRecorder.WebViewCallbackType.ON_RECEIVED_LOGIN_REQUEST);
        }
    }

    @CalledByNative
    private void onReceivedError(
            // WebResourceRequest
            String url,
            boolean isOutermostMainFrame,
            boolean hasUserGesture,
            boolean isRendererInitiated,
            String method,
            String[] requestHeaderNames,
            String[] requestHeaderValues,
            // WebResourceError
            @NetError int errorCode,
            String description,
            boolean safebrowsingHit,
            boolean shouldOmitNotificationsForSafeBrowsingHit) {
        AwContentsClient.AwWebResourceRequest request =
                new AwContentsClient.AwWebResourceRequest(
                        url,
                        isOutermostMainFrame,
                        hasUserGesture,
                        method,
                        requestHeaderNames,
                        requestHeaderValues);
        AwContentsClient.AwWebResourceError error = new AwContentsClient.AwWebResourceError();
        error.errorCode = ErrorCodeConversionHelper.convertErrorCode(errorCode);
        error.description = description;

        String unreachableWebDataUrl = AwContentsStatics.getUnreachableWebDataUrl();
        boolean isErrorUrl =
                unreachableWebDataUrl != null && unreachableWebDataUrl.equals(request.url);

        if ((!isErrorUrl && errorCode != NetError.ERR_ABORTED) || safebrowsingHit) {
            // NetError.ERR_ABORTED error code is generated for the following reasons:
            // - WebView.stopLoading is called;
            // - the navigation is intercepted by the embedder via shouldOverrideUrlLoading;
            // - server returned 204 status (no content).
            //
            // Android WebView does not notify the embedder of these situations using
            // this error code with the WebViewClient.onReceivedError callback.
            if (safebrowsingHit) {
                if (shouldOmitNotificationsForSafeBrowsingHit) {
                    // With committed interstitials we don't fire these notifications when the
                    // interstitial shows, we instead handle them once the interstitial is
                    // dismissed.
                    return;
                } else {
                    error.errorCode = WebviewErrorCode.ERROR_UNSAFE_RESOURCE;
                }
            }
            if (request.isOutermostMainFrame
                    && AwComputedFlags.pageStartedOnCommitEnabled(isRendererInitiated)) {
                mClient.getCallbackHelper().postOnPageStarted(request.url);
            }
            mClient.getCallbackHelper().postOnReceivedError(request, error);
            if (request.isOutermostMainFrame) {
                // Need to call onPageFinished after onReceivedError for backwards compatibility
                // with the classic webview. See also AwWebContentsObserver.didFailLoad which is
                // used when we want to send onPageFinished alone.
                mClient.getCallbackHelper().postOnPageFinished(request.url);
            }
        }
    }

    @CalledByNative
    public void onSafeBrowsingHit(
            // WebResourceRequest
            String url,
            boolean isOutermostMainFrame,
            boolean hasUserGesture,
            String method,
            String[] requestHeaderNames,
            String[] requestHeaderValues,
            int threatType,
            final int requestId) {
        AwContentsClient.AwWebResourceRequest request =
                new AwContentsClient.AwWebResourceRequest(
                        url,
                        isOutermostMainFrame,
                        hasUserGesture,
                        method,
                        requestHeaderNames,
                        requestHeaderValues);

        Callback<AwSafeBrowsingResponse> callback =
                response ->
                        PostTask.runOrPostTask(
                                TaskTraits.UI_DEFAULT,
                                () ->
                                        AwContentsClientBridgeJni.get()
                                                .takeSafeBrowsingAction(
                                                        mNativeContentsClientBridge,
                                                        AwContentsClientBridge.this,
                                                        response.action(),
                                                        response.reporting(),
                                                        requestId));

        int webViewThreatType = AwSafeBrowsingConversionHelper.convertThreatType(threatType);
        mClient.getCallbackHelper().postOnSafeBrowsingHit(request, webViewThreatType, callback);
    }

    @CalledByNative
    private void onReceivedHttpError(
            // WebResourceRequest
            String url,
            boolean isOutermostMainFrame,
            boolean hasUserGesture,
            String method,
            String[] requestHeaderNames,
            String[] requestHeaderValues,
            // WebResourceResponse
            String mimeType,
            String encoding,
            int statusCode,
            String reasonPhrase,
            String[] responseHeaderNames,
            String[] responseHeaderValues) {
        AwContentsClient.AwWebResourceRequest request =
                new AwContentsClient.AwWebResourceRequest(
                        url,
                        isOutermostMainFrame,
                        hasUserGesture,
                        method,
                        requestHeaderNames,
                        requestHeaderValues);
        Map<String, String> responseHeaders =
                new HashMap<String, String>(responseHeaderNames.length);
        // Note that we receive un-coalesced response header lines, thus we need to combine
        // values for the same header.
        for (int i = 0; i < responseHeaderNames.length; ++i) {
            if (!responseHeaders.containsKey(responseHeaderNames[i])) {
                responseHeaders.put(responseHeaderNames[i], responseHeaderValues[i]);
            } else if (!responseHeaderValues[i].isEmpty()) {
                String currentValue = responseHeaders.get(responseHeaderNames[i]);
                if (!currentValue.isEmpty()) {
                    currentValue += ", ";
                }
                responseHeaders.put(responseHeaderNames[i], currentValue + responseHeaderValues[i]);
            }
        }
        WebResourceResponseInfo response =
                new WebResourceResponseInfo(
                        mimeType, encoding, null, statusCode, reasonPhrase, responseHeaders);
        mClient.getCallbackHelper().postOnReceivedHttpError(request, response);
    }

    @CalledByNativeUnchecked
    private boolean shouldOverrideUrlLoading(
            String url,
            boolean hasUserGesture,
            boolean isRedirect,
            String[] requestHeaderNames,
            String[] requestHeaderValues,
            boolean isOutermostMainFrame) {
        HashMap<String, String> requestHeaders = null;
        if (requestHeaderNames.length > 0) {
            requestHeaders = new HashMap<String, String>(requestHeaderNames.length);
            for (int i = 0; i < requestHeaderNames.length; ++i) {
                assert !requestHeaders.containsKey(requestHeaderNames[i]);
                assert !requestHeaderValues[i].isEmpty();
                requestHeaders.put(requestHeaderNames[i], requestHeaderValues[i]);
            }
        }
        return mClient.shouldIgnoreNavigation(
                mContext, url, isOutermostMainFrame, hasUserGesture, requestHeaders, isRedirect);
    }

    @CalledByNative
    private boolean sendBrowseIntent(String url) {
        try {
            Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
            intent.addCategory(Intent.CATEGORY_BROWSABLE);
            intent.addCategory(Intent.CATEGORY_DEFAULT);
            if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
                intent.setFlags(
                        Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER
                                | Intent.FLAG_ACTIVITY_REQUIRE_DEFAULT);
            } else {
                ResolveInfo bestActivity = getBestActivityForIntent(intent);
                if (bestActivity == null) {
                    return false;
                }
                intent.setComponent(
                        new ComponentName(
                                bestActivity.activityInfo.packageName,
                                bestActivity.activityInfo.name));
            }
            mContext.startActivity(intent);
            return true;
        } catch (ActivityNotFoundException e) {
            Log.w(TAG, "Could not find an application to handle : %s", url);
        } catch (Exception e) {
            Log.e(TAG, "Exception while sending browse Intent.", e);
        }
        return false;
    }

    private ResolveInfo getBestActivityForIntent(Intent intent) {
        List<ResolveInfo> resolveInfos =
                mContext.getPackageManager()
                        .queryIntentActivities(intent, PackageManager.GET_RESOLVED_FILTER);

        ResolveInfo bestActivity = null;
        final int n = resolveInfos.size();

        if (n == 1) {
            bestActivity = resolveInfos.get(0);
        } else if (n > 1) {
            ResolveInfo r0 = resolveInfos.get(0);
            ResolveInfo r1 = resolveInfos.get(1);

            // If the first activity has a higher priority, or a different
            // default, then it is always desirable to pick it, else there is a tie
            // between the first and second activity and we cant choose one(best one).
            if (r0.priority > r1.priority
                    || r0.preferredOrder > r1.preferredOrder
                    || r0.isDefault != r1.isDefault) {
                bestActivity = resolveInfos.get(0);
            }
        }
        // Different cases due to which we return null from here
        // 1. There is no activity to handle this intent
        // 2. We can't come down to a single higher priority activity
        // 3. Best activity to handle is actually a browser.
        if (bestActivity == null || isBrowserApp(bestActivity)) {
            return null;
        }
        return bestActivity;
    }

    private boolean isBrowserApp(ResolveInfo ri) {
        if (ri.filter.hasCategory(Intent.CATEGORY_APP_BROWSER)
                || (ri.filter.hasDataScheme("http")
                        && ri.filter.hasDataScheme("https")
                        && ri.filter.countDataAuthorities() == 0)) {
            return true;
        }
        return false;
    }

    void confirmJsResult(int id, String prompt) {
        if (mNativeContentsClientBridge == 0) return;
        AwContentsClientBridgeJni.get()
                .confirmJsResult(
                        mNativeContentsClientBridge, AwContentsClientBridge.this, id, prompt);
    }

    void cancelJsResult(int id) {
        if (mNativeContentsClientBridge == 0) return;
        AwContentsClientBridgeJni.get()
                .cancelJsResult(mNativeContentsClientBridge, AwContentsClientBridge.this, id);
    }

    @NativeMethods
    interface Natives {
        void takeSafeBrowsingAction(
                long nativeAwContentsClientBridge,
                AwContentsClientBridge caller,
                int action,
                boolean reporting,
                int requestId);

        void proceedSslError(
                long nativeAwContentsClientBridge,
                AwContentsClientBridge caller,
                boolean proceed,
                int id);

        void provideClientCertificateResponse(
                long nativeAwContentsClientBridge,
                AwContentsClientBridge caller,
                int id,
                byte[][] certChain,
                PrivateKey androidKey);

        void confirmJsResult(
                long nativeAwContentsClientBridge,
                AwContentsClientBridge caller,
                int id,
                String prompt);

        void cancelJsResult(
                long nativeAwContentsClientBridge, AwContentsClientBridge caller, int id);
    }
}