// Copyright 2016 The Chromium Authors. All rights reserved.
// 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.content.SharedPreferences;
import android.os.AsyncTask;
import android.os.StrictMode;
import android.support.annotation.WorkerThread;
import android.util.Pair;
import android.util.SparseBooleanArray;
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.VisibleForTesting;
import org.chromium.base.library_loader.LibraryLoader;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.chrome.browser.TabState;
import org.chromium.chrome.browser.compositor.layouts.content.TabContentManager;
import java.io.BufferedInputStream;
import java.io.DataInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
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";
/** <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";
@VisibleForTesting
static final String PREF_HAS_RUN_FILE_MIGRATION =
"org.chromium.chrome.browser.tabmodel.TabPersistentStore.HAS_RUN_FILE_MIGRATION";
@VisibleForTesting
static final String PREF_HAS_RUN_MULTI_INSTANCE_FILE_MIGRATION =
"org.chromium.chrome.browser.tabmodel.TabPersistentStore."
+ "HAS_RUN_MULTI_INSTANCE_FILE_MIGRATION";
/** The name of the directory where the state is saved. */
@VisibleForTesting
static final String SAVED_STATE_DIRECTORY = "0";
/** Prevents two copies of the Migration task from being created. */
private static final Object MIGRATION_LOCK = new Object();
/** Prevents two state directories from getting created simultaneously. */
private static final Object DIR_CREATION_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. */
private static final AtomicBoolean MERGE_IN_PROGRESS = new AtomicBoolean();
private static AsyncTask<Void, Void, Void> sMigrationTask;
private static AsyncTask<Void, Void, Void> sCleanupTask;
private static File sStateDirectory;
private final SharedPreferences mPreferences;
private final int mSelectorIndex;
private final int mOtherSelectorIndex;
private TabContentManager mTabContentManager;
private boolean mDestroyed;
/**
* 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.
*/
public TabbedModeTabPersistencePolicy(int selectorIndex) {
mPreferences = ContextUtils.getAppSharedPreferences();
mSelectorIndex = selectorIndex;
mOtherSelectorIndex = selectorIndex == 0 ? 1 : 0;
}
@Override
public File getOrCreateStateDirectory() {
return getOrCreateTabbedModeStateDirectory();
}
@Override
public String getStateFileName() {
return getStateFileName(mSelectorIndex);
}
@Override
public String getStateToBeMergedFileName() {
return getStateFileName(mOtherSelectorIndex);
}
/**
* @param selectorIndex The index that represents which state file to pull and save state to.
* @return The name of the state file.
*/
@VisibleForTesting
public static String getStateFileName(int selectorIndex) {
return TabPersistentStore.getStateFileName(Integer.toString(selectorIndex));
}
/**
* The folder where the state should be saved to.
* @return A file representing the directory that contains TabModelSelector states.
*/
public static File getOrCreateTabbedModeStateDirectory() {
synchronized (DIR_CREATION_LOCK) {
if (sStateDirectory == null) {
sStateDirectory = new File(
TabPersistentStore.getOrCreateBaseStateDirectory(), SAVED_STATE_DIRECTORY);
StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads();
StrictMode.allowThreadDiskWrites();
try {
if (!sStateDirectory.exists() && !sStateDirectory.mkdirs()) {
Log.e(TAG, "Failed to create state folder: " + sStateDirectory);
}
} finally {
StrictMode.setThreadPolicy(oldPolicy);
}
}
}
return sStateDirectory;
}
@Override
public boolean performInitialization(Executor executor) {
ThreadUtils.assertOnUiThread();
final boolean hasRunLegacyMigration =
mPreferences.getBoolean(PREF_HAS_RUN_FILE_MIGRATION, false);
final boolean hasRunMultiInstanceMigration =
mPreferences.getBoolean(PREF_HAS_RUN_MULTI_INSTANCE_FILE_MIGRATION, false);
if (hasRunLegacyMigration && hasRunMultiInstanceMigration) return false;
synchronized (MIGRATION_LOCK) {
if (sMigrationTask != null) return true;
sMigrationTask = new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
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;
}
}.executeOnExecutor(executor);
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, getStateFileName()))) {
Log.e(TAG, "Failed to rename file: " + modelFile);
}
}
File[] files = oldFolder.listFiles();
if (files != null) {
for (File file : files) {
if (TabState.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, getStateFileName());
File oldMetadataFile = new File(stateDir, LEGACY_SAVED_STATE_FILE);
if (newMetadataFile.exists()) {
Log.e(TAG, "New metadata file already exists");
if (LibraryLoader.isInitialized()) {
RecordHistogram.recordBooleanHistogram(
"Android.MultiInstanceMigration.NewMetadataFileExists", true);
}
} 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);
if (LibraryLoader.isInitialized()) {
RecordHistogram.recordBooleanHistogram(
"Android.MultiInstanceMigration.FailedToRenameMetadataFile", true);
}
}
}
// 2. Move files from other state directories.
for (int i = TabModelSelectorImpl.CUSTOM_TABS_SELECTOR_INDEX;
i < TabWindowManager.MAX_SIMULTANEOUS_SELECTORS; i++) {
// Skip the directory we're migrating to.
if (i == 0) continue;
File otherStateDir = new File(
TabPersistentStore.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, getStateFileName(i)))) {
Log.e(TAG, "Failed to rename file: " + oldMetadataFile);
}
}
// Rename tab files.
File[] files = otherStateDir.listFiles();
if (files != null) {
for (File file : files) {
if (TabState.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() {
mPreferences.edit().putBoolean(PREF_HAS_RUN_FILE_MIGRATION, true).apply();
}
private void setMultiInstanceFileMigrationPref() {
mPreferences.edit().putBoolean(PREF_HAS_RUN_MULTI_INSTANCE_FILE_MIGRATION, true).apply();
}
@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<List<String>> filesToDelete) {
synchronized (CLEAN_UP_TASK_LOCK) {
if (sCleanupTask != null) sCleanupTask.cancel(true);
sCleanupTask = new CleanUpTabStateDataTask(filesToDelete);
sCleanupTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
}
@Override
public void setTabContentManager(TabContentManager cache) {
mTabContentManager = cache;
}
@Override
public void destroy() {
mDestroyed = true;
}
private class CleanUpTabStateDataTask extends AsyncTask<Void, Void, Void> {
private final Callback<List<String>> mFilesToDeleteCallback;
private String[] mTabFileNames;
private String[] mThumbnailFileNames;
private SparseBooleanArray mOtherTabIds;
CleanUpTabStateDataTask(Callback<List<String>> filesToDelete) {
mFilesToDeleteCallback = filesToDelete;
}
@Override
protected Void doInBackground(Void... voids) {
if (mDestroyed) return null;
mTabFileNames = getOrCreateStateDirectory().list();
String thumbnailDirectory = PathUtils.getThumbnailCacheDirectory();
mThumbnailFileNames = new File(thumbnailDirectory).list();
mOtherTabIds = new SparseBooleanArray();
getTabsFromOtherStateFiles(mOtherTabIds);
return null;
}
@Override
protected void onPostExecute(Void unused) {
if (mDestroyed) return;
TabWindowManager tabWindowManager = TabWindowManager.getInstance();
if (mTabFileNames != null) {
List<String> filesToDelete = new ArrayList<>();
for (String fileName : mTabFileNames) {
Pair<Integer, Boolean> data = TabState.parseInfoFromFilename(fileName);
if (data != null) {
int tabId = data.first;
if (shouldDeleteTabFile(tabId, tabWindowManager)) {
filesToDelete.add(fileName);
}
}
}
mFilesToDeleteCallback.onResult(filesToDelete);
}
if (mTabContentManager != null && mThumbnailFileNames != null) {
for (String fileName : mThumbnailFileNames) {
try {
int tabId = Integer.parseInt(fileName);
if (shouldDeleteTabFile(tabId, tabWindowManager)) {
mTabContentManager.removeTabThumbnail(tabId);
}
} catch (NumberFormatException expected) {
// This is an unknown file name, we'll leave it there.
}
}
}
}
private boolean shouldDeleteTabFile(int tabId, TabWindowManager tabWindowManager) {
return !tabWindowManager.tabExistsInAnySelector(tabId) && !mOtherTabIds.get(tabId);
}
/**
* Gets the IDs of all tabs in TabModelSelectors other than the currently selected one. IDs
* for custom tabs are excluded.
* @param tabIds SparseBooleanArray to populate with TabIds.
*/
private void getTabsFromOtherStateFiles(SparseBooleanArray tabIds) {
for (int i = 0; i < TabWindowManager.MAX_SIMULTANEOUS_SELECTORS; i++) {
// 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 (i == mSelectorIndex) continue;
File metadataFile = new File(getOrCreateStateDirectory(), getStateFileName(i));
if (metadataFile.exists()) {
DataInputStream stream = null;
try {
stream = new DataInputStream(
new BufferedInputStream(new FileInputStream(metadataFile)));
TabPersistentStore.readSavedStateFile(stream, null, tabIds, false);
} catch (Exception e) {
Log.e(TAG, "Unable to read state for " + metadataFile.getName() + ": " + e);
} finally {
StreamUtil.closeQuietly(stream);
}
}
}
}
}
}