chromium/chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabTabPersistencePolicy.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.customtabs;

import static org.chromium.chrome.browser.dependency_injection.ChromeCommonQualifiers.SAVED_INSTANCE_SUPPLIER;

import android.app.Activity;
import android.os.Bundle;
import android.util.Pair;
import android.util.SparseBooleanArray;

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

import org.chromium.base.ApplicationStatus;
import org.chromium.base.Callback;
import org.chromium.base.Log;
import org.chromium.base.StreamUtil;
import org.chromium.base.ThreadUtils;
import org.chromium.base.supplier.Supplier;
import org.chromium.base.task.AsyncTask;
import org.chromium.base.task.BackgroundOnlyAsyncTask;
import org.chromium.base.task.SequencedTaskRunner;
import org.chromium.base.task.TaskRunner;
import org.chromium.chrome.browser.dependency_injection.ActivityScope;
import org.chromium.chrome.browser.tab_ui.TabContentManager;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.tabmodel.TabPersistenceFileInfo;
import org.chromium.chrome.browser.tabmodel.TabPersistencePolicy;
import org.chromium.chrome.browser.tabmodel.TabPersistentStore;
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.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutionException;

import javax.inject.Inject;
import javax.inject.Named;

/** Handles the Custom Tab specific behaviors of tab persistence. */
@ActivityScope
public class CustomTabTabPersistencePolicy implements TabPersistencePolicy {

    /** Threshold where old state files should be deleted (30 days). */
    protected static final long STATE_EXPIRY_THRESHOLD = 30L * 24 * 60 * 60 * 1000;

    /** Maximum number of state files before we should start deleting old ones. */
    protected static final int MAXIMUM_STATE_FILES = 30;

    private static final String TAG = "tabmodel";

    /**
     * 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();

    private static AsyncTask<Void> sCleanupTask;

    private final int mTaskId;
    private final boolean mShouldRestore;

    private AsyncTask<Void> mInitializationTask;
    private SequencedTaskRunner mTaskRunner;
    private boolean mDestroyed;

    @Inject
    public CustomTabTabPersistencePolicy(
            Activity activity,
            @Named(SAVED_INSTANCE_SUPPLIER) Supplier<Bundle> savedInstanceStateSupplier) {
        mTaskId = activity.getTaskId();
        mShouldRestore = (savedInstanceStateSupplier.get() != null);
    }

    /**
     * Constructor for slightly simplifying testing.
     *
     * @param taskId The task ID that the owning Custom Tab is in.
     * @param shouldRestore Whether an attempt to restore tab state information should be done on
     *                      startup.
     */
    @VisibleForTesting
    CustomTabTabPersistencePolicy(int taskId, boolean shouldRestore) {
        mTaskId = taskId;
        mShouldRestore = shouldRestore;
    }

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

    @Override
    public String getMetadataFileName() {
        return TabPersistentStore.getMetadataFileName(Integer.toString(mTaskId));
    }

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

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

    @Override
    public boolean performInitialization(TaskRunner taskRunner) {
        mInitializationTask =
                new BackgroundOnlyAsyncTask<Void>() {
                    @Override
                    protected Void doInBackground() {
                        File stateDir = getOrCreateStateDirectory();
                        File metadataFile = new File(stateDir, getMetadataFileName());
                        if (metadataFile.exists()) {
                            if (mShouldRestore) {
                                if (!metadataFile.setLastModified(System.currentTimeMillis())) {
                                    Log.e(
                                            TAG,
                                            "Unable to update last modified time: " + metadataFile);
                                }
                            } else {
                                if (!metadataFile.delete()) {
                                    Log.e(TAG, "Failed to delete file: " + metadataFile);
                                }
                            }
                        }
                        return null;
                    }
                }.executeOnTaskRunner(taskRunner);

        return true;
    }

    @Override
    public void waitForInitializationToFinish() {
        if (mInitializationTask == null) return;
        try {
            mInitializationTask.get();
        } catch (InterruptedException | ExecutionException e) {
            // Ignore and proceed.
        }
    }

    @Override
    public boolean isMergeInProgress() {
        return false;
    }

    @Override
    public void setMergeInProgress(boolean isStarted) {
        assert false : "Merge not supported in Custom Tabs";
    }

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

