summaryrefslogtreecommitdiffstats
path: root/chrome/android/java/src/org/chromium/chrome/browser/tabmodel/DocumentModeAssassin.java
blob: bc7e0ba87ae07ecacd6b4bc71c6484a21e50d01f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
// 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.util.Pair;

import org.chromium.base.ApplicationStatus;
import org.chromium.base.CommandLine;
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.ChromeSwitches;
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.preferences.DocumentModeManager;
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.DocumentTabModelSelector;
import org.chromium.chrome.browser.tabmodel.document.StorageDelegate;
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;

import javax.annotation.Nullable;

/**
 * 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.
 *
 *    TODO(dfalcantara): Check what happens on other launchers.
 *
 *    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.
 *
 *    TODO(dfalcantara): Add histograms for tracking migration progress.
 *
 * TODO(dfalcantara): Potential pitfalls that need to be accounted for:
 *   - Consistently crashing during migration means you can never open Chrome until you clear data.
 *   - Successfully migrating, but crashing while deleting things and closing off tasks.
 *   - Failing to copy all the TabState files over during migration because of a lack of space.
 */
@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;

    private static final String TAG = "DocumentModeAssassin";

    /** Which TabModelSelectorImpl to copy files into during migration. */
    private static final int TAB_MODEL_INDEX = 0;

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

    /** Observers of the migration pipeline. */
    private final ObserverList<DocumentModeAssassinObserver> mObservers =
            new ObserverList<DocumentModeAssassinObserver>();

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

    /** Returns whether or not a migration to tabbed mode from document mode is necessary. */
    public static boolean isMigrationNecessary() {
        return CommandLine.getInstance().hasSwitch(ChromeSwitches.ENABLE_FORCED_MIGRATION)
                && FeatureUtilities.isDocumentMode(ApplicationStatus.getApplicationContext());
    }

    /** Migrates the user from document mode to tabbed mode if necessary. */
    @VisibleForTesting
    public void migrateFromDocumentToTabbedMode() {
        ThreadUtils.assertOnUiThread();

        if (!isMigrationNecessary()) {
            // Don't kick off anything if we don't need to.
            setStage(STAGE_UNINITIALIZED, STAGE_DONE);
            return;
        } else if (mStage != STAGE_UNINITIALIZED) {
            // Migration is already underway.
            return;
        }

        // TODO(dfalcantara): Add a pathway to catch repeated migration failures.

        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.
     *
     * TODO(dfalcantara): Prevent migrating chrome:// pages?
     *
     * @param selectedTabId             ID of the last viewed non-Incognito tab.
     * @param context                   Context to use when accessing directories.
     * @param documentDirectoryOverride Overrides the default location for where document mode's
     *                                  TabState files are expected to be.
     * @param tabbedDirectoryOverride   Overrides the default location for where tabbed mode's
     *                                  TabState files are expected to be.
     */
    void copyTabStateFiles(final int selectedTabId, final Context context,
            @Nullable final File documentDirectoryOverride,
            @Nullable final File tabbedDirectoryOverride) {
        ThreadUtils.assertOnUiThread();
        if (!setStage(STAGE_INITIALIZED, STAGE_COPY_TAB_STATES_STARTED)) return;

        new AsyncTask<Void, Void, Void>() {
            private DocumentTabModelImpl mNormalTabModel;

            @Override
            protected void onPreExecute() {
                if (documentDirectoryOverride == null) {
                    mNormalTabModel = (DocumentTabModelImpl)
                            ChromeApplication.getDocumentTabModelSelector().getModel(false);
                }
            }

            @Override
            protected Void doInBackground(Void... params) {
                File documentDirectory = documentDirectoryOverride == null
                        ? mNormalTabModel.getStorageDelegate().getStateDirectory()
                        : documentDirectoryOverride;
                File tabbedDirectory = tabbedDirectoryOverride == null
                        ? TabPersistentStore.getStateDirectory(context, TAB_MODEL_INDEX)
                        : tabbedDirectoryOverride;

                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 normalTabModel            DocumentTabModel containing info about non-Incognito tabs.
     * @param migratedTabIds            IDs of Tabs whose TabState files were copied successfully.
     * @param context                   Context to access Files from.
     * @param tabbedDirectoryOverride   Overrides the default location for where tabbed mode's
     *                                  TabState files are expected to be.
     */
    void writeTabModelMetadata(final DocumentTabModel normalTabModel,
            final Set<Integer> migratedTabIds, final Context context,
            @Nullable final File tabbedDirectoryOverride) {
        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.
                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) {
                    File tabbedDirectory = tabbedDirectoryOverride == null
                            ? TabPersistentStore.getStateDirectory(context, TAB_MODEL_INDEX)
                            : tabbedDirectoryOverride;
                    TabPersistentStore.saveListToFile(tabbedDirectory, 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.
     * @param context Context to grab SharedPreferences from.
     */
    void changePreferences(Context context) {
        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.");
        DocumentModeManager.getInstance(context).setOptedOutState(
                DocumentModeManager.OPTED_OUT_OF_DOCUMENT_MODE);

        setStage(STAGE_CHANGE_SETTINGS_STARTED, STAGE_CHANGE_SETTINGS_DONE);
    }

    /** TODO(dfalcantara): Add a unit test for this function. */
    private void deleteDocumentModeData(final Context context) {
        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.");

                // 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.
                ActivityDelegate delegate = new ActivityDelegateImpl(
                        DocumentActivity.class, IncognitoDocumentActivity.class);
                delegate.finishAllDocumentActivities();

                // Delete the old tab state directory.
                StorageDelegate migrationStorageDelegate = new StorageDelegate();
                FileUtils.recursivelyDeleteFile(migrationStorageDelegate.getStateDirectory());

                // Clean up the {@link DocumentTabModel} shared preferences.
                SharedPreferences prefs = context.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.
     */
    private boolean setStage(int expectedStage, int newStage) {
        ThreadUtils.assertOnUiThread();

        assert mStage == expectedStage;
        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 void startStage(int newStage) {
        ThreadUtils.assertOnUiThread();
        if (!mIsPipelineActive) return;

        Context context = ApplicationStatus.getApplicationContext();
        if (newStage == STAGE_INITIALIZED) {
            Log.d(TAG, "Migrating user into tabbed mode.");
            int selectedTabId = DocumentUtils.getLastShownTabIdFromPrefs(context, false);
            copyTabStateFiles(selectedTabId, context, null, null);
        } else if (newStage == STAGE_COPY_TAB_STATES_DONE) {
            Log.d(TAG, "Writing tabbed mode metadata file.");
            DocumentTabModelSelector selector = ChromeApplication.getDocumentTabModelSelector();
            DocumentTabModelImpl normalTabModel =
                    (DocumentTabModelImpl) selector.getModel(false);
            writeTabModelMetadata(normalTabModel, mMigratedTabIds, context, null);
        } else if (newStage == STAGE_WRITE_TABMODEL_METADATA_DONE) {
            Log.d(TAG, "Changing user preference.");
            changePreferences(context);
        } else if (newStage == STAGE_CHANGE_SETTINGS_DONE) {
            Log.d(TAG, "Cleaning up document mode data.");
            deleteDocumentModeData(context);
        }
    }


    /**
     * Returns the current stage of the pipeline.
     */
    @VisibleForTesting
    public int getStage() {
        ThreadUtils.assertOnUiThread();
        return mStage;
    }


    /**
     * Adds a observer that is alerted as migration progresses.
     *
     * @param observer Observer to add.
     */
    @VisibleForTesting
    public void addObserver(final DocumentModeAssassinObserver observer) {
        ThreadUtils.assertOnUiThread();
        mObservers.addObserver(observer);
    }

    /**
     * Removes an Observer.
     *
     * @param observer Observer to remove.
     */
    @VisibleForTesting
    public void removeObserver(final DocumentModeAssassinObserver observer) {
        ThreadUtils.assertOnUiThread();
        mObservers.removeObserver(observer);
    }

    /**
     * Creates a DocumentModeAssassin that starts at the given stage and does not automatically
     * move along the pipeline.
     *
     * @param stage Stage to start at.  See the STAGE_* values above.
     * @return DocumentModeAssassin that can be used for testing specific stages of the pipeline.
     */
    @VisibleForTesting
    public static DocumentModeAssassin createForTesting(int stage) {
        return new DocumentModeAssassin(stage, false);
    }

    private DocumentModeAssassin() {
        this(isMigrationNecessary() ? STAGE_UNINITIALIZED : STAGE_DONE, true);
    }

    private DocumentModeAssassin(int stage, boolean isPipelineActive) {
        mStage = stage;
        mIsPipelineActive = isPipelineActive;
    }
}