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

import static org.chromium.chrome.browser.tasks.tab_management.MessageCardViewProperties.MESSAGE_TYPE;
import static org.chromium.chrome.browser.tasks.tab_management.TabListModel.CardProperties.CARD_ALPHA;
import static org.chromium.chrome.browser.tasks.tab_management.TabListModel.CardProperties.CARD_TYPE;
import static org.chromium.chrome.browser.tasks.tab_management.TabListModel.CardProperties.ModelType.TAB;
import static org.chromium.chrome.browser.tasks.tab_management.TabProperties.ACTION_BUTTON_DESCRIPTION_STRING;
import static org.chromium.chrome.browser.tasks.tab_management.TabProperties.TAB_ID;
import static org.chromium.chrome.browser.tasks.tab_management.TabProperties.THUMBNAIL_FETCHER;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.content.ComponentCallbacks;
import android.content.Context;
import android.content.res.ColorStateList;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.Handler;
import android.text.TextUtils;
import android.util.Pair;
import android.util.Size;
import android.view.View;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;

import androidx.annotation.IdRes;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.OnScrollListener;

import org.chromium.base.Callback;
import org.chromium.base.Log;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.Token;
import org.chromium.base.ValueChangedCallback;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.shared_preferences.SharedPreferencesManager;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.Supplier;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.chrome.browser.data_sharing.DataSharingServiceFactory;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.preferences.ChromePreferenceKeys;
import org.chromium.chrome.browser.preferences.ChromeSharedPreferences;
import org.chromium.chrome.browser.price_tracking.PriceTrackingFeatures;
import org.chromium.chrome.browser.price_tracking.PriceTrackingUtilities;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.quick_delete.QuickDeleteAnimationGradientDrawable;
import org.chromium.chrome.browser.signin.services.IdentityServicesProvider;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabCreationState;
import org.chromium.chrome.browser.tab.TabLaunchType;
import org.chromium.chrome.browser.tab.TabObserver;
import org.chromium.chrome.browser.tab.TabSelectionType;
import org.chromium.chrome.browser.tab.state.ShoppingPersistedTabData;
import org.chromium.chrome.browser.tab_group_sync.TabGroupSyncFeatures;
import org.chromium.chrome.browser.tab_group_sync.TabGroupSyncServiceFactory;
import org.chromium.chrome.browser.tab_ui.TabListFaviconProvider;
import org.chromium.chrome.browser.tab_ui.TabListFaviconProvider.TabFaviconFetcher;
import org.chromium.chrome.browser.tab_ui.ThumbnailProvider;
import org.chromium.chrome.browser.tabmodel.TabClosureParams;
import org.chromium.chrome.browser.tabmodel.TabList;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModelFilter;
import org.chromium.chrome.browser.tabmodel.TabModelObserver;
import org.chromium.chrome.browser.tabmodel.TabModelUtils;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupColorUtils;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupModelFilter;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupModelFilterObserver;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupUtils;
import org.chromium.chrome.browser.tasks.tab_management.ActionConfirmationManager.ConfirmationResult;
import org.chromium.chrome.browser.tasks.tab_management.PriceMessageService.PriceTabData;
import org.chromium.chrome.browser.tasks.tab_management.TabGridView.QuickDeleteAnimationStatus;
import org.chromium.chrome.browser.tasks.tab_management.TabListCoordinator.TabListMode;
import org.chromium.chrome.browser.tasks.tab_management.TabProperties.TabActionState;
import org.chromium.chrome.browser.tasks.tab_management.TabProperties.UiType;
import org.chromium.chrome.browser.tasks.tab_management.TabUiMetricsHelper.TabListEditorActionMetricGroups;
import org.chromium.chrome.tab_ui.R;
import org.chromium.components.browser_ui.styles.SemanticColorUtils;
import org.chromium.components.browser_ui.widget.selectable_list.SelectionDelegate;
import org.chromium.components.data_sharing.DataSharingService;
import org.chromium.components.embedder_support.util.UrlUtilities;
import org.chromium.components.feature_engagement.FeatureConstants;
import org.chromium.components.signin.identitymanager.IdentityManager;
import org.chromium.components.tab_group_sync.TabGroupSyncService;
import org.chromium.components.tab_groups.TabGroupColorId;
import org.chromium.content_public.browser.NavigationHandle;
import org.chromium.ui.base.DeviceFormFactor;
import org.chromium.ui.modaldialog.DialogDismissalCause;
import org.chromium.ui.modaldialog.ModalDialogManager;
import org.chromium.ui.modaldialog.ModalDialogProperties;
import org.chromium.ui.modelutil.ListObservable;
import org.chromium.ui.modelutil.ListObservable.ListObserver;
import org.chromium.ui.modelutil.MVCListAdapter.ListItem;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.modelutil.PropertyModel.WritableObjectPropertyKey;
import org.chromium.ui.modelutil.SimpleRecyclerViewAdapter;
import org.chromium.url.GURL;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Collectors;

/**
 * Mediator for business logic for the tab grid. This class should be initialized with a list of
 * tabs and a TabModel to observe for changes and should not have any logic around what the list
 * signifies. TODO(yusufo): Move some of the logic here to a parent component to make the above
 * true.
 */
class TabListMediator implements TabListNotificationHandler {
    // Set to true after a `resetWithListOfTabs` that used a non-null list of tabs. Remains true
    // until `postHiding` is invoked or the mediator is destroyed. While true, this mediator is
    // actively tracking updates to a TabModel.
    private boolean mShowingTabs;
    private boolean mShownIPH;
    private Tab mTabToAddDelayed;

    /** An interface to handle requests about updating TabGridDialog. */
    public interface TabGridDialogHandler {
        /**
         * This method updates the status of the ungroup bar in TabGridDialog.
         *
         * @param status The status in {@link TabGridDialogView.UngroupBarStatus} that the ungroup
         *         bar should be updated to.
         */
        void updateUngroupBarStatus(@TabGridDialogView.UngroupBarStatus int status);

        /**
         * This method updates the content of the TabGridDialog.
         *
         * @param tabId The id of the {@link Tab} that is used to update TabGridDialog.
         */
        void updateDialogContent(int tabId);
    }

    /**
     * An interface to expose functionality needed to support reordering in grid layouts in
     * accessibility mode.
     */
    public interface TabGridAccessibilityHelper {
        /**
         * This method gets the possible actions for reordering a tab in grid layout.
         *
         * @param view The host view that triggers the accessibility action.
         * @return The list of possible {@link AccessibilityAction}s for host view.
         */
        List<AccessibilityAction> getPotentialActionsForView(View view);

        /**
         * This method gives the previous and target position of current reordering based on the
         * host view and current action.
         *
         * @param view   The host view that triggers the accessibility action.
         * @param action The id of the action.
         * @return {@link Pair} that contains previous and target position of this action.
         */
        Pair<Integer, Integer> getPositionsOfReorderAction(View view, int action);

        /**
         * This method returns whether the given action is a type of the reordering actions.
         *
         * @param action The accessibility action.
         * @return Whether the given action is a reordering action.
         */
        boolean isReorderAction(int action);
    }

    /** Provides capability to asynchronously acquire {@link ShoppingPersistedTabData} */
    static class ShoppingPersistedTabDataFetcher {
        protected final Tab mTab;
        protected final Supplier<PriceWelcomeMessageController>
                mPriceWelcomeMessageControllerSupplier;

        /**
         * @param tab {@link Tab} {@link ShoppingPersistedTabData} will be acquired for.
         * @param priceWelcomeMessageControllerSupplier to show the price welcome message.
         */
        ShoppingPersistedTabDataFetcher(
                Tab tab,
                @NonNull
                        Supplier<PriceWelcomeMessageController>
                                priceWelcomeMessageControllerSupplier) {
            mTab = tab;
            mPriceWelcomeMessageControllerSupplier = priceWelcomeMessageControllerSupplier;
        }

        /**
         * Asynchronously acquire {@link ShoppingPersistedTabData}
         * @param callback {@link Callback} to pass {@link ShoppingPersistedTabData} back in
         */
        public void fetch(Callback<ShoppingPersistedTabData> callback) {
            ShoppingPersistedTabData.from(
                    mTab,
                    (res) -> {
                        callback.onResult(res);
                        maybeShowPriceWelcomeMessage(res);
                    });
        }

        @VisibleForTesting
        void maybeShowPriceWelcomeMessage(
                @Nullable ShoppingPersistedTabData shoppingPersistedTabData) {
            // Avoid inserting message while RecyclerView is computing a layout.
            new Handler()
                    .post(
                            () -> {
                                if (!PriceTrackingUtilities.isPriceWelcomeMessageCardEnabled(
                                                mTab.getProfile())
                                        || (mPriceWelcomeMessageControllerSupplier == null)
                                        || (mPriceWelcomeMessageControllerSupplier.get() == null)
                                        || (shoppingPersistedTabData == null)
                                        || (shoppingPersistedTabData.getPriceDrop() == null)) {
                                    return;
                                }
                                mPriceWelcomeMessageControllerSupplier
                                        .get()
                                        .showPriceWelcomeMessage(
                                                new PriceTabData(
                                                        mTab.getId(),
                                                        shoppingPersistedTabData.getPriceDrop()));
                            });
        }
    }

    /** An interface to show IPH for a tab. */
    public interface IphProvider {
        void showIPH(View anchor);
    }

    private final IphProvider mIphProvider =
            new IphProvider() {
                private static final int IPH_DELAY_MS = 1000;

                @Override
                public void showIPH(View anchor) {
                    if (mShownIPH) return;
                    mShownIPH = true;

                    PostTask.postDelayedTask(
                            TaskTraits.UI_DEFAULT,
                            () -> {
                                TabGroupUtils.maybeShowIPH(
                                        mProfile,
                                        FeatureConstants.TAB_GROUPS_YOUR_TABS_ARE_TOGETHER_FEATURE,
                                        anchor,
                                        null);
                            },
                            IPH_DELAY_MS);
                }
            };

    /**
     * An interface to get a SelectionDelegate that contains the selected items for a selectable
     * tab list.
     */
    public interface SelectionDelegateProvider {
        SelectionDelegate getSelectionDelegate();
    }

    /** An interface to get the onClickListener when clicking on a grid card. */
    interface GridCardOnClickListenerProvider {
        /**
         * @return {@link TabActionListener} to open Tab Grid dialog.
         * If the given {@link Tab} is not able to create group, return null;
         */
        @Nullable
        TabActionListener openTabGridDialog(@NonNull Tab tab);

        /**
         * Run additional actions on tab selection.
         * @param tabId The ID of selected {@link Tab}.
         * @param fromActionButton Whether it is called from the Action button on the card.
         */
        void onTabSelecting(int tabId, boolean fromActionButton);
    }

    /** A class that stores shared info regarding a tab group's state. */
    static class TabGroupInfo {
        private boolean mIsTabGroupSyncEnabled;
        private boolean mIsTabGroup;

        TabGroupInfo(boolean isTabGroupSyncEnabled, boolean isTabGroup) {
            mIsTabGroupSyncEnabled = isTabGroupSyncEnabled;
            mIsTabGroup = isTabGroup;
        }

        boolean getIsTabGroupSyncEnabled() {
            return mIsTabGroupSyncEnabled;
        }

        boolean getIsTabGroup() {
            return mIsTabGroup;
        }
    }

    @IntDef({
        TabClosedFrom.TAB_STRIP,
        TabClosedFrom.GRID_TAB_SWITCHER,
        TabClosedFrom.GRID_TAB_SWITCHER_GROUP
    })
    @Retention(RetentionPolicy.SOURCE)
    private @interface TabClosedFrom {
        int TAB_STRIP = 0;
        // int TAB_GRID_SHEET = 1;  // Obsolete
        int GRID_TAB_SWITCHER = 2;
        int GRID_TAB_SWITCHER_GROUP = 3;
        int NUM_ENTRIES = 4;
    }

    private static final String TAG = "TabListMediator";
    private static Map<Integer, Integer> sTabClosedFromMapTabClosedFromMap = new HashMap<>();
    private static Set<Integer> sViewedTabIds = new HashSet<>();

    private final Context mContext;
    private final TabListModel mModel;
    private final @TabListMode int mMode;
    private final ModalDialogManager mModalDialogManager;
    private final ObservableSupplier<TabModelFilter> mCurrentTabModelFilterSupplier;
    private final ValueChangedCallback<TabModelFilter> mOnTabModelFilterChanged =
            new ValueChangedCallback<>(this::onTabModelFilterChanged);
    private final TabActionListener mTabClosedListener;
    private final SelectionDelegateProvider mSelectionDelegateProvider;
    private final GridCardOnClickListenerProvider mGridCardOnClickListenerProvider;
    private final TabGridDialogHandler mTabGridDialogHandler;
    private final TabListFaviconProvider mTabListFaviconProvider;
    private final Supplier<PriceWelcomeMessageController> mPriceWelcomeMessageControllerSupplier;
    private final TabGroupColorFaviconProvider mTabGroupColorFaviconProvider;
    private final TabListGroupMenuCoordinator.OnItemClickedCallback mOnMenuItemClickedCallback =
            this::onMenuItemClicked;
    private @NonNull final ActionConfirmationManager mActionConfirmationManager;
    private final Runnable mOnTabGroupCreation;

    private TabListGroupMenuCoordinator mTabListGroupMenuCoordinator;
    private @Nullable Profile mProfile;
    private Size mDefaultGridCardSize;
    private String mComponentName;
    private ThumbnailProvider mThumbnailProvider;
    private boolean mActionsOnAllRelatedTabs;
    private ComponentCallbacks mComponentCallbacks;
    private TabGridItemTouchHelperCallback mTabGridItemTouchHelperCallback;
    private int mNextTabId = Tab.INVALID_TAB_ID;
    private @TabActionState int mTabActionState;
    private GridLayoutManager mGridLayoutManager;
    // mRecyclerView and mOnScrollListener are null, unless the the price drop IPH or badge is
    // enabled.
    private @Nullable RecyclerView mRecyclerView;
    private @Nullable OnScrollListener mOnScrollListener;

    private final TabActionListener mTabSelectedListener =
            new TabActionListener() {
                @Override
                public void run(View view, int tabId) {
                    if (mModel.indexFromId(tabId) == TabModel.INVALID_TAB_INDEX) return;

                    mNextTabId = tabId;

                    TabModel tabModel = mCurrentTabModelFilterSupplier.get().getTabModel();
                    if (!mActionsOnAllRelatedTabs) {
                        Tab currentTab = TabModelUtils.getCurrentTab(tabModel);
                        Tab newlySelectedTab = tabModel.getTabById(tabId);

                        // We filtered the tab switching related metric for components that takes
                        // actions on all related tabs (e.g. GTS) because that component can
                        // switch to different TabModel before switching tabs, while this class
                        // only contains information for all tabs that are in the same TabModel,
                        // more specifically:
                        //   * For Tabs.TabOffsetOfSwitch, we do not want to log anything if the
                        // user
                        //     switched from normal to incognito or vice-versa.
                        //   * For MobileTabSwitched, as compared to the VTS, we need to account for
                        //     MobileTabReturnedToCurrentTab action. This action is defined as
                        // return to the
                        //     same tab as before entering the component, and we don't have this
                        // information
                        //     here.
                        recordUserSwitchedTab(currentTab, newlySelectedTab);
                    }
                    if (mGridCardOnClickListenerProvider != null) {
                        mGridCardOnClickListenerProvider.onTabSelecting(
                                tabId, /* fromActionButton= */ true);
                    } else {
                        tabModel.setIndex(
                                TabModelUtils.getTabIndexById(tabModel, tabId),
                                TabSelectionType.FROM_USER);
                    }
                }

                /**
                 * Records MobileTabSwitched for the component. Also, records Tabs.TabOffsetOfSwitch
                 * but only when fromTab and toTab are within the same group. This method only
                 * records UMA for components other than TabSwitcher.
                 *
                 * @param fromTab The previous selected tab.
                 * @param toTab The new selected tab.
                 */
                private void recordUserSwitchedTab(Tab fromTab, Tab toTab) {
                    TabModelFilter filter = mCurrentTabModelFilterSupplier.get();
                    int fromFilterIndex = filter.indexOf(fromTab);
                    int toFilterIndex = filter.indexOf(toTab);

                    RecordUserAction.record("MobileTabSwitched." + mComponentName);

                    if (fromFilterIndex != toFilterIndex) return;

                    TabModel tabModel = filter.getTabModel();
                    int fromIndex = TabModelUtils.getTabIndexById(tabModel, fromTab.getId());
                    int toIndex = TabModelUtils.getTabIndexById(tabModel, toTab.getId());

                    RecordHistogram.recordSparseHistogram(
                            "Tabs.TabOffsetOfSwitch." + mComponentName, fromIndex - toIndex);
                }
            };

