chromium/chrome/android/features/tab_ui/java/src/org/chromium/chrome/browser/tasks/tab_management/TabSwitcherPaneBase.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.tasks.tab_management;

import static org.chromium.chrome.browser.hub.HubLayoutConstants.SHRINK_EXPAND_DURATION_MS;
import static org.chromium.chrome.browser.tasks.tab_management.TabSwitcherConstants.DESTROY_COORDINATOR_DELAY_MS;
import static org.chromium.chrome.browser.tasks.tab_management.TabSwitcherConstants.HARD_CLEANUP_DELAY_MS;
import static org.chromium.chrome.browser.tasks.tab_management.TabSwitcherConstants.SOFT_CLEANUP_DELAY_MS;

import android.content.Context;
import android.graphics.Rect;
import android.os.Handler;
import android.os.SystemClock;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;

import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.Callback;
import org.chromium.base.Log;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.base.supplier.OneshotSupplier;
import org.chromium.base.supplier.Supplier;
import org.chromium.base.supplier.SyncOneshotSupplier;
import org.chromium.base.supplier.SyncOneshotSupplierImpl;
import org.chromium.base.supplier.TransitiveObservableSupplier;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.build.BuildConfig;
import org.chromium.chrome.browser.feature_engagement.TrackerFactory;
import org.chromium.chrome.browser.hub.DisplayButtonData;
import org.chromium.chrome.browser.hub.FadeHubLayoutAnimationFactory;
import org.chromium.chrome.browser.hub.FullButtonData;
import org.chromium.chrome.browser.hub.HubContainerView;
import org.chromium.chrome.browser.hub.HubFieldTrial;
import org.chromium.chrome.browser.hub.HubLayoutAnimationListener;
import org.chromium.chrome.browser.hub.HubLayoutAnimatorProvider;
import org.chromium.chrome.browser.hub.HubLayoutConstants;
import org.chromium.chrome.browser.hub.LoadHint;
import org.chromium.chrome.browser.hub.Pane;
import org.chromium.chrome.browser.hub.PaneHubController;
import org.chromium.chrome.browser.hub.ShrinkExpandAnimationData;
import org.chromium.chrome.browser.hub.ShrinkExpandHubLayoutAnimationFactory;
import org.chromium.chrome.browser.profiles.ProfileProvider;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab_ui.RecyclerViewPosition;
import org.chromium.chrome.browser.tab_ui.TabSwitcher;
import org.chromium.chrome.browser.tab_ui.TabSwitcherCustomViewManager;
import org.chromium.chrome.browser.tasks.tab_management.TabListCoordinator.TabListMode;
import org.chromium.chrome.browser.ui.edge_to_edge.EdgeToEdgeUtils;
import org.chromium.chrome.browser.user_education.IPHCommand;
import org.chromium.chrome.browser.user_education.IPHCommandBuilder;
import org.chromium.chrome.browser.user_education.UserEducationHelper;
import org.chromium.chrome.tab_ui.R;
import org.chromium.components.browser_ui.styles.ChromeColors;
import org.chromium.components.browser_ui.widget.MenuOrKeyboardActionController.MenuOrKeyboardActionHandler;
import org.chromium.components.feature_engagement.FeatureConstants;
import org.chromium.components.feature_engagement.Tracker;
import org.chromium.ui.base.DeviceFormFactor;

import java.util.List;
import java.util.function.DoubleConsumer;

/**
 * An abstract {@link Pane} representing a tab switcher for shared logic between the normal and
 * incognito modes.
 */
public abstract class TabSwitcherPaneBase implements Pane, TabSwitcher, TabSwitcherResetHandler {
    private static final String TAG = "TabSwitcherPaneBase";
    private static final int ON_SHOWN_IPH_DELAY = 700;

    private static boolean sShowIphForTesting;

