1 /* 2 * Copyright (C) 2019 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 package com.android.launcher3.hybridhotseat; 17 18 import static com.android.launcher3.LauncherAnimUtils.SCALE_PROPERTY; 19 import static com.android.launcher3.LauncherState.NORMAL; 20 import static com.android.launcher3.anim.AnimatorListeners.forSuccessCallback; 21 import static com.android.launcher3.hybridhotseat.HotseatEduController.getSettingsIntent; 22 import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_HOTSEAT_PREDICTION_PINNED; 23 import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_HOTSEAT_RANKED; 24 import static com.android.launcher3.util.Executors.MAIN_EXECUTOR; 25 26 import android.animation.Animator; 27 import android.animation.AnimatorSet; 28 import android.animation.ObjectAnimator; 29 import android.content.ComponentName; 30 import android.view.HapticFeedbackConstants; 31 import android.view.View; 32 import android.view.ViewGroup; 33 34 import androidx.annotation.NonNull; 35 import androidx.annotation.Nullable; 36 import androidx.annotation.VisibleForTesting; 37 38 import com.android.launcher3.DeviceProfile; 39 import com.android.launcher3.DragSource; 40 import com.android.launcher3.DropTarget; 41 import com.android.launcher3.Hotseat; 42 import com.android.launcher3.LauncherSettings; 43 import com.android.launcher3.R; 44 import com.android.launcher3.anim.AnimationSuccessListener; 45 import com.android.launcher3.dragndrop.DragController; 46 import com.android.launcher3.dragndrop.DragOptions; 47 import com.android.launcher3.graphics.DragPreviewProvider; 48 import com.android.launcher3.logger.LauncherAtom.ContainerInfo; 49 import com.android.launcher3.logger.LauncherAtom.PredictedHotseatContainer; 50 import com.android.launcher3.logging.InstanceId; 51 import com.android.launcher3.model.BgDataModel.FixedContainerItems; 52 import com.android.launcher3.model.data.ItemInfo; 53 import com.android.launcher3.model.data.WorkspaceItemInfo; 54 import com.android.launcher3.popup.SystemShortcut; 55 import com.android.launcher3.testing.TestLogging; 56 import com.android.launcher3.testing.shared.TestProtocol; 57 import com.android.launcher3.touch.ItemLongClickListener; 58 import com.android.launcher3.uioverrides.PredictedAppIcon; 59 import com.android.launcher3.uioverrides.QuickstepLauncher; 60 import com.android.launcher3.util.OnboardingPrefs; 61 import com.android.launcher3.views.Snackbar; 62 63 import java.util.ArrayList; 64 import java.util.Collections; 65 import java.util.List; 66 import java.util.function.Predicate; 67 import java.util.stream.Collectors; 68 69 /** 70 * Provides prediction ability for the hotseat. Fills gaps in hotseat with predicted items, allows 71 * pinning of predicted apps and manages replacement of predicted apps with user drag. 72 */ 73 public class HotseatPredictionController implements DragController.DragListener, 74 SystemShortcut.Factory<QuickstepLauncher>, DeviceProfile.OnDeviceProfileChangeListener, 75 DragSource, ViewGroup.OnHierarchyChangeListener { 76 77 private static final int FLAG_UPDATE_PAUSED = 1 << 0; 78 private static final int FLAG_DRAG_IN_PROGRESS = 1 << 1; 79 private static final int FLAG_FILL_IN_PROGRESS = 1 << 2; 80 private static final int FLAG_REMOVING_PREDICTED_ICON = 1 << 3; 81 82 private int mHotSeatItemsCount; 83 84 private QuickstepLauncher mLauncher; 85 private final Hotseat mHotseat; 86 private final Runnable mUpdateFillIfNotLoading = this::updateFillIfNotLoading; 87 88 private List<ItemInfo> mPredictedItems = Collections.emptyList(); 89 90 private AnimatorSet mIconRemoveAnimators; 91 private int mPauseFlags = 0; 92 93 private List<PredictedAppIcon.PredictedIconOutlineDrawing> mOutlineDrawings = new ArrayList<>(); 94 95 private boolean mEnableHotseatLongPressTipForTesting = true; 96 97 private final View.OnLongClickListener mPredictionLongClickListener = v -> { 98 if (!ItemLongClickListener.canStartDrag(mLauncher)) return false; 99 if (mLauncher.getWorkspace().isSwitchingState()) return false; 100 101 TestLogging.recordEvent(TestProtocol.SEQUENCE_MAIN, "onWorkspaceItemLongClick"); 102 if (mEnableHotseatLongPressTipForTesting && !mLauncher.getOnboardingPrefs().getBoolean( 103 OnboardingPrefs.HOTSEAT_LONGPRESS_TIP_SEEN)) { 104 Snackbar.show(mLauncher, R.string.hotseat_tip_gaps_filled, 105 R.string.hotseat_prediction_settings, null, 106 () -> mLauncher.startActivity(getSettingsIntent())); 107 mLauncher.getOnboardingPrefs().markChecked(OnboardingPrefs.HOTSEAT_LONGPRESS_TIP_SEEN); 108 mLauncher.getDragLayer().performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); 109 return true; 110 } 111 112 // Start the drag 113 // Use a new itemInfo so that the original predicted item is stable 114 WorkspaceItemInfo dragItem = new WorkspaceItemInfo((WorkspaceItemInfo) v.getTag()); 115 v.setVisibility(View.INVISIBLE); 116 mLauncher.getWorkspace().beginDragShared( 117 v, null, this, dragItem, new DragPreviewProvider(v), 118 mLauncher.getDefaultWorkspaceDragOptions()); 119 return true; 120 }; 121 HotseatPredictionController(QuickstepLauncher launcher)122 public HotseatPredictionController(QuickstepLauncher launcher) { 123 mLauncher = launcher; 124 mHotseat = launcher.getHotseat(); 125 mHotSeatItemsCount = mLauncher.getDeviceProfile().numShownHotseatIcons; 126 mLauncher.getDragController().addDragListener(this); 127 128 launcher.addOnDeviceProfileChangeListener(this); 129 mHotseat.getShortcutsAndWidgets().setOnHierarchyChangeListener(this); 130 } 131 132 @Override onChildViewAdded(View parent, View child)133 public void onChildViewAdded(View parent, View child) { 134 onHotseatHierarchyChanged(); 135 } 136 137 @Override onChildViewRemoved(View parent, View child)138 public void onChildViewRemoved(View parent, View child) { 139 onHotseatHierarchyChanged(); 140 } 141 142 /** Enables/disabled the hotseat prediction icon long press edu for testing. */ 143 @VisibleForTesting enableHotseatEdu(boolean enable)144 public void enableHotseatEdu(boolean enable) { 145 mEnableHotseatLongPressTipForTesting = enable; 146 } 147 onHotseatHierarchyChanged()148 private void onHotseatHierarchyChanged() { 149 if (mPauseFlags == 0 && !mLauncher.isWorkspaceLoading()) { 150 // Post update after a single frame to avoid layout within layout 151 MAIN_EXECUTOR.getHandler().removeCallbacks(mUpdateFillIfNotLoading); 152 MAIN_EXECUTOR.getHandler().post(mUpdateFillIfNotLoading); 153 } 154 } 155 updateFillIfNotLoading()156 private void updateFillIfNotLoading() { 157 if (mPauseFlags == 0 && !mLauncher.isWorkspaceLoading()) { 158 fillGapsWithPrediction(true); 159 } 160 } 161 162 /** 163 * Shows appropriate hotseat education based on prediction enabled and migration states. 164 */ showEdu()165 public void showEdu() { 166 mLauncher.getStateManager().goToState(NORMAL, true, forSuccessCallback(() -> { 167 HotseatEduController eduController = new HotseatEduController(mLauncher); 168 eduController.setPredictedApps(mPredictedItems.stream() 169 .map(i -> (WorkspaceItemInfo) i) 170 .collect(Collectors.toList())); 171 eduController.showEdu(); 172 })); 173 } 174 175 /** 176 * Returns if hotseat client has predictions 177 */ hasPredictions()178 public boolean hasPredictions() { 179 return !mPredictedItems.isEmpty(); 180 } 181 fillGapsWithPrediction()182 private void fillGapsWithPrediction() { 183 fillGapsWithPrediction(false); 184 } 185 fillGapsWithPrediction(boolean animate)186 private void fillGapsWithPrediction(boolean animate) { 187 if (mPauseFlags != 0) { 188 return; 189 } 190 191 int predictionIndex = 0; 192 int numViewsAnimated = 0; 193 ArrayList<WorkspaceItemInfo> newItems = new ArrayList<>(); 194 // make sure predicted icon removal and filling predictions don't step on each other 195 if (mIconRemoveAnimators != null && mIconRemoveAnimators.isRunning()) { 196 mIconRemoveAnimators.addListener(new AnimationSuccessListener() { 197 @Override 198 public void onAnimationSuccess(Animator animator) { 199 fillGapsWithPrediction(animate); 200 mIconRemoveAnimators.removeListener(this); 201 } 202 }); 203 return; 204 } 205 206 mPauseFlags |= FLAG_FILL_IN_PROGRESS; 207 for (int rank = 0; rank < mHotSeatItemsCount; rank++) { 208 View child = mHotseat.getChildAt( 209 mHotseat.getCellXFromOrder(rank), 210 mHotseat.getCellYFromOrder(rank)); 211 212 if (child != null && !isPredictedIcon(child)) { 213 continue; 214 } 215 if (mPredictedItems.size() <= predictionIndex) { 216 // Remove predicted apps from the past 217 if (isPredictedIcon(child)) { 218 mHotseat.removeView(child); 219 } 220 continue; 221 } 222 WorkspaceItemInfo predictedItem = 223 (WorkspaceItemInfo) mPredictedItems.get(predictionIndex++); 224 if (isPredictedIcon(child) && child.isEnabled()) { 225 PredictedAppIcon icon = (PredictedAppIcon) child; 226 boolean animateIconChange = icon.shouldAnimateIconChange(predictedItem); 227 icon.applyFromWorkspaceItem(predictedItem, animateIconChange, numViewsAnimated); 228 if (animateIconChange) { 229 numViewsAnimated++; 230 } 231 icon.finishBinding(mPredictionLongClickListener); 232 } else { 233 newItems.add(predictedItem); 234 } 235 preparePredictionInfo(predictedItem, rank); 236 } 237 bindItems(newItems, animate); 238 239 mPauseFlags &= ~FLAG_FILL_IN_PROGRESS; 240 } 241 bindItems(List<WorkspaceItemInfo> itemsToAdd, boolean animate)242 private void bindItems(List<WorkspaceItemInfo> itemsToAdd, boolean animate) { 243 AnimatorSet animationSet = new AnimatorSet(); 244 for (WorkspaceItemInfo item : itemsToAdd) { 245 PredictedAppIcon icon = PredictedAppIcon.createIcon(mHotseat, item); 246 mLauncher.getWorkspace().addInScreenFromBind(icon, item); 247 icon.finishBinding(mPredictionLongClickListener); 248 if (animate) { 249 animationSet.play(ObjectAnimator.ofFloat(icon, SCALE_PROPERTY, 0.2f, 1)); 250 } 251 } 252 if (animate) { 253 animationSet.addListener( 254 forSuccessCallback(this::removeOutlineDrawings)); 255 animationSet.start(); 256 } else { 257 removeOutlineDrawings(); 258 } 259 } 260 removeOutlineDrawings()261 private void removeOutlineDrawings() { 262 if (mOutlineDrawings.isEmpty()) return; 263 for (PredictedAppIcon.PredictedIconOutlineDrawing outlineDrawing : mOutlineDrawings) { 264 mHotseat.removeDelegatedCellDrawing(outlineDrawing); 265 } 266 mHotseat.invalidate(); 267 mOutlineDrawings.clear(); 268 } 269 270 271 /** 272 * Unregisters callbacks and frees resources 273 */ destroy()274 public void destroy() { 275 mLauncher.removeOnDeviceProfileChangeListener(this); 276 } 277 278 /** 279 * start and pauses predicted apps update on the hotseat 280 */ setPauseUIUpdate(boolean paused)281 public void setPauseUIUpdate(boolean paused) { 282 mPauseFlags = paused 283 ? (mPauseFlags | FLAG_UPDATE_PAUSED) 284 : (mPauseFlags & ~FLAG_UPDATE_PAUSED); 285 if (!paused) { 286 fillGapsWithPrediction(); 287 } 288 } 289 290 /** 291 * Sets or updates the predicted items 292 */ setPredictedItems(FixedContainerItems items)293 public void setPredictedItems(FixedContainerItems items) { 294 mPredictedItems = new ArrayList(items.items); 295 if (mPredictedItems.isEmpty()) { 296 HotseatRestoreHelper.restoreBackup(mLauncher); 297 } 298 fillGapsWithPrediction(); 299 } 300 301 /** 302 * Pins a predicted app icon into place. 303 */ pinPrediction(ItemInfo info)304 public void pinPrediction(ItemInfo info) { 305 PredictedAppIcon icon = (PredictedAppIcon) mHotseat.getChildAt( 306 mHotseat.getCellXFromOrder(info.rank), 307 mHotseat.getCellYFromOrder(info.rank)); 308 if (icon == null) { 309 return; 310 } 311 WorkspaceItemInfo workspaceItemInfo = new WorkspaceItemInfo((WorkspaceItemInfo) info); 312 mLauncher.getModelWriter().addItemToDatabase(workspaceItemInfo, 313 LauncherSettings.Favorites.CONTAINER_HOTSEAT, workspaceItemInfo.screenId, 314 workspaceItemInfo.cellX, workspaceItemInfo.cellY); 315 ObjectAnimator.ofFloat(icon, SCALE_PROPERTY, 1, 0.8f, 1).start(); 316 icon.pin(workspaceItemInfo); 317 mLauncher.getStatsLogManager().logger() 318 .withItemInfo(workspaceItemInfo) 319 .log(LAUNCHER_HOTSEAT_PREDICTION_PINNED); 320 } 321 getPredictedIcons()322 private List<PredictedAppIcon> getPredictedIcons() { 323 List<PredictedAppIcon> icons = new ArrayList<>(); 324 ViewGroup vg = mHotseat.getShortcutsAndWidgets(); 325 for (int i = 0; i < vg.getChildCount(); i++) { 326 View child = vg.getChildAt(i); 327 if (isPredictedIcon(child)) { 328 icons.add((PredictedAppIcon) child); 329 } 330 } 331 return icons; 332 } 333 removePredictedApps(List<PredictedAppIcon.PredictedIconOutlineDrawing> outlines, DropTarget.DragObject dragObject)334 private void removePredictedApps(List<PredictedAppIcon.PredictedIconOutlineDrawing> outlines, 335 DropTarget.DragObject dragObject) { 336 if (mIconRemoveAnimators != null) { 337 mIconRemoveAnimators.end(); 338 } 339 mIconRemoveAnimators = new AnimatorSet(); 340 removeOutlineDrawings(); 341 for (PredictedAppIcon icon : getPredictedIcons()) { 342 if (!icon.isEnabled()) { 343 continue; 344 } 345 if (dragObject.dragSource == this && icon.equals(dragObject.originalView)) { 346 removeIconWithoutNotify(icon); 347 continue; 348 } 349 int rank = ((WorkspaceItemInfo) icon.getTag()).rank; 350 outlines.add(new PredictedAppIcon.PredictedIconOutlineDrawing( 351 mHotseat.getCellXFromOrder(rank), mHotseat.getCellYFromOrder(rank), icon)); 352 icon.setEnabled(false); 353 ObjectAnimator animator = ObjectAnimator.ofFloat(icon, SCALE_PROPERTY, 0); 354 animator.addListener(new AnimationSuccessListener() { 355 @Override 356 public void onAnimationSuccess(Animator animator) { 357 if (icon.getParent() != null) { 358 removeIconWithoutNotify(icon); 359 } 360 } 361 }); 362 mIconRemoveAnimators.play(animator); 363 } 364 mIconRemoveAnimators.start(); 365 } 366 367 /** 368 * Removes icon while suppressing any extra tasks performed on view-hierarchy changes. 369 * This avoids recursive/redundant updates as the control updates the UI anyway after 370 * it's animation. 371 */ removeIconWithoutNotify(PredictedAppIcon icon)372 private void removeIconWithoutNotify(PredictedAppIcon icon) { 373 mPauseFlags |= FLAG_REMOVING_PREDICTED_ICON; 374 mHotseat.removeView(icon); 375 mPauseFlags &= ~FLAG_REMOVING_PREDICTED_ICON; 376 } 377 378 @Override onDragStart(DropTarget.DragObject dragObject, DragOptions options)379 public void onDragStart(DropTarget.DragObject dragObject, DragOptions options) { 380 removePredictedApps(mOutlineDrawings, dragObject); 381 if (mOutlineDrawings.isEmpty()) return; 382 for (PredictedAppIcon.PredictedIconOutlineDrawing outlineDrawing : mOutlineDrawings) { 383 mHotseat.addDelegatedCellDrawing(outlineDrawing); 384 } 385 mPauseFlags |= FLAG_DRAG_IN_PROGRESS; 386 mHotseat.invalidate(); 387 } 388 389 @Override onDragEnd()390 public void onDragEnd() { 391 mPauseFlags &= ~FLAG_DRAG_IN_PROGRESS; 392 fillGapsWithPrediction(true); 393 } 394 395 @Nullable 396 @Override getShortcut(QuickstepLauncher activity, ItemInfo itemInfo, View originalView)397 public SystemShortcut<QuickstepLauncher> getShortcut(QuickstepLauncher activity, 398 ItemInfo itemInfo, View originalView) { 399 if (itemInfo.container != LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION) { 400 return null; 401 } 402 return new PinPrediction(activity, itemInfo, originalView); 403 } 404 preparePredictionInfo(WorkspaceItemInfo itemInfo, int rank)405 private void preparePredictionInfo(WorkspaceItemInfo itemInfo, int rank) { 406 itemInfo.container = LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION; 407 itemInfo.rank = rank; 408 itemInfo.cellX = mHotseat.getCellXFromOrder(rank); 409 itemInfo.cellY = mHotseat.getCellYFromOrder(rank); 410 itemInfo.screenId = rank; 411 } 412 413 @Override onDeviceProfileChanged(DeviceProfile profile)414 public void onDeviceProfileChanged(DeviceProfile profile) { 415 this.mHotSeatItemsCount = profile.numShownHotseatIcons; 416 } 417 418 @Override onDropCompleted(View target, DropTarget.DragObject d, boolean success)419 public void onDropCompleted(View target, DropTarget.DragObject d, boolean success) { 420 //Does nothing 421 } 422 423 /** 424 * Logs rank info based on current list of predicted items 425 */ logLaunchedAppRankingInfo(@onNull ItemInfo itemInfo, InstanceId instanceId)426 public void logLaunchedAppRankingInfo(@NonNull ItemInfo itemInfo, InstanceId instanceId) { 427 ComponentName targetCN = itemInfo.getTargetComponent(); 428 if (targetCN == null) { 429 return; 430 } 431 int rank = -1; 432 for (int i = mPredictedItems.size() - 1; i >= 0; i--) { 433 ItemInfo info = mPredictedItems.get(i); 434 if (targetCN.equals(info.getTargetComponent()) && itemInfo.user.equals(info.user)) { 435 rank = i; 436 break; 437 } 438 } 439 if (rank < 0) { 440 return; 441 } 442 443 int cardinality = 0; 444 for (PredictedAppIcon icon : getPredictedIcons()) { 445 ItemInfo info = (ItemInfo) icon.getTag(); 446 cardinality |= 1 << info.screenId; 447 } 448 449 PredictedHotseatContainer.Builder containerBuilder = PredictedHotseatContainer.newBuilder(); 450 containerBuilder.setCardinality(cardinality); 451 if (itemInfo.container == LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION) { 452 containerBuilder.setIndex(rank); 453 } 454 mLauncher.getStatsLogManager().logger() 455 .withInstanceId(instanceId) 456 .withRank(rank) 457 .withContainerInfo(ContainerInfo.newBuilder() 458 .setPredictedHotseatContainer(containerBuilder) 459 .build()) 460 .log(LAUNCHER_HOTSEAT_RANKED); 461 } 462 463 /** 464 * Called when app/shortcut icon is removed by system. This is used to prune visible stale 465 * predictions while while waiting for AppAPrediction service to send new batch of predictions. 466 * 467 * @param matcher filter matching items that have been removed 468 */ onModelItemsRemoved(Predicate<ItemInfo> matcher)469 public void onModelItemsRemoved(Predicate<ItemInfo> matcher) { 470 if (mPredictedItems.removeIf(matcher)) { 471 fillGapsWithPrediction(true); 472 } 473 } 474 475 /** 476 * Called when user completes adding item requiring a config activity to the hotseat 477 */ onDeferredDrop(int cellX, int cellY)478 public void onDeferredDrop(int cellX, int cellY) { 479 View child = mHotseat.getChildAt(cellX, cellY); 480 if (child instanceof PredictedAppIcon) { 481 removeIconWithoutNotify((PredictedAppIcon) child); 482 } 483 } 484 485 private class PinPrediction extends SystemShortcut<QuickstepLauncher> { 486 PinPrediction(QuickstepLauncher target, ItemInfo itemInfo, View originalView)487 private PinPrediction(QuickstepLauncher target, ItemInfo itemInfo, View originalView) { 488 super(R.drawable.ic_pin, R.string.pin_prediction, target, 489 itemInfo, originalView); 490 } 491 492 @Override onClick(View view)493 public void onClick(View view) { 494 dismissTaskMenuView(mTarget); 495 pinPrediction(mItemInfo); 496 } 497 } 498 isPredictedIcon(View view)499 private static boolean isPredictedIcon(View view) { 500 return view instanceof PredictedAppIcon && view.getTag() instanceof WorkspaceItemInfo 501 && ((WorkspaceItemInfo) view.getTag()).container 502 == LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION; 503 } 504 } 505