chromium/chrome/browser/ui/android/toolbar/java/src/org/chromium/chrome/browser/toolbar/top/tab_strip/TabStripTransitionCoordinator.java

// Copyright 2023 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.top.tab_strip;

import android.content.ComponentCallbacks;
import android.content.res.Configuration;
import android.os.Handler;
import android.os.Looper;
import android.util.DisplayMetrics;
import android.view.View;
import android.view.View.OnLayoutChangeListener;

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

import org.chromium.base.CallbackController;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.supplier.OneshotSupplier;
import org.chromium.chrome.browser.browser_controls.BrowserControlsVisibilityManager;
import org.chromium.chrome.browser.tab.TabObscuringHandler;
import org.chromium.chrome.browser.toolbar.ControlContainer;
import org.chromium.chrome.browser.toolbar.R;
import org.chromium.chrome.browser.toolbar.top.ToolbarLayout;
import org.chromium.chrome.browser.ui.desktop_windowing.AppHeaderState;
import org.chromium.chrome.browser.ui.desktop_windowing.AppHeaderUtils;
import org.chromium.chrome.browser.ui.desktop_windowing.DesktopWindowStateProvider;
import org.chromium.chrome.browser.ui.desktop_windowing.DesktopWindowStateProvider.AppHeaderObserver;
import org.chromium.ui.util.TokenHolder;

/** Class used to manage tab strip visibility and height updates. */
public class TabStripTransitionCoordinator implements ComponentCallbacks, AppHeaderObserver {
    static Integer sHeightTransitionThresholdForTesting;
    static Integer sFadeTransitionThresholdForTesting;

    // Delay to kickoff the transition to avoid frame drops while application is too busy when the
    // configuration changed.
    private static final int TRANSITION_DELAY_MS = 200;

    /** Observes height of tab strip that could change during run time. */
    // TODO(crbug.com/41481630): Rework the observer interface.
    public interface TabStripHeightObserver {
        /**
         * Called when the tab strip requests an update when control container changes its width.
         *
         * @param newHeight The expected height tab strip will be changed into.
         */
        default void onTransitionRequested(int newHeight) {}
    }

    /** Delegate to enforce tab strip updates when strip transition is requested. */
    public interface TabStripTransitionDelegate {
        /**
         * Called when the tab strip height changed. This height will match the space on top of the
         * toolbar reserved for the tab strip.
         *
         * @param newHeight The height same as {@link #getTabStripHeight()}.
         */
        default void onHeightChanged(int newHeight) {}

        /** Notify when the tab strip height transition is completed by the browser controls. */
        default void onHeightTransitionFinished() {}

        /**
         * Called when the tab strip visibility needs to be updated by updating the tab strip scrim
         * in-place.
         *
         * @param startOpacity The scrim opacity at the start of the transition.
         * @param endOpacity The scrim opacity at the end of the transition.
         * @param durationMs The duration of the transition animation, in ms.
         */
        default void onFadeTransitionRequested(
                float startOpacity, float endOpacity, int durationMs) {}
    }

    private final CallbackController mCallbackController = new CallbackController();
    private final Handler mHandler;
    private final ControlContainer mControlContainer;
    private final int mTabStripHeightFromResource;
    private final TabObscuringHandler mTabObscuringHandler;
    private final TokenHolder mDeferTransitionTokenHolder;
    private final int mTabStripReservedTopPadding;

    /**
     * Internal state used to block the transition until the TRANSITION_DELAY_MS after the last
     * #onLayout pass.
     */
    private int mOnLayoutToken = TokenHolder.INVALID_TOKEN;

    /** Token used to block the transition when URL bar has focus. */
    private int mUrlBarFocusToken = TokenHolder.INVALID_TOKEN;

    private int mTabObscureToken = TokenHolder.INVALID_TOKEN;

    /** Tracks the last width seen for the tab strip. */
    private int mTabStripWidth;

    /** Tracks the additional top padding added to the tab strip. */
    private int mTopPadding;

