• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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