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