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