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