chromium/chrome/android/java/src/org/chromium/chrome/browser/tabmodel/TabbedModeTabPersistencePolicy.java

// Copyright 2016 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.tabmodel;

import android.util.Pair;
import android.util.SparseBooleanArray;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;

import org.chromium.base.Callback;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.PathUtils;
import org.chromium.base.StreamUtil;
import org.chromium.base.ThreadUtils;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.supplier.Supplier;
import org.chromium.base.task.AsyncTask;
import org.chromium.base.task.BackgroundOnlyAsyncTask;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskRunner;
import org.chromium.base.task.TaskTraits;
import org.chromium.chrome.browser.app.tabmodel.TabWindowManagerSingleton;
import org.chromium.chrome.browser.preferences.ChromePreferenceKeys;
import org.chromium.chrome.browser.preferences.ChromeSharedPreferences;
import org.chromium.chrome.browser.tab_ui.TabContentManager;
import org.chromium.chrome.browser.tabpersistence.TabStateDirectory;
import org.chromium.chrome.browser.tabpersistence.TabStateFileManager;

import java.io.BufferedInputStream;
import java.io.DataInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;

/** Handles the Tabbed mode specific behaviors of tab persistence. */
public class TabbedModeTabPersistencePolicy implements TabPersistencePolicy {
    private static final String TAG = "tabmodel";

    // This shared prefs file was used for storing tab group session counts. It is no longer in use.
    private static final String LEGACY_TAB_GROUP_PREFS_FILE = "tab_group_pref";

    // This shared prefs file was used for storing tab properties to use before tabs had loaded for
    // tab switching surfaces.
    private static final String LEGACY_TAB_ATTRIBUTE_CACHE_FILE = "tab_attribute_cache";

    /** <M53 The name of the file where the old tab metadata file is saved per directory. */
    @VisibleForTesting static final String LEGACY_SAVED_STATE_FILE = "tab_state";

    /** Prevents two copies of the Migration task from being created. */
    private static final Object MIGRATION_LOCK = new Object();

    /**
     * Prevents two clean up tasks from getting created simultaneously. Also protects against
     * incorrectly interleaving create/run/cancel on the task.
     */
    private static final Object CLEAN_UP_TASK_LOCK = new Object();

    /** Tracks whether tabs from two TabPersistentStores tabs are being merged together. */
    // TODO(crbug.com/40131185): Transit AtomicBoolean to an AtomicInteger to keep track the task id
    //        of activity being merged.
    private static final AtomicBoolean MERGE_IN_PROGRESS = new AtomicBoolean();

    private static AsyncTask<Void> sMigrationTask;
    private static AsyncTask<Void> sCleanupTask;

    private final @NonNull String mMetadataFileName;
    private final @Nullable String mOtherMetadataFileName;
    private final boolean mMergeTabsOnStartup;
    private final int mMaxSelectors;

    private TabContentManager mTabContentManager;
    private boolean mDestroyed;

    /**
     * Constructs a persistence policy that handles the Tabbed mode specific logic.
     *
     * @param metadataFileName The state file name to pull and save state to.
     * @param otherMetadataFileName The state file name to use for merging. Must be non-null if
     *     either {@code mergeTabsOnStartup} or {@code tabMergingEnabled} are true and is ignored if
     *     both are false.
     * @param mergeTabsOnStartup Whether this policy should handle merging tabs from all available
     *     tabbed mode files at startup.
     * @param tabMergingEnabled Whether tab merging operation should be done for multi-window/
     *     instance feature in general.
     */
    public TabbedModeTabPersistencePolicy(
            @NonNull String metadataFileName,
            @Nullable String otherMetadataFileName,
            boolean mergeTabsOnStartup,
            boolean tabMergingEnabled) {
        mMetadataFileName = metadataFileName;
        if (mergeTabsOnStartup || tabMergingEnabled) {
            assert otherMetadataFileName != null
                    : "otherMetadataFileName must be non-null if tab merging is enabled.";
            mOtherMetadataFileName = otherMetadataFileName;
        } else {
            mOtherMetadataFileName = null;
        }
        mMergeTabsOnStartup = mergeTabsOnStartup;
        TabWindowManager tabWindowManager = TabWindowManagerSingleton.getInstance();
        mMaxSelectors = tabWindowManager.getMaxSimultaneousSelectors();
    }

