chromium/chrome/browser/ui/android/toolbar/java/src/org/chromium/chrome/browser/toolbar/bottom/BottomControlsMediator.java

// Copyright 2019 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.chrome.browser.toolbar.bottom;

import androidx.annotation.ColorInt;
import androidx.annotation.Nullable;

import org.chromium.base.CallbackController;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.browser.browser_controls.BottomControlsLayer;
import org.chromium.chrome.browser.browser_controls.BottomControlsStacker;
import org.chromium.chrome.browser.browser_controls.BottomControlsStacker.LayerScrollBehavior;
import org.chromium.chrome.browser.browser_controls.BottomControlsStacker.LayerType;
import org.chromium.chrome.browser.browser_controls.BottomControlsStacker.LayerVisibility;
import org.chromium.chrome.browser.browser_controls.BrowserControlsStateProvider;
import org.chromium.chrome.browser.browser_controls.BrowserStateBrowserControlsVisibilityDelegate;
import org.chromium.chrome.browser.fullscreen.FullscreenManager;
import org.chromium.chrome.browser.layouts.LayoutStateProvider;
import org.chromium.chrome.browser.layouts.LayoutStateProvider.LayoutStateObserver;
import org.chromium.chrome.browser.layouts.LayoutType;
import org.chromium.chrome.browser.tab.TabObscuringHandler;
import org.chromium.chrome.browser.ui.edge_to_edge.EdgeToEdgeController;
import org.chromium.chrome.browser.ui.edge_to_edge.EdgeToEdgeSupplier.ChangeObserver;
import org.chromium.chrome.browser.ui.edge_to_edge.EdgeToEdgeUtils;
import org.chromium.ui.KeyboardVisibilityDelegate;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.modelutil.PropertyModel;

/**
 * This class is responsible for reacting to events from the outside world, interacting with other
 * coordinators, running most of the business logic associated with the bottom controls component,
 * and updating the model accordingly.
 */