    protected final ObservableSupplierImpl<DisplayButtonData> mReferenceButtonDataSupplier =
            new ObservableSupplierImpl<>();
    protected final ObservableSupplierImpl<FullButtonData> mNewTabButtonDataSupplier =
            new ObservableSupplierImpl<>();
    protected final ObservableSupplierImpl<Boolean> mHairlineVisibilitySupplier =
            new ObservableSupplierImpl<>();
    protected final UserEducationHelper mUserEducationHelper;
    private final ObservableSupplierImpl<Boolean> mIsVisibleSupplier =
            new ObservableSupplierImpl<>();
    private final ObservableSupplierImpl<Boolean> mIsAnimatingSupplier =
            new ObservableSupplierImpl<>();
    private final Callback<Boolean> mVisibilityObserver = this::onVisibilityChanged;
    private final Handler mHandler = new Handler();
    private final Runnable mSoftCleanupRunnable = this::softCleanupInternal;
    private final Runnable mHardCleanupRunnable = this::hardCleanupInternal;
    private final Runnable mDestroyCoordinatorRunnable = this::destroyTabSwitcherPaneCoordinator;
    private final TabSwitcherCustomViewManager mTabSwitcherCustomViewManager =
            new TabSwitcherCustomViewManager();

    private final MenuOrKeyboardActionHandler mMenuOrKeyboardActionHandler =
            new MenuOrKeyboardActionHandler() {
                @Override
                public boolean handleMenuOrKeyboardAction(int id, boolean fromMenu) {
                    if (id == R.id.menu_select_tabs) {
                        @Nullable
                        TabSwitcherPaneCoordinator coordinator =
                                mTabSwitcherPaneCoordinatorSupplier.get();
                        if (coordinator == null) return false;

                        coordinator.showTabListEditor();
                        RecordUserAction.record("MobileMenuSelectTabs");
                        return true;
                    }
                    return false;
                }
            };
    private final ObservableSupplierImpl<TabSwitcherPaneCoordinator>
            mTabSwitcherPaneCoordinatorSupplier = new ObservableSupplierImpl<>();
    private final TransitiveObservableSupplier<TabSwitcherPaneCoordinator, Boolean>
            mHandleBackPressChangedSupplier =
                    new TransitiveObservableSupplier<>(
                            mTabSwitcherPaneCoordinatorSupplier,
                            pc -> pc.getHandleBackPressChangedSupplier());
    private final OneshotSupplier<ProfileProvider> mProfileProviderSupplier;
    private final FrameLayout mRootView;
    private final TabSwitcherPaneCoordinatorFactory mFactory;
    private final boolean mIsIncognito;
    private final DoubleConsumer mOnToolbarAlphaChange;
    private final HubLayoutAnimationListener mAnimationListener =
            new HubLayoutAnimationListener() {
                @Override
                public void beforeStart() {
                    mIsAnimatingSupplier.set(true);
                }

                @Override
                public void afterEnd() {
                    mIsAnimatingSupplier.set(false);
                }
            };

    private boolean mNativeInitialized;
    private @Nullable PaneHubController mPaneHubController;
    private @Nullable Long mWaitForTabStateInitializedStartTimeMs;
    private @Nullable Tracker mTracker;

    /**
     * @param context The activity context.
     * @param profileProviderSupplier The profile provider supplier.
     * @param factory The factory used to construct {@link TabSwitcherPaneCoordinator}s.
     * @param isIncognito Whether the pane is incognito.
     * @param onToolbarAlphaChange Observer to notify when alpha changes during animations.
     * @param userEducationHelper Used for showing IPHs.
     */
    TabSwitcherPaneBase(
            @NonNull Context context,
            @NonNull OneshotSupplier<ProfileProvider> profileProviderSupplier,
            @NonNull TabSwitcherPaneCoordinatorFactory factory,
            boolean isIncognito,
            @NonNull DoubleConsumer onToolbarAlphaChange,
            @NonNull UserEducationHelper userEducationHelper) {
        mProfileProviderSupplier = profileProviderSupplier;
        mFactory = factory;
        mIsIncognito = isIncognito;

        mRootView = new FrameLayout(context);
        mIsVisibleSupplier.set(false);
        mIsVisibleSupplier.addObserver(mVisibilityObserver);
        mIsAnimatingSupplier.set(false);
        mOnToolbarAlphaChange = onToolbarAlphaChange;
        mUserEducationHelper = userEducationHelper;
    }

    @Override
    public void destroy() {
        mIsVisibleSupplier.removeObserver(mVisibilityObserver);
        destroyTabSwitcherPaneCoordinator();
    }

