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