chromium/chrome/android/features/tab_ui/java/src/org/chromium/chrome/browser/tasks/tab_management/TabGridItemTouchHelperCallback.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_TYPE;
import static org.chromium.chrome.browser.tasks.tab_management.TabListModel.CardProperties.ModelType.MESSAGE;
import static org.chromium.chrome.browser.tasks.tab_management.TabListModel.CardProperties.ModelType.TAB;

import android.content.Context;
import android.graphics.Canvas;
import android.view.HapticFeedbackConstants;
import android.view.View;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;

import org.chromium.base.ResettersForTesting;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.browser.feature_engagement.TrackerFactory;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModelFilter;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupModelFilter;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupUtils;
import org.chromium.chrome.browser.tasks.tab_management.MessageService.MessageType;
import org.chromium.chrome.browser.tasks.tab_management.TabListCoordinator.TabListMode;
import org.chromium.chrome.browser.tasks.tab_management.TabListMediator.TabActionListener;
import org.chromium.chrome.browser.tasks.tab_management.TabListMediator.TabGridDialogHandler;
import org.chromium.chrome.browser.tasks.tab_management.TabProperties.UiType;
import org.chromium.chrome.tab_ui.R;
import org.chromium.components.feature_engagement.EventConstants;
import org.chromium.components.feature_engagement.Tracker;
import org.chromium.ui.modelutil.SimpleRecyclerViewAdapter;

import java.util.List;

/**
 * A {@link ItemTouchHelper.SimpleCallback} implementation to host the logic for swipe and drag
 * related actions in grid related layouts.
 */
public class TabGridItemTouchHelperCallback extends ItemTouchHelper.SimpleCallback {
    /** An interface to observe the longpress event triggered on a tab card item. */
    interface OnLongPressTabItemEventListener {
        /**
         * Notify the observers that the longpress event on the tab has triggered.
         * @param tabId the id of the current tab that is being selected.
         */
        void onLongPressEvent(int tabId);
    }

    private final TabListModel mModel;
    private final Supplier<TabModelFilter> mCurrentTabModelFilterSupplier;
    private final TabListMediator.TabActionListener mTabClosedListener;
    private final String mComponentName;
    private final TabListMediator.TabGridDialogHandler mTabGridDialogHandler;
    private final @TabListMode int mMode;
    @Nullable private OnLongPressTabItemEventListener mOnLongPressTabItemEventListener;
    private final int mLongPressDpThreshold;
    private final TabGroupCreationDialogManager mTabGroupCreationDialogManager;
    private float mSwipeToDismissThreshold;
    private float mMergeThreshold;
    private float mUngroupThreshold;
    // A bool to track whether an action such as swiping, group/ungroup and drag past a certain
    // threshold was attempted. This can determine if a longpress on the tab is the objective.
    private boolean mActionAttempted;
    // A bool to track whether any action that is not a pure longpress hold-no-drag, was started.
    // This can determine if an unwanted following click from a pure longpress must be blocked.
    private boolean mActionStarted;
    private boolean mActionsOnAllRelatedTabs;
    private boolean mIsSwipingToDismiss;
    private boolean mShouldBlockAction;
    private int mDragFlags;
    private int mSelectedTabIndex = TabModel.INVALID_TAB_INDEX;
    private int mHoveredTabIndex = TabModel.INVALID_TAB_INDEX;
    private int mUnGroupTabIndex = TabModel.INVALID_TAB_INDEX;
    private int mCurrentActionState = ItemTouchHelper.ACTION_STATE_IDLE;
    private RecyclerView mRecyclerView;
    private Profile mProfile;
    private Context mContext;