    @Override
    public void cleanupUnusedFiles(Callback<TabPersistenceFileInfo> tabDataToDelete) {
        synchronized (CLEAN_UP_TASK_LOCK) {
            if (sCleanupTask != null) sCleanupTask.cancel(true);
            sCleanupTask = new CleanUpTabStateDataTask(tabDataToDelete);
            sCleanupTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
        }
    }

    @Override
    public void setTabContentManager(TabContentManager cache) {}

    @Override
    public void notifyStateLoaded(int tabCountAtStartup) {}

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

    @Override
    public void setTaskRunner(SequencedTaskRunner taskRunner) {
        mTaskRunner = taskRunner;
    }

    /** Triggers an async deletion of the tab state metadata file. */
    public void deleteMetadataStateFileAsync() {
        assert mTaskRunner != null;
        mTaskRunner.postTask(
                () -> {
                    File stateDir = getOrCreateStateDirectory();
                    File metadataFile = new File(stateDir, getMetadataFileName());
                    if (metadataFile.exists() && !metadataFile.delete()) {
                        Log.e(TAG, "Failed to delete file: " + metadataFile);
                    }
                });
    }

    /**
     * Given a list of metadata files, determine which are applicable for deletion based on the
     * deletion strategy of Custom Tabs.
     *
     * @param currentTimeMillis The current time in milliseconds
     *                          ({@link System#currentTimeMillis()}.
     * @param allMetadataFiles The complete list of all metadata files to check.
     * @return The list of metadata files that are applicable for deletion.
     */
    protected static List<File> getMetadataFilesForDeletion(
            long currentTimeMillis, List<File> allMetadataFiles) {
        Collections.sort(
                allMetadataFiles,
                new Comparator<File>() {
                    @Override
                    public int compare(File lhs, File rhs) {
                        long lhsModifiedTime = lhs.lastModified();
                        long rhsModifiedTime = rhs.lastModified();

                        // Sort such that older files (those with an lower timestamp number) are at
                        // the end of the sorted listed.
                        return Long.compare(rhsModifiedTime, lhsModifiedTime);
                    }
                });

        List<File> stateFilesApplicableForDeletion = new ArrayList<File>();
        for (int i = 0; i < allMetadataFiles.size(); i++) {
            File file = allMetadataFiles.get(i);
            long fileAge = currentTimeMillis - file.lastModified();
            if (i >= MAXIMUM_STATE_FILES || fileAge >= STATE_EXPIRY_THRESHOLD) {
                stateFilesApplicableForDeletion.add(file);
            }
        }
        return stateFilesApplicableForDeletion;
    }

    /**
     * Get all current Tab IDs used by the specified activity.
     *
     * @param activity The activity whose tab IDs are to be collected from.
     * @param tabIds Where the tab IDs should be added to.
     */
    private static void getAllTabIdsForActivity(
            BaseCustomTabActivity activity, Set<Integer> tabIds) {
        if (activity == null) return;
        TabModelSelector selector = activity.getTabModelSelector();
        if (selector == null) return;
        List<TabModel> models = selector.getModels();
        for (int i = 0; i < models.size(); i++) {
            TabModel model = models.get(i);
            for (int j = 0; j < model.getCount(); j++) {
                tabIds.add(model.getTabAt(j).getId());
            }
        }
    }

    /**
     * Gathers all of the tab IDs and task IDs for all currently live Custom Tabs.
     *
     * @param liveTabIds Where tab IDs will be added.
     * @param liveTaskIds Where task IDs will be added.
     */
    protected static void getAllLiveTabAndTaskIds(
            Set<Integer> liveTabIds, Set<Integer> liveTaskIds) {
        ThreadUtils.assertOnUiThread();

        for (Activity activity : ApplicationStatus.getRunningActivities()) {
            if (activity instanceof BaseCustomTabActivity customActivity) {
                getAllTabIdsForActivity(customActivity, liveTabIds);
                liveTaskIds.add(customActivity.getTaskId());
            }
        }
    }

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

        private Set<Integer> mUnreferencedTabIds;
        private List<File> mDeletableMetadataFiles;
        private Map<File, SparseBooleanArray> mTabIdsByMetadataFile;

        CleanUpTabStateDataTask(Callback<TabPersistenceFileInfo> storedTabDataToDeleteCallback) {
            mTabDataToDeleteCallback = storedTabDataToDeleteCallback;
        }

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