class BottomControlsMediator
        implements BrowserControlsStateProvider.Observer,
                KeyboardVisibilityDelegate.KeyboardVisibilityListener,
                LayoutStateObserver,
                TabObscuringHandler.Observer,
                BottomControlsLayer {
    private static final String TAG = "BotControlsMediator";

    /** The model for the bottom controls component that holds all of its view state. */
    private final PropertyModel mModel;

    /** The fullscreen manager to observe fullscreen events. */
    private final FullscreenManager mFullscreenManager;

    /** The browser controls sizer/manager to observe browser controls events. */
    private final BottomControlsStacker mBottomControlsStacker;

    private final BrowserStateBrowserControlsVisibilityDelegate mBrowserControlsVisibilityDelegate;
    private final TabObscuringHandler mTabObscuringHandler;

    private final CallbackController mCallbackController;

    private final ObservableSupplier<EdgeToEdgeController> mEdgeToEdgeControllerSupplier;

    private final Supplier<Boolean> mReadAloudRestoringSupplier;

    /** The height of the bottom bar in pixels, not including the top shadow. */
    private int mBottomControlsHeight;

    /** A {@link WindowAndroid} for watching keyboard visibility events. */
    private final WindowAndroid mWindowAndroid;

    /** The bottom controls visibility. */
    private boolean mIsBottomControlsVisible;

    /** The background color for the bottom controls. */
    private @ColorInt int mBottomControlsColor;

    /** Whether any overlay panel is showing. */
    private boolean mIsOverlayPanelShowing;

    /** Whether the swipe layout is currently active. */
    private boolean mIsInSwipeLayout;

    /** Whether the soft keyboard is visible. */
    private boolean mIsKeyboardVisible;

    private LayoutStateProvider mLayoutStateProvider;

    @Nullable private ChangeObserver mEdgeToEdgeChangeObserver;
    private int mEdgeToEdgePaddingPx;

    /**
     * Build a new mediator that handles events from outside the bottom controls component.
     *
     * @param windowAndroid A {@link WindowAndroid} for watching keyboard visibility events.
     * @param model The {@link BottomControlsProperties} that holds all the view state for the
     *     bottom controls component.
     * @param controlsStacker The {@link BottomControlsStacker} to manipulate browser controls.
     * @param fullscreenManager A {@link FullscreenManager} for events related to the browser
     *     controls.
     * @param tabObscuringHandler Delegate object handling obscuring views.
     * @param bottomControlsHeight The height of the bottom bar in pixels.
     * @param overlayPanelVisibilitySupplier Notifies overlay panel visibility event.
     * @param edgeToEdgeControllerSupplier Supplies an {@link EdgeToEdgeController} to adjust the
     *     height of the bottom controls when drawing all the way to the edge of the screen.
     * @param readAloudRestoringSupplier Supplier that returns true if Read Aloud is currently
     *     restoring its player, e.g. after theme change.
     */
    BottomControlsMediator(
            WindowAndroid windowAndroid,
            PropertyModel model,
            BottomControlsStacker controlsStacker,
            BrowserStateBrowserControlsVisibilityDelegate browserControlsVisibilityDelegate,
            FullscreenManager fullscreenManager,
            TabObscuringHandler tabObscuringHandler,
            int bottomControlsHeight,
            ObservableSupplier<Boolean> overlayPanelVisibilitySupplier,
            ObservableSupplier<EdgeToEdgeController> edgeToEdgeControllerSupplier,
            Supplier<Boolean> readAloudRestoringSupplier) {
        mModel = model;

        mFullscreenManager = fullscreenManager;
        mBottomControlsStacker = controlsStacker;
        getBrowserControls().addObserver(this);
        mBrowserControlsVisibilityDelegate = browserControlsVisibilityDelegate;
        mTabObscuringHandler = tabObscuringHandler;
        tabObscuringHandler.addObserver(this);

        mBottomControlsHeight = bottomControlsHeight;
        mCallbackController = new CallbackController();
        overlayPanelVisibilitySupplier.addObserver(
                mCallbackController.makeCancelable(
                        (showing) -> {
                            mIsOverlayPanelShowing = showing;
                            updateAndroidViewVisibility();
                        }));

        // Watch for keyboard events so we can hide the bottom toolbar when the keyboard is showing.
        mWindowAndroid = windowAndroid;
        mWindowAndroid.getKeyboardDelegate().addKeyboardVisibilityListener(this);

        mEdgeToEdgeControllerSupplier = edgeToEdgeControllerSupplier;
        if (mEdgeToEdgeControllerSupplier.get() != null) {
            mEdgeToEdgeChangeObserver = this::onEdgeToEdgeChanged;
            mEdgeToEdgeControllerSupplier.get().registerObserver(mEdgeToEdgeChangeObserver);
        }
        mReadAloudRestoringSupplier = readAloudRestoringSupplier;
        mBottomControlsStacker.addLayer(this);
    }

    void setLayoutStateProvider(LayoutStateProvider layoutStateProvider) {
        mLayoutStateProvider = layoutStateProvider;
        layoutStateProvider.addObserver(this);
    }

    void setBottomControlsVisible(boolean visible) {
        boolean visibilityChanged = mIsBottomControlsVisible != visible;
        mIsBottomControlsVisible = visible;
        updateCompositedViewVisibility();
        updateAndroidViewVisibility();

        // When tab group UI changed from hidden -> visible, request browser controls to show
        // transiently. This is a workaround to when tab is opened in background with a new tab
        // group, the offsets in TabBrowserControlsOffsetHelper is stale. See crbug.com/357398783
        if (visible && visibilityChanged) {
            mBrowserControlsVisibilityDelegate.showControlsTransient();
        }
    }

    void setBottomControlsColor(@ColorInt int color) {
        mBottomControlsColor = color;
    }

    /** Clean up anything that needs to be when the bottom controls component is destroyed. */
    void destroy() {
        mCallbackController.destroy();
        getBrowserControls().removeObserver(this);
        mBottomControlsStacker.removeLayer(this);
        mWindowAndroid.getKeyboardDelegate().removeKeyboardVisibilityListener(this);
        if (mLayoutStateProvider != null) {
            mLayoutStateProvider.removeObserver(this);
            mLayoutStateProvider = null;
        }
        if (mEdgeToEdgeControllerSupplier.get() != null && mEdgeToEdgeChangeObserver != null) {
            mEdgeToEdgeControllerSupplier.get().unregisterObserver(mEdgeToEdgeChangeObserver);
            mEdgeToEdgeChangeObserver = null;
        }
        mTabObscuringHandler.removeObserver(this);
    }

    @Override
    public void onControlsOffsetChanged(
            int topOffset,
            int topControlsMinHeightOffset,
            int bottomOffset,
            int bottomControlsMinHeightOffset,
            boolean needsAnimate,
            boolean isVisibilityForced) {
        // Method call routed to onBrowserControlsOffsetUpdate.
        if (BottomControlsStacker.isDispatchingYOffset()) return;

        setYOffset(bottomOffset - getBrowserControls().getBottomControlsMinHeight());
    }

    @Override
    public void onBottomControlsHeightChanged(
            int bottomControlsHeight, int bottomControlsMinHeight) {
        // TODO(331829509): Set position in a way that doesn't rely on browser controls size system.
        // Normally our Android view is translated at the end of bottom controls min height
        // animations to place its bottom edge at the min height. This doesn't work during theme
        // change because onControlsOffsetChanged() is never called in that case. Instead we have
        // this special case to make sure the bottom controls aren't covered by the Read Aloud
        // player when it is shown again following browser UI being recreated.
        if (mReadAloudRestoringSupplier.get()) {
            mModel.set(
                    BottomControlsProperties.ANDROID_VIEW_TRANSLATE_Y,
                    mModel.get(BottomControlsProperties.Y_OFFSET));
        }
        // A min height greater than 0 suggests the presence of some other UI component underneath
        // the bottom controls.
        if (bottomControlsMinHeight == 0) {
            mBottomControlsStacker.notifyBackgroundColor(mBottomControlsColor);
        }
    }

    @Override
    public void keyboardVisibilityChanged(boolean isShowing) {
        mIsKeyboardVisible = isShowing;
        updateCompositedViewVisibility();
        updateAndroidViewVisibility();
    }

    // LayoutStateObserver

    @Override
    public void onStartedShowing(@LayoutType int layoutType) {
        mIsInSwipeLayout = layoutType == LayoutType.TOOLBAR_SWIPE;
        updateAndroidViewVisibility();
    }

    private void onEdgeToEdgeChanged(
            int bottomInset, boolean isDrawingToEdge, boolean isPageOptInToEdge) {
        mEdgeToEdgePaddingPx = isDrawingToEdge ? bottomInset : 0;

        updateBrowserControlsHeight();

        int androidViewHeight = getAndroidViewHeight();
        if (androidViewHeight != mModel.get(BottomControlsProperties.ANDROID_VIEW_HEIGHT)) {
            mModel.set(BottomControlsProperties.ANDROID_VIEW_HEIGHT, androidViewHeight);
        }
    }

    /**
     * @return Whether the browser is currently in fullscreen mode.
     */
    private boolean isInFullscreenMode() {
        return mFullscreenManager != null && mFullscreenManager.getPersistentFullscreenMode();
    }

    private void setYOffset(int yOffset) {
        mModel.set(BottomControlsProperties.Y_OFFSET, yOffset);

        // This call also updates the view's position if the animation has just finished.
        updateAndroidViewVisibility();
    }

    /**
     * The composited view is the composited version of the Android View. It is used to be able to
     * scroll the bottom controls off-screen synchronously. Since the bottom controls live below the
     * webcontents we re-size the webcontents through {@link
     * BottomControlsStacker#setBottomControlsHeight(int, int, boolean)} whenever the composited
     * view visibility changes.
     */
    private void updateCompositedViewVisibility() {
        final boolean isCompositedViewVisible = isCompositedViewVisible();
        mModel.set(BottomControlsProperties.COMPOSITED_VIEW_VISIBLE, isCompositedViewVisible);
        updateBrowserControlsHeight();
    }

    private int getBrowserControlsHeight() {
        int minHeight = getBrowserControls().getBottomControlsMinHeight();
        int androidViewHeight = getAndroidViewHeight();

        return isCompositedViewVisible() ? androidViewHeight + minHeight : minHeight;
    }

    private int getAndroidViewHeight() {
        int edgeToEdgePadding = 0;

        if (mEdgeToEdgeControllerSupplier.get() != null
                && !EdgeToEdgeUtils.isEdgeToEdgeBottomChinEnabled()) {
            // TODO(https://crbug.com/327274751): Account for presence of Read Aloud when
            // determining bottom controls height.
            edgeToEdgePadding = mEdgeToEdgePaddingPx;
        }

        return mBottomControlsHeight + edgeToEdgePadding;
    }

    private void updateBrowserControlsHeight() {
        mBottomControlsStacker.setBottomControlsHeight(
                getBrowserControlsHeight(),
                getBrowserControls().getBottomControlsMinHeight(),
                false);
    }

    boolean isCompositedViewVisible() {
        return mIsBottomControlsVisible && !mIsKeyboardVisible && !isInFullscreenMode();
    }

    /**
     * The Android View is the interactive view. The composited view should always be behind the
     * Android view which means we hide the Android view whenever the composited view is hidden. We
     * also hide the Android view as we are scrolling the bottom controls off screen this is done by
     * checking if {@link BrowserControlsStateProvider#getBottomControlOffset()} is non-zero.
     */
    private void updateAndroidViewVisibility() {
        final boolean visible =
                isCompositedViewVisible()
                        && !mIsOverlayPanelShowing
                        && !mIsInSwipeLayout
                        && getBrowserControls().getBottomControlOffset() == 0;
        if (visible) {
            // Translate view so that its bottom is aligned with the "base" y_offset, or the
            // y_offset when the bottom controls aren't offset.
            mModel.set(
                    BottomControlsProperties.ANDROID_VIEW_TRANSLATE_Y,
                    mModel.get(BottomControlsProperties.Y_OFFSET));
        }
        mModel.set(BottomControlsProperties.ANDROID_VIEW_VISIBLE, visible);
    }

    @Override
    public void updateObscured(boolean obscureTabContent, boolean obscureToolbar) {
        mModel.set(BottomControlsProperties.IS_OBSCURED, obscureToolbar);
    }

    private BrowserControlsStateProvider getBrowserControls() {
        return mBottomControlsStacker.getBrowserControls();
    }

    // Implements BottomControlsLayer

    @Override
    public int getType() {
        return LayerType.BOTTOM_TOOLBAR;
    }

    @Override
    public int getHeight() {
        return getAndroidViewHeight();
    }

    @Override
    public @LayerScrollBehavior int getScrollBehavior() {
        return LayerScrollBehavior.ALWAYS_SCROLL_OFF;
    }

    @Override
    public @LayerVisibility int getLayerVisibility() {
        return isCompositedViewVisible() ? LayerVisibility.VISIBLE : LayerVisibility.HIDDEN;
    }

    @Override
    public void onBrowserControlsOffsetUpdate(int layerYOffset) {
        assert BottomControlsStacker.isDispatchingYOffset();
        setYOffset(layerYOffset);
    }

    ChangeObserver getEdgeToEdgeChangeObserverForTesting() {
        return mEdgeToEdgeChangeObserver;
    }

    void simulateEdgeToEdgeChangeForTesting(
            int bottomInset, boolean isDrawingToEdge, boolean isPageOptedIntoEdgeToEdge) {
        onEdgeToEdgeChanged(bottomInset, isDrawingToEdge, isPageOptedIntoEdgeToEdge);
    }
}