// Copyright 2019 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.annotation.SuppressLint;
import android.app.Activity;
import android.app.ActivityManager;
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
import android.hardware.display.DisplayManager;
import android.hardware.display.DisplayManager.DisplayListener;
import android.util.Pair;
import android.view.Display;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import org.chromium.base.ActivityState;
import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.CommandLine;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.browser.ChromeTabbedActivity;
import org.chromium.chrome.browser.app.tab_activity_glue.ReparentingTask;
import org.chromium.chrome.browser.app.tabmodel.TabModelOrchestrator;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.lifecycle.ActivityLifecycleDispatcher;
import org.chromium.chrome.browser.lifecycle.ConfigurationChangedObserver;
import org.chromium.chrome.browser.lifecycle.DestroyObserver;
import org.chromium.chrome.browser.lifecycle.NativeInitObserver;
import org.chromium.chrome.browser.lifecycle.PauseResumeWithNativeObserver;
import org.chromium.chrome.browser.lifecycle.RecreateObserver;
import org.chromium.chrome.browser.multiwindow.MultiWindowUtils.InstanceAllocationType;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.tabmodel.TabModelSelectorTabModelObserver;
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.ui.display.DisplayAndroidManager;
import org.chromium.ui.modaldialog.ModalDialogManager;
import java.util.Collections;
import java.util.List;
/**
* Manages multi-instance mode for an associated activity. After construction, call {@link
* #isStartedUpCorrectly(int)} to validate that the owning Activity should be allowed to finish
* starting up.
*/
public class MultiInstanceManager
implements PauseResumeWithNativeObserver,
RecreateObserver,
ConfigurationChangedObserver,
NativeInitObserver,
MultiWindowModeStateDispatcher.MultiWindowModeObserver,
DestroyObserver,
MenuOrKeyboardActionController.MenuOrKeyboardActionHandler {
/** Should be called when multi-instance mode is started. */
public static void onMultiInstanceModeStarted() {
// When a second instance is created, the merged instance task id should be cleared.
setMergedInstanceTaskId(0);
}
/** The task id of the activity that tabs were merged into. */
private static int sMergedInstanceTaskId;
/** The class of the activity will do merge on start up. */
private static Class sActivityTypePendingMergeOnStartup;
private Boolean mMergeTabsOnResume;
/**
* Used to observe state changes to a different ChromeTabbedActivity instances to determine
* when to merge tabs if applicable.
*/
private ApplicationStatus.ActivityStateListener mOtherCTAStateObserver;
protected final Activity mActivity;
protected final ObservableSupplier<TabModelOrchestrator> mTabModelOrchestratorSupplier;
protected final MultiWindowModeStateDispatcher mMultiWindowModeStateDispatcher;
private final ActivityLifecycleDispatcher mActivityLifecycleDispatcher;
private final MenuOrKeyboardActionController mMenuOrKeyboardActionController;
protected TabModelSelectorTabModelObserver mTabModelObserver;
private int mActivityTaskId;
private boolean mNativeInitialized;
private DisplayManager.DisplayListener mDisplayListener;
private boolean mShouldMergeOnConfigurationChange;
private boolean mIsRecreating;
private int mDisplayId;
private static List<Integer> sTestDisplayIds;
private boolean mDestroyed;
/**
* Create a new {@link MultiInstanceManager}.
*
* @param activity The activity.
* @param tabModelOrchestratorSupplier A supplier for the {@link TabModelOrchestrator} for the
* associated activity.
* @param multiWindowModeStateDispatcher The {@link MultiWindowModeStateDispatcher} for the
* associated activity.
* @param activityLifecycleDispatcher The {@link ActivityLifecycleDispatcher} for the associated
* activity.
* @param modalDialogManagerSupplier A supplier for the {@link ModalDialogManager}.
* @param menuOrKeyboardActionController The {@link MenuOrKeyboardActionController} for the
* associated activity.
* @param desktopWindowStateProviderSupplier A supplier for the {@link
* DesktopWindowStateProvider} instance.
* @return {@link MultiInstanceManager} object or {@code null} on the platform it is not needed.
*/
public @Nullable static MultiInstanceManager create(
Activity activity,
ObservableSupplier<TabModelOrchestrator> tabModelOrchestratorSupplier,
MultiWindowModeStateDispatcher multiWindowModeStateDispatcher,
ActivityLifecycleDispatcher activityLifecycleDispatcher,
ObservableSupplier<ModalDialogManager> modalDialogManagerSupplier,
MenuOrKeyboardActionController menuOrKeyboardActionController,
Supplier<DesktopWindowStateProvider> desktopWindowStateProviderSupplier) {
if (MultiWindowUtils.isMultiInstanceApi31Enabled()) {
return new MultiInstanceManagerApi31(
activity,
tabModelOrchestratorSupplier,
multiWindowModeStateDispatcher,
activityLifecycleDispatcher,
modalDialogManagerSupplier,
menuOrKeyboardActionController,
desktopWindowStateProviderSupplier);
} else {
return new MultiInstanceManager(
activity,
tabModelOrchestratorSupplier,
multiWindowModeStateDispatcher,
activityLifecycleDispatcher,
menuOrKeyboardActionController);
}
}
protected MultiInstanceManager(
Activity activity,
ObservableSupplier<TabModelOrchestrator> tabModelOrchestratorSupplier,
MultiWindowModeStateDispatcher multiWindowModeStateDispatcher,
ActivityLifecycleDispatcher activityLifecycleDispatcher,
MenuOrKeyboardActionController menuOrKeyboardActionController) {
mActivity = activity;
mTabModelOrchestratorSupplier = tabModelOrchestratorSupplier;
mMultiWindowModeStateDispatcher = multiWindowModeStateDispatcher;
mMultiWindowModeStateDispatcher.addObserver(this);
mActivityLifecycleDispatcher = activityLifecycleDispatcher;
mActivityLifecycleDispatcher.register(this);
mMenuOrKeyboardActionController = menuOrKeyboardActionController;
mMenuOrKeyboardActionController.registerMenuOrKeyboardActionHandler(this);
}
@Override
public void onDestroy() {
mDestroyed = true;
mMultiWindowModeStateDispatcher.removeObserver(this);
mMenuOrKeyboardActionController.unregisterMenuOrKeyboardActionHandler(this);
mActivityLifecycleDispatcher.unregister(this);
removeOtherCTAStateObserver();
DisplayManager displayManager =
(DisplayManager) mActivity.getSystemService(Context.DISPLAY_SERVICE);
if (displayManager != null && mDisplayListener != null) {
displayManager.unregisterDisplayListener(mDisplayListener);
}
}
/**
* Called during activity startup to check whether the activity is recreated because
* the secondary display is removed.
*
* @return True if the activity is recreated after a display is removed. Should consider
* merging tabs.
*/
public static boolean shouldMergeOnStartup(Activity activity) {
return sActivityTypePendingMergeOnStartup != null
&& sActivityTypePendingMergeOnStartup.equals(activity.getClass());
}
/**
* Called after {@link #shouldMergeOnStartup(Activity)} to indicate merge has started,
* so there is no merge on following recreate.
*/
public static void mergedOnStartup() {
sActivityTypePendingMergeOnStartup = null;
}
/**
* Called during activity startup to check whether this instance of the MultiInstanceManager
* is associated with an activity task ID that should be started up.
*
* @return True if the activity should proceed with startup. False otherwise.
*/
public boolean isStartedUpCorrectly(int activityTaskId) {
mActivityTaskId = activityTaskId;
// If tabs from this instance were merged into a different ChromeTabbedActivity instance
// and the other instance is still running, then this instance should not be created. This
// may happen if the process is restarted e.g. on upgrade or from about://flags.
// See crbug.com/657418
boolean tabsMergedIntoAnotherInstance =
sMergedInstanceTaskId != 0 && sMergedInstanceTaskId != mActivityTaskId;
// Since a static is used to track the merged instance task id, it is possible that
// sMergedInstanceTaskId is still set even though the associated task is not running.
boolean mergedInstanceTaskStillRunning = isMergedInstanceTaskRunning();
if (tabsMergedIntoAnotherInstance && mergedInstanceTaskStillRunning) {
// Currently only two instances of ChromeTabbedActivity may be running at any given
// time. If tabs were merged into another instance and this instance is being killed due
// to incorrect startup, then no other instances should exist. Reset the merged instance
// task id.
setMergedInstanceTaskId(0);
return false;
} else if (!mergedInstanceTaskStillRunning) {
setMergedInstanceTaskId(0);
}
return true;
}
@Override
public void onFinishNativeInitialization() {
mNativeInitialized = true;
DisplayManager displayManager =
(DisplayManager) mActivity.getSystemService(Context.DISPLAY_SERVICE);
if (displayManager == null) return;
Display display = DisplayAndroidManager.getDefaultDisplayForContext(mActivity);
mDisplayId = display.getDisplayId();
mDisplayListener =
new DisplayListener() {
@Override
public void onDisplayAdded(int displayId) {
if (!isNormalDisplay(displayId)) return;
sActivityTypePendingMergeOnStartup = null;
}
@Override
public void onDisplayRemoved(int displayId) {
if (!isNormalDisplay(displayId)) return;
if (displayId == mDisplayId) {
// If activity on removed display is in the foreground, do tab merge.
// Note that activity on removed display may be recreated because of the
// change of the dpi. If it is going to recreate, then CTA will merge on
// start up; otherwise, calling maybeMergeTabs() can merge tabs.
if (mActivityLifecycleDispatcher.getCurrentActivityState()
== ActivityLifecycleDispatcher.ActivityState
.RESUMED_WITH_NATIVE) {
// wait to merge until onConfigurationChanged so that we can know
// whether the activity is going to recreate.
mShouldMergeOnConfigurationChange = true;
}
} else {
// Otherwise, activity on the remaining display does tab merge.
Activity cta = getOtherResumedCTA();
if (cta == null) {
maybeMergeTabs();
}
}
}
@Override
public void onDisplayChanged(int displayId) {
if (displayId == mDisplayId || !isNormalDisplay(displayId)) return;
List<Integer> ids =
sTestDisplayIds != null
? sTestDisplayIds
: ApiCompatibilityUtils.getTargetableDisplayIds(mActivity);
if (ids.size() == 1 && ids.get(0).equals(mDisplayId)) {
maybeMergeTabs();
}
}
};
displayManager.registerDisplayListener(mDisplayListener, null);
}
/**
* Check if the given display is what Chrome can use for showing activity/tab.
* It should be either the default display, or secondary one such as external,
* wireless display.
* @param id ID of the display.
* @return {@code true} if the display is a normal one.
*/
private boolean isNormalDisplay(int id) {
if (id == Display.DEFAULT_DISPLAY || sTestDisplayIds != null) return true;
Display display = getDisplayFromId(id);
return (display != null && (display.getFlags() & Display.FLAG_PRESENTATION) != 0);
}
private @Nullable Display getDisplayFromId(int id) {
DisplayManager displayManager =
(DisplayManager) mActivity.getSystemService(Context.DISPLAY_SERVICE);
if (displayManager == null) return null;
Display[] displays = displayManager.getDisplays();
for (Display display : displays) {
if (display.getDisplayId() == id) return display;
}
return null;
}
@Override
public void onResumeWithNative() {
if (isTabModelMergingEnabled()) {
boolean inMultiWindowMode =
mMultiWindowModeStateDispatcher.isInMultiWindowMode()
|| mMultiWindowModeStateDispatcher.isInMultiDisplayMode();
// Don't need to merge tabs when mMergeTabsOnResume is null (cold start) since they get
// merged when TabPersistentStore.loadState(boolean) is called from initializeState().
if (!inMultiWindowMode && (mMergeTabsOnResume != null && mMergeTabsOnResume)) {
maybeMergeTabs();
} else if (!inMultiWindowMode && mMergeTabsOnResume == null) {
// This happens on cold start to kill any second activity that might exist.
killOtherTask();
}
mMergeTabsOnResume = false;
}
}
@Override
public void onPauseWithNative() {
removeOtherCTAStateObserver();
}
@Override
public void onMultiWindowModeChanged(boolean isInMultiWindowMode) {
if (!isTabModelMergingEnabled() || !mNativeInitialized) {
return;
}
if (!isInMultiWindowMode) {
// If the activity is currently resumed when multi-window mode is exited, try to merge
// tabs from the other activity instance.
if (mActivityLifecycleDispatcher.getCurrentActivityState()
== ActivityLifecycleDispatcher.ActivityState.RESUMED_WITH_NATIVE) {
ChromeTabbedActivity otherResumedCTA = getOtherResumedCTA();
if (otherResumedCTA == null) {
maybeMergeTabs();
} else {
// Remove the other CTA state observer if one already exists to protect
// against multiple #onMultiWindowModeChanged calls.
// See https://crbug.com/1385987.
removeOtherCTAStateObserver();
// Wait for the other ChromeTabbedActivity to pause before trying to merge
// tabs.
mOtherCTAStateObserver =
(activity, newState) -> {
if (newState == ActivityState.PAUSED) {
removeOtherCTAStateObserver();
maybeMergeTabs();
}
};
ApplicationStatus.registerStateListenerForActivity(
mOtherCTAStateObserver, otherResumedCTA);
}
} else {
mMergeTabsOnResume = true;
}
}
}
@Override
public void onRecreate() {
mIsRecreating = true;
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
// Prepare for merging tabs: do tab merge now if the activity is not going to recreate;
// otherwise, do it on start up.
if (mShouldMergeOnConfigurationChange) {
if (mIsRecreating) {
sActivityTypePendingMergeOnStartup = mActivity.getClass();
} else {
sActivityTypePendingMergeOnStartup = null;
maybeMergeTabs();
}
mShouldMergeOnConfigurationChange = false;
}
}
@Override
public boolean handleMenuOrKeyboardAction(int id, boolean fromMenu) {
if (id == org.chromium.chrome.R.id.move_to_other_window_menu_id) {
TabModelOrchestrator tabModelOrchestrator = mTabModelOrchestratorSupplier.get();
if (tabModelOrchestrator == null) return true;
TabModelSelector tabModelSelector = tabModelOrchestrator.getTabModelSelector();
if (tabModelSelector == null) return true;
Tab currentTab = tabModelSelector.getCurrentTab();
if (currentTab != null) moveTabToOtherWindow(currentTab);
return true;
} else if (id == org.chromium.chrome.R.id.new_window_menu_id) {
openNewWindow("MobileMenuNewWindow");
return true;
}
return false;
}
private @Nullable ChromeTabbedActivity getOtherResumedCTA() {
Class<?> otherWindowActivityClass =
mMultiWindowModeStateDispatcher.getOpenInOtherWindowActivity();
for (Activity activity : ApplicationStatus.getRunningActivities()) {
if (activity.getClass().equals(otherWindowActivityClass)
&& ApplicationStatus.getStateForActivity(activity) == ActivityState.RESUMED) {
return (ChromeTabbedActivity) activity;
}
}
return null;
}
private void removeOtherCTAStateObserver() {
if (mOtherCTAStateObserver != null) {
ApplicationStatus.unregisterActivityStateListener(mOtherCTAStateObserver);
mOtherCTAStateObserver = null;
}
}
private void killOtherTask() {
if (!isTabModelMergingEnabled()) return;
Class<?> otherWindowActivityClass =
mMultiWindowModeStateDispatcher.getOpenInOtherWindowActivity();
// 1. Find the other activity's task if it's still running so that it can be removed from
// Android recents.
ActivityManager activityManager =
(ActivityManager) mActivity.getSystemService(Context.ACTIVITY_SERVICE);
List<ActivityManager.AppTask> appTasks = activityManager.getAppTasks();
ActivityManager.AppTask otherActivityTask = null;
for (ActivityManager.AppTask task : appTasks) {
String baseActivity = MultiWindowUtils.getActivityNameFromTask(task);
if (baseActivity.equals(otherWindowActivityClass.getName())) {
otherActivityTask = task;
}
}
if (otherActivityTask != null) {
for (Activity activity : ApplicationStatus.getRunningActivities()) {
// 2. If the other activity is still running (not destroyed), save its tab list.
// Saving the tab list prevents missing tabs or duplicate tabs if tabs have been
// reparented.
// TODO(twellington): saveState() gets called in onStopWithNative() after the merge
// starts, causing some duplicate work to be done. Avoid the redundancy.
if (activity.getClass().equals(otherWindowActivityClass)) {
((ChromeTabbedActivity) activity).saveState();
break;
}
}
// 3. Kill the other activity's task to remove it from Android recents.
otherActivityTask.finishAndRemoveTask();
}
setMergedInstanceTaskId(mActivityTaskId);
}
/**
* Merges tabs from a second ChromeTabbedActivity instance if necessary and calls
* finishAndRemoveTask() on the other activity.
*/
@VisibleForTesting
public void maybeMergeTabs() {
assert !mDestroyed;
if (!isTabModelMergingEnabled() || mDestroyed) return;
killOtherTask();
RecordUserAction.record("Android.MergeState.Live");
mTabModelOrchestratorSupplier.get().mergeState();
}
private static void setMergedInstanceTaskId(int mergedInstanceTaskId) {
sMergedInstanceTaskId = mergedInstanceTaskId;
}
@SuppressLint("NewApi")
private boolean isMergedInstanceTaskRunning() {
if (!isTabModelMergingEnabled() || sMergedInstanceTaskId == 0) {
return false;
}
ActivityManager manager =
(ActivityManager) mActivity.getSystemService(Context.ACTIVITY_SERVICE);
for (ActivityManager.AppTask task : manager.getAppTasks()) {
ActivityManager.RecentTaskInfo info = AndroidTaskUtils.getTaskInfoFromTask(task);
if (info == null) continue;
if (info.id == sMergedInstanceTaskId) return true;
}
return false;
}
public void moveTabToNewWindow(Tab tab) {
// Not implemented
}
public void moveTabToWindow(Activity activity, Tab tab, int atIndex) {
// Not implemented
}
protected void moveTabToOtherWindow(Tab tab) {
Intent intent = mMultiWindowModeStateDispatcher.getOpenInOtherWindowIntent();
if (intent == null) return;
onMultiInstanceModeStarted();
ReparentingTask.from(tab)
.begin(
mActivity,
intent,
mMultiWindowModeStateDispatcher.getOpenInOtherWindowActivityOptions(),
null);
RecordUserAction.record("MobileMenuMoveToOtherWindow");
}
protected void openNewWindow(String umaAction) {
assert mMultiWindowModeStateDispatcher.canEnterMultiWindowMode()
|| mMultiWindowModeStateDispatcher.isInMultiWindowMode()
|| mMultiWindowModeStateDispatcher.isInMultiDisplayMode();
Intent intent = mMultiWindowModeStateDispatcher.getOpenInOtherWindowIntent();
if (intent == null) return;
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.addFlags(Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT);
onMultiInstanceModeStarted();
mActivity.startActivity(
intent, mMultiWindowModeStateDispatcher.getOpenInOtherWindowActivityOptions());
RecordUserAction.record(umaAction);
}
/**
* @return List of {@link InstanceInfo} structs for an activity that can be switched to, or
* newly launched.
*/
public List<InstanceInfo> getInstanceInfo() {
return Collections.emptyList();
}
/**
* Assigned an ID for the current activity instance.
*
* @param windowId Instance ID explicitly given for assignment.
* @param taskId Task ID of the activity.
* @param preferNew Boolean indicating a fresh new instance is preferred over the one that will
* load previous tab files from disk.
*/
public Pair<Integer, Integer> allocInstanceId(int windowId, int taskId, boolean preferNew) {
return Pair.create(0, InstanceAllocationType.DEFAULT); // Use a default index 0.
}
/**
* Initialize the manager with the allocated instance ID.
* @param instanceId Instance ID of the activity.
* @param taskId Task ID of the activity.
*/
public void initialize(int instanceId, int taskId) {}
/** Perform initialization tasks for the manager after the tab state is initialized. */
public void onTabStateInitialized() {}
/**
* @return True if tab model merging for Android N+ is enabled.
*/
public boolean isTabModelMergingEnabled() {
return !CommandLine.getInstance().hasSwitch(ChromeSwitches.DISABLE_TAB_MERGING_FOR_TESTING);
}
public void setCurrentDisplayIdForTesting(int displayId) {
var oldValue = mDisplayId;
mDisplayId = displayId;
ResettersForTesting.register(() -> mDisplayId = oldValue);
}
public DisplayManager.DisplayListener getDisplayListenerForTesting() {
return mDisplayListener;
}
@VisibleForTesting
public static void setTestDisplayIds(List<Integer> testDisplayIds) {
sTestDisplayIds = testDisplayIds;
}
public TabModelSelectorTabModelObserver getTabModelObserverForTesting() {
return mTabModelObserver;
}
public void setTabModelObserverForTesting(TabModelSelectorTabModelObserver tabModelObserver) {
mTabModelObserver = tabModelObserver;
}
/**
* @return InstanceId for current instance.
*/
public int getCurrentInstanceId() {
return MultiWindowUtils.INVALID_INSTANCE_ID;
}
/**
* 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.
*/
public boolean closeChromeWindowIfEmpty(int instanceId) {
return false;
}
}