    private final @Nullable DesktopWindowStateProvider mDesktopWindowStateProvider;
    private @Nullable AppHeaderState mAppHeaderState;
    private boolean mForceUpdateHeight;

    private OnLayoutChangeListener mOnLayoutChangedListener;
    private TabObscuringHandler.Observer mTabObscuringHandlerObserver;
    private @Nullable Runnable mLayoutTransitionTask;

    // TODO (crbug.com/345849359): Create a base handler class to hold common members.
    private final HeightTransitionHandler mHeightTransitionHandler;
    private final FadeTransitionHandler mFadeTransitionHandler;

    /**
     * Create the coordinator to manage transitions to show / hide the tab strip.
     *
     * @param browserControlsVisibilityManager {@link BrowserControlsVisibilityManager} to observe
     *     browser controls height and animation state.
     * @param controlContainer The {@link ControlContainer} for the containing activity.
     * @param toolbarLayout {@link ToolbarLayout} for the current toolbar.
     * @param tabStripHeightFromResource The height of the tab strip defined in resource.
     * @param tabObscuringHandler Delegate object handling obscuring views.
     * @param desktopWindowStateProvider The {@link DesktopWindowStateProvider} instance.
     * @param tabStripTransitionDelegateSupplier Supplier for the {@link
     *     TabStripTransitionDelegate}.
     */
    public TabStripTransitionCoordinator(
            BrowserControlsVisibilityManager browserControlsVisibilityManager,
            ControlContainer controlContainer,
            View toolbarLayout,
            int tabStripHeightFromResource,
            TabObscuringHandler tabObscuringHandler,
            @Nullable DesktopWindowStateProvider desktopWindowStateProvider,
            OneshotSupplier<TabStripTransitionDelegate> tabStripTransitionDelegateSupplier) {
        mControlContainer = controlContainer;
        mTabStripHeightFromResource = tabStripHeightFromResource;
        mDesktopWindowStateProvider = desktopWindowStateProvider;
        mHandler = new Handler(Looper.getMainLooper());
        mHeightTransitionHandler =
                new HeightTransitionHandler(
                        browserControlsVisibilityManager,
                        controlContainer,
                        toolbarLayout,
                        tabStripHeightFromResource,
                        mCallbackController,
                        mHandler,
                        tabStripTransitionDelegateSupplier);
        mFadeTransitionHandler =
                new FadeTransitionHandler(tabStripTransitionDelegateSupplier, mCallbackController);

        mTabStripReservedTopPadding =
                controlContainerView()
                        .getResources()
                        .getDimensionPixelSize(R.dimen.tab_strip_reserved_top_padding);

        mOnLayoutChangedListener =
                (view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
                    int windowWidth = Math.abs(right - left);
                    onLayoutWidthChanged(windowWidth);
                };
        controlContainerView().addOnLayoutChangeListener(mOnLayoutChangedListener);
        mDeferTransitionTokenHolder =
                new TokenHolder(mCallbackController.makeCancelable(this::onTokenUpdate));

        mTabObscuringHandler = tabObscuringHandler;
        mTabObscuringHandlerObserver =
                (obscureTabContent, obscureToolbar) -> {
                    // Do not block transition if the toolbar is also obscured.
                    if (obscureToolbar) return;

                    if (obscureTabContent) {
                        int token = requestDeferTabStripTransitionToken();
                        if (mTabObscureToken != TokenHolder.INVALID_TOKEN) {
                            releaseTabStripToken(mTabObscureToken);
                        }
                        mTabObscureToken = token;
                    } else {
                        releaseTabStripToken(mTabObscureToken);
                        mTabObscureToken = TokenHolder.INVALID_TOKEN;
                    }
                };
        mTabObscuringHandler.addObserver(mTabObscuringHandlerObserver);

        updateTabStripTransitionThreshold();

        AppHeaderState appHeaderState = null;
        if (mDesktopWindowStateProvider != null) {
            mDesktopWindowStateProvider.addObserver(this);
            appHeaderState = mDesktopWindowStateProvider.getAppHeaderState();
        }

        // Initialize the tab strip size based on whether we have app header.
        if (appHeaderState != null) {
            onAppHeaderStateChanged(appHeaderState);
        } else {
            onLayoutWidthChanged(controlContainerView().getWidth());
        }
    }

