// 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.annotation.TargetApi; import android.content.Context; import android.content.SharedPreferences; import android.os.AsyncTask; import android.os.Build; import android.os.StrictMode; import android.util.Pair; import org.chromium.base.ContextUtils; import org.chromium.base.FileUtils; import org.chromium.base.Log; import org.chromium.base.ObserverList; import org.chromium.base.StreamUtil; import org.chromium.base.ThreadUtils; import org.chromium.base.VisibleForTesting; import org.chromium.chrome.browser.ChromeApplication; import org.chromium.chrome.browser.TabState; import org.chromium.chrome.browser.document.DocumentActivity; import org.chromium.chrome.browser.document.DocumentUtils; import org.chromium.chrome.browser.document.IncognitoDocumentActivity; import org.chromium.chrome.browser.incognito.IncognitoNotificationManager; import org.chromium.chrome.browser.tab.Tab; import org.chromium.chrome.browser.tabmodel.TabPersistentStore.TabModelMetadata; import org.chromium.chrome.browser.tabmodel.document.ActivityDelegate; import org.chromium.chrome.browser.tabmodel.document.ActivityDelegateImpl; import org.chromium.chrome.browser.tabmodel.document.DocumentTabModel; import org.chromium.chrome.browser.tabmodel.document.DocumentTabModelImpl; import org.chromium.chrome.browser.tabmodel.document.StorageDelegate; import org.chromium.chrome.browser.toolbar.TabSwitcherCallout; import org.chromium.chrome.browser.util.FeatureUtilities; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.nio.channels.FileChannel; import java.util.HashSet; import java.util.Set; /** * Divorces Chrome's tabs from Android's Overview menu. Assumes native libraries are unavailable. * * Migration from document mode to tabbed mode occurs in two main phases: * * 1) NON-DESTRUCTIVE MIGRATION: * TabState files for the normal DocumentTabModel are copied from the document mode directories * into the tabbed mode directory. Incognito tabs are silently dropped, as with the previous * migration pathway. * * Once all TabState files are copied, a TabModel metadata file is written out for the tabbed * mode {@link TabModelImpl} to read out. Because the native library is not available, the file * will be incomplete but usable; it will be corrected by the TabModelImpl when it loads it and * all of the TabState files up. See {@link #writeTabModelMetadata} for details. * * 2) CLEANUP OF ALL DOCUMENT-RELATED THINGS: * DocumentActivity tasks in Android's Recents are removed, TabState files in the document mode * directory are deleted, and document mode preferences are cleared. */ @TargetApi(Build.VERSION_CODES.LOLLIPOP) public class DocumentModeAssassin { /** Alerted about progress along the migration pipeline. */ public static class DocumentModeAssassinObserver { /** * Called on the UI thread when the DocumentModeAssassin has progressed along its pipeline, * and when the DocumentModeAssasssinObserver is first added to the DocumentModeAssassin. * * @param newStage New stage of the pipeline. */ public void onStageChange(int newStage) { } /** * Called on the background thread when a TabState file has been copied from the document * to tabbed mode directory. * * @param copiedId ID of the Tab whose TabState file was copied. */ public void onTabStateFileCopied(int copiedId) { } } /** Stages of the pipeline. Each stage is blocked off by a STARTED and DONE pair. */ static final int STAGE_UNINITIALIZED = 0; static final int STAGE_INITIALIZED = 1; static final int STAGE_COPY_TAB_STATES_STARTED = 2; static final int STAGE_COPY_TAB_STATES_DONE = 3; static final int STAGE_WRITE_TABMODEL_METADATA_STARTED = 4; static final int STAGE_WRITE_TABMODEL_METADATA_DONE = 5; static final int STAGE_CHANGE_SETTINGS_STARTED = 6; static final int STAGE_CHANGE_SETTINGS_DONE = 7; static final int STAGE_DELETION_STARTED = 8; public static final int STAGE_DONE = 9; static final String PREF_NUM_MIGRATION_ATTEMPTS = "org.chromium.chrome.browser.tabmodel.NUM_MIGRATION_ATTEMPTS"; static final int MAX_MIGRATION_ATTEMPTS_BEFORE_FAILURE = 3; private static final String TAG = "DocumentModeAssassin"; /** Which TabModelSelectorImpl to copy files into during migration. */ private static final int TAB_MODEL_INDEX = 0; /** SharedPreference values to determine whether user had document mode turned on. */ static final String OPT_OUT_STATE = "opt_out_state"; private static final int OPT_IN_TO_DOCUMENT_MODE = 0; private static final int OPT_OUT_STATE_UNSET = -1; static final int OPTED_OUT_OF_DOCUMENT_MODE = 2; /** * Preference that denotes that Chrome has attempted to migrate from tabbed mode to document * mode. Indicates that the user may be in document mode. */ static final String MIGRATION_ON_UPGRADE_ATTEMPTED = "migration_on_upgrade_attempted"; /** Creates and holds the Singleton. */ private static class LazyHolder { private static final DocumentModeAssassin INSTANCE = new DocumentModeAssassin(); } /** Returns the Singleton instance. */ public static DocumentModeAssassin getInstance() { return LazyHolder.INSTANCE; } /** IDs of Tabs that have had their TabState files copied between directories successfully. */ private final Set<Integer> mMigratedTabIds = new HashSet<>(); /** Observers of the migration pipeline. */ private final ObserverList<DocumentModeAssassinObserver> mObservers = new ObserverList<>(); /** Current stage of the migration. */ private int mStage = STAGE_UNINITIALIZED; /** Whether or not startStage is allowed to progress along the migration pipeline. */ private boolean mIsPipelineActive; /** Migrates the user from document mode to tabbed mode if necessary. */ @VisibleForTesting public final void migrateFromDocumentToTabbedMode() { ThreadUtils.assertOnUiThread(); // Migration is already underway. if (mStage != STAGE_UNINITIALIZED) return; // If migration isn't necessary, don't do anything. if (!isMigrationNecessary()) { setStage(STAGE_UNINITIALIZED, STAGE_DONE); return; } // If we've crashed or failed too many times, send them to tabbed mode without their data. // - Any incorrect or invalid files in the tabbed mode directory will be wiped out by the // TabPersistentStore when the ChromeTabbedActivity starts. // // - If it crashes in the step after being migrated, then the user will simply be left // with a bunch of inaccessible document mode data instead of being stuck trying to // migrate, which is a lesser evil. This case will be caught by the check above to see if // migration is even necessary. SharedPreferences prefs = ContextUtils.getAppSharedPreferences(); int numMigrationAttempts = prefs.getInt(PREF_NUM_MIGRATION_ATTEMPTS, 0); if (numMigrationAttempts >= MAX_MIGRATION_ATTEMPTS_BEFORE_FAILURE) { Log.e(TAG, "Too many failures. Migrating user to tabbed mode without data."); setStage(STAGE_UNINITIALIZED, STAGE_WRITE_TABMODEL_METADATA_DONE); return; } // Kick off the migration pipeline. // Using apply() instead of commit() seems to save the preference just fine, even if Chrome // crashes immediately afterward. SharedPreferences.Editor editor = prefs.edit(); editor.putInt(PREF_NUM_MIGRATION_ATTEMPTS, numMigrationAttempts + 1); editor.apply(); setStage(STAGE_UNINITIALIZED, STAGE_INITIALIZED); } /** * Makes copies of {@link TabState} files in the document mode directory and places them in the * tabbed mode directory. Only non-Incognito tabs are transferred. * * If the user is out of space on their device, this plows through the migration pathway. * TODO(dfalcantara): Should we do something about this? A user can have at most 16 tabs in * Android's Recents menu. * * @param selectedTabId ID of the last viewed non-Incognito tab. */ final void copyTabStateFiles(final int selectedTabId) { ThreadUtils.assertOnUiThread(); if (!setStage(STAGE_INITIALIZED, STAGE_COPY_TAB_STATES_STARTED)) return; new AsyncTask<Void, Void, Void>() { @Override protected Void doInBackground(Void... params) { File documentDirectory = getDocumentDataDirectory(); File tabbedDirectory = getTabbedDataDirectory(); Log.d(TAG, "Copying TabState files from document to tabbed mode directory."); assert mMigratedTabIds.size() == 0; File[] allTabStates = documentDirectory.listFiles(); if (allTabStates != null) { // If we know what tab the user was last viewing, copy just that TabState file // before all the other ones to mitigate storage issues for devices with limited // available storage. if (selectedTabId != Tab.INVALID_TAB_ID) { copyTabStateFilesInternal( allTabStates, tabbedDirectory, selectedTabId, true); } // Copy over everything else. copyTabStateFilesInternal(allTabStates, tabbedDirectory, selectedTabId, false); } return null; } @Override protected void onPostExecute(Void result) { Log.d(TAG, "Finished copying files."); setStage(STAGE_COPY_TAB_STATES_STARTED, STAGE_COPY_TAB_STATES_DONE); } /** * Copies the files from the document mode directory to the tabbed mode directory. * * @param allTabStates Listing of all files in the document mode directory. * @param tabbedDirectory Directory for the tabbed mode files. * @param selectedTabId ID of the non-Incognito tab the user last viewed. May be * {@link Tab#INVALID_TAB_ID} if the ID is unknown. * @param copyOnlySelectedTab Copy only the TabState file for the selectedTabId. */ private void copyTabStateFilesInternal(File[] allTabStates, File tabbedDirectory, int selectedTabId, boolean copyOnlySelectedTab) { assert !ThreadUtils.runningOnUiThread(); for (int i = 0; i < allTabStates.length; i++) { // Trawl the directory for non-Incognito TabState files. String fileName = allTabStates[i].getName(); Pair<Integer, Boolean> tabInfo = TabState.parseInfoFromFilename(fileName); if (tabInfo == null || tabInfo.second) continue; // Ignore any files that are not relevant for the current pass. int tabId = tabInfo.first; if (selectedTabId != Tab.INVALID_TAB_ID) { if (copyOnlySelectedTab && tabId != selectedTabId) continue; if (!copyOnlySelectedTab && tabId == selectedTabId) continue; } // Copy the file over. File oldFile = allTabStates[i]; File newFile = new File(tabbedDirectory, fileName); FileInputStream inputStream = null; FileOutputStream outputStream = null; try { inputStream = new FileInputStream(oldFile); outputStream = new FileOutputStream(newFile); FileChannel inputChannel = inputStream.getChannel(); FileChannel outputChannel = outputStream.getChannel(); inputChannel.transferTo(0, inputChannel.size(), outputChannel); mMigratedTabIds.add(tabId); for (DocumentModeAssassinObserver observer : mObservers) { observer.onTabStateFileCopied(tabId); } } catch (IOException e) { Log.e(TAG, "Failed to copy: " + oldFile.getName() + " to " + newFile.getName()); } finally { StreamUtil.closeQuietly(inputStream); StreamUtil.closeQuietly(outputStream); } } } }.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR); } /** * Converts information about the normal {@link DocumentTabModel} into info for * {@link TabModelImpl}, then persists it to storage. Incognito is intentionally not migrated. * * Because the native library is not available, we have no way of getting the URL from the * {@link TabState} files. Instead, the TabModel metadata file this function writes out * writes out inaccurate URLs for each tab: * - When the TabState for a Tab is available, a URL of "" is saved out because the * {@link TabPersistentStore} ignores it and restores it from the TabState, anyway. * * - If a TabState isn't available, we fall back to using the initial URL that was used to spawn * a document mode Tab. * * These tradeoffs are deemed acceptable because the URL from the metadata file isn't commonly * used immediately: * * 1) {@link TabPersistentStore} uses the URL to allow reusing already open tabs for Home screen * Intents. If a Tab doesn't match the Intent's URL, a new Tab is created. This is already * the case when a cold start launches into document mode because the data is unavailable at * startup. * * 2) {@link TabModelImpl} uses the URL when it fails to load a Tab's persisted TabState. This * means that the user loses some navigation history, but it's not a case document mode would * have been able to recover from anyway because the TabState stores the URL data. * * @param migratedTabIds IDs of Tabs whose TabState files were copied successfully. */ final void writeTabModelMetadata(final Set<Integer> migratedTabIds) { ThreadUtils.assertOnUiThread(); if (!setStage(STAGE_COPY_TAB_STATES_DONE, STAGE_WRITE_TABMODEL_METADATA_STARTED)) return; new AsyncTask<Void, Void, Boolean>() { private byte[] mSerializedMetadata; @Override protected void onPreExecute() { Log.d(TAG, "Beginning to write tabbed mode metadata files."); // Collect information about all the normal tabs on the UI thread. final DocumentTabModel normalTabModel = getNormalDocumentTabModel(); TabModelMetadata normalMetadata = new TabModelMetadata(normalTabModel.index()); for (int i = 0; i < normalTabModel.getCount(); i++) { int tabId = normalTabModel.getTabAt(i).getId(); normalMetadata.ids.add(tabId); if (migratedTabIds.contains(tabId)) { // Don't save a URL because it's in the TabState. normalMetadata.urls.add(""); } else { // The best that can be done is to fall back to the initial URL for the Tab. Log.e(TAG, "Couldn't restore state for #" + tabId + "; using initial URL."); normalMetadata.urls.add(normalTabModel.getInitialUrlForDocument(tabId)); } } // Incognito tabs are dropped. TabModelMetadata incognitoMetadata = new TabModelMetadata(TabModel.INVALID_TAB_INDEX); try { mSerializedMetadata = TabPersistentStore.serializeMetadata( normalMetadata, incognitoMetadata, null); } catch (IOException e) { Log.e(TAG, "Failed to serialize the TabModel.", e); mSerializedMetadata = null; } } @Override protected Boolean doInBackground(Void... params) { if (mSerializedMetadata != null) { // If an old tab state file still exists when we run migration in TPS, then it // will overwrite the new tab state file that our document tabs migrated to. File oldMetadataFile = new File( getTabbedDataDirectory(), TabbedModeTabPersistencePolicy.LEGACY_SAVED_STATE_FILE); if (oldMetadataFile.exists() && !oldMetadataFile.delete()) { Log.e(TAG, "Failed to delete old tab state file: " + oldMetadataFile); } TabPersistentStore.saveListToFile( getTabbedDataDirectory(), TabbedModeTabPersistencePolicy.getStateFileName(TAB_MODEL_INDEX), mSerializedMetadata); return true; } else { return false; } } @Override protected void onPostExecute(Boolean result) { // TODO(dfalcantara): What do we do if the metadata file failed to be written out? Log.d(TAG, "Finished writing tabbed mode metadata file."); setStage(STAGE_WRITE_TABMODEL_METADATA_STARTED, STAGE_WRITE_TABMODEL_METADATA_DONE); } }.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR); } /** Moves the user to tabbed mode by opting them out and removing all the tasks. */ final void switchToTabbedMode() { ThreadUtils.assertOnUiThread(); if (!setStage(STAGE_WRITE_TABMODEL_METADATA_DONE, STAGE_CHANGE_SETTINGS_STARTED)) return; // Record that the user has opted-out of document mode now that their data has been // safely copied to the other directory. Log.d(TAG, "Setting tabbed mode preference."); setOptedOutState(OPTED_OUT_OF_DOCUMENT_MODE); TabSwitcherCallout.setIsTabSwitcherCalloutNecessary(getContext(), true); // Remove all the {@link DocumentActivity} tasks from Android's Recents list. Users // viewing Recents during migration will continue to see their tabs until they exit. // Reselecting a migrated tab will kick the user to the launcher without crashing. // TODO(dfalcantara): Confirm that the different Android flavors work the same way. createActivityDelegate().finishAllDocumentActivities(); // Dismiss the "Close all incognito tabs" notification. IncognitoNotificationManager.dismissIncognitoNotification(); setStage(STAGE_CHANGE_SETTINGS_STARTED, STAGE_CHANGE_SETTINGS_DONE); } /** Deletes all remnants of the document mode directory and preferences. */ final void deleteDocumentModeData() { ThreadUtils.assertOnUiThread(); if (!setStage(STAGE_CHANGE_SETTINGS_DONE, STAGE_DELETION_STARTED)) return; new AsyncTask<Void, Void, Void>() { @Override protected Void doInBackground(Void... params) { Log.d(TAG, "Starting to delete document mode data."); // Delete the old tab state directory. FileUtils.recursivelyDeleteFile(getDocumentDataDirectory()); // Clean up the {@link DocumentTabModel} shared preferences. SharedPreferences prefs = getContext().getSharedPreferences( DocumentTabModelImpl.PREF_PACKAGE, Context.MODE_PRIVATE); SharedPreferences.Editor editor = prefs.edit(); editor.clear(); editor.apply(); return null; } @Override protected void onPostExecute(Void result) { Log.d(TAG, "Finished deleting document mode data."); setStage(STAGE_DELETION_STARTED, STAGE_DONE); } }.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR); } /** * Updates the stage of the migration. * @param expectedStage Stage of the pipeline that is currently expected. * @param newStage Stage of the pipeline that is being activated. * @return Whether or not the stage was updated. */ private final boolean setStage(int expectedStage, int newStage) { ThreadUtils.assertOnUiThread(); if (mStage != expectedStage) { Log.e(TAG, "Wrong stage encountered: expected " + expectedStage + " but in " + mStage); return false; } mStage = newStage; for (DocumentModeAssassinObserver callback : mObservers) callback.onStageChange(newStage); startStage(newStage); return true; } /** * Kicks off tasks for the new state of the pipeline. * * We don't wait for the DocumentTabModel to finish parsing its metadata file before proceeding * with migration because it doesn't have actionable information: * * 1) WE DON'T NEED TO RE-POPULATE THE "RECENTLY CLOSED" LIST: * The metadata file contains a list of tabs Chrome knew about before it died, which * could differ from the list of tabs in Android Overview. The canonical list of * live tabs, however, has always been the ones displayed by the Android Overview. * * 2) RETARGETING MIGRATED TABS FROM THE HOME SCREEN IS A CORNER CASE: * The only downside here is that Chrome ends up creating a new tab for a home screen * shortcut the first time they start Chrome after migration. This was already * broken for document mode during cold starts, anyway. */ private final void startStage(int newStage) { ThreadUtils.assertOnUiThread(); if (!mIsPipelineActive) return; if (newStage == STAGE_INITIALIZED) { Log.d(TAG, "Migrating user into tabbed mode."); int selectedTabId = DocumentUtils.getLastShownTabIdFromPrefs(getContext(), false); copyTabStateFiles(selectedTabId); } else if (newStage == STAGE_COPY_TAB_STATES_DONE) { Log.d(TAG, "Writing tabbed mode metadata file."); writeTabModelMetadata(mMigratedTabIds); } else if (newStage == STAGE_WRITE_TABMODEL_METADATA_DONE) { Log.d(TAG, "Changing user preference."); switchToTabbedMode(); } else if (newStage == STAGE_CHANGE_SETTINGS_DONE) { Log.d(TAG, "Cleaning up document mode data."); deleteDocumentModeData(); } } /** @return the current stage of the pipeline. */ public final int getStage() { ThreadUtils.assertOnUiThread(); return mStage; } /** * Adds a observer that is alerted as migration progresses. * * @param observer Observer to add. */ public final void addObserver(final DocumentModeAssassinObserver observer) { ThreadUtils.assertOnUiThread(); mObservers.addObserver(observer); } /** * Removes an Observer. * * @param observer Observer to remove. */ public final void removeObserver(final DocumentModeAssassinObserver observer) { ThreadUtils.assertOnUiThread(); mObservers.removeObserver(observer); } private DocumentModeAssassin() { mStage = isMigrationNecessary() ? STAGE_UNINITIALIZED : STAGE_DONE; mIsPipelineActive = true; } private DocumentModeAssassin(int stage, boolean isPipelineActive) { mStage = stage; mIsPipelineActive = isPipelineActive; } /** DocumentModeAssassin that can have its methods be overridden for testing. */ static class DocumentModeAssassinForTesting extends DocumentModeAssassin { /** * Creates a DocumentModeAssassin that starts at the given stage. * * @param stage Stage to start at. See the STAGE_* values above. * @param isPipelineActive Whether the pipeline should continue after a stage finishes. */ @VisibleForTesting DocumentModeAssassinForTesting(int stage, boolean isPipelineActive) { super(stage, isPipelineActive); } } /** @return Whether or not a migration to tabbed mode from document mode is necessary. */ public boolean isMigrationNecessary() { return FeatureUtilities.isDocumentMode(ContextUtils.getApplicationContext()); } /** @return Context to use when grabbing SharedPreferences, Files, and other resources. */ protected Context getContext() { return ContextUtils.getApplicationContext(); } /** @return Interfaces with the Android ActivityManager. */ protected ActivityDelegate createActivityDelegate() { return new ActivityDelegateImpl(DocumentActivity.class, IncognitoDocumentActivity.class); } /** @return The {@link DocumentTabModelImpl} for regular tabs. */ protected DocumentTabModel getNormalDocumentTabModel() { return ChromeApplication.getDocumentTabModelSelector().getModel(false); } /** @return Where document mode data is stored for the normal {@link DocumentTabModel}. */ protected File getDocumentDataDirectory() { return new StorageDelegate().getStateDirectory(); } /** @return Where tabbed mode data is stored. */ protected File getTabbedDataDirectory() { return TabbedModeTabPersistencePolicy.getOrCreateTabbedModeStateDirectory(); } /** @return True if the user is not in document mode. */ public static boolean isOptedOutOfDocumentMode() { // The OPT_OUT_STATE preference was introduced sometime after document mode was rolled out. // It may not be set for all users, even if they are in document mode. In order to correctly // detect whether the user is in document mode, if OPT_OUT_STATE is not state we must check // whether MIGRATION_ON_UPGRADE_ATTEMPTED is set. int optOutState = ContextUtils.getAppSharedPreferences().getInt(OPT_OUT_STATE, OPT_OUT_STATE_UNSET); if (optOutState == OPT_OUT_STATE_UNSET) { boolean hasMigratedToDocumentMode = ContextUtils.getAppSharedPreferences().getBoolean( MIGRATION_ON_UPGRADE_ATTEMPTED, false); if (!hasMigratedToDocumentMode) { optOutState = OPTED_OUT_OF_DOCUMENT_MODE; } else { // Check if a migration has already happened by looking for tab_state0 file. // See crbug.com/646146. boolean newMetadataFileExists = false; StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads(); try { File newMetadataFile = new File( TabbedModeTabPersistencePolicy.getOrCreateTabbedModeStateDirectory(), TabbedModeTabPersistencePolicy.getStateFileName(TAB_MODEL_INDEX)); newMetadataFileExists = newMetadataFile.exists(); } finally { StrictMode.setThreadPolicy(oldPolicy); } if (newMetadataFileExists) { optOutState = OPTED_OUT_OF_DOCUMENT_MODE; } else { optOutState = OPT_IN_TO_DOCUMENT_MODE; } } setOptedOutState(optOutState); } return optOutState == OPTED_OUT_OF_DOCUMENT_MODE; } /** * Sets the opt out preference. * @param state One of OPTED_OUT_OF_DOCUMENT_MODE or OPT_IN_TO_DOCUMENT_MODE. */ public static void setOptedOutState(int state) { SharedPreferences.Editor sharedPreferencesEditor = ContextUtils.getAppSharedPreferences().edit(); sharedPreferencesEditor.putInt(OPT_OUT_STATE, state); sharedPreferencesEditor.apply(); } }