    @Override
    public @NonNull ViewGroup getRootView() {
        return mRootView;
    }

    @Override
    public @Nullable MenuOrKeyboardActionHandler getMenuOrKeyboardActionHandler() {
        return mMenuOrKeyboardActionHandler;
    }

    @Override
    public boolean getMenuButtonVisible() {
        return true;
    }

    @Override
    public void setPaneHubController(@Nullable PaneHubController paneHubController) {
        mPaneHubController = paneHubController;
    }

    @Override
    public void notifyLoadHint(@LoadHint int loadHint) {
        boolean isVisible = loadHint == LoadHint.HOT;
        mIsVisibleSupplier.set(isVisible);
        removeDelayedCallbacks();

        if (isVisible) {
            createTabSwitcherPaneCoordinator();
            showAllTabs();
            setInitialScrollIndexOffset();
            // TODO(crbug.com/40942549): This should only happen when the Pane becomes user visible
            // which might only happen after the Hub animation finishes. Figure out how to handle
            // that since the load hint for hot will come before the animation is started. Panes
            // likely need to know an animation is going to play and when it is finished (possibly
            // using the isAnimatingSupplier?).
            requestAccessibilityFocusOnCurrentTab();
        } else {
            cancelWaitForTabStateInitializedTimer();
        }

        if (loadHint == LoadHint.WARM) {
            if (mTabSwitcherPaneCoordinatorSupplier.hasValue()) {
                mHandler.postDelayed(mSoftCleanupRunnable, SOFT_CLEANUP_DELAY_MS);
            } else if (shouldEagerlyCreateCoordinator()) {
                createTabSwitcherPaneCoordinator();
            }
        }

        if (loadHint == LoadHint.COLD) {
            if (mTabSwitcherPaneCoordinatorSupplier.hasValue()) {
                mHandler.postDelayed(mSoftCleanupRunnable, SOFT_CLEANUP_DELAY_MS);
                mHandler.postDelayed(mHardCleanupRunnable, HARD_CLEANUP_DELAY_MS);
                mHandler.postDelayed(mDestroyCoordinatorRunnable, DESTROY_COORDINATOR_DELAY_MS);
            }
        }
    }

    @Override
    public @NonNull ObservableSupplier<FullButtonData> getActionButtonDataSupplier() {
        return mNewTabButtonDataSupplier;
    }

    @Override
    public @NonNull ObservableSupplier<DisplayButtonData> getReferenceButtonDataSupplier() {
        return mReferenceButtonDataSupplier;
    }

    @Override
    public @NonNull ObservableSupplier<Boolean> getHairlineVisibilitySupplier() {
        return mHairlineVisibilitySupplier;
    }

    @Override
    public @Nullable HubLayoutAnimationListener getHubLayoutAnimationListener() {
        return mAnimationListener;
    }

    @Override
    public @NonNull HubLayoutAnimatorProvider createShowHubLayoutAnimatorProvider(
            @NonNull HubContainerView hubContainerView) {
        assert !DeviceFormFactor.isNonMultiDisplayContextOnTablet(hubContainerView.getContext());
        int tabId = getCurrentTabId();
        if (getTabListMode() == TabListMode.LIST || tabId == Tab.INVALID_TAB_ID) {
            return FadeHubLayoutAnimationFactory.createFadeInAnimatorProvider(
                    hubContainerView, HubLayoutConstants.FADE_DURATION_MS, mOnToolbarAlphaChange);
        }

        @ColorInt int backgroundColor = getAnimationBackgroundColor();
        SyncOneshotSupplier<ShrinkExpandAnimationData> animationDataSupplier =
                requestAnimationData(hubContainerView, /* isShrink= */ true, tabId);
        return ShrinkExpandHubLayoutAnimationFactory.createShrinkTabAnimatorProvider(
                hubContainerView,
                animationDataSupplier,
                backgroundColor,
                SHRINK_EXPAND_DURATION_MS,
                mOnToolbarAlphaChange);
    }