    // ComponentCallbacks implementation.

    @Override
    public void onConfigurationChanged(@NonNull Configuration newConfig) {
        updateTabStripTransitionThreshold();
    }

    @Override
    public void onLowMemory() {}

    // DesktopWindowStateProvider.AppHeaderObserver implementation.

    @Override
    public void onAppHeaderStateChanged(AppHeaderState newState) {
        assert mDesktopWindowStateProvider != null;
        assert newState != null;

        boolean wasInDesktopWindow = mAppHeaderState != null && mAppHeaderState.isInDesktopWindow();
        boolean isInDesktopWindow = newState.isInDesktopWindow();

        // Force trigger the strip height transition when the app is switching desktop windowing
        // mode, to update the strip top padding.
        mForceUpdateHeight = wasInDesktopWindow != isInDesktopWindow;
        mHeightTransitionHandler.setForceUpdateHeight(mForceUpdateHeight);

        mAppHeaderState = newState;
        if (mAppHeaderState.isInDesktopWindow()) {
            int height = mAppHeaderState.getAppHeaderHeight();
            int topPadding =
                    Math.max(mTabStripReservedTopPadding, height - mTabStripHeightFromResource);
            onTabStripSizeChanged(mAppHeaderState.getUnoccludedRectWidth(), topPadding);
        } else {
            onTabStripSizeChanged(controlContainerView().getWidth(), 0);
        }
    }

    /** Remove observers and release reference to dependencies. */
    public void destroy() {
        if (mOnLayoutChangedListener != null) {
            controlContainerView().removeOnLayoutChangeListener(mOnLayoutChangedListener);
            mOnLayoutChangedListener = null;
        }
        if (mDesktopWindowStateProvider != null) {
            mDesktopWindowStateProvider.removeObserver(this);
        }
        if (mTabObscuringHandlerObserver != null) {
            mTabObscuringHandler.removeObserver(mTabObscuringHandlerObserver);
            mTabObscuringHandlerObserver = null;
        }
        mCallbackController.destroy();
        mHeightTransitionHandler.destroy();
    }

    /** Return the current tab strip height. */
    public int getTabStripHeight() {
        return mHeightTransitionHandler.getTabStripHeight();
    }

    /** Add observer for tab strip height change. */
    public void addObserver(TabStripHeightObserver observer) {
        mHeightTransitionHandler.addObserver(observer);
    }

    // Tab strip height transition implementation methods.

    /** Remove observer for tab strip height change. */
    public void removeObserver(TabStripHeightObserver observer) {
        mHeightTransitionHandler.removeObserver(observer);
    }

    /** Request the token to defer the tab strip transition to a later time. */
    public int requestDeferTabStripTransitionToken() {
        return mDeferTransitionTokenHolder.acquireToken();
    }

    /**
     * Release the token acquired from {@link #requestDeferTabStripTransitionToken()} so the tab
     * strip can transition based on its current size.
     */
    public void releaseTabStripToken(int token) {
        mDeferTransitionTokenHolder.releaseToken(token);
    }

    /**
     * Called when URL bar gains / lost focus. When gaining focus, block the tab strip transition.
     */
    // TODO(crbug.com/41492673): Remove this APIs - location bar is also using TabObscuringHandler.
    public void onUrlFocusChange(boolean hasFocus) {
        if (hasFocus) {
            int token = requestDeferTabStripTransitionToken();
            if (mUrlBarFocusToken != TokenHolder.INVALID_TOKEN) {
                releaseTabStripToken(mUrlBarFocusToken);
            }
            mUrlBarFocusToken = token;
        }
    }

