chromium/chrome/android/java/src/org/chromium/chrome/browser/tab/TabArchiver.java

// Copyright 2024 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;

import static org.chromium.chrome.browser.tab.Tab.INVALID_TIMESTAMP;
import static org.chromium.chrome.browser.tabmodel.TabList.INVALID_TAB_INDEX;

import androidx.annotation.VisibleForTesting;

import org.chromium.base.ObserverList;
import org.chromium.base.ThreadUtils;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.chrome.browser.tab.state.ArchivePersistedTabData;
import org.chromium.chrome.browser.tabmodel.AsyncTabParamsManager;
import org.chromium.chrome.browser.tabmodel.TabClosureParams;
import org.chromium.chrome.browser.tabmodel.TabCreator;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.tabmodel.TabModelUtils;
import org.chromium.chrome.browser.tabmodel.TabReparentingParams;
import org.chromium.chrome.browser.tabmodel.TabWindowManager;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

/** Responsible for moving tabs to/from the archived {@link TabModel}. */
public class TabArchiver implements TabWindowManager.Observer {
    @FunctionalInterface
    /** Provides the current timestamp. */
    public interface Clock {
        long currentTimeMillis();
    }

    /** Provides an interface to observer the declutter process. */
    public interface Observer {
        void onDeclutterPassCompleted();
    }

    private final ObserverList<Observer> mObservers = new ObserverList<>();
    private final TabModel mArchivedTabModel;
    private final TabCreator mArchivedTabCreator;
    private final AsyncTabParamsManager mAsyncTabParamsManager;
    private final TabWindowManager mTabWindowManager;
    private final TabArchiveSettings mTabArchiveSettings;
    private final Clock mClock;

    private boolean mDeclutterInitCalled;
    private int mSelectorsQueuedForDeclutter;

    /**
     * @param archivedTabModel The archived {@link TabModel}.
     * @param archivedTabCreator The {@link TabCreator} for the archived TabModel.
     * @param asyncTabParamsManager The {@link AsyncTabParamsManager} used when unarchiving tabs.
     * @param tabWindowManager The {@link TabWindowManager} used for accessing TabModelSelectors.
     * @param tabArchiveSettings The settings for tab archiving/deletion.
     * @param clock A clock object to get the current time..
     */
    public TabArchiver(
            TabModel archivedTabModel,
            TabCreator archivedTabCreator,
            AsyncTabParamsManager asyncTabParamsManager,
            TabWindowManager tabWindowManager,
            TabArchiveSettings tabArchiveSettings,
            Clock clock) {
        mArchivedTabModel = archivedTabModel;
        mArchivedTabCreator = archivedTabCreator;
        mAsyncTabParamsManager = asyncTabParamsManager;
        mTabWindowManager = tabWindowManager;
        mTabArchiveSettings = tabArchiveSettings;
        mClock = clock;
    }

    public void addObserver(Observer observer) {
        mObservers.addObserver(observer);
    }

    public void removeObserver(Observer observer) {
        mObservers.removeObserver(observer);
    }

    /** Initialize the archiving process by observing TabWindowManager for new TabModelSelectors. */
    public void initDeclutter() {
        ThreadUtils.assertOnUiThread();
        // Observe new TabModelSelectors being added so inactive tabs are archived automatically
        // as new selectors are activated.
        mTabWindowManager.addObserver(this);

        mDeclutterInitCalled = true;
    }

    /**
     * 1. Iterates through all known tab model selects, and archives inactive tabs. 2. Iterates
     * through all archived tabs, and automatically deletes those old enough.
     */
    public void triggerScheduledDeclutter() {
        ThreadUtils.assertOnUiThread();
        assert mDeclutterInitCalled;

        // Trigger archival of inactive tabs for the current selectors.
        for (int i = 0; i < mTabWindowManager.getMaxSimultaneousSelectors(); i++) {
            TabModelSelector selector = mTabWindowManager.getTabModelSelectorById(i);
            if (selector == null) continue;
            mSelectorsQueuedForDeclutter++;
            onTabModelSelectorAdded(selector);
        }

        // Trigger auto-deletion after archiving tabs.
        deleteEligibleArchivedTabs();
        ensureArchivedTabsHaveCorrectFields();
    }

