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.os.Bundle; 27 import android.service.notification.NotificationListenerService; 28 import android.service.notification.NotificationListenerService.RankingMap; 29 import android.telephony.TelephonyManager; 30 import android.text.TextUtils; 31 import android.util.Log; 32 33 import com.android.car.notification.template.MessageNotificationViewHolder; 34 import com.android.internal.annotations.VisibleForTesting; 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.List; 41 import java.util.Map; 42 import java.util.SortedMap; 43 import java.util.TreeMap; 44 import java.util.UUID; 45 46 /** 47 * Manager that filters, groups and ranks the notifications in the notification center. 48 * 49 * <p> Note that heads-up notifications have a different filtering mechanism and is managed by 50 * {@link CarHeadsUpNotificationManager}. 51 */ 52 public class PreprocessingManager { 53 54 /** Listener that will be notified when a call state changes. **/ 55 public interface CallStateListener { 56 /** 57 * @param isInCall is true when user is currently in a call. 58 */ onCallStateChanged(boolean isInCall)59 void onCallStateChanged(boolean isInCall); 60 } 61 62 private static final String TAG = "PreprocessingManager"; 63 64 private final String mEllipsizedString; 65 private final Context mContext; 66 67 private static PreprocessingManager sInstance; 68 69 private int mMaxStringLength = Integer.MAX_VALUE; 70 private Map<String, AlertEntry> mOldNotifications; 71 private List<NotificationGroup> mOldProcessedNotifications; 72 private NotificationListenerService.RankingMap mOldRankingMap; 73 private Map<String, Integer> mRanking = new HashMap<>(); 74 75 private boolean mIsInCall; 76 private List<CallStateListener> mCallStateListeners = new ArrayList<>(); 77 78 @VisibleForTesting 79 final BroadcastReceiver mIntentReceiver = new BroadcastReceiver() { 80 @Override 81 public void onReceive(Context context, Intent intent) { 82 String action = intent.getAction(); 83 if (action.equals(TelephonyManager.ACTION_PHONE_STATE_CHANGED)) { 84 mIsInCall = TelephonyManager.EXTRA_STATE_OFFHOOK 85 .equals(intent.getStringExtra(TelephonyManager.EXTRA_STATE)); 86 for (CallStateListener listener : mCallStateListeners) { 87 listener.onCallStateChanged(mIsInCall); 88 } 89 } 90 } 91 }; 92 PreprocessingManager(Context context)93 private PreprocessingManager(Context context) { 94 mEllipsizedString = context.getString(R.string.ellipsized_string); 95 mContext = context; 96 97 IntentFilter filter = new IntentFilter(); 98 filter.addAction(TelephonyManager.ACTION_PHONE_STATE_CHANGED); 99 context.registerReceiver(mIntentReceiver, filter); 100 } 101 getInstance(Context context)102 public static PreprocessingManager getInstance(Context context) { 103 if (sInstance == null) { 104 sInstance = new PreprocessingManager(context); 105 } 106 return sInstance; 107 } 108 109 /** 110 * Initialize the data when the UI becomes foreground. 111 */ init(Map<String, AlertEntry> notifications, RankingMap rankingMap)112 public void init(Map<String, AlertEntry> notifications, RankingMap rankingMap) { 113 mOldNotifications = notifications; 114 mOldRankingMap = rankingMap; 115 mOldProcessedNotifications = 116 process(/* showLessImportantNotifications = */ false, notifications, rankingMap); 117 } 118 119 /** 120 * Process the given notifications. In order for DiffUtil to work, the adapter needs a new 121 * data object each time it updates, therefore wrapping the return value in a new list. 122 * 123 * @param showLessImportantNotifications whether less important notifications should be shown. 124 * @param notifications the list of notifications to be processed. 125 * @param rankingMap the ranking map for the notifications. 126 * @return the processed notifications in a new list. 127 */ process( boolean showLessImportantNotifications, Map<String, AlertEntry> notifications, RankingMap rankingMap)128 public List<NotificationGroup> process( 129 boolean showLessImportantNotifications, 130 Map<String, AlertEntry> notifications, 131 RankingMap rankingMap) { 132 133 return new ArrayList<>( 134 rank(group(optimizeForDriving( 135 filter(showLessImportantNotifications, 136 new ArrayList<>(notifications.values()), 137 rankingMap))), 138 rankingMap)); 139 } 140 141 /** 142 * Create a new list of notifications based on existing list. 143 * 144 * @param showLessImportantNotifications whether less important notifications should be shown. 145 * @param newRankingMap the latest ranking map for the notifications. 146 * @return the new notification group list that should be shown to the user. 147 */ updateNotifications( boolean showLessImportantNotifications, AlertEntry alertEntry, int updateType, RankingMap newRankingMap)148 public List<NotificationGroup> updateNotifications( 149 boolean showLessImportantNotifications, 150 AlertEntry alertEntry, 151 int updateType, 152 RankingMap newRankingMap) { 153 154 if (updateType == CarNotificationListener.NOTIFY_NOTIFICATION_REMOVED) { 155 // removal of a notification is the same as a normal preprocessing 156 mOldNotifications.remove(alertEntry.getKey()); 157 mOldProcessedNotifications = 158 process(showLessImportantNotifications, mOldNotifications, mOldRankingMap); 159 } 160 161 if (updateType == CarNotificationListener.NOTIFY_NOTIFICATION_POSTED) { 162 AlertEntry notification = optimizeForDriving(alertEntry); 163 boolean isUpdate = mOldNotifications.containsKey(notification.getKey()); 164 if (isUpdate) { 165 // if is an update of the previous notification 166 mOldNotifications.put(notification.getKey(), notification); 167 mOldProcessedNotifications = process(showLessImportantNotifications, 168 mOldNotifications, mOldRankingMap); 169 } else { 170 // insert a new notification into the list 171 mOldNotifications.put(notification.getKey(), notification); 172 mOldProcessedNotifications = new ArrayList<>( 173 additionalRank(additionalGroup(alertEntry), newRankingMap)); 174 } 175 } 176 177 return mOldProcessedNotifications; 178 } 179 180 /** Add {@link CallStateListener} in order to be notified when call state is changed. **/ addCallStateListener(CallStateListener listener)181 public void addCallStateListener(CallStateListener listener) { 182 if (mCallStateListeners.contains(listener)) return; 183 mCallStateListeners.add(listener); 184 listener.onCallStateChanged(mIsInCall); 185 } 186 187 /** Remove {@link CallStateListener} to stop getting notified when call state is changed. **/ removeCallStateListener(CallStateListener listener)188 public void removeCallStateListener(CallStateListener listener) { 189 mCallStateListeners.remove(listener); 190 } 191 192 /** 193 * Returns true if the current {@link AlertEntry} should be filtered out and not 194 * added to the list. 195 */ shouldFilter(AlertEntry alertEntry, RankingMap rankingMap)196 boolean shouldFilter(AlertEntry alertEntry, RankingMap rankingMap) { 197 return isLessImportantForegroundNotification(alertEntry, rankingMap) 198 || isMediaOrNavigationNotification(alertEntry); 199 } 200 201 /** 202 * Filter a list of {@link AlertEntry}s according to OEM's configurations. 203 */ 204 @VisibleForTesting filter( boolean showLessImportantNotifications, List<AlertEntry> notifications, RankingMap rankingMap)205 protected List<AlertEntry> filter( 206 boolean showLessImportantNotifications, 207 List<AlertEntry> notifications, 208 RankingMap rankingMap) { 209 // remove notifications that should be filtered. 210 if (!showLessImportantNotifications) { 211 notifications.removeIf(alertEntry -> shouldFilter(alertEntry, rankingMap)); 212 } 213 214 // Call notifications should not be shown in the panel. 215 // Since they're shown as persistent HUNs, and notifications are not added to the panel 216 // until after they're dismissed as HUNs, it does not make sense to have them in the panel, 217 // and sequencing could cause them to be removed before being added here. 218 notifications.removeIf(alertEntry -> Notification.CATEGORY_CALL.equals( 219 alertEntry.getNotification().category)); 220 221 return notifications; 222 } 223 isLessImportantForegroundNotification(AlertEntry alertEntry, RankingMap rankingMap)224 private boolean isLessImportantForegroundNotification(AlertEntry alertEntry, 225 RankingMap rankingMap) { 226 boolean isForeground = 227 (alertEntry.getNotification().flags 228 & Notification.FLAG_FOREGROUND_SERVICE) != 0; 229 230 if (!isForeground) { 231 return false; 232 } 233 234 int importance = 0; 235 NotificationListenerService.Ranking ranking = 236 new NotificationListenerService.Ranking(); 237 if (rankingMap.getRanking(alertEntry.getKey(), ranking)) { 238 importance = ranking.getImportance(); 239 } 240 241 return importance < NotificationManager.IMPORTANCE_DEFAULT 242 && NotificationUtils.isSystemPrivilegedOrPlatformKey(mContext, alertEntry); 243 } 244 isMediaOrNavigationNotification(AlertEntry alertEntry)245 private boolean isMediaOrNavigationNotification(AlertEntry alertEntry) { 246 Notification notification = alertEntry.getNotification(); 247 return notification.isMediaNotification() 248 || Notification.CATEGORY_NAVIGATION.equals(notification.category); 249 } 250 251 /** 252 * Process a list of {@link AlertEntry}s to be driving optimized. 253 * 254 * <p> Note that the string length limit is always respected regardless of whether distraction 255 * optimization is required. 256 */ optimizeForDriving(List<AlertEntry> notifications)257 private List<AlertEntry> optimizeForDriving(List<AlertEntry> notifications) { 258 notifications.forEach(notification -> notification = optimizeForDriving(notification)); 259 return notifications; 260 } 261 262 /** 263 * Helper method that optimize a single {@link AlertEntry} for driving. 264 * 265 * <p> Currently only trimming texts that have visual effects in car. Operation is done on 266 * the original notification object passed in; no new object is created. 267 * 268 * <p> Note that message notifications are not trimmed, so that messages are preserved for 269 * assistant read-out. Instead, {@link MessageNotificationViewHolder} will be responsible 270 * for the presentation-level text truncation. 271 */ optimizeForDriving(AlertEntry alertEntry)272 AlertEntry optimizeForDriving(AlertEntry alertEntry) { 273 if (Notification.CATEGORY_MESSAGE.equals(alertEntry.getNotification().category)){ 274 return alertEntry; 275 } 276 277 Bundle extras = alertEntry.getNotification().extras; 278 for (String key : extras.keySet()) { 279 switch (key) { 280 case Notification.EXTRA_TITLE: 281 case Notification.EXTRA_TEXT: 282 case Notification.EXTRA_TITLE_BIG: 283 case Notification.EXTRA_SUMMARY_TEXT: 284 CharSequence value = extras.getCharSequence(key); 285 extras.putCharSequence(key, trimText(value)); 286 default: 287 continue; 288 } 289 } 290 return alertEntry; 291 } 292 293 /** 294 * Helper method that takes a string and trims the length to the maximum character allowed 295 * by the {@link CarUxRestrictionsManager}. 296 */ 297 @Nullable trimText(@ullable CharSequence text)298 public CharSequence trimText(@Nullable CharSequence text) { 299 if (TextUtils.isEmpty(text) || text.length() < mMaxStringLength) { 300 return text; 301 } 302 int maxLength = mMaxStringLength - mEllipsizedString.length(); 303 return text.toString().substring(0, maxLength).concat(mEllipsizedString); 304 } 305 306 /** 307 * Group notifications that have the same group key. 308 * 309 * <p> Automatically generated group summaries that contains no child notifications are removed. 310 * This can happen if a notification group only contains less important notifications that are 311 * filtered out in the previous {@link #filter} step. 312 * 313 * <p> A group of child notifications without a summary notification will not be grouped. 314 * 315 * @param list list of ungrouped {@link AlertEntry}s. 316 * @return list of grouped notifications as {@link NotificationGroup}s. 317 */ 318 @VisibleForTesting group(List<AlertEntry> list)319 List<NotificationGroup> group(List<AlertEntry> list) { 320 SortedMap<String, NotificationGroup> groupedNotifications = new TreeMap<>(); 321 322 // First pass: group all notifications according to their groupKey. 323 for (int i = 0; i < list.size(); i++) { 324 AlertEntry alertEntry = list.get(i); 325 Notification notification = alertEntry.getNotification(); 326 327 String groupKey; 328 if (Notification.CATEGORY_CALL.equals(notification.category)) { 329 // DO NOT group CATEGORY_CALL. 330 groupKey = UUID.randomUUID().toString(); 331 } else { 332 groupKey = alertEntry.getStatusBarNotification().getGroupKey(); 333 } 334 335 if (!groupedNotifications.containsKey(groupKey)) { 336 NotificationGroup notificationGroup = new NotificationGroup(); 337 groupedNotifications.put(groupKey, notificationGroup); 338 } 339 if (notification.isGroupSummary()) { 340 groupedNotifications.get(groupKey) 341 .setGroupSummaryNotification(alertEntry); 342 } else { 343 groupedNotifications.get(groupKey).addNotification(alertEntry); 344 } 345 } 346 347 // Second pass: remove automatically generated group summary if it contains no child 348 // notifications. This can happen if a notification group only contains less important 349 // notifications that are filtered out in the previous filter step. 350 List<NotificationGroup> groupList = new ArrayList<>(groupedNotifications.values()); 351 groupList.removeIf( 352 notificationGroup -> { 353 AlertEntry summaryNotification = 354 notificationGroup.getGroupSummaryNotification(); 355 return notificationGroup.getChildCount() == 0 356 && summaryNotification != null 357 && summaryNotification.getStatusBarNotification().getOverrideGroupKey() 358 != null; 359 }); 360 361 // Third pass: a notification group without a group summary should be restored back into 362 // individual notifications. 363 List<NotificationGroup> validGroupList = new ArrayList<>(); 364 groupList.forEach( 365 group -> { 366 if (group.getChildCount() > 1 && group.getGroupSummaryNotification() == null) { 367 group.getChildNotifications().forEach( 368 notification -> { 369 NotificationGroup newGroup = new NotificationGroup(); 370 newGroup.addNotification(notification); 371 validGroupList.add(newGroup); 372 }); 373 } else { 374 validGroupList.add(group); 375 } 376 }); 377 378 // Fourth Pass: group notifications with no child notifications should be removed. 379 validGroupList.removeIf(notificationGroup -> 380 notificationGroup.getChildNotifications().isEmpty()); 381 382 // Fifth pass: if a notification is a group notification, update the timestamp if one of 383 // the children notifications shows a timestamp. 384 validGroupList.forEach(group -> { 385 if (!group.isGroup()) { 386 return; 387 } 388 389 AlertEntry groupSummaryNotification = group.getGroupSummaryNotification(); 390 boolean showWhen = false; 391 long greatestTimestamp = 0; 392 for (AlertEntry notification : group.getChildNotifications()) { 393 if (notification.getNotification().showsTime()) { 394 showWhen = true; 395 greatestTimestamp = Math.max(greatestTimestamp, 396 notification.getNotification().when); 397 } 398 } 399 400 if (showWhen) { 401 groupSummaryNotification.getNotification().extras.putBoolean( 402 Notification.EXTRA_SHOW_WHEN, true); 403 groupSummaryNotification.getNotification().when = greatestTimestamp; 404 } 405 }); 406 407 return validGroupList; 408 } 409 410 /** 411 * Add new NotificationGroup to an existing list of NotificationGroups. 412 * 413 * @param newNotification the {@link AlertEntry} that should be added to the list. 414 * @return list of grouped notifications as {@link NotificationGroup}s. 415 */ 416 @VisibleForTesting additionalGroup(AlertEntry newNotification)417 protected List<NotificationGroup> additionalGroup(AlertEntry newNotification) { 418 Notification notification = newNotification.getNotification(); 419 420 if (notification.isGroupSummary()) { 421 // if child notifications already exist, ignore this insertion 422 for (String key : mOldNotifications.keySet()) { 423 if (hasSameGroupKey(mOldNotifications.get(key), newNotification)) { 424 return mOldProcessedNotifications; 425 } 426 } 427 // if child notifications do not exist, insert the summary as a new notification 428 NotificationGroup newGroup = new NotificationGroup(); 429 newGroup.setGroupSummaryNotification(newNotification); 430 mOldProcessedNotifications.add(newGroup); 431 return mOldProcessedNotifications; 432 } else { 433 for (int i = 0; i < mOldProcessedNotifications.size(); i++) { 434 NotificationGroup oldGroup = mOldProcessedNotifications.get(i); 435 // if a group already exists 436 if (TextUtils.equals(oldGroup.getGroupKey(), 437 newNotification.getStatusBarNotification().getGroupKey())) { 438 // if a standalone group summary exists, replace the group summary notification 439 if (oldGroup.getChildCount() == 0) { 440 mOldProcessedNotifications.add(i, new NotificationGroup(newNotification)); 441 return mOldProcessedNotifications; 442 } 443 // if a group already exist with multiple children, insert outside of the group 444 mOldProcessedNotifications.add(new NotificationGroup(newNotification)); 445 return mOldProcessedNotifications; 446 } 447 } 448 // if it is a new notification, insert directly 449 mOldProcessedNotifications.add(new NotificationGroup(newNotification)); 450 return mOldProcessedNotifications; 451 } 452 } 453 hasSameGroupKey(AlertEntry notification1, AlertEntry notification2)454 private boolean hasSameGroupKey(AlertEntry notification1, AlertEntry notification2) { 455 return TextUtils.equals(notification1.getStatusBarNotification().getGroupKey(), 456 notification2.getStatusBarNotification().getGroupKey()); 457 } 458 459 /** 460 * Rank notifications according to the ranking key supplied by the notification. 461 */ 462 @VisibleForTesting rank(List<NotificationGroup> notifications, RankingMap rankingMap)463 protected List<NotificationGroup> rank(List<NotificationGroup> notifications, 464 RankingMap rankingMap) { 465 466 Collections.sort(notifications, new NotificationComparator(rankingMap)); 467 468 // Rank within each group 469 notifications.forEach(notificationGroup -> { 470 if (notificationGroup.isGroup()) { 471 Collections.sort( 472 notificationGroup.getChildNotifications(), 473 new InGroupComparator(rankingMap)); 474 } 475 }); 476 return notifications; 477 } 478 479 /** 480 * Only rank top-level notification groups because no children should be inserted into a group. 481 */ additionalRank( List<NotificationGroup> notifications, RankingMap newRankingMap)482 public List<NotificationGroup> additionalRank( 483 List<NotificationGroup> notifications, RankingMap newRankingMap) { 484 485 Collections.sort( 486 notifications, new AdditionalNotificationComparator(newRankingMap)); 487 488 return notifications; 489 } 490 491 @VisibleForTesting getOldNotifications()492 protected Map getOldNotifications() { 493 return mOldNotifications; 494 } 495 setCarUxRestrictionManagerWrapper(CarUxRestrictionManagerWrapper manager)496 public void setCarUxRestrictionManagerWrapper(CarUxRestrictionManagerWrapper manager) { 497 try { 498 if (manager == null || manager.getCurrentCarUxRestrictions() == null) { 499 return; 500 } 501 mMaxStringLength = 502 manager.getCurrentCarUxRestrictions().getMaxRestrictedStringLength(); 503 } catch (RuntimeException e) { 504 mMaxStringLength = Integer.MAX_VALUE; 505 Log.e(TAG, "Failed to get UxRestrictions thus running unrestricted", e); 506 } 507 } 508 509 /** 510 * Comparator that sorts within the notification group by the sort key. If a sort key is not 511 * supplied, sort by the global ranking order. 512 */ 513 private static class InGroupComparator implements Comparator<AlertEntry> { 514 private final RankingMap mRankingMap; 515 InGroupComparator(RankingMap rankingMap)516 InGroupComparator(RankingMap rankingMap) { 517 mRankingMap = rankingMap; 518 } 519 520 @Override compare(AlertEntry left, AlertEntry right)521 public int compare(AlertEntry left, AlertEntry right) { 522 if (left.getNotification().getSortKey() != null 523 && right.getNotification().getSortKey() != null) { 524 return left.getNotification().getSortKey().compareTo( 525 right.getNotification().getSortKey()); 526 } 527 528 NotificationListenerService.Ranking leftRanking = 529 new NotificationListenerService.Ranking(); 530 mRankingMap.getRanking(left.getKey(), leftRanking); 531 532 NotificationListenerService.Ranking rightRanking = 533 new NotificationListenerService.Ranking(); 534 mRankingMap.getRanking(right.getKey(), rightRanking); 535 536 return leftRanking.getRank() - rightRanking.getRank(); 537 } 538 } 539 540 /** 541 * Comparator that sorts the notification groups by their representative notification's rank. 542 */ 543 private class NotificationComparator implements Comparator<NotificationGroup> { 544 private final NotificationListenerService.RankingMap mRankingMap; 545 NotificationComparator(NotificationListenerService.RankingMap rankingMap)546 NotificationComparator(NotificationListenerService.RankingMap rankingMap) { 547 mRankingMap = rankingMap; 548 } 549 550 @Override compare(NotificationGroup left, NotificationGroup right)551 public int compare(NotificationGroup left, NotificationGroup right) { 552 NotificationListenerService.Ranking leftRanking = 553 new NotificationListenerService.Ranking(); 554 mRankingMap.getRanking(left.getNotificationForSorting().getKey(), leftRanking); 555 556 NotificationListenerService.Ranking rightRanking = 557 new NotificationListenerService.Ranking(); 558 mRankingMap.getRanking(right.getNotificationForSorting().getKey(), rightRanking); 559 560 return leftRanking.getRank() - rightRanking.getRank(); 561 } 562 } 563 564 /** 565 * Comparator that sorts the notification groups by their representative notification's 566 * rank using both of the initial ranking map and the current ranking map. 567 * 568 * <p>Cache the ranking value so that it doesn't change over time.</p> 569 */ 570 private class AdditionalNotificationComparator implements Comparator<NotificationGroup> { 571 private final RankingMap mNewRankingMap; 572 AdditionalNotificationComparator(RankingMap newRankingMap)573 AdditionalNotificationComparator(RankingMap newRankingMap) { 574 mNewRankingMap = newRankingMap; 575 } 576 577 @Override compare(NotificationGroup left, NotificationGroup right)578 public int compare(NotificationGroup left, NotificationGroup right) { 579 int leftRankingNumber = getRanking(left, mNewRankingMap); 580 int rightRankingNumber = getRanking(right, mNewRankingMap); 581 return leftRankingNumber - rightRankingNumber; 582 } 583 } 584 getRanking(NotificationGroup group, RankingMap newRankingMap)585 private int getRanking(NotificationGroup group, RankingMap newRankingMap) { 586 int rankingNumber; 587 588 if (mRanking.containsKey(group.getGroupKey())) { 589 rankingNumber = mRanking.get(group.getGroupKey()); 590 } else { 591 NotificationListenerService.Ranking rightRanking = 592 new NotificationListenerService.Ranking(); 593 if (!mOldRankingMap.getRanking( 594 group.getNotificationForSorting().getKey(), rightRanking)) { 595 if (newRankingMap != null) { 596 newRankingMap.getRanking( 597 group.getNotificationForSorting().getKey(), rightRanking); 598 } 599 } 600 rankingNumber = rightRanking.getRank(); 601 } 602 mRanking.putIfAbsent(group.getGroupKey(), rankingNumber); 603 return rankingNumber; 604 } 605 } 606