• 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 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