1 /*
2  * Copyright 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 
18 package com.android.quickstep.util;
19 
20 import static android.app.ActivityTaskManager.INVALID_TASK_ID;
21 
22 import static com.android.internal.jank.Cuj.CUJ_LAUNCHER_LAUNCH_APP_PAIR_FROM_TASKBAR;
23 import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_APP_PAIR_LAUNCH;
24 import static com.android.launcher3.model.data.AppInfo.PACKAGE_KEY_COMPARATOR;
25 import static com.android.launcher3.model.data.ItemInfoWithIcon.FLAG_SUPPORTS_MULTI_INSTANCE;
26 import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
27 import static com.android.launcher3.util.Executors.MODEL_EXECUTOR;
28 import static com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_BOTTOM_OR_RIGHT;
29 import static com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_TOP_OR_LEFT;
30 import static com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_50_50;
31 import static com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_NONE;
32 import static com.android.wm.shell.common.split.SplitScreenConstants.SPLIT_POSITION_BOTTOM_OR_RIGHT;
33 import static com.android.wm.shell.common.split.SplitScreenConstants.SPLIT_POSITION_TOP_OR_LEFT;
34 import static com.android.wm.shell.common.split.SplitScreenConstants.isPersistentSnapPosition;
35 
36 import android.content.Context;
37 import android.content.Intent;
38 import android.content.pm.LauncherApps;
39 import android.util.Log;
40 import android.util.Pair;
41 
42 import androidx.annotation.NonNull;
43 import androidx.annotation.Nullable;
44 import androidx.annotation.VisibleForTesting;
45 
46 import com.android.internal.jank.Cuj;
47 import com.android.launcher3.LauncherAppState;
48 import com.android.launcher3.LauncherSettings;
49 import com.android.launcher3.R;
50 import com.android.launcher3.accessibility.LauncherAccessibilityDelegate;
51 import com.android.launcher3.allapps.AllAppsStore;
52 import com.android.launcher3.apppairs.AppPairIcon;
53 import com.android.launcher3.config.FeatureFlags;
54 import com.android.launcher3.icons.IconCache;
55 import com.android.launcher3.logging.InstanceId;
56 import com.android.launcher3.logging.StatsLogManager;
57 import com.android.launcher3.model.data.AppInfo;
58 import com.android.launcher3.model.data.AppPairInfo;
59 import com.android.launcher3.model.data.ItemInfo;
60 import com.android.launcher3.model.data.ItemInfoWithIcon;
61 import com.android.launcher3.model.data.WorkspaceItemInfo;
62 import com.android.launcher3.taskbar.TaskbarActivityContext;
63 import com.android.launcher3.uioverrides.QuickstepLauncher;
64 import com.android.launcher3.util.ComponentKey;
65 import com.android.launcher3.util.PackageManagerHelper;
66 import com.android.launcher3.util.SplitConfigurationOptions.StagePosition;
67 import com.android.launcher3.views.ActivityContext;
68 import com.android.quickstep.SystemUiProxy;
69 import com.android.quickstep.TaskUtils;
70 import com.android.quickstep.TopTaskTracker;
71 import com.android.quickstep.views.GroupedTaskView;
72 import com.android.quickstep.views.TaskView;
73 import com.android.systemui.shared.recents.model.Task;
74 import com.android.systemui.shared.system.InteractionJankMonitorWrapper;
75 import com.android.wm.shell.common.split.SplitScreenConstants.PersistentSnapPosition;
76 
77 import java.util.Arrays;
78 import java.util.List;
79 
80 /**
81  * Controller class that handles app pair interactions: saving, modifying, deleting, etc.
82  * <br>
83  * App pairs contain two "member" apps, which are determined at the time of app pair creation
84  * and never modified. The member apps are WorkspaceItemInfos, but use the "rank" attribute
85  * differently from other ItemInfos -- we use it to store information about the split position and
86  * ratio.
87  */
88 public class AppPairsController {
89     private static final String TAG = "AppPairsController";
90 
91     // Used for encoding and decoding the "rank" attribute
92     private static final int BITMASK_SIZE = 16;
93     private static final int BITMASK_FOR_SNAP_POSITION = (1 << BITMASK_SIZE) - 1;
94 
95     private Context mContext;
96     private final SplitSelectStateController mSplitSelectStateController;
97     private final StatsLogManager mStatsLogManager;
AppPairsController(Context context, SplitSelectStateController splitSelectStateController, StatsLogManager statsLogManager)98     public AppPairsController(Context context,
99             SplitSelectStateController splitSelectStateController,
100             StatsLogManager statsLogManager) {
101         mContext = context;
102         mSplitSelectStateController = splitSelectStateController;
103         mStatsLogManager = statsLogManager;
104     }
105 
onDestroy()106     void onDestroy() {
107         mContext = null;
108     }
109 
110     /**
111      * Returns whether the specified GroupedTaskView can be saved as an app pair.
112      */
canSaveAppPair(TaskView taskView)113     public boolean canSaveAppPair(TaskView taskView) {
114         if (mContext == null) {
115             // Can ignore as the activity is already destroyed
116             return false;
117         }
118 
119         // Disallow saving app pairs if:
120         // - app pairs feature is not enabled
121         // - the task in question is a single task
122         // - at least one app in app pair is unpinnable
123         // - the task is not a GroupedTaskView
124         // - both tasks in the GroupedTaskView are from the same app and the app does not
125         //   support multi-instance
126         boolean hasUnpinnableApp = taskView.getTaskContainers().stream()
127                 .anyMatch(att -> att != null && att.getItemInfo() != null
128                         && ((att.getItemInfo().runtimeStatusFlags
129                             & ItemInfoWithIcon.FLAG_NOT_PINNABLE) != 0));
130         if (!FeatureFlags.enableAppPairs()
131                 || !taskView.containsMultipleTasks()
132                 || hasUnpinnableApp
133                 || !(taskView instanceof GroupedTaskView)) {
134             return false;
135         }
136 
137         GroupedTaskView gtv = (GroupedTaskView) taskView;
138         List<TaskView.TaskContainer> containers = gtv.getTaskContainers();
139         ComponentKey taskKey1 = TaskUtils.getLaunchComponentKeyForTask(
140                 containers.get(0).getTask().key);
141         ComponentKey taskKey2 = TaskUtils.getLaunchComponentKeyForTask(
142                 containers.get(1).getTask().key);
143         AppInfo app1 = resolveAppInfoByComponent(taskKey1);
144         AppInfo app2 = resolveAppInfoByComponent(taskKey2);
145 
146         if (app1 == null || app2 == null) {
147             // Disallow saving app pairs for apps that don't have a front-door in Launcher
148             return false;
149         }
150 
151         if (PackageManagerHelper.isSameAppForMultiInstance(app1, app2)) {
152             if (!app1.supportsMultiInstance() || !app2.supportsMultiInstance()) {
153                 return false;
154             }
155         }
156         return true;
157     }
158 
159     /**
160      * Creates a new app pair ItemInfo and adds it to the workspace.
161      * <br>
162      * We create WorkspaceItemInfos to save onto the app pair in the following way:
163      * <br> 1. We verify that the ComponentKey from our Recents tile corresponds to a real
164      * launchable app in the app store.
165      * <br> 2. If it doesn't, we search for the underlying launchable app via package name, and use
166      * that instead.
167      * <br> 3. If that fails, we re-use the existing WorkspaceItemInfo by cloning it and replacing
168      * its intent with one from PackageManager.
169      * <br> 4. If everything fails, we just use the WorkspaceItemInfo as is, with its existing
170      * intent. This is not preferred, but will still work in most cases (notably it will not work
171      * well on trampoline apps).
172      */
saveAppPair(GroupedTaskView gtv)173     public void saveAppPair(GroupedTaskView gtv) {
174         InteractionJankMonitorWrapper.begin(gtv, Cuj.CUJ_LAUNCHER_SAVE_APP_PAIR);
175         List<TaskView.TaskContainer> containers = gtv.getTaskContainers();
176         WorkspaceItemInfo recentsInfo1 = containers.get(0).getItemInfo();
177         WorkspaceItemInfo recentsInfo2 = containers.get(1).getItemInfo();
178         WorkspaceItemInfo app1 = resolveAppPairWorkspaceInfo(recentsInfo1);
179         WorkspaceItemInfo app2 = resolveAppPairWorkspaceInfo(recentsInfo2);
180 
181         if (app1 == null || app2 == null) {
182             // This shouldn't happen if canSaveAppPair() is called above, but log an error and do
183             // not create the app pair if the workspace items can't be resolved
184             Log.w(TAG, "Failed to save app pair due to invalid apps ("
185                     + "app1=" + recentsInfo1.getComponentKey().componentName
186                     + " app2=" + recentsInfo2.getComponentKey().componentName + ")");
187             return;
188         }
189 
190         @PersistentSnapPosition int snapPosition = gtv.getSnapPosition();
191         if (snapPosition == SNAP_TO_NONE) {
192             // Free snap mode is enabled, just save it as 50/50 split.
193             snapPosition = SNAP_TO_50_50;
194         }
195         if (!isPersistentSnapPosition(snapPosition)) {
196             // If we received an illegal snap position, log an error and do not create the app pair
197             Log.wtf(TAG, "Tried to save an app pair with illegal snapPosition "
198                     + snapPosition);
199             return;
200         }
201 
202         app1.rank = encodeRank(SPLIT_POSITION_TOP_OR_LEFT, snapPosition);
203         app2.rank = encodeRank(SPLIT_POSITION_BOTTOM_OR_RIGHT, snapPosition);
204         AppPairInfo newAppPair = new AppPairInfo(app1, app2);
205 
206         IconCache iconCache = LauncherAppState.getInstance(mContext).getIconCache();
207         MODEL_EXECUTOR.execute(() -> {
208             newAppPair.getAppContents().forEach(member -> {
209                 member.title = "";
210                 member.bitmap = iconCache.getDefaultIcon(newAppPair.user);
211                 iconCache.getTitleAndIcon(member, member.usingLowResIcon());
212             });
213             MAIN_EXECUTOR.execute(() -> {
214                 LauncherAccessibilityDelegate delegate =
215                         QuickstepLauncher.getLauncher(mContext).getAccessibilityDelegate();
216                 if (delegate != null) {
217                     delegate.addToWorkspace(newAppPair, true, (success) -> {
218                         if (success) {
219                             InteractionJankMonitorWrapper.end(Cuj.CUJ_LAUNCHER_SAVE_APP_PAIR);
220                         } else {
221                             InteractionJankMonitorWrapper.cancel(Cuj.CUJ_LAUNCHER_SAVE_APP_PAIR);
222                         }
223                     });
224                     mStatsLogManager.logger().withItemInfo(newAppPair)
225                             .log(StatsLogManager.LauncherEvent.LAUNCHER_APP_PAIR_SAVE);
226                 }
227             });
228         });
229     }
230 
231     /**
232      * Launches an app pair by searching the RecentsModel for running instances of each app, and
233      * staging either those running instances or launching the apps as new Intents.
234      *
235      * @param cuj Should be an integer from {@link Cuj} or -1 if no CUJ needs to be logged for jank
236      *            monitoring
237      */
launchAppPair(AppPairIcon appPairIcon, int cuj)238     public void launchAppPair(AppPairIcon appPairIcon, int cuj) {
239         WorkspaceItemInfo app1 = appPairIcon.getInfo().getFirstApp();
240         WorkspaceItemInfo app2 = appPairIcon.getInfo().getSecondApp();
241         ComponentKey app1Key = new ComponentKey(app1.getTargetComponent(), app1.user);
242         ComponentKey app2Key = new ComponentKey(app2.getTargetComponent(), app2.user);
243         mSplitSelectStateController.setLaunchingCuj(cuj);
244         InteractionJankMonitorWrapper.begin(appPairIcon, cuj);
245 
246         mSplitSelectStateController.findLastActiveTasksAndRunCallback(
247                 Arrays.asList(app1Key, app2Key),
248                 false /* findExactPairMatch */,
249                 foundTasks -> {
250                     @Nullable Task foundTask1 = foundTasks[0];
251                     Intent task1Intent;
252                     int task1Id;
253                     if (foundTask1 != null) {
254                         task1Id = foundTask1.key.id;
255                         task1Intent = null;
256                     } else {
257                         task1Id = INVALID_TASK_ID;
258                         task1Intent = app1.intent;
259                     }
260 
261                     mSplitSelectStateController.setInitialTaskSelect(task1Intent,
262                             AppPairsController.convertRankToStagePosition(app1.rank),
263                             app1,
264                             LAUNCHER_APP_PAIR_LAUNCH,
265                             task1Id);
266 
267                     @Nullable Task foundTask2 = foundTasks[1];
268                     if (foundTask2 != null) {
269                         mSplitSelectStateController.setSecondTask(foundTask2, app2);
270                     } else {
271                         mSplitSelectStateController.setSecondTask(
272                                 app2.intent, app2.user, app2);
273                     }
274 
275                     mSplitSelectStateController.setLaunchingIconView(appPairIcon);
276 
277                     mSplitSelectStateController.launchSplitTasks(
278                             AppPairsController.convertRankToSnapPosition(app1.rank));
279                 }
280         );
281     }
282 
283     /**
284      * Returns an AppInfo associated with the app for the given ComponentKey, or null if no such
285      * package exists in the AllAppsStore.
286      */
287     @Nullable
resolveAppInfoByComponent(@onNull ComponentKey key)288     private AppInfo resolveAppInfoByComponent(@NonNull ComponentKey key) {
289         AllAppsStore appsStore = ActivityContext.lookupContext(mContext)
290                 .getAppsView().getAppsStore();
291 
292         // First look up the app info in order of:
293         // - The exact activity for the recent task
294         // - The first(?) loaded activity from the package
295         AppInfo appInfo = appsStore.getApp(key);
296         if (appInfo == null) {
297             appInfo = appsStore.getApp(key, PACKAGE_KEY_COMPARATOR);
298         }
299         return appInfo;
300     }
301 
302     /**
303      * Creates a new launchable WorkspaceItemInfo of itemType=ITEM_TYPE_APPLICATION by looking the
304      * ComponentKey up in the AllAppsStore. If no app is found, attempts a lookup by package
305      * instead. If that lookup fails, returns null.
306      */
307     @Nullable
resolveAppPairWorkspaceInfo( @onNull WorkspaceItemInfo recentTaskInfo)308     private WorkspaceItemInfo resolveAppPairWorkspaceInfo(
309             @NonNull WorkspaceItemInfo recentTaskInfo) {
310         // ComponentKey should never be null (see TaskView#getItemInfo)
311         AppInfo appInfo = resolveAppInfoByComponent(recentTaskInfo.getComponentKey());
312         if (appInfo == null) {
313             return null;
314         }
315         return appInfo.makeWorkspaceItem(mContext);
316     }
317 
318     /**
319      * Handles the complicated logic for how to animate an app pair entrance when already inside an
320      * app or app pair.
321      *
322      * If the user tapped on an app pair while already in an app pair, there are 4 general cases:
323      *   a) Clicked app pair A|B, but both apps are already running on screen.
324      *   b) App A is already on-screen, but App B isn't.
325      *   c) App B is on-screen, but App A isn't.
326      *   d) Neither is on-screen.
327      *
328      * If the user tapped an app pair while inside a single app, there are 3 cases:
329      *   a) The on-screen app is App A of the app pair.
330      *   b) The on-screen app is App B of the app pair.
331      *   c) It is neither.
332      *
333      * For each case, we call the appropriate animation and split launch type.
334      */
handleAppPairLaunchInApp(AppPairIcon launchingIconView, List<? extends ItemInfo> itemInfos)335     public void handleAppPairLaunchInApp(AppPairIcon launchingIconView,
336             List<? extends ItemInfo> itemInfos) {
337         TaskbarActivityContext context = (TaskbarActivityContext) launchingIconView.getContext();
338         List<ComponentKey> componentKeys =
339                 itemInfos.stream().map(ItemInfo::getComponentKey).toList();
340 
341         // Use TopTaskTracker to find the currently running app (or apps)
342         TopTaskTracker topTaskTracker = getTopTaskTracker();
343 
344         // getRunningSplitTasksIds() will return a pair of ids if we are currently running a
345         // split pair, or an empty array with zero length if we are running a single app.
346         int[] runningSplitTasks = topTaskTracker.getRunningSplitTaskIds();
347         if (runningSplitTasks != null && runningSplitTasks.length == 2) {
348             // Tapped an app pair while in an app pair
349             int runningTaskId1 = runningSplitTasks[0];
350             int runningTaskId2 = runningSplitTasks[1];
351 
352             mSplitSelectStateController.findLastActiveTasksAndRunCallback(
353                     componentKeys,
354                     false /* findExactPairMatch */,
355                     foundTasks -> {
356                         // If our clicked app pair has already-running Tasks, we grab the
357                         // taskIds here so we can see if those ids are already on-screen now
358                         List<Integer> lastActiveTasksOfAppPair =
359                                 Arrays.stream(foundTasks).map((Task task) -> {
360                                     if (task != null) {
361                                         return task.getKey().getId();
362                                     } else {
363                                         return INVALID_TASK_ID;
364                                     }
365                                 }).toList();
366 
367                         if (lastActiveTasksOfAppPair.contains(runningTaskId1)
368                                 && lastActiveTasksOfAppPair.contains(runningTaskId2)) {
369                             // App A and App B are already on-screen, so do nothing.
370                         } else if (!lastActiveTasksOfAppPair.contains(runningTaskId1)
371                                 && !lastActiveTasksOfAppPair.contains(runningTaskId2)) {
372                             // Neither A nor B are on screen, so just launch a new app pair
373                             // normally.
374                             launchAppPair(launchingIconView,
375                                     CUJ_LAUNCHER_LAUNCH_APP_PAIR_FROM_TASKBAR);
376                         } else {
377                             // Exactly one app (A or B) is on-screen, so we have to launch the other
378                             // on the appropriate side.
379                             ItemInfo app1 = itemInfos.get(0);
380                             ItemInfo app2 = itemInfos.get(1);
381                             int task1 = lastActiveTasksOfAppPair.get(0);
382                             int task2 = lastActiveTasksOfAppPair.get(1);
383 
384                             // If task1 is one of the running on-screen tasks, we launch app2.
385                             // If not, task2 must be the running task, and we launch app1.
386                             ItemInfo appToLaunch =
387                                     task1 == runningTaskId1 || task1 == runningTaskId2
388                                             ? app2
389                                             : app1;
390                             // If the on-screen task is on the bottom/right position, we launch to
391                             // the top/left. If not, we launch to the bottom/right.
392                             @StagePosition int sideToLaunch =
393                                     task1 == runningTaskId2 || task2 == runningTaskId2
394                                             ? STAGE_POSITION_TOP_OR_LEFT
395                                             : STAGE_POSITION_BOTTOM_OR_RIGHT;
396 
397                             launchToSide(context, launchingIconView.getInfo(), appToLaunch,
398                                     sideToLaunch);
399                         }
400                     }
401             );
402         } else {
403             // Tapped an app pair while in a single app
404             int runningTaskId = topTaskTracker
405                     .getCachedTopTask(false /* filterOnlyVisibleRecents */).getTaskId();
406 
407             mSplitSelectStateController.findLastActiveTasksAndRunCallback(
408                     componentKeys,
409                     false /* findExactPairMatch */,
410                     foundTasks -> {
411                         Task foundTask1 = foundTasks[0];
412                         Task foundTask2 = foundTasks[1];
413                         boolean task1IsOnScreen =
414                                 foundTask1 != null && foundTask1.getKey().getId() == runningTaskId;
415                         boolean task2IsOnScreen =
416                                 foundTask2 != null && foundTask2.getKey().getId() == runningTaskId;
417 
418                         if (!task1IsOnScreen && !task2IsOnScreen) {
419                             // Neither App A nor App B are on-screen, launch the app pair normally.
420                             launchAppPair(launchingIconView,
421                                     CUJ_LAUNCHER_LAUNCH_APP_PAIR_FROM_TASKBAR);
422                         } else {
423                             // Either A or B is on-screen, so launch the other on the appropriate
424                             // side.
425                             ItemInfo app1 = itemInfos.get(0);
426                             ItemInfo app2 = itemInfos.get(1);
427                             // If task1 is the running on-screen task, we launch app2 on the
428                             // bottom/right. If task2 is on-screen, launch app1 on the top/left.
429                             ItemInfo appToLaunch = task1IsOnScreen ? app2 : app1;
430                             @StagePosition int sideToLaunch = task1IsOnScreen
431                                     ? STAGE_POSITION_BOTTOM_OR_RIGHT
432                                     : STAGE_POSITION_TOP_OR_LEFT;
433 
434                             launchToSide(context, launchingIconView.getInfo(), appToLaunch,
435                                     sideToLaunch);
436                         }
437                 }
438             );
439         }
440     }
441 
442     /**
443      * Executes a split launch by launching an app to the side of an existing app.
444      * @param context The TaskbarActivityContext that we are launching the app pair from.
445      * @param launchingItemInfo The itemInfo of the icon that was tapped.
446      * @param app The app that will launch to the side of the existing running app (not necessarily
447      *  the same as the previous parameter; e.g. we tap an app pair but launch an app).
448      * @param side A @StagePosition, either STAGE_POSITION_TOP_OR_LEFT or
449      *  STAGE_POSITION_BOTTOM_OR_RIGHT.
450      */
451     @VisibleForTesting
launchToSide( TaskbarActivityContext context, ItemInfo launchingItemInfo, ItemInfo app, @StagePosition int side )452     public void launchToSide(
453             TaskbarActivityContext context,
454             ItemInfo launchingItemInfo,
455             ItemInfo app,
456             @StagePosition int side
457     ) {
458         LauncherApps launcherApps = context.getSystemService(LauncherApps.class);
459 
460         // Set up to log app pair launch event
461         Pair<com.android.internal.logging.InstanceId, InstanceId> instanceIds =
462                 LogUtils.getShellShareableInstanceId();
463         context.getStatsLogManager()
464                 .logger()
465                 .withItemInfo(launchingItemInfo)
466                 .withInstanceId(instanceIds.second)
467                 .log(LAUNCHER_APP_PAIR_LAUNCH);
468 
469         SystemUiProxy.INSTANCE.get(context)
470                 .startIntent(
471                         launcherApps.getMainActivityLaunchIntent(
472                                 app.getIntent().getComponent(),
473                                 null,
474                                 app.user
475                         ),
476                         app.user.getIdentifier(),
477                         new Intent(),
478                         side,
479                         null,
480                         instanceIds.first
481                 );
482     }
483 
484     /**
485      * App pair members have a "rank" attribute that contains information about the split position
486      * and ratio. We implement this by splitting the int in half (e.g. 16 bits each), then use one
487      * half to store splitPosition (left vs right) and the other half to store snapPosition
488      * (30-70 split vs 50-50 split)
489      */
490     @VisibleForTesting
encodeRank(int splitPosition, int snapPosition)491     public int encodeRank(int splitPosition, int snapPosition) {
492         return (splitPosition << BITMASK_SIZE) + snapPosition;
493     }
494 
495     /**
496      * Returns the desired stage position for the app pair to be launched in (decoded from the
497      * "rank" integer).
498      */
convertRankToStagePosition(int rank)499     public static int convertRankToStagePosition(int rank) {
500         return rank >> BITMASK_SIZE;
501     }
502 
503     /**
504      * Returns the desired split ratio for the app pair to be launched in (decoded from the "rank"
505      * integer).
506      */
convertRankToSnapPosition(int rank)507     public static int convertRankToSnapPosition(int rank) {
508         return rank & BITMASK_FOR_SNAP_POSITION;
509     }
510 
511     /**
512      * Gets the TopTaskTracker, which is a cached record of the top running Task.
513      */
514     @VisibleForTesting
getTopTaskTracker()515     public TopTaskTracker getTopTaskTracker() {
516         return TopTaskTracker.INSTANCE.get(mContext);
517     }
518 }
519