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