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