    /** Called when URL bar focus animation finished. Release the token for tab strip transition. */
    public void onUrlAnimationFinished(boolean hasFocus) {
        if (!hasFocus) {
            releaseTabStripToken(mUrlBarFocusToken);
            mUrlBarFocusToken = TokenHolder.INVALID_TOKEN;
        }
    }

    private void onTokenUpdate() {
        // Block new request for transitions as long as there's any token left.
        if (mDeferTransitionTokenHolder.hasTokens()) return;

        boolean isInDesktopWindow =
                AppHeaderUtils.isAppInDesktopWindow(mDesktopWindowStateProvider);
        if (!isInDesktopWindow || mForceUpdateHeight) {
            mHeightTransitionHandler.requestTransition();
            // Reset internal state after use.
            mForceUpdateHeight = false;
        }
        if (isInDesktopWindow) {
            mFadeTransitionHandler.requestTransition();
        }
    }

    private void updateTabStripTransitionThreshold() {
        DisplayMetrics displayMetrics = controlContainerView().getResources().getDisplayMetrics();
        mHeightTransitionHandler.updateTabStripTransitionThreshold(displayMetrics);
        mFadeTransitionHandler.updateTabStripTransitionThreshold(displayMetrics);
    }

    private View controlContainerView() {
        return mControlContainer.getView();
    }

    private void onLayoutWidthChanged(int newWidth) {
        // If mAppHeaderState exists, check the widestUnoccludedRect too. This is needed as
        // updates in mAppHeaderState can happen prior / during a layout pass, while the
        // transition needs to wait until UI is in a stable state.
        if (mAppHeaderState != null && mAppHeaderState.getUnoccludedRectWidth() > 0) {
            newWidth = Math.min(newWidth, mAppHeaderState.getUnoccludedRectWidth());
        }

        onTabStripSizeChanged(newWidth, mTopPadding);
    }

    /**
     * Always wait for a short delay after the last #onLayout pass for the control container to make
     * sure the UI is in a stable state.
     *
     * @param width The current width of tab strip.
     * @param topPadding The top padding to be added to the tab strip.
     */
    private void onTabStripSizeChanged(int width, int topPadding) {
        if (width == mTabStripWidth && topPadding == mTopPadding) return;
        mTabStripWidth = width;
        mTopPadding = topPadding;
        mHeightTransitionHandler.setTabStripSize(width, topPadding);
        mFadeTransitionHandler.setTabStripSize(width);

        AppHeaderUtils.recordDesktopWindowModeStateEnumHistogram(
                mDesktopWindowStateProvider,
                "Android.DynamicTopChrome.WindowResize.DesktopWindowModeState");

        if (mLayoutTransitionTask != null) {
            mHandler.removeCallbacks(mLayoutTransitionTask);
        }
        int oldToken = mOnLayoutToken;
        mOnLayoutToken = mDeferTransitionTokenHolder.acquireToken();
        mDeferTransitionTokenHolder.releaseToken(oldToken);
        mLayoutTransitionTask =
                mCallbackController.makeCancelable(
                        () -> mDeferTransitionTokenHolder.releaseToken(mOnLayoutToken));
        mHandler.postDelayed(mLayoutTransitionTask, TRANSITION_DELAY_MS);
    }

    // Testing methods.

    /**
     * Set the tab strip height transition threshold for testing.
     *
     * @param transitionThresholdForTesting Threshold for the tab strip to become visible.
     */
    public static void setHeightTransitionThresholdForTesting(int transitionThresholdForTesting) {
        sHeightTransitionThresholdForTesting = transitionThresholdForTesting;
        ResettersForTesting.register(() -> sHeightTransitionThresholdForTesting = null);
    }

    /**
     * Set the tab strip fade transition threshold for testing.
     *
     * @param transitionThresholdForTesting Threshold for the tab strip to become visible.
     */
    public static void setFadeTransitionThresholdForTesting(int transitionThresholdForTesting) {
        sFadeTransitionThresholdForTesting = transitionThresholdForTesting;
        ResettersForTesting.register(() -> sFadeTransitionThresholdForTesting = null);
    }
}