From cf9132e07c874eff9c6b2ebcec9fc8765c6ea382 Mon Sep 17 00:00:00 2001 From: Jeremy Sim Date: Tue, 6 Feb 2024 16:23:04 +0800 Subject: [PATCH] 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 --- .../taskbar/TaskbarActivityContext.java | 46 ++- .../quickstep/util/AppPairsController.java | 188 ++++++++++++- .../util/SplitAnimationController.kt | 105 ++++++- .../quickstep/util/AppPairsControllerTest.kt | 262 ++++++++++++++++++ .../util/SplitAnimationControllerTest.kt | 5 +- 5 files changed, 587 insertions(+), 19 deletions(-) diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java index 3f5793f28a..55deca884b 100644 --- a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java +++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java @@ -1063,7 +1063,7 @@ public class TaskbarActivityContext extends BaseTaskbarContext { 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 @@ public class TaskbarActivityContext extends BaseTaskbarContext { 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 @@ public class TaskbarActivityContext extends BaseTaskbarContext { // 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); @@ -1152,13 +1150,49 @@ public class TaskbarActivityContext extends BaseTaskbarContext { } } + /** + * 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 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 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 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 839320ed3b..8f9395f444 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.R; 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 @@ public class AppPairsController { task1Id = foundTask1.key.id; task1Intent = null; } else { - task1Id = ActivityTaskManager.INVALID_TASK_ID; + task1Id = INVALID_TASK_ID; task1Intent = app1.intent; } @@ -176,6 +188,170 @@ public class AppPairsController { ); } + /** + * 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 itemInfos) { + TaskbarActivityContext context = (TaskbarActivityContext) launchingIconView.getContext(); + List 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 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 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 @@ -209,4 +385,12 @@ public class AppPairsController { 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 ad9f5ea1cc..0ee27bee86 100644 --- a/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt +++ b/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt @@ -391,14 +391,6 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC "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 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC * 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 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC 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 @@ -649,6 +653,91 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC launchAnimation.start() } + /** + * 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 1723844843..510faf6132 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 @@ package com.android.quickstep.util 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 @@ class AppPairsControllerTest { 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>> + + 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 @@ class AppPairsControllerTest { 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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 de152fa974..68c9bf9799 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 @@ class SplitAnimationControllerTest { 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 @@ class SplitAnimationControllerTest { {} /* finishCallback */ ) - verify(spySplitAnimationController) - .composeFadeInSplitLaunchAnimator(any(), any(), any(), any(), any()) + verify(spySplitAnimationController).composeScaleUpLaunchAnimation(any(), any(), any()) } @Test