    private final TabActionListener mSelectableTabOnClickListener =
            new TabActionListener() {
                @Override
                public void run(View view, int tabId) {
                    SelectionDelegate<Integer> selectionDelegate = getTabSelectionDelegate();
                    assert selectionDelegate != null;
                    selectionDelegate.toggleSelectionForItem(tabId);

                    int index = mModel.indexFromId(tabId);
                    if (index == TabModel.INVALID_TAB_INDEX) return;
                    PropertyModel model = mModel.get(index).model;
                    boolean selected = model.get(TabProperties.IS_SELECTED);
                    if (selected) {
                        TabUiMetricsHelper.recordSelectionEditorActionMetrics(
                                TabListEditorActionMetricGroups.UNSELECTED);
                    } else {
                        TabUiMetricsHelper.recordSelectionEditorActionMetrics(
                                TabListEditorActionMetricGroups.SELECTED);
                    }
                    model.set(TabProperties.IS_SELECTED, !selected);
                    // Reset thumbnail to ensure the color of the blank tab slots is correct.
                    TabModelFilter filter = mCurrentTabModelFilterSupplier.get();
                    Tab tab = filter.getTabModel().getTabById(tabId);
                    if (tab != null && filter.isTabInTabGroup(tab)) {
                        updateThumbnailFetcher(model, tabId);
                    }
                }
            };

    private final TabObserver mTabObserver =
            new EmptyTabObserver() {
                @Override
                public void onDidStartNavigationInPrimaryMainFrame(
                        Tab tab, NavigationHandle navigationHandle) {
                    assert mShowingTabs;

                    // The URL of the tab and the navigation handle can match without it being a
                    // same document navigation if the tab had no renderer and needed to start a
                    // new one.
                    // See https://crbug.com/1359002.
                    if (navigationHandle.isSameDocument()
                            || UrlUtilities.isNtpUrl(tab.getUrl())
                            || tab.getUrl().equals(navigationHandle.getUrl())) {
                        return;
                    }
                    if (mModel.indexFromId(tab.getId()) == TabModel.INVALID_TAB_INDEX
                            || (mActionsOnAllRelatedTabs
                                    && mCurrentTabModelFilterSupplier.get().isTabInTabGroup(tab))) {
                        return;
                    }

                    mModel.get(mModel.indexFromId(tab.getId()))
                            .model
                            .set(
                                    TabProperties.FAVICON_FETCHER,
                                    mTabListFaviconProvider.getDefaultFaviconFetcher(
                                            tab.isIncognito()));
                }

                @Override
                public void onTitleUpdated(Tab updatedTab) {
                    assert mShowingTabs;

                    int index = mModel.indexFromId(updatedTab.getId());
                    // TODO(crbug.com/40136874) The null check for tab here should be redundant once
                    // we have resolved the bug.
                    if (index == TabModel.INVALID_TAB_INDEX
                            || mCurrentTabModelFilterSupplier
                                            .get()
                                            .getTabModel()
                                            .getTabById(updatedTab.getId())
                                    == null) {
                        return;
                    }
                    mModel.get(index)
                            .model
                            .set(
                                    TabProperties.TITLE,
                                    getLatestTitleForTab(updatedTab, /* useDefault= */ true));
                }

                @Override
                public void onFaviconUpdated(Tab updatedTab, Bitmap icon, GURL iconUrl) {
                    assert mShowingTabs;

                    if (!mActionsOnAllRelatedTabs) {
                        updateFaviconForTab(updatedTab, icon, iconUrl);
                        return;
                    }

                    Tab tab = null;
                    if (isTabInTabGroup(updatedTab)) {
                        @Nullable
                        Pair<Integer, Tab> indexAndTab =
                                getIndexAndTabForRootId(updatedTab.getRootId());
                        if (indexAndTab == null) return;

                        tab = indexAndTab.second;

                        if (mThumbnailProvider != null) {
                            PropertyModel model = mModel.get(indexAndTab.first).model;
                            updateThumbnailFetcher(model, tab.getId());
                        }
                    } else {
                        tab = updatedTab;
                    }

                    updateFaviconForTab(tab, icon, iconUrl);
                }

                @Override
                public void onUrlUpdated(Tab updatedTab) {
                    assert mShowingTabs;

                    int index = mModel.indexFromId(updatedTab.getId());

                    if (index != TabModel.INVALID_TAB_INDEX) {
                        mModel.get(index)
                                .model
                                .set(TabProperties.URL_DOMAIN, getDomainForTab(updatedTab));
                    } else if (mActionsOnAllRelatedTabs) {
                        @Nullable
                        Pair<Integer, Tab> indexAndTab =
                                getIndexAndTabForRootId(updatedTab.getRootId());
                        if (indexAndTab == null) return;
                        Tab tab = indexAndTab.second;
                        PropertyModel model = mModel.get(indexAndTab.first).model;

                        model.set(TabProperties.URL_DOMAIN, getDomainForTab(tab));
                    }
                }
            };

    /** Interface for toggling whether item animations will run on the recycler view. */
    interface RecyclerViewItemAnimationToggle {
        void setDisableItemAnimations(boolean state);
    }

    private RecyclerViewItemAnimationToggle mRecyclerViewItemAnimationToggle;

    private final TabModelObserver mTabModelObserver;

    private ListObserver<Void> mListObserver;

    private TabGroupTitleEditor mTabGroupTitleEditor;

    private View.AccessibilityDelegate mAccessibilityDelegate;

    private int mLastSelectedTabListModelIndex = TabList.INVALID_TAB_INDEX;

