chromium/android_webview/java/src/org/chromium/android_webview/AwWebContentsDelegateAdapter.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.annotation.SuppressLint;
import android.content.Context;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Handler;
import android.os.Message;
import android.provider.MediaStore;
import android.text.TextUtils;
import android.util.Log;
import android.view.KeyEvent;
import android.view.View;
import android.webkit.URLUtil;
import android.widget.FrameLayout;

import org.chromium.android_webview.common.Lifetime;
import org.chromium.base.Callback;
import org.chromium.base.ContentUriUtils;
import org.chromium.base.ThreadUtils;
import org.chromium.base.task.AsyncTask;
import org.chromium.content_public.browser.InvalidateTypes;
import org.chromium.content_public.common.ContentUrlConstants;
import org.chromium.content_public.common.ResourceRequestBody;
import org.chromium.url.GURL;

/**
 * Adapts the AwWebContentsDelegate interface to the AwContentsClient interface.
 * This class also serves a secondary function of routing certain callbacks from the content layer
 * to specific listener interfaces.
 */
@Lifetime.WebView
class AwWebContentsDelegateAdapter extends AwWebContentsDelegate {
    private static final String TAG = "AwWebContentsDelegateAdapter";

    private final AwContents mAwContents;
    private final AwContentsClient mContentsClient;
    private final AwSettings mAwSettings;
    private final Context mContext;
    private View mContainerView;
    private FrameLayout mCustomView;
    private boolean mDidSynthesizePageLoad;

    public AwWebContentsDelegateAdapter(
            AwContents awContents,
            AwContentsClient contentsClient,
            AwSettings settings,
            Context context,
            View containerView) {
        mAwContents = awContents;
        mContentsClient = contentsClient;
        mAwSettings = settings;
        mContext = context;
        mDidSynthesizePageLoad = false;
        setContainerView(containerView);
    }

    public void setContainerView(View containerView) {
        mContainerView = containerView;
        mContainerView.setClickable(true);
    }

    @Override
    public void handleKeyboardEvent(KeyEvent event) {
        if (event.getAction() == KeyEvent.ACTION_DOWN) {
            int direction;
            switch (event.getKeyCode()) {
                case KeyEvent.KEYCODE_DPAD_DOWN:
                    direction = View.FOCUS_DOWN;
                    break;
                case KeyEvent.KEYCODE_DPAD_UP:
                    direction = View.FOCUS_UP;
                    break;
                case KeyEvent.KEYCODE_DPAD_LEFT:
                    direction = View.FOCUS_LEFT;
                    break;
                case KeyEvent.KEYCODE_DPAD_RIGHT:
                    direction = View.FOCUS_RIGHT;
                    break;
                default:
                    direction = 0;
                    break;
            }
            if (direction != 0 && tryToMoveFocus(direction)) return;
            if (AwKeyboardShortcuts.onKeyDown(event, mAwContents)) return;
        }
        handleMediaKey(event);
        mContentsClient.onUnhandledKeyEvent(event);
    }

    /**
     * Redispatches unhandled media keys. This allows bluetooth headphones with play/pause or
     * other buttons to function correctly.
     */
    private void handleMediaKey(KeyEvent e) {
        switch (e.getKeyCode()) {
            case KeyEvent.KEYCODE_MUTE:
            case KeyEvent.KEYCODE_HEADSETHOOK:
            case KeyEvent.KEYCODE_MEDIA_PLAY:
            case KeyEvent.KEYCODE_MEDIA_PAUSE:
            case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
            case KeyEvent.KEYCODE_MEDIA_STOP:
            case KeyEvent.KEYCODE_MEDIA_NEXT:
            case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
            case KeyEvent.KEYCODE_MEDIA_REWIND:
            case KeyEvent.KEYCODE_MEDIA_RECORD:
            case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD:
            case KeyEvent.KEYCODE_MEDIA_CLOSE:
            case KeyEvent.KEYCODE_MEDIA_EJECT:
            case KeyEvent.KEYCODE_MEDIA_AUDIO_TRACK:
                AudioManager am = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
                am.dispatchMediaKeyEvent(e);
                break;
            default:
                break;
        }
    }

    @Override
    public boolean takeFocus(boolean reverse) {
        int direction =
                (reverse == (mContainerView.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL))
                        ? View.FOCUS_RIGHT
                        : View.FOCUS_LEFT;
        if (tryToMoveFocus(direction)) return true;
        direction = reverse ? View.FOCUS_BACKWARD : View.FOCUS_FORWARD;
        return tryToMoveFocus(direction);
    }