    /**
     * Constructs a persistence policy that handles the Tabbed mode specific logic.
     *
     * @param selectorIndex The index that represents which state file to pull and save state to.
     *     This is used when there can be more than one TabModelSelector.
     * @param mergeTabsOnStartup Whether this policy should handle merging tabs from all available
     *     tabbed mode files at startup.
     * @param tabMergingEnabled Whether tab merging operation should be done for multi-window/
     *     instance feature in general.
     */
    public TabbedModeTabPersistencePolicy(
            int selectorIndex, boolean mergeTabsOnStartup, boolean tabMergingEnabled) {
        this(
                getMetadataFileNameForIndex(selectorIndex),
                getMetadataFileNameForIndex(selectorIndex == 0 ? 1 : 0),
                mergeTabsOnStartup,
                tabMergingEnabled);
    }

    @Override
    public File getOrCreateStateDirectory() {
        return TabStateDirectory.getOrCreateTabbedModeStateDirectory();
    }

    @Override
    public @NonNull String getMetadataFileName() {
        return mMetadataFileName;
    }

    @Override
    public boolean shouldMergeOnStartup() {
        return mMergeTabsOnStartup;
    }

    @Override
    public @Nullable String getMetadataFileNameToBeMerged() {
        return mOtherMetadataFileName;
    }

    /**
     * @param selectorIndex The index that represents which state file to pull and save state to.
     * @return The name of the state file.
     */
    public static String getMetadataFileNameForIndex(int selectorIndex) {
        return TabPersistentStore.getMetadataFileName(Integer.toString(selectorIndex));
    }

    @Override
    public boolean performInitialization(TaskRunner taskRunner) {
        ThreadUtils.assertOnUiThread();

        final boolean hasRunLegacyMigration =
                ChromeSharedPreferences.getInstance()
                        .readBoolean(ChromePreferenceKeys.TABMODEL_HAS_RUN_FILE_MIGRATION, false);
        final boolean hasRunMultiInstanceMigration =
                ChromeSharedPreferences.getInstance()
                        .readBoolean(
                                ChromePreferenceKeys.TABMODEL_HAS_RUN_MULTI_INSTANCE_FILE_MIGRATION,
                                false);

        if (hasRunLegacyMigration && hasRunMultiInstanceMigration) return false;

        synchronized (MIGRATION_LOCK) {
            if (sMigrationTask != null) return true;
            sMigrationTask =
                    new BackgroundOnlyAsyncTask<Void>() {
                        @Override
                        protected Void doInBackground() {
                            if (!hasRunLegacyMigration) {
                                performLegacyMigration();
                            }

                            // It's possible that the legacy migration ran in the past but the
                            // preference wasn't set, because the legacy migration hasn't always
                            // set a preference upon completion. If the legacy migration has
                            // already been performed, performLecacyMigration() will exit early
                            // without renaming the metadata file, so the multi-instance migration
                            // is still necessary.
                            if (!hasRunMultiInstanceMigration) {
                                performMultiInstanceMigration();
                            }
                            return null;
                        }
                    }.executeOnTaskRunner(taskRunner);
            return true;
        }
    }

    /**
     * Upgrades users from an old version of Chrome when the state file was still in the root
     * directory.
     */
    @WorkerThread
    private void performLegacyMigration() {
        Log.w(TAG, "Starting to perform legacy migration.");
        File newFolder = getOrCreateStateDirectory();
        File[] newFiles = newFolder.listFiles();
        // Attempt migration if we have no tab state file in the new directory.
        if (newFiles == null || newFiles.length == 0) {
            File oldFolder = ContextUtils.getApplicationContext().getFilesDir();
            File modelFile = new File(oldFolder, LEGACY_SAVED_STATE_FILE);
            if (modelFile.exists()) {
                if (!modelFile.renameTo(new File(newFolder, getMetadataFileName()))) {
                    Log.e(TAG, "Failed to rename file: " + modelFile);
                }
            }

            File[] files = oldFolder.listFiles();
            if (files != null) {
                for (File file : files) {
                    if (TabStateFileManager.parseInfoFromFilename(file.getName()) != null) {
                        if (!file.renameTo(new File(newFolder, file.getName()))) {
                            Log.e(TAG, "Failed to rename file: " + file);
                        }
                    }
                }
            }
        }
        setLegacyFileMigrationPref();
        Log.w(TAG, "Finished performing legacy migration.");
    }

