chromium/chrome/android/java/src/org/chromium/chrome/browser/tab/tab_restore/HistoricalTabModelObserver.java

// Copyright 2022 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.tab.tab_restore;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.Token;
import org.chromium.base.supplier.LazyOneshotSupplier;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.tab.Tab;
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.tasks.tab_groups.TabGroupModelFilter;
import org.chromium.components.tab_groups.TabGroupColorId;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Set;

/** A tab model observer for managing bulk closures. */
public class HistoricalTabModelObserver implements TabModelObserver {
    private final TabGroupModelFilter mTabGroupModelFilter;
    private final HistoricalTabSaver mHistoricalTabSaver;

    /**
     * @param tabModelFilter The tab model filter to observe tab closures in.
     */
    public HistoricalTabModelObserver(TabModelFilter tabModelFilter) {
        this(tabModelFilter, new HistoricalTabSaverImpl(tabModelFilter.getTabModel()));
    }

    @VisibleForTesting
    public HistoricalTabModelObserver(
            TabModelFilter tabModelFilter, HistoricalTabSaver historicalTabSaver) {
        mTabGroupModelFilter = (TabGroupModelFilter) tabModelFilter;
        mHistoricalTabSaver = historicalTabSaver;

        tabModelFilter.addObserver(this);
    }

    /** Removes observers. */
    public void destroy() {
        mTabGroupModelFilter.removeObserver(this);
        mHistoricalTabSaver.destroy();
    }

    /**
     * Adds a secondary {@link TabModel} supplier to check if a deleted tab should be added to
     * recent tabs.
     */
    public void addSecodaryTabModelSupplier(Supplier<TabModel> tabModelSupplier) {
        mHistoricalTabSaver.addSecodaryTabModelSupplier(tabModelSupplier);
    }

    /**
     * Removes a secondary {@link TabModel} supplier to check if a deleted tab should be added to
     * recent tabs.
     */
    public void removeSecodaryTabModelSupplier(Supplier<TabModel> tabModelSupplier) {
        mHistoricalTabSaver.removeSecodaryTabModelSupplier(tabModelSupplier);
    }

    @Override
    public void onFinishingMultipleTabClosure(List<Tab> tabs, boolean canRestore) {
        if (tabs.isEmpty() || !canRestore) return;

        if (tabs.size() == 1) {
            Tab tab = tabs.get(0);
            if (!isTabGroupWithOneTab(tab)) {
                mHistoricalTabSaver.createHistoricalTab(tab);
                return;
            }
        }

        buildGroupsAndCreateClosure(tabs);
    }

    private void buildGroupsAndCreateClosure(List<Tab> tabs) {
        HashMap<Integer, HistoricalEntry> idToGroup = new HashMap<>();
        List<HistoricalEntry> entries = new ArrayList<>();

        LazyOneshotSupplier<Set<Token>> tabGroupIdsInComprehensiveModel =
                mTabGroupModelFilter.getLazyAllTabGroupIdsInComprehensiveModel(tabs);
        for (Tab tab : tabs) {
            // Ignore complete tab groups that are being hidden. They will be accessible from the
            // tab group pane instead. Still process closures for events that don't finish hiding
            // the group.
            @Nullable Token tabGroupId = tab.getTabGroupId();
            if (tabGroupId != null) {
                if (mTabGroupModelFilter.isTabGroupHiding(tabGroupId)
                        && !tabGroupIdsInComprehensiveModel.get().contains(tabGroupId)) {
                    continue;
                }
            }

            // {@link TabGroupModelFilter} removes tabs from its data model as soon as they are
            // pending closure so it cannot be directly relied upon for group structure. Instead
            // rely on the underlying root ID in the tab's persisted data which is used to restore
            // groups across an pending closure cancellation (undo). The root ID is the group ID
            // unless the tab is ungrouped in which case the root ID is the tab's ID.
            int rootId = tab.getRootId();
            if (idToGroup.containsKey(rootId)) {
                idToGroup.get(rootId).getTabs().add(tab);
                continue;
            }
            // null title for default title is handled in HistoricalTabSaver.
            String title = mTabGroupModelFilter.getTabGroupTitle(rootId);
            // Give a tab group the first color in the color list as a placeholder.
            @TabGroupColorId int color = TabGroupColorId.GREY;
            if (ChromeFeatureList.sTabGroupParityAndroid.isEnabled()) {
                color = mTabGroupModelFilter.getTabGroupColorWithFallback(rootId);
            }
            List<Tab> groupTabs = new ArrayList<>();
            groupTabs.add(tab);
            HistoricalEntry historicalGroup =
                    new HistoricalEntry(rootId, tabGroupId, title, color, groupTabs);
            entries.add(historicalGroup);
            idToGroup.put(rootId, historicalGroup);
        }

        // If only a subset of tabs in the tab group are closing tabs should be saved individually
        // so that a duplicate of the tab group isn't created on restore.
        // TODO(crbug/327166316): Wire up tab group IDs when saving in native so that the tabs
        // can be restored into their prior group if it still exists.
        List<HistoricalEntry> groupAdjustedEntries = new ArrayList<>();
        for (HistoricalEntry entry : entries) {
            if (shouldSaveSeparateTabs(entry)) {
                for (Tab tab : entry.getTabs()) {
                    groupAdjustedEntries.add(new HistoricalEntry(tab));
                }
            } else {
                groupAdjustedEntries.add(entry);
            }
        }

        mHistoricalTabSaver.createHistoricalBulkClosure(groupAdjustedEntries);
    }

    private boolean shouldSaveSeparateTabs(HistoricalEntry entry) {
        if (entry.getRootId() == Tab.INVALID_TAB_ID) return false;

        int rootId = entry.getRootId();
        boolean groupExists = mTabGroupModelFilter.tabGroupExistsForRootId(rootId);
        if (groupExists
                && entry.getTabs().size()
                        != mTabGroupModelFilter.getRelatedTabCountForRootId(rootId)) {
            // Case: Group information not lost yet (non-undoable closure). Rely on whether all the
            // tabs in the group are closing.
            return true;
        } else if (!groupExists) {
            // Case: Group information already lost (undoable closure). Rely on whether any unclosed
            // tabs share a root ID with the closing group.
            TabList comprehensiveModel = mTabGroupModelFilter.getTabModel().getComprehensiveModel();
            for (int i = 0; i < comprehensiveModel.getCount(); i++) {
                if (rootId == comprehensiveModel.getTabAt(i).getRootId()) return true;
            }
        }
        return false;
    }

    private boolean isTabGroupWithOneTab(Tab tab) {
        int rootId = tab.getRootId();
        if (!mTabGroupModelFilter.tabGroupExistsForRootId(rootId)) {
            Token tabGroupId = tab.getTabGroupId();
            if (tabGroupId == null) return false;
            // Case: Group information already lost (undoable closure). Rely on whether the tab
            // still has a tab group ID.
            TabList comprehensiveModel = mTabGroupModelFilter.getTabModel().getComprehensiveModel();
            for (int i = 0; i < comprehensiveModel.getCount(); i++) {
                if (tabGroupId.equals(comprehensiveModel.getTabAt(i).getTabGroupId())) return false;
            }
            return true;
        } else {
            // Case: Group information not lost yet (non-undoable closure). Rely on whether the tab
            // is the only tab in its tab group.
            return mTabGroupModelFilter.isTabInTabGroup(tab)
                    && mTabGroupModelFilter.getRelatedTabCountForRootId(rootId) == 1;
        }
    }
}