    private boolean tryToMoveFocus(int direction) {
        View focus = mContainerView.focusSearch(direction);
        return focus != null && focus != mContainerView && focus.requestFocus();
    }

    @Override
    public boolean addMessageToConsole(int level, String message, int lineNumber, String sourceId) {
        @AwConsoleMessage.MessageLevel int messageLevel = AwConsoleMessage.MESSAGE_LEVEL_DEBUG;
        switch (level) {
            case LOG_LEVEL_TIP:
                messageLevel = AwConsoleMessage.MESSAGE_LEVEL_TIP;
                break;
            case LOG_LEVEL_LOG:
                messageLevel = AwConsoleMessage.MESSAGE_LEVEL_LOG;
                break;
            case LOG_LEVEL_WARNING:
                messageLevel = AwConsoleMessage.MESSAGE_LEVEL_WARNING;
                break;
            case LOG_LEVEL_ERROR:
                messageLevel = AwConsoleMessage.MESSAGE_LEVEL_ERROR;
                break;
            default:
                Log.w(TAG, "Unknown message level, defaulting to DEBUG");
                break;
        }
        boolean result =
                mContentsClient.onConsoleMessage(
                        new AwConsoleMessage(message, sourceId, lineNumber, messageLevel));
        return result;
    }

    @Override
    public void onUpdateUrl(GURL url) {
        // TODO: implement
    }

    @Override
    public void openNewTab(
            GURL url,
            String extraHeaders,
            ResourceRequestBody postData,
            int disposition,
            boolean isRendererInitiated) {
        // This is only called in chrome layers.
        assert false;
    }

    @Override
    public void closeContents() {
        mContentsClient.onCloseWindow();
    }

    @Override
    @SuppressLint("HandlerLeak")
    public void showRepostFormWarningDialog() {
        // TODO(mkosiba) We should be using something akin to the JsResultReceiver as the
        // callback parameter (instead of WebContents) and implement a way of converting
        // that to a pair of messages.
        final int msgContinuePendingReload = 1;
        final int msgCancelPendingReload = 2;

        final Handler handler =
                new Handler(ThreadUtils.getUiThreadLooper()) {
                    @Override
                    public void handleMessage(Message msg) {
                        if (mAwContents.getNavigationController() == null) return;

                        switch (msg.what) {
                            case msgContinuePendingReload:
                                {
                                    mAwContents.getNavigationController().continuePendingReload();
                                    break;
                                }
                            case msgCancelPendingReload:
                                {
                                    mAwContents.getNavigationController().cancelPendingReload();
                                    break;
                                }
                            default:
                                throw new IllegalStateException(
                                        "WebContentsDelegateAdapter: unhandled message "
                                                + msg.what);
                        }
                    }
                };

        Message resend = handler.obtainMessage(msgContinuePendingReload);
        Message dontResend = handler.obtainMessage(msgCancelPendingReload);
        mContentsClient.getCallbackHelper().postOnFormResubmission(dontResend, resend);
    }

    @Override
    public void runFileChooser(
            final int processId,
            final int renderId,
            final int modeFlags,
            String acceptTypes,
            String title,
            String defaultFilename,
            boolean capture) {
        int correctedModeFlags = FileModeConversionHelper.convertFileChooserMode(modeFlags);
        AwContentsClient.FileChooserParamsImpl params =
                new AwContentsClient.FileChooserParamsImpl(
                        correctedModeFlags, acceptTypes, title, defaultFilename, capture);

        mContentsClient.showFileChooser(
                new Callback<String[]>() {
                    boolean mCompleted;

                    @Override
                    public void onResult(String[] results) {
                        if (mCompleted) {
                            throw new IllegalStateException("Duplicate showFileChooser result");
                        }
                        mCompleted = true;
                        if (results == null) {
                            AwWebContentsDelegateJni.get()
                                    .filesSelectedInChooser(
                                            processId, renderId, correctedModeFlags, null, null);
                            return;
                        }
                        GetDisplayNameTask task =
                                new GetDisplayNameTask(
                                        mContext, processId, renderId, correctedModeFlags, results);
                        task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
                    }
                },
                params);
    }

    @Override
    public boolean addNewContents(boolean isDialog, boolean isUserGesture) {
        return mContentsClient.onCreateWindow(isDialog, isUserGesture);
    }

    @Override
    public void activateContents() {
        mContentsClient.onRequestFocus();
    }