    @Override
    public @NonNull HubLayoutAnimatorProvider createHideHubLayoutAnimatorProvider(
            @NonNull HubContainerView hubContainerView) {
        assert !DeviceFormFactor.isNonMultiDisplayContextOnTablet(hubContainerView.getContext());
        int tabId = getCurrentTabId();
        if (getTabListMode() == TabListMode.LIST || tabId == Tab.INVALID_TAB_ID) {
            return FadeHubLayoutAnimationFactory.createFadeOutAnimatorProvider(
                    hubContainerView, HubLayoutConstants.FADE_DURATION_MS, mOnToolbarAlphaChange);
        }

        @ColorInt int backgroundColor = getAnimationBackgroundColor();
        SyncOneshotSupplier<ShrinkExpandAnimationData> animationDataSupplier =
                requestAnimationData(hubContainerView, /* isShrink= */ false, tabId);
        return ShrinkExpandHubLayoutAnimationFactory.createExpandTabAnimatorProvider(
                hubContainerView,
                animationDataSupplier,
                backgroundColor,
                SHRINK_EXPAND_DURATION_MS,
                mOnToolbarAlphaChange);
    }

    private @ColorInt int getAnimationBackgroundColor() {
        if (mIsIncognito) {
            return ChromeColors.getPrimaryBackgroundColor(mRootView.getContext(), mIsIncognito);
        } else {
            // TODO(crbug.com/40948541): Consider not getting the color from home surface.
            return ChromeColors.getSurfaceColor(
                    mRootView.getContext(), R.dimen.home_surface_background_color_elevation);
        }
    }

    private SyncOneshotSupplier<ShrinkExpandAnimationData> requestAnimationData(
            @NonNull HubContainerView hubContainerView, boolean isShrink, int tabId) {
        SyncOneshotSupplierImpl<ShrinkExpandAnimationData> animationDataSupplier =
                new SyncOneshotSupplierImpl<>();
        @Nullable TabSwitcherPaneCoordinator coordinator = getTabSwitcherPaneCoordinator();
        assert coordinator != null;
        Runnable provideAnimationData =
                () -> {
                    Rect hubRect = new Rect();
                    hubContainerView.getGlobalVisibleRect(hubRect);
                    Rect initialRect;
                    Rect finalRect;

                    Rect recyclerViewRect = coordinator.getRecyclerViewRect();
                    if (EdgeToEdgeUtils.isEnabled()) {
                        // Extend the recyclerViewRect to include the bottom nav bar area on
                        // edge-to-edge to align the animation with the start / end state.
                        Rect rootViewRect = new Rect();
                        mRootView.getRootView().getGlobalVisibleRect(rootViewRect);
                        recyclerViewRect.bottom = rootViewRect.bottom;
                    }

                    int leftOffset = 0;
                    if (isShrink) {
                        initialRect = recyclerViewRect;
                        finalRect = coordinator.getTabThumbnailRect(tabId);
                        leftOffset = initialRect.left;
                    } else {
                        initialRect = coordinator.getTabThumbnailRect(tabId);
                        finalRect = recyclerViewRect;
                        leftOffset = finalRect.left;
                    }

                    boolean useFallbackAnimation = false;
                    if (initialRect.isEmpty() || finalRect.isEmpty()) {
                        Log.d(TAG, "Geometry not ready using fallback animation.");
                        useFallbackAnimation = true;
                    }
                    // Ignore left offset and just ensure the width is correct. See crbug/1502437.
                    initialRect.offset(-leftOffset, -hubRect.top);
                    finalRect.offset(-leftOffset, -hubRect.top);
                    animationDataSupplier.set(
                            new ShrinkExpandAnimationData(
                                    initialRect,
                                    finalRect,
                                    coordinator.getThumbnailSize(),
                                    useFallbackAnimation));
                };
        coordinator.waitForLayoutWithTab(tabId, provideAnimationData);
        return animationDataSupplier;
    }

    @Override
    public @BackPressResult int handleBackPress() {
        @Nullable
        TabSwitcherPaneCoordinator coordinator = mTabSwitcherPaneCoordinatorSupplier.get();
        if (coordinator == null) return BackPressResult.FAILURE;
        return coordinator.handleBackPress();
    }

    @Override
    public ObservableSupplier<Boolean> getHandleBackPressChangedSupplier() {
        return mHandleBackPressChangedSupplier;
    }

    @Override
    public boolean resetWithTabs(@Nullable List<Tab> tabs, boolean quickMode) {
        assert false : "Not reached.";
        return true;
    }

