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