From 94fd07e5da924dd7f11d67d45a13b26afa29cff3 Mon Sep 17 00:00:00 2001 From: Jeremy Sim Date: Wed, 19 Apr 2023 20:56:49 -0700 Subject: [PATCH] App Pairs: Implement save, inflate, launch, and delete This is the third of several patches implementing the App Pairs feature behind a flag. This patch includes: - AppPairIcon and associated XML. Actual icon asset is placeholder for now - Ability to launch split pair on click - Icon can be moved around, incl. to Taskbar - App pair can be deleted by dragging to "Remove" drop zone - Icon persists on Launcher reload Change-Id: I88aec6fbc814be98f9ef048bbc5af889d0797970 Flag: ENABLE_APP_PAIRS (set to false) Bug: 274835596 Test: Not included in this CL, but will follow --- .../taskbar/TaskbarActivityContext.java | 8 +- .../taskbar/TaskbarUIController.java | 17 +- .../uioverrides/QuickstepLauncher.java | 16 +- .../quickstep/TaskShortcutFactory.java | 1 + .../quickstep/util/AppPairsController.java | 58 ++++- .../util/SplitSelectStateController.java | 76 +++--- .../util/SplitSelectStateControllerTest.kt | 242 +++++++++++++++--- res/layout/app_pair_icon.xml | 30 +++ src/com/android/launcher3/Launcher.java | 15 +- .../launcher3/apppairs/AppPairIcon.java | 102 ++++++++ .../graphics/LauncherPreviewRenderer.java | 12 +- .../launcher3/logging/StatsLogManager.java | 10 +- .../android/launcher3/model/LoaderTask.java | 2 + .../android/launcher3/model/ModelWriter.java | 1 + .../launcher3/model/data/FolderInfo.java | 4 +- .../launcher3/touch/ItemClickHandler.java | 14 + 16 files changed, 523 insertions(+), 85 deletions(-) create mode 100644 res/layout/app_pair_icon.xml create mode 100644 src/com/android/launcher3/apppairs/AppPairIcon.java diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java index 42cb29046f..9810ab9b48 100644 --- a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java +++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java @@ -117,6 +117,7 @@ import com.android.systemui.unfold.updates.RotationChangeProvider; import com.android.systemui.unfold.util.ScopedUnfoldTransitionProgressProvider; import java.io.PrintWriter; +import java.util.Collections; import java.util.Optional; /** @@ -992,9 +993,10 @@ public class TaskbarActivityContext extends BaseTaskbarContext { if (recents == null) { return; } - recents.getSplitSelectController().findLastActiveTaskAndRunCallback( - info.getComponentKey(), - foundTask -> { + recents.getSplitSelectController().findLastActiveTasksAndRunCallback( + Collections.singletonList(info.getComponentKey()), + foundTasks -> { + @Nullable Task foundTask = foundTasks.get(0); if (foundTask != null) { TaskView foundTaskView = recents.getTaskViewByTaskId(foundTask.key.id); diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java index 7154731ee5..6fad655687 100644 --- a/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java +++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarUIController.java @@ -40,8 +40,11 @@ import com.android.quickstep.util.GroupTask; import com.android.quickstep.views.RecentsView; import com.android.quickstep.views.TaskView; import com.android.quickstep.views.TaskView.TaskIdAttributeContainer; +import com.android.systemui.shared.recents.model.Task; import java.io.PrintWriter; +import java.util.Arrays; +import java.util.Collections; import java.util.stream.Stream; /** @@ -204,9 +207,10 @@ public class TaskbarUIController { return; } - recentsView.getSplitSelectController().findLastActiveTaskAndRunCallback( - splitSelectSource.itemInfo.getComponentKey(), - foundTask -> { + recentsView.getSplitSelectController().findLastActiveTasksAndRunCallback( + Collections.singletonList(splitSelectSource.itemInfo.getComponentKey()), + foundTasks -> { + @Nullable Task foundTask = foundTasks.get(0); splitSelectSource.alreadyRunningTaskId = foundTask == null ? INVALID_TASK_ID : foundTask.key.id; @@ -221,9 +225,10 @@ public class TaskbarUIController { */ public void triggerSecondAppForSplit(ItemInfoWithIcon info, Intent intent, View startingView) { RecentsView recents = getRecentsView(); - recents.getSplitSelectController().findLastActiveTaskAndRunCallback( - info.getComponentKey(), - foundTask -> { + recents.getSplitSelectController().findLastActiveTasksAndRunCallback( + Collections.singletonList(info.getComponentKey()), + foundTasks -> { + @Nullable Task foundTask = foundTasks.get(0); if (foundTask != null) { TaskView foundTaskView = recents.getTaskViewByTaskId(foundTask.key.id); // TODO (b/266482558): This additional null check is needed because there diff --git a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java index ffd22b89e2..f90b210a9a 100644 --- a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java +++ b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java @@ -117,6 +117,7 @@ import com.android.launcher3.logging.StatsLogManager.StatsLogger; import com.android.launcher3.model.BgDataModel.FixedContainerItems; import com.android.launcher3.model.WellbeingModel; import com.android.launcher3.model.data.ItemInfo; +import com.android.launcher3.model.data.WorkspaceItemInfo; import com.android.launcher3.popup.SystemShortcut; import com.android.launcher3.proxy.ProxyActivityStarter; import com.android.launcher3.statehandlers.DepthController; @@ -173,6 +174,7 @@ import com.android.quickstep.views.FloatingTaskView; import com.android.quickstep.views.OverviewActionsView; import com.android.quickstep.views.RecentsView; import com.android.quickstep.views.TaskView; +import com.android.systemui.shared.recents.model.Task; import com.android.systemui.shared.system.ActivityManagerWrapper; import com.android.systemui.unfold.RemoteUnfoldSharedComponent; import com.android.systemui.unfold.UnfoldSharedComponent; @@ -618,9 +620,10 @@ public class QuickstepLauncher extends Launcher { RecentsView recentsView = getOverviewPanel(); // Check if there is already an instance of this app running, if so, initiate the split // using that. - mSplitSelectStateController.findLastActiveTaskAndRunCallback( - splitSelectSource.itemInfo.getComponentKey(), - foundTask -> { + mSplitSelectStateController.findLastActiveTasksAndRunCallback( + Collections.singletonList(splitSelectSource.itemInfo.getComponentKey()), + foundTasks -> { + @Nullable Task foundTask = foundTasks.get(0); boolean taskWasFound = foundTask != null; splitSelectSource.alreadyRunningTaskId = taskWasFound ? foundTask.key.id @@ -1324,6 +1327,13 @@ public class QuickstepLauncher extends Launcher { : groupTask.mSplitBounds.leftTaskPercent); } + /** + * Launches two apps as an app pair. + */ + public void launchAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) { + mSplitSelectStateController.getAppPairsController().launchAppPair(app1, app2); + } + public boolean canStartHomeSafely() { OverviewCommandHelper overviewCommandHelper = mTISBindHelper.getOverviewCommandHelper(); return overviewCommandHelper == null || overviewCommandHelper.canStartHomeSafely(); diff --git a/quickstep/src/com/android/quickstep/TaskShortcutFactory.java b/quickstep/src/com/android/quickstep/TaskShortcutFactory.java index 56f407cff3..901690bb9d 100644 --- a/quickstep/src/com/android/quickstep/TaskShortcutFactory.java +++ b/quickstep/src/com/android/quickstep/TaskShortcutFactory.java @@ -140,6 +140,7 @@ public interface TaskShortcutFactory { @Override public void onClick(View view) { + dismissTaskMenuView(mTarget); ((RecentsView) mTarget.getOverviewPanel()) .getSplitSelectController().getAppPairsController().saveAppPair(mTaskView); } diff --git a/quickstep/src/com/android/quickstep/util/AppPairsController.java b/quickstep/src/com/android/quickstep/util/AppPairsController.java index cbde257003..1a7099da09 100644 --- a/quickstep/src/com/android/quickstep/util/AppPairsController.java +++ b/quickstep/src/com/android/quickstep/util/AppPairsController.java @@ -17,19 +17,30 @@ package com.android.quickstep.util; +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 android.app.ActivityTaskManager; import android.content.Context; +import android.content.Intent; + +import androidx.annotation.Nullable; import com.android.launcher3.Launcher; import com.android.launcher3.LauncherAppState; import com.android.launcher3.LauncherSettings; import com.android.launcher3.accessibility.LauncherAccessibilityDelegate; import com.android.launcher3.icons.IconCache; +import com.android.launcher3.logging.StatsLogManager; import com.android.launcher3.model.data.FolderInfo; import com.android.launcher3.model.data.WorkspaceItemInfo; +import com.android.launcher3.util.ComponentKey; +import com.android.launcher3.util.SplitConfigurationOptions; import com.android.quickstep.views.TaskView; +import com.android.systemui.shared.recents.model.Task; + +import java.util.Arrays; /** * Mini controller class that handles app pair interactions: saving, modifying, deleting, etc. @@ -52,10 +63,13 @@ public class AppPairsController { private final Context mContext; private final SplitSelectStateController mSplitSelectStateController; + private final StatsLogManager mStatsLogManager; public AppPairsController(Context context, - SplitSelectStateController splitSelectStateController) { + SplitSelectStateController splitSelectStateController, + StatsLogManager statsLogManager) { mContext = context; mSplitSelectStateController = splitSelectStateController; + mStatsLogManager = statsLogManager; } /** @@ -84,11 +98,51 @@ public class AppPairsController { LauncherAccessibilityDelegate delegate = Launcher.getLauncher(mContext).getAccessibilityDelegate(); if (delegate != null) { - MAIN_EXECUTOR.execute(() -> delegate.addToWorkspace(newAppPair, true)); + delegate.addToWorkspace(newAppPair, true); + mStatsLogManager.logger().withItemInfo(newAppPair) + .log(StatsLogManager.LauncherEvent.LAUNCHER_APP_PAIR_SAVE); } }); }); + } + /** + * Launches an app pair by searching the RecentsModel for running instances of each app, and + * staging either those running instances or launching the apps as new Intents. + */ + public void launchAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) { + ComponentKey app1Key = new ComponentKey(app1.getTargetComponent(), app1.user); + ComponentKey app2Key = new ComponentKey(app2.getTargetComponent(), app2.user); + mSplitSelectStateController.findLastActiveTasksAndRunCallback( + Arrays.asList(app1Key, app2Key), + foundTasks -> { + @Nullable Task foundTask1 = foundTasks.get(0); + Intent task1Intent; + int task1Id; + if (foundTask1 != null) { + task1Id = foundTask1.key.id; + task1Intent = null; + } else { + task1Id = ActivityTaskManager.INVALID_TASK_ID; + task1Intent = app1.intent; + } + + mSplitSelectStateController.setInitialTaskSelect(task1Intent, + SplitConfigurationOptions.STAGE_POSITION_TOP_OR_LEFT, + app1, + LAUNCHER_APP_PAIR_LAUNCH, + task1Id); + + @Nullable Task foundTask2 = foundTasks.get(1); + if (foundTask2 != null) { + mSplitSelectStateController.setSecondTask(foundTask2); + } else { + mSplitSelectStateController.setSecondTask( + app2.intent, app2.user); + } + + mSplitSelectStateController.launchSplitTasks(); + }); } /** diff --git a/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java b/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java index 7ba6d4277c..c42b8342b6 100644 --- a/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java +++ b/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java @@ -78,6 +78,7 @@ import com.android.systemui.shared.system.RemoteAnimationRunnerCompat; import java.io.PrintWriter; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.function.Consumer; @@ -126,7 +127,7 @@ public class SplitSelectStateController { mDepthController = depthController; mRecentTasksModel = recentsModel; mSplitAnimationController = new SplitAnimationController(this); - mAppPairsController = new AppPairsController(context, this); + mAppPairsController = new AppPairsController(context, this, statsLogManager); mSplitSelectDataHolder = new SplitSelectDataHolder(mContext); } @@ -153,37 +154,46 @@ public class SplitSelectStateController { } /** - * Pulls the list of active Tasks from RecentsModel, and finds the most recently active Task - * matching a given ComponentName. Then uses that Task (which could be null) with the given - * callback. + * Maps a List to List<@Nullable Task>, searching through active Tasks in + * RecentsModel. If found, the Task will be the most recently-interacted-with instance of that + * Task. Then runs the given callback on that List. *

* Used in various task-switching or splitscreen operations when we need to check if there is a * currently running Task of a certain type and use the most recent one. */ - public void findLastActiveTaskAndRunCallback( - @Nullable ComponentKey componentKey, Consumer callback) { + public void findLastActiveTasksAndRunCallback( + @Nullable List componentKeys, Consumer> callback) { mRecentTasksModel.getTasks(taskGroups -> { - if (componentKey == null) { - callback.accept(null); + if (componentKeys == null || componentKeys.isEmpty()) { + callback.accept(Collections.emptyList()); return; } - Task lastActiveTask = null; - // Loop through tasks in reverse, since they are ordered with most-recent tasks last. - for (int i = taskGroups.size() - 1; i >= 0; i--) { - GroupTask groupTask = taskGroups.get(i); - Task task1 = groupTask.task1; - if (isInstanceOfComponent(task1, componentKey)) { - lastActiveTask = task1; - break; - } - Task task2 = groupTask.task2; - if (isInstanceOfComponent(task2, componentKey)) { - lastActiveTask = task2; - break; + + List lastActiveTasks = new ArrayList<>(); + // For each key we are looking for, add to lastActiveTasks with the corresponding Task + // (or null if not found). + for (ComponentKey key : componentKeys) { + Task lastActiveTask = null; + // Loop through tasks in reverse, since they are ordered with most-recent tasks last + for (int i = taskGroups.size() - 1; i >= 0; i--) { + GroupTask groupTask = taskGroups.get(i); + Task task1 = groupTask.task1; + // Don't add duplicate Tasks + if (isInstanceOfComponent(task1, key) && !lastActiveTasks.contains(task1)) { + lastActiveTask = task1; + break; + } + Task task2 = groupTask.task2; + if (isInstanceOfComponent(task2, key) && !lastActiveTasks.contains(task2)) { + lastActiveTask = task2; + break; + } } + + lastActiveTasks.add(lastActiveTask); } - callback.accept(lastActiveTask); + callback.accept(lastActiveTasks); }); } @@ -226,7 +236,7 @@ public class SplitSelectStateController { * To be called when the both split tasks are ready to be launched. Call after launcher side * animations are complete. */ - public void launchSplitTasks(Consumer callback) { + public void launchSplitTasks(@Nullable Consumer callback) { Pair instanceIds = LogUtils.getShellShareableInstanceId(); launchTasks(callback, false /* freezeTaskList */, DEFAULT_SPLIT_RATIO, @@ -238,6 +248,14 @@ public class SplitSelectStateController { .log(mSplitSelectDataHolder.getSplitEvent()); } + /** + * A version of {@link #launchTasks(Consumer, boolean, float, InstanceId)} with no success + * callback. + */ + public void launchSplitTasks() { + launchSplitTasks(null); + } + /** * To be called as soon as user selects the second task (even if animations aren't complete) * @param task The second task that will be launched. @@ -271,8 +289,8 @@ public class SplitSelectStateController { * create a split instance, null for cases that bring existing instaces to the * foreground (quickswitch, launching previous pairs from overview) */ - public void launchTasks(Consumer callback, boolean freezeTaskList, float splitRatio, - @Nullable InstanceId shellInstanceId) { + public void launchTasks(@Nullable Consumer callback, boolean freezeTaskList, + float splitRatio, @Nullable InstanceId shellInstanceId) { TestLogging.recordEvent( TestProtocol.SEQUENCE_MAIN, "launchSplitTasks"); final ActivityOptions options1 = ActivityOptions.makeBasic(); @@ -457,7 +475,7 @@ public class SplitSelectStateController { } private RemoteTransition getShellRemoteTransition(int firstTaskId, int secondTaskId, - Consumer callback, String transitionName) { + @Nullable Consumer callback, String transitionName) { final RemoteSplitLaunchTransitionRunner animationRunner = new RemoteSplitLaunchTransitionRunner(firstTaskId, secondTaskId, callback); return new RemoteTransition(animationRunner, @@ -465,7 +483,7 @@ public class SplitSelectStateController { } private RemoteAnimationAdapter getLegacyRemoteAdapter(int firstTaskId, int secondTaskId, - Consumer callback) { + @Nullable Consumer callback) { final RemoteSplitLaunchAnimationRunner animationRunner = new RemoteSplitLaunchAnimationRunner(firstTaskId, secondTaskId, callback); return new RemoteAnimationAdapter(animationRunner, 300, 150, @@ -514,7 +532,7 @@ public class SplitSelectStateController { private final Consumer mSuccessCallback; RemoteSplitLaunchTransitionRunner(int initialTaskId, int secondTaskId, - Consumer callback) { + @Nullable Consumer callback) { mInitialTaskId = initialTaskId; mSecondTaskId = secondTaskId; mSuccessCallback = callback; @@ -563,7 +581,7 @@ public class SplitSelectStateController { private final Consumer mSuccessCallback; RemoteSplitLaunchAnimationRunner(int initialTaskId, int secondTaskId, - Consumer successCallback) { + @Nullable Consumer successCallback) { mInitialTaskId = initialTaskId; mSecondTaskId = secondTaskId; mSuccessCallback = successCallback; diff --git a/quickstep/tests/src/com/android/quickstep/util/SplitSelectStateControllerTest.kt b/quickstep/tests/src/com/android/quickstep/util/SplitSelectStateControllerTest.kt index 65542cf71b..69109c2c45 100644 --- a/quickstep/tests/src/com/android/quickstep/util/SplitSelectStateControllerTest.kt +++ b/quickstep/tests/src/com/android/quickstep/util/SplitSelectStateControllerTest.kt @@ -37,6 +37,7 @@ import com.android.launcher3.util.withArgCaptor import com.android.quickstep.RecentsModel import com.android.quickstep.SystemUiProxy import com.android.systemui.shared.recents.model.Task +import java.util.function.Consumer import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -48,7 +49,6 @@ import org.mockito.Mock import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations -import java.util.function.Consumer @RunWith(AndroidJUnit4::class) class SplitSelectStateControllerTest { @@ -67,6 +67,9 @@ class SplitSelectStateControllerTest { private val primaryUserHandle = UserHandle(ActivityManager.RunningTaskInfo().userId) private val nonPrimaryUserHandle = UserHandle(ActivityManager.RunningTaskInfo().userId + 10) + private var taskIdCounter = 0 + private fun getUniqueId(): Int { return ++taskIdCounter } + @Before fun setup() { MockitoAnnotations.initMocks(this) @@ -100,15 +103,15 @@ class SplitSelectStateControllerTest { tasks.add(groupTask2) // Assertions happen in the callback we get from what we pass into - // #findLastActiveTaskAndRunCallback + // #findLastActiveTasksAndRunCallback val taskConsumer = - Consumer { assertNull("No tasks should have matched", it /*task*/) } + Consumer> { assertNull("No tasks should have matched", it[0] /*task*/) } // Capture callback from recentsModel#getTasks() val consumer = withArgCaptor>> { - splitSelectStateController.findLastActiveTaskAndRunCallback( - nonMatchingComponent, + splitSelectStateController.findLastActiveTasksAndRunCallback( + listOf(nonMatchingComponent), taskConsumer ) verify(recentsModel).getTasks(capture()) @@ -139,27 +142,27 @@ class SplitSelectStateControllerTest { tasks.add(groupTask2) // Assertions happen in the callback we get from what we pass into - // #findLastActiveTaskAndRunCallback + // #findLastActiveTasksAndRunCallback val taskConsumer = - Consumer { + Consumer> { assertEquals( "ComponentName package mismatched", - it.key.baseIntent.component.packageName, + it[0].key.baseIntent.component?.packageName, matchingPackage ) assertEquals( "ComponentName class mismatched", - it.key.baseIntent.component.className, + it[0].key.baseIntent.component?.className, matchingClass ) - assertEquals(it, groupTask1.task1) + assertEquals(it[0], groupTask1.task1) } // Capture callback from recentsModel#getTasks() val consumer = withArgCaptor>> { - splitSelectStateController.findLastActiveTaskAndRunCallback( - matchingComponent, + splitSelectStateController.findLastActiveTasksAndRunCallback( + listOf(matchingComponent), taskConsumer ) verify(recentsModel).getTasks(capture()) @@ -190,15 +193,15 @@ class SplitSelectStateControllerTest { tasks.add(groupTask2) // Assertions happen in the callback we get from what we pass into - // #findLastActiveTaskAndRunCallback + // #findLastActiveTasksAndRunCallback val taskConsumer = - Consumer { assertNull("No tasks should have matched", it /*task*/) } + Consumer> { assertNull("No tasks should have matched", it[0] /*task*/) } // Capture callback from recentsModel#getTasks() val consumer = withArgCaptor>> { - splitSelectStateController.findLastActiveTaskAndRunCallback( - nonPrimaryUserComponent, + splitSelectStateController.findLastActiveTasksAndRunCallback( + listOf(nonPrimaryUserComponent), taskConsumer ) verify(recentsModel).getTasks(capture()) @@ -231,28 +234,28 @@ class SplitSelectStateControllerTest { tasks.add(groupTask2) // Assertions happen in the callback we get from what we pass into - // #findLastActiveTaskAndRunCallback + // #findLastActiveTasksAndRunCallback val taskConsumer = - Consumer { + Consumer> { assertEquals( "ComponentName package mismatched", - it.key.baseIntent.component.packageName, + it[0].key.baseIntent.component?.packageName, matchingPackage ) assertEquals( "ComponentName class mismatched", - it.key.baseIntent.component.className, + it[0].key.baseIntent.component?.className, matchingClass ) - assertEquals("userId mismatched", it.key.userId, nonPrimaryUserHandle.identifier) - assertEquals(it, groupTask1.task1) + assertEquals("userId mismatched", it[0].key.userId, nonPrimaryUserHandle.identifier) + assertEquals(it[0], groupTask1.task1) } // Capture callback from recentsModel#getTasks() val consumer = withArgCaptor>> { - splitSelectStateController.findLastActiveTaskAndRunCallback( - nonPrimaryUserComponent, + splitSelectStateController.findLastActiveTasksAndRunCallback( + listOf(nonPrimaryUserComponent), taskConsumer ) verify(recentsModel).getTasks(capture()) @@ -283,27 +286,200 @@ class SplitSelectStateControllerTest { tasks.add(groupTask1) // Assertions happen in the callback we get from what we pass into - // #findLastActiveTaskAndRunCallback + // #findLastActiveTasksAndRunCallback val taskConsumer = - Consumer { + Consumer> { assertEquals( "ComponentName package mismatched", - it.key.baseIntent.component.packageName, + it[0].key.baseIntent.component?.packageName, matchingPackage ) assertEquals( "ComponentName class mismatched", - it.key.baseIntent.component.className, + it[0].key.baseIntent.component?.className, matchingClass ) - assertEquals(it, groupTask2.task2) + assertEquals(it[0], groupTask1.task1) } // Capture callback from recentsModel#getTasks() val consumer = withArgCaptor>> { - splitSelectStateController.findLastActiveTaskAndRunCallback( - matchingComponent, + splitSelectStateController.findLastActiveTasksAndRunCallback( + listOf(matchingComponent), + taskConsumer + ) + verify(recentsModel).getTasks(capture()) + } + + // Send our mocked tasks + consumer.accept(tasks) + } + + @Test + fun activeTasks_multipleSearchShouldFindTask() { + val nonMatchingComponent = ComponentKey(ComponentName("no", "match"), primaryUserHandle) + val matchingPackage = "hotdog" + val matchingClass = "juice" + val matchingComponent = + ComponentKey(ComponentName(matchingPackage, matchingClass), primaryUserHandle) + + val groupTask1 = + generateGroupTask( + ComponentName("hotdog", "pie"), + ComponentName("pumpkin", "pie") + ) + val groupTask2 = + generateGroupTask( + ComponentName("pomegranate", "juice"), + ComponentName(matchingPackage, matchingClass) + ) + val tasks: ArrayList = ArrayList() + tasks.add(groupTask2) + tasks.add(groupTask1) + + // Assertions happen in the callback we get from what we pass into + // #findLastActiveTasksAndRunCallback + val taskConsumer = + Consumer> { + assertEquals("Expected array length 2", 2, it.size) + assertNull("No tasks should have matched", it[0] /*task*/) + assertEquals( + "ComponentName package mismatched", + it[1].key.baseIntent.component?.packageName, + matchingPackage + ) + assertEquals( + "ComponentName class mismatched", + it[1].key.baseIntent.component?.className, + matchingClass + ) + assertEquals(it[1], groupTask2.task2) + } + + // Capture callback from recentsModel#getTasks() + val consumer = + withArgCaptor>> { + splitSelectStateController.findLastActiveTasksAndRunCallback( + listOf(nonMatchingComponent, matchingComponent), + taskConsumer + ) + verify(recentsModel).getTasks(capture()) + } + + // Send our mocked tasks + consumer.accept(tasks) + } + + @Test + fun activeTasks_multipleSearchShouldNotFindSameTaskTwice() { + val matchingPackage = "hotdog" + val matchingClass = "juice" + val matchingComponent = + ComponentKey(ComponentName(matchingPackage, matchingClass), primaryUserHandle) + + val groupTask1 = + generateGroupTask( + ComponentName("hotdog", "pie"), + ComponentName("pumpkin", "pie") + ) + val groupTask2 = + generateGroupTask( + ComponentName("pomegranate", "juice"), + ComponentName(matchingPackage, matchingClass) + ) + val tasks: ArrayList = ArrayList() + tasks.add(groupTask2) + tasks.add(groupTask1) + + // Assertions happen in the callback we get from what we pass into + // #findLastActiveTasksAndRunCallback + val taskConsumer = + Consumer> { + assertEquals("Expected array length 2", 2, it.size) + assertEquals( + "ComponentName package mismatched", + it[0].key.baseIntent.component?.packageName, + matchingPackage + ) + assertEquals( + "ComponentName class mismatched", + it[0].key.baseIntent.component?.className, + matchingClass + ) + assertEquals(it[0], groupTask2.task2) + assertNull("No tasks should have matched", it[1] /*task*/) + } + + // Capture callback from recentsModel#getTasks() + val consumer = + withArgCaptor>> { + splitSelectStateController.findLastActiveTasksAndRunCallback( + listOf(matchingComponent, matchingComponent), + taskConsumer + ) + verify(recentsModel).getTasks(capture()) + } + + // Send our mocked tasks + consumer.accept(tasks) + } + + @Test + fun activeTasks_multipleSearchShouldFindDifferentInstancesOfSameTask() { + val matchingPackage = "hotdog" + val matchingClass = "juice" + val matchingComponent = + ComponentKey(ComponentName(matchingPackage, matchingClass), primaryUserHandle) + + val groupTask1 = + generateGroupTask( + ComponentName(matchingPackage, matchingClass), + ComponentName("pumpkin", "pie") + ) + val groupTask2 = + generateGroupTask( + ComponentName("pomegranate", "juice"), + ComponentName(matchingPackage, matchingClass) + ) + val tasks: ArrayList = ArrayList() + tasks.add(groupTask2) + tasks.add(groupTask1) + + // Assertions happen in the callback we get from what we pass into + // #findLastActiveTasksAndRunCallback + val taskConsumer = + Consumer> { + assertEquals("Expected array length 2", 2, it.size) + assertEquals( + "ComponentName package mismatched", + it[0].key.baseIntent.component?.packageName, + matchingPackage + ) + assertEquals( + "ComponentName class mismatched", + it[0].key.baseIntent.component?.className, + matchingClass + ) + assertEquals(it[0], groupTask1.task1) + assertEquals( + "ComponentName package mismatched", + it[1].key.baseIntent.component?.packageName, + matchingPackage + ) + assertEquals( + "ComponentName class mismatched", + it[1].key.baseIntent.component?.className, + matchingClass + ) + assertEquals(it[1], groupTask2.task2) + } + + // Capture callback from recentsModel#getTasks() + val consumer = + withArgCaptor>> { + splitSelectStateController.findLastActiveTasksAndRunCallback( + listOf(matchingComponent, matchingComponent), taskConsumer ) verify(recentsModel).getTasks(capture()) @@ -366,6 +542,7 @@ class SplitSelectStateControllerTest { ): GroupTask { val task1 = Task() var taskInfo = ActivityManager.RunningTaskInfo() + taskInfo.taskId = getUniqueId() var intent = Intent() intent.component = task1ComponentName taskInfo.baseIntent = intent @@ -373,6 +550,7 @@ class SplitSelectStateControllerTest { val task2 = Task() taskInfo = ActivityManager.RunningTaskInfo() + taskInfo.taskId = getUniqueId() intent = Intent() intent.component = task2ComponentName taskInfo.baseIntent = intent @@ -393,6 +571,7 @@ class SplitSelectStateControllerTest { ): GroupTask { val task1 = Task() var taskInfo = ActivityManager.RunningTaskInfo() + taskInfo.taskId = getUniqueId() // Apply custom userHandle1 taskInfo.userId = userHandle1.identifier var intent = Intent() @@ -401,6 +580,7 @@ class SplitSelectStateControllerTest { task1.key = Task.TaskKey(taskInfo) val task2 = Task() taskInfo = ActivityManager.RunningTaskInfo() + taskInfo.taskId = getUniqueId() // Apply custom userHandle2 taskInfo.userId = userHandle2.identifier intent = Intent() diff --git a/res/layout/app_pair_icon.xml b/res/layout/app_pair_icon.xml new file mode 100644 index 0000000000..2b9a98b037 --- /dev/null +++ b/res/layout/app_pair_icon.xml @@ -0,0 +1,30 @@ + + + + + + diff --git a/src/com/android/launcher3/Launcher.java b/src/com/android/launcher3/Launcher.java index bfbd660cac..93c0e7e23f 100644 --- a/src/com/android/launcher3/Launcher.java +++ b/src/com/android/launcher3/Launcher.java @@ -145,6 +145,7 @@ import com.android.launcher3.allapps.AllAppsTransitionController; import com.android.launcher3.allapps.BaseSearchConfig; import com.android.launcher3.allapps.DiscoveryBounce; import com.android.launcher3.anim.PropertyListBuilder; +import com.android.launcher3.apppairs.AppPairIcon; import com.android.launcher3.celllayout.CellPosMapper; import com.android.launcher3.celllayout.CellPosMapper.CellPos; import com.android.launcher3.celllayout.CellPosMapper.TwoPanelCellPosMapper; @@ -2445,9 +2446,9 @@ public class Launcher extends StatefulActivity break; } case LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR: { - FolderInfo info = (FolderInfo) item; - // TODO (jeremysim b/274189428): Create app pair icon - view = null; + view = AppPairIcon.inflateIcon(R.layout.app_pair_icon, this, + (ViewGroup) workspace.getChildAt(workspace.getCurrentPage()), + (FolderInfo) item); break; } case LauncherSettings.Favorites.ITEM_TYPE_APPWIDGET: @@ -3389,4 +3390,12 @@ public class Launcher extends StatefulActivity public View.OnLongClickListener getAllAppsItemLongClickListener() { return ItemLongClickListener.INSTANCE_ALL_APPS; } + + /** + * Handles an app pair launch; overridden in + * {@link com.android.launcher3.uioverrides.QuickstepLauncher} + */ + public void launchAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) { + // Overridden + } } diff --git a/src/com/android/launcher3/apppairs/AppPairIcon.java b/src/com/android/launcher3/apppairs/AppPairIcon.java new file mode 100644 index 0000000000..1dc4ad293a --- /dev/null +++ b/src/com/android/launcher3/apppairs/AppPairIcon.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.launcher3.apppairs; + +import android.content.Context; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import androidx.annotation.Nullable; + +import com.android.launcher3.BubbleTextView; +import com.android.launcher3.R; +import com.android.launcher3.dragndrop.DraggableView; +import com.android.launcher3.model.data.FolderInfo; +import com.android.launcher3.model.data.WorkspaceItemInfo; +import com.android.launcher3.views.ActivityContext; + +import java.util.Collections; +import java.util.Comparator; + +/** + * A {@link android.widget.FrameLayout} used to represent an app pair icon on the workspace. + */ +public class AppPairIcon extends FrameLayout implements DraggableView { + + private ActivityContext mActivity; + private BubbleTextView mAppPairName; + private FolderInfo mInfo; + + public AppPairIcon(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public AppPairIcon(Context context) { + super(context); + } + + /** + * Builds an AppPairIcon to be added to the Launcher + */ + public static AppPairIcon inflateIcon(int resId, ActivityContext activity, + @Nullable ViewGroup group, FolderInfo appPairInfo) { + + LayoutInflater inflater = (group != null) + ? LayoutInflater.from(group.getContext()) + : activity.getLayoutInflater(); + AppPairIcon icon = (AppPairIcon) inflater.inflate(resId, group, false); + + // Sort contents, so that left-hand app comes first + Collections.sort(appPairInfo.contents, Comparator.comparingInt(a -> a.rank)); + + icon.setClipToPadding(false); + icon.mAppPairName = icon.findViewById(R.id.app_pair_icon_name); + + // TODO (jeremysim b/274189428): Replace this placeholder icon + WorkspaceItemInfo placeholder = new WorkspaceItemInfo(); + placeholder.newIcon(icon.getContext()); + icon.mAppPairName.applyFromWorkspaceItem(placeholder); + + icon.mAppPairName.setText(appPairInfo.title); + + icon.setTag(appPairInfo); + icon.setOnClickListener(activity.getItemOnClickListener()); + icon.mInfo = appPairInfo; + icon.mActivity = activity; + + icon.setAccessibilityDelegate(activity.getAccessibilityDelegate()); + + return icon; + } + + @Override + public int getViewType() { + return DRAGGABLE_ICON; + } + + @Override + public void getWorkspaceVisualDragBounds(Rect bounds) { + mAppPairName.getIconBounds(bounds); + } + + public FolderInfo getInfo() { + return mInfo; + } +} diff --git a/src/com/android/launcher3/graphics/LauncherPreviewRenderer.java b/src/com/android/launcher3/graphics/LauncherPreviewRenderer.java index 7241b17a9c..68106c4d52 100644 --- a/src/com/android/launcher3/graphics/LauncherPreviewRenderer.java +++ b/src/com/android/launcher3/graphics/LauncherPreviewRenderer.java @@ -51,6 +51,7 @@ import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; import android.view.WindowManager; +import android.widget.FrameLayout; import android.widget.TextClock; import androidx.annotation.NonNull; @@ -70,6 +71,7 @@ import com.android.launcher3.R; import com.android.launcher3.Utilities; import com.android.launcher3.Workspace; import com.android.launcher3.WorkspaceLayoutManager; +import com.android.launcher3.apppairs.AppPairIcon; import com.android.launcher3.celllayout.CellLayoutLayoutParams; import com.android.launcher3.celllayout.CellPosMapper; import com.android.launcher3.config.FeatureFlags; @@ -358,12 +360,13 @@ public class LauncherPreviewRenderer extends ContextWrapper addInScreenFromBind(icon, info); } - private void inflateAndAddFolder(FolderInfo info) { + private void inflateAndAddCollectionIcon(FolderInfo info) { CellLayout screen = info.container == Favorites.CONTAINER_DESKTOP ? mWorkspaceScreens.get(info.screenId) : mHotseat; - FolderIcon folderIcon = FolderIcon.inflateIcon(R.layout.folder_icon, this, screen, - info); + FrameLayout folderIcon = info.itemType == Favorites.ITEM_TYPE_FOLDER + ? FolderIcon.inflateIcon(R.layout.folder_icon, this, screen, info) + : AppPairIcon.inflateIcon(R.layout.app_pair_icon, this, screen, info); addInScreenFromBind(folderIcon, info); } @@ -467,7 +470,8 @@ public class LauncherPreviewRenderer extends ContextWrapper inflateAndAddIcon((WorkspaceItemInfo) itemInfo); break; case Favorites.ITEM_TYPE_FOLDER: - inflateAndAddFolder((FolderInfo) itemInfo); + case Favorites.ITEM_TYPE_APP_PAIR: + inflateAndAddCollectionIcon((FolderInfo) itemInfo); break; default: break; diff --git a/src/com/android/launcher3/logging/StatsLogManager.java b/src/com/android/launcher3/logging/StatsLogManager.java index 2a7cd9a0bd..8bb06c1b99 100644 --- a/src/com/android/launcher3/logging/StatsLogManager.java +++ b/src/com/android/launcher3/logging/StatsLogManager.java @@ -641,11 +641,17 @@ public class StatsLogManager implements ResourceBasedOverride { @UiEvent(doc = "User has swiped upwards from the gesture handle to show transient taskbar.") LAUNCHER_TRANSIENT_TASKBAR_SHOW(1331), + @UiEvent(doc = "User has clicked an app pair and launched directly into split screen.") + LAUNCHER_APP_PAIR_LAUNCH(1374), + + @UiEvent(doc = "User saved an app pair.") + LAUNCHER_APP_PAIR_SAVE(1456), + @UiEvent(doc = "App launched through pending intent") - LAUNCHER_APP_LAUNCH_PENDING_INTENT(1394), - ; + LAUNCHER_APP_LAUNCH_PENDING_INTENT(1394) // ADD MORE + ; private final int mId; diff --git a/src/com/android/launcher3/model/LoaderTask.java b/src/com/android/launcher3/model/LoaderTask.java index 787ac38c26..933468cab1 100644 --- a/src/com/android/launcher3/model/LoaderTask.java +++ b/src/com/android/launcher3/model/LoaderTask.java @@ -690,9 +690,11 @@ public class LoaderTask implements Runnable { break; case Favorites.ITEM_TYPE_FOLDER: + case Favorites.ITEM_TYPE_APP_PAIR: FolderInfo folderInfo = mBgDataModel.findOrMakeFolder(c.id); c.applyCommonProperties(folderInfo); + folderInfo.itemType = c.itemType; // Do not trim the folder label, as is was set by the user. folderInfo.title = c.getString(c.mTitleIndex); folderInfo.spanX = 1; diff --git a/src/com/android/launcher3/model/ModelWriter.java b/src/com/android/launcher3/model/ModelWriter.java index a6b4d591b3..2358a9fc5f 100644 --- a/src/com/android/launcher3/model/ModelWriter.java +++ b/src/com/android/launcher3/model/ModelWriter.java @@ -489,6 +489,7 @@ public class ModelWriter { case Favorites.ITEM_TYPE_APPLICATION: case Favorites.ITEM_TYPE_DEEP_SHORTCUT: case Favorites.ITEM_TYPE_FOLDER: + case Favorites.ITEM_TYPE_APP_PAIR: if (!mBgDataModel.workspaceItems.contains(modelItem)) { mBgDataModel.workspaceItems.add(modelItem); } diff --git a/src/com/android/launcher3/model/data/FolderInfo.java b/src/com/android/launcher3/model/data/FolderInfo.java index e5a0eb1996..9bf6d43b17 100644 --- a/src/com/android/launcher3/model/data/FolderInfo.java +++ b/src/com/android/launcher3/model/data/FolderInfo.java @@ -119,8 +119,8 @@ public class FolderInfo extends ItemInfo { public static FolderInfo createAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) { FolderInfo newAppPair = new FolderInfo(); newAppPair.itemType = LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR; - newAppPair.contents.add(app1); - newAppPair.contents.add(app2); + newAppPair.add(app1, /* animate */ false); + newAppPair.add(app2, /* animate */ false); return newAppPair; } diff --git a/src/com/android/launcher3/touch/ItemClickHandler.java b/src/com/android/launcher3/touch/ItemClickHandler.java index 790c226a11..8c12547bb5 100644 --- a/src/com/android/launcher3/touch/ItemClickHandler.java +++ b/src/com/android/launcher3/touch/ItemClickHandler.java @@ -42,6 +42,7 @@ import com.android.launcher3.Launcher; import com.android.launcher3.LauncherSettings; import com.android.launcher3.R; import com.android.launcher3.Utilities; +import com.android.launcher3.apppairs.AppPairIcon; import com.android.launcher3.folder.Folder; import com.android.launcher3.folder.FolderIcon; import com.android.launcher3.logging.InstanceId; @@ -95,6 +96,8 @@ public class ItemClickHandler { } else if (tag instanceof FolderInfo) { if (v instanceof FolderIcon) { onClickFolderIcon(v); + } else if (v instanceof AppPairIcon) { + onClickAppPairIcon(v); } } else if (tag instanceof AppInfo) { startAppShortcutOrInfoActivity(v, (AppInfo) tag, launcher); @@ -122,6 +125,17 @@ public class ItemClickHandler { } } + /** + * Event handler for an app pair icon click. + * + * @param v The view that was clicked. Must be an instance of {@link AppPairIcon}. + */ + private static void onClickAppPairIcon(View v) { + Launcher launcher = Launcher.getLauncher(v.getContext()); + FolderInfo folderInfo = ((AppPairIcon) v).getInfo(); + launcher.launchAppPair(folderInfo.contents.get(0), folderInfo.contents.get(1)); + } + /** * Event handler for the app widget view which has not fully restored. */