    @Override
    public void softCleanup() {
        assert false : "Not reached.";
    }

    @Override
    public void hardCleanup() {
        assert false : "Not reached.";
    }

    @Override
    public void initWithNative() {
        if (mNativeInitialized) return;

        mNativeInitialized = true;
        @Nullable
        TabSwitcherPaneCoordinator coordinator = mTabSwitcherPaneCoordinatorSupplier.get();
        if (coordinator != null) {
            coordinator.initWithNative();
        }
    }

    /** Returns a {@link Supplier} that provides dialog visibility information. */
    @Override
    public @Nullable Supplier<Boolean> getTabGridDialogVisibilitySupplier() {
        @Nullable
        TabSwitcherPaneCoordinator coordinator = mTabSwitcherPaneCoordinatorSupplier.get();
        if (coordinator == null) return null;
        return coordinator.getTabGridDialogVisibilitySupplier();
    }

    /** Returns a {@link TabSwitcherCustomViewManager} for supplying custom views. */
    @Override
    public @Nullable TabSwitcherCustomViewManager getTabSwitcherCustomViewManager() {
        return mTabSwitcherCustomViewManager;
    }

    /** Returns the number of elements in the tab switcher's tab list model. */
    @Override
    public int getTabSwitcherTabListModelSize() {
        @Nullable
        TabSwitcherPaneCoordinator coordinator = mTabSwitcherPaneCoordinatorSupplier.get();
        if (coordinator == null) return 0;
        return coordinator.getTabSwitcherTabListModelSize();
    }

    /** Set the tab switcher's RecyclerViewPosition. */
    @Override
    public void setTabSwitcherRecyclerViewPosition(RecyclerViewPosition position) {
        @Nullable
        TabSwitcherPaneCoordinator coordinator = mTabSwitcherPaneCoordinatorSupplier.get();
        if (coordinator == null) return;
        coordinator.setTabSwitcherRecyclerViewPosition(position);
    }

    /** Show the Quick Delete animation on the tab list . */
    @Override
    public void showQuickDeleteAnimation(Runnable onAnimationEnd, List<Tab> tabs) {
        @Nullable
        TabSwitcherPaneCoordinator coordinator = mTabSwitcherPaneCoordinatorSupplier.get();
        if (coordinator == null || getTabListMode() != TabListMode.GRID) {
            onAnimationEnd.run();
            return;
        }
        coordinator.showQuickDeleteAnimation(onAnimationEnd, tabs);
    }

    @Override
    public void showCloseAllTabsAnimation(Runnable closeTabs) {
        @Nullable
        TabSwitcherPaneCoordinator coordinator = mTabSwitcherPaneCoordinatorSupplier.get();
        if (coordinator == null) {
            closeTabs.run();
        } else {
            coordinator.showCloseAllTabsAnimation(closeTabs);
        }
    }

    @Override
    public void openInvitationModal(String invitationId) {
        TabSwitcherPaneCoordinator coordinator = mTabSwitcherPaneCoordinatorSupplier.get();
        if (coordinator == null) return;
        coordinator.openInvitationModal(invitationId);
    }

    @Override
    public boolean requestOpenTabGroupDialog(int tabId) {
        @Nullable
        TabSwitcherPaneCoordinator coordinator = mTabSwitcherPaneCoordinatorSupplier.get();
        if (coordinator != null) {
            coordinator.requestOpenTabGroupDialog(tabId);
            return true;
        } else {
            return false;
        }
    }

    /**
     * Request to show all the tabs in the pane. Subclasses should override this method to invoke
     * {@link TabSwitcherResetHandler#resetWithTabList} with their available tabs.
     */
    protected abstract void showAllTabs();

    /** Returns the current selected tab ID. */
    protected abstract int getCurrentTabId();

    /** Returns whether to eagerly create the coordinator in the {@link LoadHint.WARM} state. */
    protected abstract boolean shouldEagerlyCreateCoordinator();

    /** A runnable that will be invoked when delegate UI creates a tab group. */
    protected abstract Runnable getOnTabGroupCreationRunnable();

    /** Called when the pane is shown to indicate IPH should maybe be shown. */
    protected abstract void tryToTriggerOnShownIphs();

