| /* |
| * Copyright (C) 2018 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| package com.android.car.notification; |
| |
| import android.annotation.Nullable; |
| import android.app.Notification; |
| import android.app.NotificationManager; |
| import android.car.drivingstate.CarUxRestrictionsManager; |
| import android.content.BroadcastReceiver; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.IntentFilter; |
| import android.os.Bundle; |
| import android.service.notification.NotificationListenerService; |
| import android.service.notification.NotificationListenerService.RankingMap; |
| import android.telephony.TelephonyManager; |
| import android.text.TextUtils; |
| import android.util.Log; |
| |
| import com.android.car.notification.template.MessageNotificationViewHolder; |
| import com.android.internal.annotations.VisibleForTesting; |
| |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.Comparator; |
| import java.util.HashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.SortedMap; |
| import java.util.TreeMap; |
| import java.util.UUID; |
| |
| /** |
| * Manager that filters, groups and ranks the notifications in the notification center. |
| * |
| * <p> Note that heads-up notifications have a different filtering mechanism and is managed by |
| * {@link CarHeadsUpNotificationManager}. |
| */ |
| public class PreprocessingManager { |
| |
| /** Listener that will be notified when a call state changes. **/ |
| public interface CallStateListener { |
| /** |
| * @param isInCall is true when user is currently in a call. |
| */ |
| void onCallStateChanged(boolean isInCall); |
| } |
| |
| private static final String TAG = "PreprocessingManager"; |
| |
| private final String mEllipsizedString; |
| private final Context mContext; |
| |
| private static PreprocessingManager sInstance; |
| |
| private int mMaxStringLength = Integer.MAX_VALUE; |
| private Map<String, AlertEntry> mOldNotifications; |
| private List<NotificationGroup> mOldProcessedNotifications; |
| private NotificationListenerService.RankingMap mOldRankingMap; |
| private Map<String, Integer> mRanking = new HashMap<>(); |
| |
| private boolean mIsInCall; |
| private List<CallStateListener> mCallStateListeners = new ArrayList<>(); |
| |
| @VisibleForTesting |
| final BroadcastReceiver mIntentReceiver = new BroadcastReceiver() { |
| @Override |
| public void onReceive(Context context, Intent intent) { |
| String action = intent.getAction(); |
| if (action.equals(TelephonyManager.ACTION_PHONE_STATE_CHANGED)) { |
| mIsInCall = TelephonyManager.EXTRA_STATE_OFFHOOK |
| .equals(intent.getStringExtra(TelephonyManager.EXTRA_STATE)); |
| for (CallStateListener listener : mCallStateListeners) { |
| listener.onCallStateChanged(mIsInCall); |
| } |
| } |
| } |
| }; |
| |
| private PreprocessingManager(Context context) { |
| mEllipsizedString = context.getString(R.string.ellipsized_string); |
| mContext = context; |
| |
| IntentFilter filter = new IntentFilter(); |
| filter.addAction(TelephonyManager.ACTION_PHONE_STATE_CHANGED); |
| context.registerReceiver(mIntentReceiver, filter); |
| } |
| |
| public static PreprocessingManager getInstance(Context context) { |
| if (sInstance == null) { |
| sInstance = new PreprocessingManager(context); |
| } |
| return sInstance; |
| } |
| |
| /** |
| * Initialize the data when the UI becomes foreground. |
| */ |
| public void init(Map<String, AlertEntry> notifications, RankingMap rankingMap) { |
| mOldNotifications = notifications; |
| mOldRankingMap = rankingMap; |
| mOldProcessedNotifications = |
| process(/* showLessImportantNotifications = */ false, notifications, rankingMap); |
| } |
| |
| /** |
| * Process the given notifications. In order for DiffUtil to work, the adapter needs a new |
| * data object each time it updates, therefore wrapping the return value in a new list. |
| * |
| * @param showLessImportantNotifications whether less important notifications should be shown. |
| * @param notifications the list of notifications to be processed. |
| * @param rankingMap the ranking map for the notifications. |
| * @return the processed notifications in a new list. |
| */ |
| public List<NotificationGroup> process( |
| boolean showLessImportantNotifications, |
| Map<String, AlertEntry> notifications, |
| RankingMap rankingMap) { |
| |
| return new ArrayList<>( |
| rank(group(optimizeForDriving( |
| filter(showLessImportantNotifications, |
| new ArrayList<>(notifications.values()), |
| rankingMap))), |
| rankingMap)); |
| } |
| |
| /** |
| * Create a new list of notifications based on existing list. |
| * |
| * @param showLessImportantNotifications whether less important notifications should be shown. |
| * @param newRankingMap the latest ranking map for the notifications. |
| * @return the new notification group list that should be shown to the user. |
| */ |
| public List<NotificationGroup> updateNotifications( |
| boolean showLessImportantNotifications, |
| AlertEntry alertEntry, |
| int updateType, |
| RankingMap newRankingMap) { |
| |
| if (updateType == CarNotificationListener.NOTIFY_NOTIFICATION_REMOVED) { |
| // removal of a notification is the same as a normal preprocessing |
| mOldNotifications.remove(alertEntry.getKey()); |
| mOldProcessedNotifications = |
| process(showLessImportantNotifications, mOldNotifications, mOldRankingMap); |
| } |
| |
| if (updateType == CarNotificationListener.NOTIFY_NOTIFICATION_POSTED) { |
| AlertEntry notification = optimizeForDriving(alertEntry); |
| boolean isUpdate = mOldNotifications.containsKey(notification.getKey()); |
| if (isUpdate) { |
| // if is an update of the previous notification |
| mOldNotifications.put(notification.getKey(), notification); |
| mOldProcessedNotifications = process(showLessImportantNotifications, |
| mOldNotifications, mOldRankingMap); |
| } else { |
| // insert a new notification into the list |
| mOldNotifications.put(notification.getKey(), notification); |
| mOldProcessedNotifications = new ArrayList<>( |
| additionalRank(additionalGroup(alertEntry), newRankingMap)); |
| } |
| } |
| |
| return mOldProcessedNotifications; |
| } |
| |
| /** Add {@link CallStateListener} in order to be notified when call state is changed. **/ |
| public void addCallStateListener(CallStateListener listener) { |
| if (mCallStateListeners.contains(listener)) return; |
| mCallStateListeners.add(listener); |
| listener.onCallStateChanged(mIsInCall); |
| } |
| |
| /** Remove {@link CallStateListener} to stop getting notified when call state is changed. **/ |
| public void removeCallStateListener(CallStateListener listener) { |
| mCallStateListeners.remove(listener); |
| } |
| |
| /** |
| * Returns true if the current {@link AlertEntry} should be filtered out and not |
| * added to the list. |
| */ |
| boolean shouldFilter(AlertEntry alertEntry, RankingMap rankingMap) { |
| return isLessImportantForegroundNotification(alertEntry, rankingMap) |
| || isMediaOrNavigationNotification(alertEntry); |
| } |
| |
| /** |
| * Filter a list of {@link AlertEntry}s according to OEM's configurations. |
| */ |
| @VisibleForTesting |
| protected List<AlertEntry> filter( |
| boolean showLessImportantNotifications, |
| List<AlertEntry> notifications, |
| RankingMap rankingMap) { |
| // remove notifications that should be filtered. |
| if (!showLessImportantNotifications) { |
| notifications.removeIf(alertEntry -> shouldFilter(alertEntry, rankingMap)); |
| } |
| |
| // Call notifications should not be shown in the panel. |
| // Since they're shown as persistent HUNs, and notifications are not added to the panel |
| // until after they're dismissed as HUNs, it does not make sense to have them in the panel, |
| // and sequencing could cause them to be removed before being added here. |
| notifications.removeIf(alertEntry -> Notification.CATEGORY_CALL.equals( |
| alertEntry.getNotification().category)); |
| |
| return notifications; |
| } |
| |
| private boolean isLessImportantForegroundNotification(AlertEntry alertEntry, |
| RankingMap rankingMap) { |
| boolean isForeground = |
| (alertEntry.getNotification().flags |
| & Notification.FLAG_FOREGROUND_SERVICE) != 0; |
| |
| if (!isForeground) { |
| return false; |
| } |
| |
| int importance = 0; |
| NotificationListenerService.Ranking ranking = |
| new NotificationListenerService.Ranking(); |
| if (rankingMap.getRanking(alertEntry.getKey(), ranking)) { |
| importance = ranking.getImportance(); |
| } |
| |
| return importance < NotificationManager.IMPORTANCE_DEFAULT |
| && NotificationUtils.isSystemPrivilegedOrPlatformKey(mContext, alertEntry); |
| } |
| |
| private boolean isMediaOrNavigationNotification(AlertEntry alertEntry) { |
| Notification notification = alertEntry.getNotification(); |
| return notification.isMediaNotification() |
| || Notification.CATEGORY_NAVIGATION.equals(notification.category); |
| } |
| |
| /** |
| * Process a list of {@link AlertEntry}s to be driving optimized. |
| * |
| * <p> Note that the string length limit is always respected regardless of whether distraction |
| * optimization is required. |
| */ |
| private List<AlertEntry> optimizeForDriving(List<AlertEntry> notifications) { |
| notifications.forEach(notification -> notification = optimizeForDriving(notification)); |
| return notifications; |
| } |
| |
| /** |
| * Helper method that optimize a single {@link AlertEntry} for driving. |
| * |
| * <p> Currently only trimming texts that have visual effects in car. Operation is done on |
| * the original notification object passed in; no new object is created. |
| * |
| * <p> Note that message notifications are not trimmed, so that messages are preserved for |
| * assistant read-out. Instead, {@link MessageNotificationViewHolder} will be responsible |
| * for the presentation-level text truncation. |
| */ |
| AlertEntry optimizeForDriving(AlertEntry alertEntry) { |
| if (Notification.CATEGORY_MESSAGE.equals(alertEntry.getNotification().category)){ |
| return alertEntry; |
| } |
| |
| Bundle extras = alertEntry.getNotification().extras; |
| for (String key : extras.keySet()) { |
| switch (key) { |
| case Notification.EXTRA_TITLE: |
| case Notification.EXTRA_TEXT: |
| case Notification.EXTRA_TITLE_BIG: |
| case Notification.EXTRA_SUMMARY_TEXT: |
| CharSequence value = extras.getCharSequence(key); |
| extras.putCharSequence(key, trimText(value)); |
| default: |
| continue; |
| } |
| } |
| return alertEntry; |
| } |
| |
| /** |
| * Helper method that takes a string and trims the length to the maximum character allowed |
| * by the {@link CarUxRestrictionsManager}. |
| */ |
| @Nullable |
| public CharSequence trimText(@Nullable CharSequence text) { |
| if (TextUtils.isEmpty(text) || text.length() < mMaxStringLength) { |
| return text; |
| } |
| int maxLength = mMaxStringLength - mEllipsizedString.length(); |
| return text.toString().substring(0, maxLength).concat(mEllipsizedString); |
| } |
| |
| /** |
| * Group notifications that have the same group key. |
| * |
| * <p> Automatically generated group summaries that contains no child notifications are removed. |
| * This can happen if a notification group only contains less important notifications that are |
| * filtered out in the previous {@link #filter} step. |
| * |
| * <p> A group of child notifications without a summary notification will not be grouped. |
| * |
| * @param list list of ungrouped {@link AlertEntry}s. |
| * @return list of grouped notifications as {@link NotificationGroup}s. |
| */ |
| @VisibleForTesting |
| List<NotificationGroup> group(List<AlertEntry> list) { |
| SortedMap<String, NotificationGroup> groupedNotifications = new TreeMap<>(); |
| |
| // First pass: group all notifications according to their groupKey. |
| for (int i = 0; i < list.size(); i++) { |
| AlertEntry alertEntry = list.get(i); |
| Notification notification = alertEntry.getNotification(); |
| |
| String groupKey; |
| if (Notification.CATEGORY_CALL.equals(notification.category)) { |
| // DO NOT group CATEGORY_CALL. |
| groupKey = UUID.randomUUID().toString(); |
| } else { |
| groupKey = alertEntry.getStatusBarNotification().getGroupKey(); |
| } |
| |
| if (!groupedNotifications.containsKey(groupKey)) { |
| NotificationGroup notificationGroup = new NotificationGroup(); |
| groupedNotifications.put(groupKey, notificationGroup); |
| } |
| if (notification.isGroupSummary()) { |
| groupedNotifications.get(groupKey) |
| .setGroupSummaryNotification(alertEntry); |
| } else { |
| groupedNotifications.get(groupKey).addNotification(alertEntry); |
| } |
| } |
| |
| // Second pass: remove automatically generated group summary if it contains no child |
| // notifications. This can happen if a notification group only contains less important |
| // notifications that are filtered out in the previous filter step. |
| List<NotificationGroup> groupList = new ArrayList<>(groupedNotifications.values()); |
| groupList.removeIf( |
| notificationGroup -> { |
| AlertEntry summaryNotification = |
| notificationGroup.getGroupSummaryNotification(); |
| return notificationGroup.getChildCount() == 0 |
| && summaryNotification != null |
| && summaryNotification.getStatusBarNotification().getOverrideGroupKey() |
| != null; |
| }); |
| |
| // Third pass: a notification group without a group summary should be restored back into |
| // individual notifications. |
| List<NotificationGroup> validGroupList = new ArrayList<>(); |
| groupList.forEach( |
| group -> { |
| if (group.getChildCount() > 1 && group.getGroupSummaryNotification() == null) { |
| group.getChildNotifications().forEach( |
| notification -> { |
| NotificationGroup newGroup = new NotificationGroup(); |
| newGroup.addNotification(notification); |
| validGroupList.add(newGroup); |
| }); |
| } else { |
| validGroupList.add(group); |
| } |
| }); |
| |
| // Fourth Pass: group notifications with no child notifications should be removed. |
| validGroupList.removeIf(notificationGroup -> |
| notificationGroup.getChildNotifications().isEmpty()); |
| |
| // Fifth pass: if a notification is a group notification, update the timestamp if one of |
| // the children notifications shows a timestamp. |
| validGroupList.forEach(group -> { |
| if (!group.isGroup()) { |
| return; |
| } |
| |
| AlertEntry groupSummaryNotification = group.getGroupSummaryNotification(); |
| boolean showWhen = false; |
| long greatestTimestamp = 0; |
| for (AlertEntry notification : group.getChildNotifications()) { |
| if (notification.getNotification().showsTime()) { |
| showWhen = true; |
| greatestTimestamp = Math.max(greatestTimestamp, |
| notification.getNotification().when); |
| } |
| } |
| |
| if (showWhen) { |
| groupSummaryNotification.getNotification().extras.putBoolean( |
| Notification.EXTRA_SHOW_WHEN, true); |
| groupSummaryNotification.getNotification().when = greatestTimestamp; |
| } |
| }); |
| |
| return validGroupList; |
| } |
| |
| /** |
| * Add new NotificationGroup to an existing list of NotificationGroups. |
| * |
| * @param newNotification the {@link AlertEntry} that should be added to the list. |
| * @return list of grouped notifications as {@link NotificationGroup}s. |
| */ |
| @VisibleForTesting |
| protected List<NotificationGroup> additionalGroup(AlertEntry newNotification) { |
| Notification notification = newNotification.getNotification(); |
| |
| if (notification.isGroupSummary()) { |
| // if child notifications already exist, ignore this insertion |
| for (String key : mOldNotifications.keySet()) { |
| if (hasSameGroupKey(mOldNotifications.get(key), newNotification)) { |
| return mOldProcessedNotifications; |
| } |
| } |
| // if child notifications do not exist, insert the summary as a new notification |
| NotificationGroup newGroup = new NotificationGroup(); |
| newGroup.setGroupSummaryNotification(newNotification); |
| mOldProcessedNotifications.add(newGroup); |
| return mOldProcessedNotifications; |
| } else { |
| for (int i = 0; i < mOldProcessedNotifications.size(); i++) { |
| NotificationGroup oldGroup = mOldProcessedNotifications.get(i); |
| // if a group already exists |
| if (TextUtils.equals(oldGroup.getGroupKey(), |
| newNotification.getStatusBarNotification().getGroupKey())) { |
| // if a standalone group summary exists, replace the group summary notification |
| if (oldGroup.getChildCount() == 0) { |
| mOldProcessedNotifications.add(i, new NotificationGroup(newNotification)); |
| return mOldProcessedNotifications; |
| } |
| // if a group already exist with multiple children, insert outside of the group |
| mOldProcessedNotifications.add(new NotificationGroup(newNotification)); |
| return mOldProcessedNotifications; |
| } |
| } |
| // if it is a new notification, insert directly |
| mOldProcessedNotifications.add(new NotificationGroup(newNotification)); |
| return mOldProcessedNotifications; |
| } |
| } |
| |
| private boolean hasSameGroupKey(AlertEntry notification1, AlertEntry notification2) { |
| return TextUtils.equals(notification1.getStatusBarNotification().getGroupKey(), |
| notification2.getStatusBarNotification().getGroupKey()); |
| } |
| |
| /** |
| * Rank notifications according to the ranking key supplied by the notification. |
| */ |
| @VisibleForTesting |
| protected List<NotificationGroup> rank(List<NotificationGroup> notifications, |
| RankingMap rankingMap) { |
| |
| Collections.sort(notifications, new NotificationComparator(rankingMap)); |
| |
| // Rank within each group |
| notifications.forEach(notificationGroup -> { |
| if (notificationGroup.isGroup()) { |
| Collections.sort( |
| notificationGroup.getChildNotifications(), |
| new InGroupComparator(rankingMap)); |
| } |
| }); |
| return notifications; |
| } |
| |
| /** |
| * Only rank top-level notification groups because no children should be inserted into a group. |
| */ |
| public List<NotificationGroup> additionalRank( |
| List<NotificationGroup> notifications, RankingMap newRankingMap) { |
| |
| Collections.sort( |
| notifications, new AdditionalNotificationComparator(newRankingMap)); |
| |
| return notifications; |
| } |
| |
| @VisibleForTesting |
| protected Map getOldNotifications() { |
| return mOldNotifications; |
| } |
| |
| public void setCarUxRestrictionManagerWrapper(CarUxRestrictionManagerWrapper manager) { |
| try { |
| if (manager == null || manager.getCurrentCarUxRestrictions() == null) { |
| return; |
| } |
| mMaxStringLength = |
| manager.getCurrentCarUxRestrictions().getMaxRestrictedStringLength(); |
| } catch (RuntimeException e) { |
| mMaxStringLength = Integer.MAX_VALUE; |
| Log.e(TAG, "Failed to get UxRestrictions thus running unrestricted", e); |
| } |
| } |
| |
| /** |
| * Comparator that sorts within the notification group by the sort key. If a sort key is not |
| * supplied, sort by the global ranking order. |
| */ |
| private static class InGroupComparator implements Comparator<AlertEntry> { |
| private final RankingMap mRankingMap; |
| |
| InGroupComparator(RankingMap rankingMap) { |
| mRankingMap = rankingMap; |
| } |
| |
| @Override |
| public int compare(AlertEntry left, AlertEntry right) { |
| if (left.getNotification().getSortKey() != null |
| && right.getNotification().getSortKey() != null) { |
| return left.getNotification().getSortKey().compareTo( |
| right.getNotification().getSortKey()); |
| } |
| |
| NotificationListenerService.Ranking leftRanking = |
| new NotificationListenerService.Ranking(); |
| mRankingMap.getRanking(left.getKey(), leftRanking); |
| |
| NotificationListenerService.Ranking rightRanking = |
| new NotificationListenerService.Ranking(); |
| mRankingMap.getRanking(right.getKey(), rightRanking); |
| |
| return leftRanking.getRank() - rightRanking.getRank(); |
| } |
| } |
| |
| /** |
| * Comparator that sorts the notification groups by their representative notification's rank. |
| */ |
| private class NotificationComparator implements Comparator<NotificationGroup> { |
| private final NotificationListenerService.RankingMap mRankingMap; |
| |
| NotificationComparator(NotificationListenerService.RankingMap rankingMap) { |
| mRankingMap = rankingMap; |
| } |
| |
| @Override |
| public int compare(NotificationGroup left, NotificationGroup right) { |
| NotificationListenerService.Ranking leftRanking = |
| new NotificationListenerService.Ranking(); |
| mRankingMap.getRanking(left.getNotificationForSorting().getKey(), leftRanking); |
| |
| NotificationListenerService.Ranking rightRanking = |
| new NotificationListenerService.Ranking(); |
| mRankingMap.getRanking(right.getNotificationForSorting().getKey(), rightRanking); |
| |
| return leftRanking.getRank() - rightRanking.getRank(); |
| } |
| } |
| |
| /** |
| * Comparator that sorts the notification groups by their representative notification's |
| * rank using both of the initial ranking map and the current ranking map. |
| * |
| * <p>Cache the ranking value so that it doesn't change over time.</p> |
| */ |
| private class AdditionalNotificationComparator implements Comparator<NotificationGroup> { |
| private final RankingMap mNewRankingMap; |
| |
| AdditionalNotificationComparator(RankingMap newRankingMap) { |
| mNewRankingMap = newRankingMap; |
| } |
| |
| @Override |
| public int compare(NotificationGroup left, NotificationGroup right) { |
| int leftRankingNumber = getRanking(left, mNewRankingMap); |
| int rightRankingNumber = getRanking(right, mNewRankingMap); |
| return leftRankingNumber - rightRankingNumber; |
| } |
| } |
| |
| private int getRanking(NotificationGroup group, RankingMap newRankingMap) { |
| int rankingNumber; |
| |
| if (mRanking.containsKey(group.getGroupKey())) { |
| rankingNumber = mRanking.get(group.getGroupKey()); |
| } else { |
| NotificationListenerService.Ranking rightRanking = |
| new NotificationListenerService.Ranking(); |
| if (!mOldRankingMap.getRanking( |
| group.getNotificationForSorting().getKey(), rightRanking)) { |
| if (newRankingMap != null) { |
| newRankingMap.getRanking( |
| group.getNotificationForSorting().getKey(), rightRanking); |
| } |
| } |
| rankingNumber = rightRanking.getRank(); |
| } |
| mRanking.putIfAbsent(group.getGroupKey(), rankingNumber); |
| return rankingNumber; |
| } |
| } |