    /**
     * Upgrades users from an older version of Chrome when the state files for multi-instance
     * were each kept in separate subdirectories.
     */
    @WorkerThread
    private void performMultiInstanceMigration() {
        Log.w(TAG, "Starting to perform multi-instance migration.");
        // 0. Do not rename the old metadata file if the new metadata file already exists. This
        //    should not happen, but if it does and the metadata file is overwritten then users
        //    may lose tabs. See crbug.com/649384.
        File stateDir = getOrCreateStateDirectory();
        File newMetadataFile = new File(stateDir, getMetadataFileName());
        File oldMetadataFile = new File(stateDir, LEGACY_SAVED_STATE_FILE);
        if (newMetadataFile.exists()) {
            Log.e(TAG, "New metadata file already exists");
        } else if (oldMetadataFile.exists()) {
            // 1. Rename tab metadata file for tab directory "0".
            if (!oldMetadataFile.renameTo(newMetadataFile)) {
                Log.e(TAG, "Failed to rename file: " + oldMetadataFile);
            }
        }

        // 2. Move files from other state directories.
        for (int i = TabModelSelectorImpl.CUSTOM_TABS_SELECTOR_INDEX; i < mMaxSelectors; i++) {
            // Skip the directory we're migrating to.
            if (i == 0) continue;

            File otherStateDir =
                    new File(
                            TabStateDirectory.getOrCreateBaseStateDirectory(), Integer.toString(i));
            if (otherStateDir == null || !otherStateDir.exists()) continue;

            // Rename tab state file.
            oldMetadataFile = new File(otherStateDir, LEGACY_SAVED_STATE_FILE);
            if (oldMetadataFile.exists()) {
                if (!oldMetadataFile.renameTo(new File(stateDir, getMetadataFileNameForIndex(i)))) {
                    Log.e(TAG, "Failed to rename file: " + oldMetadataFile);
                }
            }

            // Rename tab files.
            File[] files = otherStateDir.listFiles();
            if (files != null) {
                for (File file : files) {
                    if (TabStateFileManager.parseInfoFromFilename(file.getName()) != null) {
                        // Custom tabs does not currently use tab files. Delete them rather than
                        // migrating.
                        if (i == TabModelSelectorImpl.CUSTOM_TABS_SELECTOR_INDEX) {
                            if (!file.delete()) {
                                Log.e(TAG, "Failed to delete file: " + file);
                            }
                            continue;
                        }

                        // If the tab was moved between windows in Android N multi-window, the tab
                        // file may exist in both directories. Keep whichever was modified more
                        // recently.
                        File newFileName = new File(stateDir, file.getName());
                        if (newFileName.exists()
                                && newFileName.lastModified() > file.lastModified()) {
                            if (!file.delete()) {
                                Log.e(TAG, "Failed to delete file: " + file);
                            }
                        } else if (!file.renameTo(newFileName)) {
                            Log.e(TAG, "Failed to rename file: " + file);
                        }
                    }
                }
            }

            // Delete other state directory.
            if (!otherStateDir.delete()) {
                Log.e(TAG, "Failed to delete directory: " + otherStateDir);
            }
        }

        setMultiInstanceFileMigrationPref();
        Log.w(TAG, "Finished performing multi-instance migration.");
    }

    private void setLegacyFileMigrationPref() {
        ChromeSharedPreferences.getInstance()
                .writeBoolean(ChromePreferenceKeys.TABMODEL_HAS_RUN_FILE_MIGRATION, true);
    }

    private void setMultiInstanceFileMigrationPref() {
        ChromeSharedPreferences.getInstance()
                .writeBoolean(
                        ChromePreferenceKeys.TABMODEL_HAS_RUN_MULTI_INSTANCE_FILE_MIGRATION, true);
    }

    @Override
    public void waitForInitializationToFinish() {
        if (sMigrationTask == null) return;
        try {
            sMigrationTask.get();
        } catch (InterruptedException e) {
        } catch (ExecutionException e) {
        }
    }

    @Override
    public boolean isMergeInProgress() {
        return MERGE_IN_PROGRESS.get();
    }

    @Override
    public void setMergeInProgress(boolean isStarted) {
        MERGE_IN_PROGRESS.set(isStarted);
    }