    /** Requests accessibility focus on the currently selected tab in the tab switcher. */
    protected void requestAccessibilityFocusOnCurrentTab() {
        TabSwitcherPaneCoordinator coordinator = mTabSwitcherPaneCoordinatorSupplier.get();
        if (coordinator == null) return;
        coordinator.requestAccessibilityFocusOnCurrentTab();
    }

    /** Returns the {@link TabListMode} of the {@link TabListCoordinator} that the pane hosts. */
    protected @TabListMode int getTabListMode() {
        return mFactory.getTabListMode();
    }

    /**
     * Returns a supplier for whether the pane is visible onscreen. Note this is not the same as
     * being focused.
     */
    protected ObservableSupplier<Boolean> getIsVisibleSupplier() {
        return mIsVisibleSupplier;
    }

    /**
     * Holds whether there's an ongoing animation with this Pane and outside the hub. Care must be
     * taken when reading this supplier as animations do not start synchronously with focus changes,
     * and a Pane may be shown before the enter animation actually starts.
     */
    protected @NonNull ObservableSupplier<Boolean> getIsAnimatingSupplier() {
        return mIsAnimatingSupplier;
    }

    /** Returns whether the pane is focused. */
    protected boolean isFocused() {
        return mPaneHubController != null;
    }

    /** Returns the PaneHubController if one exists or null otherwise. */
    protected @Nullable PaneHubController getPaneHubController() {
        return mPaneHubController;
    }

    /** Returns the current {@link TabSwitcherPaneCoordinator} or null if one doesn't exist. */
    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
    @Nullable
    TabSwitcherPaneCoordinator getTabSwitcherPaneCoordinator() {
        return mTabSwitcherPaneCoordinatorSupplier.get();
    }

    /** Returns an observable supplier that hold the current coordinator. */
    protected @NonNull ObservableSupplier<TabSwitcherPaneCoordinator>
            getTabSwitcherPaneCoordinatorSupplier() {
        return mTabSwitcherPaneCoordinatorSupplier;
    }

    /** Notify that the new tab button was clicked. */
    protected void notifyNewTabButtonClick() {
        if (HubFieldTrial.usesFloatActionButton()) {
            getTracker().notifyEvent("tab_switcher_floating_action_button_clicked");
        }
    }

    /** Returns the feature engagement tracker. */
    protected Tracker getTracker() {
        if (mTracker != null) return mTracker;

        mTracker =
                TrackerFactory.getTrackerForProfile(
                        mProfileProviderSupplier.get().getOriginalProfile());
        return mTracker;
    }

    /** Creates a {@link TabSwitcherCoordinator}. */
    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
    void createTabSwitcherPaneCoordinator() {
        if (mTabSwitcherPaneCoordinatorSupplier.hasValue()) return;

        @NonNull
        TabSwitcherPaneCoordinator coordinator =
                mFactory.create(
                        mRootView,
                        /* resetHandler= */ this,
                        mIsVisibleSupplier,
                        mIsAnimatingSupplier,
                        this::onTabClick,
                        mHairlineVisibilitySupplier::set,
                        mIsIncognito,
                        getOnTabGroupCreationRunnable());
        mTabSwitcherPaneCoordinatorSupplier.set(coordinator);
        mTabSwitcherCustomViewManager.setDelegate(
                coordinator.getTabSwitcherCustomViewManagerDelegate());
        if (mNativeInitialized) {
            coordinator.initWithNative();
        }
    }

    /**
     * Destroys the current {@link TabSwitcherCoordinator}. It is safe to call this even if a
     * coordinator does not exist.
     */
    @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
    void destroyTabSwitcherPaneCoordinator() {
        if (!mTabSwitcherPaneCoordinatorSupplier.hasValue()) return;

        @NonNull TabSwitcherPaneCoordinator coordinator = mTabSwitcherPaneCoordinatorSupplier.get();
        mTabSwitcherPaneCoordinatorSupplier.set(null);
        mRootView.removeAllViews();
        mTabSwitcherCustomViewManager.setDelegate(null);
        coordinator.destroy();
    }

