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

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

import org.chromium.base.Callback;
import org.chromium.base.Token;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
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.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.tabmodel.TabModelSelectorTabModelObserver;
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.ui.messages.snackbar.Snackbar;
import org.chromium.chrome.browser.ui.messages.snackbar.SnackbarManager;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

/**
 * A controller that listens to {@link TabGroupModelFilterObserver#didCreateGroup(List, List, List)}
 * and shows a undo snackbar.
 */
public class UndoGroupSnackbarController implements SnackbarManager.SnackbarController {
    private final Context mContext;
    private final TabModelSelector mTabModelSelector;
    private final SnackbarManager mSnackbarManager;
    private final TabGroupModelFilterObserver mTabGroupModelFilterObserver;
    private final Callback<TabModel> mCurrentTabModelObserver;
    private final TabModelSelectorTabModelObserver mTabModelSelectorTabModelObserver;

    private class TabUndoInfo {
        public final Tab tab;
        public final int tabOriginalIndex;
        public final int tabOriginalRootId;
        public final @Nullable Token tabOriginalTabGroupId;
        public final String destinationGroupTitle;
        public final int destinationGroupColorId;
        public final boolean destinationGroupTitleCollapsed;

        TabUndoInfo(
                Tab tab,
                int tabIndex,
                int rootId,
                @Nullable Token tabGroupId,
                String destinationGroupTitle,
                int destinationGroupColorId,
                boolean destinationGroupTitleCollapsed) {
            this.tab = tab;
            this.tabOriginalIndex = tabIndex;
            this.tabOriginalRootId = rootId;
            this.tabOriginalTabGroupId = tabGroupId;
            this.destinationGroupTitle = destinationGroupTitle;
            this.destinationGroupColorId = destinationGroupColorId;
            this.destinationGroupTitleCollapsed = destinationGroupTitleCollapsed;
        }
    }

    /**
     * @param context The current Android context.
     * @param tabModelSelector The current {@link TabModelSelector}.
     * @param snackbarManager Manages the snackbar.
     */
    public UndoGroupSnackbarController(
            @NonNull Context context,
            @NonNull TabModelSelector tabModelSelector,
            @NonNull SnackbarManager snackbarManager) {
        mContext = context;
        mTabModelSelector = tabModelSelector;
        mSnackbarManager = snackbarManager;
        mTabGroupModelFilterObserver =
                new TabGroupModelFilterObserver() {
                    @Override
                    public void willMoveTabOutOfGroup(Tab movedTab, int newRootId) {
                        // Fix for b/338511492 is to dismiss the snackbar if an ungroup operation
                        // happens because information that allowed the group action to be undone
                        // may no longer be usable (incorrect indices, group IDs, etc.).
                        mSnackbarManager.dismissSnackbars(UndoGroupSnackbarController.this);
                    }

                    @Override
                    public void didCreateGroup(
                            List<Tab> tabs,
                            List<Integer> tabOriginalIndex,
                            List<Integer> originalRootId,
                            List<Token> originalTabGroupId,
                            String destinationGroupTitle,
                            int destinationGroupColorId,
                            boolean destinationGroupTitleCollapsed) {
                        assert tabs.size() == tabOriginalIndex.size();

                        List<TabUndoInfo> tabUndoInfo = new ArrayList<>();
                        for (int i = 0; i < tabs.size(); i++) {
                            Tab tab = tabs.get(i);
                            int index = tabOriginalIndex.get(i);
                            int rootId = originalRootId.get(i);
                            Token tabGroupId = originalTabGroupId.get(i);

                            tabUndoInfo.add(
                                    new TabUndoInfo(
                                            tab,
                                            index,
                                            rootId,
                                            tabGroupId,
                                            destinationGroupTitle,
                                            destinationGroupColorId,
                                            destinationGroupTitleCollapsed));
                        }
                        showUndoGroupSnackbar(tabUndoInfo);
                    }
                };

        ((TabGroupModelFilter)
                        mTabModelSelector.getTabModelFilterProvider().getTabModelFilter(false))
                .addTabGroupObserver(mTabGroupModelFilterObserver);
        ((TabGroupModelFilter)
                        mTabModelSelector.getTabModelFilterProvider().getTabModelFilter(true))
                .addTabGroupObserver(mTabGroupModelFilterObserver);

        mCurrentTabModelObserver =
                (tabModel) -> {
                    mSnackbarManager.dismissSnackbars(UndoGroupSnackbarController.this);
                };

        mTabModelSelector.getCurrentTabModelSupplier().addObserver(mCurrentTabModelObserver);

        mTabModelSelectorTabModelObserver =
                new TabModelSelectorTabModelObserver(mTabModelSelector) {
                    @Override
                    public void didAddTab(
                            Tab tab,
                            @TabLaunchType int type,
                            @TabCreationState int creationState,
                            boolean markedForSelection) {
                        mSnackbarManager.dismissSnackbars(UndoGroupSnackbarController.this);
                    }

                    @Override
                    public void willCloseTab(Tab tab, boolean didCloseAlone) {
                        mSnackbarManager.dismissSnackbars(UndoGroupSnackbarController.this);
                    }

                    @Override
                    public void onFinishingTabClosure(Tab tab) {
                        mSnackbarManager.dismissSnackbars(UndoGroupSnackbarController.this);
                    }
                };
    }

