chromium/chrome/android/java/src/org/chromium/chrome/browser/multiwindow/MultiInstanceManagerApi31.java

// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser.multiwindow;

import android.app.Activity;
import android.app.ActivityManager;
import android.app.ActivityManager.AppTask;
import android.content.Context;
import android.content.Intent;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Bundle;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.util.Pair;
import android.util.SparseBooleanArray;
import android.util.SparseIntArray;

import androidx.annotation.VisibleForTesting;

import org.chromium.base.ActivityState;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.ApplicationStatus.ActivityStateListener;
import org.chromium.base.ContextUtils;
import org.chromium.base.IntentUtils;
import org.chromium.base.Log;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.shared_preferences.SharedPreferencesManager;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.Supplier;
import org.chromium.build.BuildConfig;
import org.chromium.chrome.browser.ChromeTabbedActivity;
import org.chromium.chrome.browser.IntentHandler;
import org.chromium.chrome.browser.app.tab_activity_glue.ReparentingTask;
import org.chromium.chrome.browser.app.tabmodel.TabModelOrchestrator;
import org.chromium.chrome.browser.app.tabmodel.TabWindowManagerSingleton;
import org.chromium.chrome.browser.crash.ChromePureJavaExceptionReporter;
import org.chromium.chrome.browser.feature_engagement.TrackerFactory;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.lifecycle.ActivityLifecycleDispatcher;
import org.chromium.chrome.browser.multiwindow.MultiInstanceState.MultiInstanceStateObserver;
import org.chromium.chrome.browser.multiwindow.MultiWindowUtils.InstanceAllocationType;
import org.chromium.chrome.browser.preferences.ChromePreferenceKeys;
import org.chromium.chrome.browser.preferences.ChromeSharedPreferences;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabObserver;
import org.chromium.chrome.browser.tab_group_sync.TabGroupSyncFeatures;
import org.chromium.chrome.browser.tab_group_sync.TabGroupSyncServiceFactory;
import org.chromium.chrome.browser.tab_group_sync.TabGroupSyncUtils;
import org.chromium.chrome.browser.tabmodel.TabClosureParams;
import org.chromium.chrome.browser.tabmodel.TabList;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.tabmodel.TabModelSelectorTabModelObserver;
import org.chromium.chrome.browser.tabmodel.TabModelUtils;
import org.chromium.chrome.browser.tabmodel.TabWindowManager;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupModelFilter;
import org.chromium.chrome.browser.tasks.tab_management.TabUiFeatureUtilities;
import org.chromium.chrome.browser.ui.desktop_windowing.AppHeaderUtils;
import org.chromium.chrome.browser.ui.desktop_windowing.DesktopWindowStateProvider;
import org.chromium.chrome.browser.util.AndroidTaskUtils;
import org.chromium.components.browser_ui.widget.MenuOrKeyboardActionController;
import org.chromium.components.favicon.LargeIconBridge;
import org.chromium.components.feature_engagement.EventConstants;
import org.chromium.components.feature_engagement.Tracker;
import org.chromium.components.tab_group_sync.TabGroupSyncService;
import org.chromium.ui.modaldialog.ModalDialogManager;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

class MultiInstanceManagerApi31 extends MultiInstanceManager implements ActivityStateListener {
    private static final String TAG = "MIMApi31";
    private static final String TAG_MULTI_INSTANCE = "MultiInstance";

    public static final int INVALID_INSTANCE_ID = MultiWindowUtils.INVALID_INSTANCE_ID;
    public static final int INVALID_TASK_ID = MultiWindowUtils.INVALID_TASK_ID;

    private static final String EMPTY_DATA = "";
    private static MultiInstanceState sState;

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    protected final int mMaxInstances;

    private ObservableSupplier<ModalDialogManager> mModalDialogManagerSupplier;

    // Instance ID for the activity associated with this manager.
    private int mInstanceId = INVALID_INSTANCE_ID;

    private Tab mActiveTab;
    private TabObserver mActiveTabObserver =
            new EmptyTabObserver() {
                @Override
                public void onTitleUpdated(Tab tab) {
                    if (!tab.isIncognito()) writeTitle(mInstanceId, tab);
                }

                @Override
                public void onUrlUpdated(Tab tab) {
                    if (!tab.isIncognito()) writeUrl(mInstanceId, tab);
                }
            };

    private final Supplier<DesktopWindowStateProvider> mDesktopWindowStateProviderSupplier;
    private final MultiInstanceStateObserver mOnMultiInstanceStateChanged;

    MultiInstanceManagerApi31(
            Activity activity,
            ObservableSupplier<TabModelOrchestrator> tabModelOrchestratorSupplier,
            MultiWindowModeStateDispatcher multiWindowModeStateDispatcher,
            ActivityLifecycleDispatcher activityLifecycleDispatcher,
            ObservableSupplier<ModalDialogManager> modalDialogManagerSupplier,
            MenuOrKeyboardActionController menuOrKeyboardActionController,
            Supplier<DesktopWindowStateProvider> desktopWindowStateProviderSupplier) {
        super(
                activity,
                tabModelOrchestratorSupplier,
                multiWindowModeStateDispatcher,
                activityLifecycleDispatcher,
                menuOrKeyboardActionController);
        mMaxInstances = MultiWindowUtils.getMaxInstances();
        mModalDialogManagerSupplier = modalDialogManagerSupplier;
        mDesktopWindowStateProviderSupplier = desktopWindowStateProviderSupplier;
        mOnMultiInstanceStateChanged = this::onMultiInstanceStateChanged;
    }

