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