    protected void startWaitForTabStateInitializedTimer() {
        if (mWaitForTabStateInitializedStartTimeMs != null) return;

        mWaitForTabStateInitializedStartTimeMs = SystemClock.elapsedRealtime();
    }

    protected void finishWaitForTabStateInitializedTimer() {
        if (mWaitForTabStateInitializedStartTimeMs != null) {
            RecordHistogram.recordTimesHistogram(
                    "Android.GridTabSwitcher.TimeToTabStateInitializedFromShown",
                    SystemClock.elapsedRealtime()
                            - mWaitForTabStateInitializedStartTimeMs.longValue());
            mWaitForTabStateInitializedStartTimeMs = null;
        }
    }

    protected void cancelWaitForTabStateInitializedTimer() {
        mWaitForTabStateInitializedStartTimeMs = null;
    }

    private void onTabClick(int tabId) {
        if (mPaneHubController == null) return;

        // TODO(crbug.com/41489932): Consider using INVALID_TAB_ID if already selected to prevent a
        // repeat selection. For now this is required to ensure the tab gets marked as shown when
        // exiting the Hub. See if this can be updated/changed.
        mPaneHubController.selectTabAndHideHub(tabId);
    }

    private void setInitialScrollIndexOffset() {
        TabSwitcherPaneCoordinator coordinator = mTabSwitcherPaneCoordinatorSupplier.get();
        if (coordinator == null) return;
        coordinator.setInitialScrollIndexOffset();
    }

    private void softCleanupInternal() {
        TabSwitcherPaneCoordinator coordinator = mTabSwitcherPaneCoordinatorSupplier.get();
        if (coordinator == null) return;
        coordinator.softCleanup();
    }

    private void hardCleanupInternal() {
        TabSwitcherPaneCoordinator coordinator = mTabSwitcherPaneCoordinatorSupplier.get();
        if (coordinator == null) return;
        coordinator.hardCleanup();
    }

    private void removeDelayedCallbacks() {
        mHandler.removeCallbacks(mSoftCleanupRunnable);
        mHandler.removeCallbacks(mHardCleanupRunnable);
        mHandler.removeCallbacks(mDestroyCoordinatorRunnable);
    }

    private void onVisibilityChanged(boolean visible) {
        if (visible) {
            postOnShownIphRunner();
        }
    }

    private void postOnShownIphRunner() {
        // TODO(crbug.com/346356139): Figure out a more elegant way of observing entering the hub as
        // well as switching between panes. Knowing when these animations complete turns out to be
        // fairly difficult, especially knowing when we're about to enter a transition.
        PostTask.postDelayedTask(TaskTraits.UI_DEFAULT, this::onShownIphRunner, ON_SHOWN_IPH_DELAY);
    }

    private void onShownIphRunner() {
        if (BuildConfig.IS_FOR_TEST && !sShowIphForTesting) return;

        // The IPH system will ensure we don't show everything at once.
        tryToTriggerFloatingActionButtonIph();
        tryToTriggerOnShownIphs();
    }

    private void tryToTriggerFloatingActionButtonIph() {
        @Nullable PaneHubController paneHubController = getPaneHubController();
        if (paneHubController == null) return;
        @Nullable View anchorView = paneHubController.getFloatingActionButton();
        if (anchorView == null) return;

        if (getIsAnimatingSupplier().get()) return;

        IPHCommand command =
                new IPHCommandBuilder(
                                getRootView().getResources(),
                                FeatureConstants.TAB_SWITCHER_FLOATING_ACTION_BUTTON,
                                R.string.iph_tab_switcher_floating_action_button,
                                R.string.iph_tab_switcher_floating_action_button)
                        .setAnchorView(anchorView)
                        .build();
        mUserEducationHelper.requestShowIPH(command);
    }

    void softCleanupForTesting() {
        mSoftCleanupRunnable.run();
    }

    void hardCleanupForTesting() {
        mHardCleanupRunnable.run();
    }

    void destroyCoordinatorForTesting() {
        mDestroyCoordinatorRunnable.run();
    }

    static void setShowIphForTesting(boolean showIphForTesting) {
        boolean oldValue = sShowIphForTesting;
        sShowIphForTesting = showIphForTesting;
        ResettersForTesting.register(() -> sShowIphForTesting = oldValue);
    }
}