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.systemui.bubbles;
17 
18 import static com.android.internal.annotations.VisibleForTesting.Visibility.PACKAGE;
19 import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
20 import static com.android.systemui.bubbles.BubbleDebugConfig.DEBUG_BUBBLE_DATA;
21 import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_BUBBLES;
22 import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
23 
24 import android.annotation.NonNull;
25 import android.app.PendingIntent;
26 import android.content.Context;
27 import android.content.pm.ShortcutInfo;
28 import android.util.Log;
29 import android.util.Pair;
30 import android.view.View;
31 
32 import androidx.annotation.Nullable;
33 
34 import com.android.internal.annotations.VisibleForTesting;
35 import com.android.systemui.R;
36 import com.android.systemui.bubbles.BubbleController.DismissReason;
37 import com.android.systemui.statusbar.notification.NotificationEntryManager;
38 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
39 
40 import java.io.FileDescriptor;
41 import java.io.PrintWriter;
42 import java.util.ArrayList;
43 import java.util.Collections;
44 import java.util.Comparator;
45 import java.util.HashMap;
46 import java.util.HashSet;
47 import java.util.List;
48 import java.util.Objects;
49 import java.util.Set;
50 import java.util.function.Consumer;
51 import java.util.function.Predicate;
52 
53 import javax.inject.Inject;
54 import javax.inject.Singleton;
55 
56 /**
57  * Keeps track of active bubbles.
58  */
59 @Singleton
60 public class BubbleData {
61 
62     private BubbleLogger mLogger = new BubbleLoggerImpl();
63 
64     private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleData" : TAG_BUBBLES;
65 
66     private static final Comparator<Bubble> BUBBLES_BY_SORT_KEY_DESCENDING =
67             Comparator.comparing(BubbleData::sortKey).reversed();
68 
69     /** Contains information about changes that have been made to the state of bubbles. */
70     static final class Update {
71         boolean expandedChanged;
72         boolean selectionChanged;
73         boolean orderChanged;
74         boolean expanded;
75         @Nullable Bubble selectedBubble;
76         @Nullable Bubble addedBubble;
77         @Nullable Bubble updatedBubble;
78         // Pair with Bubble and @DismissReason Integer
79         final List<Pair<Bubble, Integer>> removedBubbles = new ArrayList<>();
80 
81         // A read-only view of the bubbles list, changes there will be reflected here.
82         final List<Bubble> bubbles;
83         final List<Bubble> overflowBubbles;
84 
Update(List<Bubble> row, List<Bubble> overflow)85         private Update(List<Bubble> row, List<Bubble> overflow) {
86             bubbles = Collections.unmodifiableList(row);
87             overflowBubbles = Collections.unmodifiableList(overflow);
88         }
89 
anythingChanged()90         boolean anythingChanged() {
91             return expandedChanged
92                     || selectionChanged
93                     || addedBubble != null
94                     || updatedBubble != null
95                     || !removedBubbles.isEmpty()
96                     || orderChanged;
97         }
98 
bubbleRemoved(Bubble bubbleToRemove, @DismissReason int reason)99         void bubbleRemoved(Bubble bubbleToRemove, @DismissReason  int reason) {
100             removedBubbles.add(new Pair<>(bubbleToRemove, reason));
101         }
102     }
103 
104     /**
105      * This interface reports changes to the state and appearance of bubbles which should be applied
106      * as necessary to the UI.
107      */
108     interface Listener {
109         /** Reports changes have have occurred as a result of the most recent operation. */
applyUpdate(Update update)110         void applyUpdate(Update update);
111     }
112 
113     interface TimeSource {
currentTimeMillis()114         long currentTimeMillis();
115     }
116 
117     private final Context mContext;
118     /** Bubbles that are actively in the stack. */
119     private final List<Bubble> mBubbles;
120     /** Bubbles that aged out to overflow. */
121     private final List<Bubble> mOverflowBubbles;
122     /** Bubbles that are being loaded but haven't been added to the stack just yet. */
123     private final HashMap<String, Bubble> mPendingBubbles;
124     private Bubble mSelectedBubble;
125     private boolean mShowingOverflow;
126     private boolean mExpanded;
127     private final int mMaxBubbles;
128     private int mMaxOverflowBubbles;
129 
130     // State tracked during an operation -- keeps track of what listener events to dispatch.
131     private Update mStateChange;
132 
133     private TimeSource mTimeSource = System::currentTimeMillis;
134 
135     @Nullable
136     private Listener mListener;
137 
138     @Nullable
139     private BubbleController.NotificationSuppressionChangedListener mSuppressionListener;
140     private BubbleController.PendingIntentCanceledListener mCancelledListener;
141 
142     /**
143      * We track groups with summaries that aren't visibly displayed but still kept around because
144      * the bubble(s) associated with the summary still exist.
145      *
146      * The summary must be kept around so that developers can cancel it (and hence the bubbles
147      * associated with it). This list is used to check if the summary should be hidden from the
148      * shade.
149      *
150      * Key: group key of the NotificationEntry
151      * Value: key of the NotificationEntry
152      */
153     private HashMap<String, String> mSuppressedGroupKeys = new HashMap<>();
154 
155     @Inject
BubbleData(Context context)156     public BubbleData(Context context) {
157         mContext = context;
158         mBubbles = new ArrayList<>();
159         mOverflowBubbles = new ArrayList<>();
160         mPendingBubbles = new HashMap<>();
161         mStateChange = new Update(mBubbles, mOverflowBubbles);
162         mMaxBubbles = mContext.getResources().getInteger(R.integer.bubbles_max_rendered);
163         mMaxOverflowBubbles = mContext.getResources().getInteger(R.integer.bubbles_max_overflow);
164     }
165 
setSuppressionChangedListener( BubbleController.NotificationSuppressionChangedListener listener)166     public void setSuppressionChangedListener(
167             BubbleController.NotificationSuppressionChangedListener listener) {
168         mSuppressionListener = listener;
169     }
170 
setPendingIntentCancelledListener( BubbleController.PendingIntentCanceledListener listener)171     public void setPendingIntentCancelledListener(
172             BubbleController.PendingIntentCanceledListener listener) {
173         mCancelledListener = listener;
174     }
175 
hasBubbles()176     public boolean hasBubbles() {
177         return !mBubbles.isEmpty();
178     }
179 
isExpanded()180     public boolean isExpanded() {
181         return mExpanded;
182     }
183 
hasAnyBubbleWithKey(String key)184     public boolean hasAnyBubbleWithKey(String key) {
185         return hasBubbleInStackWithKey(key) || hasOverflowBubbleWithKey(key);
186     }
187 
hasBubbleInStackWithKey(String key)188     public boolean hasBubbleInStackWithKey(String key) {
189         return getBubbleInStackWithKey(key) != null;
190     }
191 
hasOverflowBubbleWithKey(String key)192     public boolean hasOverflowBubbleWithKey(String key) {
193         return getOverflowBubbleWithKey(key) != null;
194     }
195 
196     @Nullable
getSelectedBubble()197     public Bubble getSelectedBubble() {
198         return mSelectedBubble;
199     }
200 
setExpanded(boolean expanded)201     public void setExpanded(boolean expanded) {
202         if (DEBUG_BUBBLE_DATA) {
203             Log.d(TAG, "setExpanded: " + expanded);
204         }
205         setExpandedInternal(expanded);
206         dispatchPendingChanges();
207     }
208 
setSelectedBubble(Bubble bubble)209     public void setSelectedBubble(Bubble bubble) {
210         if (DEBUG_BUBBLE_DATA) {
211             Log.d(TAG, "setSelectedBubble: " + bubble);
212         }
213         setSelectedBubbleInternal(bubble);
214         dispatchPendingChanges();
215     }
216 
setShowingOverflow(boolean showingOverflow)217     void setShowingOverflow(boolean showingOverflow) {
218         mShowingOverflow = showingOverflow;
219     }
220 
221     /**
222      * Constructs a new bubble or returns an existing one. Does not add new bubbles to
223      * bubble data, must go through {@link #notificationEntryUpdated(Bubble, boolean, boolean)}
224      * for that.
225      *
226      * @param entry The notification entry to use, only null if it's a bubble being promoted from
227      *              the overflow that was persisted over reboot.
228      * @param persistedBubble The bubble to use, only non-null if it's a bubble being promoted from
229      *              the overflow that was persisted over reboot.
230      */
getOrCreateBubble(NotificationEntry entry, Bubble persistedBubble)231     Bubble getOrCreateBubble(NotificationEntry entry, Bubble persistedBubble) {
232         String key = entry != null ? entry.getKey() : persistedBubble.getKey();
233         Bubble bubbleToReturn = getBubbleInStackWithKey(key);
234 
235         if (bubbleToReturn == null) {
236             bubbleToReturn = getOverflowBubbleWithKey(key);
237             if (bubbleToReturn != null) {
238                 // Promoting from overflow
239                 mOverflowBubbles.remove(bubbleToReturn);
240             } else if (mPendingBubbles.containsKey(key)) {
241                 // Update while it was pending
242                 bubbleToReturn = mPendingBubbles.get(key);
243             } else if (entry != null) {
244                 // New bubble
245                 bubbleToReturn = new Bubble(entry, mSuppressionListener, mCancelledListener);
246             } else {
247                 // Persisted bubble being promoted
248                 bubbleToReturn = persistedBubble;
249             }
250         }
251 
252         if (entry != null) {
253             bubbleToReturn.setEntry(entry);
254         }
255         mPendingBubbles.put(key, bubbleToReturn);
256         return bubbleToReturn;
257     }
258 
259     /**
260      * When this method is called it is expected that all info in the bubble has completed loading.
261      * @see Bubble#inflate(BubbleViewInfoTask.Callback, Context,
262      * BubbleStackView, BubbleIconFactory).
263      */
notificationEntryUpdated(Bubble bubble, boolean suppressFlyout, boolean showInShade)264     void notificationEntryUpdated(Bubble bubble, boolean suppressFlyout, boolean showInShade) {
265         if (DEBUG_BUBBLE_DATA) {
266             Log.d(TAG, "notificationEntryUpdated: " + bubble);
267         }
268         mPendingBubbles.remove(bubble.getKey()); // No longer pending once we're here
269         Bubble prevBubble = getBubbleInStackWithKey(bubble.getKey());
270         suppressFlyout |= !bubble.isVisuallyInterruptive();
271 
272         if (prevBubble == null) {
273             // Create a new bubble
274             bubble.setSuppressFlyout(suppressFlyout);
275             doAdd(bubble);
276             trim();
277         } else {
278             // Updates an existing bubble
279             bubble.setSuppressFlyout(suppressFlyout);
280             doUpdate(bubble);
281         }
282 
283         if (bubble.shouldAutoExpand()) {
284             bubble.setShouldAutoExpand(false);
285             setSelectedBubbleInternal(bubble);
286             if (!mExpanded) {
287                 setExpandedInternal(true);
288             }
289         }
290 
291         boolean isBubbleExpandedAndSelected = mExpanded && mSelectedBubble == bubble;
292         boolean suppress = isBubbleExpandedAndSelected || !showInShade || !bubble.showInShade();
293         bubble.setSuppressNotification(suppress);
294         bubble.setShowDot(!isBubbleExpandedAndSelected /* show */);
295 
296         dispatchPendingChanges();
297     }
298 
299     /**
300      * Dismisses the bubble with the matching key, if it exists.
301      */
dismissBubbleWithKey(String key, @DismissReason int reason)302     public void dismissBubbleWithKey(String key, @DismissReason int reason) {
303         if (DEBUG_BUBBLE_DATA) {
304             Log.d(TAG, "notificationEntryRemoved: key=" + key + " reason=" + reason);
305         }
306         doRemove(key, reason);
307         dispatchPendingChanges();
308     }
309 
310     /**
311      * Adds a group key indicating that the summary for this group should be suppressed.
312      *
313      * @param groupKey the group key of the group whose summary should be suppressed.
314      * @param notifKey the notification entry key of that summary.
315      */
addSummaryToSuppress(String groupKey, String notifKey)316     void addSummaryToSuppress(String groupKey, String notifKey) {
317         mSuppressedGroupKeys.put(groupKey, notifKey);
318     }
319 
320     /**
321      * Retrieves the notif entry key of the summary associated with the provided group key.
322      *
323      * @param groupKey the group to look up
324      * @return the key for the {@link NotificationEntry} that is the summary of this group.
325      */
getSummaryKey(String groupKey)326     String getSummaryKey(String groupKey) {
327         return mSuppressedGroupKeys.get(groupKey);
328     }
329 
330     /**
331      * Removes a group key indicating that summary for this group should no longer be suppressed.
332      */
removeSuppressedSummary(String groupKey)333     void removeSuppressedSummary(String groupKey) {
334         mSuppressedGroupKeys.remove(groupKey);
335     }
336 
337     /**
338      * Whether the summary for the provided group key is suppressed.
339      */
isSummarySuppressed(String groupKey)340     boolean isSummarySuppressed(String groupKey) {
341         return mSuppressedGroupKeys.containsKey(groupKey);
342     }
343 
344     /**
345      * Retrieves any bubbles that are part of the notification group represented by the provided
346      * group key.
347      */
getBubblesInGroup(@ullable String groupKey, @NonNull NotificationEntryManager nem)348     ArrayList<Bubble> getBubblesInGroup(@Nullable String groupKey, @NonNull
349             NotificationEntryManager nem) {
350         ArrayList<Bubble> bubbleChildren = new ArrayList<>();
351         if (groupKey == null) {
352             return bubbleChildren;
353         }
354         for (Bubble b : mBubbles) {
355             final NotificationEntry entry = nem.getPendingOrActiveNotif(b.getKey());
356             if (entry != null && groupKey.equals(entry.getSbn().getGroupKey())) {
357                 bubbleChildren.add(b);
358             }
359         }
360         return bubbleChildren;
361     }
362 
363     /**
364      * Removes bubbles from the given package whose shortcut are not in the provided list of valid
365      * shortcuts.
366      */
removeBubblesWithInvalidShortcuts( String packageName, List<ShortcutInfo> validShortcuts, int reason)367     public void removeBubblesWithInvalidShortcuts(
368             String packageName, List<ShortcutInfo> validShortcuts, int reason) {
369 
370         final Set<String> validShortcutIds = new HashSet<String>();
371         for (ShortcutInfo info : validShortcuts) {
372             validShortcutIds.add(info.getId());
373         }
374 
375         final Predicate<Bubble> invalidBubblesFromPackage = bubble -> {
376             final boolean bubbleIsFromPackage = packageName.equals(bubble.getPackageName());
377             final boolean isShortcutBubble = bubble.hasMetadataShortcutId();
378             if (!bubbleIsFromPackage || !isShortcutBubble) {
379                 return false;
380             }
381             final boolean hasShortcutIdAndValidShortcut =
382                     bubble.hasMetadataShortcutId()
383                             && bubble.getShortcutInfo() != null
384                             && bubble.getShortcutInfo().isEnabled()
385                             && validShortcutIds.contains(bubble.getShortcutInfo().getId());
386             return bubbleIsFromPackage && !hasShortcutIdAndValidShortcut;
387         };
388 
389         final Consumer<Bubble> removeBubble = bubble ->
390                 dismissBubbleWithKey(bubble.getKey(), reason);
391 
392         performActionOnBubblesMatching(getBubbles(), invalidBubblesFromPackage, removeBubble);
393         performActionOnBubblesMatching(
394                 getOverflowBubbles(), invalidBubblesFromPackage, removeBubble);
395     }
396 
397     /** Dismisses all bubbles from the given package. */
removeBubblesWithPackageName(String packageName, int reason)398     public void removeBubblesWithPackageName(String packageName, int reason) {
399         final Predicate<Bubble> bubbleMatchesPackage = bubble ->
400                 bubble.getPackageName().equals(packageName);
401 
402         final Consumer<Bubble> removeBubble = bubble ->
403                 dismissBubbleWithKey(bubble.getKey(), reason);
404 
405         performActionOnBubblesMatching(getBubbles(), bubbleMatchesPackage, removeBubble);
406         performActionOnBubblesMatching(getOverflowBubbles(), bubbleMatchesPackage, removeBubble);
407     }
408 
doAdd(Bubble bubble)409     private void doAdd(Bubble bubble) {
410         if (DEBUG_BUBBLE_DATA) {
411             Log.d(TAG, "doAdd: " + bubble);
412         }
413         mBubbles.add(0, bubble);
414         mStateChange.addedBubble = bubble;
415         // Adding the first bubble doesn't change the order
416         mStateChange.orderChanged = mBubbles.size() > 1;
417         if (!isExpanded()) {
418             setSelectedBubbleInternal(mBubbles.get(0));
419         }
420     }
421 
trim()422     private void trim() {
423         if (mBubbles.size() > mMaxBubbles) {
424             mBubbles.stream()
425                     // sort oldest first (ascending lastActivity)
426                     .sorted(Comparator.comparingLong(Bubble::getLastActivity))
427                     // skip the selected bubble
428                     .filter((b) -> !b.equals(mSelectedBubble))
429                     .findFirst()
430                     .ifPresent((b) -> doRemove(b.getKey(), BubbleController.DISMISS_AGED));
431         }
432     }
433 
doUpdate(Bubble bubble)434     private void doUpdate(Bubble bubble) {
435         if (DEBUG_BUBBLE_DATA) {
436             Log.d(TAG, "doUpdate: " + bubble);
437         }
438         mStateChange.updatedBubble = bubble;
439         if (!isExpanded()) {
440             int prevPos = mBubbles.indexOf(bubble);
441             mBubbles.remove(bubble);
442             mBubbles.add(0, bubble);
443             mStateChange.orderChanged = prevPos != 0;
444             setSelectedBubbleInternal(mBubbles.get(0));
445         }
446     }
447 
448     /** Runs the given action on Bubbles that match the given predicate. */
performActionOnBubblesMatching( List<Bubble> bubbles, Predicate<Bubble> predicate, Consumer<Bubble> action)449     private void performActionOnBubblesMatching(
450             List<Bubble> bubbles, Predicate<Bubble> predicate, Consumer<Bubble> action) {
451         final List<Bubble> matchingBubbles = new ArrayList<>();
452         for (Bubble bubble : bubbles) {
453             if (predicate.test(bubble)) {
454                 matchingBubbles.add(bubble);
455             }
456         }
457 
458         for (Bubble matchingBubble : matchingBubbles) {
459             action.accept(matchingBubble);
460         }
461     }
462 
doRemove(String key, @DismissReason int reason)463     private void doRemove(String key, @DismissReason int reason) {
464         if (DEBUG_BUBBLE_DATA) {
465             Log.d(TAG, "doRemove: " + key);
466         }
467         //  If it was pending remove it
468         if (mPendingBubbles.containsKey(key)) {
469             mPendingBubbles.remove(key);
470         }
471         int indexToRemove = indexForKey(key);
472         if (indexToRemove == -1) {
473             if (hasOverflowBubbleWithKey(key)
474                 && (reason == BubbleController.DISMISS_NOTIF_CANCEL
475                     || reason == BubbleController.DISMISS_GROUP_CANCELLED
476                     || reason == BubbleController.DISMISS_NO_LONGER_BUBBLE
477                     || reason == BubbleController.DISMISS_BLOCKED
478                     || reason == BubbleController.DISMISS_SHORTCUT_REMOVED
479                     || reason == BubbleController.DISMISS_PACKAGE_REMOVED)) {
480 
481                 Bubble b = getOverflowBubbleWithKey(key);
482                 if (DEBUG_BUBBLE_DATA) {
483                     Log.d(TAG, "Cancel overflow bubble: " + b);
484                 }
485                 if (b != null) {
486                     b.stopInflation();
487                 }
488                 mLogger.logOverflowRemove(b, reason);
489                 mStateChange.bubbleRemoved(b, reason);
490                 mOverflowBubbles.remove(b);
491             }
492             return;
493         }
494         Bubble bubbleToRemove = mBubbles.get(indexToRemove);
495         bubbleToRemove.stopInflation();
496         if (mBubbles.size() == 1) {
497             // Going to become empty, handle specially.
498             setExpandedInternal(false);
499             // Don't use setSelectedBubbleInternal because we don't want to trigger an applyUpdate
500             mSelectedBubble = null;
501         }
502         if (indexToRemove < mBubbles.size() - 1) {
503             // Removing anything but the last bubble means positions will change.
504             mStateChange.orderChanged = true;
505         }
506         mBubbles.remove(indexToRemove);
507         mStateChange.bubbleRemoved(bubbleToRemove, reason);
508         if (!isExpanded()) {
509             mStateChange.orderChanged |= repackAll();
510         }
511 
512         overflowBubble(reason, bubbleToRemove);
513 
514         // Note: If mBubbles.isEmpty(), then mSelectedBubble is now null.
515         if (Objects.equals(mSelectedBubble, bubbleToRemove)) {
516             // Move selection to the new bubble at the same position.
517             int newIndex = Math.min(indexToRemove, mBubbles.size() - 1);
518             Bubble newSelected = mBubbles.get(newIndex);
519             setSelectedBubbleInternal(newSelected);
520         }
521         maybeSendDeleteIntent(reason, bubbleToRemove);
522     }
523 
overflowBubble(@ismissReason int reason, Bubble bubble)524     void overflowBubble(@DismissReason int reason, Bubble bubble) {
525         if (bubble.getPendingIntentCanceled()
526                 || !(reason == BubbleController.DISMISS_AGED
527                 || reason == BubbleController.DISMISS_USER_GESTURE)) {
528             return;
529         }
530         if (DEBUG_BUBBLE_DATA) {
531             Log.d(TAG, "Overflowing: " + bubble);
532         }
533         mLogger.logOverflowAdd(bubble, reason);
534         mOverflowBubbles.add(0, bubble);
535         bubble.stopInflation();
536         if (mOverflowBubbles.size() == mMaxOverflowBubbles + 1) {
537             // Remove oldest bubble.
538             Bubble oldest = mOverflowBubbles.get(mOverflowBubbles.size() - 1);
539             if (DEBUG_BUBBLE_DATA) {
540                 Log.d(TAG, "Overflow full. Remove: " + oldest);
541             }
542             mStateChange.bubbleRemoved(oldest, BubbleController.DISMISS_OVERFLOW_MAX_REACHED);
543             mLogger.log(bubble, BubbleLogger.Event.BUBBLE_OVERFLOW_REMOVE_MAX_REACHED);
544             mOverflowBubbles.remove(oldest);
545         }
546     }
547 
dismissAll(@ismissReason int reason)548     public void dismissAll(@DismissReason int reason) {
549         if (DEBUG_BUBBLE_DATA) {
550             Log.d(TAG, "dismissAll: reason=" + reason);
551         }
552         if (mBubbles.isEmpty()) {
553             return;
554         }
555         setExpandedInternal(false);
556         setSelectedBubbleInternal(null);
557         while (!mBubbles.isEmpty()) {
558             doRemove(mBubbles.get(0).getKey(), reason);
559         }
560         dispatchPendingChanges();
561     }
562 
563     /**
564      * Indicates that the provided display is no longer in use and should be cleaned up.
565      *
566      * @param displayId the id of the display to clean up.
567      */
notifyDisplayEmpty(int displayId)568     void notifyDisplayEmpty(int displayId) {
569         for (Bubble b : mBubbles) {
570             if (b.getDisplayId() == displayId) {
571                 if (b.getExpandedView() != null) {
572                     b.getExpandedView().notifyDisplayEmpty();
573                 }
574                 return;
575             }
576         }
577     }
578 
dispatchPendingChanges()579     private void dispatchPendingChanges() {
580         if (mListener != null && mStateChange.anythingChanged()) {
581             mListener.applyUpdate(mStateChange);
582         }
583         mStateChange = new Update(mBubbles, mOverflowBubbles);
584     }
585 
586     /**
587      * Requests a change to the selected bubble.
588      *
589      * @param bubble the new selected bubble
590      */
setSelectedBubbleInternal(@ullable Bubble bubble)591     private void setSelectedBubbleInternal(@Nullable Bubble bubble) {
592         if (DEBUG_BUBBLE_DATA) {
593             Log.d(TAG, "setSelectedBubbleInternal: " + bubble);
594         }
595         if (!mShowingOverflow && Objects.equals(bubble, mSelectedBubble)) {
596             return;
597         }
598         // Otherwise, if we are showing the overflow menu, return to the previously selected bubble.
599 
600         if (bubble != null && !mBubbles.contains(bubble) && !mOverflowBubbles.contains(bubble)) {
601             Log.e(TAG, "Cannot select bubble which doesn't exist!"
602                     + " (" + bubble + ") bubbles=" + mBubbles);
603             return;
604         }
605         if (mExpanded && bubble != null) {
606             bubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
607         }
608         mSelectedBubble = bubble;
609         mStateChange.selectedBubble = bubble;
610         mStateChange.selectionChanged = true;
611     }
612 
613     /**
614      * Requests a change to the expanded state.
615      *
616      * @param shouldExpand the new requested state
617      */
setExpandedInternal(boolean shouldExpand)618     private void setExpandedInternal(boolean shouldExpand) {
619         if (DEBUG_BUBBLE_DATA) {
620             Log.d(TAG, "setExpandedInternal: shouldExpand=" + shouldExpand);
621         }
622         if (mExpanded == shouldExpand) {
623             return;
624         }
625         if (shouldExpand) {
626             if (mBubbles.isEmpty()) {
627                 Log.e(TAG, "Attempt to expand stack when empty!");
628                 return;
629             }
630             if (mSelectedBubble == null) {
631                 Log.e(TAG, "Attempt to expand stack without selected bubble!");
632                 return;
633             }
634             mSelectedBubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
635             mStateChange.orderChanged |= repackAll();
636         } else if (!mBubbles.isEmpty()) {
637             // Apply ordering and grouping rules from expanded -> collapsed, then save
638             // the result.
639             mStateChange.orderChanged |= repackAll();
640             // Save the state which should be returned to when expanded (with no other changes)
641 
642             if (mShowingOverflow) {
643                 // Show previously selected bubble instead of overflow menu on next expansion.
644                 setSelectedBubbleInternal(mSelectedBubble);
645             }
646             if (mBubbles.indexOf(mSelectedBubble) > 0) {
647                 // Move the selected bubble to the top while collapsed.
648                 int index = mBubbles.indexOf(mSelectedBubble);
649                 if (index != 0) {
650                     mBubbles.remove(mSelectedBubble);
651                     mBubbles.add(0, mSelectedBubble);
652                     mStateChange.orderChanged = true;
653                 }
654             }
655         }
656         mExpanded = shouldExpand;
657         mStateChange.expanded = shouldExpand;
658         mStateChange.expandedChanged = true;
659     }
660 
sortKey(Bubble bubble)661     private static long sortKey(Bubble bubble) {
662         return bubble.getLastActivity();
663     }
664 
665     /**
666      * This applies a full sort and group pass to all existing bubbles.
667      * Bubbles are sorted by lastUpdated descending.
668      *
669      * @return true if the position of any bubbles changed as a result
670      */
repackAll()671     private boolean repackAll() {
672         if (DEBUG_BUBBLE_DATA) {
673             Log.d(TAG, "repackAll()");
674         }
675         if (mBubbles.isEmpty()) {
676             return false;
677         }
678         List<Bubble> repacked = new ArrayList<>(mBubbles.size());
679         // Add bubbles, freshest to oldest
680         mBubbles.stream()
681                 .sorted(BUBBLES_BY_SORT_KEY_DESCENDING)
682                 .forEachOrdered(repacked::add);
683         if (repacked.equals(mBubbles)) {
684             return false;
685         }
686         mBubbles.clear();
687         mBubbles.addAll(repacked);
688         return true;
689     }
690 
maybeSendDeleteIntent(@ismissReason int reason, @NonNull final Bubble bubble)691     private void maybeSendDeleteIntent(@DismissReason int reason, @NonNull final Bubble bubble) {
692         if (reason != BubbleController.DISMISS_USER_GESTURE) return;
693         PendingIntent deleteIntent = bubble.getDeleteIntent();
694         if (deleteIntent == null) return;
695         try {
696             deleteIntent.send();
697         } catch (PendingIntent.CanceledException e) {
698             Log.w(TAG, "Failed to send delete intent for bubble with key: " + bubble.getKey());
699         }
700     }
701 
indexForKey(String key)702     private int indexForKey(String key) {
703         for (int i = 0; i < mBubbles.size(); i++) {
704             Bubble bubble = mBubbles.get(i);
705             if (bubble.getKey().equals(key)) {
706                 return i;
707             }
708         }
709         return -1;
710     }
711 
712     /**
713      * The set of bubbles in row.
714      */
715     @VisibleForTesting(visibility = PACKAGE)
getBubbles()716     public List<Bubble> getBubbles() {
717         return Collections.unmodifiableList(mBubbles);
718     }
719 
720     /**
721      * The set of bubbles in overflow.
722      */
723     @VisibleForTesting(visibility = PRIVATE)
getOverflowBubbles()724     List<Bubble> getOverflowBubbles() {
725         return Collections.unmodifiableList(mOverflowBubbles);
726     }
727 
728     @VisibleForTesting(visibility = PRIVATE)
729     @Nullable
getAnyBubbleWithkey(String key)730     Bubble getAnyBubbleWithkey(String key) {
731         Bubble b = getBubbleInStackWithKey(key);
732         if (b == null) {
733             b = getOverflowBubbleWithKey(key);
734         }
735         return b;
736     }
737 
738     @VisibleForTesting(visibility = PRIVATE)
739     @Nullable
getBubbleInStackWithKey(String key)740     Bubble getBubbleInStackWithKey(String key) {
741         for (int i = 0; i < mBubbles.size(); i++) {
742             Bubble bubble = mBubbles.get(i);
743             if (bubble.getKey().equals(key)) {
744                 return bubble;
745             }
746         }
747         return null;
748     }
749 
750     @Nullable
getBubbleWithView(View view)751     Bubble getBubbleWithView(View view) {
752         for (int i = 0; i < mBubbles.size(); i++) {
753             Bubble bubble = mBubbles.get(i);
754             if (bubble.getIconView() != null && bubble.getIconView().equals(view)) {
755                 return bubble;
756             }
757         }
758         return null;
759     }
760 
761     @VisibleForTesting(visibility = PRIVATE)
getOverflowBubbleWithKey(String key)762     Bubble getOverflowBubbleWithKey(String key) {
763         for (int i = 0; i < mOverflowBubbles.size(); i++) {
764             Bubble bubble = mOverflowBubbles.get(i);
765             if (bubble.getKey().equals(key)) {
766                 return bubble;
767             }
768         }
769         return null;
770     }
771 
772     @VisibleForTesting(visibility = PRIVATE)
setTimeSource(TimeSource timeSource)773     void setTimeSource(TimeSource timeSource) {
774         mTimeSource = timeSource;
775     }
776 
setListener(Listener listener)777     public void setListener(Listener listener) {
778         mListener = listener;
779     }
780 
781     /**
782      * Set maximum number of bubbles allowed in overflow.
783      * This method should only be used in tests, not in production.
784      */
785     @VisibleForTesting
setMaxOverflowBubbles(int maxOverflowBubbles)786     void setMaxOverflowBubbles(int maxOverflowBubbles) {
787         mMaxOverflowBubbles = maxOverflowBubbles;
788     }
789 
790     /**
791      * Description of current bubble data state.
792      */
dump(FileDescriptor fd, PrintWriter pw, String[] args)793     public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
794         pw.print("selected: ");
795         pw.println(mSelectedBubble != null
796                 ? mSelectedBubble.getKey()
797                 : "null");
798         pw.print("expanded: ");
799         pw.println(mExpanded);
800         pw.print("count:    ");
801         pw.println(mBubbles.size());
802         for (Bubble bubble : mBubbles) {
803             bubble.dump(fd, pw, args);
804         }
805         pw.print("summaryKeys: ");
806         pw.println(mSuppressedGroupKeys.size());
807         for (String key : mSuppressedGroupKeys.keySet()) {
808             pw.println("   suppressing: " + key);
809         }
810     }
811 }
812