    @Override
    public boolean handleMenuOrKeyboardAction(int id, boolean fromMenu) {
        if (id == org.chromium.chrome.R.id.manage_all_windows_menu_id) {
            List<InstanceInfo> info = getInstanceInfo();
            InstanceSwitcherCoordinator.showDialog(
                    mActivity,
                    mModalDialogManagerSupplier.get(),
                    new LargeIconBridge(getProfile()),
                    (item) -> openInstance(item.instanceId, item.taskId),
                    (item) -> {
                        RecordUserAction.record("MobileMenuWindowManagerCloseInstance");
                        closeInstance(item.instanceId, item.taskId);
                        cleanupSyncedTabGroupsIfLastInstance();
                    },
                    () -> openNewWindow("Android.WindowManager.NewWindow"),
                    info.size() < MultiWindowUtils.getMaxInstances(),
                    info);

            if (AppHeaderUtils.isAppInDesktopWindow(mDesktopWindowStateProviderSupplier.get())) {
                RecordUserAction.record("MobileMenuWindowManager.InDesktopWindow");
            } else {
                RecordUserAction.record("MobileMenuWindowManager");
            }

            AppHeaderUtils.recordDesktopWindowModeStateEnumHistogram(
                    mDesktopWindowStateProviderSupplier.get(),
                    "Android.MultiInstance.WindowManager.DesktopWindowModeState");

            Tracker tracker = TrackerFactory.getTrackerForProfile(getProfile());
            assert tracker.isInitialized();
            tracker.notifyEvent(EventConstants.INSTANCE_SWITCHER_IPH_USED);
            return true;
        }
        return super.handleMenuOrKeyboardAction(id, fromMenu);
    }

