// Copyright 2014 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.Context; import android.content.SharedPreferences; import android.os.AsyncTask; import android.os.StrictMode; import android.os.SystemClock; import android.support.annotation.Nullable; import android.support.v4.util.AtomicFile; import android.text.TextUtils; import android.util.Pair; import android.util.SparseBooleanArray; import android.util.SparseIntArray; import org.chromium.base.Callback; import org.chromium.base.ContextUtils; import org.chromium.base.Log; 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.base.metrics.RecordUserAction; import org.chromium.chrome.browser.TabState; import org.chromium.chrome.browser.compositor.layouts.content.TabContentManager; import org.chromium.chrome.browser.tab.Tab; import org.chromium.chrome.browser.tab.TabIdManager; import org.chromium.content_public.browser.LoadUrlParams; import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Deque; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; /** * This class handles saving and loading tab state from the persistent storage. */ public class TabPersistentStore extends TabPersister { private static final String TAG = "tabmodel"; /** * The current version of the saved state file. * Version 4: In addition to the tab's ID, save the tab's last URL. * Version 5: In addition to the total tab count, save the incognito tab count. */ private static final int SAVED_STATE_VERSION = 5; private static final String BASE_STATE_FOLDER = "tabs"; /** The name of the directory where the state is saved. */ @VisibleForTesting static final String SAVED_STATE_DIRECTORY = "0"; @VisibleForTesting static final String PREF_ACTIVE_TAB_ID = "org.chromium.chrome.browser.tabmodel.TabPersistentStore.ACTIVE_TAB_ID"; private static final String PREF_HAS_COMPUTED_MAX_ID = "org.chromium.chrome.browser.tabmodel.TabPersistentStore.HAS_COMPUTED_MAX_ID"; /** Prevents two TabPersistentStores from saving the same file simultaneously. */ private static final Object SAVE_LIST_LOCK = new Object(); /** * Callback interface to use while reading the persisted TabModelSelector info from disk. */ public static interface OnTabStateReadCallback { /** * To be called as the details about a persisted Tab are read from the TabModelSelector's * persisted data. * @param index The index out of all tabs for the current tab read. * @param id The id for the current tab read. * @param url The url for the current tab read. * @param isIncognito Whether the Tab is definitely Incognito, or null if it * couldn't be determined because we didn't know how many * Incognito tabs were saved out. * @param isStandardActiveIndex Whether the current tab read is the normal active tab. * @param isIncognitoActiveIndex Whether the current tab read is the incognito active tab. */ void onDetailsRead(int index, int id, String url, Boolean isIncognito, boolean isStandardActiveIndex, boolean isIncognitoActiveIndex); } /** * Alerted at various stages of operation. */ public static interface TabPersistentStoreObserver { /** * To be called when the file containing the initial information about the TabModels has * been loaded. * @param tabCountAtStartup How many tabs there are in the TabModels. */ void onInitialized(int tabCountAtStartup); /** * Called when details about a Tab are read from the metadata file. */ void onDetailsRead(int index, int id, String url, boolean isStandardActiveIndex, boolean isIncognitoActiveIndex); /** * To be called when the TabStates have all been loaded. */ void onStateLoaded(); /** * To be called when the TabState from another instance has been merged. */ void onStateMerged(); /** * Called when the metadata file has been saved out asynchronously. * This currently does not get called when the metadata file is saved out on the UI thread. */ void onMetadataSavedAsynchronously(); } /** Stores information about a TabModel. */ public static class TabModelMetadata { public final int index; public final List<Integer> ids; public final List<String> urls; TabModelMetadata(int selectedIndex) { index = selectedIndex; ids = new ArrayList<>(); urls = new ArrayList<>(); } } private static class BaseStateDirectoryHolder { // Not final for tests. private static File sDirectory; static { sDirectory = ContextUtils.getApplicationContext() .getDir(BASE_STATE_FOLDER, Context.MODE_PRIVATE); } } private final TabPersistencePolicy mPersistencePolicy; private final TabModelSelector mTabModelSelector; private final TabCreatorManager mTabCreatorManager; private TabPersistentStoreObserver mObserver; private final Deque<Tab> mTabsToSave; private final Deque<TabRestoreDetails> mTabsToRestore; private final Set<Integer> mTabIdsToRestore; private LoadTabTask mLoadTabTask; private SaveTabTask mSaveTabTask; private SaveListTask mSaveListTask; private boolean mDestroyed; private boolean mCancelNormalTabLoads = false; private boolean mCancelIncognitoTabLoads = false; // Keys are the original tab indexes, values are the tab ids. private SparseIntArray mNormalTabsRestored; private SparseIntArray mIncognitoTabsRestored; private SharedPreferences mPreferences; private AsyncTask<Void, Void, DataInputStream> mPrefetchTabListTask; private AsyncTask<Void, Void, DataInputStream> mPrefetchTabListToMergeTask; private byte[] mLastSavedMetadata; // Tracks whether this TabPersistentStore's tabs are being loaded. private boolean mLoadInProgress; // The number of tabs being merged. Used for logging time to restore per tab. private int mMergeTabCount; // Set when restoreTabs() is called during a non-cold-start merge. Used for logging time to // restore per tab. private long mRestoreMergedTabsStartTime; @VisibleForTesting AsyncTask<Void, Void, TabState> mPrefetchActiveTabTask; /** * Creates an instance of a TabPersistentStore. * @param modelSelector The {@link TabModelSelector} to restore to and save from. * @param tabCreatorManager The {@link TabCreatorManager} to use. * @param observer Notified when the TabPersistentStore has completed tasks. * @param mergeTabs Whether tabs from a second TabModelSelector should be merged into * into this instance. */ public TabPersistentStore(TabPersistencePolicy policy, TabModelSelector modelSelector, TabCreatorManager tabCreatorManager, TabPersistentStoreObserver observer, final boolean mergeTabs) { mPersistencePolicy = policy; mTabModelSelector = modelSelector; mTabCreatorManager = tabCreatorManager; mTabsToSave = new ArrayDeque<>(); mTabsToRestore = new ArrayDeque<>(); mTabIdsToRestore = new HashSet<>(); mObserver = observer; mPreferences = ContextUtils.getAppSharedPreferences(); assert isStateFile(policy.getStateFileName()) : "State file name is not valid"; boolean needsInitialization = mPersistencePolicy.performInitialization( AsyncTask.SERIAL_EXECUTOR); if (mPersistencePolicy.isMergeInProgress()) return; Executor executor = needsInitialization ? AsyncTask.SERIAL_EXECUTOR : AsyncTask.THREAD_POOL_EXECUTOR; mPrefetchTabListTask = startFetchTabListTask(executor, mPersistencePolicy.getStateFileName()); startPrefetchActiveTabTask(executor); if (mergeTabs) { assert mPersistencePolicy.getStateToBeMergedFileName() != null; mPrefetchTabListToMergeTask = startFetchTabListTask( executor, mPersistencePolicy.getStateToBeMergedFileName()); } } @Override protected File getStateDirectory() { return mPersistencePolicy.getOrCreateStateDirectory(); } /** * Waits for the task that migrates all state files to their new location to finish. */ @VisibleForTesting public void waitForMigrationToFinish() { mPersistencePolicy.waitForInitializationToFinish(); } /** * Sets the {@link TabContentManager} to use. * @param cache The {@link TabContentManager} to use. */ public void setTabContentManager(TabContentManager cache) { mPersistencePolicy.setTabContentManager(cache); } private static void logExecutionTime(String name, long time) { if (LibraryLoader.isInitialized()) { RecordHistogram.recordTimesHistogram("Android.StrictMode.TabPersistentStore." + name, SystemClock.uptimeMillis() - time, TimeUnit.MILLISECONDS); } } public void saveState() { // Temporarily allowing disk access. TODO: Fix. See http://b/5518024 StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites(); try { long saveStateStartTime = SystemClock.uptimeMillis(); // The list of tabs should be saved first in case our activity is terminated early. // Explicitly toss out any existing SaveListTask because they only save the TabModel as // it looked when the SaveListTask was first created. if (mSaveListTask != null) mSaveListTask.cancel(true); try { saveListToFile(serializeTabMetadata()); } catch (IOException e) { Log.w(TAG, "Error while saving tabs state; will attempt to continue...", e); } logExecutionTime("SaveListTime", saveStateStartTime); // Add current tabs to save because they did not get a save signal yet. Tab currentStandardTab = TabModelUtils.getCurrentTab(mTabModelSelector.getModel(false)); if (currentStandardTab != null && !mTabsToSave.contains(currentStandardTab) && currentStandardTab.isTabStateDirty() // For content URI, the read permission granted to an activity is not // persistent. && !isTabUrlContentScheme(currentStandardTab)) { mTabsToSave.addLast(currentStandardTab); } Tab currentIncognitoTab = TabModelUtils.getCurrentTab(mTabModelSelector.getModel(true)); if (currentIncognitoTab != null && !mTabsToSave.contains(currentIncognitoTab) && currentIncognitoTab.isTabStateDirty() && !isTabUrlContentScheme(currentIncognitoTab)) { mTabsToSave.addLast(currentIncognitoTab); } // Wait for the current tab to save. if (mSaveTabTask != null) { // Cancel calls get() to wait for this to finish internally if it has to. // The issue is it may assume it cancelled the task, but the task still actually // wrote the state to disk. That's why we have to check mStateSaved here. if (mSaveTabTask.cancel(false) && !mSaveTabTask.mStateSaved) { // The task was successfully cancelled. We should try to save this state again. Tab cancelledTab = mSaveTabTask.mTab; if (!mTabsToSave.contains(cancelledTab) && cancelledTab.isTabStateDirty() && !isTabUrlContentScheme(cancelledTab)) { mTabsToSave.addLast(cancelledTab); } } mSaveTabTask = null; } long saveTabsStartTime = SystemClock.uptimeMillis(); // Synchronously save any remaining unsaved tabs (hopefully very few). for (Tab tab : mTabsToSave) { int id = tab.getId(); boolean incognito = tab.isIncognito(); try { TabState state = tab.getState(); if (state != null) { TabState.saveState(getTabStateFile(id, incognito), state, incognito); } } catch (OutOfMemoryError e) { Log.w(TAG, "Out of memory error while attempting to save tab state. Erasing."); deleteTabState(id, incognito); } } mTabsToSave.clear(); logExecutionTime("SaveTabsTime", saveTabsStartTime); logExecutionTime("SaveStateTime", saveStateStartTime); } finally { StrictMode.setThreadPolicy(oldPolicy); } } /** * Restore saved state. Must be called before any tabs are added to the list. * * This will read the metadata file for the current TabPersistentStore and the metadata file * from another TabPersistentStore if applicable. When restoreTabs() is called, tabs from both * will be restored into this instance. * * @param ignoreIncognitoFiles Whether to skip loading incognito tabs. */ public void loadState(boolean ignoreIncognitoFiles) { long time = SystemClock.uptimeMillis(); // If a cleanup task is in progress, cancel it before loading state. mPersistencePolicy.cancelCleanupInProgress(); waitForMigrationToFinish(); logExecutionTime("LoadStateTime", time); mCancelNormalTabLoads = false; mCancelIncognitoTabLoads = ignoreIncognitoFiles; mNormalTabsRestored = new SparseIntArray(); mIncognitoTabsRestored = new SparseIntArray(); try { long timeLoadingState = SystemClock.uptimeMillis(); assert mTabModelSelector.getModel(true).getCount() == 0; assert mTabModelSelector.getModel(false).getCount() == 0; checkAndUpdateMaxTabId(); DataInputStream stream; if (mPrefetchTabListTask != null) { long timeWaitingForPrefetch = SystemClock.uptimeMillis(); stream = mPrefetchTabListTask.get(); // Restore the tabs for this TabPeristentStore instance if the tab metadata file // exists. if (stream != null) { logExecutionTime("LoadStateInternalPrefetchTime", timeWaitingForPrefetch); mLoadInProgress = true; readSavedStateFile( stream, createOnTabStateReadCallback(mTabModelSelector.isIncognitoSelected(), false), null, false); logExecutionTime("LoadStateInternalTime", timeLoadingState); } } // Restore the tabs for the other TabPeristentStore instance if its tab metadata file // exists. if (mPrefetchTabListToMergeTask != null) { long timeMergingState = SystemClock.uptimeMillis(); stream = mPrefetchTabListToMergeTask.get(); if (stream != null) { logExecutionTime("MergeStateInternalFetchTime", timeMergingState); mPersistencePolicy.setMergeInProgress(true); readSavedStateFile( stream, createOnTabStateReadCallback(mTabModelSelector.isIncognitoSelected(), mTabsToRestore.size() == 0 ? false : true), null, true); logExecutionTime("MergeStateInternalTime", timeMergingState); RecordUserAction.record("Android.MergeState.ColdStart"); } } } catch (Exception e) { // Catch generic exception to prevent a corrupted state from crashing app on startup. Log.d(TAG, "loadState exception: " + e.toString(), e); } if (mObserver != null) mObserver.onInitialized(mTabsToRestore.size()); } /** * Merge the tabs of the other Chrome instance into this instance by reading its tab metadata * file and tab state files. * * This method should be called after a change in activity state indicates that a merge is * necessary. #loadState() will take care of merging states on application cold start if needed. * * If there is currently a merge or load in progress then this method will return early. */ public void mergeState() { if (mLoadInProgress || mPersistencePolicy.isMergeInProgress() || !mTabsToRestore.isEmpty()) { Log.e(TAG, "Tab load still in progress when merge was attempted."); return; } // Initialize variables. mCancelNormalTabLoads = false; mCancelIncognitoTabLoads = false; mNormalTabsRestored = new SparseIntArray(); mIncognitoTabsRestored = new SparseIntArray(); try { long time = SystemClock.uptimeMillis(); // Read the tab state metadata file. DataInputStream stream = startFetchTabListTask( AsyncTask.SERIAL_EXECUTOR, mPersistencePolicy.getStateToBeMergedFileName()).get(); // Return early if the stream is null, which indicates there isn't a second instance // to merge. if (stream == null) return; logExecutionTime("MergeStateInternalFetchTime", time); mPersistencePolicy.setMergeInProgress(true); readSavedStateFile(stream, createOnTabStateReadCallback(mTabModelSelector.isIncognitoSelected(), true), null, true); logExecutionTime("MergeStateInternalTime", time); } catch (Exception e) { // Catch generic exception to prevent a corrupted state from crashing app. Log.d(TAG, "meregeState exception: " + e.toString(), e); } // Restore the tabs from the second activity asynchronously. new AsyncTask<Void, Void, Void>() { @Override protected Void doInBackground(Void... voids) { mMergeTabCount = mTabsToRestore.size(); mRestoreMergedTabsStartTime = SystemClock.uptimeMillis(); restoreTabs(false); return null; } }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } /** * Restore tab state. Tab state is loaded asynchronously, other than the active tab which * can be forced to load synchronously. * * @param setActiveTab If true the last active tab given in the saved state is loaded * synchronously and set as the current active tab. If false all tabs are * loaded asynchronously. */ public void restoreTabs(boolean setActiveTab) { if (setActiveTab) { // Restore and select the active tab, which is first in the restore list. // If the active tab can't be restored, restore and select another tab. Otherwise, the // tab model won't have a valid index and the UI will break. http://crbug.com/261378 while (!mTabsToRestore.isEmpty() && mNormalTabsRestored.size() == 0 && mIncognitoTabsRestored.size() == 0) { TabRestoreDetails tabToRestore = mTabsToRestore.removeFirst(); restoreTab(tabToRestore, true); } } loadNextTab(); } /** * If a tab is being restored with the given url, then restore the tab in a frozen state * synchronously. */ public void restoreTabStateForUrl(String url) { restoreTabStateInternal(url, Tab.INVALID_TAB_ID); } /** * If a tab is being restored with the given id, then restore the tab in a frozen state * synchronously. */ public void restoreTabStateForId(int id) { restoreTabStateInternal(null, id); } private void restoreTabStateInternal(String url, int id) { TabRestoreDetails tabToRestore = null; if (mLoadTabTask != null) { if ((url == null && mLoadTabTask.mTabToRestore.id == id) || (url != null && TextUtils.equals(mLoadTabTask.mTabToRestore.url, url))) { // Steal the task of restoring the tab from the active load tab task. mLoadTabTask.cancel(false); tabToRestore = mLoadTabTask.mTabToRestore; loadNextTab(); // Queue up async task to load next tab after we're done here. } } if (tabToRestore == null) { if (url == null) { tabToRestore = getTabToRestoreById(id); } else { tabToRestore = getTabToRestoreByUrl(url); } } if (tabToRestore != null) { mTabsToRestore.remove(tabToRestore); restoreTab(tabToRestore, false); } } private void restoreTab(TabRestoreDetails tabToRestore, boolean setAsActive) { // As we do this in startup, and restoring the active tab's state is critical, we permit // this read in the event that the prefetch task is not available. Either: // 1. The user just upgraded, has not yet set the new active tab id pref yet. Or // 2. restoreTab is used to preempt async queue and restore immediately on the UI thread. StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads(); try { long time = SystemClock.uptimeMillis(); TabState state; int restoredTabId = mPreferences.getInt(PREF_ACTIVE_TAB_ID, Tab.INVALID_TAB_ID); if (restoredTabId == tabToRestore.id && mPrefetchActiveTabTask != null) { long timeWaitingForPrefetch = SystemClock.uptimeMillis(); state = mPrefetchActiveTabTask.get(); logExecutionTime("RestoreTabPrefetchTime", timeWaitingForPrefetch); } else { // Necessary to do on the UI thread as a last resort. state = TabState.restoreTabState(getStateDirectory(), tabToRestore.id); } logExecutionTime("RestoreTabTime", time); restoreTab(tabToRestore, state, setAsActive); } catch (Exception e) { // Catch generic exception to prevent a corrupted state from crashing the app // at startup. Log.d(TAG, "loadTabs exception: " + e.toString(), e); } finally { StrictMode.setThreadPolicy(oldPolicy); } } private void restoreTab( TabRestoreDetails tabToRestore, TabState tabState, boolean setAsActive) { // If we don't have enough information about the Tab, bail out. boolean isIncognito = isIncognitoTabBeingRestored(tabToRestore, tabState); if (tabState == null) { if (tabToRestore.isIncognito == null) { Log.w(TAG, "Failed to restore tab: not enough info about its type was available."); return; } else if (isIncognito) { Log.i(TAG, "Failed to restore Incognito tab: its TabState could not be restored."); return; } } TabModel model = mTabModelSelector.getModel(isIncognito); SparseIntArray restoredTabs = isIncognito ? mIncognitoTabsRestored : mNormalTabsRestored; int restoredIndex = 0; if (tabToRestore.fromMerge) { // Put any tabs being merged into this list at the end. restoredIndex = mTabModelSelector.getModel(isIncognito).getCount(); } else if (restoredTabs.size() > 0 && tabToRestore.originalIndex > restoredTabs.keyAt(restoredTabs.size() - 1)) { // If the tab's index is too large, restore it at the end of the list. restoredIndex = restoredTabs.size(); } else { // Otherwise try to find the tab we should restore before, if any. for (int i = 0; i < restoredTabs.size(); i++) { if (restoredTabs.keyAt(i) > tabToRestore.originalIndex) { Tab nextTabByIndex = TabModelUtils.getTabById(model, restoredTabs.valueAt(i)); restoredIndex = nextTabByIndex != null ? model.indexOf(nextTabByIndex) : -1; break; } } } int tabId = tabToRestore.id; if (tabState != null) { mTabCreatorManager.getTabCreator(isIncognito).createFrozenTab( tabState, tabToRestore.id, restoredIndex); } else { Log.w(TAG, "Failed to restore TabState; creating Tab with last known URL."); Tab fallbackTab = mTabCreatorManager.getTabCreator(isIncognito).createNewTab( new LoadUrlParams(tabToRestore.url), TabModel.TabLaunchType.FROM_RESTORE, null); tabId = fallbackTab.getId(); model.moveTab(tabId, restoredIndex); } // If the tab is being restored from a merge and its index is 0, then the model being // merged into doesn't contain any tabs. Select the first tab to avoid having no tab // selected. TODO(twellington): The first tab will always be selected. Instead, the tab that // was selected in the other model before the merge should be selected after the merge. if (setAsActive || (tabToRestore.fromMerge && restoredIndex == 0)) { boolean wasIncognitoTabModelSelected = mTabModelSelector.isIncognitoSelected(); int selectedModelTabCount = mTabModelSelector.getCurrentModel().getCount(); TabModelUtils.setIndex(model, TabModelUtils.getTabIndexById(model, tabId)); boolean isIncognitoTabModelSelected = mTabModelSelector.isIncognitoSelected(); // Setting the index will cause the tab's model to be selected. Set it back to the model // that was selected before setting the index if the index is being set during a merge // unless the previously selected model is empty (e.g. showing the empty background // view on tablets). if (tabToRestore.fromMerge && wasIncognitoTabModelSelected != isIncognitoTabModelSelected && selectedModelTabCount != 0) { mTabModelSelector.selectModel(wasIncognitoTabModelSelected); } } restoredTabs.put(tabToRestore.originalIndex, tabId); } /** * @return Number of restored tabs on cold startup. */ public int getRestoredTabCount() { return mTabsToRestore.size(); } /** * Deletes all files in the tab state directory. This will delete all files and not just those * owned by this TabPersistentStore. */ public void clearState() { mPersistencePolicy.cancelCleanupInProgress(); AsyncTask.SERIAL_EXECUTOR.execute(new Runnable() { @Override public void run() { File[] baseStateFiles = getOrCreateBaseStateDirectory().listFiles(); if (baseStateFiles == null) return; for (File baseStateFile : baseStateFiles) { // In legacy scenarios (prior to migration, state files could reside in the // root state directory. So, handle deleting direct child files as well as // those that reside in sub directories. if (!baseStateFile.isDirectory()) { if (!baseStateFile.delete()) { Log.e(TAG, "Failed to delete file: " + baseStateFile); } } else { File[] files = baseStateFile.listFiles(); if (files == null) continue; for (File file : files) { if (!file.delete()) Log.e(TAG, "Failed to delete file: " + file); } } } } }); onStateLoaded(); } /** * Cancels loading of {@link Tab}s from disk from saved state. This is useful if the user * does an action which impacts all {@link Tab}s, not just the ones currently loaded into * the model. For example, if the user tries to close all {@link Tab}s, we need don't want * to restore old {@link Tab}s anymore. * * @param incognito Whether or not to ignore incognito {@link Tab}s or normal * {@link Tab}s as they are being restored. */ public void cancelLoadingTabs(boolean incognito) { if (incognito) { mCancelIncognitoTabLoads = true; } else { mCancelNormalTabLoads = true; } } public void addTabToSaveQueue(Tab tab) { if (!mTabsToSave.contains(tab) && tab.isTabStateDirty() && !isTabUrlContentScheme(tab)) { mTabsToSave.addLast(tab); } saveNextTab(); } public void removeTabFromQueues(Tab tab) { mTabsToSave.remove(tab); mTabsToRestore.remove(getTabToRestoreById(tab.getId())); if (mLoadTabTask != null && mLoadTabTask.mTabToRestore.id == tab.getId()) { mLoadTabTask.cancel(false); mLoadTabTask = null; loadNextTab(); } if (mSaveTabTask != null && mSaveTabTask.mId == tab.getId()) { mSaveTabTask.cancel(false); mSaveTabTask = null; saveNextTab(); } cleanupPersistentData(tab.getId(), tab.isIncognito()); } private TabRestoreDetails getTabToRestoreByUrl(String url) { for (TabRestoreDetails tabBeingRestored : mTabsToRestore) { if (TextUtils.equals(tabBeingRestored.url, url)) { return tabBeingRestored; } } return null; } private TabRestoreDetails getTabToRestoreById(int id) { for (TabRestoreDetails tabBeingRestored : mTabsToRestore) { if (tabBeingRestored.id == id) { return tabBeingRestored; } } return null; } public void destroy() { mDestroyed = true; mPersistencePolicy.destroy(); if (mLoadTabTask != null) mLoadTabTask.cancel(true); mTabsToSave.clear(); mTabsToRestore.clear(); if (mSaveTabTask != null) mSaveTabTask.cancel(false); if (mSaveListTask != null) mSaveListTask.cancel(true); } private void cleanupPersistentData(int id, boolean incognito) { deleteFileAsync(TabState.getTabStateFilename(id, incognito)); // No need to forward that event to the tab content manager as this is already // done as part of the standard tab removal process. } private byte[] serializeTabMetadata() throws IOException { List<TabRestoreDetails> tabsToRestore = new ArrayList<>(); // The metadata file may be being written out before all of the Tabs have been restored. // Save that information out, as well. if (mLoadTabTask != null) tabsToRestore.add(mLoadTabTask.mTabToRestore); for (TabRestoreDetails details : mTabsToRestore) { tabsToRestore.add(details); } return serializeTabModelSelector(mTabModelSelector, tabsToRestore); } /** * Serializes {@code selector} to a byte array, copying out the data pertaining to tab ordering * and selected indices. * @param selector The {@link TabModelSelector} to serialize. * @param tabsBeingRestored Tabs that are in the process of being restored. * @return {@code byte[]} containing the serialized state of {@code selector}. */ @VisibleForTesting public static byte[] serializeTabModelSelector(TabModelSelector selector, List<TabRestoreDetails> tabsBeingRestored) throws IOException { ThreadUtils.assertOnUiThread(); TabModel incognitoModel = selector.getModel(true); TabModelMetadata incognitoInfo = new TabModelMetadata(incognitoModel.index()); for (int i = 0; i < incognitoModel.getCount(); i++) { incognitoInfo.ids.add(incognitoModel.getTabAt(i).getId()); incognitoInfo.urls.add(incognitoModel.getTabAt(i).getUrl()); } TabModel normalModel = selector.getModel(false); TabModelMetadata normalInfo = new TabModelMetadata(normalModel.index()); for (int i = 0; i < normalModel.getCount(); i++) { normalInfo.ids.add(normalModel.getTabAt(i).getId()); normalInfo.urls.add(normalModel.getTabAt(i).getUrl()); } // Cache the active tab id to be pre-loaded next launch. int activeTabId = Tab.INVALID_TAB_ID; int activeIndex = normalModel.index(); if (activeIndex != TabList.INVALID_TAB_INDEX) { activeTabId = normalModel.getTabAt(activeIndex).getId(); } // Always override the existing value in case there is no active tab. ContextUtils.getAppSharedPreferences().edit().putInt( PREF_ACTIVE_TAB_ID, activeTabId).apply(); return serializeMetadata(normalInfo, incognitoInfo, tabsBeingRestored); } /** * Serializes data from a {@link TabModelSelector} into a byte array. * @param standardInfo Info about the regular {@link TabModel}. * @param incognitoInfo Info about the Incognito {@link TabModel}. * @param tabsBeingRestored Tabs that are in the process of being restored. * @return {@code byte[]} containing the serialized state of {@code selector}. */ public static byte[] serializeMetadata(TabModelMetadata standardInfo, TabModelMetadata incognitoInfo, @Nullable List<TabRestoreDetails> tabsBeingRestored) throws IOException { ThreadUtils.assertOnUiThread(); int standardCount = standardInfo.ids.size(); int incognitoCount = incognitoInfo.ids.size(); // Determine how many Tabs there are, including those not yet been added to the TabLists. int numAlreadyLoaded = incognitoCount + standardCount; int numStillBeingLoaded = tabsBeingRestored == null ? 0 : tabsBeingRestored.size(); int numTabsTotal = numStillBeingLoaded + numAlreadyLoaded; // Save the index file containing the list of tabs to restore. ByteArrayOutputStream output = new ByteArrayOutputStream(); DataOutputStream stream = new DataOutputStream(output); stream.writeInt(SAVED_STATE_VERSION); stream.writeInt(numTabsTotal); stream.writeInt(incognitoCount); stream.writeInt(incognitoInfo.index); stream.writeInt(standardInfo.index + incognitoCount); Log.d(TAG, "Serializing tab lists; counts: " + standardCount + ", " + incognitoCount + ", " + (tabsBeingRestored == null ? 0 : tabsBeingRestored.size())); // Save incognito state first, so when we load, if the incognito files are unreadable // we can fall back easily onto the standard selected tab. for (int i = 0; i < incognitoCount; i++) { stream.writeInt(incognitoInfo.ids.get(i)); stream.writeUTF(incognitoInfo.urls.get(i)); } for (int i = 0; i < standardCount; i++) { stream.writeInt(standardInfo.ids.get(i)); stream.writeUTF(standardInfo.urls.get(i)); } // Write out information about the tabs that haven't finished being loaded. // We shouldn't have to worry about Tab duplication because the tab details are processed // only on the UI Thread. if (tabsBeingRestored != null) { for (TabRestoreDetails details : tabsBeingRestored) { stream.writeInt(details.id); stream.writeUTF(details.url); } } stream.close(); return output.toByteArray(); } private void saveListToFile(byte[] listData) { if (Arrays.equals(mLastSavedMetadata, listData)) return; saveListToFile(getStateDirectory(), mPersistencePolicy.getStateFileName(), listData); mLastSavedMetadata = listData; if (LibraryLoader.isInitialized()) { RecordHistogram.recordCountHistogram( "Android.TabPersistentStore.MetadataFileSize", listData.length); } } /** * Atomically writes the given serialized data out to disk. * @param stateDirectory Directory to save TabModel data into. * @param stateFileName File name to save TabModel data into. * @param listData TabModel data in the form of a serialized byte array. */ public static void saveListToFile(File stateDirectory, String stateFileName, byte[] listData) { synchronized (SAVE_LIST_LOCK) { // Save the index file containing the list of tabs to restore. File metadataFile = new File(stateDirectory, stateFileName); AtomicFile file = new AtomicFile(metadataFile); FileOutputStream stream = null; try { stream = file.startWrite(); stream.write(listData, 0, listData.length); file.finishWrite(stream); } catch (IOException e) { if (stream != null) file.failWrite(stream); Log.e(TAG, "Failed to write file: " + metadataFile.getAbsolutePath()); } } } /** * @param isIncognitoSelected Whether the tab model is incognito. * @return A callback for reading data from tab models. */ private OnTabStateReadCallback createOnTabStateReadCallback(final boolean isIncognitoSelected, final boolean fromMerge) { return new OnTabStateReadCallback() { @Override public void onDetailsRead(int index, int id, String url, Boolean isIncognito, boolean isStandardActiveIndex, boolean isIncognitoActiveIndex) { if (mLoadInProgress) { // If a load and merge are both in progress, that means two metadata files // are being read. If a merge was previously started and interrupted due to the // app dying, the two metadata files may contain duplicate IDs. Skip tabs with // duplicate IDs. if (mPersistencePolicy.isMergeInProgress() && mTabIdsToRestore.contains(id)) { return; } mTabIdsToRestore.add(id); } // Note that incognito tab may not load properly so we may need to use // the current tab from the standard model. // This logic only works because we store the incognito indices first. TabRestoreDetails details = new TabRestoreDetails(id, index, isIncognito, url, fromMerge); if (!fromMerge && ((isIncognitoActiveIndex && isIncognitoSelected) || (isStandardActiveIndex && !isIncognitoSelected))) { // Active tab gets loaded first mTabsToRestore.addFirst(details); } else { mTabsToRestore.addLast(details); } if (mObserver != null) { mObserver.onDetailsRead( index, id, url, isStandardActiveIndex, isIncognitoActiveIndex); } } }; } /** * If a global max tab ID has not been computed and stored before, then check all the state * folders and calculate a new global max tab ID to be used. Must be called before any new tabs * are created. * * @throws IOException */ private void checkAndUpdateMaxTabId() throws IOException { if (mPreferences.getBoolean(PREF_HAS_COMPUTED_MAX_ID, false)) return; int maxId = 0; // Calculation of the max tab ID is done only once per user and is stored in // SharedPreferences afterwards. This is done on the UI thread because it is on the // critical patch to initializing the TabIdManager with the correct max tab ID. StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads(); try { File[] subDirectories = getOrCreateBaseStateDirectory().listFiles(); if (subDirectories != null) { for (File subDirectory : subDirectories) { if (!subDirectory.isDirectory()) { assert false : "Only directories should exist below the base state directory"; continue; } File[] files = subDirectory.listFiles(); if (files == null) continue; for (File file : files) { Pair<Integer, Boolean> tabStateInfo = TabState.parseInfoFromFilename(file.getName()); if (tabStateInfo != null) { maxId = Math.max(maxId, tabStateInfo.first); } else if (isStateFile(file.getName())) { DataInputStream stream = null; try { stream = new DataInputStream( new BufferedInputStream(new FileInputStream(file))); maxId = Math.max( maxId, readSavedStateFile(stream, null, null, false)); } finally { StreamUtil.closeQuietly(stream); } } } } } } finally { StrictMode.setThreadPolicy(oldPolicy); } TabIdManager.getInstance().incrementIdCounterTo(maxId); mPreferences.edit().putBoolean(PREF_HAS_COMPUTED_MAX_ID, true).apply(); } /** * Extracts the tab information from a given tab state stream. * * @param stream The stream pointing to the tab state file to be parsed. * @param callback A callback to be streamed updates about the tab state information being read. * @param tabIds A mapping of tab ID to whether the tab is an off the record tab. * @param forMerge Whether this state file was read as part of a merge. * @return The next available tab ID based on the maximum ID referenced in this state file. */ public static int readSavedStateFile( DataInputStream stream, @Nullable OnTabStateReadCallback callback, @Nullable SparseBooleanArray tabIds, boolean forMerge) throws IOException { if (stream == null) return 0; long time = SystemClock.uptimeMillis(); int nextId = 0; boolean skipUrlRead = false; boolean skipIncognitoCount = false; final int version = stream.readInt(); if (version != SAVED_STATE_VERSION) { // We don't support restoring Tab data from before M18. if (version < 3) return 0; // Older versions are missing newer data. if (version < 5) skipIncognitoCount = true; if (version < 4) skipUrlRead = true; } final int count = stream.readInt(); final int incognitoCount = skipIncognitoCount ? -1 : stream.readInt(); final int incognitoActiveIndex = stream.readInt(); final int standardActiveIndex = stream.readInt(); if (count < 0 || incognitoActiveIndex >= count || standardActiveIndex >= count) { throw new IOException(); } for (int i = 0; i < count; i++) { int id = stream.readInt(); String tabUrl = skipUrlRead ? "" : stream.readUTF(); if (id >= nextId) nextId = id + 1; if (tabIds != null) tabIds.append(id, true); Boolean isIncognito = (incognitoCount < 0) ? null : i < incognitoCount; if (callback != null) { callback.onDetailsRead(i, id, tabUrl, isIncognito, i == standardActiveIndex, i == incognitoActiveIndex); } } if (forMerge) { logExecutionTime("ReadMergedStateTime", time); int tabCount = count + ((incognitoCount > 0) ? incognitoCount : 0); RecordHistogram.recordLinearCountHistogram( "Android.TabPersistentStore.MergeStateTabCount", tabCount, 1, 200, 200); } logExecutionTime("ReadSavedStateTime", time); return nextId; } private void saveNextTab() { if (mSaveTabTask != null) return; if (!mTabsToSave.isEmpty()) { Tab tab = mTabsToSave.removeFirst(); mSaveTabTask = new SaveTabTask(tab); mSaveTabTask.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR); } else { saveTabListAsynchronously(); } } /** * Kick off an AsyncTask to save the current list of Tabs. */ public void saveTabListAsynchronously() { if (mSaveListTask != null) mSaveListTask.cancel(true); mSaveListTask = new SaveListTask(); mSaveListTask.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR); } private class SaveTabTask extends AsyncTask<Void, Void, Void> { Tab mTab; int mId; TabState mState; boolean mEncrypted; boolean mStateSaved = false; SaveTabTask(Tab tab) { mTab = tab; mId = tab.getId(); mEncrypted = tab.isIncognito(); } @Override protected void onPreExecute() { if (mDestroyed || isCancelled()) return; mState = mTab.getState(); } @Override protected Void doInBackground(Void... voids) { mStateSaved = saveTabState(mId, mEncrypted, mState); return null; } @Override protected void onPostExecute(Void v) { if (mDestroyed || isCancelled()) return; if (mStateSaved) mTab.setIsTabStateDirty(false); mSaveTabTask = null; saveNextTab(); } } private class SaveListTask extends AsyncTask<Void, Void, Void> { byte[] mListData; @Override protected void onPreExecute() { if (mDestroyed || isCancelled()) return; try { mListData = serializeTabMetadata(); } catch (IOException e) { mListData = null; } } @Override protected Void doInBackground(Void... voids) { if (mListData == null || isCancelled()) return null; saveListToFile(mListData); mListData = null; return null; } @Override protected void onPostExecute(Void v) { if (mDestroyed || isCancelled()) return; if (mSaveListTask == this) { mSaveListTask = null; if (mObserver != null) mObserver.onMetadataSavedAsynchronously(); } } } private void onStateLoaded() { if (mObserver != null) mObserver.onStateLoaded(); } private void loadNextTab() { if (mDestroyed) return; if (mTabsToRestore.isEmpty()) { mNormalTabsRestored = null; mIncognitoTabsRestored = null; mLoadInProgress = false; // If tabs are done being merged into this instance, save the tab metadata file for this // TabPersistentStore and delete the metadata file for the other instance, then notify // observers. if (mPersistencePolicy.isMergeInProgress()) { if (mMergeTabCount != 0) { long timePerTab = (SystemClock.uptimeMillis() - mRestoreMergedTabsStartTime) / mMergeTabCount; RecordHistogram.recordTimesHistogram( "Android.TabPersistentStore.MergeStateTimePerTab", timePerTab, TimeUnit.MILLISECONDS); } ThreadUtils.postOnUiThread(new Runnable() { @Override public void run() { // This eventually calls serializeTabModelSelector() which much be called // from the UI thread. #mergeState() starts an async task in the background // that goes through this code path. saveTabListAsynchronously(); } }); deleteFileAsync(mPersistencePolicy.getStateToBeMergedFileName()); if (mObserver != null) mObserver.onStateMerged(); } cleanUpPersistentData(); onStateLoaded(); mLoadTabTask = null; Log.d(TAG, "Loaded tab lists; counts: " + mTabModelSelector.getModel(false).getCount() + "," + mTabModelSelector.getModel(true).getCount()); } else { TabRestoreDetails tabToRestore = mTabsToRestore.removeFirst(); mLoadTabTask = new LoadTabTask(tabToRestore); mLoadTabTask.execute(); } } /** * Asynchronously triggers a cleanup of any unused persistent data. */ private void cleanUpPersistentData() { mPersistencePolicy.cleanupUnusedFiles(new Callback<List<String>>() { @Override public void onResult(List<String> result) { if (result == null) return; for (int i = 0; i < result.size(); i++) { deleteFileAsync(result.get(i)); } } }); } /** * File mutations (e.g. saving & deleting) are explicitly serialized to ensure that they occur * in the correct order. * * @param file Name of file under the state directory to be deleted. */ private void deleteFileAsync(final String file) { new AsyncTask<Void, Void, Void>() { @Override protected Void doInBackground(Void... voids) { File stateFile = new File(getStateDirectory(), file); if (stateFile.exists()) { if (!stateFile.delete()) Log.e(TAG, "Failed to delete file: " + stateFile); // The merge isn't completely finished until the other TabPersistentStore's // metadata file is deleted. if (file.equals(mPersistencePolicy.getStateToBeMergedFileName())) { mPersistencePolicy.setMergeInProgress(false); } } return null; } }.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR); // TODO(twellington): delete tab files using the thread pool rather than the serial // executor. } private class LoadTabTask extends AsyncTask<Void, Void, TabState> { public final TabRestoreDetails mTabToRestore; public LoadTabTask(TabRestoreDetails tabToRestore) { mTabToRestore = tabToRestore; } @Override protected TabState doInBackground(Void... voids) { if (mDestroyed || isCancelled()) return null; try { return TabState.restoreTabState(getStateDirectory(), mTabToRestore.id); } catch (Exception e) { Log.w(TAG, "Unable to read state: " + e); return null; } } @Override protected void onPostExecute(TabState tabState) { if (mDestroyed || isCancelled()) return; boolean isIncognito = isIncognitoTabBeingRestored(mTabToRestore, tabState); boolean isLoadCancelled = (isIncognito && mCancelIncognitoTabLoads) || (!isIncognito && mCancelNormalTabLoads); if (!isLoadCancelled) restoreTab(mTabToRestore, tabState, false); loadNextTab(); } } private static final class TabRestoreDetails { public final int id; public final int originalIndex; public final String url; public final Boolean isIncognito; public final Boolean fromMerge; public TabRestoreDetails(int id, int originalIndex, Boolean isIncognito, String url, Boolean fromMerge) { this.id = id; this.originalIndex = originalIndex; this.url = url; this.isIncognito = isIncognito; this.fromMerge = fromMerge; } } private boolean isTabUrlContentScheme(Tab tab) { String url = tab.getUrl(); return url != null && url.startsWith("content"); } /** * Determines if a Tab being restored is definitely an Incognito Tab. * * This function can fail to determine if a Tab is incognito if not enough data about the Tab * was successfully saved out. * * @return True if the tab is definitely Incognito, false if it's not or if it's undecideable. */ private boolean isIncognitoTabBeingRestored(TabRestoreDetails tabDetails, TabState tabState) { if (tabState != null) { // The Tab's previous state was completely restored. return tabState.isIncognito(); } else if (tabDetails.isIncognito != null) { // The TabState couldn't be restored, but we have some information about the tab. return tabDetails.isIncognito; } else { // The tab's type is undecideable. return false; } } private AsyncTask<Void, Void, DataInputStream> startFetchTabListTask( Executor executor, final String stateFileName) { return new AsyncTask<Void, Void, DataInputStream>() { @Override protected DataInputStream doInBackground(Void... params) { Log.w(TAG, "Starting to fetch tab list."); File stateFile = new File(getStateDirectory(), stateFileName); if (!stateFile.exists()) { Log.e(TAG, "State file does not exist."); return null; } if (LibraryLoader.isInitialized()) { RecordHistogram.recordCountHistogram( "Android.TabPersistentStore.MergeStateMetadataFileSize", (int) stateFile.length()); } FileInputStream stream = null; byte[] data; try { stream = new FileInputStream(stateFile); data = new byte[(int) stateFile.length()]; stream.read(data); } catch (IOException exception) { Log.e(TAG, "Could not read state file.", exception); return null; } finally { StreamUtil.closeQuietly(stream); } Log.w(TAG, "Finished fetching tab list."); return new DataInputStream(new ByteArrayInputStream(data)); } }.executeOnExecutor(executor); } private void startPrefetchActiveTabTask(Executor executor) { final int activeTabId = mPreferences.getInt(PREF_ACTIVE_TAB_ID, Tab.INVALID_TAB_ID); if (activeTabId == Tab.INVALID_TAB_ID) return; mPrefetchActiveTabTask = new AsyncTask<Void, Void, TabState>() { @Override protected TabState doInBackground(Void... params) { return TabState.restoreTabState(getStateDirectory(), activeTabId); } }.executeOnExecutor(executor); } @VisibleForTesting public void setObserverForTesting(TabPersistentStoreObserver observer) { mObserver = observer; } /** * Directory containing all data for TabModels. Each subdirectory stores info about different * TabModelSelectors, including metadata about each TabModel and TabStates for each of their * tabs. * * @return The parent state directory. */ @VisibleForTesting public static File getOrCreateBaseStateDirectory() { return BaseStateDirectoryHolder.sDirectory; } /** * @param uniqueId The ID that uniquely identifies this state file. * @return The name of the state file. */ @VisibleForTesting public static String getStateFileName(String uniqueId) { return TabPersistencePolicy.SAVED_STATE_FILE_PREFIX + uniqueId; } /** * Parses the state file name and returns the unique ID encoded into it. * @param stateFileName The state file name to be parsed. * @return The unique ID used when generating the file name. */ public static String getStateFileUniqueId(String stateFileName) { assert isStateFile(stateFileName); return stateFileName.substring(TabPersistencePolicy.SAVED_STATE_FILE_PREFIX.length()); } /** * @return Whether the specified filename matches the expected pattern of the tab state files. */ public static boolean isStateFile(String fileName) { return fileName.startsWith(TabPersistencePolicy.SAVED_STATE_FILE_PREFIX); } /** * Sets where the base state directory is in tests. */ @VisibleForTesting public static void setBaseStateDirectoryForTests(File directory) { BaseStateDirectoryHolder.sDirectory = directory; } }