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