• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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         if (DEBUG) {
237             Log.d(TAG, "Filtered notifications: " + notifications);
238         }
239 
240         return notifications;
241     }
242 
isLessImportantForegroundNotification(AlertEntry alertEntry, RankingMap rankingMap)243     private boolean isLessImportantForegroundNotification(AlertEntry alertEntry,
244             RankingMap rankingMap) {
245         boolean isForeground =
246                 (alertEntry.getNotification().flags
247                         & Notification.FLAG_FOREGROUND_SERVICE) != 0;
248 
249         if (!isForeground) {
250             Log.d(TAG, alertEntry + " is not a foreground notification.");
251             return false;
252         }
253 
254         int importance = 0;
255         NotificationListenerService.Ranking ranking =
256                 new NotificationListenerService.Ranking();
257         if (rankingMap.getRanking(alertEntry.getKey(), ranking)) {
258             importance = ranking.getImportance();
259         }
260 
261         if (DEBUG) {
262             if (importance < NotificationManager.IMPORTANCE_DEFAULT) {
263                 Log.d(TAG, alertEntry + " importance is insufficient to show in notification "
264                         + "center");
265             } else {
266                 Log.d(TAG, alertEntry + " importance is sufficient to show in notification "
267                         + "center");
268             }
269 
270             if (NotificationUtils.isSystemPrivilegedOrPlatformKey(mContext, alertEntry)) {
271                 Log.d(TAG, alertEntry + " application is system privileged or signed with "
272                         + "platform key");
273             } else {
274                 Log.d(TAG, alertEntry + " application is neither system privileged nor signed "
275                         + "with platform key");
276             }
277         }
278 
279         return importance < NotificationManager.IMPORTANCE_DEFAULT
280                 && NotificationUtils.isSystemPrivilegedOrPlatformKey(mContext, alertEntry);
281     }
282 
isMediaOrNavigationNotification(AlertEntry alertEntry)283     private boolean isMediaOrNavigationNotification(AlertEntry alertEntry) {
284         Notification notification = alertEntry.getNotification();
285         boolean mediaOrNav = notification.isMediaNotification()
286                 || Notification.CATEGORY_NAVIGATION.equals(notification.category);
287         if (DEBUG) {
288             Log.d(TAG, alertEntry + " category: " + notification.category);
289         }
290         return mediaOrNav;
291     }
292 
293     /**
294      * Process a list of {@link AlertEntry}s to be driving optimized.
295      *
296      * <p> Note that the string length limit is always respected regardless of whether distraction
297      * optimization is required.
298      */
optimizeForDriving(List<AlertEntry> notifications)299     private List<AlertEntry> optimizeForDriving(List<AlertEntry> notifications) {
300         notifications.forEach(notification -> notification = optimizeForDriving(notification));
301         return notifications;
302     }
303 
304     /**
305      * Helper method that optimize a single {@link AlertEntry} for driving.
306      *
307      * <p> Currently only trimming texts that have visual effects in car. Operation is done on
308      * the original notification object passed in; no new object is created.
309      *
310      * <p> Note that message notifications are not trimmed, so that messages are preserved for
311      * assistant read-out. Instead, {@link MessageNotificationViewHolder} will be responsible
312      * for the presentation-level text truncation.
313      */
optimizeForDriving(AlertEntry alertEntry)314     AlertEntry optimizeForDriving(AlertEntry alertEntry) {
315         if (Notification.CATEGORY_MESSAGE.equals(alertEntry.getNotification().category)) {
316             return alertEntry;
317         }
318 
319         Bundle extras = alertEntry.getNotification().extras;
320         for (String key : extras.keySet()) {
321             switch (key) {
322                 case Notification.EXTRA_TITLE:
323                 case Notification.EXTRA_TEXT:
324                 case Notification.EXTRA_TITLE_BIG:
325                 case Notification.EXTRA_SUMMARY_TEXT:
326                     CharSequence value = extras.getCharSequence(key);
327                     extras.putCharSequence(key, trimText(value));
328                 default:
329                     continue;
330             }
331         }
332         return alertEntry;
333     }
334 
335     /**
336      * Helper method that takes a string and trims the length to the maximum character allowed
337      * by the {@link CarUxRestrictionsManager}.
338      */
339     @Nullable
trimText(@ullable CharSequence text)340     public CharSequence trimText(@Nullable CharSequence text) {
341         if (TextUtils.isEmpty(text) || text.length() < mMaxStringLength) {
342             return text;
343         }
344         int maxLength = mMaxStringLength - mEllipsizedSuffix.length();
345         return text.toString().substring(0, maxLength) + mEllipsizedSuffix;
346     }
347 
348     /**
349      * @return the maximum numbers of characters allowed by the {@link CarUxRestrictionsManager}
350      */
getMaximumStringLength()351     public int getMaximumStringLength() {
352         return mMaxStringLength;
353     }
354 
355     /**
356      * Group notifications that have the same group key.
357      *
358      * <p> Automatically generated group summaries that contains no child notifications are removed.
359      * This can happen if a notification group only contains less important notifications that are
360      * filtered out in the previous {@link #filter} step.
361      *
362      * <p> A group of child notifications without a summary notification will not be grouped.
363      *
364      * @param list list of ungrouped {@link AlertEntry}s.
365      * @return list of grouped notifications as {@link NotificationGroup}s.
366      */
367     @VisibleForTesting
group(List<AlertEntry> list)368     List<NotificationGroup> group(List<AlertEntry> list) {
369         SortedMap<String, NotificationGroup> groupedNotifications = new TreeMap<>();
370 
371         // First pass: group all notifications according to their groupKey.
372         for (int i = 0; i < list.size(); i++) {
373             AlertEntry alertEntry = list.get(i);
374             Notification notification = alertEntry.getNotification();
375 
376             String groupKey;
377             if (Notification.CATEGORY_CALL.equals(notification.category)) {
378                 // DO NOT group CATEGORY_CALL.
379                 groupKey = UUID.randomUUID().toString();
380             } else {
381                 groupKey = alertEntry.getStatusBarNotification().getGroupKey();
382             }
383 
384             if (!groupedNotifications.containsKey(groupKey)) {
385                 NotificationGroup notificationGroup = new NotificationGroup();
386                 groupedNotifications.put(groupKey, notificationGroup);
387             }
388             if (notification.isGroupSummary()) {
389                 groupedNotifications.get(groupKey)
390                         .setGroupSummaryNotification(alertEntry);
391             } else {
392                 groupedNotifications.get(groupKey).addNotification(alertEntry);
393             }
394         }
395         if (DEBUG) {
396             Log.d(TAG, "(First pass) Grouped notifications according to groupKey: "
397                     + groupedNotifications);
398         }
399 
400         // Second pass: remove automatically generated group summary if it contains no child
401         // notifications. This can happen if a notification group only contains less important
402         // notifications that are filtered out in the previous filter step.
403         List<NotificationGroup> groupList = new ArrayList<>(groupedNotifications.values());
404         groupList.removeIf(
405                 notificationGroup -> {
406                     AlertEntry summaryNotification =
407                             notificationGroup.getGroupSummaryNotification();
408                     return notificationGroup.getChildCount() == 0
409                             && summaryNotification != null
410                             && summaryNotification.getStatusBarNotification().getOverrideGroupKey()
411                             != null;
412                 });
413         if (DEBUG) {
414             Log.d(TAG, "(Second pass) Remove automatically generated group summaries: "
415                     + groupList);
416         }
417 
418         if (mShowRecentsAndOlderHeaders) {
419             mNotificationDataManager.updateUnseenNotificationGroups(groupList);
420         }
421 
422 
423         // Third Pass: If a notification group has seen and unseen notifications, we need to split
424         // up the group into its seen and unseen constituents.
425         List<NotificationGroup> tempGroupList = new ArrayList<>();
426         groupList.forEach(notificationGroup -> {
427             AlertEntry groupSummary = notificationGroup.getGroupSummaryNotification();
428             if (groupSummary == null || !mShowRecentsAndOlderHeaders) {
429                 boolean isNotificationSeen = mNotificationDataManager
430                         .isNotificationSeen(notificationGroup.getSingleNotification());
431                 notificationGroup.setSeen(isNotificationSeen);
432                 tempGroupList.add(notificationGroup);
433                 return;
434             }
435 
436             NotificationGroup seenNotificationGroup = new NotificationGroup();
437             seenNotificationGroup.setSeen(true);
438             seenNotificationGroup.setGroupSummaryNotification(groupSummary);
439             NotificationGroup unseenNotificationGroup = new NotificationGroup();
440             unseenNotificationGroup.setGroupSummaryNotification(groupSummary);
441             unseenNotificationGroup.setSeen(false);
442 
443             notificationGroup.getChildNotifications().forEach(alertEntry -> {
444                 if (mNotificationDataManager.isNotificationSeen(alertEntry)) {
445                     seenNotificationGroup.addNotification(alertEntry);
446                 } else {
447                     unseenNotificationGroup.addNotification(alertEntry);
448                 }
449             });
450             tempGroupList.add(unseenNotificationGroup);
451             tempGroupList.add(seenNotificationGroup);
452         });
453         groupList.clear();
454         groupList.addAll(tempGroupList);
455         if (DEBUG) {
456             Log.d(TAG, "(Third pass) Split notification groups by seen and unseen: "
457                     + groupList);
458         }
459 
460         List<NotificationGroup> validGroupList = new ArrayList<>();
461         if (mUseLauncherIcon) {
462             // Fourth pass: since we do not use group summaries when using launcher icon, we can
463             // restore groups into individual notifications that do not meet grouping threshold.
464             groupList.forEach(
465                     group -> {
466                         if (group.getChildCount() < mMinimumGroupingThreshold) {
467                             group.getChildNotifications().forEach(
468                                     notification -> {
469                                         NotificationGroup newGroup = new NotificationGroup();
470                                         newGroup.addNotification(notification);
471                                         newGroup.setSeen(group.isSeen());
472                                         validGroupList.add(newGroup);
473                                     });
474                         } else {
475                             validGroupList.add(group);
476                         }
477                     });
478         } else {
479             // Fourth pass: a notification group without a group summary or a notification group
480             // that do not meet grouping threshold should be restored back into individual
481             // notifications.
482             groupList.forEach(
483                     group -> {
484                         boolean groupWithNoGroupSummary = group.getChildCount() > 1
485                                 && group.getGroupSummaryNotification() == null;
486                         boolean groupWithGroupSummaryButNotEnoughNotifs =
487                                 group.getChildCount() < mMinimumGroupingThreshold
488                                         && group.getGroupSummaryNotification() != null;
489                         if (groupWithNoGroupSummary || groupWithGroupSummaryButNotEnoughNotifs) {
490                             group.getChildNotifications().forEach(
491                                     notification -> {
492                                         NotificationGroup newGroup = new NotificationGroup();
493                                         newGroup.addNotification(notification);
494                                         newGroup.setSeen(group.isSeen());
495                                         validGroupList.add(newGroup);
496                                     });
497                         } else {
498                             validGroupList.add(group);
499                         }
500                     });
501         }
502         if (DEBUG) {
503             if (mUseLauncherIcon) {
504                 Log.d(TAG, "(Fourth pass) Split notification groups that do not meet minimum "
505                         + "grouping threshold of " + mMinimumGroupingThreshold + " : "
506                         + validGroupList);
507             } else {
508                 Log.d(TAG, "(Fourth pass) Restore notifications without group summaries and do"
509                         + " not meet minimum grouping threshold of " + mMinimumGroupingThreshold
510                         + " : " + validGroupList);
511             }
512         }
513 
514 
515         // Fifth Pass: group notifications with no child notifications should be removed.
516         validGroupList.removeIf(notificationGroup ->
517                 notificationGroup.getChildNotifications().isEmpty());
518         if (DEBUG) {
519             Log.d(TAG, "(Fifth pass) Group notifications without child notifications "
520                     + "are removed: " + validGroupList);
521         }
522 
523         // Sixth pass: if a notification is a group notification, update the timestamp if one of
524         // the children notifications shows a timestamp.
525         validGroupList.forEach(group -> {
526             if (!group.isGroup()) {
527                 return;
528             }
529 
530             AlertEntry groupSummaryNotification = group.getGroupSummaryNotification();
531             boolean showWhen = false;
532             long greatestTimestamp = 0;
533             for (AlertEntry notification : group.getChildNotifications()) {
534                 if (notification.getNotification().showsTime()) {
535                     showWhen = true;
536                     greatestTimestamp = Math.max(greatestTimestamp,
537                             notification.getNotification().when);
538                 }
539             }
540 
541             if (showWhen) {
542                 groupSummaryNotification.getNotification().extras.putBoolean(
543                         Notification.EXTRA_SHOW_WHEN, true);
544                 groupSummaryNotification.getNotification().when = greatestTimestamp;
545             }
546         });
547         if (DEBUG) {
548             Log.d(TAG, "Grouped notifications: " + validGroupList);
549         }
550 
551         return validGroupList;
552     }
553 
554     /**
555      * Add new NotificationGroup to an existing list of NotificationGroups. The group will be
556      * placed above next highest ranked notification without changing the ordering of the full list.
557      *
558      * @param newNotification the {@link AlertEntry} that should be added to the list.
559      * @return list of grouped notifications as {@link NotificationGroup}s.
560      */
561     @VisibleForTesting
additionalGroupAndRank(AlertEntry newNotification, RankingMap newRankingMap, boolean isUpdate)562     protected List<NotificationGroup> additionalGroupAndRank(AlertEntry newNotification,
563             RankingMap newRankingMap, boolean isUpdate) {
564         Notification notification = newNotification.getNotification();
565         NotificationGroup newGroup = new NotificationGroup();
566 
567         // The newGroup should appear in the recent section and marked as seen
568         // because the panel is open.
569         newGroup.setSeen(false);
570         mNotificationDataManager.setNotificationAsSeen(newNotification);
571 
572         if (notification.isGroupSummary()) {
573             // If child notifications already exist, update group summary
574             for (NotificationGroup oldGroup : mOldProcessedNotifications) {
575                 if (hasSameGroupKey(oldGroup.getSingleNotification(), newNotification)) {
576                     oldGroup.setGroupSummaryNotification(newNotification);
577                     return mOldProcessedNotifications;
578                 }
579             }
580             // If child notifications do not exist, insert the summary as a new notification
581             newGroup.setGroupSummaryNotification(newNotification);
582             insertRankedNotification(newGroup, newRankingMap);
583             return mOldProcessedNotifications;
584         } else {
585             // To keep track of indexes of unseen Notifications with the same group key
586             Set<Integer> indexOfUnseenGroupsWithSameGroupKey = new HashSet<>();
587             for (int i = 0; i < mOldProcessedNotifications.size(); i++) {
588                 NotificationGroup oldGroup = mOldProcessedNotifications.get(i);
589 
590                 // If an unseen group already exists with the same group key
591                 if (TextUtils.equals(oldGroup.getGroupKey(),
592                         newNotification.getStatusBarNotification().getGroupKey())
593                         && (!mShowRecentsAndOlderHeaders || !oldGroup.isSeen())) {
594                     indexOfUnseenGroupsWithSameGroupKey.add(i);
595 
596                     // If a group already exist with no children
597                     if (oldGroup.getChildCount() == 0) {
598                         // A group with no children is a standalone group summary
599                         NotificationGroup group = oldGroup;
600                         if (isUpdate) {
601                             // Replace the standalone group summary
602                             group = newGroup;
603                         }
604                         group.addNotification(newNotification);
605                         mOldProcessedNotifications.set(i, group);
606                         return mOldProcessedNotifications;
607                     }
608 
609                     // Group with same group key exist with multiple children
610                     // For update, replace the old notification with the updated notification
611                     // else add the new notification to the existing group if it's notification
612                     // count is greater than the minimum threshold.
613                     if (isUpdate) {
614                         oldGroup.removeNotification(newNotification);
615                     }
616                     if (isUpdate || oldGroup.getChildCount() >= mMinimumGroupingThreshold) {
617                         oldGroup.addNotification(newNotification);
618                         mOldProcessedNotifications.set(i, oldGroup);
619                         return mOldProcessedNotifications;
620                     }
621                 }
622             }
623 
624             // Not an update to an existing group and no groups with same group key and
625             // child count > minimum grouping threshold or child count == 0 exist in the list.
626             AlertEntry groupSummaryNotification = findGroupSummaryNotification(
627                     newNotification.getStatusBarNotification().getGroupKey());
628             // If the number of unseen notifications (+1 to account for new notification being
629             // added) with same group key is greater than the minimum grouping threshold
630             if (((indexOfUnseenGroupsWithSameGroupKey.size() + 1) >= mMinimumGroupingThreshold)
631                     && groupSummaryNotification != null) {
632                 // Remove all individual groups and add all notifications with the same group key
633                 // to the new group
634                 List<NotificationGroup> otherProcessedNotifications = new ArrayList<>();
635                 for (int i = 0; i < mOldProcessedNotifications.size(); i++) {
636                     NotificationGroup notificationGroup = mOldProcessedNotifications.get(i);
637                     if (indexOfUnseenGroupsWithSameGroupKey.contains(i)) {
638                         // Group has the same group key
639                         for (AlertEntry alertEntry : notificationGroup.getChildNotifications()) {
640                             newGroup.addNotification(alertEntry);
641                         }
642                     } else {
643                         otherProcessedNotifications.add(notificationGroup);
644                     }
645                 }
646                 mOldProcessedNotifications = otherProcessedNotifications;
647                 mNotificationDataManager.setNotificationAsSeen(groupSummaryNotification);
648                 newGroup.setGroupSummaryNotification(groupSummaryNotification);
649             }
650 
651             newGroup.addNotification(newNotification);
652             insertRankedNotification(newGroup, newRankingMap);
653             return mOldProcessedNotifications;
654         }
655     }
656 
657     /**
658      * Finds Group Summary Notification with the same group key from {@code mOldNotifications}.
659      */
660     @Nullable
findGroupSummaryNotification(String groupKey)661     private AlertEntry findGroupSummaryNotification(String groupKey) {
662         for (AlertEntry alertEntry : mOldNotifications.values()) {
663             if (alertEntry.getNotification().isGroupSummary() && TextUtils.equals(
664                     alertEntry.getStatusBarNotification().getGroupKey(), groupKey)) {
665                 return alertEntry;
666             }
667         }
668         return null;
669     }
670 
671     // When adding a new notification we want to add it before the next highest ranked without
672     // changing existing order
insertRankedNotification(NotificationGroup group, RankingMap newRankingMap)673     private void insertRankedNotification(NotificationGroup group, RankingMap newRankingMap) {
674         NotificationListenerService.Ranking newRanking = new NotificationListenerService.Ranking();
675         newRankingMap.getRanking(group.getNotificationForSorting().getKey(), newRanking);
676 
677         for (int i = 0; i < mOldProcessedNotifications.size(); i++) {
678             NotificationListenerService.Ranking ranking = new NotificationListenerService.Ranking();
679             newRankingMap.getRanking(mOldProcessedNotifications.get(
680                     i).getNotificationForSorting().getKey(), ranking);
681             if (mShowRecentsAndOlderHeaders && group.isSeen()
682                     && !mOldProcessedNotifications.get(i).isSeen()) {
683                 mOldProcessedNotifications.add(i, group);
684                 return;
685             }
686 
687             if (newRanking.getRank() < ranking.getRank()) {
688                 mOldProcessedNotifications.add(i, group);
689                 return;
690             }
691         }
692 
693         // If it's not higher ranked than any existing notifications then just add at end
694         mOldProcessedNotifications.add(group);
695     }
696 
hasSameGroupKey(AlertEntry notification1, AlertEntry notification2)697     private boolean hasSameGroupKey(AlertEntry notification1, AlertEntry notification2) {
698         return TextUtils.equals(notification1.getStatusBarNotification().getGroupKey(),
699                 notification2.getStatusBarNotification().getGroupKey());
700     }
701 
702     /**
703      * Rank notifications according to the ranking key supplied by the notification.
704      */
705     @VisibleForTesting
rank(List<NotificationGroup> notifications, RankingMap rankingMap)706     protected List<NotificationGroup> rank(List<NotificationGroup> notifications,
707             RankingMap rankingMap) {
708 
709         Collections.sort(notifications, new NotificationComparator(rankingMap));
710 
711         // Rank within each group
712         notifications.forEach(notificationGroup -> {
713             if (notificationGroup.isGroup()) {
714                 Collections.sort(
715                         notificationGroup.getChildNotifications(),
716                         new InGroupComparator(rankingMap));
717             }
718         });
719         return notifications;
720     }
721 
722     @VisibleForTesting
getOldNotifications()723     protected Map getOldNotifications() {
724         return mOldNotifications;
725     }
726 
setCarUxRestrictionManagerWrapper(CarUxRestrictionManagerWrapper manager)727     public void setCarUxRestrictionManagerWrapper(CarUxRestrictionManagerWrapper manager) {
728         try {
729             if (manager == null || manager.getCurrentCarUxRestrictions() == null) {
730                 return;
731             }
732             mMaxStringLength =
733                     manager.getCurrentCarUxRestrictions().getMaxRestrictedStringLength();
734         } catch (RuntimeException e) {
735             mMaxStringLength = Integer.MAX_VALUE;
736             Log.e(TAG, "Failed to get UxRestrictions thus running unrestricted", e);
737         }
738     }
739 
740     /**
741      * Comparator that sorts within the notification group by the sort key. If a sort key is not
742      * supplied, sort by the global ranking order.
743      */
744     private static class InGroupComparator implements Comparator<AlertEntry> {
745         private final RankingMap mRankingMap;
746 
InGroupComparator(RankingMap rankingMap)747         InGroupComparator(RankingMap rankingMap) {
748             mRankingMap = rankingMap;
749         }
750 
751         @Override
compare(AlertEntry left, AlertEntry right)752         public int compare(AlertEntry left, AlertEntry right) {
753             if (left.getNotification().getSortKey() != null
754                     && right.getNotification().getSortKey() != null) {
755                 return left.getNotification().getSortKey().compareTo(
756                         right.getNotification().getSortKey());
757             }
758 
759             NotificationListenerService.Ranking leftRanking =
760                     new NotificationListenerService.Ranking();
761             mRankingMap.getRanking(left.getKey(), leftRanking);
762 
763             NotificationListenerService.Ranking rightRanking =
764                     new NotificationListenerService.Ranking();
765             mRankingMap.getRanking(right.getKey(), rightRanking);
766 
767             return leftRanking.getRank() - rightRanking.getRank();
768         }
769     }
770 
771     /**
772      * Comparator that sorts the notification groups by their representative notification's rank.
773      */
774     private class NotificationComparator implements Comparator<NotificationGroup> {
775         private final NotificationListenerService.RankingMap mRankingMap;
776 
NotificationComparator(NotificationListenerService.RankingMap rankingMap)777         NotificationComparator(NotificationListenerService.RankingMap rankingMap) {
778             mRankingMap = rankingMap;
779         }
780 
781         @Override
compare(NotificationGroup left, NotificationGroup right)782         public int compare(NotificationGroup left, NotificationGroup right) {
783             if (mShowRecentsAndOlderHeaders) {
784                 if (left.isSeen() && !right.isSeen()) {
785                     return -1;
786                 } else if (!left.isSeen() && right.isSeen()) {
787                     return 1;
788                 }
789             }
790 
791             NotificationListenerService.Ranking leftRanking =
792                     new NotificationListenerService.Ranking();
793             mRankingMap.getRanking(left.getNotificationForSorting().getKey(), leftRanking);
794 
795             NotificationListenerService.Ranking rightRanking =
796                     new NotificationListenerService.Ranking();
797             mRankingMap.getRanking(right.getNotificationForSorting().getKey(), rightRanking);
798 
799             return leftRanking.getRank() - rightRanking.getRank();
800         }
801     }
802 }
803