    @Override
    protected void moveTabToOtherWindow(Tab tab) {
        TargetSelectorCoordinator.showDialog(
                mActivity,
                mModalDialogManagerSupplier.get(),
                new LargeIconBridge(getProfile()),
                (instanceInfo) -> {
                    moveTabAction(instanceInfo, tab, TabList.INVALID_TAB_INDEX);

                    // Close the source instance window, if needed.
                    closeChromeWindowIfEmpty(mInstanceId);
                },
                getInstanceInfo());
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    void moveTabAction(InstanceInfo info, Tab tab, int tabAtIndex) {
        Activity targetActivity = getActivityById(info.instanceId);
        if (targetActivity != null) {
            reparentTabToRunningActivity((ChromeTabbedActivity) targetActivity, tab, tabAtIndex);
        } else {
            moveAndReparentTabToNewWindow(
                    tab,
                    info.instanceId,
                    /* preferNew= */ false,
                    /* openAdjacently= */ true,
                    /* addTrustedIntentExtras= */ true);
        }
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    void moveAndReparentTabToNewWindow(
            Tab tab,
            int instanceId,
            boolean preferNew,
            boolean openAdjacently,
            boolean addTrustedIntentExtras) {
        onMultiInstanceModeStarted();
        Intent intent =
                MultiWindowUtils.createNewWindowIntent(
                        mActivity, instanceId, preferNew, openAdjacently, addTrustedIntentExtras);
        beginReparenting(
                tab,
                intent,
                mMultiWindowModeStateDispatcher.getOpenInOtherWindowActivityOptions(),
                null);
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    void reparentTabToRunningActivity(
            ChromeTabbedActivity targetActivity, Tab tab, int tabAtIndex) {
        assert targetActivity != null;
        Intent intent = new Intent();
        Context appContext = ContextUtils.getApplicationContext();
        intent.setClassName(appContext, ChromeTabbedActivity.class.getName());
        MultiWindowUtils.setOpenInOtherWindowIntentExtras(
                intent, mActivity, targetActivity.getClass());
        onMultiInstanceModeStarted();
        RecordUserAction.record("MobileMenuMoveToOtherWindow");

        if (tabAtIndex != TabList.INVALID_TAB_INDEX) {
            intent.putExtra(IntentHandler.EXTRA_TAB_INDEX, tabAtIndex);
        }
        setupIntentForReparenting(tab, intent, null);

        targetActivity.onNewIntent(intent);
        bringTaskForeground(targetActivity.getTaskId());
    }

    @Override
    protected void openNewWindow(String umaAction) {
        Intent intent = new Intent(mActivity, ChromeTabbedActivity.class);
        onMultiInstanceModeStarted();
        MultiWindowUtils.setOpenInOtherWindowIntentExtras(
                intent, mActivity, ChromeTabbedActivity.class);
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
        intent.putExtra(IntentHandler.EXTRA_PREFER_NEW, true);
        IntentUtils.addTrustedIntentExtras(intent);
        if (mMultiWindowModeStateDispatcher.canEnterMultiWindowMode()
                || mMultiWindowModeStateDispatcher.isInMultiWindowMode()
                || mMultiWindowModeStateDispatcher.isInMultiDisplayMode()) {
            intent.addFlags(Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT);
            Bundle bundle = mMultiWindowModeStateDispatcher.getOpenInOtherWindowActivityOptions();
            mActivity.startActivity(intent, bundle);
        } else {
            mActivity.startActivity(intent);
        }
        Log.i(TAG_MULTI_INSTANCE, "Opening new window from action: " + umaAction);
        RecordUserAction.record(umaAction);
    }

    @Override
    public List<InstanceInfo> getInstanceInfo() {
        removeInvalidInstanceData(/* cleanupApplicationStatus= */ false);
        List<InstanceInfo> result = new ArrayList<>();
        SparseBooleanArray visibleTasks = MultiWindowUtils.getVisibleTasks();
        int currentItemPos = -1;
        for (int i = 0; i < mMaxInstances; ++i) {
            if (!instanceEntryExists(i)) continue;
            @InstanceInfo.Type int type = InstanceInfo.Type.OTHER;
            Activity a = getActivityById(i);
            if (a != null) {
                // The task for the activity must match the one found in our mapping.
                assert getTaskFromMap(i) == a.getTaskId();
                if (a == mActivity) {
                    type = InstanceInfo.Type.CURRENT;
                    currentItemPos = result.size();
                } else if (isRunningInAdjacentWindow(visibleTasks, a)) {
                    type = InstanceInfo.Type.ADJACENT;
                }
            }

            int taskId = getTaskFromMap(i);
            result.add(
                    new InstanceInfo(
                            i,
                            taskId,
                            type,
                            readUrl(i),
                            readTitle(i),
                            readTabCount(i),
                            readIncognitoTabCount(i),
                            readIncognitoSelected(i)));
        }

        // Move the current instance always to the top of the list.
        assert currentItemPos != -1;
        if (currentItemPos != 0 && result.size() > 1) result.add(0, result.remove(currentItemPos));
        return result;
    }

    @Override
    public int getCurrentInstanceId() {
        List<InstanceInfo> allInstances = getInstanceInfo();
        if (allInstances == null || allInstances.isEmpty()) return INVALID_INSTANCE_ID;
        // Current instance is at top of list.
        return allInstances.get(0).instanceId;
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    protected boolean isRunningInAdjacentWindow(
            SparseBooleanArray visibleTasks, Activity activity) {
        assert activity != mActivity;
        return visibleTasks.get(activity.getTaskId());
    }

    @Override
    public Pair<Integer, Integer> allocInstanceId(int windowId, int taskId, boolean preferNew) {
        removeInvalidInstanceData(/* cleanupApplicationStatus= */ true);

        int instanceId = getInstanceByTask(taskId);

        // Explicitly specified window ID should be preferred. This comes from user selecting
        // a certain instance on UI when no task is present for it.
        // When out of range, ignore the ID and apply the normal allocation logic below.
        if (windowId >= 0 && windowId < mMaxInstances && instanceId == INVALID_INSTANCE_ID) {
            Log.i(TAG_MULTI_INSTANCE, "Existing Instance - selected Id allocated: " + windowId);
            return Pair.create(windowId, InstanceAllocationType.EXISTING_INSTANCE_UNMAPPED_TASK);
        }

        // First, see if we have instance-task ID mapping. If we do, use the instance id. This
        // takes care of a task that had its activity destroyed and comes back to create a
        // new one. We pair them again.
        if (instanceId != INVALID_INSTANCE_ID) {
            Log.i(TAG_MULTI_INSTANCE, "Existing Instance - mapped Id allocated: " + instanceId);
            return Pair.create(instanceId, InstanceAllocationType.EXISTING_INSTANCE_MAPPED_TASK);
        }

        // If asked to always create a fresh new instance, not from persistent state, do it here.
        if (preferNew) {
            for (int i = 0; i < mMaxInstances; ++i) {
                if (!instanceEntryExists(i)) {
                    logNewInstanceId(i);
                    return Pair.create(i, InstanceAllocationType.PREFER_NEW_INSTANCE_NEW_TASK);
                }
            }
            return Pair.create(
                    INVALID_INSTANCE_ID, InstanceAllocationType.PREFER_NEW_INVALID_INSTANCE);
        }

        // Search for an unassigned ID. The index is available for the assignment if:
        // a) there is no associated task, or
        // b) the corresponding persistent state does not exist.
        // Prefer a over b. Pick the MRU instance if there is more than one. Type b returns 0
        // for |readLastAccessedTime|, so can be regarded as the least favored.
        int id = INVALID_INSTANCE_ID;
        boolean newInstanceIdAllocated = false;
        @InstanceAllocationType int allocationType = InstanceAllocationType.INVALID_INSTANCE;
        for (int i = 0; i < mMaxInstances; ++i) {
            int taskIdFromMap = getTaskFromMap(i);
            if (taskIdFromMap != INVALID_TASK_ID) {
                continue;
            }
            if (id == INVALID_INSTANCE_ID || readLastAccessedTime(i) > readLastAccessedTime(id)) {
                id = i;
                newInstanceIdAllocated = !instanceEntryExists(i);
                allocationType =
                        newInstanceIdAllocated
                                ? InstanceAllocationType.NEW_INSTANCE_NEW_TASK
                                : InstanceAllocationType.EXISTING_INSTANCE_NEW_TASK;
            }
        }

        if (newInstanceIdAllocated) {
            logNewInstanceId(id);
        } else if (id != INVALID_INSTANCE_ID) {
            Log.i(
                    TAG_MULTI_INSTANCE,
                    "Existing Instance - persisted and unmapped Id allocated: " + id);
        }

        return Pair.create(id, allocationType);
    }

    private void logNewInstanceId(int i) {
        StringBuilder taskData = new StringBuilder();
        ActivityManager activityManager =
                (ActivityManager) mActivity.getSystemService(Context.ACTIVITY_SERVICE);
        for (AppTask task : activityManager.getAppTasks()) {
            String baseActivity = MultiWindowUtils.getActivityNameFromTask(task);
            ActivityManager.RecentTaskInfo info = AndroidTaskUtils.getTaskInfoFromTask(task);
            taskData.append(
                    "Task with id: "
                            + (info != null ? info.id : "NOT_SET")
                            + " has base activity: "
                            + baseActivity
                            + ".\n");
        }
        Log.i(
                TAG_MULTI_INSTANCE,
                "New Instance - unused Id allocated: "
                        + i
                        + ". Task data during instance allocation: "
                        + taskData);
    }

    @Override
    public void initialize(int instanceId, int taskId) {
        mInstanceId = instanceId;
        updateTaskMap(instanceId, taskId);
        installTabModelObserver();
        recordInstanceCountHistogram();
        recordActivityCountHistogram();
        ActivityManager activityManager =
                (ActivityManager) mActivity.getSystemService(Context.ACTIVITY_SERVICE);
        String launchActivityName = ChromeTabbedActivity.MAIN_LAUNCHER_ACTIVITY_NAME;
        if (activityManager != null) {
            sState =
                    MultiInstanceState.maybeCreate(
                            activityManager::getAppTasks,
                            (activityName) ->
                                    TextUtils.equals(
                                                    activityName,
                                                    ChromeTabbedActivity.class.getName())
                                            || TextUtils.equals(activityName, launchActivityName));
            sState.addObserver(mOnMultiInstanceStateChanged);
        }
        ApplicationStatus.registerStateListenerForActivity(this, mActivity);
    }

    @Override
    public void onTabStateInitialized() {
        TabModelSelector selector = mTabModelOrchestratorSupplier.get().getTabModelSelector();
        writeTabCount(mInstanceId, selector);
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    protected void installTabModelObserver() {
        TabModelSelector selector = mTabModelOrchestratorSupplier.get().getTabModelSelector();
        mTabModelObserver =
                new TabModelSelectorTabModelObserver(selector) {
                    @Override
                    public void didSelectTab(Tab tab, int type, int lastId) {
                        // We will check if |mActiveTab| is the same as the selected |tab| to avoid
                        // a superfluous update to an instance's stored active tab info that
                        // remains unchanged.
                        // The check on |lastId| is required to continue updating this info for an
                        // instance even when |mActiveTab| is the same as the selected |tab|, in
                        // the following scenario: If |mActiveTab| is the last tab in instance 1,
                        // and is moved to instance
                        // 2, instance 1 stores "empty" active tab information since it now
                        // contains no tabs.
                        // When |mActiveTab| is moved back to instance 1, |mActiveTab| is now the
                        // same as the selected |tab| in instance 1, however instance 1's active
                        // tab information will not be updated, unless we establish that this
                        // instance is currently holding "empty" info, reflected by the fact that
                        // it has an invalid last selected tab ID, so it's active tab info can
                        // then be updated.
                        if (mActiveTab == tab && lastId != Tab.INVALID_TAB_ID) return;
                        if (mActiveTab != null) mActiveTab.removeObserver(mActiveTabObserver);
                        mActiveTab = tab;
                        if (mActiveTab != null) {
                            mActiveTab.addObserver(mActiveTabObserver);
                            writeIncognitoSelected(mInstanceId, mActiveTab);
                            // When an incognito tab is focused, keep the normal active tab info.
                            Tab urlTab =
                                    mActiveTab.isIncognito()
                                            ? TabModelUtils.getCurrentTab(selector.getModel(false))
                                            : mActiveTab;
                            if (urlTab != null) {
                                writeUrl(mInstanceId, urlTab);
                                writeTitle(mInstanceId, urlTab);
                            } else {
                                writeUrl(mInstanceId, EMPTY_DATA);
                                writeTitle(mInstanceId, EMPTY_DATA);
                            }
                        }
                    }

                    @Override
                    public void didAddTab(
                            Tab tab, int type, int creationState, boolean markedForSelection) {
                        writeTabCount(mInstanceId, selector);
                    }

                    @Override
                    public void onFinishingTabClosure(Tab tab) {
                        // onFinishingTabClosure is called for both normal/incognito tabs, whereas
                        // tabClosureCommitted is called for normal tabs only.
                        writeTabCount(mInstanceId, selector);
                    }

                    @Override
                    public void tabRemoved(Tab tab) {
                        // Updates the tab count of the src activity a reparented tab gets detached
                        // from.
                        writeTabCount(mInstanceId, selector);
                    }
                };
    }

    static int getTaskFromMap(int index) {
        return ChromeSharedPreferences.getInstance().readInt(taskMapKey(index), INVALID_TASK_ID);
    }

    private static String taskMapKey(int index) {
        return ChromePreferenceKeys.MULTI_INSTANCE_TASK_MAP.createKey(String.valueOf(index));
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    static void updateTaskMap(int instanceId, int taskId) {
        ChromeSharedPreferences.getInstance().writeInt(taskMapKey(instanceId), taskId);
    }

    private void removeInvalidInstanceData(boolean cleanupApplicationStatus) {
        // Remove tasks that do not exist any more from the task map
        ActivityManager activityManager =
                (ActivityManager) mActivity.getSystemService(Context.ACTIVITY_SERVICE);
        List<AppTask> appTasks = activityManager.getAppTasks();
        Set<Integer> appTaskIds = getAllAppTaskIds(appTasks);
        Map<String, Integer> taskMap =
                ChromeSharedPreferences.getInstance()
                        .readIntsWithPrefix(ChromePreferenceKeys.MULTI_INSTANCE_TASK_MAP);
        List<String> tasksRemoved = new ArrayList<>();
        for (Map.Entry<String, Integer> entry : taskMap.entrySet()) {
            if (!appTaskIds.contains(entry.getValue())) {
                // TODO (crbug/327054706): Remove this check once we have verified that crash
                // reports have reduced.
                checkInvalidTaskNotInAllTasks(appTasks, entry.getValue());
                tasksRemoved.add(entry.getKey() + " - " + entry.getValue());
                ChromeSharedPreferences.getInstance().removeKey(entry.getKey());
                if (ChromeFeatureList.sMultiInstanceApplicationStatusCleanup.isEnabled()
                        && cleanupApplicationStatus) {
                    boolean foundTasks = ApplicationStatus.cleanupInvalidTask(entry.getValue());
                    if (foundTasks) {
                        if (BuildConfig.ENABLE_ASSERTS) {
                            String logMessage =
                                    "This is not a crash. Found tracked ApplicationStatus for Task "
                                            + " that no longer exists in #getAppTasks().";
                            ChromePureJavaExceptionReporter.reportJavaException(
                                    new Throwable(logMessage));
                        }
                    }
                }
            }
        }

        List<Integer> instancesRemoved = new ArrayList<>();
        // Remove persistent data for unrecoverable instances.
        for (int i = 0; i < mMaxInstances; ++i) {
            if (instanceEntryExists(i) && !MultiWindowUtils.isRestorableInstance(i)) {
                instancesRemoved.add(i);
                removeInstanceInfo(i);
            }
        }

        if (!tasksRemoved.isEmpty() || !instancesRemoved.isEmpty()) {
            Log.i(
                    TAG_MULTI_INSTANCE,
                    "Removed invalid instance data. Removed tasks-instance mappings: "
                            + tasksRemoved
                            + " and shared prefs for instances: "
                            + instancesRemoved);
        }
    }

    @VisibleForTesting
    protected static List<Activity> getAllRunningActivities() {
        return ApplicationStatus.getRunningActivities();
    }

    @VisibleForTesting
    protected Set<Integer> getAllAppTaskIds(List<AppTask> allTasks) {
        Set<Integer> results = new HashSet<>();
        for (AppTask task : allTasks) {
            ActivityManager.RecentTaskInfo info = AndroidTaskUtils.getTaskInfoFromTask(task);
            if (info != null) results.add(info.taskId);
        }
        return results;
    }

    private void checkInvalidTaskNotInAllTasks(List<AppTask> allTasks, int removedTaskId) {
        if (!BuildConfig.ENABLE_ASSERTS || VERSION.SDK_INT < VERSION_CODES.Q) return;

        for (AppTask task : allTasks) {
            ActivityManager.RecentTaskInfo info = AndroidTaskUtils.getTaskInfoFromTask(task);
            if (info != null && (info.id == removedTaskId || info.taskId == removedTaskId)) {
                String message =
                        "Removed instance data for Task still available in all app tasks. " + info;
                Log.i(TAG_MULTI_INSTANCE, message);
                if (info != null && info.isRunning) {
                    String crashMessage =
                            "This is not a crash. Removed instance data for running Task still"
                                    + " available in all app tasks. "
                                    + info;
                    ChromePureJavaExceptionReporter.reportJavaException(
                            new Throwable(crashMessage));
                }
                break;
            }
        }
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    static Activity getActivityById(int id) {
        TabWindowManager windowManager = TabWindowManagerSingleton.getInstance();
        for (Activity activity : getAllRunningActivities()) {
            if (id == windowManager.getIndexForWindow(activity)) return activity;
        }
        return null;
    }

    private int getInstanceByTask(int taskId) {
        for (int i = 0; i < mMaxInstances; ++i) {
            if (taskId == getTaskFromMap(i)) return i;
        }
        return INVALID_INSTANCE_ID;
    }

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

    private void recordActivityCountHistogram() {
        RecordHistogram.recordExactLinearHistogram(
                "Android.MultiInstance.NumActivities",
                getRunningTabbedActivityCount(),
                mMaxInstances + 1);
    }

    static int getRunningTabbedActivityCount() {
        int numActivities = 0;
        List<Activity> activities = getAllRunningActivities();
        for (Activity activity : activities) {
            if (activity instanceof ChromeTabbedActivity) numActivities++;
        }
        return numActivities;
    }

    private void recordInstanceCountHistogram() {
        // Ensure we have instance info entry for the current one.
        writeLastAccessedTime(mInstanceId);

        RecordHistogram.recordExactLinearHistogram(
                "Android.MultiInstance.NumInstances",
                MultiWindowUtils.getInstanceCount(),
                mMaxInstances + 1);
    }

    private static String incognitoSelectedKey(int index) {
        return ChromePreferenceKeys.MULTI_INSTANCE_IS_INCOGNITO_SELECTED.createKey(
                String.valueOf(index));
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    static void writeIncognitoSelected(int index, Tab tab) {
        ChromeSharedPreferences.getInstance()
                .writeBoolean(incognitoSelectedKey(index), tab.isIncognito());
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    static boolean readIncognitoSelected(int index) {
        return ChromeSharedPreferences.getInstance()
                .readBoolean(incognitoSelectedKey(index), false);
    }

    private static String urlKey(int index) {
        return ChromePreferenceKeys.MULTI_INSTANCE_URL.createKey(String.valueOf(index));
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    static String readUrl(int index) {
        return ChromeSharedPreferences.getInstance().readString(urlKey(index), null);
    }

    static void writeUrl(int index, String url) {
        ChromeSharedPreferences.getInstance().writeString(urlKey(index), url);
    }

    private static void writeUrl(int index, Tab tab) {
        assert !tab.isIncognito();
        writeUrl(index, tab.getOriginalUrl().getSpec());
    }

    private static String titleKey(int index) {
        return ChromePreferenceKeys.MULTI_INSTANCE_TITLE.createKey(String.valueOf(index));
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    static String readTitle(int index) {
        return ChromeSharedPreferences.getInstance().readString(titleKey(index), null);
    }

    private static void writeTitle(int index, Tab tab) {
        assert !tab.isIncognito();
        writeTitle(index, tab.getTitle());
    }

    private static void writeTitle(int index, String title) {
        ChromeSharedPreferences.getInstance().writeString(titleKey(index), title);
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    static String tabCountKey(int index) {
        return ChromePreferenceKeys.MULTI_INSTANCE_TAB_COUNT.createKey(String.valueOf(index));
    }

    static int readTabCount(int index) {
        return ChromeSharedPreferences.getInstance().readInt(tabCountKey(index));
    }

    private static String incognitoTabCountKey(int index) {
        return ChromePreferenceKeys.MULTI_INSTANCE_INCOGNITO_TAB_COUNT.createKey(
                String.valueOf(index));
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    static int readIncognitoTabCount(int index) {
        return ChromeSharedPreferences.getInstance().readInt(incognitoTabCountKey(index));
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    static void writeTabCount(int index, TabModelSelector selector) {
        if (!selector.isTabStateInitialized()) return;
        SharedPreferencesManager prefs = ChromeSharedPreferences.getInstance();
        int tabCount = selector.getModel(false).getCount();
        prefs.writeInt(tabCountKey(index), tabCount);
        prefs.writeInt(incognitoTabCountKey(index), selector.getModel(true).getCount());
        if (tabCount == 0) {
            writeUrl(index, EMPTY_DATA);
            writeTitle(index, EMPTY_DATA);
        }
    }

    static boolean instanceEntryExists(int index) {
        return readLastAccessedTime(index) != 0;
    }

    private static String lastAccessedTimeKey(int index) {
        return MultiWindowUtils.lastAccessedTimeKey(index);
    }

    private static long readLastAccessedTime(int index) {
        return MultiWindowUtils.readLastAccessedTime(index);
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    static void writeLastAccessedTime(int index) {
        MultiWindowUtils.writeLastAccessedTime(index);
    }

    /**
     * @return The window IDs of the currently running ChromeTabbedActivity's. It is possible to
     *     have more number of saved instances than the number of currently running activities (for
     *     example, when an activity is killed from the Android app menu, its instance state still
     *     persists for use by Chrome).
     */
    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
    static SparseIntArray getWindowIdsOfRunningTabbedActivities() {
        List<Activity> activities = ApplicationStatus.getRunningActivities();
        var windowIdsOfRunningTabbedActivities = new SparseIntArray();
        for (Activity activity : activities) {
            if (!(activity instanceof ChromeTabbedActivity)) continue;
            int windowId = TabWindowManagerSingleton.getInstance().getIndexForWindow(activity);
            windowIdsOfRunningTabbedActivities.put(windowId, windowId);
        }
        return windowIdsOfRunningTabbedActivities;
    }

    /**
     * Open or launch a given instance.
     *
     * @param instanceId ID of the instance to open.
     * @param taskId ID of the task the instance resides in.
     */
    @VisibleForTesting
    void openInstance(int instanceId, int taskId) {
        RecordUserAction.record("Android.WindowManager.SelectWindow");
        if (taskId != INVALID_TASK_ID) {
            // Bring the task to foreground if the activity is alive, this completes the opening
            // of the instance. Otherwise, create a new activity for the instance and kill the
            // existing task.
            // TODO: Consider killing the instance and start it again to be able to position it
            //       in the intended window.
            if (getActivityById(instanceId) != null) {
                bringTaskForeground(taskId);
                return;
            } else {
                var appTask = AndroidTaskUtils.getAppTaskFromId(mActivity, taskId);
                if (appTask != null) {
                    appTask.finishAndRemoveTask();
                }
            }
        }
        onMultiInstanceModeStarted();
        // TODO: Pass this flag from UI to control the window to open.
        boolean openAdjacently = true;
        Intent intent =
                MultiWindowUtils.createNewWindowIntent(
                        mActivity,
                        instanceId,
                        /* preferNew= */ false,
                        openAdjacently,
                        /* addTrustedIntentExtras= */ true);
        if (openAdjacently) {
            mActivity.startActivity(
                    intent, mMultiWindowModeStateDispatcher.getOpenInOtherWindowActivityOptions());
        } else {
            mActivity.startActivity(intent);
        }
    }

    /**
     * Launch the given intent in an existing ChromeTabbedActivity instance.
     *
     * @param intent The intent to launch.
     * @param instanceId ID of the instance to launch the intent in.
     */
    static void launchIntentInInstance(Intent intent, int instanceId) {
        Activity activity = getActivityById(instanceId);
        if (!(activity instanceof ChromeTabbedActivity)) return;
        int taskId = activity.getTaskId();
        if (taskId != INVALID_TASK_ID) {
            // Launch the intent in the existing activity and bring the task to foreground if it is
            // alive.
            ((ChromeTabbedActivity) activity).onNewIntent(intent);
            var activityManager =
                    (ActivityManager) activity.getSystemService(Context.ACTIVITY_SERVICE);
            activityManager.moveTaskToFront(taskId, 0);
        }
    }

    /**
     * Close a given task/activity instance.
     * @param instanceId ID of the activity instance.
     * @param taskId ID of the task including the activity.
     */
    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    protected void closeInstance(int instanceId, int taskId) {
        removeInstanceInfo(instanceId);
        TabModelSelector selector =
                TabWindowManagerSingleton.getInstance().getTabModelSelectorById(instanceId);
        if (selector != null) {
            // Close all tabs as the window is closing. This ensures the tabs are added to the
            // recent tabs page.
            //
            // TODO(crbug.com/40826734): This only works for windows with live activities. It is
            // non-trivial to add recent tab entries without an active {@link Tab} instance.
            var filterProvider = selector.getTabModelFilterProvider();
            TabClosureParams params =
                    TabClosureParams.closeAllTabs().uponExit(true).hideTabGroups(true).build();
            ((TabGroupModelFilter) filterProvider.getTabModelFilter(true)).closeTabs(params);
            ((TabGroupModelFilter) filterProvider.getTabModelFilter(false)).closeTabs(params);
        }
        mTabModelOrchestratorSupplier.get().cleanupInstance(instanceId);
        Activity activity = getActivityById(instanceId);
        if (activity != null) activity.finishAndRemoveTask();
    }

    @VisibleForTesting
    void bringTaskForeground(int taskId) {
        ActivityManager am = (ActivityManager) mActivity.getSystemService(Context.ACTIVITY_SERVICE);
        am.moveTaskToFront(taskId, 0);
    }

    @VisibleForTesting
    void setupIntentForReparenting(Tab tab, Intent intent, Runnable finalizeCallback) {
        ReparentingTask.from(tab).setupIntent(mActivity, intent, finalizeCallback);
    }

    @VisibleForTesting
    void beginReparenting(
            Tab tab, Intent intent, Bundle startActivityOptions, Runnable finalizeCallback) {
        ReparentingTask.from(tab).begin(mActivity, intent, startActivityOptions, finalizeCallback);
    }

    private Profile getProfile() {
        return mTabModelOrchestratorSupplier
                .get()
                .getTabModelSelector()
                .getCurrentModel()
                .getProfile();
    }

    @Override
    public void onDestroy() {
        if (mTabModelObserver != null) mTabModelObserver.destroy();
        // This handles a case where an instance is deleted within Chrome but not through
        // Window manager UI, and the task is removed by system. See https://crbug.com/1241719.
        removeInvalidInstanceData(/* cleanupApplicationStatus= */ false);
        if (mInstanceId != INVALID_INSTANCE_ID) {
            ApplicationStatus.unregisterActivityStateListener(this);
        }
        if (sState != null) {
            if (getAllRunningActivities().isEmpty()) {
                sState.clear();
            } else {
                sState.removeObserver(mOnMultiInstanceStateChanged);
            }
        }

        super.onDestroy();
    }

    private static void removeInstanceInfo(int index) {
        SharedPreferencesManager prefs = ChromeSharedPreferences.getInstance();
        prefs.removeKey(urlKey(index));
        prefs.removeKey(titleKey(index));
        prefs.removeKey(tabCountKey(index));
        prefs.removeKey(incognitoTabCountKey(index));
        prefs.removeKey(incognitoSelectedKey(index));
        prefs.removeKey(lastAccessedTimeKey(index));
    }

    @Override
    public void onResumeWithNative() {
        super.onResumeWithNative();
        writeLastAccessedTime(mInstanceId);
    }

    @Override
    public void onActivityStateChange(Activity activity, int newState) {
        if (!MultiWindowUtils.isMultiInstanceApi31Enabled()) return;

        if (newState != ActivityState.RESUMED && newState != ActivityState.STOPPED) return;

        SharedPreferencesManager prefs = ChromeSharedPreferences.getInstance();
        // Check the max instance count in a day for every state update if needed.
        long timestamp = prefs.readLong(ChromePreferenceKeys.MULTI_INSTANCE_MAX_COUNT_TIME, 0);
        int maxCount = prefs.readInt(ChromePreferenceKeys.MULTI_INSTANCE_MAX_INSTANCE_COUNT, 0);
        long current = System.currentTimeMillis();

        if (current - timestamp > DateUtils.DAY_IN_MILLIS) {
            if (timestamp != 0) {
                RecordHistogram.recordExactLinearHistogram(
                        "Android.MultiInstance.MaxInstanceCount", maxCount, mMaxInstances + 1);
            }
            prefs.writeLong(ChromePreferenceKeys.MULTI_INSTANCE_MAX_COUNT_TIME, current);
            // Reset the count to 0 to be ready to obtain the max count for the next 24-hour period.
            maxCount = 0;
        }
        int instanceCount = MultiWindowUtils.getInstanceCount();
        if (instanceCount > maxCount) {
            prefs.writeInt(ChromePreferenceKeys.MULTI_INSTANCE_MAX_INSTANCE_COUNT, instanceCount);
        }
    }

    private void onMultiInstanceStateChanged(boolean inMultiInstanceMode) {
        if (!MultiWindowUtils.isMultiInstanceApi31Enabled()) return;

        SharedPreferencesManager prefs = ChromeSharedPreferences.getInstance();
        long startTime = prefs.readLong(ChromePreferenceKeys.MULTI_INSTANCE_START_TIME);
        long current = System.currentTimeMillis();

        // This method in invoked for every ChromeActivity instance. Logging metrics for the first
        // ChromeActivity is enough. The pref |MULTI_INSTANCE_START_TIME| is set to non-zero once
        // Android.MultiInstance.Enter is logged, and reset to zero after
        // Android.MultiInstance.Exit to avoid duplicated logging.
        if (startTime == 0 && inMultiInstanceMode) {
            RecordUserAction.record("Android.MultiInstance.Enter");
            prefs.writeLong(ChromePreferenceKeys.MULTI_INSTANCE_START_TIME, current);
        } else if (startTime != 0 && !inMultiInstanceMode) {
            RecordUserAction.record("Android.MultiInstance.Exit");
            RecordHistogram.recordLongTimesHistogram(
                    "Android.MultiInstance.TotalDuration", current - startTime);
            prefs.writeLong(ChromePreferenceKeys.MULTI_INSTANCE_START_TIME, 0);
        }
    }

    /**
     * Open a new instance of the ChromeTabbedActivity window and move the specified tab from
     * existing instance to the new one.
     *
     * @param tab Tab that is to be moved to a new Chrome instance.
     */
    @Override
    public void moveTabToNewWindow(Tab tab) {
        // Check if the new Chrome instance can be opened.
        if (MultiWindowUtils.getInstanceCount() < mMaxInstances) {
            moveAndReparentTabToNewWindow(
                    tab,
                    INVALID_INSTANCE_ID,
                    /* preferNew= */ true,
                    /* openAdjacently= */ false,
                    /* addTrustedIntentExtras= */ true);
        } else {
            // Just try to launch a Chrome window to inform user that maximum number of instances
            // limit is exceeded. This will pop up a toast message and the tab will not be removed
            // from the exiting window.
            openNewWindow("Android.WindowManager.NewWindow");
        }
    }

    /**
     * Move the specified tab to the current instance of the ChromeTabbedActivity window.
     *
     * @param activity Activity of the Chrome Window in which the tab is to be moved.
     * @param tab Tab that is to be moved to the current instance.
     * @param atIndex Tab position index in the destination window instance.
     */
    @Override
    public void moveTabToWindow(Activity activity, Tab tab, int atIndex) {
        // Get the current instance and move tab there.
        InstanceInfo info = getInstanceInfoFor(activity);
        if (info != null) {
            moveTabAction(info, tab, atIndex);
        } else {
            Log.w(TAG, "DnD: InstanceInfo of Chrome Window not found.");
        }
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    InstanceInfo getInstanceInfoFor(Activity activity) {
        // Loop thru all instances to determine if the destination activity is present.
        int destinationWindowTaskId = INVALID_TASK_ID;
        for (int i = 0; i < mMaxInstances; ++i) {
            if (!instanceEntryExists(i)) continue;
            Activity activityById = getActivityById(i);
            if (activityById != null) {
                // The task for the activity must match the one found in our mapping.
                assert getTaskFromMap(i) == activityById.getTaskId();
                if (activityById == activity) {
                    destinationWindowTaskId = activityById.getTaskId();
                    break;
                }
            }
        }
        if (destinationWindowTaskId == INVALID_TASK_ID) return null;

        List<InstanceInfo> allInstances = getInstanceInfo();
        for (InstanceInfo instanceInfo : allInstances) {
            if (instanceInfo.taskId == destinationWindowTaskId) {
                return instanceInfo;
            }
        }
        return null;
    }

    /**
     * Determine if a Chrome instance can be closed based on the environment.
     *
     * @param instanceId Instance Id of the Chrome window that needs to be closed.
     */
    private boolean canCloseChromeWindow(int instanceId) {
        // Close the source instance window after tab reparenting if permitted by the feature flag
        // or if the app is in a desktop window, and the source instance is known.
        if (instanceId == INVALID_INSTANCE_ID) return false;

        return TabUiFeatureUtilities.isTabDragAsWindowEnabled()
                || AppHeaderUtils.isAppInDesktopWindow(mDesktopWindowStateProviderSupplier.get());
    }

    /**
     * Close a Chrome window instance only if it contains no open tabs including incognito ones.
     *
     * @param instanceId Instance id of the Chrome window that needs to be closed.
     * @return {@code true} if the window was closed, {@code false} otherwise.
     */
    @Override
    public boolean closeChromeWindowIfEmpty(int instanceId) {
        if (canCloseChromeWindow(instanceId)) {
            TabModelSelector selector =
                    TabWindowManagerSingleton.getInstance().getTabModelSelectorById(instanceId);
            // Determine if the drag source Chrome instance window has any tabs including incognito
            // ones left so as to close if it is empty.
            if (selector.getTotalTabCount() == 0) {
                Log.i(TAG, "Closing empty Chrome instance as no tabs exist.");
                closeInstance(instanceId, INVALID_TASK_ID);
                return true;
            }
        }
        return false;
    }

    /**
     * This method makes a call out to sync to audit all of the tab groups if there is only one
     * remaining active Chrome instance. This is a workaround to the fact that closing an instance
     * that does not have an active {@link TabModelSelector} will never notify sync that the tabs it
     * contained were closed and as such sync will continue to think some inactive instance contains
     * the tab groups that aren't available in the current activity. If we get down to a single
     * instance of Chrome we know any data for tab groups not found in the current activity's {@link
     * TabModelSelector} must be closed and we can remove the sync mapping.
     */
    @VisibleForTesting
    void cleanupSyncedTabGroupsIfLastInstance() {
        List<InstanceInfo> info = getInstanceInfo();
        if (info.size() != 1) return;

        TabModelSelector selector =
                TabWindowManagerSingleton.getInstance()
                        .getTabModelSelectorById(info.get(0).instanceId);
        assert selector != null;

        TabGroupModelFilter filter =
                (TabGroupModelFilter) selector.getTabModelFilterProvider().getTabModelFilter(false);

        Profile profile = filter.getTabModel().getProfile();
        if (!TabGroupSyncFeatures.isTabGroupSyncEnabled(profile)) return;

        TabGroupSyncService tabGroupSyncService = TabGroupSyncServiceFactory.getForProfile(profile);
        TabGroupSyncUtils.unmapLocalIdsNotInTabGroupModelFilter(tabGroupSyncService, filter);
    }
}