    /**
     * Cleans up this class, removes {@link Callback<TabModel>} from {@link
     * TabModelSelector#getCurrentTabModelSupplier()} and {@link TabGroupModelFilterObserver} from
     * {@link TabGroupModelFilter}.
     */
    public void destroy() {
        if (mTabModelSelector != null) {
            mTabModelSelector.getCurrentTabModelSupplier().removeObserver(mCurrentTabModelObserver);
            ((TabGroupModelFilter)
                            mTabModelSelector.getTabModelFilterProvider().getTabModelFilter(false))
                    .removeTabGroupObserver(mTabGroupModelFilterObserver);
            ((TabGroupModelFilter)
                            mTabModelSelector.getTabModelFilterProvider().getTabModelFilter(true))
                    .removeTabGroupObserver(mTabGroupModelFilterObserver);
        }
        mTabModelSelectorTabModelObserver.destroy();
    }

    private void showUndoGroupSnackbar(List<TabUndoInfo> tabUndoInfo) {
        int mergedGroupSize =
                mTabModelSelector
                        .getTabModelFilterProvider()
                        .getCurrentTabModelFilter()
                        .getRelatedTabIds(tabUndoInfo.get(0).tab.getId())
                        .size();

        String content = String.format(Locale.getDefault(), "%d", mergedGroupSize);
        String templateText;
        if (mergedGroupSize == 1) {
            templateText = mContext.getString(R.string.undo_bar_group_tab_message);
        } else {
            templateText = mContext.getString(R.string.undo_bar_group_tabs_message);
        }
        mSnackbarManager.showSnackbar(
                Snackbar.make(
                                content,
                                this,
                                Snackbar.TYPE_ACTION,
                                Snackbar.UMA_TAB_GROUP_MANUAL_CREATION_UNDO)
                        .setTemplateText(templateText)
                        .setAction(mContext.getString(R.string.undo), tabUndoInfo));
    }

    @Override
    public void onAction(Object actionData) {
        undo((List<TabUndoInfo>) actionData);
    }

    @Override
    public void onDismissNoAction(Object actionData) {
        TabGroupModelFilter filter =
                (TabGroupModelFilter)
                        mTabModelSelector.getTabModelFilterProvider().getCurrentTabModelFilter();

        // Delete the original tab group titles and colors of the merging tabs once the merge is
        // committed.
        for (TabUndoInfo info : (List<TabUndoInfo>) actionData) {
            int rootId = info.tabOriginalRootId;
            if (info.tab.getRootId() == rootId) continue;

            filter.deleteTabGroupTitle(rootId);

            if (ChromeFeatureList.sTabGroupParityAndroid.isEnabled()) {
                filter.deleteTabGroupColor(rootId);
            }
            if (ChromeFeatureList.sTabStripGroupCollapse.isEnabled()) {
                filter.deleteTabGroupCollapsed(rootId);
            }
        }
    }

    private void undo(List<TabUndoInfo> data) {
        assert data.size() != 0;

        TabGroupModelFilter filter =
                (TabGroupModelFilter)
                        mTabModelSelector.getTabModelFilterProvider().getCurrentTabModelFilter();
        TabUndoInfo firstInfo = data.get(0);
        int firstRootId = firstInfo.tab.getRootId();

        // The new rootID will be the destination tab group being merged to. If that destination
        // tab group had no title previously, on undo it may inherit a title from the group that
        // was merged to it, and persist when merging with other tabs later on. This check deletes
        // the group title for that rootID on undo since the destination group never had a group
        // title to begin with, and the merging tabs still have the original group title stored.
        if (firstInfo.destinationGroupTitle == null) {
            filter.deleteTabGroupTitle(firstRootId);
        }

        if (ChromeFeatureList.sTabGroupParityAndroid.isEnabled()) {
            // If the destination rootID previously did not have a color id associated with it since
            // it was either created from a new tab group or was originally a single tab before
            // merge, delete that color id on undo. This check deletes the group color for that
            // destination rootID, as all tabs still currently share that ID before the undo
            // operation is performed.
            if (firstInfo.destinationGroupColorId == TabGroupColorUtils.INVALID_COLOR_ID) {
                filter.deleteTabGroupColor(firstRootId);
            }
        }

        // The action of merging expands the destination group. If it was originally collapsed, we
        // need to restore that state.
        if (ChromeFeatureList.sTabStripGroupCollapse.isEnabled()) {
            if (firstInfo.destinationGroupTitleCollapsed) {
                filter.setTabGroupCollapsed(firstRootId, true);
            }
        }

        for (int i = data.size() - 1; i >= 0; i--) {
            TabUndoInfo info = data.get(i);
            filter.undoGroupedTab(
                    info.tab,
                    info.tabOriginalIndex,
                    info.tabOriginalRootId,
                    info.tabOriginalTabGroupId);
        }
    }
}