    /**
     * @param context The activity context.
     * @param tabGroupCreationDialogManager The manager for showing a dialog on group creation.
     * @param tabListModel The property model of tab data to act on.
     * @param currentTabModelFilterSupplier The supplier of the current {@link TabModelFilter}. It
     *     should never return null.
     * @param tabClosedListener The listener to invoke when a tab is closed.
     * @param tabGridDialogHandler The interface for sending updates when using a tab grid dialog.
     * @param componentName The name of the component for metrics logging.
     * @param actionsOnAllRelatedTabs Whether to operate on related tabs.
     * @param mode The mode of the tab list.
     */
    public TabGridItemTouchHelperCallback(
            Context context,
            TabGroupCreationDialogManager tabGroupCreationDialogManager,
            TabListModel tabListModel,
            Supplier<TabModelFilter> currentTabModelFilterSupplier,
            TabActionListener tabClosedListener,
            TabGridDialogHandler tabGridDialogHandler,
            String componentName,
            boolean actionsOnAllRelatedTabs,
            @TabListMode int mode) {
        super(0, 0);
        mModel = tabListModel;
        mCurrentTabModelFilterSupplier = currentTabModelFilterSupplier;
        mTabClosedListener = tabClosedListener;
        mComponentName = componentName;
        mActionsOnAllRelatedTabs = actionsOnAllRelatedTabs;
        mTabGridDialogHandler = tabGridDialogHandler;
        mContext = context;
        mMode = mode;
        mLongPressDpThreshold =
                context.getResources()
                        .getDimensionPixelSize(R.dimen.tab_list_editor_longpress_entry_threshold);
        mTabGroupCreationDialogManager = tabGroupCreationDialogManager;
    }

    /**
     * @param listener the handler for longpress actions.
     */
    void setOnLongPressTabItemEventListener(OnLongPressTabItemEventListener listener) {
        mOnLongPressTabItemEventListener = listener;
    }

    /**
     * This method sets up parameters that are used by the {@link ItemTouchHelper} to make decisions
     * about user actions.
     *
     * @param swipeToDismissThreshold Defines the threshold that user needs to swipe in order to be
     *     considered as a remove operation.
     * @param mergeThreshold Defines the percentage threshold as a decimal of how much area of the
     *     two items need to be overlapped in order to be considered as a merge operation.
     */
    void setupCallback(
            float swipeToDismissThreshold, float mergeThreshold, float ungroupThreshold) {
        mSwipeToDismissThreshold = swipeToDismissThreshold;
        mMergeThreshold = mergeThreshold;
        mUngroupThreshold = ungroupThreshold;
        mDragFlags =
                ItemTouchHelper.START
                        | ItemTouchHelper.END
                        | ItemTouchHelper.UP
                        | ItemTouchHelper.DOWN;
    }

    boolean isMessageType(@Nullable RecyclerView.ViewHolder viewHolder) {
        if (viewHolder == null) return false;

        @UiType int type = viewHolder.getItemViewType();
        return type == UiType.MESSAGE
                || type == UiType.LARGE_MESSAGE
                || type == UiType.CUSTOM_MESSAGE;
    }

    @Override
    public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
        final int dragFlags = isMessageType(viewHolder) ? 0 : mDragFlags;
        int swipeFlags = ItemTouchHelper.START | ItemTouchHelper.END;
        // The archived tabs message can't be dismissed.
        if (viewHolder.getItemViewType() == UiType.CUSTOM_MESSAGE) {
            SimpleRecyclerViewAdapter.ViewHolder simpleViewHolder =
                    (SimpleRecyclerViewAdapter.ViewHolder) viewHolder;
            if (simpleViewHolder.model.get(MESSAGE_TYPE) == MessageType.ARCHIVED_TABS_MESSAGE) {
                swipeFlags = 0;
            }
        }