    /** Delete eligible archived tabs. */
    public void deleteEligibleArchivedTabs() {
        ThreadUtils.assertOnUiThread();
        if (!mTabArchiveSettings.isAutoDeleteEnabled()) return;

        List<Tab> tabs = new ArrayList<>();
        for (int i = 0; i < mArchivedTabModel.getCount(); i++) {
            tabs.add(mArchivedTabModel.getTabAt(i));
        }

        for (Tab tab : tabs) {
            ArchivePersistedTabData.from(
                    tab,
                    (archivePersistedTabData) -> {
                        if (isArchivedTabEligibleForDeletion(archivePersistedTabData)) {
                            int tabAgeDays =
                                    timestampMillisToDays(
                                            archivePersistedTabData.getArchivedTimeMs());
                            mArchivedTabModel.closeTabs(
                                    TabClosureParams.closeTab(tab).allowUndo(false).build());
                            RecordHistogram.recordCount1000Histogram(
                                    "Tabs.TabAutoDeleted.AfterNDays", tabAgeDays);
                            RecordUserAction.record("Tabs.ArchivedTabAutoDeleted");
                        }
                    });
        }
    }

    /**
     * Rescue archived tabs, moving them back to the regular TabModel. This is done when the feature
     * is disabled, but there are still archived tabs.
     */
    public void rescueArchivedTabs(TabCreator regularTabCreator) {
        ThreadUtils.assertOnUiThread();
        while (mArchivedTabModel.getCount() > 0) {
            Tab tab = mArchivedTabModel.getTabAt(0);
            unarchiveAndRestoreTab(regularTabCreator, tab);
            RecordUserAction.record("Tabs.ArchivedTabRescued");
        }
    }

    /**
     * Create an archived copy of the given Tab in the archived TabModel, and close the Tab in the
     * regular TabModel. Must be called on the UI thread.
     *
     * @param tabModel The {@link TabModel} the tab currently belongs to.
     * @param tab The {@link Tab} to unarchive.
     * @return The archived {@link Tab}.
     */
    public Tab archiveAndRemoveTab(TabModel tabModel, Tab tab) {
        ThreadUtils.assertOnUiThread();
        int tabAgeDays = timestampMillisToDays(tab.getTimestampMillis());
        TabState tabState = prepareTabState(tab);
        Tab newTab = mArchivedTabCreator.createFrozenTab(tabState, tab.getId(), INVALID_TAB_INDEX);
        tabModel.closeTabs(TabClosureParams.closeTab(tab).allowUndo(false).build());

        ArchivePersistedTabData.from(
                newTab,
                (archivePersistedTabData) -> {
                    // Persisted tab data requires a true supplier before saving to disk.
                    archivePersistedTabData.registerIsTabSaveEnabledSupplier(
                            new ObservableSupplierImpl<>(true));
                    archivePersistedTabData.setArchivedTimeMs(mClock.currentTimeMillis());
                });

        RecordHistogram.recordCount1000Histogram("Tabs.TabArchived.AfterNDays", tabAgeDays);
        RecordUserAction.record("Tabs.TabArchived");
        return newTab;
    }

    /**
     * Unarchive the given tab, moving it into the normal TabModel. The tab is reused between the
     * archived/regular TabModels. Must be called on the UI thread.
     *
     * @param tabCreator The {@link TabCreator} to use when recreating the tabs.
     * @param tab The {@link Tab} to unarchive.
     */
    public void unarchiveAndRestoreTab(TabCreator tabCreator, Tab tab) {
        ThreadUtils.assertOnUiThread();
        TabState tabState = prepareTabState(tab);
        mArchivedTabModel.removeTab(tab);
        mAsyncTabParamsManager.add(tab.getId(), new TabReparentingParams(tab, null));
        tabCreator.createFrozenTab(tabState, tab.getId(), INVALID_TAB_INDEX);
        RecordUserAction.record("Tabs.ArchivedTabRestored");
    }

    // TabWindowManager.Observer implementation.

    @Override
    public void onTabModelSelectorAdded(TabModelSelector selector) {
        ThreadUtils.assertOnUiThread();
        if (!mTabArchiveSettings.getArchiveEnabled()) return;

        TabModelUtils.runOnTabStateInitialized(
                selector, this::archiveEligibleTabsFromTabModelSelector);
    }

    // Private functions.

