blob: 65d5bebc625dce84578a5aeeccc369e920d76712 [file] [log] [blame]
Alan Viverette3da604b2020-06-10 18:34:39 +00001/*
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 */
16package com.android.systemui.bubbles;
17
18import static com.android.internal.annotations.VisibleForTesting.Visibility.PACKAGE;
19import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
20import static com.android.systemui.bubbles.BubbleDebugConfig.DEBUG_BUBBLE_DATA;
21import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_BUBBLES;
22import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
23
24import static java.util.stream.Collectors.toList;
25
26import android.app.Notification;
27import android.app.PendingIntent;
28import android.content.Context;
29import android.service.notification.NotificationListenerService;
30import android.util.Log;
31import android.util.Pair;
32import android.view.View;
33
34import androidx.annotation.Nullable;
35
36import com.android.internal.annotations.VisibleForTesting;
37import com.android.systemui.R;
38import com.android.systemui.bubbles.BubbleController.DismissReason;
39import com.android.systemui.statusbar.notification.collection.NotificationEntry;
40
41import java.io.FileDescriptor;
42import java.io.PrintWriter;
43import java.util.ArrayList;
44import java.util.Collections;
45import java.util.Comparator;
46import java.util.HashMap;
47import java.util.List;
48import java.util.Map;
49import java.util.Objects;
50
51import javax.inject.Inject;
52import javax.inject.Singleton;
53
54/**
55 * Keeps track of active bubbles.
56 */
57@Singleton
58public class BubbleData {
59
60 private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleData" : TAG_BUBBLES;
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 final List<Bubble> overflowBubbles;
83
84 private Update(List<Bubble> row, List<Bubble> overflow) {
85 bubbles = Collections.unmodifiableList(row);
86 overflowBubbles = Collections.unmodifiableList(overflow);
87 }
88
89 boolean anythingChanged() {
90 return expandedChanged
91 || selectionChanged
92 || addedBubble != null
93 || updatedBubble != null
94 || !removedBubbles.isEmpty()
95 || orderChanged;
96 }
97
98 void bubbleRemoved(Bubble bubbleToRemove, @DismissReason int reason) {
99 removedBubbles.add(new Pair<>(bubbleToRemove, reason));
100 }
101 }
102
103 /**
104 * This interface reports changes to the state and appearance of bubbles which should be applied
105 * as necessary to the UI.
106 */
107 interface Listener {
108 /** Reports changes have have occurred as a result of the most recent operation. */
109 void applyUpdate(Update update);
110 }
111
112 interface TimeSource {
113 long currentTimeMillis();
114 }
115
116 private final Context mContext;
117 /** Bubbles that are actively in the stack. */
118 private final List<Bubble> mBubbles;
119 /** Bubbles that aged out to overflow. */
120 private final List<Bubble> mOverflowBubbles;
121 /** Bubbles that are being loaded but haven't been added to the stack just yet. */
122 private final List<Bubble> mPendingBubbles;
123 private Bubble mSelectedBubble;
124 private boolean mShowingOverflow;
125 private boolean mExpanded;
126 private final int mMaxBubbles;
127 private int mMaxOverflowBubbles;
128
129 // State tracked during an operation -- keeps track of what listener events to dispatch.
130 private Update mStateChange;
131
132 private NotificationListenerService.Ranking mTmpRanking;
133
134 private TimeSource mTimeSource = System::currentTimeMillis;
135
136 @Nullable
137 private Listener mListener;
138
139 @Nullable
140 private BubbleController.NotificationSuppressionChangedListener mSuppressionListener;
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
156 public BubbleData(Context context) {
157 mContext = context;
158 mBubbles = new ArrayList<>();
159 mOverflowBubbles = new ArrayList<>();
160 mPendingBubbles = new ArrayList<>();
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
166 public void setSuppressionChangedListener(
167 BubbleController.NotificationSuppressionChangedListener listener) {
168 mSuppressionListener = listener;
169 }
170
171 public boolean hasBubbles() {
172 return !mBubbles.isEmpty();
173 }
174
175 public boolean isExpanded() {
176 return mExpanded;
177 }
178
179 public boolean hasAnyBubbleWithKey(String key) {
180 return hasBubbleInStackWithKey(key) || hasOverflowBubbleWithKey(key);
181 }
182
183 public boolean hasBubbleInStackWithKey(String key) {
184 return getBubbleInStackWithKey(key) != null;
185 }
186
187 public boolean hasOverflowBubbleWithKey(String key) {
188 return getOverflowBubbleWithKey(key) != null;
189 }
190
191 @Nullable
192 public Bubble getSelectedBubble() {
193 return mSelectedBubble;
194 }
195
196 public void setExpanded(boolean expanded) {
197 if (DEBUG_BUBBLE_DATA) {
198 Log.d(TAG, "setExpanded: " + expanded);
199 }
200 setExpandedInternal(expanded);
201 dispatchPendingChanges();
202 }
203
204 public void setSelectedBubble(Bubble bubble) {
205 if (DEBUG_BUBBLE_DATA) {
206 Log.d(TAG, "setSelectedBubble: " + bubble);
207 }
208 setSelectedBubbleInternal(bubble);
209 dispatchPendingChanges();
210 }
211
212 public void promoteBubbleFromOverflow(Bubble bubble, BubbleStackView stack,
213 BubbleIconFactory factory) {
214 if (DEBUG_BUBBLE_DATA) {
215 Log.d(TAG, "promoteBubbleFromOverflow: " + bubble);
216 }
217 moveOverflowBubbleToPending(bubble);
218 // Preserve new order for next repack, which sorts by last updated time.
219 bubble.markUpdatedAt(mTimeSource.currentTimeMillis());
220 bubble.inflate(
221 b -> {
222 notificationEntryUpdated(bubble, /* suppressFlyout */
223 false, /* showInShade */ true);
224 setSelectedBubble(bubble);
225 },
226 mContext, stack, factory);
227 dispatchPendingChanges();
228 }
229
230 void setShowingOverflow(boolean showingOverflow) {
231 mShowingOverflow = showingOverflow;
232 }
233
234 private void moveOverflowBubbleToPending(Bubble b) {
235 mOverflowBubbles.remove(b);
236 mPendingBubbles.add(b);
237 }
238
239 /**
240 * Constructs a new bubble or returns an existing one. Does not add new bubbles to
241 * bubble data, must go through {@link #notificationEntryUpdated(Bubble, boolean, boolean)}
242 * for that.
243 */
244 Bubble getOrCreateBubble(NotificationEntry entry) {
245 String key = entry.getKey();
246 Bubble bubble = getBubbleInStackWithKey(entry.getKey());
247 if (bubble != null) {
248 bubble.setEntry(entry);
249 } else {
250 bubble = getOverflowBubbleWithKey(key);
251 if (bubble != null) {
252 moveOverflowBubbleToPending(bubble);
253 bubble.setEntry(entry);
254 return bubble;
255 }
256 // Check for it in pending
257 for (int i = 0; i < mPendingBubbles.size(); i++) {
258 Bubble b = mPendingBubbles.get(i);
259 if (b.getKey().equals(entry.getKey())) {
260 b.setEntry(entry);
261 return b;
262 }
263 }
264 bubble = new Bubble(entry, mSuppressionListener);
265 mPendingBubbles.add(bubble);
266 }
267 return bubble;
268 }
269
270 /**
271 * When this method is called it is expected that all info in the bubble has completed loading.
272 * @see Bubble#inflate(BubbleViewInfoTask.Callback, Context,
273 * BubbleStackView, BubbleIconFactory).
274 */
275 void notificationEntryUpdated(Bubble bubble, boolean suppressFlyout, boolean showInShade) {
276 if (DEBUG_BUBBLE_DATA) {
277 Log.d(TAG, "notificationEntryUpdated: " + bubble);
278 }
279 mPendingBubbles.remove(bubble); // No longer pending once we're here
280 Bubble prevBubble = getBubbleInStackWithKey(bubble.getKey());
281 suppressFlyout |= !bubble.getEntry().getRanking().visuallyInterruptive();
282
283 if (prevBubble == null) {
284 // Create a new bubble
285 bubble.setSuppressFlyout(suppressFlyout);
286 doAdd(bubble);
287 trim();
288 } else {
289 // Updates an existing bubble
290 bubble.setSuppressFlyout(suppressFlyout);
291 doUpdate(bubble);
292 }
293 if (bubble.shouldAutoExpand()) {
294 setSelectedBubbleInternal(bubble);
295 if (!mExpanded) {
296 setExpandedInternal(true);
297 }
298 } else if (mSelectedBubble == null) {
299 setSelectedBubbleInternal(bubble);
300 }
301
302 boolean isBubbleExpandedAndSelected = mExpanded && mSelectedBubble == bubble;
303 boolean suppress = isBubbleExpandedAndSelected || !showInShade || !bubble.showInShade();
304 bubble.setSuppressNotification(suppress);
305 bubble.setShowDot(!isBubbleExpandedAndSelected /* show */);
306
307 dispatchPendingChanges();
308 }
309
310 public void notificationEntryRemoved(NotificationEntry entry, @DismissReason int reason) {
311 if (DEBUG_BUBBLE_DATA) {
312 Log.d(TAG, "notificationEntryRemoved: entry=" + entry + " reason=" + reason);
313 }
314 doRemove(entry.getKey(), reason);
315 dispatchPendingChanges();
316 }
317
318 /**
319 * Adds a group key indicating that the summary for this group should be suppressed.
320 *
321 * @param groupKey the group key of the group whose summary should be suppressed.
322 * @param notifKey the notification entry key of that summary.
323 */
324 void addSummaryToSuppress(String groupKey, String notifKey) {
325 mSuppressedGroupKeys.put(groupKey, notifKey);
326 }
327
328 /**
329 * Retrieves the notif entry key of the summary associated with the provided group key.
330 *
331 * @param groupKey the group to look up
332 * @return the key for the {@link NotificationEntry} that is the summary of this group.
333 */
334 String getSummaryKey(String groupKey) {
335 return mSuppressedGroupKeys.get(groupKey);
336 }
337
338 /**
339 * Removes a group key indicating that summary for this group should no longer be suppressed.
340 */
341 void removeSuppressedSummary(String groupKey) {
342 mSuppressedGroupKeys.remove(groupKey);
343 }
344
345 /**
346 * Whether the summary for the provided group key is suppressed.
347 */
348 boolean isSummarySuppressed(String groupKey) {
349 return mSuppressedGroupKeys.containsKey(groupKey);
350 }
351
352 /**
353 * Retrieves any bubbles that are part of the notification group represented by the provided
354 * group key.
355 */
356 ArrayList<Bubble> getBubblesInGroup(@Nullable String groupKey) {
357 ArrayList<Bubble> bubbleChildren = new ArrayList<>();
358 if (groupKey == null) {
359 return bubbleChildren;
360 }
361 for (Bubble b : mBubbles) {
362 if (groupKey.equals(b.getEntry().getSbn().getGroupKey())) {
363 bubbleChildren.add(b);
364 }
365 }
366 return bubbleChildren;
367 }
368
369 private void doAdd(Bubble bubble) {
370 if (DEBUG_BUBBLE_DATA) {
371 Log.d(TAG, "doAdd: " + bubble);
372 }
373 int minInsertPoint = 0;
374 boolean newGroup = !hasBubbleWithGroupId(bubble.getGroupId());
375 if (isExpanded()) {
376 // first bubble of a group goes to the beginning, otherwise within the existing group
377 minInsertPoint = newGroup ? 0 : findFirstIndexForGroup(bubble.getGroupId());
378 }
379 if (insertBubble(minInsertPoint, bubble) < mBubbles.size() - 1) {
380 mStateChange.orderChanged = true;
381 }
382 mStateChange.addedBubble = bubble;
383
384 if (!isExpanded()) {
385 mStateChange.orderChanged |= packGroup(findFirstIndexForGroup(bubble.getGroupId()));
386 // Top bubble becomes selected.
387 setSelectedBubbleInternal(mBubbles.get(0));
388 }
389 }
390
391 private void trim() {
392 if (mBubbles.size() > mMaxBubbles) {
393 mBubbles.stream()
394 // sort oldest first (ascending lastActivity)
395 .sorted(Comparator.comparingLong(Bubble::getLastActivity))
396 // skip the selected bubble
397 .filter((b) -> !b.equals(mSelectedBubble))
398 .findFirst()
399 .ifPresent((b) -> doRemove(b.getKey(), BubbleController.DISMISS_AGED));
400 }
401 }
402
403 private void doUpdate(Bubble bubble) {
404 if (DEBUG_BUBBLE_DATA) {
405 Log.d(TAG, "doUpdate: " + bubble);
406 }
407 mStateChange.updatedBubble = bubble;
408 if (!isExpanded()) {
409 // while collapsed, update causes re-pack
410 int prevPos = mBubbles.indexOf(bubble);
411 mBubbles.remove(bubble);
412 int newPos = insertBubble(0, bubble);
413 if (prevPos != newPos) {
414 packGroup(newPos);
415 mStateChange.orderChanged = true;
416 }
417 setSelectedBubbleInternal(mBubbles.get(0));
418 }
419 }
420
421 private void doRemove(String key, @DismissReason int reason) {
422 if (DEBUG_BUBBLE_DATA) {
423 Log.d(TAG, "doRemove: " + key);
424 }
425 // If it was pending remove it
426 for (int i = 0; i < mPendingBubbles.size(); i++) {
427 if (mPendingBubbles.get(i).getKey().equals(key)) {
428 mPendingBubbles.remove(mPendingBubbles.get(i));
429 }
430 }
431 int indexToRemove = indexForKey(key);
432 if (indexToRemove == -1) {
433 if (hasOverflowBubbleWithKey(key)
434 && (reason == BubbleController.DISMISS_NOTIF_CANCEL
435 || reason == BubbleController.DISMISS_GROUP_CANCELLED
436 || reason == BubbleController.DISMISS_NO_LONGER_BUBBLE
437 || reason == BubbleController.DISMISS_BLOCKED)) {
438
439 Bubble b = getOverflowBubbleWithKey(key);
440 if (DEBUG_BUBBLE_DATA) {
441 Log.d(TAG, "Cancel overflow bubble: " + b);
442 }
443 mStateChange.bubbleRemoved(b, reason);
444 mOverflowBubbles.remove(b);
445 }
446 return;
447 }
448 Bubble bubbleToRemove = mBubbles.get(indexToRemove);
449 if (mBubbles.size() == 1) {
450 // Going to become empty, handle specially.
451 setExpandedInternal(false);
452 setSelectedBubbleInternal(null);
453 }
454 if (indexToRemove < mBubbles.size() - 1) {
455 // Removing anything but the last bubble means positions will change.
456 mStateChange.orderChanged = true;
457 }
458 mBubbles.remove(indexToRemove);
459 mStateChange.bubbleRemoved(bubbleToRemove, reason);
460 if (!isExpanded()) {
461 mStateChange.orderChanged |= repackAll();
462 }
463
464 overflowBubble(reason, bubbleToRemove);
465
466 // Note: If mBubbles.isEmpty(), then mSelectedBubble is now null.
467 if (Objects.equals(mSelectedBubble, bubbleToRemove)) {
468 // Move selection to the new bubble at the same position.
469 int newIndex = Math.min(indexToRemove, mBubbles.size() - 1);
470 Bubble newSelected = mBubbles.get(newIndex);
471 setSelectedBubbleInternal(newSelected);
472 }
473 maybeSendDeleteIntent(reason, bubbleToRemove.getEntry());
474 }
475
476 void overflowBubble(@DismissReason int reason, Bubble bubble) {
477 if (bubble.getPendingIntentCanceled()
478 || !(reason == BubbleController.DISMISS_AGED
479 || reason == BubbleController.DISMISS_USER_GESTURE)) {
480 return;
481 }
482 if (DEBUG_BUBBLE_DATA) {
483 Log.d(TAG, "Overflowing: " + bubble);
484 }
485 mOverflowBubbles.add(0, bubble);
486 bubble.stopInflation();
487 if (mOverflowBubbles.size() == mMaxOverflowBubbles + 1) {
488 // Remove oldest bubble.
489 Bubble oldest = mOverflowBubbles.get(mOverflowBubbles.size() - 1);
490 if (DEBUG_BUBBLE_DATA) {
491 Log.d(TAG, "Overflow full. Remove: " + oldest);
492 }
493 mStateChange.bubbleRemoved(oldest, BubbleController.DISMISS_OVERFLOW_MAX_REACHED);
494 mOverflowBubbles.remove(oldest);
495 }
496 }
497
498 public void dismissAll(@DismissReason int reason) {
499 if (DEBUG_BUBBLE_DATA) {
500 Log.d(TAG, "dismissAll: reason=" + reason);
501 }
502 if (mBubbles.isEmpty()) {
503 return;
504 }
505 setExpandedInternal(false);
506 setSelectedBubbleInternal(null);
507 while (!mBubbles.isEmpty()) {
508 doRemove(mBubbles.get(0).getKey(), reason);
509 }
510 dispatchPendingChanges();
511 }
512
513 /**
514 * Indicates that the provided display is no longer in use and should be cleaned up.
515 *
516 * @param displayId the id of the display to clean up.
517 */
518 void notifyDisplayEmpty(int displayId) {
519 for (Bubble b : mBubbles) {
520 if (b.getDisplayId() == displayId) {
521 if (b.getExpandedView() != null) {
522 b.getExpandedView().notifyDisplayEmpty();
523 }
524 return;
525 }
526 }
527 }
528
529 private void dispatchPendingChanges() {
530 if (mListener != null && mStateChange.anythingChanged()) {
531 mListener.applyUpdate(mStateChange);
532 }
533 mStateChange = new Update(mBubbles, mOverflowBubbles);
534 }
535
536 /**
537 * Requests a change to the selected bubble.
538 *
539 * @param bubble the new selected bubble
540 */
541 private void setSelectedBubbleInternal(@Nullable Bubble bubble) {
542 if (DEBUG_BUBBLE_DATA) {
543 Log.d(TAG, "setSelectedBubbleInternal: " + bubble);
544 }
545 if (!mShowingOverflow && Objects.equals(bubble, mSelectedBubble)) {
546 return;
547 }
548 // Otherwise, if we are showing the overflow menu, return to the previously selected bubble.
549
550 if (bubble != null && !mBubbles.contains(bubble) && !mOverflowBubbles.contains(bubble)) {
551 Log.e(TAG, "Cannot select bubble which doesn't exist!"
552 + " (" + bubble + ") bubbles=" + mBubbles);
553 return;
554 }
555 if (mExpanded && bubble != null) {
556 bubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
557 }
558 mSelectedBubble = bubble;
559 mStateChange.selectedBubble = bubble;
560 mStateChange.selectionChanged = true;
561 }
562
563 /**
564 * Requests a change to the expanded state.
565 *
566 * @param shouldExpand the new requested state
567 */
568 private void setExpandedInternal(boolean shouldExpand) {
569 if (DEBUG_BUBBLE_DATA) {
570 Log.d(TAG, "setExpandedInternal: shouldExpand=" + shouldExpand);
571 }
572 if (mExpanded == shouldExpand) {
573 return;
574 }
575 if (shouldExpand) {
576 if (mBubbles.isEmpty()) {
577 Log.e(TAG, "Attempt to expand stack when empty!");
578 return;
579 }
580 if (mSelectedBubble == null) {
581 Log.e(TAG, "Attempt to expand stack without selected bubble!");
582 return;
583 }
584 mSelectedBubble.markUpdatedAt(mTimeSource.currentTimeMillis());
585 mSelectedBubble.markAsAccessedAt(mTimeSource.currentTimeMillis());
586 mStateChange.orderChanged |= repackAll();
587 } else if (!mBubbles.isEmpty()) {
588 // Apply ordering and grouping rules from expanded -> collapsed, then save
589 // the result.
590 mStateChange.orderChanged |= repackAll();
591 // Save the state which should be returned to when expanded (with no other changes)
592
593 if (mShowingOverflow) {
594 // Show previously selected bubble instead of overflow menu on next expansion.
595 setSelectedBubbleInternal(mSelectedBubble);
596 }
597 if (mBubbles.indexOf(mSelectedBubble) > 0) {
598 // Move the selected bubble to the top while collapsed.
599 if (!mSelectedBubble.isOngoing() && mBubbles.get(0).isOngoing()) {
600 // The selected bubble cannot be raised to the first position because
601 // there is an ongoing bubble there. Instead, force the top ongoing bubble
602 // to become selected.
603 setSelectedBubbleInternal(mBubbles.get(0));
604 } else {
605 // Raise the selected bubble (and it's group) up to the front so the selected
606 // bubble remains on top.
607 mBubbles.remove(mSelectedBubble);
608 mBubbles.add(0, mSelectedBubble);
609 mStateChange.orderChanged |= packGroup(0);
610 }
611 }
612 }
613 mExpanded = shouldExpand;
614 mStateChange.expanded = shouldExpand;
615 mStateChange.expandedChanged = true;
616 }
617
618 private static long sortKey(Bubble bubble) {
619 long key = bubble.getLastUpdateTime();
620 if (bubble.isOngoing()) {
621 // Set 2nd highest bit (signed long int), to partition between ongoing and regular
622 key |= 0x4000000000000000L;
623 }
624 return key;
625 }
626
627 /**
628 * Locates and inserts the bubble into a sorted position. The is inserted
629 * based on sort key, groupId is not considered. A call to {@link #packGroup(int)} may be
630 * required to keep grouping intact.
631 *
632 * @param minPosition the first insert point to consider
633 * @param newBubble the bubble to insert
634 * @return the position where the bubble was inserted
635 */
636 private int insertBubble(int minPosition, Bubble newBubble) {
637 long newBubbleSortKey = sortKey(newBubble);
638 String previousGroupId = null;
639
640 for (int pos = minPosition; pos < mBubbles.size(); pos++) {
641 Bubble bubbleAtPos = mBubbles.get(pos);
642 String groupIdAtPos = bubbleAtPos.getGroupId();
643 boolean atStartOfGroup = !groupIdAtPos.equals(previousGroupId);
644
645 if (atStartOfGroup && newBubbleSortKey > sortKey(bubbleAtPos)) {
646 // Insert before the start of first group which has older bubbles.
647 mBubbles.add(pos, newBubble);
648 return pos;
649 }
650 previousGroupId = groupIdAtPos;
651 }
652 mBubbles.add(newBubble);
653 return mBubbles.size() - 1;
654 }
655
656 private boolean hasBubbleWithGroupId(String groupId) {
657 return mBubbles.stream().anyMatch(b -> b.getGroupId().equals(groupId));
658 }
659
660 private int findFirstIndexForGroup(String appId) {
661 for (int i = 0; i < mBubbles.size(); i++) {
662 Bubble bubbleAtPos = mBubbles.get(i);
663 if (bubbleAtPos.getGroupId().equals(appId)) {
664 return i;
665 }
666 }
667 return 0;
668 }
669
670 /**
671 * Starting at the given position, moves all bubbles with the same group id to follow. Bubbles
672 * at positions lower than {@code position} are unchanged. Relative order within the group
673 * unchanged. Relative order of any other bubbles are also unchanged.
674 *
675 * @param position the position of the first bubble for the group
676 * @return true if the position of any bubbles has changed as a result
677 */
678 private boolean packGroup(int position) {
679 if (DEBUG_BUBBLE_DATA) {
680 Log.d(TAG, "packGroup: position=" + position);
681 }
682 Bubble groupStart = mBubbles.get(position);
683 final String groupAppId = groupStart.getGroupId();
684 List<Bubble> moving = new ArrayList<>();
685
686 // Walk backward, collect bubbles within the group
687 for (int i = mBubbles.size() - 1; i > position; i--) {
688 if (mBubbles.get(i).getGroupId().equals(groupAppId)) {
689 moving.add(0, mBubbles.get(i));
690 }
691 }
692 if (moving.isEmpty()) {
693 return false;
694 }
695 mBubbles.removeAll(moving);
696 mBubbles.addAll(position + 1, moving);
697 return true;
698 }
699
700 /**
701 * This applies a full sort and group pass to all existing bubbles. The bubbles are grouped
702 * by groupId. Each group is then sorted by the max(lastUpdated) time of its bubbles. Bubbles
703 * within each group are then sorted by lastUpdated descending.
704 *
705 * @return true if the position of any bubbles changed as a result
706 */
707 private boolean repackAll() {
708 if (DEBUG_BUBBLE_DATA) {
709 Log.d(TAG, "repackAll()");
710 }
711 if (mBubbles.isEmpty()) {
712 return false;
713 }
714 Map<String, Long> groupLastActivity = new HashMap<>();
715 for (Bubble bubble : mBubbles) {
716 long maxSortKeyForGroup = groupLastActivity.getOrDefault(bubble.getGroupId(), 0L);
717 long sortKeyForBubble = sortKey(bubble);
718 if (sortKeyForBubble > maxSortKeyForGroup) {
719 groupLastActivity.put(bubble.getGroupId(), sortKeyForBubble);
720 }
721 }
722
723 // Sort groups by their most recently active bubble
724 List<String> groupsByMostRecentActivity =
725 groupLastActivity.entrySet().stream()
726 .sorted(GROUPS_BY_MAX_SORT_KEY_DESCENDING)
727 .map(Map.Entry::getKey)
728 .collect(toList());
729
730 List<Bubble> repacked = new ArrayList<>(mBubbles.size());
731
732 // For each group, add bubbles, freshest to oldest
733 for (String appId : groupsByMostRecentActivity) {
734 mBubbles.stream()
735 .filter((b) -> b.getGroupId().equals(appId))
736 .sorted(BUBBLES_BY_SORT_KEY_DESCENDING)
737 .forEachOrdered(repacked::add);
738 }
739 if (repacked.equals(mBubbles)) {
740 return false;
741 }
742 mBubbles.clear();
743 mBubbles.addAll(repacked);
744 return true;
745 }
746
747 private void maybeSendDeleteIntent(@DismissReason int reason, NotificationEntry entry) {
748 if (reason == BubbleController.DISMISS_USER_GESTURE) {
749 Notification.BubbleMetadata bubbleMetadata = entry.getBubbleMetadata();
750 PendingIntent deleteIntent = bubbleMetadata != null
751 ? bubbleMetadata.getDeleteIntent()
752 : null;
753 if (deleteIntent != null) {
754 try {
755 deleteIntent.send();
756 } catch (PendingIntent.CanceledException e) {
757 Log.w(TAG, "Failed to send delete intent for bubble with key: "
758 + entry.getKey());
759 }
760 }
761 }
762 }
763
764 private int indexForKey(String key) {
765 for (int i = 0; i < mBubbles.size(); i++) {
766 Bubble bubble = mBubbles.get(i);
767 if (bubble.getKey().equals(key)) {
768 return i;
769 }
770 }
771 return -1;
772 }
773
774 /**
775 * The set of bubbles in row.
776 */
777 @VisibleForTesting(visibility = PACKAGE)
778 public List<Bubble> getBubbles() {
779 return Collections.unmodifiableList(mBubbles);
780 }
781 /**
782 * The set of bubbles in overflow.
783 */
784 @VisibleForTesting(visibility = PRIVATE)
785 public List<Bubble> getOverflowBubbles() {
786 return Collections.unmodifiableList(mOverflowBubbles);
787 }
788
789 @VisibleForTesting(visibility = PRIVATE)
790 @Nullable
791 Bubble getAnyBubbleWithkey(String key) {
792 Bubble b = getBubbleInStackWithKey(key);
793 if (b == null) {
794 b = getOverflowBubbleWithKey(key);
795 }
796 return b;
797 }
798
799 @VisibleForTesting(visibility = PRIVATE)
800 @Nullable
801 Bubble getBubbleInStackWithKey(String key) {
802 for (int i = 0; i < mBubbles.size(); i++) {
803 Bubble bubble = mBubbles.get(i);
804 if (bubble.getKey().equals(key)) {
805 return bubble;
806 }
807 }
808 return null;
809 }
810
811 @Nullable
812 Bubble getBubbleWithView(View view) {
813 for (int i = 0; i < mBubbles.size(); i++) {
814 Bubble bubble = mBubbles.get(i);
815 if (bubble.getIconView() != null && bubble.getIconView().equals(view)) {
816 return bubble;
817 }
818 }
819 return null;
820 }
821
822 @VisibleForTesting(visibility = PRIVATE)
823 Bubble getOverflowBubbleWithKey(String key) {
824 for (int i = 0; i < mOverflowBubbles.size(); i++) {
825 Bubble bubble = mOverflowBubbles.get(i);
826 if (bubble.getKey().equals(key)) {
827 return bubble;
828 }
829 }
830 return null;
831 }
832
833 @VisibleForTesting(visibility = PRIVATE)
834 void setTimeSource(TimeSource timeSource) {
835 mTimeSource = timeSource;
836 }
837
838 public void setListener(Listener listener) {
839 mListener = listener;
840 }
841
842 /**
843 * Set maximum number of bubbles allowed in overflow.
844 * This method should only be used in tests, not in production.
845 */
846 @VisibleForTesting
847 void setMaxOverflowBubbles(int maxOverflowBubbles) {
848 mMaxOverflowBubbles = maxOverflowBubbles;
849 }
850
851 /**
852 * Description of current bubble data state.
853 */
854 public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
855 pw.print("selected: ");
856 pw.println(mSelectedBubble != null
857 ? mSelectedBubble.getKey()
858 : "null");
859 pw.print("expanded: ");
860 pw.println(mExpanded);
861 pw.print("count: ");
862 pw.println(mBubbles.size());
863 for (Bubble bubble : mBubbles) {
864 bubble.dump(fd, pw, args);
865 }
866 pw.print("summaryKeys: ");
867 pw.println(mSuppressedGroupKeys.size());
868 for (String key : mSuppressedGroupKeys.keySet()) {
869 pw.println(" suppressing: " + key);
870 }
871 }
872}