Fix app pairs launch from in-app Taskbar
This CL adds a handler function in SplitAnimationController that manages the complicated logic flow for launching an app pair when already inside of an app.
To give an idea of the complicated logic:
If the user tapped on an app pair while already in an app pair, there are 4 general cases:
a) Clicked app pair A|B, but both apps are already running on screen
b) App A is already on-screen, but App B isn't
c) App B is on-screen, but App A isn't
d) Neither is on-screen
If the user tapped an app pair while inside a single app, there are 3 cases:
a) The on-screen app is App A of the app pair
b) The on-screen app is App B of the app pair
c) It is neither
For each case, we call a different animation and launch the app pair in a different way.
When merged, this patch will fix all animation glitches that are currently happening in these situations, and get us 90% of the way to having the ideal animation in all cases. There are still a few complicated cases that need a polished animation (like when you launch app pairs with custom ratios), which will be implemented in a following patch soon (I thought this CL was big enough already as is).
Bug: 316485863
Fixes: 315190686
Test: Manual testing of all the different launch combinations
Flag: ACONFIG com.android.wm.shell.enable_app_pairs TEAMFOOD
Change-Id: I5c0e03512bb706360c575d833cac6ed02a5de936
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
index 3f5793f..55deca8 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
@@ -1063,7 +1063,7 @@
Toast.LENGTH_SHORT).show();
} else {
// Else launch the selected app pair
- launchFromTaskbarPreservingSplitIfVisible(recents, view, fi.contents);
+ launchFromTaskbar(recents, view, fi.contents);
mControllers.uiController.onTaskbarIconLaunched(fi);
mControllers.taskbarStashController.updateAndAnimateTransientTaskbar(true);
}
@@ -1097,8 +1097,7 @@
getSystemService(LauncherApps.class)
.startShortcut(packageName, id, null, null, info.user);
} else {
- launchFromTaskbarPreservingSplitIfVisible(
- recents, view, Collections.singletonList(info));
+ launchFromTaskbar(recents, view, Collections.singletonList(info));
}
} catch (NullPointerException
@@ -1136,8 +1135,7 @@
// If we are selecting a second app for split, launch the split tasks
taskbarUIController.triggerSecondAppForSplit(info, info.intent, view);
} else {
- launchFromTaskbarPreservingSplitIfVisible(
- recents, view, Collections.singletonList(info));
+ launchFromTaskbar(recents, view, Collections.singletonList(info));
}
mControllers.uiController.onTaskbarIconLaunched(info);
mControllers.taskbarStashController.updateAndAnimateTransientTaskbar(true);
@@ -1153,12 +1151,48 @@
}
/**
+ * Runs when the user taps a Taskbar icon in TaskbarActivityContext (Overview or inside an app),
+ * and calls the appropriate method to animate and launch.
+ */
+ private void launchFromTaskbar(@Nullable RecentsView recents, @Nullable View launchingIconView,
+ List<? extends ItemInfo> itemInfos) {
+ if (isInApp()) {
+ launchFromInAppTaskbar(recents, launchingIconView, itemInfos);
+ } else {
+ launchFromOverviewTaskbar(recents, launchingIconView, itemInfos);
+ }
+ }
+
+ /**
+ * Runs when the user taps a Taskbar icon while inside an app.
+ */
+ private void launchFromInAppTaskbar(@Nullable RecentsView recents,
+ @Nullable View launchingIconView, List<? extends ItemInfo> itemInfos) {
+ if (recents == null) {
+ return;
+ }
+
+ boolean tappedAppPair = itemInfos.size() == 2;
+
+ if (tappedAppPair) {
+ // If the icon is an app pair, the logic gets a bit complicated because we play
+ // different animations depending on which app (or app pair) is currently running on
+ // screen, so delegate logic to appPairsController.
+ recents.getSplitSelectController().getAppPairsController()
+ .handleAppPairLaunchInApp((AppPairIcon) launchingIconView, itemInfos);
+ } else {
+ // Tapped a single app, nothing complicated here.
+ startItemInfoActivity(itemInfos.get(0));
+ }
+ }
+
+ /**
* Run when the user taps a Taskbar icon while in Overview. If the tapped app is currently
* visible to the user in Overview, or is part of a visible split pair, we expand the TaskView
* as if the user tapped on it (preserving the split pair). Otherwise, launch it normally
* (potentially breaking a split pair).
*/
- private void launchFromTaskbarPreservingSplitIfVisible(@Nullable RecentsView recents,
+ private void launchFromOverviewTaskbar(@Nullable RecentsView recents,
@Nullable View launchingIconView, List<? extends ItemInfo> itemInfos) {
if (recents == null) {
return;
diff --git a/quickstep/src/com/android/quickstep/util/AppPairsController.java b/quickstep/src/com/android/quickstep/util/AppPairsController.java
index 839320e..8f9395f 100644
--- a/quickstep/src/com/android/quickstep/util/AppPairsController.java
+++ b/quickstep/src/com/android/quickstep/util/AppPairsController.java
@@ -17,17 +17,22 @@
package com.android.quickstep.util;
+import static android.app.ActivityTaskManager.INVALID_TASK_ID;
+
import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_APP_PAIR_LAUNCH;
import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
import static com.android.launcher3.util.Executors.MODEL_EXECUTOR;
+import static com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_BOTTOM_OR_RIGHT;
+import static com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_TOP_OR_LEFT;
import static com.android.wm.shell.common.split.SplitScreenConstants.SPLIT_POSITION_BOTTOM_OR_RIGHT;
import static com.android.wm.shell.common.split.SplitScreenConstants.SPLIT_POSITION_TOP_OR_LEFT;
import static com.android.wm.shell.common.split.SplitScreenConstants.isPersistentSnapPosition;
-import android.app.ActivityTaskManager;
import android.content.Context;
import android.content.Intent;
+import android.content.pm.LauncherApps;
import android.util.Log;
+import android.util.Pair;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
@@ -39,16 +44,23 @@
import com.android.launcher3.accessibility.LauncherAccessibilityDelegate;
import com.android.launcher3.apppairs.AppPairIcon;
import com.android.launcher3.icons.IconCache;
+import com.android.launcher3.logging.InstanceId;
import com.android.launcher3.logging.StatsLogManager;
import com.android.launcher3.model.data.FolderInfo;
+import com.android.launcher3.model.data.ItemInfo;
import com.android.launcher3.model.data.WorkspaceItemInfo;
+import com.android.launcher3.taskbar.TaskbarActivityContext;
import com.android.launcher3.util.ComponentKey;
+import com.android.launcher3.util.SplitConfigurationOptions.StagePosition;
+import com.android.quickstep.SystemUiProxy;
+import com.android.quickstep.TopTaskTracker;
import com.android.quickstep.views.GroupedTaskView;
import com.android.quickstep.views.TaskView;
import com.android.systemui.shared.recents.model.Task;
import com.android.wm.shell.common.split.SplitScreenConstants.PersistentSnapPosition;
import java.util.Arrays;
+import java.util.List;
/**
* Controller class that handles app pair interactions: saving, modifying, deleting, etc.
@@ -150,7 +162,7 @@
task1Id = foundTask1.key.id;
task1Intent = null;
} else {
- task1Id = ActivityTaskManager.INVALID_TASK_ID;
+ task1Id = INVALID_TASK_ID;
task1Intent = app1.intent;
}
@@ -177,6 +189,170 @@
}
/**
+ * Handles the complicated logic for how to animate an app pair entrance when already inside an
+ * app or app pair.
+ *
+ * If the user tapped on an app pair while already in an app pair, there are 4 general cases:
+ * a) Clicked app pair A|B, but both apps are already running on screen.
+ * b) App A is already on-screen, but App B isn't.
+ * c) App B is on-screen, but App A isn't.
+ * d) Neither is on-screen.
+ *
+ * If the user tapped an app pair while inside a single app, there are 3 cases:
+ * a) The on-screen app is App A of the app pair.
+ * b) The on-screen app is App B of the app pair.
+ * c) It is neither.
+ *
+ * For each case, we call the appropriate animation and split launch type.
+ */
+ public void handleAppPairLaunchInApp(AppPairIcon launchingIconView,
+ List<? extends ItemInfo> itemInfos) {
+ TaskbarActivityContext context = (TaskbarActivityContext) launchingIconView.getContext();
+ List<ComponentKey> componentKeys =
+ itemInfos.stream().map(ItemInfo::getComponentKey).toList();
+
+ // Use TopTaskTracker to find the currently running app (or apps)
+ TopTaskTracker topTaskTracker = getTopTaskTracker(context);
+
+ // getRunningSplitTasksIds() will return a pair of ids if we are currently running a
+ // split pair, or an empty array with zero length if we are running a single app.
+ int[] runningSplitTasks = topTaskTracker.getRunningSplitTaskIds();
+ if (runningSplitTasks != null && runningSplitTasks.length == 2) {
+ // Tapped an app pair while in an app pair
+ int runningTaskId1 = runningSplitTasks[0];
+ int runningTaskId2 = runningSplitTasks[1];
+
+ mSplitSelectStateController.findLastActiveTasksAndRunCallback(
+ componentKeys,
+ false /* findExactPairMatch */,
+ foundTasks -> {
+ // If our clicked app pair has already-running Tasks, we grab the
+ // taskIds here so we can see if those ids are already on-screen now
+ List<Integer> lastActiveTasksOfAppPair =
+ Arrays.stream(foundTasks).map((Task task) -> {
+ if (task != null) {
+ return task.getKey().getId();
+ } else {
+ return INVALID_TASK_ID;
+ }
+ }).toList();
+
+ if (lastActiveTasksOfAppPair.contains(runningTaskId1)
+ && lastActiveTasksOfAppPair.contains(runningTaskId2)) {
+ // App A and App B are already on-screen, so do nothing.
+ } else if (!lastActiveTasksOfAppPair.contains(runningTaskId1)
+ && !lastActiveTasksOfAppPair.contains(runningTaskId2)) {
+ // Neither A nor B are on screen, so just launch a new app pair
+ // normally.
+ launchAppPair(launchingIconView);
+ } else {
+ // Exactly one app (A or B) is on-screen, so we have to launch the other
+ // on the appropriate side.
+ ItemInfo app1 = itemInfos.get(0);
+ ItemInfo app2 = itemInfos.get(1);
+ int task1 = lastActiveTasksOfAppPair.get(0);
+ int task2 = lastActiveTasksOfAppPair.get(1);
+
+ // If task1 is one of the running on-screen tasks, we launch app2.
+ // If not, task2 must be the running task, and we launch app1.
+ ItemInfo appToLaunch =
+ task1 == runningTaskId1 || task1 == runningTaskId2
+ ? app2
+ : app1;
+ // If the on-screen task is on the bottom/right position, we launch to
+ // the top/left. If not, we launch to the bottom/right.
+ @StagePosition int sideToLaunch =
+ task1 == runningTaskId2 || task2 == runningTaskId2
+ ? STAGE_POSITION_TOP_OR_LEFT
+ : STAGE_POSITION_BOTTOM_OR_RIGHT;
+
+ launchToSide(context, launchingIconView.getInfo(), appToLaunch,
+ sideToLaunch);
+ }
+ }
+ );
+ } else {
+ // Tapped an app pair while in a single app
+ int runningTaskId = topTaskTracker
+ .getCachedTopTask(false /* filterOnlyVisibleRecents */).getTaskId();
+
+ mSplitSelectStateController.findLastActiveTasksAndRunCallback(
+ componentKeys,
+ false /* findExactPairMatch */,
+ foundTasks -> {
+ Task foundTask1 = foundTasks[0];
+ Task foundTask2 = foundTasks[1];
+ boolean task1IsOnScreen =
+ foundTask1 != null && foundTask1.getKey().getId() == runningTaskId;
+ boolean task2IsOnScreen =
+ foundTask2 != null && foundTask2.getKey().getId() == runningTaskId;
+
+ if (!task1IsOnScreen && !task2IsOnScreen) {
+ // Neither App A nor App B are on-screen, launch the app pair normally.
+ launchAppPair(launchingIconView);
+ } else {
+ // Either A or B is on-screen, so launch the other on the appropriate
+ // side.
+ ItemInfo app1 = itemInfos.get(0);
+ ItemInfo app2 = itemInfos.get(1);
+ // If task1 is the running on-screen task, we launch app2 on the
+ // bottom/right. If task2 is on-screen, launch app1 on the top/left.
+ ItemInfo appToLaunch = task1IsOnScreen ? app2 : app1;
+ @StagePosition int sideToLaunch = task1IsOnScreen
+ ? STAGE_POSITION_BOTTOM_OR_RIGHT
+ : STAGE_POSITION_TOP_OR_LEFT;
+
+ launchToSide(context, launchingIconView.getInfo(), appToLaunch,
+ sideToLaunch);
+ }
+ }
+ );
+ }
+ }
+
+ /**
+ * Executes a split launch by launching an app to the side of an existing app.
+ * @param context The TaskbarActivityContext that we are launching the app pair from.
+ * @param launchingItemInfo The itemInfo of the icon that was tapped.
+ * @param app The app that will launch to the side of the existing running app (not necessarily
+ * the same as the previous parameter; e.g. we tap an app pair but launch an app).
+ * @param side A @StagePosition, either STAGE_POSITION_TOP_OR_LEFT or
+ * STAGE_POSITION_BOTTOM_OR_RIGHT.
+ */
+ @VisibleForTesting
+ public void launchToSide(
+ TaskbarActivityContext context,
+ ItemInfo launchingItemInfo,
+ ItemInfo app,
+ @StagePosition int side
+ ) {
+ LauncherApps launcherApps = context.getSystemService(LauncherApps.class);
+
+ // Set up to log app pair launch event
+ Pair<com.android.internal.logging.InstanceId, InstanceId> instanceIds =
+ LogUtils.getShellShareableInstanceId();
+ context.getStatsLogManager()
+ .logger()
+ .withItemInfo(launchingItemInfo)
+ .withInstanceId(instanceIds.second)
+ .log(LAUNCHER_APP_PAIR_LAUNCH);
+
+ SystemUiProxy.INSTANCE.get(context)
+ .startIntent(
+ launcherApps.getMainActivityLaunchIntent(
+ app.getIntent().getComponent(),
+ null,
+ app.user
+ ),
+ app.user.getIdentifier(),
+ new Intent(),
+ side,
+ null,
+ instanceIds.first
+ );
+ }
+
+ /**
* App pair members have a "rank" attribute that contains information about the split position
* and ratio. We implement this by splitting the int in half (e.g. 16 bits each), then use one
* half to store splitPosition (left vs right) and the other half to store snapPosition
@@ -209,4 +385,12 @@
public String getDefaultTitle(CharSequence app1, CharSequence app2) {
return mContext.getString(R.string.app_pair_default_title, app1, app2);
}
+
+ /**
+ * Gets the TopTaskTracker, which is a cached record of the top running Task.
+ */
+ @VisibleForTesting
+ public TopTaskTracker getTopTaskTracker(Context context) {
+ return TopTaskTracker.INSTANCE.get(context);
+ }
}
diff --git a/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt b/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt
index ad9f5ea..0ee27be 100644
--- a/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt
+++ b/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt
@@ -391,14 +391,6 @@
"trying to launch an app pair icon, but encountered an unexpected null"
}
- // If launching an app pair from Taskbar inside of an app context, use fade-in animation
- // TODO (b/316485863): Replace with desired app pair launch animation
- if (launchingIconView.context is TaskbarActivityContext) {
- composeFadeInSplitLaunchAnimator(
- initialTaskId, secondTaskId, info, t, finishCallback)
- return
- }
-
composeIconSplitLaunchAnimator(launchingIconView, info, t, finishCallback)
} else {
// Fallback case: simple fade-in animation
@@ -483,6 +475,10 @@
* We want to animate the Root (grandparent) so that it affects both apps and the divider.
* To do this, we find one of the nodes with WINDOWING_MODE_MULTI_WINDOW (one of the
* left-side ones, for simplicity) and traverse the tree until we find the grandparent.
+ *
+ * This function is only called when we are animating the app pair in from scratch. It is NOT
+ * called when we are animating in from an existing visible TaskView tile or an app that is
+ * already on screen.
*/
@VisibleForTesting
fun composeIconSplitLaunchAnimator(
@@ -491,6 +487,14 @@
t: Transaction,
finishCallback: Runnable
) {
+ // If launching an app pair from Taskbar inside of an app context (no access to Launcher),
+ // use the scale-up animation
+ if (launchingIconView.context is TaskbarActivityContext) {
+ composeScaleUpLaunchAnimation(transitionInfo, t, finishCallback)
+ return
+ }
+
+ // Else we are in Launcher and can launch with the full icon stretch-and-split animation.
val launcher = Launcher.getLauncher(launchingIconView.context)
val dp = launcher.deviceProfile
@@ -650,6 +654,91 @@
}
/**
+ * This is a scale-up-and-fade-in animation (34% to 100%) for launching an app in Overview when
+ * there is no visible associated tile to expand from.
+ */
+ @VisibleForTesting
+ fun composeScaleUpLaunchAnimation(
+ transitionInfo: TransitionInfo,
+ t: Transaction,
+ finishCallback: Runnable
+ ) {
+ val launchAnimation = AnimatorSet()
+ val progressUpdater = ValueAnimator.ofFloat(0f, 1f)
+ progressUpdater.setDuration(QuickstepTransitionManager.APP_LAUNCH_DURATION)
+ progressUpdater.interpolator = Interpolators.EMPHASIZED
+
+ var rootCandidate: Change? = null
+
+ for (change in transitionInfo.changes) {
+ val taskInfo: RunningTaskInfo = change.taskInfo ?: continue
+
+ // TODO (b/316490565): Replace this logic when SplitBounds is available to
+ // startAnimation() and we can know the precise taskIds of launching tasks.
+ // Find a change that has WINDOWING_MODE_MULTI_WINDOW.
+ if (
+ taskInfo.windowingMode == WINDOWING_MODE_MULTI_WINDOW &&
+ (change.mode == TRANSIT_OPEN || change.mode == TRANSIT_TO_FRONT)
+ ) {
+ // Found one!
+ rootCandidate = change
+ break
+ }
+ }
+
+ // If we could not find a proper root candidate, something went wrong.
+ check(rootCandidate != null) { "Could not find a split root candidate" }
+
+ // Recurse up the tree until parent is null, then we've found our root.
+ var parentToken: WindowContainerToken? = rootCandidate.parent
+ while (parentToken != null) {
+ rootCandidate = transitionInfo.getChange(parentToken) ?: break
+ parentToken = rootCandidate.parent
+ }
+
+ // Make sure nothing weird happened, like getChange() returning null.
+ check(rootCandidate != null) { "Failed to find a root leash" }
+
+ // Starting position is a 34% size tile centered in the middle of the screen.
+ // Ending position is the full device screen.
+ val screenBounds = rootCandidate.endAbsBounds
+ val startingScale = 0.34f
+ val startX =
+ screenBounds.left +
+ ((screenBounds.right - screenBounds.left) * ((1 - startingScale) / 2f))
+ val startY =
+ screenBounds.top +
+ ((screenBounds.bottom - screenBounds.top) * ((1 - startingScale) / 2f))
+ val endX = screenBounds.left
+ val endY = screenBounds.top
+
+ progressUpdater.addUpdateListener { valueAnimator: ValueAnimator ->
+ val progress = valueAnimator.animatedFraction
+
+ val x = startX + ((endX - startX) * progress)
+ val y = startY + ((endY - startY) * progress)
+ val scale = startingScale + ((1 - startingScale) * progress)
+
+ t.setPosition(rootCandidate.leash, x, y)
+ t.setScale(rootCandidate.leash, scale, scale)
+ t.setAlpha(rootCandidate.leash, progress)
+ t.apply()
+ }
+
+ // When animation ends, run finishCallback
+ progressUpdater.addListener(
+ object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator) {
+ finishCallback.run()
+ }
+ }
+ )
+
+ launchAnimation.play(progressUpdater)
+ launchAnimation.start()
+ }
+
+ /**
* If we are launching split screen without any special animation from a starting View, we
* simply fade in the starting apps and fade out launcher.
*/
diff --git a/quickstep/tests/src/com/android/quickstep/util/AppPairsControllerTest.kt b/quickstep/tests/src/com/android/quickstep/util/AppPairsControllerTest.kt
index 1723844..510faf6 100644
--- a/quickstep/tests/src/com/android/quickstep/util/AppPairsControllerTest.kt
+++ b/quickstep/tests/src/com/android/quickstep/util/AppPairsControllerTest.kt
@@ -18,18 +18,37 @@
import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.launcher3.apppairs.AppPairIcon
import com.android.launcher3.logging.StatsLogManager
+import com.android.launcher3.model.data.ItemInfo
+import com.android.launcher3.taskbar.TaskbarActivityContext
import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_BOTTOM_OR_RIGHT
import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_TOP_OR_LEFT
+import com.android.quickstep.TopTaskTracker
+import com.android.quickstep.TopTaskTracker.CachedTaskInfo
+import com.android.systemui.shared.recents.model.Task
+import com.android.systemui.shared.recents.model.Task.TaskKey
import com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_30_70
import com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_50_50
import com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_70_30
+import java.util.function.Consumer
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.Captor
import org.mockito.Mock
import org.mockito.MockitoAnnotations
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.doNothing
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.never
+import org.mockito.kotlin.spy
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
@RunWith(AndroidJUnit4::class)
class AppPairsControllerTest {
@@ -58,11 +77,38 @@
appPairsController.encodeRank(STAGE_POSITION_BOTTOM_OR_RIGHT, SNAP_TO_70_30)
}
+ @Mock lateinit var mockAppPairIcon: AppPairIcon
+ @Mock lateinit var mockTaskbarActivityContext: TaskbarActivityContext
+ @Mock lateinit var mockTopTaskTracker: TopTaskTracker
+ @Mock lateinit var mockCachedTaskInfo: CachedTaskInfo
+ @Mock lateinit var mockItemInfo1: ItemInfo
+ @Mock lateinit var mockItemInfo2: ItemInfo
+ @Mock lateinit var mockTask1: Task
+ @Mock lateinit var mockTask2: Task
+ @Mock lateinit var mockTaskKey1: TaskKey
+ @Mock lateinit var mockTaskKey2: TaskKey
+ @Captor lateinit var callbackCaptor: ArgumentCaptor<Consumer<Array<Task>>>
+
+ private lateinit var spyAppPairsController: AppPairsController
+
@Before
fun setup() {
MockitoAnnotations.initMocks(this)
appPairsController =
AppPairsController(context, splitSelectStateController, statsLogManager)
+
+ // Stub methods on appPairsController so that they return mocks
+ spyAppPairsController = spy(appPairsController)
+ whenever(mockAppPairIcon.context).thenReturn(mockTaskbarActivityContext)
+ whenever(spyAppPairsController.getTopTaskTracker(mockTaskbarActivityContext))
+ .thenReturn(mockTopTaskTracker)
+ whenever(mockTopTaskTracker.getCachedTopTask(any())).thenReturn(mockCachedTaskInfo)
+ whenever(mockTask1.getKey()).thenReturn(mockTaskKey1)
+ whenever(mockTask2.getKey()).thenReturn(mockTaskKey2)
+ doNothing().whenever(spyAppPairsController).launchAppPair(any())
+ doNothing()
+ .whenever(spyAppPairsController)
+ .launchToSide(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())
}
@Test
@@ -144,4 +190,220 @@
AppPairsController.convertRankToSnapPosition(right70),
)
}
+
+ @Test
+ fun handleAppPairLaunchInApp_shouldDoNothingWhenAppsAreAlreadyRunning() {
+ // Test launching apps 1 and 2 from app pair
+ whenever(mockTaskKey1.getId()).thenReturn(1)
+ whenever(mockTaskKey2.getId()).thenReturn(2)
+ // ... with apps 1 and 2 already on screen
+ whenever(mockTopTaskTracker.runningSplitTaskIds).thenReturn(arrayListOf(1, 2).toIntArray())
+
+ // Trigger app pair launch, capture and run callback from findLastActiveTasksAndRunCallback
+ spyAppPairsController.handleAppPairLaunchInApp(
+ mockAppPairIcon,
+ listOf(mockItemInfo1, mockItemInfo2)
+ )
+ verify(splitSelectStateController)
+ .findLastActiveTasksAndRunCallback(any(), any(), callbackCaptor.capture())
+ val callback: Consumer<Array<Task>> = callbackCaptor.value
+ callback.accept(arrayOf(mockTask1, mockTask2))
+
+ // Verify that launchAppPair and launchToSide were never called
+ verify(spyAppPairsController, never()).launchAppPair(any())
+ verify(spyAppPairsController, never())
+ .launchToSide(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())
+ }
+
+ @Test
+ fun handleAppPairLaunchInApp_shouldLaunchApp2ToRightWhenApp1IsOnLeft() {
+ // Test launching apps 1 and 2 from app pair
+ whenever(mockTaskKey1.getId()).thenReturn(1)
+ whenever(mockTaskKey2.getId()).thenReturn(2)
+ // ... with apps 1 and 3 already on screen
+ whenever(mockTopTaskTracker.runningSplitTaskIds).thenReturn(arrayListOf(1, 3).toIntArray())
+
+ // Trigger app pair launch, capture and run callback from findLastActiveTasksAndRunCallback
+ spyAppPairsController.handleAppPairLaunchInApp(
+ mockAppPairIcon,
+ listOf(mockItemInfo1, mockItemInfo2)
+ )
+ verify(splitSelectStateController)
+ .findLastActiveTasksAndRunCallback(any(), any(), callbackCaptor.capture())
+ val callback: Consumer<Array<Task>> = callbackCaptor.value
+ callback.accept(arrayOf(mockTask1, mockTask2))
+
+ // Verify that launchToSide was called with the correct arguments
+ verify(spyAppPairsController, never()).launchAppPair(any())
+ verify(spyAppPairsController, times(1))
+ .launchToSide(anyOrNull(), anyOrNull(), anyOrNull(), eq(STAGE_POSITION_BOTTOM_OR_RIGHT))
+ }
+
+ @Test
+ fun handleAppPairLaunchInApp_shouldLaunchApp2ToLeftWhenApp1IsOnRight() {
+ // Test launching apps 1 and 2 from app pair
+ whenever(mockTaskKey1.getId()).thenReturn(1)
+ whenever(mockTaskKey2.getId()).thenReturn(2)
+ // ... with apps 3 and 1 already on screen
+ whenever(mockTopTaskTracker.runningSplitTaskIds).thenReturn(arrayListOf(3, 1).toIntArray())
+
+ // Trigger app pair launch, capture and run callback from findLastActiveTasksAndRunCallback
+ spyAppPairsController.handleAppPairLaunchInApp(
+ mockAppPairIcon,
+ listOf(mockItemInfo1, mockItemInfo2)
+ )
+ verify(splitSelectStateController)
+ .findLastActiveTasksAndRunCallback(any(), any(), callbackCaptor.capture())
+ val callback: Consumer<Array<Task>> = callbackCaptor.value
+ callback.accept(arrayOf(mockTask1, mockTask2))
+
+ // Verify that launchToSide was called with the correct arguments
+ verify(spyAppPairsController, never()).launchAppPair(any())
+ verify(spyAppPairsController, times(1))
+ .launchToSide(anyOrNull(), anyOrNull(), anyOrNull(), eq(STAGE_POSITION_TOP_OR_LEFT))
+ }
+
+ @Test
+ fun handleAppPairLaunchInApp_shouldLaunchApp1ToRightWhenApp2IsOnLeft() {
+ // Test launching apps 1 and 2 from app pair
+ whenever(mockTaskKey1.getId()).thenReturn(1)
+ whenever(mockTaskKey2.getId()).thenReturn(2)
+ // ... with apps 2 and 3 already on screen
+ whenever(mockTopTaskTracker.runningSplitTaskIds).thenReturn(arrayListOf(2, 3).toIntArray())
+
+ // Trigger app pair launch, capture and run callback from findLastActiveTasksAndRunCallback
+ spyAppPairsController.handleAppPairLaunchInApp(
+ mockAppPairIcon,
+ listOf(mockItemInfo1, mockItemInfo2)
+ )
+ verify(splitSelectStateController)
+ .findLastActiveTasksAndRunCallback(any(), any(), callbackCaptor.capture())
+ val callback: Consumer<Array<Task>> = callbackCaptor.value
+ callback.accept(arrayOf(mockTask1, mockTask2))
+
+ // Verify that launchToSide was called with the correct arguments
+ verify(spyAppPairsController, never()).launchAppPair(any())
+ verify(spyAppPairsController, times(1))
+ .launchToSide(anyOrNull(), anyOrNull(), anyOrNull(), eq(STAGE_POSITION_BOTTOM_OR_RIGHT))
+ }
+
+ @Test
+ fun handleAppPairLaunchInApp_shouldLaunchApp1ToLeftWhenApp2IsOnRight() {
+ // Test launching apps 1 and 2 from app pair
+ whenever(mockTaskKey1.getId()).thenReturn(1)
+ whenever(mockTaskKey2.getId()).thenReturn(2)
+ // ... with apps 3 and 2 already on screen
+ whenever(mockTopTaskTracker.runningSplitTaskIds).thenReturn(arrayListOf(3, 2).toIntArray())
+
+ // Trigger app pair launch, capture and run callback from findLastActiveTasksAndRunCallback
+ spyAppPairsController.handleAppPairLaunchInApp(
+ mockAppPairIcon,
+ listOf(mockItemInfo1, mockItemInfo2)
+ )
+ verify(splitSelectStateController)
+ .findLastActiveTasksAndRunCallback(any(), any(), callbackCaptor.capture())
+ val callback: Consumer<Array<Task>> = callbackCaptor.value
+ callback.accept(arrayOf(mockTask1, mockTask2))
+
+ // Verify that launchToSide was called with the correct arguments
+ verify(spyAppPairsController, never()).launchAppPair(any())
+ verify(spyAppPairsController, times(1))
+ .launchToSide(anyOrNull(), anyOrNull(), anyOrNull(), eq(STAGE_POSITION_TOP_OR_LEFT))
+ }
+
+ @Test
+ fun handleAppPairLaunchInApp_shouldLaunchAppPairNormallyWhenUnrelatedPairIsOnScreen() {
+ // Test launching apps 1 and 2 from app pair
+ whenever(mockTaskKey1.getId()).thenReturn(1)
+ whenever(mockTaskKey2.getId()).thenReturn(2)
+ // ... with apps 3 and 4 already on screen
+ whenever(mockTopTaskTracker.runningSplitTaskIds).thenReturn(arrayListOf(3, 4).toIntArray())
+
+ // Trigger app pair launch, capture and run callback from findLastActiveTasksAndRunCallback
+ spyAppPairsController.handleAppPairLaunchInApp(
+ mockAppPairIcon,
+ listOf(mockItemInfo1, mockItemInfo2)
+ )
+ verify(splitSelectStateController)
+ .findLastActiveTasksAndRunCallback(any(), any(), callbackCaptor.capture())
+ val callback: Consumer<Array<Task>> = callbackCaptor.value
+ callback.accept(arrayOf(mockTask1, mockTask2))
+
+ // Verify that launchAppPair was called
+ verify(spyAppPairsController, times(1)).launchAppPair(any())
+ verify(spyAppPairsController, never())
+ .launchToSide(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())
+ }
+
+ @Test
+ fun handleAppPairLaunchInApp_shouldLaunchApp2ToRightWhenApp1IsFullscreen() {
+ /// Test launching apps 1 and 2 from app pair
+ whenever(mockTaskKey1.getId()).thenReturn(1)
+ whenever(mockTaskKey2.getId()).thenReturn(2)
+ // ... with app 1 already on screen
+ whenever(mockCachedTaskInfo.taskId).thenReturn(1)
+
+ // Trigger app pair launch, capture and run callback from findLastActiveTasksAndRunCallback
+ spyAppPairsController.handleAppPairLaunchInApp(
+ mockAppPairIcon,
+ listOf(mockItemInfo1, mockItemInfo2)
+ )
+ verify(splitSelectStateController)
+ .findLastActiveTasksAndRunCallback(any(), any(), callbackCaptor.capture())
+ val callback: Consumer<Array<Task>> = callbackCaptor.value
+ callback.accept(arrayOf(mockTask1, mockTask2))
+
+ // Verify that launchToSide was called with the correct arguments
+ verify(spyAppPairsController, never()).launchAppPair(any())
+ verify(spyAppPairsController, times(1))
+ .launchToSide(anyOrNull(), anyOrNull(), anyOrNull(), eq(STAGE_POSITION_BOTTOM_OR_RIGHT))
+ }
+
+ @Test
+ fun handleAppPairLaunchInApp_shouldLaunchApp1ToLeftWhenApp2IsFullscreen() {
+ /// Test launching apps 1 and 2 from app pair
+ whenever(mockTaskKey1.getId()).thenReturn(1)
+ whenever(mockTaskKey2.getId()).thenReturn(2)
+ // ... with app 2 already on screen
+ whenever(mockCachedTaskInfo.taskId).thenReturn(2)
+
+ // Trigger app pair launch, capture and run callback from findLastActiveTasksAndRunCallback
+ spyAppPairsController.handleAppPairLaunchInApp(
+ mockAppPairIcon,
+ listOf(mockItemInfo1, mockItemInfo2)
+ )
+ verify(splitSelectStateController)
+ .findLastActiveTasksAndRunCallback(any(), any(), callbackCaptor.capture())
+ val callback: Consumer<Array<Task>> = callbackCaptor.value
+ callback.accept(arrayOf(mockTask1, mockTask2))
+
+ // Verify that launchToSide was called with the correct arguments
+ verify(spyAppPairsController, never()).launchAppPair(any())
+ verify(spyAppPairsController, times(1))
+ .launchToSide(anyOrNull(), anyOrNull(), anyOrNull(), eq(STAGE_POSITION_TOP_OR_LEFT))
+ }
+
+ @Test
+ fun handleAppPairLaunchInApp_shouldLaunchAppPairNormallyWhenUnrelatedSingleAppIsFullscreen() {
+ // Test launching apps 1 and 2 from app pair
+ whenever(mockTaskKey1.getId()).thenReturn(1)
+ whenever(mockTaskKey2.getId()).thenReturn(2)
+ // ... with app 3 already on screen
+ whenever(mockCachedTaskInfo.taskId).thenReturn(3)
+
+ // Trigger app pair launch, capture and run callback from findLastActiveTasksAndRunCallback
+ spyAppPairsController.handleAppPairLaunchInApp(
+ mockAppPairIcon,
+ listOf(mockItemInfo1, mockItemInfo2)
+ )
+ verify(splitSelectStateController)
+ .findLastActiveTasksAndRunCallback(any(), any(), callbackCaptor.capture())
+ val callback: Consumer<Array<Task>> = callbackCaptor.value
+ callback.accept(arrayOf(mockTask1, mockTask2))
+
+ // Verify that launchAppPair was called
+ verify(spyAppPairsController, times(1)).launchAppPair(any())
+ verify(spyAppPairsController, never())
+ .launchToSide(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())
+ }
}
diff --git a/quickstep/tests/src/com/android/quickstep/util/SplitAnimationControllerTest.kt b/quickstep/tests/src/com/android/quickstep/util/SplitAnimationControllerTest.kt
index de152fa..68c9bf9 100644
--- a/quickstep/tests/src/com/android/quickstep/util/SplitAnimationControllerTest.kt
+++ b/quickstep/tests/src/com/android/quickstep/util/SplitAnimationControllerTest.kt
@@ -281,7 +281,7 @@
whenever(mockAppPairIcon.context).thenReturn(mockTaskbarActivityContext)
doNothing()
.whenever(spySplitAnimationController)
- .composeFadeInSplitLaunchAnimator(any(), any(), any(), any(), any())
+ .composeScaleUpLaunchAnimation(any(), any(), any())
spySplitAnimationController.playSplitLaunchAnimation(
null /* launchingTaskView */,
@@ -298,8 +298,7 @@
{} /* finishCallback */
)
- verify(spySplitAnimationController)
- .composeFadeInSplitLaunchAnimator(any(), any(), any(), any(), any())
+ verify(spySplitAnimationController).composeScaleUpLaunchAnimation(any(), any(), any())
}
@Test