• 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.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             // If there is no flyout, we probably shouldn't show the bubble at the top
281             doUpdate(bubble, !suppressFlyout /* reorder */);
282         }
283 
284         if (bubble.shouldAutoExpand()) {
285             bubble.setShouldAutoExpand(false);
286             setSelectedBubbleInternal(bubble);
287             if (!mExpanded) {
288                 setExpandedInternal(true);
289             }
290         }
291 
292         boolean isBubbleExpandedAndSelected = mExpanded && mSelectedBubble == bubble;
293         boolean suppress = isBubbleExpandedAndSelected || !showInShade || !bubble.showInShade();
294         bubble.setSuppressNotification(suppress);
295         bubble.setShowDot(!isBubbleExpandedAndSelected /* show */);
296 
297         dispatchPendingChanges();
298     }
299 
300     /**
301      * Dismisses the bubble with the matching key, if it exists.
302      */
dismissBubbleWithKey(String key, @DismissReason int reason)303     public void dismissBubbleWithKey(String key, @DismissReason int reason) {
304         if (DEBUG_BUBBLE_DATA) {
305             Log.d(TAG, "notificationEntryRemoved: key=" + key + " reason=" + reason);
306         }
307         doRemove(key, reason);
308         dispatchPendingChanges();
309     }
310 
311     /**
312      * Adds a group key indicating that the summary for this group should be suppressed.
313      *
314      * @param groupKey the group key of the group whose summary should be suppressed.
315      * @param notifKey the notification entry key of that summary.
316      */
addSummaryToSuppress(String groupKey, String notifKey)317     void addSummaryToSuppress(String groupKey, String notifKey) {
318         mSuppressedGroupKeys.put(groupKey, notifKey);
319     }
320 
321     /**
322      * Retrieves the notif entry key of the summary associated with the provided group key.
323      *
324      * @param groupKey the group to look up
325      * @return the key for the {@link NotificationEntry} that is the summary of this group.
326      */
getSummaryKey(String groupKey)327     String getSummaryKey(String groupKey) {
328         return mSuppressedGroupKeys.get(groupKey);
329     }
330 
331     /**
332      * Removes a group key indicating that summary for this group should no longer be suppressed.
333      */
removeSuppressedSummary(String groupKey)334     void removeSuppressedSummary(String groupKey) {
335         mSuppressedGroupKeys.remove(groupKey);
336     }
337 
338     /**
339      * Whether the summary for the provided group key is suppressed.
340      */
isSummarySuppressed(String groupKey)341     boolean isSummarySuppressed(String groupKey) {
342         return mSuppressedGroupKeys.containsKey(groupKey);
343     }
344 
345     /**
346      * Retrieves any bubbles that are part of the notification group represented by the provided
347      * group key.
348      */
getBubblesInGroup(@ullable String groupKey, @NonNull NotificationEntryManager nem)349     ArrayList<Bubble> getBubblesInGroup(@Nullable String groupKey, @NonNull
350             NotificationEntryManager nem) {
351         ArrayList<Bubble> bubbleChildren = new ArrayList<>();
352         if (groupKey == null) {
353             return bubbleChildren;
354         }
355         for (Bubble b : mBubbles) {
356             final NotificationEntry entry = nem.getPendingOrActiveNotif(b.getKey());
357             if (entry != null && groupKey.equals(entry.getSbn().getGroupKey())) {
358                 bubbleChildren.add(b);
359             }
360         }
361         return bubbleChildren;
362     }
363 
364     /**
365      * Removes bubbles from the given package whose shortcut are not in the provided list of valid
366      * shortcuts.
367      */
removeBubblesWithInvalidShortcuts( String packageName, List<ShortcutInfo> validShortcuts, int reason)368     public void removeBubblesWithInvalidShortcuts(
369             String packageName, List<ShortcutInfo> validShortcuts, int reason) {
370 
371         final Set<String> validShortcutIds = new HashSet<String>();
372         for (ShortcutInfo info : validShortcuts) {
373             validShortcutIds.add(info.getId());
374         }
375 
376         final Predicate<Bubble> invalidBubblesFromPackage = bubble -> {
377             final boolean bubbleIsFromPackage = packageName.equals(bubble.getPackageName());
378             final boolean isShortcutBubble = bubble.hasMetadataShortcutId();
379             if (!bubbleIsFromPackage || !isShortcutBubble) {
380                 return false;
381             }
382             final boolean hasShortcutIdAndValidShortcut =
383                     bubble.hasMetadataShortcutId()
384                             && bubble.getShortcutInfo() != null
385                             && bubble.getShortcutInfo().isEnabled()
386                             && validShortcutIds.contains(bubble.getShortcutInfo().getId());
387             return bubbleIsFromPackage && !hasShortcutIdAndValidShortcut;
388         };
389 
390         final Consumer<Bubble> removeBubble = bubble ->
391                 dismissBubbleWithKey(bubble.getKey(), reason);
392 
393         performActionOnBubblesMatching(getBubbles(), invalidBubblesFromPackage, removeBubble);
394         performActionOnBubblesMatching(
395                 getOverflowBubbles(), invalidBubblesFromPackage, removeBubble);
396     }
397 
398     /** Dismisses all bubbles from the given package. */
removeBubblesWithPackageName(String packageName, int reason)399     public void removeBubblesWithPackageName(String packageName, int reason) {
400         final Predicate<Bubble> bubbleMatchesPackage = bubble ->
401                 bubble.getPackageName().equals(packageName);
402 
403         final Consumer<Bubble> removeBubble = bubble ->
404                 dismissBubbleWithKey(bubble.getKey(), reason);
405 
406         performActionOnBubblesMatching(getBubbles(), bubbleMatchesPackage, removeBubble);
407         performActionOnBubblesMatching(getOverflowBubbles(), bubbleMatchesPackage, removeBubble);
408     }
409 
doAdd(Bubble bubble)410     private void doAdd(Bubble bubble) {
411         if (DEBUG_BUBBLE_DATA) {
412             Log.d(TAG, "doAdd: " + bubble);
413         }
414         mBubbles.add(0, bubble);
415         mStateChange.addedBubble = bubble;
416         // Adding the first bubble doesn't change the order
417         mStateChange.orderChanged = mBubbles.size() > 1;
418         if (!isExpanded()) {
419             setSelectedBubbleInternal(mBubbles.get(0));
420         }
421     }
422 
trim()423     private void trim() {
424         if (mBubbles.size() > mMaxBubbles) {
425             mBubbles.stream()
426                     // sort oldest first (ascending lastActivity)
427                     .sorted(Comparator.comparingLong(Bubble::getLastActivity))
428                     // skip the selected bubble
429                     .filter((b) -> !b.equals(mSelectedBubble))
430                     .findFirst()
431                     .ifPresent((b) -> doRemove(b.getKey(), BubbleController.DISMISS_AGED));
432         }
433     }
434 
doUpdate(Bubble bubble, boolean reorder)435     private void doUpdate(Bubble bubble, boolean reorder) {
436         if (DEBUG_BUBBLE_DATA) {
437             Log.d(TAG, "doUpdate: " + bubble);
438         }
439         mStateChange.updatedBubble = bubble;
440         if (!isExpanded() && reorder) {
441             int prevPos = mBubbles.indexOf(bubble);
442             mBubbles.remove(bubble);
443             mBubbles.add(0, bubble);
444             mStateChange.orderChanged = prevPos != 0;
445             setSelectedBubbleInternal(mBubbles.get(0));
446         }
447     }
448 
449     /** Runs the given action on Bubbles that match the given predicate. */
performActionOnBubblesMatching( List<Bubble> bubbles, Predicate<Bubble> predicate, Consumer<Bubble> action)450     private void performActionOnBubblesMatching(
451             List<Bubble> bubbles, Predicate<Bubble> predicate, Consumer<Bubble> action) {
452         final List<Bubble> matchingBubbles = new ArrayList<>();
453         for (Bubble bubble : bubbles) {
454             if (predicate.test(bubble)) {
455                 matchingBubbles.add(bubble);
456             }
457         }
458 
459         for (Bubble matchingBubble : matchingBubbles) {
460             action.accept(matchingBubble);
461         }
462     }
463 
doRemove(String key, @DismissReason int reason)464     private void doRemove(String key, @DismissReason int reason) {
465         if (DEBUG_BUBBLE_DATA) {
466             Log.d(TAG, "doRemove: " + key);
467         }
468         //  If it was pending remove it
469         if (mPendingBubbles.containsKey(key)) {
470             mPendingBubbles.remove(key);
471         }
472         int indexToRemove = indexForKey(key);
473         if (indexToRemove == -1) {
474             if (hasOverflowBubbleWithKey(key)
475                 && (reason == BubbleController.DISMISS_NOTIF_CANCEL
476                     || reason == BubbleController.DISMISS_GROUP_CANCELLED
477                     || reason == BubbleController.DISMISS_NO_LONGER_BUBBLE
478                     || reason == BubbleController.DISMISS_BLOCKED
479                     || reason == BubbleController.DISMISS_SHORTCUT_REMOVED
480                     || reason == BubbleController.DISMISS_PACKAGE_REMOVED)) {
481 
482                 Bubble b = getOverflowBubbleWithKey(key);
483                 if (DEBUG_BUBBLE_DATA) {
484                     Log.d(TAG, "Cancel overflow bubble: " + b);
485                 }
486                 if (b != null) {
487                     b.stopInflation();
488                 }
489                 mLogger.logOverflowRemove(b, reason);
490                 mStateChange.bubbleRemoved(b, reason);
491                 mOverflowBubbles.remove(b);
492             }
493             return;
494         }
495         Bubble bubbleToRemove = mBubbles.get(indexToRemove);
496         bubbleToRemove.stopInflation();
497         if (mBubbles.size() == 1) {
498             // Going to become empty, handle specially.
499             setExpandedInternal(false);
500             // Don't use setSelectedBubbleInternal because we don't want to trigger an applyUpdate
501             mSelectedBubble = null;
502         }
503         if (indexToRemove < mBubbles.size() - 1) {
504             // Removing anything but the last bubble means positions will change.
505             mStateChange.orderChanged = true;
506         }
507         mBubbles.remove(indexToRemove);
508         mStateChange.bubbleRemoved(bubbleToRemove, reason);
509         if (!isExpanded()) {
510             mStateChange.orderChanged |= repackAll();
511         }
512 
513         overflowBubble(reason, bubbleToRemove);
514 
515         // Note: If mBubbles.isEmpty(), then mSelectedBubble is now null.
516         if (Objects.equals(mSelectedBubble, bubbleToRemove)) {
517             // Move selection to the new bubble at the same position.
518             int newIndex = Math.min(indexToRemove, mBubbles.size() - 1);
519             Bubble newSelected = mBubbles.get(newIndex);
520             setSelectedBubbleInternal(newSelected);
521         }
522         maybeSendDeleteIntent(reason, bubbleToRemove);
523     }
524 
overflowBubble(@ismissReason int reason, Bubble bubble)525     void overflowBubble(@DismissReason int reason, Bubble bubble) {
526         if (bubble.getPendingIntentCanceled()
527                 || !(reason == BubbleController.DISMISS_AGED
528                 || reason == BubbleController.DISMISS_USER_GESTURE)) {
529             return;
530         }
531         if (DEBUG_BUBBLE_DATA) {
532             Log.d(TAG, "Overflowing: " + bubble);
533         }
534         mLogger.logOverflowAdd(bubble, reason);
535         mOverflowBubbles.add(0, bubble);
536         bubble.stopInflation();
537         if (mOverflowBubbles.size() == mMaxOverflowBubbles + 1) {
538             // Remove oldest bubble.
539             Bubble oldest = mOverflowBubbles.get(mOverflowBubbles.size() - 1);
540             if (DEBUG_BUBBLE_DATA) {
541                 Log.d(TAG, "Overflow full. Remove: " + oldest);
542             }
543             mStateChange.bubbleRemoved(oldest, BubbleController.DISMISS_OVERFLOW_MAX_REACHED);
544             mLogger.log(bubble, BubbleLogger.Event.BUBBLE_OVERFLOW_REMOVE_MAX_REACHED);
545             mOverflowBubbles.remove(oldest);
546         }
547     }
548 
dismissAll(@ismissReason int reason)549     public void dismissAll(@DismissReason int reason) {
550         if (DEBUG_BUBBLE_DATA) {
551             Log.d(TAG, "dismissAll: reason=" + reason);
552         }
553         if (mBubbles.isEmpty()) {
554             return;
555         }
556         setExpandedInternal(false);
557         setSelectedBubbleInternal(null);
558         while (!mBubbles.isEmpty()) {
559             doRemove(mBubbles.get(0).getKey(), reason);
560         }
561         dispatchPendingChanges();
562     }
563 
564     /**
565      * Indicates that the provided display is no longer in use and should be cleaned up.
566      *
567      * @param displayId the id of the display to clean up.
568      */
notifyDisplayEmpty(int displayId)569     void notifyDisplayEmpty(int displayId) {
570         for (Bubble b : mBubbles) {
571             if (b.getDisplayId() == displayId) {
572                 if (b.getExpandedView() != null) {
573                     b.getExpandedView().notifyDisplayEmpty();
574                 }
575                 return;
576             }
577         }
578     }
579 
dispatchPendingChanges()580     private void dispatchPendingChanges() {
581         if (mListener != null && mStateChange.anythingChanged()) {
582             mListener.applyUpdate(mStateChange);
583         }
584         mStateChange = new Update(mBubbles, mOverflowBubbles);
585     }
586 
587     /**
588      * Requests a change to the selected bubble.
589      *
590      * @param bubble the new selected bubble
591      */
setSelectedBubbleInternal(@ullable Bubble bubble)592     private void setSelectedBubbleInternal(@Nullable Bubble bubble) {
593         if (DEBUG_BUBBLE_DATA) {
594             Log.d(TAG, "setSelectedBubbleInternal: " + bubble);
595         }
596         if (!mShowingOverflow && Objects.equals(bubble, mSelectedBubble)) {
597             return;
598         }
599         // Otherwise, if we are showing the overflow menu, return to the previously selected bubble.
600 
601         if (bubble != null && !mBubbles.contains(bubble) && !mOverflowBubbles.contains(bubble)) {
602             Log.e(TAG, "Cannot select bubble which doesn't exist!"
603                     + " (" + bubble + ") bubbles=" + mBubbles);
604             return;
605         }
606         if (mExpanded && bubble != null) {
607             bubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
608         }
609         mSelectedBubble = bubble;
610         mStateChange.selectedBubble = bubble;
611         mStateChange.selectionChanged = true;
612     }
613 
614     /**
615      * Requests a change to the expanded state.
616      *
617      * @param shouldExpand the new requested state
618      */
setExpandedInternal(boolean shouldExpand)619     private void setExpandedInternal(boolean shouldExpand) {
620         if (DEBUG_BUBBLE_DATA) {
621             Log.d(TAG, "setExpandedInternal: shouldExpand=" + shouldExpand);
622         }
623         if (mExpanded == shouldExpand) {
624             return;
625         }
626         if (shouldExpand) {
627             if (mBubbles.isEmpty()) {
628                 Log.e(TAG, "Attempt to expand stack when empty!");
629                 return;
630             }
631             if (mSelectedBubble == null) {
632                 Log.e(TAG, "Attempt to expand stack without selected bubble!");
633                 return;
634             }
635             mSelectedBubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
636             mStateChange.orderChanged |= repackAll();
637         } else if (!mBubbles.isEmpty()) {
638             // Apply ordering and grouping rules from expanded -> collapsed, then save
639             // the result.
640             mStateChange.orderChanged |= repackAll();
641             // Save the state which should be returned to when expanded (with no other changes)
642 
643             if (mShowingOverflow) {
644                 // Show previously selected bubble instead of overflow menu on next expansion.
645                 setSelectedBubbleInternal(mSelectedBubble);
646             }
647             if (mBubbles.indexOf(mSelectedBubble) > 0) {
648                 // Move the selected bubble to the top while collapsed.
649                 int index = mBubbles.indexOf(mSelectedBubble);
650                 if (index != 0) {
651                     mBubbles.remove(mSelectedBubble);
652                     mBubbles.add(0, mSelectedBubble);
653                     mStateChange.orderChanged = true;
654                 }
655             }
656         }
657         mExpanded = shouldExpand;
658         mStateChange.expanded = shouldExpand;
659         mStateChange.expandedChanged = true;
660     }
661 
sortKey(Bubble bubble)662     private static long sortKey(Bubble bubble) {
663         return bubble.getLastActivity();
664     }
665 
666     /**
667      * This applies a full sort and group pass to all existing bubbles.
668      * Bubbles are sorted by lastUpdated descending.
669      *
670      * @return true if the position of any bubbles changed as a result
671      */
repackAll()672     private boolean repackAll() {
673         if (DEBUG_BUBBLE_DATA) {
674             Log.d(TAG, "repackAll()");
675         }
676         if (mBubbles.isEmpty()) {
677             return false;
678         }
679         List<Bubble> repacked = new ArrayList<>(mBubbles.size());
680         // Add bubbles, freshest to oldest
681         mBubbles.stream()
682                 .sorted(BUBBLES_BY_SORT_KEY_DESCENDING)
683                 .forEachOrdered(repacked::add);
684         if (repacked.equals(mBubbles)) {
685             return false;
686         }
687         mBubbles.clear();
688         mBubbles.addAll(repacked);
689         return true;
690     }
691 
maybeSendDeleteIntent(@ismissReason int reason, @NonNull final Bubble bubble)692     private void maybeSendDeleteIntent(@DismissReason int reason, @NonNull final Bubble bubble) {
693         if (reason != BubbleController.DISMISS_USER_GESTURE) return;
694         PendingIntent deleteIntent = bubble.getDeleteIntent();
695         if (deleteIntent == null) return;
696         try {
697             deleteIntent.send();
698         } catch (PendingIntent.CanceledException e) {
699             Log.w(TAG, "Failed to send delete intent for bubble with key: " + bubble.getKey());
700         }
701     }
702 
indexForKey(String key)703     private int indexForKey(String key) {
704         for (int i = 0; i < mBubbles.size(); i++) {
705             Bubble bubble = mBubbles.get(i);
706             if (bubble.getKey().equals(key)) {
707                 return i;
708             }
709         }
710         return -1;
711     }
712 
713     /**
714      * The set of bubbles in row.
715      */
716     @VisibleForTesting(visibility = PACKAGE)
getBubbles()717     public List<Bubble> getBubbles() {
718         return Collections.unmodifiableList(mBubbles);
719     }
720 
721     /**
722      * The set of bubbles in overflow.
723      */
724     @VisibleForTesting(visibility = PRIVATE)
getOverflowBubbles()725     List<Bubble> getOverflowBubbles() {
726         return Collections.unmodifiableList(mOverflowBubbles);
727     }
728 
729     @VisibleForTesting(visibility = PRIVATE)
730     @Nullable
getAnyBubbleWithkey(String key)731     Bubble getAnyBubbleWithkey(String key) {
732         Bubble b = getBubbleInStackWithKey(key);
733         if (b == null) {
734             b = getOverflowBubbleWithKey(key);
735         }
736         return b;
737     }
738 
739     @VisibleForTesting(visibility = PRIVATE)
740     @Nullable
getBubbleInStackWithKey(String key)741     Bubble getBubbleInStackWithKey(String key) {
742         for (int i = 0; i < mBubbles.size(); i++) {
743             Bubble bubble = mBubbles.get(i);
744             if (bubble.getKey().equals(key)) {
745                 return bubble;
746             }
747         }
748         return null;
749     }
750 
751     @Nullable
getBubbleWithView(View view)752     Bubble getBubbleWithView(View view) {
753         for (int i = 0; i < mBubbles.size(); i++) {
754             Bubble bubble = mBubbles.get(i);
755             if (bubble.getIconView() != null && bubble.getIconView().equals(view)) {
756                 return bubble;
757             }
758         }
759         return null;
760     }
761 
762     @VisibleForTesting(visibility = PRIVATE)
getOverflowBubbleWithKey(String key)763     Bubble getOverflowBubbleWithKey(String key) {
764         for (int i = 0; i < mOverflowBubbles.size(); i++) {
765             Bubble bubble = mOverflowBubbles.get(i);
766             if (bubble.getKey().equals(key)) {
767                 return bubble;
768             }
769         }
770         return null;
771     }
772 
773     @VisibleForTesting(visibility = PRIVATE)
setTimeSource(TimeSource timeSource)774     void setTimeSource(TimeSource timeSource) {
775         mTimeSource = timeSource;
776     }
777 
setListener(Listener listener)778     public void setListener(Listener listener) {
779         mListener = listener;
780     }
781 
782     /**
783      * Set maximum number of bubbles allowed in overflow.
784      * This method should only be used in tests, not in production.
785      */
786     @VisibleForTesting
setMaxOverflowBubbles(int maxOverflowBubbles)787     void setMaxOverflowBubbles(int maxOverflowBubbles) {
788         mMaxOverflowBubbles = maxOverflowBubbles;
789     }
790 
791     /**
792      * Description of current bubble data state.
793      */
dump(FileDescriptor fd, PrintWriter pw, String[] args)794     public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
795         pw.print("selected: ");
796         pw.println(mSelectedBubble != null
797                 ? mSelectedBubble.getKey()
798                 : "null");
799         pw.print("expanded: ");
800         pw.println(mExpanded);
801         pw.print("count:    ");
802         pw.println(mBubbles.size());
803         for (Bubble bubble : mBubbles) {
804             bubble.dump(fd, pw, args);
805         }
806         pw.print("summaryKeys: ");
807         pw.println(mSuppressedGroupKeys.size());
808         for (String key : mSuppressedGroupKeys.keySet()) {
809             pw.println("   suppressing: " + key);
810         }
811     }
812 }
813