    private void archiveEligibleTabsFromTabModelSelector(TabModelSelector selector) {
        ThreadUtils.postOnUiThread(
                () -> {
                    int numExistingRegularTabsFound = 0;
                    TabModel model = selector.getModel(/* isIncognito= */ false);
                    int activeTabId = TabModelUtils.getCurrentTabId(model);
                    for (int i = 0; i < model.getCount(); ) {
                        Tab tab = model.getTabAt(i);
                        // If there's an existing archived tab for the tab id, then we've run into a
                        // case where the tab metadata file wasn't updated after an archive or
                        // restore pass. Remove the tab from the regular tab model since the tab
                        // was already archived.
                        Tab archivedTab = mArchivedTabModel.getTabById(tab.getId());
                        if (archivedTab != null) {
                            numExistingRegularTabsFound++;
                            model.closeTabs(
                                    TabClosureParams.closeTab(tab).allowUndo(false).build());
                        } else if (activeTabId != tab.getId() && isTabEligibleForArchive(tab)) {
                            archiveAndRemoveTab(model, tab);
                        } else {
                            i++;
                        }
                    }
                    mSelectorsQueuedForDeclutter--;
                    RecordHistogram.recordCount1000Histogram(
                            "Tabs.TabArchived.FoundDuplicateInRegularModel",
                            numExistingRegularTabsFound);

                    if (mSelectorsQueuedForDeclutter == 0) {
                        for (Observer obs : mObservers) {
                            obs.onDeclutterPassCompleted();
                        }
                    }
                });
    }

    private boolean isTabEligibleForArchive(Tab tab) {
        // Explicitly prevent grouped tabs from getting archived.
        if (tab.getTabGroupId() != null) return false;
        TabState tabState = TabStateExtractor.from(tab);
        if (tabState.contentsState == null) return false;

        long timestampMillis = tab.getTimestampMillis();
        int tabAgeDays = timestampMillisToDays(timestampMillis);
        boolean result =
                isTimestampWithinTargetHours(
                        timestampMillis, mTabArchiveSettings.getArchiveTimeDeltaHours());
        RecordHistogram.recordCount1000Histogram(
                "Tabs.TabArchiveEligibilityCheck.AfterNDays", tabAgeDays);
        return result;
    }

    private boolean isArchivedTabEligibleForDeletion(
            ArchivePersistedTabData archivePersistedTabData) {
        if (archivePersistedTabData == null) return false;

        long archivedTimeMillis = archivePersistedTabData.getArchivedTimeMs();
        int tabAgeDays = timestampMillisToDays(archivedTimeMillis);
        boolean result =
                isTimestampWithinTargetHours(
                        archivedTimeMillis, mTabArchiveSettings.getAutoDeleteTimeDeltaHours());
        RecordHistogram.recordCount1000Histogram(
                "Tabs.TabAutoDeleteEligibilityCheck.AfterNDays", tabAgeDays);
        return result;
    }

    private boolean isTimestampWithinTargetHours(long timestampMillis, int targetHours) {
        if (timestampMillis == INVALID_TIMESTAMP) return false;

        long ageHours = TimeUnit.MILLISECONDS.toHours(mClock.currentTimeMillis() - timestampMillis);
        return ageHours >= targetHours;
    }

    private int timestampMillisToDays(long timestampMillis) {
        if (timestampMillis == INVALID_TIMESTAMP) return (int) INVALID_TIMESTAMP;

        return (int) TimeUnit.MILLISECONDS.toDays(mClock.currentTimeMillis() - timestampMillis);
    }

    /** Extracts the tab state and prepares it for archive/restore. */
    private TabState prepareTabState(Tab tab) {
        TabState tabState = TabStateExtractor.from(tab);
        // Strip the parent id to avoid ordering issues within the tab model.
        tabState.parentId = Tab.INVALID_TAB_ID;
        // Strip the root id to avoid re-using the old rootId from the tab state file.
        tabState.rootId = Tab.INVALID_TAB_ID;
        return tabState;
    }

    @VisibleForTesting
    void ensureArchivedTabsHaveCorrectFields() {
        for (int i = 0; i < mArchivedTabModel.getCount(); i++) {
            Tab archivedTab = mArchivedTabModel.getTabAt(i);
            // Archived tabs shouldn't have a root id or parent id. It's possible that there's
            // stale data around for clients that have archived tabs prior to crrev.com/c/5750590
            // landing. Fix those fields so that they're corrected in the tab state file.
            archivedTab.setRootId(archivedTab.getId());
            archivedTab.setParentId(Tab.INVALID_TAB_ID);
        }
    }
}