    private final TabGroupModelFilterObserver mTabGroupObserver =
            new TabGroupModelFilterObserver() {
                @Override
                public void didChangeTabGroupTitle(int rootId, String newTitle) {
                    assert mShowingTabs;

                    if (!mActionsOnAllRelatedTabs) return;

                    @Nullable Pair<Integer, Tab> indexAndTab = getIndexAndTabForRootId(rootId);
                    if (indexAndTab == null) return;
                    Tab tab = indexAndTab.second;
                    PropertyModel model = mModel.get(indexAndTab.first).model;

                    // Do not trust the `newTitle`, it may be necessary to apply a default/fallback.
                    newTitle = getLatestTitleForTab(tab, /* useDefault= */ true);

                    model.set(TabProperties.TITLE, newTitle);
                    updateDescriptionString(tab, model);
                    updateActionButtonDescriptionString(tab, model);
                }

                @Override
                public void didChangeTabGroupColor(int rootId, @TabGroupColorId int newColor) {
                    assert mShowingTabs;

                    if (!ChromeFeatureList.sTabGroupParityAndroid.isEnabled()) return;

                    if (!mActionsOnAllRelatedTabs) return;

                    @Nullable Pair<Integer, Tab> indexAndTab = getIndexAndTabForRootId(rootId);
                    if (indexAndTab == null) return;
                    Tab tab = indexAndTab.second;
                    PropertyModel model = mModel.get(indexAndTab.first).model;

                    if (mMode == TabListMode.LIST) {
                        model.set(TabProperties.TAB_GROUP_COLOR_ID, newColor);
                    } else if (mMode == TabListMode.GRID) {
                        updateFaviconForTab(tab, null, null);
                    }
                    updateDescriptionString(tab, model);
                    updateActionButtonDescriptionString(tab, model);
                }

                @Override
                public void didMoveWithinGroup(
                        Tab movedTab, int tabModelOldIndex, int tabModelNewIndex) {
                    assert mShowingTabs;

                    if (tabModelNewIndex == tabModelOldIndex) return;

                    TabModelFilter filter = mCurrentTabModelFilterSupplier.get();
                    TabModel tabModel = filter.getTabModel();

                    // For the tab switcher update the tab card correctly.
                    if (mActionsOnAllRelatedTabs && mThumbnailProvider != null) {
                        int indexInModel = getIndexForTabWithRelatedTabs(movedTab);
                        if (indexInModel == TabModel.INVALID_TAB_INDEX) return;

                        Tab lastShownTab = filter.getTabAt(filter.indexOf(movedTab));
                        PropertyModel model = mModel.get(indexInModel).model;
                        updateThumbnailFetcher(model, lastShownTab.getId());
                        return;
                    }

                    // For the grid dialog or tab strip maintain order.
                    int curPosition = mModel.indexFromId(movedTab.getId());

                    if (!isValidMovePosition(curPosition)) return;

                    Tab destinationTab =
                            tabModel.getTabAt(
                                    tabModelNewIndex > tabModelOldIndex
                                            ? tabModelNewIndex - 1
                                            : tabModelNewIndex + 1);

                    int newPosition = mModel.indexFromId(destinationTab.getId());

                    if (!isValidMovePosition(newPosition)) return;
                    mModel.move(curPosition, newPosition);
                }

                @Override
                public void didMoveTabOutOfGroup(Tab movedTab, int prevFilterIndex) {
                    assert mShowingTabs;

                    assert !(mActionsOnAllRelatedTabs && mTabGridDialogHandler != null);

                    TabGroupModelFilter filter =
                            (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get();
                    Tab previousGroupTab = filter.getTabAt(prevFilterIndex);
                    if (mActionsOnAllRelatedTabs) {
                        final int currentSelectedTabId =
                                TabModelUtils.getCurrentTabId(filter.getTabModel());
                        // Only add a tab to the model if it represents a new card (new group or new
                        // singular tab). However, always update the previous group to clean up old
                        // state. The addition of the new tab to an existing group is handled in
                        // didMergeTabToGroup().
                        if (filter.getRelatedTabCountForRootId(movedTab.getRootId()) == 1
                                && movedTab != previousGroupTab) {
                            int filterIndex = filter.indexOf(movedTab);
                            addTabInfoToModel(
                                    movedTab,
                                    mModel.indexOfNthTabCard(filterIndex),
                                    currentSelectedTabId == movedTab.getId());
                        }
                        updateTab(
                                mModel.indexOfNthTabCard(prevFilterIndex),
                                previousGroupTab,
                                true,
                                false);
                    } else {
                        boolean isUngroupingLastTabInGroup =
                                previousGroupTab.getId() == movedTab.getId();
                        int curTabListModelIndex = mModel.indexFromId(movedTab.getId());
                        if (!isValidMovePosition(curTabListModelIndex)) return;
                        mModel.removeAt(curTabListModelIndex);
                        if (mTabGridDialogHandler != null) {
                            mTabGridDialogHandler.updateDialogContent(
                                    isUngroupingLastTabInGroup
                                            ? Tab.INVALID_TAB_ID
                                            : filter.getTabAt(prevFilterIndex).getId());
                        }
                    }
                }

                @Override
                public void didMergeTabToGroup(Tab movedTab, int selectedTabIdInGroup) {
                    assert mShowingTabs;

                    TabGroupModelFilter filter =
                            (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get();
                    TabModel tabModel = filter.getTabModel();
                    if (mActionsOnAllRelatedTabs) {
                        // When merging Tab 1 to Tab 2 as a new group, or merging Tab 1 to an
                        // existing group 1, we can always find the current indexes of 1) Tab 1
                        // and 2) Tab 2 or group 1 in the model. The method
                        // getIndexesForMergeToGroup() returns these two ids by using Tab 1's
                        // related Tabs, which have been updated in
                        // TabModel.
                        List<Tab> relatedTabs = getRelatedTabsForId(movedTab.getId());
                        Pair<Integer, Integer> positions =
                                mModel.getIndexesForMergeToGroup(tabModel, relatedTabs);
                        int srcIndex = positions.second;
                        int desIndex = positions.first;

                        // If only the desIndex is valid then the movedTab was already part of
                        // another group and is not present in the model. This happens only during
                        // an undo.
                        // Refresh just the desIndex tab card in the model. The removal of the
                        // movedTab from its previous group was already handled by
                        // didMoveTabOutOfGroup.
                        if (desIndex != TabModel.INVALID_TAB_INDEX
                                && srcIndex == TabModel.INVALID_TAB_INDEX) {
                            Tab tab =
                                    tabModel.getTabById(
                                            mModel.get(desIndex).model.get(TabProperties.TAB_ID));
                            updateTab(desIndex, tab, false, false);
                            return;
                        }

                        if (!isValidMovePosition(srcIndex) || !isValidMovePosition(desIndex)) {
                            return;
                        }

                        Tab newSelectedTabInMergedGroup = null;
                        mModel.removeAt(srcIndex);
                        if (getRelatedTabsForId(movedTab.getId()).size() == 2) {
                            // When users use drop-to-merge to create a group.
                            RecordUserAction.record("TabGroup.Created.DropToMerge");
                        } else {
                            RecordUserAction.record("TabGrid.Drag.DropToMerge");
                        }
                        desIndex =
                                srcIndex > desIndex ? desIndex : mModel.getTabIndexBefore(desIndex);
                        newSelectedTabInMergedGroup =
                                filter.getTabAt(mModel.getTabCardCountsBefore(desIndex));
                        updateTab(desIndex, newSelectedTabInMergedGroup, true, false);
                    } else {
                        // If the model is empty we can't check if the added tab is part of the
                        // current group. Assume it isn't since a group state with 0 tab should be
                        // impossible.
                        if (mModel.size() == 0) return;

                        // If the added tab is part of the group add it and update the dialog.
                        int firstTabId = mModel.get(0).model.get(TabProperties.TAB_ID);
                        Tab firstTab = tabModel.getTabById(firstTabId);
                        if (firstTab == null || firstTab.getRootId() != movedTab.getRootId()) {
                            return;
                        }

                        movedTab.addObserver(mTabObserver);
                        onTabAdded(movedTab, /* onlyShowRelatedTabs= */ true);
                        if (mTabGridDialogHandler != null) {
                            mTabGridDialogHandler.updateDialogContent(
                                    filter.getGroupLastShownTabId(firstTab.getRootId()));
                        }
                    }
                }

                @Override
                public void didMoveTabGroup(
                        Tab movedTab, int tabModelOldIndex, int tabModelNewIndex) {
                    assert mShowingTabs;

                    if (!mActionsOnAllRelatedTabs || tabModelNewIndex == tabModelOldIndex) {
                        return;
                    }
                    List<Tab> relatedTabs = getRelatedTabsForId(movedTab.getId());
                    TabModelFilter filter = mCurrentTabModelFilterSupplier.get();
                    Tab currentGroupSelectedTab =
                            TabGroupUtils.getSelectedTabInGroupForTab(
                                    (TabGroupModelFilter) filter, movedTab);
                    TabModel tabModel = filter.getTabModel();
                    int curPosition = mModel.indexFromId(currentGroupSelectedTab.getId());
                    if (curPosition == TabModel.INVALID_TAB_INDEX) {
                        // Sync TabListModel with updated TabGroupModelFilter.
                        int indexToUpdate =
                                mModel.indexOfNthTabCard(
                                        filter.indexOf(tabModel.getTabAt(tabModelOldIndex)));
                        mModel.updateTabListModelIdForGroup(currentGroupSelectedTab, indexToUpdate);
                        curPosition = mModel.indexFromId(currentGroupSelectedTab.getId());
                    }
                    if (!isValidMovePosition(curPosition)) return;

                    // Find the tab which was in the destination index before this move. Use
                    // that tab to figure out the new position.
                    int destinationTabIndex =
                            tabModelNewIndex > tabModelOldIndex
                                    ? tabModelNewIndex - relatedTabs.size()
                                    : tabModelNewIndex + 1;
                    Tab destinationTab = tabModel.getTabAt(destinationTabIndex);
                    Tab destinationGroupSelectedTab =
                            TabGroupUtils.getSelectedTabInGroupForTab(
                                    (TabGroupModelFilter) filter, destinationTab);
                    int newPosition = mModel.indexFromId(destinationGroupSelectedTab.getId());
                    if (newPosition == TabModel.INVALID_TAB_INDEX) {
                        int indexToUpdate =
                                mModel.indexOfNthTabCard(
                                        filter.indexOf(destinationTab)
                                                + (tabModelNewIndex > tabModelOldIndex ? 1 : -1));
                        mModel.updateTabListModelIdForGroup(
                                destinationGroupSelectedTab, indexToUpdate);
                        newPosition = mModel.indexFromId(destinationGroupSelectedTab.getId());
                    }
                    if (!isValidMovePosition(newPosition)) return;

                    mModel.move(curPosition, newPosition);
                }

                @Override
                public void didCreateNewGroup(Tab destinationTab, TabGroupModelFilter filter) {
                    // On new group creation for the tab group representation in the GTS, update
                    // the tab group color icon.
                    if (ChromeFeatureList.sTabGroupParityAndroid.isEnabled()
                            && mMode == TabListMode.LIST) {
                        int groupIndex = filter.indexOf(destinationTab);
                        Tab groupTab = filter.getTabAt(groupIndex);
                        PropertyModel model = getModelFromId(groupTab.getId());

                        if (model != null) {
                            @TabGroupColorId
                            int colorId =
                                    filter.getTabGroupColorWithFallback(destinationTab.getRootId());
                            model.set(TabProperties.TAB_GROUP_COLOR_ID, colorId);
                        }
                    }
                }
            };

    /** Interface for implementing a {@link Runnable} that takes a tabId for a generic action. */
    public interface TabActionListener {
        /** Run the action for the given view and tabId. */
        void run(View view, int tabId);
    }

    /**
     * Construct the Mediator with the given Models and observing hooks from the given
     * ChromeActivity.
     *
     * @param context The context used to get some configuration information.
     * @param model The Model to keep state about a list of {@link Tab}s.
     * @param mode The {@link TabListMode}
     * @param modalDialogManager The {@link ModalDialogManager} for managing dialog lifecycles.
     * @param thumbnailProvider {@link ThumbnailProvider} to provide screenshot related details.
     * @param tabListFaviconProvider Provider for all favicon related drawables.
     * @param tabGroupColorFaviconProvider Provider for tab group color favicon related drawables.
     * @param actionOnRelatedTabs Whether tab-related actions should be operated on all related
     *     tabs.
     * @param selectionDelegateProvider Provider for a {@link SelectionDelegate} that is used for a
     *     selectable list. It's null when selection is not possible.
     * @param gridCardOnClickListenerProvider Provides the onClickListener for opening dialog when
     *     click on a grid card.
     * @param dialogHandler A handler to handle requests about updating TabGridDialog.
     * @param priceWelcomeMessageControllerSupplier A supplier of a controller to show
     *     PriceWelcomeMessage.
     * @param componentName This is a unique string to identify different components.
     * @param initialTabActionState The initial {@link TabActionState} to use for the shown tabs.
     *     Must always be CLOSABLE for TabListMode.STRIP.
     * @param onTabGroupCreation Should be run when the UI is used to create a tab group.
     */
    public TabListMediator(
            Context context,
            TabListModel model,
            @TabListMode int mode,
            @Nullable ModalDialogManager modalDialogManager,
            @NonNull ObservableSupplier<TabModelFilter> tabModelFilterSupplier,
            @Nullable ThumbnailProvider thumbnailProvider,
            TabListFaviconProvider tabListFaviconProvider,
            @NonNull TabGroupColorFaviconProvider tabGroupColorFaviconProvider,
            boolean actionOnRelatedTabs,
            @Nullable SelectionDelegateProvider selectionDelegateProvider,
            @Nullable GridCardOnClickListenerProvider gridCardOnClickListenerProvider,
            @Nullable TabGridDialogHandler dialogHandler,
            @NonNull Supplier<PriceWelcomeMessageController> priceWelcomeMessageControllerSupplier,
            String componentName,
            @TabActionState int initialTabActionState,
            @NonNull ActionConfirmationManager actionConfirmationManager,
            @Nullable Runnable onTabGroupCreation) {
        mContext = context;
        mModalDialogManager = modalDialogManager;
        mCurrentTabModelFilterSupplier = tabModelFilterSupplier;
        mThumbnailProvider = thumbnailProvider;
        mModel = model;
        mMode = mode;
        mTabListFaviconProvider = tabListFaviconProvider;
        mTabGroupColorFaviconProvider = tabGroupColorFaviconProvider;
        mComponentName = componentName;
        mSelectionDelegateProvider = selectionDelegateProvider;
        mGridCardOnClickListenerProvider = gridCardOnClickListenerProvider;
        mTabGridDialogHandler = dialogHandler;
        mActionsOnAllRelatedTabs = actionOnRelatedTabs;
        mTabActionState = initialTabActionState;
        mPriceWelcomeMessageControllerSupplier = priceWelcomeMessageControllerSupplier;
        mProfile = mCurrentTabModelFilterSupplier.get().getTabModel().getProfile();
        mActionConfirmationManager = actionConfirmationManager;
        mOnTabGroupCreation = onTabGroupCreation;

        mTabModelObserver =
                new TabModelObserver() {
                    @Override
                    public void didSelectTab(Tab tab, int type, int lastId) {
                        assert mShowingTabs;

                        mNextTabId = Tab.INVALID_TAB_ID;
                        if (tab.getId() == lastId) return;

                        int oldIndex = mModel.indexFromId(lastId);
                        if (oldIndex == TabModel.INVALID_TAB_INDEX && mActionsOnAllRelatedTabs) {
                            oldIndex = getIndexForTabIdWithRelatedTabs(lastId);
                        }
                        int newIndex = mModel.indexFromId(tab.getId());
                        if (newIndex == TabModel.INVALID_TAB_INDEX && mActionsOnAllRelatedTabs) {
                            // If a tab in tab group does not exist in model and needs to be
                            // selected, identify the related tab ids and determine newIndex
                            // based on if any of the related ids are present in model.
                            newIndex = getIndexForTabWithRelatedTabs(tab);
                            // For UNDO ensure we update the representative tab in the model.
                            if (type == TabSelectionType.FROM_UNDO
                                    && newIndex != Tab.INVALID_TAB_ID) {
                                model.updateTabListModelIdForGroup(tab, newIndex);
                            }
                        }

                        mLastSelectedTabListModelIndex = oldIndex;
                        if (mTabToAddDelayed != null && mTabToAddDelayed == tab) {
                            // If tab is being added later, it will be selected later.
                            return;
                        }
                        selectTab(oldIndex, newIndex);
                    }

                    @Override
                    public void tabClosureUndone(Tab tab) {
                        assert mShowingTabs;

                        tab.addObserver(mTabObserver);
                        onTabAdded(tab, !mActionsOnAllRelatedTabs);

                        if (sTabClosedFromMapTabClosedFromMap.containsKey(tab.getId())) {
                            @TabClosedFrom
                            int from = sTabClosedFromMapTabClosedFromMap.get(tab.getId());
                            switch (from) {
                                case TabClosedFrom.TAB_STRIP:
                                    RecordUserAction.record("TabStrip.UndoCloseTab");
                                    break;
                                case TabClosedFrom.GRID_TAB_SWITCHER:
                                    RecordUserAction.record("GridTabSwitch.UndoCloseTab");
                                    break;
                                case TabClosedFrom.GRID_TAB_SWITCHER_GROUP:
                                    RecordUserAction.record("GridTabSwitcher.UndoCloseTabGroup");
                                    break;
                                default:
                                    assert false
                                            : "tabClosureUndone for tab that closed from an unknown"
                                                    + " UI";
                            }
                            sTabClosedFromMapTabClosedFromMap.remove(tab.getId());
                        }
                        // TODO(yuezhanggg): clean up updateTab() calls in this class.
                        if (mActionsOnAllRelatedTabs) {
                            TabModelFilter filter = mCurrentTabModelFilterSupplier.get();
                            int filterIndex = filter.indexOf(tab);
                            if (filterIndex == TabList.INVALID_TAB_INDEX
                                    || !filter.isTabInTabGroup(tab)
                                    || filterIndex >= mModel.size()) {
                                return;
                            }
                            Tab currentGroupSelectedTab = filter.getTabAt(filterIndex);

                            int tabListModelIndex = mModel.indexOfNthTabCard(filterIndex);
                            assert mModel.indexFromId(currentGroupSelectedTab.getId())
                                    == tabListModelIndex;

                            updateTab(tabListModelIndex, currentGroupSelectedTab, false, false);
                        }
                    }

                    @Override
                    public void didAddTab(
                            Tab tab,
                            @TabLaunchType int type,
                            @TabCreationState int creationState,
                            boolean markedForSelection) {
                        assert mShowingTabs;

                        TabModelFilter filter = mCurrentTabModelFilterSupplier.get();
                        if (filter == null || !filter.isTabModelRestored()) {
                            return;
                        }

                        tab.addObserver(mTabObserver);

                        // Check if we need to delay tab addition to model.
                        boolean delayAdd =
                                (type == TabLaunchType.FROM_TAB_SWITCHER_UI)
                                        && markedForSelection
                                        && TabSwitcherPaneCoordinator.COMPONENT_NAME.equals(
                                                mComponentName);
                        if (delayAdd) {
                            mTabToAddDelayed = tab;
                            return;
                        }

                        onTabAdded(tab, !mActionsOnAllRelatedTabs);
                        if (type == TabLaunchType.FROM_RESTORE && mActionsOnAllRelatedTabs) {
                            // When tab is restored after restoring stage (e.g. exiting multi-window
                            // mode, switching between dark/light mode in incognito), we need to
                            // update related property models.
                            int filterIndex = filter.indexOf(tab);
                            if (filterIndex == TabList.INVALID_TAB_INDEX) return;
                            Tab currentGroupSelectedTab = filter.getTabAt(filterIndex);
                            // TabModel and TabListModel may be in the process of syncing up through
                            // restoring. Examples of this situation are switching between
                            // light/dark mode in incognito, exiting multi-window mode, etc.
                            int tabListModelIndex = mModel.indexOfNthTabCard(filterIndex);
                            if (mModel.indexFromId(currentGroupSelectedTab.getId())
                                    != tabListModelIndex) {
                                return;
                            }
                            updateTab(tabListModelIndex, currentGroupSelectedTab, false, false);
                        }
                    }

                    @Override
                    public void willCloseTab(Tab tab, boolean didCloseAlone) {
                        assert mShowingTabs;

                        tab.removeObserver(mTabObserver);

                        // If the tab closed was part of a tab group and the closure was triggered
                        // from the tab switcher, update the group to reflect the closure instead of
                        // closing the tab.
                        if (mActionsOnAllRelatedTabs
                                && (mCurrentTabModelFilterSupplier.get()
                                        instanceof TabGroupModelFilter groupFilter)
                                && groupFilter.tabGroupExistsForRootId(tab.getRootId())) {
                            int groupIndex = groupFilter.indexOf(tab);
                            Tab groupTab = groupFilter.getTabAt(groupIndex);
                            if (!groupTab.isClosing()) {
                                updateTab(
                                        mModel.indexOfNthTabCard(groupIndex),
                                        groupTab,
                                        true,
                                        false);

                                return;
                            }
                        }

                        if (mModel.indexFromId(tab.getId()) == TabModel.INVALID_TAB_INDEX) return;
                        mModel.removeAt(mModel.indexFromId(tab.getId()));
                    }

                    @Override
                    public void tabRemoved(Tab tab) {
                        assert mShowingTabs;

                        tab.removeObserver(mTabObserver);

                        if (mModel.indexFromId(tab.getId()) == TabModel.INVALID_TAB_INDEX) return;
                        mModel.removeAt(mModel.indexFromId(tab.getId()));
                    }
                };

        // TODO(meiliang): follow up with unit tests to test the close signal is sent correctly with
        // the recommendedNextTab.
        mTabClosedListener =
                new TabActionListener() {
                    @Override
                    public void run(View view, int tabId) {
                        // TODO(crbug.com/40638921): Consider disabling all touch events during
                        // animation.
                        if (mModel.indexFromId(tabId) == TabModel.INVALID_TAB_INDEX) return;

                        TabGroupModelFilter filter =
                                (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get();
                        TabModel tabModel = filter.getTabModel();
                        Tab closingTab = tabModel.getTabById(tabId);
                        if (closingTab == null) return;

                        if (mActionsOnAllRelatedTabs || filter.isIncognito()) {
                            doCloseTab(tabId, closingTab, filter, /* allowUndo= */ true);
                            return;
                        }

                        Callback<Integer> onResult =
                                (@ConfirmationResult Integer result) -> {
                                    if (result == ConfirmationResult.CONFIRMATION_NEGATIVE) {
                                        // If this is being invoked because of a swipe, the view
                                        // element has been removed. We need to bring that back.
                                        // This is done by just triggering a model update for that
                                        // index.
                                        int lastIndex = mModel.size() - 1;
                                        if (lastIndex >= 0) {
                                            mModel.update(lastIndex, mModel.get(lastIndex));
                                        }
                                    } else {
                                        doCloseTab(
                                                tabId,
                                                closingTab,
                                                filter,
                                                result == ConfirmationResult.IMMEDIATE_CONTINUE);
                                    }
                                };

                        mActionConfirmationManager.processCloseTabAttempt(
                                Collections.singletonList(closingTab.getId()), onResult);
                    }

                    private void doCloseTab(
                            int tabId,
                            Tab closingTab,
                            TabGroupModelFilter filter,
                            boolean allowUndo) {
                        RecordUserAction.record("MobileTabClosed." + mComponentName);

                        setUseShrinkCloseAnimation(tabId, /* useShrinkCloseAnimation= */ true);
                        if (mActionsOnAllRelatedTabs && filter.isTabInTabGroup(closingTab)) {
                            List<Tab> related = getRelatedTabsForId(tabId);
                            onGroupClosedFrom(tabId);
                            filter.closeTabs(
                                    TabClosureParams.closeTabs(related)
                                            .allowUndo(allowUndo)
                                            .hideTabGroups(true)
                                            .build());
                        } else {
                            TabModel tabModel = filter.getTabModel();
                            onTabClosedFrom(tabId, mComponentName);

                            Tab currentTab = TabModelUtils.getCurrentTab(tabModel);
                            Tab nextTab = currentTab == closingTab ? getNextTab(tabId) : null;

                            tabModel.closeTabs(
                                    TabClosureParams.closeTab(closingTab)
                                            .recommendedNextTab(nextTab)
                                            .allowUndo(allowUndo)
                                            .build());
                        }
                    }

                    private Tab getNextTab(int closingTabId) {
                        int closingTabIndex = mModel.indexFromId(closingTabId);

                        if (closingTabIndex == TabModel.INVALID_TAB_INDEX) {
                            assert false;
                            return null;
                        }

                        int nextTabId = Tab.INVALID_TAB_ID;
                        if (mModel.size() > 1) {
                            int nextTabIndex =
                                    closingTabIndex == 0
                                            ? mModel.getTabIndexAfter(closingTabIndex)
                                            : mModel.getTabIndexBefore(closingTabIndex);
                            nextTabId =
                                    nextTabIndex == TabModel.INVALID_TAB_INDEX
                                            ? Tab.INVALID_TAB_ID
                                            : mModel.get(nextTabIndex)
                                                    .model
                                                    .get(TabProperties.TAB_ID);
                        }

                        return mCurrentTabModelFilterSupplier
                                .get()
                                .getTabModel()
                                .getTabById(nextTabId);
                    }
                };

        TabActionListener swipeSafeTabActionListener =
                (view, tabId) -> {
                    // The DefaultItemAnimator is prone to crashing in combination with the swipe
                    // animation when closing the last tab.
                    // Avoid this issue by disabling the default item animation for the duration of
                    // the removal of the last tab. This is a framework issue. For more details see
                    // crbug/1319859.
                    boolean shouldDisableItemAnimations =
                            mCurrentTabModelFilterSupplier.hasValue()
                                    && mCurrentTabModelFilterSupplier.get().getTotalTabCount() <= 1;
                    if (shouldDisableItemAnimations) {
                        mRecyclerViewItemAnimationToggle.setDisableItemAnimations(true);
                    }

                    mTabClosedListener.run(view, tabId);

                    // It is necessary to post the restoration as otherwise any animation triggered
                    // by removing the tab will still use the animator as they are also posted to
                    // the UI thread.
                    if (shouldDisableItemAnimations) {
                        new Handler()
                                .post(
                                        () -> {
                                            mRecyclerViewItemAnimationToggle
                                                    .setDisableItemAnimations(false);
                                        });
                    }
                };

        var tabGroupCreationDialogManager =
                new TabGroupCreationDialogManager(context, modalDialogManager, mOnTabGroupCreation);
        mTabGridItemTouchHelperCallback =
                new TabGridItemTouchHelperCallback(
                        context,
                        tabGroupCreationDialogManager,
                        mModel,
                        mCurrentTabModelFilterSupplier,
                        swipeSafeTabActionListener,
                        mTabGridDialogHandler,
                        mComponentName,
                        mActionsOnAllRelatedTabs,
                        mMode);
    }

    /**
     * @param onLongPressTabItemEventListener to handle long press events on tabs.
     */
    public void setOnLongPressTabItemEventListener(
            @Nullable
                    TabGridItemTouchHelperCallback.OnLongPressTabItemEventListener
                            onLongPressTabItemEventListener) {
        mTabGridItemTouchHelperCallback.setOnLongPressTabItemEventListener(
                onLongPressTabItemEventListener);
    }

    void setRecyclerViewItemAnimationToggle(
            RecyclerViewItemAnimationToggle recyclerViewItemAnimationToggle) {
        mRecyclerViewItemAnimationToggle = recyclerViewItemAnimationToggle;
    }

    /**
     * @param size The default size to use for any new Tab cards.
     */
    void setDefaultGridCardSize(Size size) {
        mDefaultGridCardSize = size;
    }

    /**
     * @return The default size to use for any tab cards.
     */
    Size getDefaultGridCardSize() {
        return mDefaultGridCardSize;
    }

    private void selectTab(int oldIndex, int newIndex) {
        // TODO(crbug.com/347886633): Change the bounds check to an assert.
        if (oldIndex != TabModel.INVALID_TAB_INDEX && oldIndex < mModel.size()) {
            PropertyModel oldModel = mModel.get(oldIndex).model;
            int lastId = oldModel.get(TAB_ID);
            oldModel.set(TabProperties.IS_SELECTED, false);
            if (mActionsOnAllRelatedTabs && mThumbnailProvider != null && mShowingTabs) {
                updateThumbnailFetcher(oldModel, lastId);
            }
        }

        if (newIndex != TabModel.INVALID_TAB_INDEX) {
            PropertyModel newModel = mModel.get(newIndex).model;
            int newId = newModel.get(TAB_ID);
            newModel.set(TabProperties.IS_SELECTED, true);
            if (mThumbnailProvider != null && mShowingTabs) {
                updateThumbnailFetcher(newModel, newId);
            }
        }
    }

    public void initWithNative(Profile profile) {
        assert !profile.isOffTheRecord() : "Expecting a non-incognito profile.";
        mProfile = profile;
        mTabListFaviconProvider.initWithNative(profile);

        mOnTabModelFilterChanged.onResult(
                mCurrentTabModelFilterSupplier.addObserver(mOnTabModelFilterChanged));

        mTabGroupTitleEditor =
                new TabGroupTitleEditor(mContext) {
                    @Override
                    protected void updateTabGroupTitle(Tab tab, String title) {
                        // Only update title in PropertyModel for tab switcher.
                        if (!mActionsOnAllRelatedTabs) return;
                        Tab currentGroupSelectedTab =
                                TabGroupUtils.getSelectedTabInGroupForTab(
                                        (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get(),
                                        tab);
                        int index = mModel.indexFromId(currentGroupSelectedTab.getId());
                        if (index == TabModel.INVALID_TAB_INDEX) return;

                        PropertyModel model = mModel.get(index).model;
                        model.set(TabProperties.TITLE, title);
                        updateDescriptionString(tab, model);
                        updateActionButtonDescriptionString(tab, model);
                    }

                    @Override
                    protected void deleteTabGroupTitle(int tabRootId) {
                        TabGroupModelFilter filter =
                                (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get();
                        filter.deleteTabGroupTitle(tabRootId);
                    }

                    @Override
                    protected String getTabGroupTitle(int tabRootId) {
                        TabGroupModelFilter filter =
                                (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get();
                        return filter.getTabGroupTitle(tabRootId);
                    }

                    @Override
                    protected void storeTabGroupTitle(int tabRootId, String title) {
                        TabGroupModelFilter filter =
                                (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get();
                        filter.setTabGroupTitle(tabRootId, title);
                    }
                };

        // Right now we need to update layout only if there is a price welcome message card in tab
        // switcher.
        if (mMode == TabListMode.GRID
                && mTabActionState != TabActionState.SELECTABLE
                && PriceTrackingFeatures.isPriceTrackingEnabled(profile)) {
            mListObserver =
                    new ListObserver<Void>() {
                        @Override
                        public void onItemRangeInserted(
                                ListObservable source, int index, int count) {
                            updateLayout();
                        }

                        @Override
                        public void onItemRangeRemoved(
                                ListObservable source, int index, int count) {
                            updateLayout();
                        }

                        @Override
                        public void onItemRangeChanged(
                                ListObservable<Void> source,
                                int index,
                                int count,
                                @Nullable Void payload) {
                            updateLayout();
                        }

                        @Override
                        public void onItemMoved(ListObservable source, int curIndex, int newIndex) {
                            updateLayout();
                        }
                    };
            mModel.addObserver(mListObserver);
        }
    }

    private void onTabClosedFrom(int tabId, String fromComponent) {
        @TabClosedFrom int from;
        if (fromComponent.equals(TabGroupUiCoordinator.COMPONENT_NAME)) {
            from = TabClosedFrom.TAB_STRIP;
        } else if (fromComponent.equals(TabSwitcherPaneCoordinator.COMPONENT_NAME)) {
            from = TabClosedFrom.GRID_TAB_SWITCHER;
        } else {
            Log.w(TAG, "Attempting to close tab from Unknown UI");
            return;
        }
        sTabClosedFromMapTabClosedFromMap.put(tabId, from);
    }

    private void onGroupClosedFrom(int tabId) {
        sTabClosedFromMapTabClosedFromMap.put(tabId, TabClosedFrom.GRID_TAB_SWITCHER_GROUP);
    }

    void setActionOnAllRelatedTabsForTesting(boolean actionOnAllRelatedTabs) {
        var oldValue = mActionsOnAllRelatedTabs;
        mActionsOnAllRelatedTabs = actionOnAllRelatedTabs;
        ResettersForTesting.register(() -> mActionsOnAllRelatedTabs = oldValue);
    }

    private List<Tab> getRelatedTabsForId(int id) {
        TabModelFilter filter = mCurrentTabModelFilterSupplier.get();
        return filter == null ? new ArrayList<>() : filter.getRelatedTabList(id);
    }

    private List<Integer> getRelatedTabsIds(int id) {
        TabModelFilter filter = mCurrentTabModelFilterSupplier.get();
        return filter == null ? new ArrayList<>() : filter.getRelatedTabIds(id);
    }

    private int getInsertionIndexOfTab(Tab tab, boolean onlyShowRelatedTabs) {
        int index = TabList.INVALID_TAB_INDEX;
        if (tab == null) return index;
        if (onlyShowRelatedTabs) {
            if (mModel.size() == 0) return TabList.INVALID_TAB_INDEX;
            List<Tab> related = getRelatedTabsForId(mModel.get(0).model.get(TabProperties.TAB_ID));
            index = related.indexOf(tab);
            if (index == -1) return TabList.INVALID_TAB_INDEX;
        } else {
            index =
                    mModel.indexOfNthTabCard(
                            TabModelUtils.getTabIndexById(
                                    mCurrentTabModelFilterSupplier.get(), tab.getId()));
            // TODO(wychen): the title (tab count in the group) is wrong when it's not the last
            //  tab added in the group.
        }
        return index;
    }

    private int onTabAdded(Tab tab, boolean onlyShowRelatedTabs) {
        int existingIndex = mModel.indexFromId(tab.getId());
        if (existingIndex != TabModel.INVALID_TAB_INDEX) return existingIndex;

        int newIndex = getInsertionIndexOfTab(tab, onlyShowRelatedTabs);
        if (newIndex == TabList.INVALID_TAB_INDEX) return newIndex;

        Tab currentTab =
                TabModelUtils.getCurrentTab(mCurrentTabModelFilterSupplier.get().getTabModel());
        addTabInfoToModel(tab, newIndex, currentTab == tab);
        return newIndex;
    }

    private boolean isValidMovePosition(int position) {
        return position != TabModel.INVALID_TAB_INDEX && position < mModel.size();
    }

    private boolean areTabsUnchanged(@Nullable List<Tab> tabs) {
        int tabsCount = 0;
        for (int i = 0; i < mModel.size(); i++) {
            if (mModel.get(i).model.get(CARD_TYPE) == TAB) {
                tabsCount += 1;
            }
        }
        if (tabs == null) {
            return tabsCount == 0;
        }
        if (tabs.size() != tabsCount) return false;
        int tabsIndex = 0;
        for (int i = 0; i < mModel.size(); i++) {
            PropertyModel model = mModel.get(i).model;
            if (model.get(CARD_TYPE) == TAB
                    && model.get(TabProperties.TAB_ID) != tabs.get(tabsIndex++).getId()) {
                return false;
            }
        }
        return true;
    }

    /**
     * Initialize the component with a list of tabs to show in a grid.
     *
     * @param tabs The list of tabs to be shown.
     * @param quickMode Whether to skip capturing the selected live tab for the thumbnail.
     * @return Whether the {@link TabListRecyclerView} can be shown quickly.
     */
    boolean resetWithListOfTabs(@Nullable List<Tab> tabs, boolean quickMode) {
        mShowingTabs = tabs != null;
        TabModelFilter filter = mCurrentTabModelFilterSupplier.get();
        if (mShowingTabs) {
            addObservers(filter, tabs);
        } else {
            removeObservers(filter);
        }
        if (tabs != null) {
            recordPriceAnnotationsEnabledMetrics();
        }
        if (areTabsUnchanged(tabs)) {
            if (tabs == null) return true;

            for (int i = 0; i < tabs.size(); i++) {
                Tab tab = tabs.get(i);
                updateTab(mModel.indexOfNthTabCard(i), tab, false, quickMode);
            }
            mLastSelectedTabListModelIndex = TabList.INVALID_TAB_INDEX;
            return true;
        }
        mModel.set(new ArrayList<>());
        mLastSelectedTabListModelIndex = TabList.INVALID_TAB_INDEX;

        if (tabs == null) {
            return true;
        }
        int currentTabId = TabModelUtils.getCurrentTabId(filter.getTabModel());

        for (int i = 0; i < tabs.size(); i++) {
            Tab tab = tabs.get(i);
            addTabInfoToModel(tab, i, isSelectedTab(tab, currentTabId));
        }

        return false;
    }

    /**
     * Add the tab id of a {@Tab} that has been viewed to the sViewedTabIds set.
     * @param tabIndex  The tab index of a {@Tab} the user has viewed.
     */
    private void addViewedTabId(int tabIndex) {
        TabModel tabModel = mCurrentTabModelFilterSupplier.get().getTabModel();
        assert !tabModel.isIncognito();
        int tabId = mModel.get(tabIndex).model.get(TabProperties.TAB_ID);
        assert tabModel.getTabById(tabId) != null;
        sViewedTabIds.add(tabId);
    }

    void postHiding() {
        removeObservers(mCurrentTabModelFilterSupplier.get());
        mShowingTabs = false;
        unregisterOnScrolledListener();
        // if tab was marked for add later, add to model and mark as selected.
        if (mTabToAddDelayed != null) {
            int index = onTabAdded(mTabToAddDelayed, !mActionsOnAllRelatedTabs);
            selectTab(mLastSelectedTabListModelIndex, index);
            mTabToAddDelayed = null;
        }
    }

    private boolean isSelectedTab(Tab tab, int tabModelSelectedTabId) {
        SelectionDelegate<Integer> selectionDelegate = getTabSelectionDelegate();
        if (selectionDelegate == null) {
            return tab.getId() == tabModelSelectedTabId;
        } else {
            return selectionDelegate.isItemSelected(tab.getId());
        }
    }

    /**
     * @see TabSwitcherMediator.ResetHandler#softCleanup
     */
    void softCleanup() {
        assert !mShowingTabs;
        for (int i = 0; i < mModel.size(); i++) {
            PropertyModel model = mModel.get(i).model;
            if (model.get(CARD_TYPE) == TAB) {
                updateThumbnailFetcher(model, Tab.INVALID_TAB_ID);
                model.set(TabProperties.FAVICON_FETCHER, null);
            }
        }
    }

    void hardCleanup() {
        assert !mShowingTabs;
        if (mProfile != null
                && PriceTrackingUtilities.isTrackPricesOnTabsEnabled(mProfile)
                && (PriceTrackingFeatures.isPriceDropIphEnabled(mProfile)
                        || PriceTrackingFeatures.isPriceDropBadgeEnabled(mProfile))) {
            saveSeenPriceDrops();
        }
        sViewedTabIds.clear();
    }

    /**
     * While leaving the tab switcher grid this update whether a tab's current price drop has or has
     * not been seen.
     */
    // TODO(crbug.com/343206772): Move code to TabSwitcherPane.
    private void saveSeenPriceDrops() {
        // The filter determines what's shown in the tab list.
        TabModelFilter filter = mCurrentTabModelFilterSupplier.get();
        // The filter's underlying model should have any tab that was viewed.
        TabModel model = filter.getTabModel();
        for (Integer tabId : sViewedTabIds) {
            Tab tab = model.getTabById(tabId);
            if (tab != null && !filter.isTabInTabGroup(tab)) {
                ShoppingPersistedTabData.from(
                        tab,
                        (sptd) -> {
                            if (sptd != null && sptd.getPriceDrop() != null) {
                                sptd.setIsCurrentPriceDropSeen(true);
                            }
                        });
            }
        }
    }

    private void updateTab(int index, Tab tab, boolean isUpdatingId, boolean quickMode) {
        if (index < 0 || index >= mModel.size()) return;

        PropertyModel model = mModel.get(index).model;
        if (isUpdatingId) {
            model.set(TabProperties.TAB_ID, tab.getId());
        } else {
            assert model.get(TabProperties.TAB_ID) == tab.getId();
        }

        boolean isTabSelected = isTabSelected(mTabActionState, tab);
        boolean isInTabGroup = isTabInTabGroup(tab);
        if (ChromeFeatureList.sTabGroupParityAndroid.isEnabled()) {
            // If the tab to update is in ListMode, update it with the most recent stored color.
            if (mMode == TabListMode.LIST) {
                int tabGroupColorId = TabGroupColorUtils.INVALID_COLOR_ID;
                // Only update the color if the tab is a representation of a tab group, otherwise
                // hide the icon by setting the color to INVALID.
                if (isInTabGroup) {
                    TabGroupModelFilter filter =
                            (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get();
                    tabGroupColorId = filter.getTabGroupColorWithFallback(tab.getRootId());
                }

                model.set(TabProperties.TAB_GROUP_COLOR_ID, tabGroupColorId);
            }
        }

        model.set(TabProperties.TAB_CLICK_LISTENER, getTabActionListener(tab, isInTabGroup));
        model.set(TabProperties.IS_SELECTED, isTabSelected);
        model.set(TabProperties.SHOULD_SHOW_PRICE_DROP_TOOLTIP, false);
        model.set(TabProperties.TITLE, getLatestTitleForTab(tab, /* useDefault= */ true));

        // A tab is deemed a tab group card representation if it is part of a tab group and
        // based in the tab switcher.
        boolean isTabGroup = isTabInTabGroup(tab) && isParentComponentTabSwitcher();
        // Update the group color icon.
        if (ChromeFeatureList.sTabGroupParityAndroid.isEnabled() && isTabGroup) {
            updateFaviconForTab(tab, null, null);
        }
        // The ordering of TAB_ACTION_BUTTON_LISTENER and IS_TAB_GROUP must be preserved when
        // setting the property keys on the model. Both properties modify the onClickListener
        // so ensure that the default behavior (close on click) is set first, and tab groups
        // under valid circumstances will override the listener with alternate behavior.
        if (!ChromeFeatureList.sTabGroupPaneAndroid.isEnabled() || !isTabGroup) {
            model.set(TabProperties.TAB_ACTION_BUTTON_LISTENER, mTabClosedListener);
        }
        // Only set this for tab group representation cards. An onClickListener will be set in the
        // view as part of the accompanying logic.
        if (ChromeFeatureList.sTabGroupPaneAndroid.isEnabled()) {
            model.set(
                    TabProperties.TAB_GROUP_INFO,
                    new TabGroupInfo(
                            TabGroupSyncFeatures.isTabGroupSyncEnabled(mProfile), isTabGroup));
        }

        bindTabActionStateProperties(model.get(TabProperties.TAB_ACTION_STATE), tab, model);

        model.set(TabProperties.URL_DOMAIN, getDomainForTab(tab));

        setupPersistedTabDataFetcherForTab(tab, index);

        updateFaviconForTab(tab, null, null);
        boolean forceUpdate = isTabSelected && !quickMode;
        boolean forceUpdateLastSelected =
                mActionsOnAllRelatedTabs && index == mLastSelectedTabListModelIndex && !quickMode;
        // TODO(crbug.com/40273706): Fetching thumbnail for group is expansive, we should consider
        // to improve it.
        if (mThumbnailProvider != null
                && mShowingTabs
                && (model.get(THUMBNAIL_FETCHER) == null
                        || forceUpdate
                        || isUpdatingId
                        || forceUpdateLastSelected
                        || isInTabGroup)) {
            updateThumbnailFetcher(model, tab.getId());
        }
    }

    @VisibleForTesting
    public boolean isTabInTabGroup(@NonNull Tab tab) {
        TabModelFilter filter = mCurrentTabModelFilterSupplier.get();
        assert filter.isTabModelRestored();

        return filter.isTabInTabGroup(tab);
    }

    public Set<Integer> getViewedTabIdsForTesting() {
        return sViewedTabIds;
    }

    /**
     * @return The callback that hosts the logic for swipe and drag related actions.
     */
    ItemTouchHelper.SimpleCallback getItemTouchHelperCallback(
            final float swipeToDismissThreshold,
            final float mergeThreshold,
            final float ungroupThreshold) {
        mTabGridItemTouchHelperCallback.setupCallback(
                swipeToDismissThreshold, mergeThreshold, ungroupThreshold);
        return mTabGridItemTouchHelperCallback;
    }

    void registerOrientationListener(GridLayoutManager manager) {
        mComponentCallbacks =
                new ComponentCallbacks() {
                    @Override
                    public void onConfigurationChanged(Configuration newConfig) {
                        updateSpanCount(manager, newConfig.screenWidthDp);
                        if (mMode == TabListMode.GRID
                                && mTabActionState != TabActionState.SELECTABLE) {
                            updateLayout();
                        }
                    }

                    @Override
                    public void onLowMemory() {}
                };
        mContext.registerComponentCallbacks(mComponentCallbacks);
        mGridLayoutManager = manager;
    }

    /**
     * Update the grid layout span count and span size lookup base on orientation.
     * @param manager     The {@link GridLayoutManager} used to update the span count.
     * @param screenWidthDp The screnWidth based on which we update the span count.
     * @return whether the span count changed.
     */
    boolean updateSpanCount(GridLayoutManager manager, int screenWidthDp) {
        final int oldSpanCount = manager.getSpanCount();
        final int newSpanCount = getSpanCount(screenWidthDp);
        manager.setSpanCount(newSpanCount);
        manager.setSpanSizeLookup(
                new GridLayoutManager.SpanSizeLookup() {
                    @Override
                    public int getSpanSize(int position) {
                        int itemType = mModel.get(position).type;

                        if (itemType == TabProperties.UiType.MESSAGE
                                || itemType == TabProperties.UiType.LARGE_MESSAGE
                                || itemType == TabProperties.UiType.CUSTOM_MESSAGE) {
                            return manager.getSpanCount();
                        }
                        return 1;
                    }
                });
        return oldSpanCount != newSpanCount;
    }

    /**
     * Adds an on scroll listener to {@link TabListRecyclerView} that determines whether a tab
     * thumbnail is within view after a scroll is completed.
     * @param recyclerView the {@link TabListRecyclerView} to add the listener too.
     */
    void registerOnScrolledListener(RecyclerView recyclerView) {
        // For InstantStart, this can be called before native is initialized, so ensure the Profile
        // is available before proceeding.
        if (mProfile == null) return;

        if (PriceTrackingUtilities.isTrackPricesOnTabsEnabled(mProfile)
                && (PriceTrackingFeatures.isPriceDropIphEnabled(mProfile)
                        || PriceTrackingFeatures.isPriceDropBadgeEnabled(mProfile))) {
            mRecyclerView = recyclerView;
            mOnScrollListener =
                    new OnScrollListener() {
                        @Override
                        public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
                            if (!mCurrentTabModelFilterSupplier.get().getTabModel().isIncognito()) {
                                for (int i = 0; i < mRecyclerView.getChildCount(); i++) {
                                    if (mRecyclerView
                                            .getLayoutManager()
                                            .isViewPartiallyVisible(
                                                    mRecyclerView.getChildAt(i), false, true)) {
                                        addViewedTabId(i);
                                    }
                                }
                            }
                        }
                    };
            mRecyclerView.addOnScrollListener(mOnScrollListener);
        }
    }

    private void unregisterOnScrolledListener() {
        if (mRecyclerView != null && mOnScrollListener != null) {
            mRecyclerView.removeOnScrollListener(mOnScrollListener);
            mOnScrollListener = null;
        }
    }

    /**
     * Span count is computed based on screen width for tablets and orientation for phones.
     * When in multi-window mode on phone, the span count is fixed to 2 to keep tab card size
     * reasonable.
     */
    private int getSpanCount(int screenWidthDp) {
        if (DeviceFormFactor.isNonMultiDisplayContextOnTablet(mContext)) {
            return screenWidthDp < TabListCoordinator.MAX_SCREEN_WIDTH_COMPACT_DP
                    ? TabListCoordinator.GRID_LAYOUT_SPAN_COUNT_COMPACT
                    : screenWidthDp < TabListCoordinator.MAX_SCREEN_WIDTH_MEDIUM_DP
                            ? TabListCoordinator.GRID_LAYOUT_SPAN_COUNT_MEDIUM
                            : TabListCoordinator.GRID_LAYOUT_SPAN_COUNT_LARGE;
        }
        return screenWidthDp < TabListCoordinator.MAX_SCREEN_WIDTH_COMPACT_DP
                ? TabListCoordinator.GRID_LAYOUT_SPAN_COUNT_COMPACT
                : TabListCoordinator.GRID_LAYOUT_SPAN_COUNT_MEDIUM;
    }

    /**
     * Setup the {@link View.AccessibilityDelegate} for grid layout.
     * @param helper The {@link TabGridAccessibilityHelper} used to setup accessibility support.
     */
    void setupAccessibilityDelegate(TabGridAccessibilityHelper helper) {
        mAccessibilityDelegate =
                new View.AccessibilityDelegate() {
                    @Override
                    public void onInitializeAccessibilityNodeInfo(
                            View host, AccessibilityNodeInfo info) {
                        super.onInitializeAccessibilityNodeInfo(host, info);
                        for (AccessibilityAction action : helper.getPotentialActionsForView(host)) {
                            info.addAction(action);
                        }
                    }

                    @Override
                    public boolean performAccessibilityAction(View host, int action, Bundle args) {
                        if (!helper.isReorderAction(action)) {
                            return super.performAccessibilityAction(host, action, args);
                        }

                        Pair<Integer, Integer> positions =
                                helper.getPositionsOfReorderAction(host, action);
                        int currentPosition = positions.first;
                        int targetPosition = positions.second;
                        if (!isValidMovePosition(currentPosition)
                                || !isValidMovePosition(targetPosition)) {
                            return false;
                        }
                        mModel.move(currentPosition, targetPosition);
                        RecordUserAction.record("TabGrid.AccessibilityDelegate.Reordered");
                        return true;
                    }
                };
    }

    /**
     * Exposes a {@link TabGroupTitleEditor} to modify the title of a tab group.
     * @return The {@link TabGroupTitleEditor} used to modify the title of a tab group.
     */
    @Nullable
    TabGroupTitleEditor getTabGroupTitleEditor() {
        return mTabGroupTitleEditor;
    }

    /** Destroy any members that needs clean up. */
    public void destroy() {
        if (mListObserver != null) {
            mModel.removeObserver(mListObserver);
        }
        removeObservers(mCurrentTabModelFilterSupplier.get());
        mCurrentTabModelFilterSupplier.removeObserver(mOnTabModelFilterChanged);

        if (mComponentCallbacks != null) {
            mContext.unregisterComponentCallbacks(mComponentCallbacks);
        }
        unregisterOnScrolledListener();
    }

    void setTabActionState(@TabActionState int tabActionState) {
        if (mTabActionState == tabActionState) return;
        mTabActionState = tabActionState;
        getTabSelectionDelegate().clearSelection();

        for (int i = 0; i < mModel.size(); i++) {
            ListItem item = mModel.get(i);
            if (item.type != UiType.TAB) continue;
            Tab tab = getTabForIndex(i);
            // Unbind the current TabActionState properties.
            PropertyModel model = item.model;
            unbindTabActionStateProperties(model);

            model.set(TabProperties.TAB_ACTION_STATE, mTabActionState);
            bindTabActionStateProperties(tabActionState, tab, model);
        }
    }

    private void unbindTabActionStateProperties(PropertyModel model) {
        model.set(TabProperties.IS_SELECTED, false);
        for (WritableObjectPropertyKey propertyKey : TabProperties.TAB_ACTION_STATE_OBJECT_KEYS) {
            model.set(propertyKey, null);
        }
    }

    private TabListMediator.TabActionListener getTabActionButtonListener(
            Tab tab, @TabActionState int tabActionState) {
        if (tabActionState == TabActionState.SELECTABLE) {
            return mSelectableTabOnClickListener;
        } else {
            return isTabInTabGroup(tab)
                    ? getTabGroupOverflowMenuClickListener()
                    : mTabClosedListener;
        }
    }

    private TabListMediator.TabActionListener getTabGroupOverflowMenuClickListener() {
        if (mTabListGroupMenuCoordinator == null) {
            boolean isTabGroupSyncEnabled = TabGroupSyncFeatures.isTabGroupSyncEnabled(mProfile);
            TabModel tabModel = mCurrentTabModelFilterSupplier.get().getTabModel();
            Profile profile = tabModel.getProfile().getOriginalProfile();
            IdentityManager identityManager = null;
            TabGroupSyncService tabGroupSyncService = null;
            DataSharingService dataSharingService = null;
            if (isTabGroupSyncEnabled
                    && ChromeFeatureList.isEnabled(ChromeFeatureList.DATA_SHARING)) {
                identityManager = IdentityServicesProvider.get().getIdentityManager(profile);
                tabGroupSyncService = TabGroupSyncServiceFactory.getForProfile(profile);
                dataSharingService = DataSharingServiceFactory.getForProfile(profile);
            }
            mTabListGroupMenuCoordinator =
                    new TabListGroupMenuCoordinator(
                            mOnMenuItemClickedCallback,
                            () -> mCurrentTabModelFilterSupplier.get().getTabModel(),
                            isTabGroupSyncEnabled,
                            identityManager,
                            tabGroupSyncService,
                            dataSharingService);
        }
        return mTabListGroupMenuCoordinator.getTabActionListener();
    }

    private TabListMediator.TabActionListener getTabClickListener(
            Tab tab, @TabActionState int tabActionState) {
        if (tabActionState == TabActionState.SELECTABLE) {
            return mSelectableTabOnClickListener;
        } else {
            if (isTabInTabGroup(tab)
                    && mActionsOnAllRelatedTabs
                    && mGridCardOnClickListenerProvider != null) {
                return mGridCardOnClickListenerProvider.openTabGridDialog(tab);
            } else {
                return mTabSelectedListener;
            }
        }
    }

    private TabListMediator.TabActionListener getTabLongClickListener(
            @TabActionState int tabActionState) {
        if (tabActionState == TabActionState.SELECTABLE) {
            return mSelectableTabOnClickListener;
        } else {
            return null;
        }
    }

    private void bindTabActionStateProperties(
            @TabActionState int tabActionState, Tab tab, PropertyModel model) {
        model.set(TabProperties.IS_SELECTED, isTabSelected(tabActionState, tab));

        model.set(
                TabProperties.TAB_ACTION_BUTTON_LISTENER,
                getTabActionButtonListener(tab, tabActionState));
        model.set(TabProperties.TAB_CLICK_LISTENER, getTabClickListener(tab, tabActionState));
        model.set(TabProperties.TAB_LONG_CLICK_LISTENER, getTabLongClickListener(tabActionState));
        model.set(TabProperties.TAB_CARD_LABEL_DATA, model.get(TabProperties.TAB_CARD_LABEL_DATA));

        if (mTabActionState == TabActionState.SELECTABLE) {
            // Incognito in both light/dark theme is the same as non-incognito mode in dark theme.
            // Non-incognito mode and incognito in both light/dark themes in dark theme all look
            // dark.
            ColorStateList checkedDrawableColorList =
                    ColorStateList.valueOf(
                            tab.isIncognito()
                                    ? mContext.getColor(R.color.default_icon_color_dark)
                                    : SemanticColorUtils.getDefaultIconColorInverse(mContext));
            ColorStateList actionButtonBackgroundColorList =
                    AppCompatResources.getColorStateList(
                            mContext,
                            tab.isIncognito()
                                    ? R.color.default_icon_color_light
                                    : R.color.default_icon_color_tint_list);
            // TODO(crbug.com/41477267): Update color baseline_primary_80 to active_color_dark when
            // the associated bug is landed.
            ColorStateList actionbuttonSelectedBackgroundColorList =
                    ColorStateList.valueOf(
                            tab.isIncognito()
                                    ? mContext.getColor(R.color.baseline_primary_80)
                                    : SemanticColorUtils.getDefaultControlColorActive(mContext));

            model.set(TabProperties.CHECKED_DRAWABLE_STATE_LIST, checkedDrawableColorList);
            model.set(
                    TabProperties.SELECTABLE_TAB_ACTION_BUTTON_BACKGROUND,
                    actionButtonBackgroundColorList);
            model.set(
                    TabProperties.SELECTABLE_TAB_ACTION_BUTTON_SELECTED_BACKGROUND,
                    actionbuttonSelectedBackgroundColorList);
        } else {
            model.set(
                    TabProperties.IS_SELECTED,
                    TabModelUtils.getCurrentTabId(
                                    mCurrentTabModelFilterSupplier.get().getTabModel())
                            == tab.getId());
            // A tab is deemed a tab group card representation if it is part of a tab group and
            // based in the tab switcher.
            boolean isTabGroup = isTabInTabGroup(tab) && isParentComponentTabSwitcher();
            // The ordering of TAB_ACTION_BUTTON_LISTENER and IS_TAB_GROUP must be preserved when
            // setting the property keys on the model. Both properties modify the onClickListener
            // so ensure that the default behavior (close on click) is set first, and tab groups
            // under valid circumstances will override the listener with alternate behavior.
            if (!ChromeFeatureList.sTabGroupPaneAndroid.isEnabled() || !isTabGroup) {
                model.set(TabProperties.TAB_ACTION_BUTTON_LISTENER, mTabClosedListener);
            }
            // Only set this for tab group representation cards. An onClickListener will be set in
            // the view as part of the accompanying logic.
            if (ChromeFeatureList.sTabGroupPaneAndroid.isEnabled()) {
                model.set(
                        TabProperties.TAB_GROUP_INFO,
                        new TabGroupInfo(
                                TabGroupSyncFeatures.isTabGroupSyncEnabled(mProfile), isTabGroup));
            }

            updateDescriptionString(tab, model);
            updateActionButtonDescriptionString(tab, model);
        }
    }

    private TabActionListener getTabActionListener(Tab tab, boolean isInTabGroup) {
        TabActionListener tabSelectedListener;
        if (mGridCardOnClickListenerProvider == null
                || !isInTabGroup
                || !mActionsOnAllRelatedTabs) {
            tabSelectedListener = mTabSelectedListener;
        } else {
            tabSelectedListener = mGridCardOnClickListenerProvider.openTabGridDialog(tab);
            if (tabSelectedListener == null) {
                tabSelectedListener = mTabSelectedListener;
            }
        }
        return tabSelectedListener;
    }

    private boolean isTabSelected(@TabActionState int tabActionState, Tab tab) {
        if (tabActionState == TabActionState.SELECTABLE) {
            SelectionDelegate selectionDelegate = getTabSelectionDelegate();
            assert selectionDelegate != null : "Null selection delegate while in SELECTABLE state.";
            return selectionDelegate.isItemSelected(tab.getId());
        } else {
            TabModel tabModel = mCurrentTabModelFilterSupplier.get().getTabModel();
            // If the tab is part of a group and also being displayed with single tabs, then there
            // is extra work needed to determine if it's selected. That is - go through all related
            // tabs, and if any is the selected tabs then the tab group is selected.
            if (mActionsOnAllRelatedTabs && tab.getTabGroupId() != null) {
                List<Tab> relatedTabs = getRelatedTabsForId(tab.getId());
                boolean isSelected = false;
                for (Tab relatedTab : relatedTabs) {
                    isSelected |= relatedTab == TabModelUtils.getCurrentTab(tabModel);
                }
                return isSelected;
            } else {
                return TabModelUtils.getCurrentTabId(tabModel) == tab.getId();
            }
        }
    }

    private void addTabInfoToModel(Tab tab, int index, boolean isSelected) {
        assert index != TabModel.INVALID_TAB_INDEX;
        boolean showIPH = false;
        boolean isInTabGroup = isTabInTabGroup(tab);
        if (mActionsOnAllRelatedTabs && !mShownIPH) {
            showIPH = isInTabGroup;
        }

        int colorId = TabGroupColorUtils.INVALID_COLOR_ID;
        if (ChromeFeatureList.sTabGroupParityAndroid.isEnabled()) {
            // While groups always have a color, only set it here when it should be shown next to
            // the title. In GRID mode this is not the case, as the color replaces the favicon.
            // Rather it's LIST mode where we do this, and additionally not when we've opened a
            // dialog for a particular group, checked by isParentComponentTabSwitcher().
            if (mMode == TabListMode.LIST && isInTabGroup && isParentComponentTabSwitcher()) {
                TabGroupModelFilter filter =
                        (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get();
                colorId = filter.getTabGroupColorWithFallback(tab.getRootId());
            }
        }

        int selectedTabBackgroundDrawableId =
                tab.isIncognito()
                        ? R.drawable.selected_tab_background_incognito
                        : R.drawable.selected_tab_background;

        int tabstripFaviconBackgroundDrawableId =
                tab.isIncognito()
                        ? R.color.favicon_background_color_incognito
                        : R.color.favicon_background_color;
        PropertyModel tabInfo =
                new PropertyModel.Builder(TabProperties.ALL_KEYS_TAB_GRID)
                        .with(TabProperties.TAB_ACTION_STATE, mTabActionState)
                        .with(TabProperties.TAB_ID, tab.getId())
                        .with(
                                TabProperties.TITLE,
                                getLatestTitleForTab(tab, /* useDefault= */ true))
                        .with(TabProperties.URL_DOMAIN, getDomainForTab(tab))
                        .with(TabProperties.FAVICON_FETCHER, null)
                        .with(TabProperties.FAVICON_FETCHED, false)
                        .with(TabProperties.IS_SELECTED, isSelected)
                        .with(TabProperties.IPH_PROVIDER, showIPH ? mIphProvider : null)
                        .with(CARD_ALPHA, 1f)
                        .with(
                                TabProperties.CARD_ANIMATION_STATUS,
                                TabGridView.AnimationStatus.CARD_RESTORE)
                        .with(TabProperties.TAB_SELECTION_DELEGATE, getTabSelectionDelegate())
                        .with(TabProperties.IS_INCOGNITO, tab.isIncognito())
                        .with(
                                TabProperties.SELECTED_TAB_BACKGROUND_DRAWABLE_ID,
                                selectedTabBackgroundDrawableId)
                        .with(
                                TabProperties.TABSTRIP_FAVICON_BACKGROUND_COLOR_ID,
                                tabstripFaviconBackgroundDrawableId)
                        .with(TabProperties.ACCESSIBILITY_DELEGATE, mAccessibilityDelegate)
                        .with(TabProperties.SHOULD_SHOW_PRICE_DROP_TOOLTIP, false)
                        .with(CARD_TYPE, TAB)
                        .with(
                                TabProperties.QUICK_DELETE_ANIMATION_STATUS,
                                QuickDeleteAnimationStatus.TAB_RESTORE)
                        .with(TabProperties.TAB_GROUP_COLOR_ID, colorId)
                        .with(TabProperties.VISIBILITY, View.VISIBLE)
                        .with(TabProperties.USE_SHRINK_CLOSE_ANIMATION, false)
                        .build();

        if (!mActionsOnAllRelatedTabs || !isTabInTabGroup(tab)) {
            tabInfo.set(
                    TabProperties.FAVICON_FETCHER,
                    mTabListFaviconProvider.getDefaultFaviconFetcher(tab.isIncognito()));
        }

        bindTabActionStateProperties(mTabActionState, tab, tabInfo);

        @UiType
        int tabUiType =
                mMode == TabListMode.STRIP ? TabProperties.UiType.STRIP : TabProperties.UiType.TAB;
        if (index >= mModel.size()) {
            mModel.add(new SimpleRecyclerViewAdapter.ListItem(tabUiType, tabInfo));
        } else {
            mModel.add(index, new SimpleRecyclerViewAdapter.ListItem(tabUiType, tabInfo));
        }

        setupPersistedTabDataFetcherForTab(tab, index);

        updateFaviconForTab(tab, null, null);

        if (mThumbnailProvider != null && mDefaultGridCardSize != null) {
            if (!mDefaultGridCardSize.equals(tabInfo.get(TabProperties.GRID_CARD_SIZE))) {
                tabInfo.set(
                        TabProperties.GRID_CARD_SIZE,
                        new Size(
                                mDefaultGridCardSize.getWidth(), mDefaultGridCardSize.getHeight()));
            }
        }
        if (mThumbnailProvider != null && mShowingTabs) {
            updateThumbnailFetcher(tabInfo, tab.getId());
        }
    }

    private String getDomainForTab(Tab tab) {
        if (!mActionsOnAllRelatedTabs) return getDomain(tab);

        List<Tab> relatedTabs = getRelatedTabsForId(tab.getId());

        List<String> domainNames = new ArrayList<>();

        for (int i = 0; i < relatedTabs.size(); i++) {
            String domain = getDomain(relatedTabs.get(i));
            domainNames.add(domain);
        }
        // TODO(crbug.com/40107640): Address i18n issue for the list delimiter.
        return TextUtils.join(", ", domainNames);
    }

    private void updateDescriptionString(Tab tab, PropertyModel model) {
        if (!mActionsOnAllRelatedTabs) return;
        boolean isInTabGroup = isTabInTabGroup(tab);
        int numOfRelatedTabs = getRelatedTabsForId(tab.getId()).size();
        if (isInTabGroup) {
            String title = getLatestTitleForTab(tab, /* useDefault= */ false);
            Resources res = mContext.getResources();
            if (!ChromeFeatureList.sTabGroupParityAndroid.isEnabled()) {
                model.set(
                        TabProperties.CONTENT_DESCRIPTION_STRING,
                        title.isEmpty()
                                ? res.getQuantityString(
                                        R.plurals.accessibility_expand_tab_group,
                                        numOfRelatedTabs,
                                        numOfRelatedTabs)
                                : res.getQuantityString(
                                        R.plurals.accessibility_expand_tab_group_with_group_name,
                                        numOfRelatedTabs,
                                        title,
                                        numOfRelatedTabs));
            } else {
                TabGroupModelFilter filter =
                        (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get();
                @TabGroupColorId int colorId = filter.getTabGroupColorWithFallback(tab.getRootId());
                final @StringRes int colorDescRes =
                        ColorPickerUtils.getTabGroupColorPickerItemColorAccessibilityString(
                                colorId);
                String colorDesc = res.getString(colorDescRes);
                model.set(
                        TabProperties.CONTENT_DESCRIPTION_STRING,
                        title.isEmpty()
                                ? res.getQuantityString(
                                        R.plurals.accessibility_expand_tab_group_with_color,
                                        numOfRelatedTabs,
                                        numOfRelatedTabs,
                                        colorDesc)
                                : res.getQuantityString(
                                        R.plurals
                                                .accessibility_expand_tab_group_with_group_name_with_color,
                                        numOfRelatedTabs,
                                        title,
                                        numOfRelatedTabs,
                                        colorDesc));
            }
        } else {
            model.set(TabProperties.CONTENT_DESCRIPTION_STRING, null);
        }
    }

    private void updateActionButtonDescriptionString(Tab tab, PropertyModel model) {
        if (mActionsOnAllRelatedTabs) {
            boolean isInTabGroup = isTabInTabGroup(tab);
            int numOfRelatedTabs = getRelatedTabsForId(tab.getId()).size();
            if (isInTabGroup) {
                String title = getLatestTitleForTab(tab, /* useDefault= */ false);

                String descriptionString =
                        getActionButtonDescriptionString(numOfRelatedTabs, title, tab.getRootId());
                model.set(TabProperties.ACTION_BUTTON_DESCRIPTION_STRING, descriptionString);
                return;
            }
        }

        model.set(
                ACTION_BUTTON_DESCRIPTION_STRING,
                mContext.getString(R.string.accessibility_tabstrip_btn_close_tab, tab.getTitle()));
    }

    @VisibleForTesting
    protected static String getDomain(Tab tab) {
        // TODO(crbug.com/40144810) Investigate how uninitialized Tabs are appearing
        // here.
        assert tab.isInitialized();
        if (!tab.isInitialized()) {
            return "";
        }

        String spec = tab.getUrl().getSpec();
        if (spec == null) return "";

        // TODO(crbug.com/40549331): convert UrlUtilities to GURL
        String domain = UrlUtilities.getDomainAndRegistry(spec, false);

        if (domain == null || domain.isEmpty()) return spec;
        return domain;
    }

    @Nullable
    private SelectionDelegate<Integer> getTabSelectionDelegate() {
        return mSelectionDelegateProvider == null
                ? null
                : mSelectionDelegateProvider.getSelectionDelegate();
    }

    @VisibleForTesting
    String getLatestTitleForTab(Tab tab, boolean useDefault) {
        String originalTitle = tab.getTitle();
        if (!mActionsOnAllRelatedTabs || mTabGroupTitleEditor == null || !isTabInTabGroup(tab)) {
            return originalTitle;
        }

        String storedTitle = mTabGroupTitleEditor.getTabGroupTitle(tab.getRootId());
        if (TextUtils.isEmpty(storedTitle)) {
            if (useDefault) {
                TabGroupModelFilter filter =
                        (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get();
                return TabGroupTitleEditor.getDefaultTitle(
                        mContext, filter.getRelatedTabCountForRootId(tab.getRootId()));
            } else {
                return "";
            }
        }
        return storedTitle;
    }

    int selectedTabId() {
        if (mNextTabId != Tab.INVALID_TAB_ID) {
            return mNextTabId;
        }

        return TabModelUtils.getCurrentTabId(mCurrentTabModelFilterSupplier.get().getTabModel());
    }

    private void setupPersistedTabDataFetcherForTab(Tab tab, int index) {
        PropertyModel model = mModel.get(index).model;
        if (mMode == TabListMode.GRID && !tab.isIncognito()) {
            assert mProfile != null;
            if (PriceTrackingUtilities.isTrackPricesOnTabsEnabled(mProfile)
                    && !isTabInTabGroup(tab)) {
                model.set(
                        TabProperties.SHOPPING_PERSISTED_TAB_DATA_FETCHER,
                        new ShoppingPersistedTabDataFetcher(
                                tab, mPriceWelcomeMessageControllerSupplier));
            } else {
                model.set(TabProperties.SHOPPING_PERSISTED_TAB_DATA_FETCHER, null);
            }
        } else {
            model.set(TabProperties.SHOPPING_PERSISTED_TAB_DATA_FETCHER, null);
        }
    }

    @VisibleForTesting
    void updateFaviconForTab(Tab tab, @Nullable Bitmap icon, @Nullable GURL iconUrl) {
        int modelIndex = mModel.indexFromId(tab.getId());
        if (modelIndex == Tab.INVALID_TAB_ID) return;

        PropertyModel model = mModel.get(modelIndex).model;
        if (mActionsOnAllRelatedTabs && isTabInTabGroup(tab)) {
            List<Tab> relatedTabList = getRelatedTabsForId(tab.getId());
            if (mMode != TabListMode.LIST) {
                // For tab group card in grid tab switcher, the favicon is set to be null.
                // With tab group colors, set the the favicon fetcher to a circle of color.
                TabFaviconFetcher faviconFetcher = null;
                if (ChromeFeatureList.sTabGroupParityAndroid.isEnabled()) {
                    TabGroupModelFilter filter =
                            (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get();
                    @TabGroupColorId
                    int colorId = filter.getTabGroupColorWithFallback(tab.getRootId());
                    faviconFetcher =
                            mTabGroupColorFaviconProvider.getFaviconFromTabGroupColorFetcher(
                                    colorId, filter.getTabModel(), tab);
                }

                model.set(TabProperties.FAVICON_FETCHER, faviconFetcher);
                return;
            } else if (mMode == TabListMode.LIST && relatedTabList.size() > 1) {
                // The order of the url list matches the multi-thumbnail.
                List<GURL> urls = new ArrayList<>();
                urls.add(tab.getUrl());
                for (int i = 0; urls.size() < 4 && i < relatedTabList.size(); i++) {
                    if (tab.getId() == relatedTabList.get(i).getId()) continue;
                    urls.add(relatedTabList.get(i).getUrl());
                }

                // For tab group card in list tab switcher, the favicon is the composed favicon.
                model.set(
                        TabProperties.FAVICON_FETCHER,
                        mTabListFaviconProvider.getComposedFaviconImageFetcher(
                                urls, tab.isIncognito()));
                return;
            }
        }
        if (!mTabListFaviconProvider.isInitialized()) {
            return;
        }

        // If there is an available icon, we fetch favicon synchronously; otherwise asynchronously.
        if (icon != null && iconUrl != null) {
            model.set(
                    TabProperties.FAVICON_FETCHER,
                    mTabListFaviconProvider.getFaviconFromBitmapFetcher(icon, iconUrl));
            return;
        }

        TabFaviconFetcher fetcher =
                mTabListFaviconProvider.getFaviconForUrlFetcher(tab.getUrl(), tab.isIncognito());
        model.set(TabProperties.FAVICON_FETCHER, fetcher);
    }

    /**
     * Inserts a special {@link org.chromium.ui.modelutil.MVCListAdapter.ListItem} at given index of
     * the current {@link TabListModel}.
     *
     * @param index The index of the {@link org.chromium.ui.modelutil.MVCListAdapter.ListItem} to be
     *     inserted.
     * @param uiType The view type the model will bind to.
     * @param model The model that will be bound to a view.
     */
    void addSpecialItemToModel(int index, @UiType int uiType, PropertyModel model) {
        mModel.add(index, new SimpleRecyclerViewAdapter.ListItem(uiType, model));
    }

    /**
     * Removes a special {@link org.chromium.ui.modelutil.MVCListAdapter.ListItem} that has the
     * given {@code uiType} and/or its {@link PropertyModel} has the given {@code itemIdentifier}
     * from the current {@link TabListModel}.
     *
     * @param uiType The uiType to match.
     * @param itemIdentifier The itemIdentifier to match. This can be obsoleted if the {@link
     *     org.chromium.ui.modelutil.MVCListAdapter.ListItem} does not need additional identifier.
     */
    void removeSpecialItemFromModel(
            @UiType int uiType, @MessageService.MessageType int itemIdentifier) {
        int index = TabModel.INVALID_TAB_INDEX;
        if (uiType == UiType.MESSAGE
                || uiType == UiType.LARGE_MESSAGE
                || uiType == UiType.CUSTOM_MESSAGE) {
            if (itemIdentifier == MessageService.MessageType.ALL) {
                while (mModel.lastIndexForMessageItem() != TabModel.INVALID_TAB_INDEX) {
                    index = mModel.lastIndexForMessageItem();
                    mModel.removeAt(index);
                }
                return;
            }
            index = mModel.lastIndexForMessageItemFromType(itemIdentifier);
        }

        if (index == TabModel.INVALID_TAB_INDEX) return;

        assert validateItemAt(index, uiType, itemIdentifier);
        mModel.removeAt(index);
    }

    private boolean validateItemAt(
            int index, @UiType int uiType, @MessageService.MessageType int itemIdentifier) {
        if (uiType == UiType.MESSAGE
                || uiType == UiType.LARGE_MESSAGE
                || uiType == UiType.CUSTOM_MESSAGE) {
            return mModel.get(index).type == uiType
                    && mModel.get(index).model.get(MESSAGE_TYPE) == itemIdentifier;
        }

        return false;
    }

    /**
     * The PriceWelcomeMessage should be in view when user enters the tab switcher, so we put it
     * exactly below the currently selected tab.
     *
     * @return Where the PriceWelcomeMessage should be inserted in the {@link TabListModel} when
     *         user enters the tab switcher.
     */
    int getPriceWelcomeMessageInsertionIndex() {
        assert mGridLayoutManager != null;
        int spanCount = mGridLayoutManager.getSpanCount();
        int selectedTabIndex =
                mModel.indexOfNthTabCard(mCurrentTabModelFilterSupplier.get().index());
        int indexBelowSelectedTab = (selectedTabIndex / spanCount + 1) * spanCount;
        int indexAfterLastTab = mModel.getTabIndexBefore(mModel.size()) + 1;
        return Math.min(indexBelowSelectedTab, indexAfterLastTab);
    }

    /**
     * Update the layout of tab switcher to make it compact. Because now we have messages within the
     * tabs like PriceMessage and these messages take up the entire row, some operations like
     * closing a tab above the message card will leave a blank grid, so we need to update the
     * layout.
     */
    @VisibleForTesting
    void updateLayout() {
        // Right now we need to update layout only if there is a price welcome message card in tab
        // switcher.
        if (mProfile == null
                || !PriceTrackingUtilities.isPriceWelcomeMessageCardEnabled(mProfile)) {
            return;
        }
        assert mGridLayoutManager != null;
        int spanCount = mGridLayoutManager.getSpanCount();
        GridLayoutManager.SpanSizeLookup spanSizeLookup = mGridLayoutManager.getSpanSizeLookup();
        int spanSizeSumForCurrentRow = 0;
        int index = 0;
        for (; index < mModel.size(); index++) {
            spanSizeSumForCurrentRow += spanSizeLookup.getSpanSize(index);
            if (spanSizeSumForCurrentRow == spanCount) {
                // This row is compact, we clear and recount the spanSize for next row.
                spanSizeSumForCurrentRow = 0;
            } else if (spanSizeSumForCurrentRow > spanCount) {
                // Find a blank grid and break.
                if (mModel.get(index).type == TabProperties.UiType.LARGE_MESSAGE) break;
                spanSizeSumForCurrentRow = 0;
            }
        }
        if (spanSizeSumForCurrentRow <= spanCount) return;
        int blankSize = spanCount - (spanSizeSumForCurrentRow - spanSizeLookup.getSpanSize(index));
        for (int i = index + 1; i < mModel.size(); i++) {
            if (spanSizeLookup.getSpanSize(i) > blankSize) continue;
            mModel.move(i, index);
            // We should return after one move because once item moved, updateLayout() will be
            // called again.
            return;
        }
    }

    View.AccessibilityDelegate getAccessibilityDelegateForTesting() {
        return mAccessibilityDelegate;
    }

    @VisibleForTesting
    void recordPriceAnnotationsEnabledMetrics() {
        if (mMode != TabListMode.GRID
                || !mActionsOnAllRelatedTabs
                || mProfile == null
                || !PriceTrackingFeatures.isPriceTrackingEligible(mProfile)) {
            return;
        }
        SharedPreferencesManager preferencesManager = ChromeSharedPreferences.getInstance();
        if (System.currentTimeMillis()
                        - preferencesManager.readLong(
                                ChromePreferenceKeys
                                        .PRICE_TRACKING_ANNOTATIONS_ENABLED_METRICS_TIMESTAMP,
                                -1)
                >= PriceTrackingFeatures.getAnnotationsEnabledMetricsWindowDurationMilliSeconds()) {
            RecordHistogram.recordBooleanHistogram(
                    "Commerce.PriceDrop.AnnotationsEnabled",
                    PriceTrackingUtilities.isTrackPricesOnTabsEnabled(mProfile));
            preferencesManager.writeLong(
                    ChromePreferenceKeys.PRICE_TRACKING_ANNOTATIONS_ENABLED_METRICS_TIMESTAMP,
                    System.currentTimeMillis());
        }
    }

    /** Returns the index of the nth tab card in the model or TabList.INVALID_TAB_INDEX. */
    int getIndexOfNthTabCard(int n) {
        return mModel.indexOfNthTabCardOrInvalid(n);
    }

    /** Returns the filter index of a tab from its view index or TabList.INVALID_TAB_INDEX. */
    int indexOfTabCardsOrInvalid(int viewIndex) {
        return mModel.indexOfTabCardsOrInvalid(viewIndex);
    }

    /**
     * @param tab the {@link Tab} to find the group index of.
     * @return the index for the tab group within {@link mModel}
     */
    int getIndexForTabWithRelatedTabs(Tab tab) {
        return getIndexForTabIdWithRelatedTabs(tab.getId());
    }

    /**
     * @param tab the {@link Tab} to find the group index of.
     * @return the index for the tab group within {@link mModel}
     */
    int getIndexForTabIdWithRelatedTabs(int tabId) {
        List<Integer> relatedTabIds = getRelatedTabsIds(tabId);
        if (!relatedTabIds.isEmpty()) {
            for (int i = 0; i < mModel.size(); i++) {
                PropertyModel model = mModel.get(i).model;
                if (model.get(CARD_TYPE) != TAB) continue;

                int modelTabId = model.get(TAB_ID);
                if (relatedTabIds.contains(modelTabId)) {
                    return i;
                }
            }
        }
        return TabModel.INVALID_TAB_INDEX;
    }

    /**
     * Returns the index in {@link mModel} of the group with {@code rootId} and the {@link Tab}
     * representing the group. Will be null if the entry is not present, the tab cannot be found, or
     * the tab is not part of a tab group.
     */
    private @Nullable Pair<Integer, Tab> getIndexAndTabForRootId(int rootId) {
        int index = getIndexForTabIdWithRelatedTabs(rootId);
        if (index == TabModel.INVALID_TAB_INDEX) return null;

        Tab tab = getTabForIndex(index);
        if (tab == null || !mCurrentTabModelFilterSupplier.get().isTabInTabGroup(tab)) {
            return null;
        }
        return Pair.create(index, tab);
    }

    private @Nullable Tab getTabForIndex(int index) {
        return mCurrentTabModelFilterSupplier
                .get()
                .getTabModel()
                .getTabById(mModel.get(index).model.get(TabProperties.TAB_ID));
    }

    Tab getTabToAddDelayedForTesting() {
        return mTabToAddDelayed;
    }

    void setComponentNameForTesting(String name) {
        var oldValue = mComponentName;
        mComponentName = name;
        ResettersForTesting.register(() -> mComponentName = oldValue);
    }

    private void onTabModelFilterChanged(
            @Nullable TabModelFilter newFilter, @Nullable TabModelFilter oldFilter) {
        removeObservers(oldFilter);

        // The observers will be bound to the newFilter's when the model is reset for with tabs for
        // that filter for the first time. Doing this on the first reset after changing models
        // makes sense as otherwise we will be observing updates when the mModel contains tabs for
        // the oldFilter which can result in invalid updates.
    }

    private void addObservers(TabModelFilter filter, @NonNull List<Tab> tabs) {
        assert filter != null;

        if (mActionsOnAllRelatedTabs) {
            for (Tab rootTab : tabs) {
                for (Tab tab : filter.getRelatedTabList(rootTab.getId())) {
                    tab.addObserver(mTabObserver);
                }
            }
        } else {
            for (Tab tab : tabs) {
                tab.addObserver(mTabObserver);
            }
        }

        filter.addObserver(mTabModelObserver);
        if (filter instanceof TabGroupModelFilter groupFilter) {
            groupFilter.addTabGroupObserver(mTabGroupObserver);
        }
    }

    private void removeObservers(@Nullable TabModelFilter filter) {
        if (filter == null) return;

        TabModel tabModel = filter.getTabModel();
        if (tabModel != null) {
            // Observers are added when tabs are shown via addTabInfoToModel(). When switching
            // filters the TabObservers should be removed from all the tabs in the previous model.
            // If no observer was added this will no-op. Previously this was only done in
            // destroy(), but that left observers behind on the inactive model.
            for (int i = 0; i < tabModel.getCount(); i++) {
                tabModel.getTabAt(i).removeObserver(mTabObserver);
            }
        }
        filter.removeObserver(mTabModelObserver);
        if (filter instanceof TabGroupModelFilter groupFilter) {
            groupFilter.removeTabGroupObserver(mTabGroupObserver);
        }
    }

    /**
     * @param itemIdentifier The itemIdentifier to match.
     * @return whether a special {@link org.chromium.ui.modelutil.MVCListAdapter.ListItem} with the
     *     given {@code itemIdentifier} for its {@link PropertyModel} exists in the current {@link
     *     TabListModel}.
     */
    boolean specialItemExistsInModel(@MessageService.MessageType int itemIdentifier) {
        if (itemIdentifier == MessageService.MessageType.ALL) {
            return mModel.lastIndexForMessageItem() != TabModel.INVALID_TAB_INDEX;
        }
        return mModel.lastIndexForMessageItemFromType(itemIdentifier) != TabModel.INVALID_TAB_INDEX;
    }

    boolean isLastItemMessage() {
        if (mModel.size() == 0) return false;
        int index = mModel.lastIndexForMessageItem();
        if (index == TabModel.INVALID_TAB_INDEX) return false;
        return index == mModel.size() - 1;
    }

    /**
     * Prepare and run the Quick Delete animation on the tab list.
     *
     * @param onAnimationEnd Runnable that is invoked when the animation is completed.
     * @param tabs The tabs to fade with the animation. These tabs will get closed after the
     *     animation is complete.
     * @param recyclerView The {@link TabListRecyclerView} that is showing the tab list UI.
     */
    public void showQuickDeleteAnimation(
            @NonNull Runnable onAnimationEnd,
            @NonNull List<Tab> tabs,
            @NonNull TabListRecyclerView recyclerView) {
        recyclerView.setBlockTouchInput(true);
        Drawable originalForeground = recyclerView.getForeground();

        // Prepare the tabs that will be hidden by the animation.
        TreeMap<Integer, List<Integer>> bottomValuesToTabIndexes = new TreeMap<>();
        getOrderOfTabsForQuickDeleteAnimation(recyclerView, tabs, bottomValuesToTabIndexes);

        setQuickDeleteAnimationStatusForTabIndexes(
                bottomValuesToTabIndexes.values().stream()
                        .flatMap(Collection::stream)
                        .collect(Collectors.toList()),
                QuickDeleteAnimationStatus.TAB_PREPARE);

        // Create the gradient drawable and prepare the animator.
        int tabGridHeight = recyclerView.getHeight();
        int intersectionHeight =
                QuickDeleteAnimationGradientDrawable.getAnimationsIntersectionHeight(tabGridHeight);
        QuickDeleteAnimationGradientDrawable gradientDrawable =
                QuickDeleteAnimationGradientDrawable.createQuickDeleteWipeAnimationDrawable(
                        mContext,
                        tabGridHeight,
                        mCurrentTabModelFilterSupplier.get().isIncognitoBranded());

        ObjectAnimator wipeAnimation = gradientDrawable.createWipeAnimator(tabGridHeight);

        wipeAnimation.addUpdateListener(
                valueAnimator -> {
                    if (bottomValuesToTabIndexes.isEmpty()) return;

                    float value = (float) valueAnimator.getAnimatedValue();
                    int bottomVal = bottomValuesToTabIndexes.lastKey();
                    if (bottomVal >= Math.round(value) + intersectionHeight) {
                        setQuickDeleteAnimationStatusForTabIndexes(
                                bottomValuesToTabIndexes.get(bottomVal),
                                QuickDeleteAnimationStatus.TAB_HIDE);
                        bottomValuesToTabIndexes.remove(bottomVal);
                    }
                });

        wipeAnimation.addListener(
                new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationEnd(Animator animation) {
                        recyclerView.setBlockTouchInput(false);
                        recyclerView.setForeground(originalForeground);
                        onAnimationEnd.run();
                    }
                });

        recyclerView.setForeground(gradientDrawable);
        wipeAnimation.start();
    }

    // TabListNotificationHandler implementation.
    @Override
    public void updateTabStripNotificationBubble(
            Set<Integer> tabIdsToBeUpdated, boolean hasUpdate) {
        assert mMode == TabListMode.STRIP;

        Callback<PropertyModel> updateTabStripItemCallback =
                (model) -> {
                    model.set(TabProperties.HAS_NOTIFICATION_BUBBLE, hasUpdate);
                };

        forAllTabListItems(tabIdsToBeUpdated, updateTabStripItemCallback);
    }

    @Override
    public void updateTabCardLabels(Map<Integer, TabCardLabelData> labelData) {
        assert mMode == TabListMode.GRID;

        Callback<PropertyModel> updateTabCardLabel =
                (model) -> {
                    int tabId = model.get(TabProperties.TAB_ID);
                    model.set(TabProperties.TAB_CARD_LABEL_DATA, labelData.get(tabId));
                };
        forAllTabListItems(labelData.keySet(), updateTabCardLabel);
    }

    private void forAllTabListItems(
            Set<Integer> tabIdsToBeUpdated, Callback<PropertyModel> updateCallback) {
        for (int i = 0; i < mModel.size(); i++) {
            PropertyModel model = mModel.get(i).model;
            if (model.get(CARD_TYPE) != TAB) continue;

            int tabId = model.get(TabProperties.TAB_ID);
            if (tabIdsToBeUpdated.contains(tabId)) {
                updateCallback.onResult(model);
            }
        }
    }

    /**
     * Gets the order of tabs to be hidden with the animation starting from the bottom up.
     *
     * @param recyclerView to get the position of tabs within the {@link TabListRecyclerView}.
     * @param tabs The tabs to fade with the animation.
     * @param bottomValuesToTabIndexes the {@link TreeMap} to map a list of sorted bottom values to
     *     tabs that have these bottom values.
     */
    @VisibleForTesting
    void getOrderOfTabsForQuickDeleteAnimation(
            TabListRecyclerView recyclerView,
            List<Tab> tabs,
            TreeMap<Integer, List<Integer>> bottomValuesToTabIndexes) {
        Set<Tab> filteredTabs = filterQuickDeleteTabsForAnimation(tabs);

        for (Tab tab : filteredTabs) {
            int id = tab.getId();
            int index = mModel.indexFromId(id);
            Rect tabRect = recyclerView.getRectOfCurrentThumbnail(index, id);

            // Ignore tabs that are outside the screen view.
            if (tabRect == null) continue;

            int bottom = tabRect.bottom;

            if (bottomValuesToTabIndexes.containsKey(bottom)) {
                bottomValuesToTabIndexes.get(bottom).add(index);
            } else {
                bottomValuesToTabIndexes.put(bottom, new ArrayList<>(List.of(index)));
            }
        }
    }

    /**
     * @param tabs The full list of tabs that will be closed with Quick Delete.
     * @return a filtered list of unique tabs that the animation should run on. This will ignore
     *     tabs with other related tabs unless all of it's related tabs are included in the list of
     *     tabs to be closed.
     */
    private Set<Tab> filterQuickDeleteTabsForAnimation(List<Tab> tabs) {
        TabModelFilter filter = mCurrentTabModelFilterSupplier.get();
        assert filter != null;

        Set<Tab> unfilteredTabs = new HashSet<>(tabs);
        Set<Tab> filteredTabs = new HashSet<>();
        Set<Integer> checkedRootIds = new HashSet<>();

        for (Tab tab : unfilteredTabs) {
            if (!filter.isTabInTabGroup(tab)) {
                filteredTabs.add(tab);
                continue;
            }

            if (checkedRootIds.contains(tab.getRootId())) continue;
            checkedRootIds.add(tab.getRootId());

            List<Tab> relatedTabs = filter.getRelatedTabList(tab.getId());
            if (unfilteredTabs.containsAll(relatedTabs)) {
                int groupIndex = filter.indexOf(tab);
                Tab groupTab = filter.getTabAt(groupIndex);
                filteredTabs.add(groupTab);
            }
        }

        return filteredTabs;
    }

    private void setQuickDeleteAnimationStatusForTabIndexes(
            List<Integer> indexes, @QuickDeleteAnimationStatus int animationStatus) {
        for (int index : indexes) {
            mModel.get(index)
                    .model
                    .set(TabProperties.QUICK_DELETE_ANIMATION_STATUS, animationStatus);
        }
    }

    @VisibleForTesting
    void onMenuItemClicked(@IdRes int menuId, int tabId, @Nullable String collaborationId) {
        boolean isSyncEnabled = TabGroupSyncFeatures.isTabGroupSyncEnabled(mProfile);
        if (menuId == R.id.close_tab || menuId == R.id.delete_tab) {
            boolean hideTabGroups = menuId == R.id.close_tab;
            if (hideTabGroups) {
                RecordUserAction.record("TabGroupItemMenu.Close");
            } else {
                RecordUserAction.record("TabGroupItemMenu.Delete");
            }
            setUseShrinkCloseAnimation(tabId, /* useShrinkCloseAnimation= */ true);
            TabUiUtils.closeTabGroup(
                    (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get(),
                    mActionConfirmationManager,
                    tabId,
                    hideTabGroups,
                    isSyncEnabled,
                    getMaybeUnsetShrinkCloseAnimationCallback(tabId));
        } else if (menuId == R.id.edit_group_name) {
            RecordUserAction.record("TabGroupItemMenu.Rename");
            renameTabGroup(tabId);
        } else if (menuId == R.id.ungroup_tab) {
            RecordUserAction.record("TabGroupItemMenu.Ungroup");
            TabUiUtils.ungroupTabGroup(
                    (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get(),
                    mActionConfirmationManager,
                    tabId,
                    isSyncEnabled);
        } else if (menuId == R.id.delete_shared_group) {
            RecordUserAction.record("TabGroupItemMenu.DeleteShared");
            TabUiUtils.deleteSharedTabGroup(
                    (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get(),
                    mActionConfirmationManager,
                    tabId);
        } else if (menuId == R.id.leave_group) {
            RecordUserAction.record("TabGroupItemMenu.LeaveShared");
            TabUiUtils.leaveTabGroup(
                    (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get(),
                    mActionConfirmationManager,
                    tabId);
        }
    }

    private void renameTabGroup(int tabId) {
        TabModel tabModel = mCurrentTabModelFilterSupplier.get().getTabModel();
        int rootId = tabModel.getTabById(tabId).getRootId();
        TabGroupModelFilter filter = (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get();

        var tabGroupVisualDataDialogManager =
                new TabGroupVisualDataDialogManager(
                        mContext,
                        mModalDialogManager,
                        TabGroupVisualDataDialogManager.DialogType.TAB_GROUP_EDIT,
                        R.string.tab_group_rename_dialog_title);

        ModalDialogProperties.Controller dialogController =
                new ModalDialogProperties.Controller() {
                    @Override
                    public void onClick(PropertyModel model, int buttonType) {
                        if (buttonType == ModalDialogProperties.ButtonType.POSITIVE
                                && !tabGroupVisualDataDialogManager.validateCurrentGroupTitle()) {
                            tabGroupVisualDataDialogManager.focusCurrentGroupTitle();
                            return;
                        }

                        final @DialogDismissalCause int cause;
                        if (buttonType == ModalDialogProperties.ButtonType.POSITIVE) {
                            cause = DialogDismissalCause.POSITIVE_BUTTON_CLICKED;
                        } else {
                            cause = DialogDismissalCause.NEGATIVE_BUTTON_CLICKED;
                        }

                        mModalDialogManager.dismissDialog(model, cause);
                    }

                    @Override
                    public void onDismiss(PropertyModel model, int dismissalCause) {
                        if (dismissalCause == DialogDismissalCause.POSITIVE_BUTTON_CLICKED) {
                            @TabGroupColorId
                            int oldColorId = filter.getTabGroupColorWithFallback(rootId);
                            @TabGroupColorId
                            int currentColorId =
                                    tabGroupVisualDataDialogManager.getCurrentColorId();
                            boolean didChangeColor = oldColorId != currentColorId;
                            if (didChangeColor) {
                                filter.setTabGroupColor(rootId, currentColorId);
                                RecordUserAction.record("TabGroup.RenameDialog.ColorChanged");
                            }

                            String defaultGroupTitle =
                                    tabGroupVisualDataDialogManager.getDefaultGroupTitle();
                            String inputGroupTitle =
                                    tabGroupVisualDataDialogManager.getCurrentGroupTitle();
                            boolean didChangeTitle =
                                    !Objects.equals(defaultGroupTitle, inputGroupTitle);
                            // This check must be included in case the user has a null title
                            // which is displayed as a tab count and chooses not to change it.
                            if (didChangeTitle) {
                                filter.setTabGroupTitle(rootId, inputGroupTitle);
                                RecordUserAction.record("TabGroup.RenameDialog.TitleChanged");
                            }
                        }

                        tabGroupVisualDataDialogManager.hideDialog();
                    }
                };

        tabGroupVisualDataDialogManager.showDialog(rootId, filter, dialogController);
    }

    private String getActionButtonDescriptionString(
            int numOfRelatedTabs, String title, int rootId) {
        Resources res = mContext.getResources();
        if (!ChromeFeatureList.sTabGroupParityAndroid.isEnabled()) {
            if (title.isEmpty()) {
                return res.getQuantityString(
                        R.plurals.accessibility_close_tab_group_button,
                        numOfRelatedTabs,
                        numOfRelatedTabs);
            } else {
                return res.getQuantityString(
                        R.plurals.accessibility_close_tab_group_button_with_group_name,
                        numOfRelatedTabs,
                        title,
                        numOfRelatedTabs);
            }
        } else {
            if (ChromeFeatureList.sTabGroupPaneAndroid.isEnabled()) {
                String descriptionTitle = title;
                if (descriptionTitle.isEmpty()) {
                    descriptionTitle =
                            TabGroupTitleEditor.getDefaultTitle(mContext, numOfRelatedTabs);
                }
                return res.getString(
                        R.string.accessibility_open_tab_group_overflow_menu_with_group_name,
                        descriptionTitle);
            } else {
                TabGroupModelFilter filter =
                        (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get();
                @TabGroupColorId int colorId = filter.getTabGroupColorWithFallback(rootId);
                final @StringRes int colorDescRes =
                        ColorPickerUtils.getTabGroupColorPickerItemColorAccessibilityString(
                                colorId);
                String colorDesc = res.getString(colorDescRes);
                if (title.isEmpty()) {
                    return res.getQuantityString(
                            R.plurals.accessibility_close_tab_group_button_with_color,
                            numOfRelatedTabs,
                            numOfRelatedTabs,
                            colorDesc);
                } else {
                    return res.getQuantityString(
                            R.plurals
                                    .accessibility_close_tab_group_button_with_group_name_with_color,
                            numOfRelatedTabs,
                            title,
                            numOfRelatedTabs,
                            colorDesc);
                }
            }
        }
    }

    private void setUseShrinkCloseAnimation(int tabId, boolean useShrinkCloseAnimation) {
        if (mMode != TabListMode.GRID) return;

        @Nullable PropertyModel model = getModelFromId(tabId);
        if (model != null) {
            model.set(TabProperties.USE_SHRINK_CLOSE_ANIMATION, useShrinkCloseAnimation);
        }
    }

    @VisibleForTesting
    @Nullable
    Callback<Boolean> getMaybeUnsetShrinkCloseAnimationCallback(int tabId) {
        TabGroupModelFilter filter = (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get();

        Tab tab = filter.getTabModel().getTabById(tabId);
        if (tab == null) return null;

        Token tabGroupId = tab.getTabGroupId();
        if (tabGroupId == null) return null;

        return (didClose) -> {
            // The close did not happen unset the shrink animation bit.
            if (!didClose) {
                setUseShrinkCloseAnimation(tabId, /* useShrinkCloseAnimation= */ false);
                return;
            }

            // Special case in defense of the group not being completely closed. We need to find the
            // group and unset the USE_SHRINK_CLOSE_ANIMATION property.
            int rootId = filter.getRootIdFromStableId(tabGroupId);
            if (rootId == Tab.INVALID_TAB_ID) return;

            List<Integer> ids = filter.getRelatedTabIds(rootId);
            for (int id : ids) {
                @Nullable PropertyModel model = getModelFromId(id);
                if (model != null) {
                    model.set(TabProperties.USE_SHRINK_CLOSE_ANIMATION, false);
                }
            }
        };
    }

    private PropertyModel getModelFromId(int tabId) {
        int modelIndex = mModel.indexFromId(tabId);
        if (modelIndex == TabModel.INVALID_TAB_INDEX) return null;
        return mModel.get(modelIndex).model;
    }

    private boolean isParentComponentTabSwitcher() {
        return TabSwitcherPaneCoordinator.COMPONENT_NAME.equals(mComponentName);
    }

    private void updateThumbnailFetcher(PropertyModel model, int tabId) {
        @Nullable ThumbnailFetcher oldFetcher = model.get(THUMBNAIL_FETCHER);
        if (oldFetcher != null) oldFetcher.cancel();

        @Nullable
        ThumbnailFetcher newFetcher =
                tabId == Tab.INVALID_TAB_ID
                        ? null
                        : new ThumbnailFetcher(mThumbnailProvider, tabId);
        model.set(THUMBNAIL_FETCHER, newFetcher);
    }

    @TabListMode
    int getTabListModeForTesting() {
        return mMode;
    }
}