1 /* 2 * Copyright (C) 2018 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.car.notification; 17 18 import static android.app.Notification.FLAG_AUTOGROUP_SUMMARY; 19 20 import android.annotation.Nullable; 21 import android.app.Notification; 22 import android.app.NotificationManager; 23 import android.car.drivingstate.CarUxRestrictionsManager; 24 import android.content.BroadcastReceiver; 25 import android.content.Context; 26 import android.content.Intent; 27 import android.content.IntentFilter; 28 import android.content.res.Resources; 29 import android.os.Build; 30 import android.os.Bundle; 31 import android.service.notification.NotificationListenerService; 32 import android.service.notification.NotificationListenerService.RankingMap; 33 import android.telephony.TelephonyManager; 34 import android.text.TextUtils; 35 import android.util.Log; 36 37 import androidx.annotation.VisibleForTesting; 38 39 import com.android.car.notification.template.MessageNotificationViewHolder; 40 41 import java.util.ArrayList; 42 import java.util.Collections; 43 import java.util.Comparator; 44 import java.util.HashSet; 45 import java.util.List; 46 import java.util.Map; 47 import java.util.Set; 48 import java.util.SortedMap; 49 import java.util.TreeMap; 50 import java.util.UUID; 51 52 /** 53 * Manager that filters, groups and ranks the notifications in the notification center. 54 * 55 * <p> Note that heads-up notifications have a different filtering mechanism and is managed by 56 * {@link CarHeadsUpNotificationManager}. 57 */ 58 public class PreprocessingManager { 59 60 /** Listener that will be notified when a call state changes. **/ 61 public interface CallStateListener { 62 /** 63 * @param isInCall is true when user is currently in a call. 64 */ onCallStateChanged(boolean isInCall)65 void onCallStateChanged(boolean isInCall); 66 } 67 68 private static final boolean DEBUG = Build.IS_ENG || Build.IS_USERDEBUG; 69 private static final String TAG = "PreprocessingManager"; 70 71 private final String mEllipsizedSuffix; 72 private final Context mContext; 73 private final boolean mShowRecentsAndOlderHeaders; 74 private final boolean mUseLauncherIcon; 75 private final int mMinimumGroupingThreshold; 76 77 private static PreprocessingManager sInstance; 78 79 private int mMaxStringLength = Integer.MAX_VALUE; 80 private Map<String, AlertEntry> mOldNotifications; 81 private List<NotificationGroup> mOldProcessedNotifications; 82 private RankingMap mOldRankingMap; 83 private NotificationDataManager mNotificationDataManager; 84 85 private boolean mIsInCall; 86 private List<CallStateListener> mCallStateListeners = new ArrayList<>(); 87 88 @VisibleForTesting 89 final BroadcastReceiver mIntentReceiver = new BroadcastReceiver() { 90 @Override 91 public void onReceive(Context context, Intent intent) { 92 String action = intent.getAction(); 93 if (action.equals(TelephonyManager.ACTION_PHONE_STATE_CHANGED)) { 94 mIsInCall = TelephonyManager.EXTRA_STATE_OFFHOOK 95 .equals(intent.getStringExtra(TelephonyManager.EXTRA_STATE)); 96 for (CallStateListener listener : mCallStateListeners) { 97 listener.onCallStateChanged(mIsInCall); 98 } 99 } 100 } 101 }; 102 PreprocessingManager(Context context)103 private PreprocessingManager(Context context) { 104 mEllipsizedSuffix = context.getString(R.string.ellipsized_string); 105 mContext = context; 106 mNotificationDataManager = NotificationDataManager.getInstance(); 107 108 Resources resources = mContext.getResources(); 109 mShowRecentsAndOlderHeaders = resources.getBoolean(R.bool.config_showRecentAndOldHeaders); 110 mUseLauncherIcon = resources.getBoolean(R.bool.config_useLauncherIcon); 111 mMinimumGroupingThreshold = resources.getInteger(R.integer.config_minimumGroupingThreshold); 112 113 IntentFilter filter = new IntentFilter(); 114 filter.addAction(TelephonyManager.ACTION_PHONE_STATE_CHANGED); 115 context.registerReceiver(mIntentReceiver, filter); 116 } 117 getInstance(Context context)118 public static PreprocessingManager getInstance(Context context) { 119 if (sInstance == null) { 120 sInstance = new PreprocessingManager(context); 121 } 122 return sInstance; 123 } 124 125 @VisibleForTesting refreshInstance()126 static void refreshInstance() { 127 sInstance = null; 128 } 129 130 @VisibleForTesting setNotificationDataManager(NotificationDataManager notificationDataManager)131 void setNotificationDataManager(NotificationDataManager notificationDataManager) { 132 mNotificationDataManager = notificationDataManager; 133 } 134 135 /** 136 * Initialize the data when the UI becomes foreground. 137 */ init(Map<String, AlertEntry> notifications, RankingMap rankingMap)138 public void init(Map<String, AlertEntry> notifications, RankingMap rankingMap) { 139 mOldNotifications = notifications; 140 mOldRankingMap = rankingMap; 141 mOldProcessedNotifications = process(notifications, rankingMap); 142 } 143 144 /** 145 * Process the given notifications. In order for DiffUtil to work, the adapter needs a new 146 * data object each time it updates, therefore wrapping the return value in a new list. 147 * 148 * @param notifications the list of notifications to be processed. 149 * @param rankingMap the ranking map for the notifications. 150 * @return the processed notifications in a new list. 151 */ process(Map<String, AlertEntry> notifications, RankingMap rankingMap)152 public List<NotificationGroup> process(Map<String, AlertEntry> notifications, 153 RankingMap rankingMap) { 154 return new ArrayList<>( 155 rank(group(optimizeForDriving( 156 filter(new ArrayList<>(notifications.values()), 157 rankingMap))), 158 rankingMap)); 159 } 160 161 /** 162 * Create a new list of notifications based adding/removing a notification to/from 163 * an existing list. 164 * 165 * @param newRankingMap the latest ranking map for the notifications. 166 * @return the new notification group list that should be shown to the user. 167 */ updateNotifications( AlertEntry alertEntry, int updateType, RankingMap newRankingMap)168 public List<NotificationGroup> updateNotifications( 169 AlertEntry alertEntry, 170 int updateType, 171 RankingMap newRankingMap) { 172 173 switch (updateType) { 174 case CarNotificationListener.NOTIFY_NOTIFICATION_REMOVED: 175 // removal of a notification is the same as a normal preprocessing 176 mOldNotifications.remove(alertEntry.getKey()); 177 mOldProcessedNotifications = 178 process(mOldNotifications, mOldRankingMap); 179 break; 180 case CarNotificationListener.NOTIFY_NOTIFICATION_POSTED: 181 AlertEntry notification = optimizeForDriving(alertEntry); 182 boolean isUpdate = mOldNotifications.containsKey(notification.getKey()); 183 mOldNotifications.put(notification.getKey(), notification); 184 // insert a new notification into the list 185 mOldProcessedNotifications = new ArrayList<>( 186 additionalGroupAndRank((alertEntry), newRankingMap, isUpdate)); 187 break; 188 } 189 190 return mOldProcessedNotifications; 191 } 192 193 /** Add {@link CallStateListener} in order to be notified when call state is changed. **/ addCallStateListener(CallStateListener listener)194 public void addCallStateListener(CallStateListener listener) { 195 if (mCallStateListeners.contains(listener)) return; 196 mCallStateListeners.add(listener); 197 listener.onCallStateChanged(mIsInCall); 198 } 199 200 /** Remove {@link CallStateListener} to stop getting notified when call state is changed. **/ removeCallStateListener(CallStateListener listener)201 public void removeCallStateListener(CallStateListener listener) { 202 mCallStateListeners.remove(listener); 203 } 204 205 /** 206 * Returns true if the current {@link AlertEntry} should be filtered out and not 207 * added to the list. 208 */ shouldFilter(AlertEntry alertEntry, RankingMap rankingMap)209 boolean shouldFilter(AlertEntry alertEntry, RankingMap rankingMap) { 210 return isLessImportantForegroundNotification(alertEntry, rankingMap) 211 || isMediaOrNavigationNotification(alertEntry); 212 } 213 214 /** 215 * Filter a list of {@link AlertEntry}s according to OEM's configurations. 216 */ 217 @VisibleForTesting filter( List<AlertEntry> notifications, RankingMap rankingMap)218 protected List<AlertEntry> filter( 219 List<AlertEntry> notifications, 220 RankingMap rankingMap) { 221 // Call notifications should not be shown in the panel. 222 // Since they're shown as persistent HUNs, and notifications are not added to the panel 223 // until after they're dismissed as HUNs, it does not make sense to have them in the panel, 224 // and sequencing could cause them to be removed before being added here. 225 notifications.removeIf(alertEntry -> Notification.CATEGORY_CALL.equals( 226 alertEntry.getNotification().category)); 227 228 // HUN suppression notifications should not be shown in the panel. 229 notifications.removeIf(alertEntry -> CarHeadsUpNotificationQueue.CATEGORY_HUN_QUEUE_INTERNAL 230 .equals(alertEntry.getNotification().category)); 231 232 if (DEBUG) { 233 Log.d(TAG, "Filtered notifications: " + notifications); 234 } 235 236 return notifications; 237 } 238 isLessImportantForegroundNotification(AlertEntry alertEntry, RankingMap rankingMap)239 private boolean isLessImportantForegroundNotification(AlertEntry alertEntry, 240 RankingMap rankingMap) { 241 boolean isForeground = 242 (alertEntry.getNotification().flags 243 & Notification.FLAG_FOREGROUND_SERVICE) != 0; 244 245 if (!isForeground) { 246 Log.d(TAG, alertEntry + " is not a foreground notification."); 247 return false; 248 } 249 250 int importance = 0; 251 NotificationListenerService.Ranking ranking = 252 new NotificationListenerService.Ranking(); 253 if (rankingMap.getRanking(alertEntry.getKey(), ranking)) { 254 importance = ranking.getImportance(); 255 } 256 257 if (DEBUG) { 258 if (importance < NotificationManager.IMPORTANCE_DEFAULT) { 259 Log.d(TAG, alertEntry + " importance is insufficient to show in notification " 260 + "center"); 261 } else { 262 Log.d(TAG, alertEntry + " importance is sufficient to show in notification " 263 + "center"); 264 } 265 266 if (NotificationUtils.isSystemPrivilegedOrPlatformKey(mContext, alertEntry)) { 267 Log.d(TAG, alertEntry + " application is system privileged or signed with " 268 + "platform key"); 269 } else { 270 Log.d(TAG, alertEntry + " application is neither system privileged nor signed " 271 + "with platform key"); 272 } 273 } 274 275 return importance < NotificationManager.IMPORTANCE_DEFAULT 276 && NotificationUtils.isSystemPrivilegedOrPlatformKey(mContext, alertEntry); 277 } 278 isMediaOrNavigationNotification(AlertEntry alertEntry)279 private boolean isMediaOrNavigationNotification(AlertEntry alertEntry) { 280 Notification notification = alertEntry.getNotification(); 281 boolean mediaOrNav = notification.isMediaNotification() 282 || Notification.CATEGORY_NAVIGATION.equals(notification.category); 283 if (DEBUG) { 284 Log.d(TAG, alertEntry + " category: " + notification.category); 285 } 286 return mediaOrNav; 287 } 288 289 /** 290 * Process a list of {@link AlertEntry}s to be driving optimized. 291 * 292 * <p> Note that the string length limit is always respected regardless of whether distraction 293 * optimization is required. 294 */ optimizeForDriving(List<AlertEntry> notifications)295 private List<AlertEntry> optimizeForDriving(List<AlertEntry> notifications) { 296 notifications.forEach(notification -> notification = optimizeForDriving(notification)); 297 return notifications; 298 } 299 300 /** 301 * Helper method that optimize a single {@link AlertEntry} for driving. 302 * 303 * <p> Currently only trimming texts that have visual effects in car. Operation is done on 304 * the original notification object passed in; no new object is created. 305 * 306 * <p> Note that message notifications are not trimmed, so that messages are preserved for 307 * assistant read-out. Instead, {@link MessageNotificationViewHolder} will be responsible 308 * for the presentation-level text truncation. 309 */ optimizeForDriving(AlertEntry alertEntry)310 AlertEntry optimizeForDriving(AlertEntry alertEntry) { 311 if (Notification.CATEGORY_MESSAGE.equals(alertEntry.getNotification().category)) { 312 return alertEntry; 313 } 314 315 Bundle extras = alertEntry.getNotification().extras; 316 if (extras == null) { 317 return alertEntry; 318 } 319 320 for (String key : extras.keySet()) { 321 switch (key) { 322 case Notification.EXTRA_TITLE: 323 case Notification.EXTRA_TEXT: 324 case Notification.EXTRA_TITLE_BIG: 325 case Notification.EXTRA_SUMMARY_TEXT: 326 CharSequence value = extras.getCharSequence(key); 327 extras.putCharSequence(key, trimText(value)); 328 } 329 } 330 return alertEntry; 331 } 332 333 /** 334 * Helper method that takes a string and trims the length to the maximum character allowed 335 * by the {@link CarUxRestrictionsManager}. 336 */ 337 @Nullable trimText(@ullable CharSequence text)338 public CharSequence trimText(@Nullable CharSequence text) { 339 if (TextUtils.isEmpty(text) || text.length() < mMaxStringLength) { 340 return text; 341 } 342 int maxLength = mMaxStringLength - mEllipsizedSuffix.length(); 343 return text.toString().substring(0, maxLength) + mEllipsizedSuffix; 344 } 345 346 /** 347 * @return the maximum numbers of characters allowed by the {@link CarUxRestrictionsManager} 348 */ getMaximumStringLength()349 public int getMaximumStringLength() { 350 return mMaxStringLength; 351 } 352 353 /** 354 * Group notifications that have the same group key. 355 * 356 * <p> Automatically generated group summaries that contains no child notifications are removed. 357 * This can happen if a notification group only contains less important notifications that are 358 * filtered out in the previous {@link #filter} step. 359 * 360 * <p> A group of child notifications without a summary notification will not be grouped. 361 * 362 * @param list list of ungrouped {@link AlertEntry}s. 363 * @return list of grouped notifications as {@link NotificationGroup}s. 364 */ 365 @VisibleForTesting group(List<AlertEntry> list)366 List<NotificationGroup> group(List<AlertEntry> list) { 367 SortedMap<String, NotificationGroup> groupedNotifications = new TreeMap<>(); 368 369 // First pass: group all notifications according to their groupKey. 370 for (int i = 0; i < list.size(); i++) { 371 AlertEntry alertEntry = list.get(i); 372 Notification notification = alertEntry.getNotification(); 373 374 String groupKey; 375 if (Notification.CATEGORY_CALL.equals(notification.category)) { 376 // DO NOT group CATEGORY_CALL. 377 groupKey = UUID.randomUUID().toString(); 378 } else { 379 groupKey = alertEntry.getStatusBarNotification().getGroupKey(); 380 } 381 382 if (groupKey == null) { 383 // set a random group key since a TreeMap does not allow null keys 384 groupKey = UUID.randomUUID().toString(); 385 } 386 387 if (!groupedNotifications.containsKey(groupKey)) { 388 NotificationGroup notificationGroup = new NotificationGroup(); 389 groupedNotifications.put(groupKey, notificationGroup); 390 } 391 if (notification.isGroupSummary()) { 392 groupedNotifications.get(groupKey) 393 .setGroupSummaryNotification(alertEntry); 394 } else { 395 groupedNotifications.get(groupKey).addNotification(alertEntry); 396 } 397 } 398 if (DEBUG) { 399 Log.d(TAG, "(First pass) Grouped notifications according to groupKey: " 400 + groupedNotifications); 401 } 402 403 // Second pass: remove automatically generated group summary if it contains no child 404 // notifications. This can happen if a notification group only contains less important 405 // notifications that are filtered out in the previous filter step. 406 List<NotificationGroup> groupList = new ArrayList<>(groupedNotifications.values()); 407 groupList.removeIf( 408 notificationGroup -> { 409 AlertEntry summaryNotification = 410 notificationGroup.getGroupSummaryNotification(); 411 return notificationGroup.getChildCount() == 0 412 && summaryNotification != null 413 && summaryNotification.getStatusBarNotification().getOverrideGroupKey() 414 != null; 415 }); 416 if (DEBUG) { 417 Log.d(TAG, "(Second pass) Remove automatically generated group summaries: " 418 + groupList); 419 } 420 421 if (mShowRecentsAndOlderHeaders) { 422 mNotificationDataManager.updateUnseenNotificationGroups(groupList); 423 } 424 425 426 // Third Pass: If a notification group has seen and unseen notifications, we need to split 427 // up the group into its seen and unseen constituents. 428 List<NotificationGroup> tempGroupList = new ArrayList<>(); 429 groupList.forEach(notificationGroup -> { 430 AlertEntry groupSummary = notificationGroup.getGroupSummaryNotification(); 431 if (groupSummary == null || !mShowRecentsAndOlderHeaders) { 432 boolean isNotificationSeen = mNotificationDataManager 433 .isNotificationSeen(notificationGroup.getSingleNotification()); 434 notificationGroup.setSeen(isNotificationSeen); 435 tempGroupList.add(notificationGroup); 436 return; 437 } 438 439 NotificationGroup seenNotificationGroup = new NotificationGroup(); 440 seenNotificationGroup.setSeen(true); 441 seenNotificationGroup.setGroupSummaryNotification(groupSummary); 442 NotificationGroup unseenNotificationGroup = new NotificationGroup(); 443 unseenNotificationGroup.setGroupSummaryNotification(groupSummary); 444 unseenNotificationGroup.setSeen(false); 445 446 notificationGroup.getChildNotifications().forEach(alertEntry -> { 447 if (mNotificationDataManager.isNotificationSeen(alertEntry)) { 448 seenNotificationGroup.addNotification(alertEntry); 449 } else { 450 unseenNotificationGroup.addNotification(alertEntry); 451 } 452 }); 453 tempGroupList.add(unseenNotificationGroup); 454 tempGroupList.add(seenNotificationGroup); 455 }); 456 groupList.clear(); 457 groupList.addAll(tempGroupList); 458 if (DEBUG) { 459 Log.d(TAG, "(Third pass) Split notification groups by seen and unseen: " 460 + groupList); 461 } 462 463 List<NotificationGroup> validGroupList = new ArrayList<>(); 464 if (mUseLauncherIcon) { 465 // Fourth pass: since we do not use group summaries when using launcher icon, we can 466 // restore groups into individual notifications that do not meet grouping threshold. 467 groupList.forEach( 468 group -> { 469 if (group.getChildCount() < mMinimumGroupingThreshold) { 470 group.getChildNotifications().forEach( 471 notification -> { 472 NotificationGroup newGroup = new NotificationGroup(); 473 newGroup.addNotification(notification); 474 newGroup.setSeen(group.isSeen()); 475 validGroupList.add(newGroup); 476 }); 477 } else { 478 validGroupList.add(group); 479 } 480 }); 481 } else { 482 // Fourth pass: a notification group without a group summary or a notification group 483 // that do not meet grouping threshold should be restored back into individual 484 // notifications. 485 groupList.forEach( 486 group -> { 487 boolean groupWithNoGroupSummary = group.getChildCount() > 1 488 && group.getGroupSummaryNotification() == null; 489 boolean groupWithGroupSummaryButNotEnoughNotifs = 490 group.getChildCount() < mMinimumGroupingThreshold 491 && group.getGroupSummaryNotification() != null; 492 if (groupWithNoGroupSummary || groupWithGroupSummaryButNotEnoughNotifs) { 493 group.getChildNotifications().forEach( 494 notification -> { 495 NotificationGroup newGroup = new NotificationGroup(); 496 newGroup.addNotification(notification); 497 newGroup.setSeen(group.isSeen()); 498 validGroupList.add(newGroup); 499 }); 500 } else { 501 validGroupList.add(group); 502 } 503 }); 504 } 505 if (DEBUG) { 506 if (mUseLauncherIcon) { 507 Log.d(TAG, "(Fourth pass) Split notification groups that do not meet minimum " 508 + "grouping threshold of " + mMinimumGroupingThreshold + " : " 509 + validGroupList); 510 } else { 511 Log.d(TAG, "(Fourth pass) Restore notifications without group summaries and do" 512 + " not meet minimum grouping threshold of " + mMinimumGroupingThreshold 513 + " : " + validGroupList); 514 } 515 } 516 517 518 // Fifth Pass: group notifications with no child notifications should be removed. 519 validGroupList.removeIf(notificationGroup -> 520 notificationGroup.getChildNotifications().isEmpty()); 521 if (DEBUG) { 522 Log.d(TAG, "(Fifth pass) Group notifications without child notifications " 523 + "are removed: " + validGroupList); 524 } 525 526 // Sixth pass: if a notification is a group notification, update the timestamp if one of 527 // the children notifications shows a timestamp. 528 validGroupList.forEach(group -> { 529 if (!group.isGroup()) { 530 return; 531 } 532 533 AlertEntry groupSummaryNotification = group.getGroupSummaryNotification(); 534 boolean showWhen = false; 535 long greatestTimestamp = 0; 536 for (AlertEntry notification : group.getChildNotifications()) { 537 if (notification.getNotification().showsTime()) { 538 showWhen = true; 539 greatestTimestamp = Math.max(greatestTimestamp, 540 notification.getNotification().when); 541 } 542 } 543 544 if (showWhen) { 545 groupSummaryNotification.getNotification().extras.putBoolean( 546 Notification.EXTRA_SHOW_WHEN, true); 547 groupSummaryNotification.getNotification().when = greatestTimestamp; 548 } 549 }); 550 if (DEBUG) { 551 Log.d(TAG, "Grouped notifications: " + validGroupList); 552 } 553 554 return validGroupList; 555 } 556 557 /** 558 * Add new NotificationGroup to an existing list of NotificationGroups. The group will be 559 * placed above next highest ranked notification without changing the ordering of the full list. 560 * 561 * @param newNotification the {@link AlertEntry} that should be added to the list. 562 * @return list of grouped notifications as {@link NotificationGroup}s. 563 */ 564 @VisibleForTesting additionalGroupAndRank(AlertEntry newNotification, RankingMap newRankingMap, boolean isUpdate)565 protected List<NotificationGroup> additionalGroupAndRank(AlertEntry newNotification, 566 RankingMap newRankingMap, boolean isUpdate) { 567 Notification notification = newNotification.getNotification(); 568 boolean isProgress = NotificationUtils.isProgress(notification); 569 NotificationGroup newGroup = new NotificationGroup(); 570 571 // The newGroup should appear in the recent section so mark the group as not seen. Since the 572 // panel is open, mark the notification as seen in the data manager so when panel is closed 573 // and reopened, it is set as seen. 574 newGroup.setSeen(false); 575 mNotificationDataManager.setNotificationAsSeen(newNotification); 576 577 if (notification.isGroupSummary()) { 578 // If child notifications already exist, update group summary 579 for (NotificationGroup oldGroup : mOldProcessedNotifications) { 580 if (hasSameGroupKey(oldGroup.getSingleNotification(), newNotification)) { 581 oldGroup.setGroupSummaryNotification(newNotification); 582 return mOldProcessedNotifications; 583 } 584 } 585 newGroup.setGroupSummaryNotification(newNotification); 586 if ((notification.flags & FLAG_AUTOGROUP_SUMMARY) == 0) { 587 // If child notifications do not exist 588 // and isn't an autogenerated group summary 589 // insert the summary as a new notification 590 insertRankedNotification(newGroup, newRankingMap); 591 } 592 return mOldProcessedNotifications; 593 } 594 595 // To keep track of indexes of unseen Notifications with the same group key 596 Set<Integer> indexOfUnseenGroupsWithSameGroupKey = new HashSet<>(); 597 Set<NotificationGroup> emptySeenGroupsToBeRemoved = new HashSet<>(); 598 599 // Check if notification with same group key exists. The notification could be: 600 // 1. present in a seen group and is an update: 601 // remove the notification from the seen group. 602 // next step will add this notification to the newGroup which is unseen. 603 // Also remove the seen group if there are no more children 604 // 2. present in an unseen group with no children (i.e. group summary). 605 // 3. present in an unseen group. 606 for (int i = 0; i < mOldProcessedNotifications.size(); i++) { 607 NotificationGroup oldGroup = mOldProcessedNotifications.get(i); 608 AlertEntry oldNotification = null; 609 610 boolean isGroupKeySame = TextUtils.equals(oldGroup.getGroupKey(), 611 newNotification.getStatusBarNotification().getGroupKey()); 612 613 if (isUpdate) { 614 // If this is an update, existing notification in group must have the same key 615 oldNotification = 616 oldGroup.getChildNotification(newNotification.getKey()); 617 if (oldNotification == null) { 618 continue; 619 } 620 } else { 621 // If not an update, group key must be the same 622 if (!isGroupKeySame) { 623 continue; 624 } 625 } 626 627 // If updating a progress notification with another progress notification, then update 628 // while maintaining order 629 if (isUpdate && isProgress 630 && NotificationUtils.isProgress(oldNotification.getNotification()) 631 && oldGroup.updateNotification(oldNotification, newNotification)) { 632 mOldProcessedNotifications.set(i, oldGroup); 633 return mOldProcessedNotifications; 634 } 635 636 // If updating: 637 // 1) progress notification with non-progress notification 638 // 2) non-progress notification with non-progress notification 639 // 2) non-progress notification with progress notification 640 if (isUpdate && oldGroup.removeNotification(newNotification)) { 641 if (mShowRecentsAndOlderHeaders && oldGroup.isSeen()) { 642 // and old group is seen, then remove old notification from group to make 643 // space for new notification group that is unseen. 644 mOldProcessedNotifications.set(i, oldGroup); 645 if (oldGroup.getChildCount() == 0) { 646 emptySeenGroupsToBeRemoved.add(oldGroup); 647 } 648 } else { 649 // If seen/unseen isn't enabled, just add new notification to the old group. 650 oldGroup.addNotification(newNotification); 651 mOldProcessedNotifications.set(i, oldGroup); 652 return mOldProcessedNotifications; 653 } 654 continue; 655 } 656 657 indexOfUnseenGroupsWithSameGroupKey.add(i); 658 659 // If a group already exist with no children 660 if (oldGroup.getChildCount() == 0) { 661 // A group with no children is a standalone group summary 662 NotificationGroup group = oldGroup; 663 if (isUpdate) { 664 // Replace the standalone group summary 665 group = newGroup; 666 } 667 group.addNotification(newNotification); 668 mOldProcessedNotifications.set(i, group); 669 return mOldProcessedNotifications; 670 } 671 672 // Group with same group key exist with multiple children 673 // Add the new notification to the existing group if it's notification 674 // count is greater than the minimum threshold. 675 if (oldGroup.getChildCount() >= mMinimumGroupingThreshold) { 676 oldGroup.addNotification(newNotification); 677 mOldProcessedNotifications.set(i, oldGroup); 678 return mOldProcessedNotifications; 679 } 680 } 681 682 mOldProcessedNotifications.removeAll(emptySeenGroupsToBeRemoved); 683 684 // Not an update to an existing group and no groups with same group key and 685 // child count > minimum grouping threshold or child count == 0 exist in the list. 686 AlertEntry groupSummaryNotification = findGroupSummaryNotification( 687 newNotification.getStatusBarNotification().getGroupKey()); 688 // If the number of unseen notifications (+1 to account for new notification being 689 // added) with same group key is greater than the minimum grouping threshold 690 if (((indexOfUnseenGroupsWithSameGroupKey.size() + 1) >= mMinimumGroupingThreshold) 691 && groupSummaryNotification != null) { 692 // Remove all individual groups and add all notifications with the same group key 693 // to the new group 694 List<NotificationGroup> otherProcessedNotifications = new ArrayList<>(); 695 for (int i = 0; i < mOldProcessedNotifications.size(); i++) { 696 NotificationGroup notificationGroup = mOldProcessedNotifications.get(i); 697 if (indexOfUnseenGroupsWithSameGroupKey.contains(i)) { 698 // Group has the same group key 699 for (AlertEntry alertEntry : notificationGroup.getChildNotifications()) { 700 newGroup.addNotification(alertEntry); 701 } 702 } else { 703 otherProcessedNotifications.add(notificationGroup); 704 } 705 } 706 mOldProcessedNotifications = otherProcessedNotifications; 707 mNotificationDataManager.setNotificationAsSeen(groupSummaryNotification); 708 newGroup.setGroupSummaryNotification(groupSummaryNotification); 709 } 710 711 // notification should be added to the new unseen group 712 newGroup.addNotification(newNotification); 713 insertRankedNotification(newGroup, newRankingMap); 714 return mOldProcessedNotifications; 715 } 716 717 /** 718 * Finds Group Summary Notification with the same group key from {@code mOldNotifications}. 719 */ 720 @Nullable findGroupSummaryNotification(String groupKey)721 private AlertEntry findGroupSummaryNotification(String groupKey) { 722 for (AlertEntry alertEntry : mOldNotifications.values()) { 723 if (alertEntry.getNotification().isGroupSummary() && TextUtils.equals( 724 alertEntry.getStatusBarNotification().getGroupKey(), groupKey)) { 725 return alertEntry; 726 } 727 } 728 return null; 729 } 730 731 // When adding a new notification we want to add it before the next highest ranked without 732 // changing existing order insertRankedNotification(NotificationGroup group, RankingMap newRankingMap)733 private void insertRankedNotification(NotificationGroup group, RankingMap newRankingMap) { 734 NotificationListenerService.Ranking newRanking = new NotificationListenerService.Ranking(); 735 newRankingMap.getRanking(group.getNotificationForSorting().getKey(), newRanking); 736 737 for (int i = 0; i < mOldProcessedNotifications.size(); i++) { 738 NotificationListenerService.Ranking ranking = new NotificationListenerService.Ranking(); 739 newRankingMap.getRanking(mOldProcessedNotifications.get( 740 i).getNotificationForSorting().getKey(), ranking); 741 if (mShowRecentsAndOlderHeaders && group.isSeen() 742 && !mOldProcessedNotifications.get(i).isSeen()) { 743 mOldProcessedNotifications.add(i, group); 744 return; 745 } 746 747 if (newRanking.getRank() < ranking.getRank()) { 748 mOldProcessedNotifications.add(i, group); 749 return; 750 } 751 } 752 753 // If it's not higher ranked than any existing notifications then just add at end 754 mOldProcessedNotifications.add(group); 755 } 756 hasSameGroupKey(AlertEntry notification1, AlertEntry notification2)757 private boolean hasSameGroupKey(AlertEntry notification1, AlertEntry notification2) { 758 return TextUtils.equals(notification1.getStatusBarNotification().getGroupKey(), 759 notification2.getStatusBarNotification().getGroupKey()); 760 } 761 762 /** 763 * Rank notifications according to the ranking key supplied by the notification. 764 */ 765 @VisibleForTesting rank(List<NotificationGroup> notifications, RankingMap rankingMap)766 protected List<NotificationGroup> rank(List<NotificationGroup> notifications, 767 RankingMap rankingMap) { 768 769 Collections.sort(notifications, new NotificationComparator(rankingMap)); 770 771 // Rank within each group 772 notifications.forEach(notificationGroup -> { 773 if (notificationGroup.isGroup()) { 774 Collections.sort( 775 notificationGroup.getChildNotifications(), 776 new InGroupComparator(rankingMap)); 777 } 778 }); 779 return notifications; 780 } 781 782 @VisibleForTesting getOldNotifications()783 protected Map<String, AlertEntry> getOldNotifications() { 784 return mOldNotifications; 785 } 786 setCarUxRestrictionManagerWrapper(CarUxRestrictionManagerWrapper manager)787 public void setCarUxRestrictionManagerWrapper(CarUxRestrictionManagerWrapper manager) { 788 try { 789 if (manager == null || manager.getCurrentCarUxRestrictions() == null) { 790 return; 791 } 792 mMaxStringLength = 793 manager.getCurrentCarUxRestrictions().getMaxRestrictedStringLength(); 794 } catch (RuntimeException e) { 795 mMaxStringLength = Integer.MAX_VALUE; 796 Log.e(TAG, "Failed to get UxRestrictions thus running unrestricted", e); 797 } 798 } 799 800 @VisibleForTesting getOldProcessedNotifications()801 List<NotificationGroup> getOldProcessedNotifications() { 802 return mOldProcessedNotifications; 803 } 804 805 /** 806 * Comparator that sorts within the notification group by the sort key. If a sort key is not 807 * supplied, sort by the global ranking order. 808 */ 809 private static class InGroupComparator implements Comparator<AlertEntry> { 810 private final RankingMap mRankingMap; 811 InGroupComparator(RankingMap rankingMap)812 InGroupComparator(RankingMap rankingMap) { 813 mRankingMap = rankingMap; 814 } 815 816 @Override compare(AlertEntry left, AlertEntry right)817 public int compare(AlertEntry left, AlertEntry right) { 818 if (left.getNotification().getSortKey() != null 819 && right.getNotification().getSortKey() != null) { 820 return left.getNotification().getSortKey().compareTo( 821 right.getNotification().getSortKey()); 822 } 823 824 NotificationListenerService.Ranking leftRanking = 825 new NotificationListenerService.Ranking(); 826 mRankingMap.getRanking(left.getKey(), leftRanking); 827 828 NotificationListenerService.Ranking rightRanking = 829 new NotificationListenerService.Ranking(); 830 mRankingMap.getRanking(right.getKey(), rightRanking); 831 832 return leftRanking.getRank() - rightRanking.getRank(); 833 } 834 } 835 836 /** 837 * Comparator that sorts the notification groups by their representative notification's rank. 838 */ 839 private class NotificationComparator implements Comparator<NotificationGroup> { 840 private final RankingMap mRankingMap; 841 NotificationComparator(RankingMap rankingMap)842 NotificationComparator(RankingMap rankingMap) { 843 mRankingMap = rankingMap; 844 } 845 846 @Override compare(NotificationGroup left, NotificationGroup right)847 public int compare(NotificationGroup left, NotificationGroup right) { 848 if (mShowRecentsAndOlderHeaders) { 849 if (left.isSeen() && !right.isSeen()) { 850 return -1; 851 } else if (!left.isSeen() && right.isSeen()) { 852 return 1; 853 } 854 } 855 856 NotificationListenerService.Ranking leftRanking = 857 new NotificationListenerService.Ranking(); 858 mRankingMap.getRanking(left.getNotificationForSorting().getKey(), leftRanking); 859 860 NotificationListenerService.Ranking rightRanking = 861 new NotificationListenerService.Ranking(); 862 mRankingMap.getRanking(right.getNotificationForSorting().getKey(), rightRanking); 863 864 return leftRanking.getRank() - rightRanking.getRank(); 865 } 866 } 867 } 868