        mRecyclerView = recyclerView;
        return makeMovementFlags(dragFlags, swipeFlags);
    }

    @Override
    public boolean canDropOver(
            @NonNull RecyclerView recyclerView,
            @NonNull RecyclerView.ViewHolder current,
            @NonNull RecyclerView.ViewHolder target) {
        if (target.getItemViewType() == TabProperties.UiType.MESSAGE
                || target.getItemViewType() == TabProperties.UiType.LARGE_MESSAGE
                || target.getItemViewType() == TabProperties.UiType.CUSTOM_MESSAGE) {
            return false;
        }
        return super.canDropOver(recyclerView, current, target);
    }

    @Override
    public boolean onMove(
            RecyclerView recyclerView,
            RecyclerView.ViewHolder fromViewHolder,
            RecyclerView.ViewHolder toViewHolder) {
        assert !(fromViewHolder instanceof SimpleRecyclerViewAdapter.ViewHolder)
                || hasTabPropertiesModel(fromViewHolder);

        mSelectedTabIndex = toViewHolder.getAdapterPosition();
        if (mHoveredTabIndex != TabModel.INVALID_TAB_INDEX) {
            mModel.updateHoveredTabForMergeToGroup(mHoveredTabIndex, false);
            mHoveredTabIndex = TabModel.INVALID_TAB_INDEX;
        }

        int currentTabId =
                ((SimpleRecyclerViewAdapter.ViewHolder) fromViewHolder)
                        .model.get(TabProperties.TAB_ID);
        int destinationTabId =
                ((SimpleRecyclerViewAdapter.ViewHolder) toViewHolder)
                        .model.get(TabProperties.TAB_ID);
        int distance = toViewHolder.getAdapterPosition() - fromViewHolder.getAdapterPosition();
        TabModelFilter filter = mCurrentTabModelFilterSupplier.get();
        TabModel tabModel = filter.getTabModel();
        if (!mActionsOnAllRelatedTabs) {
            int destinationIndex = tabModel.indexOf(tabModel.getTabById(destinationTabId));
            tabModel.moveTab(currentTabId, distance > 0 ? destinationIndex + 1 : destinationIndex);
        } else {
            List<Tab> destinationTabGroup = getRelatedTabsForId(destinationTabId);
            int newIndex =
                    distance >= 0
                            ? TabGroupUtils.getLastTabModelIndexForList(
                                            tabModel, destinationTabGroup)
                                    + 1
                            : TabGroupUtils.getFirstTabModelIndexForList(
                                    tabModel, destinationTabGroup);
            ((TabGroupModelFilter) filter).moveRelatedTabs(currentTabId, newIndex);
        }
        RecordUserAction.record("TabGrid.Drag.Reordered." + mComponentName);
        mActionAttempted = true;
        return true;
    }

    @Override
    public void onSwiped(RecyclerView.ViewHolder viewHolder, int i) {
        assert viewHolder instanceof SimpleRecyclerViewAdapter.ViewHolder;

        SimpleRecyclerViewAdapter.ViewHolder simpleViewHolder =
                (SimpleRecyclerViewAdapter.ViewHolder) viewHolder;

        if (simpleViewHolder.model.get(CARD_TYPE) == TAB) {
            mTabClosedListener.run(
                    viewHolder.itemView, simpleViewHolder.model.get(TabProperties.TAB_ID));

            RecordUserAction.record("MobileStackViewSwipeCloseTab." + mComponentName);
        } else if (simpleViewHolder.model.get(CARD_TYPE) == MESSAGE) {
            // TODO(crbug.com/40099080): Have a caller instead of simulating the close click. And
            // write unit test to verify the caller is called.
            viewHolder.itemView.findViewById(R.id.close_button).performClick();
            // TODO(crbug.com/40099080): UserAction swipe to dismiss.
        }
        mActionAttempted = true;
    }

    @Override
    public void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState) {
        super.onSelectedChanged(viewHolder, actionState);
        if (isMessageType(viewHolder)) return;

        if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
            mSelectedTabIndex = viewHolder.getAdapterPosition();
            mModel.updateSelectedTabForMergeToGroup(mSelectedTabIndex, true);
            RecordUserAction.record("TabGrid.Drag.Start." + mComponentName);
        } else if (actionState == ItemTouchHelper.ACTION_STATE_IDLE) {
            mIsSwipingToDismiss = false;

            RecyclerView.ViewHolder hoveredViewHolder =
                    mRecyclerView.findViewHolderForAdapterPosition(mHoveredTabIndex);
            boolean shouldUpdate =
                    !(hoveredViewHolder instanceof SimpleRecyclerViewAdapter.ViewHolder)
                            || hasTabPropertiesModel(hoveredViewHolder);

            if (mHoveredTabIndex != TabModel.INVALID_TAB_INDEX && mActionsOnAllRelatedTabs) {
                RecyclerView.ViewHolder selectedViewHolder =
                        mRecyclerView.findViewHolderForAdapterPosition(mSelectedTabIndex);
                if (selectedViewHolder != null
                        && !mRecyclerView.isComputingLayout()
                        && shouldUpdate) {
                    View selectedItemView = selectedViewHolder.itemView;
                    onTabMergeToGroup(
                            mModel.getTabCardCountsBefore(mSelectedTabIndex),
                            mModel.getTabCardCountsBefore(mHoveredTabIndex));
                    mRecyclerView.getLayoutManager().removeView(selectedItemView);
                }
                mActionAttempted = true;
            } else {
                mModel.updateSelectedTabForMergeToGroup(mSelectedTabIndex, false);
            }

            if (mHoveredTabIndex != TabModel.INVALID_TAB_INDEX && shouldUpdate) {
                mModel.updateHoveredTabForMergeToGroup(
                        mSelectedTabIndex > mHoveredTabIndex
                                ? mHoveredTabIndex
                                : mModel.getTabIndexBefore(mHoveredTabIndex),
                        false);
                mActionAttempted = true;
            }
            if (mUnGroupTabIndex != TabModel.INVALID_TAB_INDEX) {
                TabGroupModelFilter filter =
                        (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get();
                RecyclerView.ViewHolder ungroupViewHolder =
                        mRecyclerView.findViewHolderForAdapterPosition(mUnGroupTabIndex);
                if (ungroupViewHolder != null && !mRecyclerView.isComputingLayout()) {
                    View ungroupItemView = ungroupViewHolder.itemView;
                    filter.moveTabOutOfGroup(
                            mModel.get(mUnGroupTabIndex).model.get(TabProperties.TAB_ID));
                    // Handle the case where the recyclerView is cleared out after ungrouping the
                    // last tab in group.
                    if (mRecyclerView.getAdapter().getItemCount() != 0) {
                        mRecyclerView.getLayoutManager().removeView(ungroupItemView);
                    }
                    RecordUserAction.record("TabGrid.Drag.RemoveFromGroup." + mComponentName);
                }
                mActionAttempted = true;
            }

            // There is a bug with ItemTouchHelper where on longpress, if the held tab is not
            // dragged (no movement occurs), then the gesture will not actually be consumed by the
            // ItemTouchHelper. This manifests as a MOTION_UP event being propagated to child view
            // click handlers and resulting in a real "click" occurring despite the action having
            // technically been consumed as a longpress by this class. The downstream click
            // handlers running can result in a tab being selected or closed in an unexpected manner
            // and due to a race condition between animations a phantom tab can even remain in the
            // UI (see crbug.com/1425336).
            //
            // To avoid this it is necessary for TabListMediator to attach an additional
            // OnItemTouchListener that resolves after the OnItemTouchListener attached by the
            // ItemTouchHelper that TabGridItemTouchHelperCallback is bound to. This additional
            // OnItemTouchListener will block the MOTION_UP event preventing the unintended action
            // from resolving.
            //
            // This block will not trigger if:
            //      a swipe was started but unfinished as mSelectedTabIndex may not be set.
            //      a swipe, move or group/ungroup happens.
            //      a tab is moved beyond a minimum distance from its original location.
            //
            // Otherwise, the unwanted click behaviour will be blocked.
            if (mSelectedTabIndex != TabModel.INVALID_TAB_INDEX
                    && mSelectedTabIndex < mModel.size()
                    && !mActionAttempted
                    && mModel.get(mSelectedTabIndex).model.get(CARD_TYPE) == TAB) {
                // If the child was ever dragged or swiped do not consume the next action, as the
                // longpress will resolve safely due to the listener intercepting the DRAG event
                // and negating any further action. However, if we just release the tab without
                // starting a swipe or drag then it is possible the longpress instead resolves as a
                // MOTION_UP click event leading to the problems described above.
                if (!mActionStarted) {
                    mShouldBlockAction = true;
                }

                if (mOnLongPressTabItemEventListener != null
                        && TabUiFeatureUtilities.isTabListEditorLongPressEntryEnabled()) {
                    int tabId = mModel.get(mSelectedTabIndex).model.get(TabProperties.TAB_ID);
                    mOnLongPressTabItemEventListener.onLongPressEvent(tabId);
                }
            }
            mHoveredTabIndex = TabModel.INVALID_TAB_INDEX;
            mSelectedTabIndex = TabModel.INVALID_TAB_INDEX;
            mUnGroupTabIndex = TabModel.INVALID_TAB_INDEX;
            if (mTabGridDialogHandler != null) {
                mTabGridDialogHandler.updateUngroupBarStatus(
                        TabGridDialogView.UngroupBarStatus.HIDE);
            }
        }
        mActionStarted = false;
        mActionAttempted = false;
    }

    private boolean hasTabPropertiesModel(RecyclerView.ViewHolder viewHolder) {
        return viewHolder instanceof SimpleRecyclerViewAdapter.ViewHolder
                && ((SimpleRecyclerViewAdapter.ViewHolder) viewHolder).model.get(CARD_TYPE) == TAB;
    }

    @Override
    public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
        super.clearView(recyclerView, viewHolder);
        int prevActionState = mCurrentActionState;
        mCurrentActionState = ItemTouchHelper.ACTION_STATE_IDLE;
        if (prevActionState != ItemTouchHelper.ACTION_STATE_DRAG) return;
        // If this item view becomes stale after the dragging animation is finished, manually clean
        // it out. Post this call as otherwise there is an IllegalStateException. See:
        // crbug.com/361498419.
        // TODO(crbug.com/40641179): Figure out why the deleting signal is not properly sent when
        // item is being dragged.
        int layoutPosition = viewHolder.getLayoutPosition();
        Runnable removeViewHolderRunnable =
                () -> {
                    if (viewHolder.itemView.getParent() == null
                            || recyclerView.getChildCount() == 0) {
                        return;
                    }

                    @Nullable var adapter = recyclerView.getAdapter();
                    if (adapter == null) return;

                    @Nullable var layoutManager = recyclerView.getLayoutManager();
                    if (layoutManager != null
                            && adapter.getItemCount() == 0
                            && layoutPosition == recyclerView.indexOfChild(viewHolder.itemView)) {
                        layoutManager.removeView(viewHolder.itemView);
                    }
                };
        recyclerView.post(removeViewHolderRunnable);
    }

    @Override
    public void onChildDraw(
            Canvas c,
            RecyclerView recyclerView,
            RecyclerView.ViewHolder viewHolder,
            float dX,
            float dY,
            int actionState,
            boolean isCurrentlyActive) {
        super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
        if (Math.abs(dX) > 0 || Math.abs(dY) > 0) {
            mActionStarted = true;
        }
        if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
            float alpha = Math.max(0.2f, 1f - 0.8f * Math.abs(dX) / mSwipeToDismissThreshold);

            assert viewHolder instanceof SimpleRecyclerViewAdapter.ViewHolder;

            int index = TabModel.INVALID_TAB_INDEX;
            SimpleRecyclerViewAdapter.ViewHolder simpleViewHolder =
                    (SimpleRecyclerViewAdapter.ViewHolder) viewHolder;

            if (simpleViewHolder.model.get(CARD_TYPE) == TAB) {
                index = mModel.indexFromId(simpleViewHolder.model.get(TabProperties.TAB_ID));
            } else if (simpleViewHolder.model.get(CARD_TYPE) == MESSAGE) {
                index =
                        mModel.lastIndexForMessageItemFromType(
                                simpleViewHolder.model.get(MESSAGE_TYPE));
            }

            if (index == TabModel.INVALID_TAB_INDEX) return;

            mModel.get(index).model.set(TabListModel.CardProperties.CARD_ALPHA, alpha);
            boolean isOverThreshold = Math.abs(dX) >= mSwipeToDismissThreshold;
            if (isOverThreshold && !mIsSwipingToDismiss) {
                viewHolder.itemView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
            }
            mIsSwipingToDismiss = isOverThreshold;
            return;
        }
        if (dX * dX + dY * dY > mLongPressDpThreshold * mLongPressDpThreshold) {
            mActionAttempted = true;
        }
        mCurrentActionState = actionState;
        if (actionState == ItemTouchHelper.ACTION_STATE_DRAG && mActionsOnAllRelatedTabs) {
            int prev_hovered = mHoveredTabIndex;
            mHoveredTabIndex =
                    TabListRecyclerView.getHoveredTabIndex(
                            recyclerView, viewHolder.itemView, dX, dY, mMergeThreshold);

            RecyclerView.ViewHolder hoveredViewHolder =
                    mRecyclerView.findViewHolderForAdapterPosition(mHoveredTabIndex);

            if (hoveredViewHolder instanceof SimpleRecyclerViewAdapter.ViewHolder
                    && !hasTabPropertiesModel(hoveredViewHolder)) {
                mHoveredTabIndex = TabModel.INVALID_TAB_INDEX;
            } else {
                mModel.updateHoveredTabForMergeToGroup(mHoveredTabIndex, true);
                if (prev_hovered != mHoveredTabIndex) {
                    mModel.updateHoveredTabForMergeToGroup(prev_hovered, false);
                }
            }
        } else if (actionState == ItemTouchHelper.ACTION_STATE_DRAG
                && mTabGridDialogHandler != null) {
            boolean isHoveredOnUngroupBar =
                    viewHolder.itemView.getBottom() + dY
                            > recyclerView.getBottom() - mUngroupThreshold;
            if (mSelectedTabIndex == TabModel.INVALID_TAB_INDEX) return;
            mUnGroupTabIndex =
                    isHoveredOnUngroupBar
                            ? viewHolder.getAdapterPosition()
                            : TabModel.INVALID_TAB_INDEX;
            mTabGridDialogHandler.updateUngroupBarStatus(
                    isHoveredOnUngroupBar
                            ? TabGridDialogView.UngroupBarStatus.HOVERED
                            : (mSelectedTabIndex == TabModel.INVALID_TAB_INDEX
                                    ? TabGridDialogView.UngroupBarStatus.HIDE
                                    : TabGridDialogView.UngroupBarStatus.SHOW));
        }
    }

    @Override
    public float getSwipeThreshold(RecyclerView.ViewHolder viewHolder) {
        return mSwipeToDismissThreshold / mRecyclerView.getWidth();
    }

    private List<Tab> getRelatedTabsForId(int id) {
        return mCurrentTabModelFilterSupplier.get().getRelatedTabList(id);
    }

    private void onTabMergeToGroup(int selectedCardIndex, int hoveredCardIndex) {
        TabGroupModelFilter filter = (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get();
        Tab selectedCard = filter.getTabAt(selectedCardIndex);
        Tab hoveredCard = filter.getTabAt(hoveredCardIndex);
        if (selectedCard == null) return;
        if (hoveredCard == null) return;
        boolean willMergingCreateNewGroup =
                filter.willMergingCreateNewGroup(List.of(selectedCard, hoveredCard));
        filter.mergeTabsToGroup(selectedCard.getId(), hoveredCard.getId());

        if (ChromeFeatureList.sTabGroupParityAndroid.isEnabled()
                && willMergingCreateNewGroup
                && !TabGroupCreationDialogManager.shouldSkipGroupCreationDialog(
                        /* shouldShow= */ TabGroupCreationDialogManager
                                .shouldShowGroupCreationDialogViaSettingsSwitch())) {
            mTabGroupCreationDialogManager.showDialog(hoveredCard.getRootId(), filter);
        }

        // If user has used drop-to-merge, send a signal to disable
        // FeatureConstants.TAB_GROUPS_DRAG_AND_DROP_FEATURE.
        final Tracker tracker =
                TrackerFactory.getTrackerForProfile(
                        mCurrentTabModelFilterSupplier.get().getTabModel().getProfile());
        tracker.notifyEvent(EventConstants.TAB_DRAG_AND_DROP_TO_GROUP);
    }

    /*
     * Returns whether or not a touch action should be blocked on an item accessed from
     * the TabListCoordinator. The bit is always defaulted to false and reset to that
     * value after shouldBlockAction() is called. It is used primarily to prevent a
     * secondary touch event from occurring on a longpress event on a tab grid item.
     */
    boolean shouldBlockAction() {
        boolean out = mShouldBlockAction;
        mShouldBlockAction = false;
        return out;
    }

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

    void setHoveredTabIndexForTesting(int index) {
        var oldValue = mHoveredTabIndex;
        mHoveredTabIndex = index;
        ResettersForTesting.register(() -> mHoveredTabIndex = oldValue);
    }

    void setSelectedTabIndexForTesting(int index) {
        var oldValue = mSelectedTabIndex;
        mSelectedTabIndex = index;
        ResettersForTesting.register(() -> mSelectedTabIndex = oldValue);
    }

    void setUnGroupTabIndexForTesting(int index) {
        var oldValue = mUnGroupTabIndex;
        mUnGroupTabIndex = index;
        ResettersForTesting.register(() -> mUnGroupTabIndex = oldValue);
    }

    void setCurrentActionStateForTesting(int actionState) {
        var oldValue = mCurrentActionState;
        mCurrentActionState = actionState;
        ResettersForTesting.register(() -> mCurrentActionState = oldValue);
    }

    boolean hasDragFlagForTesting(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
        int flags = getMovementFlags(recyclerView, viewHolder);
        return (flags >> 16) != 0;
    }

    @VisibleForTesting
    boolean hasSwipeFlag(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
        int flags = getMovementFlags(recyclerView, viewHolder);
        return ((flags >> 8) & 0xFF) != 0;
    }
}