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.CarNotConnectedException;
22 import android.car.drivingstate.CarUxRestrictionsManager;
23 import android.content.BroadcastReceiver;
24 import android.content.Context;
25 import android.content.Intent;
26 import android.content.IntentFilter;
27 import android.os.Bundle;
28 import android.service.notification.NotificationListenerService;
29 import android.service.notification.NotificationListenerService.RankingMap;
30 import android.service.notification.StatusBarNotification;
31 import android.telephony.TelephonyManager;
32 import android.text.TextUtils;
33 import android.util.Log;
34 
35 import com.android.car.notification.template.MessageNotificationViewHolder;
36 import com.android.internal.annotations.VisibleForTesting;
37 
38 import java.util.ArrayList;
39 import java.util.Collections;
40 import java.util.Comparator;
41 import java.util.HashMap;
42 import java.util.List;
43 import java.util.Map;
44 import java.util.SortedMap;
45 import java.util.TreeMap;
46 import java.util.UUID;
47 
48 /**
49  * Manager that filters, groups and ranks the notifications in the notification center.
50  *
51  * <p> Note that heads-up notifications have a different filtering mechanism and is managed by
52  * {@link CarHeadsUpNotificationManager}.
53  */
54 public class PreprocessingManager {
55 
56     /** Listener that will be notified when a call state changes. **/
57     public interface CallStateListener {
58         /**
59          * @param isInCall is true when user is currently in a call.
60          */
onCallStateChanged(boolean isInCall)61         void onCallStateChanged(boolean isInCall);
62     }
63 
64     private static final String TAG = "PreprocessingManager";
65 
66     private final String mEllipsizedString;
67     private final Context mContext;
68 
69     private static PreprocessingManager sInstance;
70 
71     private int mMaxStringLength = Integer.MAX_VALUE;
72     private Map<String, StatusBarNotification> mOldNotifications;
73     private List<NotificationGroup> mOldProcessedNotifications;
74     private NotificationListenerService.RankingMap mOldRankingMap;
75     private Map<String, Integer> mRanking = new HashMap<>();
76 
77     private boolean mIsInCall;
78     private List<CallStateListener> mCallStateListeners = new ArrayList<>();
79 
80     @VisibleForTesting
81     final BroadcastReceiver mIntentReceiver = new BroadcastReceiver() {
82         @Override
83         public void onReceive(Context context, Intent intent) {
84             String action = intent.getAction();
85             if (action.equals(TelephonyManager.ACTION_PHONE_STATE_CHANGED)) {
86                 mIsInCall = TelephonyManager.EXTRA_STATE_OFFHOOK
87                         .equals(intent.getStringExtra(TelephonyManager.EXTRA_STATE));
88                 for (CallStateListener listener : mCallStateListeners) {
89                     listener.onCallStateChanged(mIsInCall);
90                 }
91             }
92         }
93     };
94 
PreprocessingManager(Context context)95     private PreprocessingManager(Context context) {
96         mEllipsizedString = context.getString(R.string.ellipsized_string);
97         mContext = context;
98 
99         IntentFilter filter = new IntentFilter();
100         filter.addAction(TelephonyManager.ACTION_PHONE_STATE_CHANGED);
101         context.registerReceiver(mIntentReceiver, filter);
102     }
103 
getInstance(Context context)104     public static PreprocessingManager getInstance(Context context) {
105         if (sInstance == null) {
106             sInstance = new PreprocessingManager(context);
107         }
108         return sInstance;
109     }
110 
111     /**
112      * Initialize the data when the UI becomes foreground.
113      */
init(Map<String, StatusBarNotification> notifications, RankingMap rankingMap)114     public void init(Map<String, StatusBarNotification> notifications, RankingMap rankingMap) {
115         mOldNotifications = notifications;
116         mOldRankingMap = rankingMap;
117         mOldProcessedNotifications =
118                 process(/* showLessImportantNotifications = */ false, notifications, rankingMap);
119     }
120 
121     /**
122      * Process the given notifications. In order for DiffUtil to work, the adapter needs a new
123      * data object each time it updates, therefore wrapping the return value in a new list.
124      *
125      * @param showLessImportantNotifications whether less important notifications should be shown.
126      * @param notifications the list of notifications to be processed.
127      * @param rankingMap the ranking map for the notifications.
128      * @return the processed notifications in a new list.
129      */
process( boolean showLessImportantNotifications, Map<String, StatusBarNotification> notifications, RankingMap rankingMap)130     public List<NotificationGroup> process(
131             boolean showLessImportantNotifications,
132             Map<String, StatusBarNotification> notifications,
133             RankingMap rankingMap) {
134 
135         return new ArrayList<>(
136                 rank(group(optimizeForDriving(
137                         filter(showLessImportantNotifications,
138                                 new ArrayList<>(notifications.values()),
139                                 rankingMap))),
140                         rankingMap));
141     }
142 
143     /**
144      * Create a new list of notifications based on existing list.
145      *
146      * @param showLessImportantNotifications whether less important notifications should be shown.
147      * @param newRankingMap the latest ranking map for the notifications.
148      * @return the new notification group list that should be shown to the user.
149      */
updateNotifications( boolean showLessImportantNotifications, StatusBarNotification sbn, int updateType, RankingMap newRankingMap)150     public List<NotificationGroup> updateNotifications(
151             boolean showLessImportantNotifications,
152             StatusBarNotification sbn,
153             int updateType,
154             RankingMap newRankingMap) {
155 
156         if (updateType == CarNotificationListener.NOTIFY_NOTIFICATION_REMOVED) {
157             // removal of a notification is the same as a normal preprocessing
158             mOldNotifications.remove(sbn.getKey());
159             mOldProcessedNotifications =
160                     process(showLessImportantNotifications, mOldNotifications, mOldRankingMap);
161         }
162 
163         if (updateType == CarNotificationListener.NOTIFY_NOTIFICATION_POSTED) {
164             StatusBarNotification notification = optimizeForDriving(sbn);
165             boolean isUpdate = mOldNotifications.containsKey(notification.getKey());
166             if (isUpdate) {
167                 // if is an update of the previous notification
168                 mOldNotifications.put(notification.getKey(), notification);
169                 mOldProcessedNotifications = process(showLessImportantNotifications,
170                         mOldNotifications, mOldRankingMap);
171             } else {
172                 // insert a new notification into the list
173                 mOldNotifications.put(notification.getKey(), notification);
174                 mOldProcessedNotifications = new ArrayList<>(
175                         additionalRank(additionalGroup(notification), newRankingMap));
176             }
177         }
178 
179         return mOldProcessedNotifications;
180     }
181 
182     /** Add {@link CallStateListener} in order to be notified when call state is changed. **/
addCallStateListener(CallStateListener listener)183     public void addCallStateListener(CallStateListener listener) {
184         if (mCallStateListeners.contains(listener)) return;
185         mCallStateListeners.add(listener);
186         listener.onCallStateChanged(mIsInCall);
187     }
188 
189     /** Remove {@link CallStateListener} to stop getting notified when call state is changed. **/
removeCallStateListener(CallStateListener listener)190     public void removeCallStateListener(CallStateListener listener) {
191         mCallStateListeners.remove(listener);
192     }
193 
194     /**
195      * Returns true if the current {@link StatusBarNotification} should be filtered out and not
196      * added to the list.
197      */
shouldFilter(StatusBarNotification sbn, RankingMap rankingMap)198     boolean shouldFilter(StatusBarNotification sbn, RankingMap rankingMap) {
199         return isLessImportantForegroundNotification(sbn, rankingMap)
200                 || isMediaOrNavigationNotification(sbn);
201     }
202 
203     /**
204      * Filter a list of {@link StatusBarNotification}s according to OEM's configurations.
205      */
filter( boolean showLessImportantNotifications, List<StatusBarNotification> notifications, RankingMap rankingMap)206     private List<StatusBarNotification> filter(
207             boolean showLessImportantNotifications,
208             List<StatusBarNotification> notifications,
209             RankingMap rankingMap) {
210         // remove less important foreground service notifications for car
211         if (!showLessImportantNotifications) {
212             notifications.removeIf(statusBarNotification
213                     -> isLessImportantForegroundNotification(statusBarNotification,
214                     rankingMap));
215 
216             // remove media and navigation notifications in the notification center for car
217             notifications.removeIf(statusBarNotification
218                     -> isMediaOrNavigationNotification(statusBarNotification));
219         }
220         return notifications;
221     }
222 
isLessImportantForegroundNotification( StatusBarNotification statusBarNotification, RankingMap rankingMap)223     private boolean isLessImportantForegroundNotification(
224             StatusBarNotification statusBarNotification, RankingMap rankingMap) {
225         boolean isForeground =
226                 (statusBarNotification.getNotification().flags
227                         & Notification.FLAG_FOREGROUND_SERVICE) != 0;
228 
229         if (!isForeground) {
230             return false;
231         }
232 
233         int importance = 0;
234         NotificationListenerService.Ranking ranking =
235                 new NotificationListenerService.Ranking();
236         if (rankingMap.getRanking(statusBarNotification.getKey(), ranking)) {
237             importance = ranking.getImportance();
238         }
239         return importance < NotificationManager.IMPORTANCE_DEFAULT
240                 && NotificationUtils.isSystemPrivilegedOrPlatformKey(mContext,
241                 statusBarNotification);
242     }
243 
isMediaOrNavigationNotification(StatusBarNotification statusBarNotification)244     private boolean isMediaOrNavigationNotification(StatusBarNotification statusBarNotification) {
245         Notification notification = statusBarNotification.getNotification();
246         return notification.isMediaNotification()
247                 || Notification.CATEGORY_NAVIGATION.equals(notification.category);
248     }
249 
250     /**
251      * Process a list of {@link StatusBarNotification}s to be driving optimized.
252      *
253      * <p> Note that the string length limit is always respected regardless of whether distraction
254      * optimization is required.
255      */
optimizeForDriving( List<StatusBarNotification> notifications)256     private List<StatusBarNotification> optimizeForDriving(
257             List<StatusBarNotification> notifications) {
258         notifications.forEach(notification -> notification = optimizeForDriving(notification));
259         return notifications;
260     }
261 
262     /**
263      * Helper method that optimize a single {@link StatusBarNotification} 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(StatusBarNotification notification)272     StatusBarNotification optimizeForDriving(StatusBarNotification notification) {
273         if (Notification.CATEGORY_MESSAGE.equals(notification.getNotification().category)) {
274             return notification;
275         }
276 
277         Bundle extras = notification.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 notification;
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 StatusBarNotification}s.
316      * @return list of grouped notifications as {@link NotificationGroup}s.
317      */
318     @VisibleForTesting
group(List<StatusBarNotification> list)319     List<NotificationGroup> group(List<StatusBarNotification> 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             StatusBarNotification statusBarNotification = list.get(i);
325             Notification notification = statusBarNotification.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 = statusBarNotification.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(statusBarNotification);
342             } else {
343                 groupedNotifications.get(groupKey).addNotification(statusBarNotification);
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                     StatusBarNotification summaryNotification =
354                             notificationGroup.getGroupSummaryNotification();
355                     return notificationGroup.getChildCount() == 0
356                             && summaryNotification != null
357                             && summaryNotification.getOverrideGroupKey() != null;
358                 });
359 
360         // Third pass: a notification group without a group summary should be restored back into
361         // individual notifications.
362         List<NotificationGroup> validGroupList = new ArrayList<>();
363         groupList.forEach(
364                 group -> {
365                     if (group.getChildCount() > 1 && group.getGroupSummaryNotification() == null) {
366                         group.getChildNotifications().forEach(
367                                 notification -> {
368                                     NotificationGroup newGroup = new NotificationGroup();
369                                     newGroup.addNotification(notification);
370                                     validGroupList.add(newGroup);
371                                 });
372                     } else {
373                         validGroupList.add(group);
374                     }
375                 });
376 
377         // Fourth pass: if a notification is a group notification, update the timestamp if one of
378         // the children notifications shows a timestamp.
379         validGroupList.forEach(group -> {
380             if (!group.isGroup()) {
381                 return;
382             }
383 
384             StatusBarNotification groupSummaryNotification = group.getGroupSummaryNotification();
385             boolean showWhen = false;
386             long greatestTimestamp = 0;
387             for (StatusBarNotification notification : group.getChildNotifications()) {
388                 if (notification.getNotification().showsTime()) {
389                     showWhen = true;
390                     greatestTimestamp = Math.max(greatestTimestamp,
391                             notification.getNotification().when);
392                 }
393             }
394 
395             if (showWhen) {
396                 groupSummaryNotification.getNotification().extras.putBoolean(
397                         Notification.EXTRA_SHOW_WHEN, true);
398                 groupSummaryNotification.getNotification().when = greatestTimestamp;
399             }
400         });
401 
402         return validGroupList;
403     }
404 
405     /**
406      * Add new NotificationGroup to an existing list of NotificationGroups.
407      *
408      * @param newNotification the {@link StatusBarNotification} that should be added to the list.
409      * @return list of grouped notifications as {@link NotificationGroup}s.
410      */
additionalGroup(StatusBarNotification newNotification)411     private List<NotificationGroup> additionalGroup(StatusBarNotification newNotification) {
412         Notification notification = newNotification.getNotification();
413 
414         if (notification.isGroupSummary()) {
415             // if child notifications already exist, ignore this insertion
416             for (String key : mOldNotifications.keySet()) {
417                 if (hasSameGroupKey(mOldNotifications.get(key), newNotification)) {
418                     return mOldProcessedNotifications;
419                 }
420             }
421             // if child notifications do not exist, insert the summary as a new notification
422             NotificationGroup newGroup = new NotificationGroup();
423             newGroup.setGroupSummaryNotification(newNotification);
424             mOldProcessedNotifications.add(newGroup);
425             return mOldProcessedNotifications;
426 
427         } else {
428             for (int i = 0; i < mOldProcessedNotifications.size(); i++) {
429                 NotificationGroup oldGroup = mOldProcessedNotifications.get(i);
430                 // if a group already exists
431                 if (TextUtils.equals(oldGroup.getGroupKey(), newNotification.getGroupKey())) {
432                     // if a standalone group summary exists, replace the group summary notification
433                     if (oldGroup.getChildCount() == 0) {
434                         mOldProcessedNotifications.add(i, new NotificationGroup(newNotification));
435                         return mOldProcessedNotifications;
436                     }
437                     // if a group already exist with multiple children, insert outside of the group
438                     mOldProcessedNotifications.add(new NotificationGroup(newNotification));
439                     return mOldProcessedNotifications;
440                 }
441             }
442             // if it is a new notification, insert directly
443             mOldProcessedNotifications.add(new NotificationGroup(newNotification));
444             return mOldProcessedNotifications;
445         }
446     }
447 
hasSameGroupKey( StatusBarNotification notification1, StatusBarNotification notification2)448     private boolean hasSameGroupKey(
449             StatusBarNotification notification1, StatusBarNotification notification2) {
450         return TextUtils.equals(notification1.getGroupKey(), notification2.getGroupKey());
451     }
452 
453     /**
454      * Rank notifications according to the ranking key supplied by the notification.
455      */
rank(List<NotificationGroup> notifications, RankingMap rankingMap)456     private List<NotificationGroup> rank(List<NotificationGroup> notifications,
457             RankingMap rankingMap) {
458 
459         Collections.sort(notifications, new NotificationComparator(rankingMap));
460 
461         // Rank within each group
462         notifications.forEach(notificationGroup -> {
463             if (notificationGroup.isGroup()) {
464                 Collections.sort(
465                         notificationGroup.getChildNotifications(),
466                         new InGroupComparator(rankingMap));
467             }
468         });
469         return notifications;
470     }
471 
472     /**
473      * Only rank top-level notification groups because no children should be inserted into a group.
474      */
additionalRank( List<NotificationGroup> notifications, RankingMap newRankingMap)475     public List<NotificationGroup> additionalRank(
476             List<NotificationGroup> notifications, RankingMap newRankingMap) {
477 
478         Collections.sort(
479                 notifications, new AdditionalNotificationComparator(newRankingMap));
480 
481         return notifications;
482     }
483 
setCarUxRestrictionManagerWrapper(CarUxRestrictionManagerWrapper manager)484     public void setCarUxRestrictionManagerWrapper(CarUxRestrictionManagerWrapper manager) {
485         try {
486             if (manager == null || manager.getCurrentCarUxRestrictions() == null) {
487                 return;
488             }
489             mMaxStringLength =
490                     manager.getCurrentCarUxRestrictions().getMaxRestrictedStringLength();
491         } catch (CarNotConnectedException e) {
492             mMaxStringLength = Integer.MAX_VALUE;
493             Log.e(TAG, "Failed to get UxRestrictions thus running unrestricted", e);
494         }
495     }
496 
497     /**
498      * Comparator that sorts within the notification group by the sort key. If a sort key is not
499      * supplied, sort by the global ranking order.
500      */
501     private static class InGroupComparator implements Comparator<StatusBarNotification> {
502         private final RankingMap mRankingMap;
503 
InGroupComparator(RankingMap rankingMap)504         InGroupComparator(RankingMap rankingMap) {
505             mRankingMap = rankingMap;
506         }
507 
508         @Override
compare(StatusBarNotification left, StatusBarNotification right)509         public int compare(StatusBarNotification left, StatusBarNotification right) {
510             if (left.getNotification().getSortKey() != null
511                     && right.getNotification().getSortKey() != null) {
512                 return left.getNotification().getSortKey().compareTo(
513                         right.getNotification().getSortKey());
514             }
515 
516             NotificationListenerService.Ranking leftRanking =
517                     new NotificationListenerService.Ranking();
518             mRankingMap.getRanking(left.getKey(), leftRanking);
519 
520             NotificationListenerService.Ranking rightRanking =
521                     new NotificationListenerService.Ranking();
522             mRankingMap.getRanking(right.getKey(), rightRanking);
523 
524             return leftRanking.getRank() - rightRanking.getRank();
525         }
526     }
527 
528     /**
529      * Comparator that sorts the notification groups by their representative notification's rank.
530      */
531     private class NotificationComparator implements Comparator<NotificationGroup> {
532         private final NotificationListenerService.RankingMap mRankingMap;
533 
NotificationComparator(NotificationListenerService.RankingMap rankingMap)534         NotificationComparator(NotificationListenerService.RankingMap rankingMap) {
535             mRankingMap = rankingMap;
536         }
537 
538         @Override
compare(NotificationGroup left, NotificationGroup right)539         public int compare(NotificationGroup left, NotificationGroup right) {
540             NotificationListenerService.Ranking leftRanking =
541                     new NotificationListenerService.Ranking();
542             mRankingMap.getRanking(left.getNotificationForSorting().getKey(), leftRanking);
543 
544             NotificationListenerService.Ranking rightRanking =
545                     new NotificationListenerService.Ranking();
546             mRankingMap.getRanking(right.getNotificationForSorting().getKey(), rightRanking);
547 
548             return leftRanking.getRank() - rightRanking.getRank();
549         }
550     }
551 
552     /**
553      * Comparator that sorts the notification groups by their representative notification's
554      * rank using both of the initial ranking map and the current ranking map.
555      *
556      * <p>Cache the ranking value so that it doesn't change over time.</p>
557      */
558     private class AdditionalNotificationComparator implements Comparator<NotificationGroup> {
559         private final RankingMap mNewRankingMap;
560 
AdditionalNotificationComparator(RankingMap newRankingMap)561         AdditionalNotificationComparator(RankingMap newRankingMap) {
562             mNewRankingMap = newRankingMap;
563         }
564 
565         @Override
compare(NotificationGroup left, NotificationGroup right)566         public int compare(NotificationGroup left, NotificationGroup right) {
567             int leftRankingNumber = getRanking(left, mNewRankingMap);
568             int rightRankingNumber = getRanking(right, mNewRankingMap);
569             return leftRankingNumber - rightRankingNumber;
570         }
571     }
572 
getRanking(NotificationGroup group, RankingMap newRankingMap)573     private int getRanking(NotificationGroup group, RankingMap newRankingMap) {
574         int rankingNumber;
575 
576         if (mRanking.containsKey(group.getGroupKey())) {
577             rankingNumber = mRanking.get(group.getGroupKey());
578         } else {
579             NotificationListenerService.Ranking rightRanking =
580                     new NotificationListenerService.Ranking();
581             if (!mOldRankingMap.getRanking(
582                     group.getNotificationForSorting().getKey(), rightRanking)) {
583                 if (newRankingMap != null) {
584                     newRankingMap.getRanking(
585                             group.getNotificationForSorting().getKey(), rightRanking);
586                 }
587             }
588             rankingNumber = rightRanking.getRank();
589         }
590         mRanking.putIfAbsent(group.getGroupKey(), rankingNumber);
591         return rankingNumber;
592     }
593 }
594