chromium/components/browser_ui/modaldialog/android/java/src/org/chromium/components/browser_ui/modaldialog/TabModalPresenter.java

// Copyright 2020 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.components.browser_ui.modaldialog;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.content.Context;
import android.view.ContextThemeWrapper;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityEvent;
import android.widget.FrameLayout;

import androidx.activity.ComponentDialog;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;

import org.chromium.base.Callback;
import org.chromium.content_public.browser.SelectionPopupController;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.LayoutInflaterUtils;
import org.chromium.ui.interpolators.Interpolators;
import org.chromium.ui.modaldialog.DialogDismissalCause;
import org.chromium.ui.modaldialog.ModalDialogManager;
import org.chromium.ui.modaldialog.ModalDialogProperties;
import org.chromium.ui.modelutil.PropertyKey;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.modelutil.PropertyModelChangeProcessor;

/**
 * A base class for presenting a single tab modal dialog.
 *
 * Several abstract methods allow embedder-specific specializations.
 */
public abstract class TabModalPresenter extends ModalDialogManager.Presenter {
    /** Enter and exit animation duration. */
    private static final int ENTER_EXIT_ANIMATION_DURATION_MS = 200;

    private final Context mContext;

    private ViewGroup mDialogContainer;

    private ModalDialogView mDialogView;

    private PropertyModelChangeProcessor<PropertyModel, ModalDialogView, PropertyKey>
            mModelChangeProcessor;

    /** Whether the action bar on selected text is temporarily cleared for showing dialogs. */
    private boolean mDidClearTextControls;

    /**
     * Whether the dialog should gain focus for accessibility when in front, determined by the
     * dialog {@link ModalDialogProperties} FOCUS_DIALOG property.
     */
    private boolean mFocusDialog;

    private class ViewBinder extends ModalDialogViewBinder {
        @Override
        public void bind(PropertyModel model, ModalDialogView view, PropertyKey propertyKey) {
            if (ModalDialogProperties.CANCEL_ON_TOUCH_OUTSIDE == propertyKey) {
                assert mDialogContainer != null;
                if (model.get(ModalDialogProperties.CANCEL_ON_TOUCH_OUTSIDE)) {
                    mDialogContainer.setOnClickListener(
                            (v) -> {
                                dismissCurrentDialog(DialogDismissalCause.TOUCH_OUTSIDE);
                            });
                } else {
                    mDialogContainer.setOnClickListener(null);
                }
            } else if (ModalDialogProperties.FOCUS_DIALOG == propertyKey) {
                if (model.get(ModalDialogProperties.FOCUS_DIALOG)) {
                    mFocusDialog = true;
                }
            } else if (ModalDialogProperties.TAB_MODAL_DIALOG_CANCEL_ON_ESCAPE == propertyKey) {
                if (model.get(ModalDialogProperties.TAB_MODAL_DIALOG_CANCEL_ON_ESCAPE)) {
                    view.setOnEscapeCallback(
                            () -> {
                                dismissCurrentDialog(DialogDismissalCause.NAVIGATE_BACK);
                            });
                } else {
                    view.setOnEscapeCallback(null);
                }
            } else {
                super.bind(model, view, propertyKey);
            }
        }
    }

    /**
     * Constructor for initializing dialog container.
     * @param context The context for inflating UI.
     */
    public TabModalPresenter(Context context) {
        mContext = context;
    }

    /** @return a ViewGroup that will host {@link TabModalPresenter#mDialogView}. */
    protected abstract ViewGroup createDialogContainer();

    /** Called when {@link TabModalPresenter#mDialogContainer} should be displayed. */
    protected abstract void showDialogContainer();

    /**
     * Set whether the browser controls access should be restricted.
     *
     * This is called any time a dialog view is being shown or hidden and should update browser
     * state, e.g. breaking fullscreen or disabling certain browser controls as necessary.
     *
     * @param restricted Whether the browser controls access should be restricted.
     */
    protected abstract void setBrowserControlsAccess(boolean restricted);

    /** @return the container previously returned by {@link TabModalPresenter#createDialogContainer}. */
    protected ViewGroup getDialogContainer() {
        return mDialogContainer;
    }

    private ModalDialogView loadDialogView(int style) {
        return (ModalDialogView)
                LayoutInflaterUtils.inflate(
                        new ContextThemeWrapper(mContext, style), R.layout.modal_dialog_view, null);
    }

    @Override
    protected void addDialogView(
            PropertyModel model, @Nullable Callback<ComponentDialog> onDialogCreatedCallback) {
        if (mDialogContainer == null) mDialogContainer = createDialogContainer();

        model.set(ModalDialogProperties.TAB_MODAL_DIALOG_CANCEL_ON_ESCAPE, true);
        int style = R.style.ThemeOverlay_BrowserUI_ModalDialog_TextPrimaryButton;
        int buttonStyles = model.get(ModalDialogProperties.BUTTON_STYLES);
        if (buttonStyles == ModalDialogProperties.ButtonStyles.PRIMARY_FILLED_NEGATIVE_OUTLINE
                || buttonStyles == ModalDialogProperties.ButtonStyles.PRIMARY_FILLED_NO_NEGATIVE) {
            style = R.style.ThemeOverlay_BrowserUI_ModalDialog_FilledPrimaryButton;
        } else if (buttonStyles
                == ModalDialogProperties.ButtonStyles.PRIMARY_OUTLINE_NEGATIVE_FILLED) {
            style = R.style.ThemeOverlay_BrowserUI_ModalDialog_FilledNegativeButton;
        }
        mDialogView = loadDialogView(style);
        mModelChangeProcessor =
                PropertyModelChangeProcessor.create(model, mDialogView, new ViewBinder());
        if (onDialogCreatedCallback != null) {
            onDialogCreatedCallback.onResult(null);
        }

        setBrowserControlsAccess(true);

        showDialogContainer();
    }