            mTabIdsByMetadataFile = new HashMap<>();
            mUnreferencedTabIds = new HashSet<>();

            File[] stateFiles = getOrCreateStateDirectory().listFiles();
            if (stateFiles == null) return null;

            Set<Integer> allTabIds = new HashSet<>();
            Set<Integer> allReferencedTabIds = new HashSet<>();
            List<File> metadataFiles = new ArrayList<>();
            for (File file : stateFiles) {
                if (TabPersistentStore.isMetadataFile(file.getName())) {
                    metadataFiles.add(file);

                    SparseBooleanArray tabIds = new SparseBooleanArray();
                    mTabIdsByMetadataFile.put(file, tabIds);
                    getTabsFromStateFile(tabIds, file);
                    for (int i = 0; i < tabIds.size(); i++) {
                        allReferencedTabIds.add(tabIds.keyAt(i));
                    }
                    continue;
                }

                Pair<Integer, Boolean> tabInfo =
                        TabStateFileManager.parseInfoFromFilename(file.getName());
                if (tabInfo == null) continue;
                allTabIds.add(tabInfo.first);
            }

            mUnreferencedTabIds.addAll(allTabIds);
            mUnreferencedTabIds.removeAll(allReferencedTabIds);

            mDeletableMetadataFiles =
                    getMetadataFilesForDeletion(System.currentTimeMillis(), metadataFiles);
            return null;
        }

        @Override
        protected void onPostExecute(Void unused) {
            TabPersistenceFileInfo tabDataToDelete = new TabPersistenceFileInfo();
            if (mDestroyed) {
                mTabDataToDeleteCallback.onResult(tabDataToDelete);
                return;
            }

            if (mUnreferencedTabIds.isEmpty() && mDeletableMetadataFiles.isEmpty()) {
                mTabDataToDeleteCallback.onResult(tabDataToDelete);
                return;
            }

            Set<Integer> liveTabIds = new HashSet<>();
            Set<Integer> liveTaskIds = new HashSet<>();
            getAllLiveTabAndTaskIds(liveTabIds, liveTaskIds);

            for (Integer unreferencedTabId : mUnreferencedTabIds) {
                // Ignore tabs that are referenced by live activities as they might not have been
                // able to write out their state yet.
                if (liveTabIds.contains(unreferencedTabId)) continue;

                // The tab state is not referenced by any current activities or any metadata files,
                // so mark it for deletion.
                tabDataToDelete.addTabStateFileInfo(unreferencedTabId, false);
            }

            for (int i = 0; i < mDeletableMetadataFiles.size(); i++) {
                File metadataFile = mDeletableMetadataFiles.get(i);
                String id = TabPersistentStore.getMetadataFileUniqueTag(metadataFile.getName());
                try {
                    int taskId = Integer.parseInt(id);

                    // Ignore the metadata file if it belongs to a currently live
                    // BaseCustomTabActivity.
                    if (liveTaskIds.contains(taskId)) continue;

                    tabDataToDelete.addMetadataFile(metadataFile.getName());

                    SparseBooleanArray unusedTabIds = mTabIdsByMetadataFile.get(metadataFile);
                    if (unusedTabIds == null) continue;
                    for (int j = 0; j < unusedTabIds.size(); j++) {
                        tabDataToDelete.addTabStateFileInfo(unusedTabIds.keyAt(j), false);
                    }
                } catch (NumberFormatException ex) {
                    assert false : "Unexpected tab metadata file found: " + metadataFile.getName();
                    continue;
                }
            }

            mTabDataToDeleteCallback.onResult(tabDataToDelete);

            synchronized (CLEAN_UP_TASK_LOCK) {
                sCleanupTask = null; // Release static reference to external callback
            }
        }

        private void getTabsFromStateFile(SparseBooleanArray tabIds, File metadataFile) {
            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
        protected void onCancelled(Void result) {
            super.onCancelled(result);
            synchronized (CLEAN_UP_TASK_LOCK) {
                sCleanupTask = null;
            }
        }
    }

    @Override
    public void getAllTabIds(Callback<SparseBooleanArray> tabIdsCallback) {
        // This function is currently only used for PersistedTabData maintenance.
        // PersistedTabData doesn't currently support Custom Tabs.
        assert false : "Not currently supported for Custom Tabs";
    }
}