    @Override
    public void navigationStateChanged(int flags) {
        // If this is a popup whose document has been accessed by script, hint
        // the client to show the last committed url through synthesizing a page
        // load, as it may be unsafe to show the pending entry. Since we want to
        // synthesize the page load only once for when the NavigationStateChange
        // call is triggered by the first initial main document access, the flag
        // must match InvalidateTypes.URL (the flag fired by
        // NavigationControllerImpl::DidAccessInitialMainDocument()) and we must
        // check whether a page load has previously been synthesized here.
        boolean shouldSynthesizePageLoad =
                mAwContents.isPopupWindow()
                        && mAwContents.hasAccessedInitialDocument()
                        && (flags == InvalidateTypes.URL)
                        && !mDidSynthesizePageLoad;
        if (shouldSynthesizePageLoad) {
            String url = mAwContents.getLastCommittedUrl();
            url = TextUtils.isEmpty(url) ? ContentUrlConstants.ABOUT_BLANK_DISPLAY_URL : url;
            mContentsClient.getCallbackHelper().postSynthesizedPageLoadingForUrlBarUpdate(url);
            mDidSynthesizePageLoad = true;
        }
    }

    @Override
    public void enterFullscreenModeForTab(boolean prefersNavigationBar, boolean prefersStatusBar) {
        enterFullscreen();
    }

    @Override
    public void exitFullscreenModeForTab() {
        exitFullscreen();
    }

    @Override
    public int getDisplayMode() {
        return mAwContents.getDisplayMode();
    }

    @Override
    public void loadingStateChanged() {
        mContentsClient.updateTitle(mAwContents.getTitle(), false);
    }

    /**
     * Called to show the web contents in fullscreen mode.
     *
     * <p>If entering fullscreen on a video element the web contents will contain just
     * the html5 video controls. {@link #enterFullscreenVideo(View)} will be called later
     * once the ContentVideoView, which contains the hardware accelerated fullscreen video,
     * is ready to be shown.
     */
    private void enterFullscreen() {
        if (mAwContents.isFullScreen()) {
            return;
        }
        View fullscreenView = mAwContents.enterFullScreen();
        if (fullscreenView == null) {
            return;
        }
        AwContentsClient.CustomViewCallback cb =
                () -> {
                    if (mCustomView != null) {
                        mAwContents.requestExitFullscreen();
                    }
                };
        mCustomView = new FrameLayout(mContext);
        mCustomView.addView(fullscreenView);
        mContentsClient.onShowCustomView(mCustomView, cb);
    }

    /** Called to show the web contents in embedded mode. */
    private void exitFullscreen() {
        if (mCustomView != null) {
            mCustomView = null;
            mAwContents.exitFullScreen();
            mContentsClient.onHideCustomView();
        }
    }

    @Override
    public boolean shouldBlockMediaRequest(GURL url) {
        return mAwSettings != null
                ? mAwSettings.getBlockNetworkLoads() && URLUtil.isNetworkUrl(url.getSpec())
                : true;
    }

    private static class GetDisplayNameTask extends AsyncTask<String[]> {
        final int mProcessId;
        final int mRenderId;
        final int mModeFlags;
        final String[] mFilePaths;

        // The task doesn't run long, so we don't gain anything from a weak ref.
        @SuppressLint("StaticFieldLeak")
        final Context mContext;

        public GetDisplayNameTask(
                Context context, int processId, int renderId, int modeFlags, String[] filePaths) {
            mProcessId = processId;
            mRenderId = renderId;
            mModeFlags = modeFlags;
            mFilePaths = filePaths;
            mContext = context;
        }

        @Override
        protected String[] doInBackground() {
            String[] displayNames = new String[mFilePaths.length];
            for (int i = 0; i < mFilePaths.length; i++) {
                displayNames[i] = resolveFileName(mFilePaths[i]);
            }
            return displayNames;
        }

        @Override
        protected void onPostExecute(String[] result) {
            AwWebContentsDelegateJni.get()
                    .filesSelectedInChooser(mProcessId, mRenderId, mModeFlags, mFilePaths, result);
        }

        /**
         * @return the display name of a path if it is a content URI and is present in the database
         * or an empty string otherwise.
         */
        private String resolveFileName(String filePath) {
            if (filePath == null) return "";
            Uri uri = Uri.parse(filePath);
            return ContentUriUtils.getDisplayName(
                    uri, mContext, MediaStore.MediaColumns.DISPLAY_NAME);
        }
    }
}