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.PRIVATE;
19 import static com.android.systemui.bubbles.BubbleDebugConfig.DEBUG_BUBBLE_DATA;
20 import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_BUBBLES;
21 import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
22 
23 import static java.util.stream.Collectors.toList;
24 
25 import android.app.Notification;
26 import android.app.PendingIntent;
27 import android.content.Context;
28 import android.service.notification.NotificationListenerService;
29 import android.service.notification.NotificationListenerService.RankingMap;
30 import android.util.Log;
31 import android.util.Pair;
32 
33 import androidx.annotation.Nullable;
34 
35 import com.android.internal.annotations.VisibleForTesting;
36 import com.android.systemui.bubbles.BubbleController.DismissReason;
37 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
38 
39 import java.io.FileDescriptor;
40 import java.io.PrintWriter;
41 import java.util.ArrayList;
42 import java.util.Collections;
43 import java.util.Comparator;
44 import java.util.HashMap;
45 import java.util.List;
46 import java.util.Map;
47 import java.util.Objects;
48 
49 import javax.inject.Inject;
50 import javax.inject.Singleton;
51 
52 /**
53  * Keeps track of active bubbles.
54  */
55 @Singleton
56 public class BubbleData {
57 
58     private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleData" : TAG_BUBBLES;
59 
60     private static final int MAX_BUBBLES = 5;
61 
62     private static final Comparator<Bubble> BUBBLES_BY_SORT_KEY_DESCENDING =
63             Comparator.comparing(BubbleData::sortKey).reversed();
64 
65     private static final Comparator<Map.Entry<String, Long>> GROUPS_BY_MAX_SORT_KEY_DESCENDING =
66             Comparator.<Map.Entry<String, Long>, Long>comparing(Map.Entry::getValue).reversed();
67 
68     /** Contains information about changes that have been made to the state of bubbles. */
69     static final class Update {
70         boolean expandedChanged;
71         boolean selectionChanged;
72         boolean orderChanged;
73         boolean expanded;
74         @Nullable Bubble selectedBubble;
75         @Nullable Bubble addedBubble;
76         @Nullable Bubble updatedBubble;
77         // Pair with Bubble and @DismissReason Integer
78         final List<Pair<Bubble, Integer>> removedBubbles = new ArrayList<>();
79 
80         // A read-only view of the bubbles list, changes there will be reflected here.
81         final List<Bubble> bubbles;
82 
Update(List<Bubble> bubbleOrder)83         private Update(List<Bubble> bubbleOrder) {
84             bubbles = Collections.unmodifiableList(bubbleOrder);
85         }
86 
anythingChanged()87         boolean anythingChanged() {
88             return expandedChanged
89                     || selectionChanged
90                     || addedBubble != null
91                     || updatedBubble != null
92                     || !removedBubbles.isEmpty()
93                     || orderChanged;
94         }
95 
bubbleRemoved(Bubble bubbleToRemove, @DismissReason int reason)96         void bubbleRemoved(Bubble bubbleToRemove, @DismissReason  int reason) {
97             removedBubbles.add(new Pair<>(bubbleToRemove, reason));
98         }
99     }
100 
101     /**
102      * This interface reports changes to the state and appearance of bubbles which should be applied
103      * as necessary to the UI.
104      */
105     interface Listener {
106         /** Reports changes have have occurred as a result of the most recent operation. */
applyUpdate(Update update)107         void applyUpdate(Update update);
108     }
109 
110     interface TimeSource {
currentTimeMillis()111         long currentTimeMillis();
112     }
113 
114     private final Context mContext;
115     private final List<Bubble> mBubbles;
116     private Bubble mSelectedBubble;
117     private boolean mExpanded;
118 
119     // State tracked during an operation -- keeps track of what listener events to dispatch.
120     private Update mStateChange;
121 
122     private NotificationListenerService.Ranking mTmpRanking;
123 
124     private TimeSource mTimeSource = System::currentTimeMillis;
125 
126     @Nullable
127     private Listener mListener;
128 
129     /**
130      * We track groups with summaries that aren't visibly displayed but still kept around because
131      * the bubble(s) associated with the summary still exist.
132      *
133      * The summary must be kept around so that developers can cancel it (and hence the bubbles
134      * associated with it). This list is used to check if the summary should be hidden from the
135      * shade.
136      *
137      * Key: group key of the NotificationEntry
138      * Value: key of the NotificationEntry
139      */
140     private HashMap<String, String> mSuppressedGroupKeys = new HashMap<>();
141 
142     @Inject
BubbleData(Context context)143     public BubbleData(Context context) {
144         mContext = context;
145         mBubbles = new ArrayList<>();
146         mStateChange = new Update(mBubbles);
147     }
148 
hasBubbles()149     public boolean hasBubbles() {
150         return !mBubbles.isEmpty();
151     }
152 
isExpanded()153     public boolean isExpanded() {
154         return mExpanded;
155     }
156 
hasBubbleWithKey(String key)157     public boolean hasBubbleWithKey(String key) {
158         return getBubbleWithKey(key) != null;
159     }
160 
161     @Nullable
getSelectedBubble()162     public Bubble getSelectedBubble() {
163         return mSelectedBubble;
164     }
165 
setExpanded(boolean expanded)166     public void setExpanded(boolean expanded) {
167         if (DEBUG_BUBBLE_DATA) {
168             Log.d(TAG, "setExpanded: " + expanded);
169         }
170         setExpandedInternal(expanded);
171         dispatchPendingChanges();
172     }
173 
setSelectedBubble(Bubble bubble)174     public void setSelectedBubble(Bubble bubble) {
175         if (DEBUG_BUBBLE_DATA) {
176             Log.d(TAG, "setSelectedBubble: " + bubble);
177         }
178         setSelectedBubbleInternal(bubble);
179         dispatchPendingChanges();
180     }
181 
notificationEntryUpdated(NotificationEntry entry, boolean suppressFlyout)182     void notificationEntryUpdated(NotificationEntry entry, boolean suppressFlyout) {
183         if (DEBUG_BUBBLE_DATA) {
184             Log.d(TAG, "notificationEntryUpdated: " + entry);
185         }
186         Bubble bubble = getBubbleWithKey(entry.key);
187         suppressFlyout = !entry.isVisuallyInterruptive || suppressFlyout;
188 
189         if (bubble == null) {
190             // Create a new bubble
191             bubble = new Bubble(mContext, entry);
192             bubble.setSuppressFlyout(suppressFlyout);
193             doAdd(bubble);
194             trim();
195         } else {
196             // Updates an existing bubble
197             bubble.updateEntry(entry);
198             bubble.setSuppressFlyout(suppressFlyout);
199             doUpdate(bubble);
200         }
201 
202         if (bubble.shouldAutoExpand()) {
203             setSelectedBubbleInternal(bubble);
204             if (!mExpanded) {
205                 setExpandedInternal(true);
206             }
207         } else if (mSelectedBubble == null) {
208             setSelectedBubbleInternal(bubble);
209         }
210         boolean isBubbleExpandedAndSelected = mExpanded && mSelectedBubble == bubble;
211         bubble.setShowInShadeWhenBubble(!isBubbleExpandedAndSelected);
212         bubble.setShowBubbleDot(!isBubbleExpandedAndSelected);
213         dispatchPendingChanges();
214     }
215 
notificationEntryRemoved(NotificationEntry entry, @DismissReason int reason)216     public void notificationEntryRemoved(NotificationEntry entry, @DismissReason int reason) {
217         if (DEBUG_BUBBLE_DATA) {
218             Log.d(TAG, "notificationEntryRemoved: entry=" + entry + " reason=" + reason);
219         }
220         doRemove(entry.key, reason);
221         dispatchPendingChanges();
222     }
223 
224     /**
225      * Called when NotificationListener has received adjusted notification rank and reapplied
226      * filtering and sorting. This is used to dismiss any bubbles which should no longer be shown
227      * due to changes in permissions on the notification channel or the global setting.
228      *
229      * @param rankingMap the updated ranking map from NotificationListenerService
230      */
notificationRankingUpdated(RankingMap rankingMap)231     public void notificationRankingUpdated(RankingMap rankingMap) {
232         if (mTmpRanking == null) {
233             mTmpRanking = new NotificationListenerService.Ranking();
234         }
235 
236         String[] orderedKeys = rankingMap.getOrderedKeys();
237         for (int i = 0; i < orderedKeys.length; i++) {
238             String key = orderedKeys[i];
239             if (hasBubbleWithKey(key)) {
240                 rankingMap.getRanking(key, mTmpRanking);
241                 if (!mTmpRanking.canBubble()) {
242                     doRemove(key, BubbleController.DISMISS_BLOCKED);
243                 }
244             }
245         }
246         dispatchPendingChanges();
247     }
248 
249     /**
250      * Adds a group key indicating that the summary for this group should be suppressed.
251      *
252      * @param groupKey the group key of the group whose summary should be suppressed.
253      * @param notifKey the notification entry key of that summary.
254      */
addSummaryToSuppress(String groupKey, String notifKey)255     void addSummaryToSuppress(String groupKey, String notifKey) {
256         mSuppressedGroupKeys.put(groupKey, notifKey);
257     }
258 
259     /**
260      * Retrieves the notif entry key of the summary associated with the provided group key.
261      *
262      * @param groupKey the group to look up
263      * @return the key for the {@link NotificationEntry} that is the summary of this group.
264      */
getSummaryKey(String groupKey)265     String getSummaryKey(String groupKey) {
266         return mSuppressedGroupKeys.get(groupKey);
267     }
268 
269     /**
270      * Removes a group key indicating that summary for this group should no longer be suppressed.
271      */
removeSuppressedSummary(String groupKey)272     void removeSuppressedSummary(String groupKey) {
273         mSuppressedGroupKeys.remove(groupKey);
274     }
275 
276     /**
277      * Whether the summary for the provided group key is suppressed.
278      */
isSummarySuppressed(String groupKey)279     boolean isSummarySuppressed(String groupKey) {
280         return mSuppressedGroupKeys.containsKey(groupKey);
281     }
282 
283     /**
284      * Retrieves any bubbles that are part of the notification group represented by the provided
285      * group key.
286      */
getBubblesInGroup(@ullable String groupKey)287     ArrayList<Bubble> getBubblesInGroup(@Nullable String groupKey) {
288         ArrayList<Bubble> bubbleChildren = new ArrayList<>();
289         if (groupKey == null) {
290             return bubbleChildren;
291         }
292         for (Bubble b : mBubbles) {
293             if (groupKey.equals(b.getEntry().notification.getGroupKey())) {
294                 bubbleChildren.add(b);
295             }
296         }
297         return bubbleChildren;
298     }
299 
doAdd(Bubble bubble)300     private void doAdd(Bubble bubble) {
301         if (DEBUG_BUBBLE_DATA) {
302             Log.d(TAG, "doAdd: " + bubble);
303         }
304         int minInsertPoint = 0;
305         boolean newGroup = !hasBubbleWithGroupId(bubble.getGroupId());
306         if (isExpanded()) {
307             // first bubble of a group goes to the beginning, otherwise within the existing group
308             minInsertPoint = newGroup ? 0 : findFirstIndexForGroup(bubble.getGroupId());
309         }
310         if (insertBubble(minInsertPoint, bubble) < mBubbles.size() - 1) {
311             mStateChange.orderChanged = true;
312         }
313         mStateChange.addedBubble = bubble;
314         if (!isExpanded()) {
315             mStateChange.orderChanged |= packGroup(findFirstIndexForGroup(bubble.getGroupId()));
316             // Top bubble becomes selected.
317             setSelectedBubbleInternal(mBubbles.get(0));
318         }
319     }
320 
trim()321     private void trim() {
322         if (mBubbles.size() > MAX_BUBBLES) {
323             mBubbles.stream()
324                     // sort oldest first (ascending lastActivity)
325                     .sorted(Comparator.comparingLong(Bubble::getLastActivity))
326                     // skip the selected bubble
327                     .filter((b) -> !b.equals(mSelectedBubble))
328                     .findFirst()
329                     .ifPresent((b) -> doRemove(b.getKey(), BubbleController.DISMISS_AGED));
330         }
331     }
332 
doUpdate(Bubble bubble)333     private void doUpdate(Bubble bubble) {
334         if (DEBUG_BUBBLE_DATA) {
335             Log.d(TAG, "doUpdate: " + bubble);
336         }
337         mStateChange.updatedBubble = bubble;
338         if (!isExpanded()) {
339             // while collapsed, update causes re-pack
340             int prevPos = mBubbles.indexOf(bubble);
341             mBubbles.remove(bubble);
342             int newPos = insertBubble(0, bubble);
343             if (prevPos != newPos) {
344                 packGroup(newPos);
345                 mStateChange.orderChanged = true;
346             }
347             setSelectedBubbleInternal(mBubbles.get(0));
348         }
349     }
350 
doRemove(String key, @DismissReason int reason)351     private void doRemove(String key, @DismissReason int reason) {
352         int indexToRemove = indexForKey(key);
353         if (indexToRemove == -1) {
354             return;
355         }
356         Bubble bubbleToRemove = mBubbles.get(indexToRemove);
357         if (mBubbles.size() == 1) {
358             // Going to become empty, handle specially.
359             setExpandedInternal(false);
360             setSelectedBubbleInternal(null);
361         }
362         if (indexToRemove < mBubbles.size() - 1) {
363             // Removing anything but the last bubble means positions will change.
364             mStateChange.orderChanged = true;
365         }
366         mBubbles.remove(indexToRemove);
367         mStateChange.bubbleRemoved(bubbleToRemove, reason);
368         if (!isExpanded()) {
369             mStateChange.orderChanged |= repackAll();
370         }
371 
372         // Note: If mBubbles.isEmpty(), then mSelectedBubble is now null.
373         if (Objects.equals(mSelectedBubble, bubbleToRemove)) {
374             // Move selection to the new bubble at the same position.
375             int newIndex = Math.min(indexToRemove, mBubbles.size() - 1);
376             Bubble newSelected = mBubbles.get(newIndex);
377             setSelectedBubbleInternal(newSelected);
378         }
379         maybeSendDeleteIntent(reason, bubbleToRemove.getEntry());
380     }
381 
dismissAll(@ismissReason int reason)382     public void dismissAll(@DismissReason int reason) {
383         if (DEBUG_BUBBLE_DATA) {
384             Log.d(TAG, "dismissAll: reason=" + reason);
385         }
386         if (mBubbles.isEmpty()) {
387             return;
388         }
389         setExpandedInternal(false);
390         setSelectedBubbleInternal(null);
391         while (!mBubbles.isEmpty()) {
392             Bubble bubble = mBubbles.remove(0);
393             maybeSendDeleteIntent(reason, bubble.getEntry());
394             mStateChange.bubbleRemoved(bubble, reason);
395         }
396         dispatchPendingChanges();
397     }
398 
399     /**
400      * Indicates that the provided display is no longer in use and should be cleaned up.
401      *
402      * @param displayId the id of the display to clean up.
403      */
notifyDisplayEmpty(int displayId)404     void notifyDisplayEmpty(int displayId) {
405         for (Bubble b : mBubbles) {
406             if (b.getDisplayId() == displayId) {
407                 if (b.getExpandedView() != null) {
408                     b.getExpandedView().notifyDisplayEmpty();
409                 }
410                 return;
411             }
412         }
413     }
414 
dispatchPendingChanges()415     private void dispatchPendingChanges() {
416         if (mListener != null && mStateChange.anythingChanged()) {
417             mListener.applyUpdate(mStateChange);
418         }
419         mStateChange = new Update(mBubbles);
420     }
421 
422     /**
423      * Requests a change to the selected bubble.
424      *
425      * @param bubble the new selected bubble
426      */
setSelectedBubbleInternal(@ullable Bubble bubble)427     private void setSelectedBubbleInternal(@Nullable Bubble bubble) {
428         if (DEBUG_BUBBLE_DATA) {
429             Log.d(TAG, "setSelectedBubbleInternal: " + bubble);
430         }
431         if (Objects.equals(bubble, mSelectedBubble)) {
432             return;
433         }
434         if (bubble != null && !mBubbles.contains(bubble)) {
435             Log.e(TAG, "Cannot select bubble which doesn't exist!"
436                     + " (" + bubble + ") bubbles=" + mBubbles);
437             return;
438         }
439         if (mExpanded && bubble != null) {
440             bubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
441         }
442         mSelectedBubble = bubble;
443         mStateChange.selectedBubble = bubble;
444         mStateChange.selectionChanged = true;
445     }
446 
447     /**
448      * Requests a change to the expanded state.
449      *
450      * @param shouldExpand the new requested state
451      */
setExpandedInternal(boolean shouldExpand)452     private void setExpandedInternal(boolean shouldExpand) {
453         if (DEBUG_BUBBLE_DATA) {
454             Log.d(TAG, "setExpandedInternal: shouldExpand=" + shouldExpand);
455         }
456         if (mExpanded == shouldExpand) {
457             return;
458         }
459         if (shouldExpand) {
460             if (mBubbles.isEmpty()) {
461                 Log.e(TAG, "Attempt to expand stack when empty!");
462                 return;
463             }
464             if (mSelectedBubble == null) {
465                 Log.e(TAG, "Attempt to expand stack without selected bubble!");
466                 return;
467             }
468             mSelectedBubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
469             mStateChange.orderChanged |= repackAll();
470         } else if (!mBubbles.isEmpty()) {
471             // Apply ordering and grouping rules from expanded -> collapsed, then save
472             // the result.
473             mStateChange.orderChanged |= repackAll();
474             // Save the state which should be returned to when expanded (with no other changes)
475 
476             if (mBubbles.indexOf(mSelectedBubble) > 0) {
477                 // Move the selected bubble to the top while collapsed.
478                 if (!mSelectedBubble.isOngoing() && mBubbles.get(0).isOngoing()) {
479                     // The selected bubble cannot be raised to the first position because
480                     // there is an ongoing bubble there. Instead, force the top ongoing bubble
481                     // to become selected.
482                     setSelectedBubbleInternal(mBubbles.get(0));
483                 } else {
484                     // Raise the selected bubble (and it's group) up to the front so the selected
485                     // bubble remains on top.
486                     mBubbles.remove(mSelectedBubble);
487                     mBubbles.add(0, mSelectedBubble);
488                     mStateChange.orderChanged |= packGroup(0);
489                 }
490             }
491         }
492         mExpanded = shouldExpand;
493         mStateChange.expanded = shouldExpand;
494         mStateChange.expandedChanged = true;
495     }
496 
sortKey(Bubble bubble)497     private static long sortKey(Bubble bubble) {
498         long key = bubble.getLastUpdateTime();
499         if (bubble.isOngoing()) {
500             // Set 2nd highest bit (signed long int), to partition between ongoing and regular
501             key |= 0x4000000000000000L;
502         }
503         return key;
504     }
505 
506     /**
507      * Locates and inserts the bubble into a sorted position. The is inserted
508      * based on sort key, groupId is not considered. A call to {@link #packGroup(int)} may be
509      * required to keep grouping intact.
510      *
511      * @param minPosition the first insert point to consider
512      * @param newBubble the bubble to insert
513      * @return the position where the bubble was inserted
514      */
insertBubble(int minPosition, Bubble newBubble)515     private int insertBubble(int minPosition, Bubble newBubble) {
516         long newBubbleSortKey = sortKey(newBubble);
517         String previousGroupId = null;
518 
519         for (int pos = minPosition; pos < mBubbles.size(); pos++) {
520             Bubble bubbleAtPos = mBubbles.get(pos);
521             String groupIdAtPos = bubbleAtPos.getGroupId();
522             boolean atStartOfGroup = !groupIdAtPos.equals(previousGroupId);
523 
524             if (atStartOfGroup && newBubbleSortKey > sortKey(bubbleAtPos)) {
525                 // Insert before the start of first group which has older bubbles.
526                 mBubbles.add(pos, newBubble);
527                 return pos;
528             }
529             previousGroupId = groupIdAtPos;
530         }
531         mBubbles.add(newBubble);
532         return mBubbles.size() - 1;
533     }
534 
hasBubbleWithGroupId(String groupId)535     private boolean hasBubbleWithGroupId(String groupId) {
536         return mBubbles.stream().anyMatch(b -> b.getGroupId().equals(groupId));
537     }
538 
findFirstIndexForGroup(String appId)539     private int findFirstIndexForGroup(String appId) {
540         for (int i = 0; i < mBubbles.size(); i++) {
541             Bubble bubbleAtPos = mBubbles.get(i);
542             if (bubbleAtPos.getGroupId().equals(appId)) {
543                 return i;
544             }
545         }
546         return 0;
547     }
548 
549     /**
550      * Starting at the given position, moves all bubbles with the same group id to follow. Bubbles
551      * at positions lower than {@code position} are unchanged. Relative order within the group
552      * unchanged. Relative order of any other bubbles are also unchanged.
553      *
554      * @param position the position of the first bubble for the group
555      * @return true if the position of any bubbles has changed as a result
556      */
packGroup(int position)557     private boolean packGroup(int position) {
558         if (DEBUG_BUBBLE_DATA) {
559             Log.d(TAG, "packGroup: position=" + position);
560         }
561         Bubble groupStart = mBubbles.get(position);
562         final String groupAppId = groupStart.getGroupId();
563         List<Bubble> moving = new ArrayList<>();
564 
565         // Walk backward, collect bubbles within the group
566         for (int i = mBubbles.size() - 1; i > position; i--) {
567             if (mBubbles.get(i).getGroupId().equals(groupAppId)) {
568                 moving.add(0, mBubbles.get(i));
569             }
570         }
571         if (moving.isEmpty()) {
572             return false;
573         }
574         mBubbles.removeAll(moving);
575         mBubbles.addAll(position + 1, moving);
576         return true;
577     }
578 
579     /**
580      * This applies a full sort and group pass to all existing bubbles. The bubbles are grouped
581      * by groupId. Each group is then sorted by the max(lastUpdated) time of it's bubbles. Bubbles
582      * within each group are then sorted by lastUpdated descending.
583      *
584      * @return true if the position of any bubbles changed as a result
585      */
repackAll()586     private boolean repackAll() {
587         if (DEBUG_BUBBLE_DATA) {
588             Log.d(TAG, "repackAll()");
589         }
590         if (mBubbles.isEmpty()) {
591             return false;
592         }
593         Map<String, Long> groupLastActivity = new HashMap<>();
594         for (Bubble bubble : mBubbles) {
595             long maxSortKeyForGroup = groupLastActivity.getOrDefault(bubble.getGroupId(), 0L);
596             long sortKeyForBubble = sortKey(bubble);
597             if (sortKeyForBubble > maxSortKeyForGroup) {
598                 groupLastActivity.put(bubble.getGroupId(), sortKeyForBubble);
599             }
600         }
601 
602         // Sort groups by their most recently active bubble
603         List<String> groupsByMostRecentActivity =
604                 groupLastActivity.entrySet().stream()
605                         .sorted(GROUPS_BY_MAX_SORT_KEY_DESCENDING)
606                         .map(Map.Entry::getKey)
607                         .collect(toList());
608 
609         List<Bubble> repacked = new ArrayList<>(mBubbles.size());
610 
611         // For each group, add bubbles, freshest to oldest
612         for (String appId : groupsByMostRecentActivity) {
613             mBubbles.stream()
614                     .filter((b) -> b.getGroupId().equals(appId))
615                     .sorted(BUBBLES_BY_SORT_KEY_DESCENDING)
616                     .forEachOrdered(repacked::add);
617         }
618         if (repacked.equals(mBubbles)) {
619             return false;
620         }
621         mBubbles.clear();
622         mBubbles.addAll(repacked);
623         return true;
624     }
625 
maybeSendDeleteIntent(@ismissReason int reason, NotificationEntry entry)626     private void maybeSendDeleteIntent(@DismissReason int reason, NotificationEntry entry) {
627         if (reason == BubbleController.DISMISS_USER_GESTURE) {
628             Notification.BubbleMetadata bubbleMetadata = entry.getBubbleMetadata();
629             PendingIntent deleteIntent = bubbleMetadata != null
630                     ? bubbleMetadata.getDeleteIntent()
631                     : null;
632             if (deleteIntent != null) {
633                 try {
634                     deleteIntent.send();
635                 } catch (PendingIntent.CanceledException e) {
636                     Log.w(TAG, "Failed to send delete intent for bubble with key: " + entry.key);
637                 }
638             }
639         }
640     }
641 
indexForKey(String key)642     private int indexForKey(String key) {
643         for (int i = 0; i < mBubbles.size(); i++) {
644             Bubble bubble = mBubbles.get(i);
645             if (bubble.getKey().equals(key)) {
646                 return i;
647             }
648         }
649         return -1;
650     }
651 
652     /**
653      * The set of bubbles.
654      */
655     @VisibleForTesting(visibility = PRIVATE)
getBubbles()656     public List<Bubble> getBubbles() {
657         return Collections.unmodifiableList(mBubbles);
658     }
659 
660     @VisibleForTesting(visibility = PRIVATE)
getBubbleWithKey(String key)661     Bubble getBubbleWithKey(String key) {
662         for (int i = 0; i < mBubbles.size(); i++) {
663             Bubble bubble = mBubbles.get(i);
664             if (bubble.getKey().equals(key)) {
665                 return bubble;
666             }
667         }
668         return null;
669     }
670 
671     @VisibleForTesting(visibility = PRIVATE)
setTimeSource(TimeSource timeSource)672     void setTimeSource(TimeSource timeSource) {
673         mTimeSource = timeSource;
674     }
675 
setListener(Listener listener)676     public void setListener(Listener listener) {
677         mListener = listener;
678     }
679 
680     /**
681      * Description of current bubble data state.
682      */
dump(FileDescriptor fd, PrintWriter pw, String[] args)683     public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
684         pw.print("selected: "); pw.println(mSelectedBubble != null
685                 ? mSelectedBubble.getKey()
686                 : "null");
687         pw.print("expanded: "); pw.println(mExpanded);
688         pw.print("count:    "); pw.println(mBubbles.size());
689         for (Bubble bubble : mBubbles) {
690             bubble.dump(fd, pw, args);
691         }
692         pw.print("summaryKeys: "); pw.println(mSuppressedGroupKeys.size());
693         for (String key : mSuppressedGroupKeys.keySet()) {
694             pw.println("   suppressing: " + key);
695         }
696     }
697 }
698