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