    @Override
    public void cancelCleanupInProgress() {
        synchronized (CLEAN_UP_TASK_LOCK) {
            if (sCleanupTask != null) sCleanupTask.cancel(true);
        }
    }

    /**
     * {@inheritDoc}
     *
     * <p>Creates an asynchronous task to delete persistent data. The task is run using a thread
     * pool and may be executed in parallel with other tasks. The cleanup task use a combination of
     * the current model and the tab state files for other models to determine which tab files
     * should be deleted. The cleanup task should be canceled if a second tab model is created.
     */
    @Override
    public void cleanupUnusedFiles(Callback<TabPersistenceFileInfo> tabDataToDelete) {
        synchronized (CLEAN_UP_TASK_LOCK) {
            if (sCleanupTask != null) sCleanupTask.cancel(true);
            sCleanupTask =
                    new CleanUpTabStateDataTask(
                            tabDataToDelete, () -> getOtherTabsId(getMetadataFileName()));
            sCleanupTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
        }
    }

    @Override
    public void cleanupInstanceState(int index, Callback<TabPersistenceFileInfo> tabDataToDelete) {
        TabModelSelector selector =
                TabWindowManagerSingleton.getInstance().getTabModelSelectorById(index);
        if (selector != null) {
            // Remove all the tabs from the instance if it is in running state to be able to
            // delete the corresponding tab state file.
            for (int i = 0; i < selector.getModels().size(); i++) {
                TabModel tabModel = selector.getModels().get(i);
                while (tabModel.getCount() > 0) tabModel.removeTab(tabModel.getTabAt(0));
            }
        }
        synchronized (CLEAN_UP_TASK_LOCK) {
            if (sCleanupTask != null) sCleanupTask.cancel(true);
            sCleanupTask =
                    new CleanUpTabStateDataTask(
                            tabDataToDelete,
                            () -> getOtherTabsId(getMetadataFileNameForIndex(index)));
            sCleanupTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
        }
    }

    /**
     * Gets the IDs of all tabs in the instances other than the specified one. IDs for custom tabs
     * are excluded. IDs for archived tabs are included.
     *
     * @param excludedMetadataFileName The metadata file name that is not included.
     */
    private SparseBooleanArray getOtherTabsId(String excludedMetadataFileName) {
        SparseBooleanArray tabIds = new SparseBooleanArray();
        for (String metadataFileName : getAllMetadataFileNames()) {
            // Although we check all selectors before deleting, we can only be sure that our own
            // selector will not go away between now and then. So, we read from disk all other
            // state files, even if they are already loaded by another selector.
            if (!excludedMetadataFileName.equals(metadataFileName)) {
                getTabsFromMetadataFile(tabIds, metadataFileName);
            }
        }
        return tabIds;
    }

    /**
     * Gets the IDs of all tabs in TabModelSelectors of a given instance.
     *
     * @param tabIds SparseBooleanArray to populate with TabIds.
     * @param metadataFileName The metadata file name for the corresponding instance.
     */
    private void getTabsFromMetadataFile(SparseBooleanArray tabIds, String metadataFileName) {
        File metadataFile = new File(getOrCreateStateDirectory(), metadataFileName);
        if (metadataFile.exists()) {
            DataInputStream stream = null;
            try {
                stream =
                        new DataInputStream(
                                new BufferedInputStream(new FileInputStream(metadataFile)));
                TabPersistentStore.readSavedMetadataFile(stream, null, tabIds);
            } catch (Exception e) {
                Log.e(TAG, "Unable to read state for " + metadataFile.getName() + ": " + e);
            } finally {
                StreamUtil.closeQuietly(stream);
            }
        }
    }

    @Override
    public void setTabContentManager(TabContentManager cache) {
        mTabContentManager = cache;
    }

    @Override
    public void notifyStateLoaded(int tabCountAtStartup) {
        RecordHistogram.recordCount1MHistogram("Tabs.CountAtStartup", tabCountAtStartup);
    }

    @Override
    public void destroy() {
        mTabContentManager = null;
        mDestroyed = true;
    }

    private class CleanUpTabStateDataTask extends AsyncTask<Void> {
        private final Callback<TabPersistenceFileInfo> mTabDataToDelete;

        private String[] mTabFileNames;
        private String[] mThumbnailFileNames;
        private Supplier<SparseBooleanArray> mOtherTabSupplier;
        private SparseBooleanArray mOtherTabIds; // Tab in use by other selectors, not be deleted.

