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