    @Override
    protected void removeDialogView(PropertyModel model) {
        setBrowserControlsAccess(false);

        // The dialog view may not have been added to the container yet, e.g. if the enter animation
        // has not yet started.
        if (ViewCompat.isAttachedToWindow(mDialogView)) {
            runExitAnimation();
        } else {
            // Cancel any existing animations as when the animation completes it may try to make use
            // of objects that have been set to null.
            mDialogContainer.animate().cancel();
        }

        if (mModelChangeProcessor != null) {
            mModelChangeProcessor.destroy();
            mModelChangeProcessor = null;
        }
        mDialogView = null;
    }

    /**
     * Change view hierarchy for the dialog container to be either the front most or beneath the
     * toolbar.
     *
     * @param toFront Whether the dialog container should be brought to the front.
     */
    public void updateContainerHierarchy(boolean toFront) {
        if (toFront) {
            mDialogView.announceForAccessibility(getContentDescription(getDialogModel()));
            mDialogView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
            mDialogView.requestFocus();
            if (mFocusDialog) {
                mDialogView.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED);
            }
        } else {
            mDialogView.clearFocus();
            mDialogView.setImportantForAccessibility(
                    View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
        }
    }

    /**
     * Dismisses the text selection action bar that would otherwise obscure a visible dialog, but
     * preserves the text selection.
     *
     * @param webContents the WebContents that the dialog is showing over.
     * @param save true if a dialog is showing and text selection should be saved; false if a dialog
     *         is hiding and text selection should be restored.
     */
    protected void saveOrRestoreTextSelection(@NonNull WebContents webContents, boolean save) {
        if (save) {
            // Dismiss the action bar that obscures the dialogs but preserve the text selection.
            SelectionPopupController controller =
                    SelectionPopupController.fromWebContents(webContents);
            controller.setPreserveSelectionOnNextLossOfFocus(true);
            webContents.getViewAndroidDelegate().getContainerView().clearFocus();
            controller.updateTextSelectionUI(false);
            mDidClearTextControls = true;
        } else if (mDidClearTextControls) {
            // Show the action bar back if it was dismissed when the dialogs were showing.
            mDidClearTextControls = false;
            SelectionPopupController.fromWebContents(webContents).updateTextSelectionUI(true);
        }
    }

    /**
     * Inserts {@link TabModalPresenter#mDialogView} into {@link TabModalPresenter#mDialogContainer}
     * and animates the container into view.
     *
     * Exposed to subclasses as they may want to control the exact start time of the animation.
     */
    protected void runEnterAnimation() {
        mDialogContainer.animate().cancel();

        FrameLayout.LayoutParams params =
                new FrameLayout.LayoutParams(
                        ViewGroup.LayoutParams.MATCH_PARENT,
                        ViewGroup.LayoutParams.WRAP_CONTENT,
                        Gravity.CENTER);
        mDialogView.setBackgroundResource(R.drawable.dialog_bg_no_shadow);
        mDialogContainer.addView(mDialogView, params);
        mDialogContainer.setAlpha(0f);
        mDialogContainer.setVisibility(View.VISIBLE);
        mDialogContainer
                .animate()
                .setDuration(ENTER_EXIT_ANIMATION_DURATION_MS)
                .alpha(1f)
                .setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN_INTERPOLATOR)
                .setListener(
                        new AnimatorListenerAdapter() {
                            @Override
                            public void onAnimationStart(Animator animation) {
                                mDialogView.onEnterAnimationStarted(animation.getDuration());
                            }

                            @Override
                            public void onAnimationEnd(Animator animation) {
                                updateContainerHierarchy(true);
                            }
                        })
                .start();
    }

    private void runExitAnimation() {
        final View dialogView = mDialogView;
        // Clear focus so that keyboard can hide accordingly while entering tab switcher.
        dialogView.clearFocus();
        mDialogContainer.animate().cancel();
        mDialogContainer
                .animate()
                .setDuration(ENTER_EXIT_ANIMATION_DURATION_MS)
                .alpha(0f)
                .setInterpolator(Interpolators.FAST_OUT_LINEAR_IN_INTERPOLATOR)
                .setListener(
                        new AnimatorListenerAdapter() {
                            @Override
                            public void onAnimationEnd(Animator animation) {
                                mDialogContainer.setVisibility(View.GONE);
                                mDialogContainer.removeView(dialogView);
                            }
                        })
                .start();
    }

    public View getDialogContainerForTest() {
        return mDialogContainer;
    }
}