        CleanUpTabStateDataTask(
                Callback<TabPersistenceFileInfo> storedTabDataId,
                Supplier<SparseBooleanArray> otherTabsSupplier) {
            mTabDataToDelete = storedTabDataId;
            mOtherTabSupplier = otherTabsSupplier;
        }

        @Override
        protected Void doInBackground() {
            if (mDestroyed) return null;

            mTabFileNames = getOrCreateStateDirectory().list();
            String thumbnailDirectory = PathUtils.getThumbnailCacheDirectory();
            mThumbnailFileNames = new File(thumbnailDirectory).list();
            mOtherTabIds = mOtherTabSupplier.get();

            ContextUtils.getApplicationContext()
                    .deleteSharedPreferences(LEGACY_TAB_GROUP_PREFS_FILE);
            ContextUtils.getApplicationContext()
                    .deleteSharedPreferences(LEGACY_TAB_ATTRIBUTE_CACHE_FILE);

            return null;
        }

        @Override
        protected void onPostExecute(Void unused) {
            if (mDestroyed) return;
            TabWindowManager tabWindowManager = TabWindowManagerSingleton.getInstance();

            TabPersistenceFileInfo storedTabDataToDelete = new TabPersistenceFileInfo();
            if (mTabFileNames != null) {
                for (String fileName : mTabFileNames) {
                    Pair<Integer, Boolean> data =
                            TabStateFileManager.parseInfoFromFilename(fileName);
                    if (data != null) {
                        int tabId = data.first;
                        if (shouldDeleteTabFile(tabId, tabWindowManager)) {
                            storedTabDataToDelete.addTabStateFileInfo(tabId, data.second);
                        }
                    }
                }
            }
            // Invoke the callback even if filesToDelete is empty since it could perform other
            // cleanups.
            mTabDataToDelete.onResult(storedTabDataToDelete);

            if (mTabContentManager != null && mThumbnailFileNames != null) {
                HashSet<Integer> checkedTabIds = new HashSet<>();
                for (String fileName : mThumbnailFileNames) {
                    // Handle *.jpeg.
                    int lastDotIdx = fileName.lastIndexOf(".");
                    if (lastDotIdx != -1) {
                        fileName = fileName.substring(0, lastDotIdx);
                    }
                    try {
                        int tabId = Integer.parseInt(fileName);
                        // Avoid double removal which could be expensive.
                        if (!checkedTabIds.add(tabId)) continue;

                        if (shouldDeleteTabFile(tabId, tabWindowManager)) {
                            mTabContentManager.removeTabThumbnail(tabId);
                        }
                    } catch (NumberFormatException expected) {
                        // This is an unknown file name, we'll leave it there.
                    }
                }
            }

            synchronized (CLEAN_UP_TASK_LOCK) {
                sCleanupTask = null;
            }
        }

        private boolean shouldDeleteTabFile(int tabId, TabWindowManager tabWindowManager) {
            return tabWindowManager.canTabStateBeDeleted(tabId) && !mOtherTabIds.get(tabId);
        }

        @Override
        protected void onCancelled(Void result) {
            super.onCancelled(result);
            synchronized (CLEAN_UP_TASK_LOCK) {
                sCleanupTask = null;
            }
        }
    }

    @Override
    public void getAllTabIds(Callback<SparseBooleanArray> tabIdsCallback) {
        PostTask.postTask(
                TaskTraits.USER_BLOCKING_MAY_BLOCK,
                () -> {
                    SparseBooleanArray tabIds = new SparseBooleanArray();
                    for (String metadataFileName : getAllMetadataFileNames()) {
                        getTabsFromMetadataFile(tabIds, metadataFileName);
                    }
                    PostTask.postTask(
                            TaskTraits.UI_DEFAULT,
                            () -> {
                                tabIdsCallback.onResult(tabIds);
                            });
                });
    }

    /** Get all the state file names excluding archived. */
    private List<String> getAllMetadataFileNames() {
        List<String> metadataFileNames = new ArrayList<>();
        for (int i = 0; i < mMaxSelectors; i++) {
            metadataFileNames.add(getMetadataFileNameForIndex(i));
        }
        return metadataFileNames;
    }

    protected static void resetMigrationTaskForTesting() {
        sMigrationTask = null;
    }
}