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