1 /*
2  * Copyright (C) 2022 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 
17 package com.android.car.notification;
18 
19 import android.annotation.Nullable;
20 import android.app.ActivityManager;
21 import android.app.ActivityTaskManager;
22 import android.app.Notification;
23 import android.app.NotificationChannel;
24 import android.app.NotificationManager;
25 import android.app.TaskStackListener;
26 import android.car.drivingstate.CarUxRestrictionsManager;
27 import android.content.Context;
28 import android.os.RemoteException;
29 import android.os.UserHandle;
30 import android.service.notification.NotificationListenerService;
31 import android.text.TextUtils;
32 
33 import androidx.annotation.AnyThread;
34 
35 import com.android.internal.annotations.VisibleForTesting;
36 
37 import java.time.Clock;
38 import java.util.Comparator;
39 import java.util.HashMap;
40 import java.util.HashSet;
41 import java.util.List;
42 import java.util.Map;
43 import java.util.PriorityQueue;
44 import java.util.Set;
45 import java.util.concurrent.ScheduledExecutorService;
46 import java.util.concurrent.ScheduledFuture;
47 import java.util.concurrent.TimeUnit;
48 
49 /**
50  * Queue for throttling heads up notifications.
51  */
52 public class CarHeadsUpNotificationQueue implements
53         CarHeadsUpNotificationManager.OnHeadsUpNotificationStateChange {
54     private static final String TAG = CarHeadsUpNotificationQueue.class.getSimpleName();
55     private static final String NOTIFICATION_CHANNEL_ID = "HUN_QUEUE_CHANNEL_ID";
56     private static final int NOTIFICATION_ID = 2000;
57     static final String CATEGORY_HUN_QUEUE_INTERNAL = "HUN_QUEUE_INTERNAL";
58 
59     private final Context mContext;
60     private final NotificationManager mNotificationManager;
61     private final PriorityQueue<String> mPriorityQueue;
62     private final ActivityTaskManager mActivityTaskManager;
63     private final CarHeadsUpNotificationQueueCallback mQueueCallback;
64     private final TaskStackListener mTaskStackListener;
65     private final ScheduledExecutorService mScheduledExecutorService;
66     private final long mNotificationExpirationTimeFromQueueWhenDriving;
67     private final long mNotificationExpirationTimeFromQueueWhenParked;
68     private final boolean mExpireHeadsUpWhileDriving;
69     private final boolean mExpireHeadsUpWhileParked;
70     private final int mHeadsUpDelayDuration;
71     private final boolean mDismissHeadsUpWhenNotificationCenterOpens;
72     private final String mNotificationTitleInParkState;
73     private final String mNotificationTitleInDriveState;
74     private final String mNotificationDescription;
75     private final Set<String> mNotificationCategoriesForImmediateShow;
76     private final Set<String> mPackagesToThrottleHeadsUp;
77     private final Map<String, AlertEntry> mKeyToAlertEntryMap;
78     private final Set<Integer> mThrottledDisplays;
79     private NotificationListenerService.RankingMap mRankingMap;
80     private Clock mClock;
81     @VisibleForTesting
82     ScheduledFuture<?> mScheduledFuture;
83     private boolean mIsActiveUxRestriction;
84     private boolean mIsOngoingHeadsUpFlush;
85     @VisibleForTesting
86     boolean mAreNotificationsExpired;
87     @VisibleForTesting
88     boolean mCancelInternalNotificationOnStateChange;
89 
CarHeadsUpNotificationQueue(Context context, ActivityTaskManager activityTaskManager, NotificationManager notificationManager, ScheduledExecutorService scheduledExecutorService, CarHeadsUpNotificationQueueCallback queuePopCallback)90     public CarHeadsUpNotificationQueue(Context context, ActivityTaskManager activityTaskManager,
91             NotificationManager notificationManager,
92             ScheduledExecutorService scheduledExecutorService,
93             CarHeadsUpNotificationQueueCallback queuePopCallback) {
94         mContext = context;
95         mActivityTaskManager = activityTaskManager;
96         mQueueCallback = queuePopCallback;
97         mNotificationManager = notificationManager;
98         mKeyToAlertEntryMap = new HashMap<>();
99         mThrottledDisplays = new HashSet<>();
100 
101         mExpireHeadsUpWhileDriving = context.getResources().getBoolean(
102                 R.bool.config_expireHeadsUpWhenDriving);
103         mExpireHeadsUpWhileParked = context.getResources().getBoolean(
104                 R.bool.config_expireHeadsUpWhenParked);
105         mDismissHeadsUpWhenNotificationCenterOpens = context.getResources().getBoolean(
106                 R.bool.config_dismissHeadsUpWhenNotificationCenterOpens);
107         mNotificationExpirationTimeFromQueueWhenDriving = context.getResources().getInteger(
108                 R.integer.headsup_queue_expire_driving_duration_ms);
109         mNotificationExpirationTimeFromQueueWhenParked = context.getResources().getInteger(
110                 R.integer.headsup_queue_expire_parked_duration_ms);
111         mHeadsUpDelayDuration = mContext.getResources().getInteger(
112                 R.integer.headsup_delay_duration);
113         mNotificationCategoriesForImmediateShow = Set.of(context.getResources().getStringArray(
114                 R.array.headsup_category_immediate_show));
115         mPackagesToThrottleHeadsUp = Set.of(context.getResources().getStringArray(
116                 R.array.headsup_throttled_foreground_packages));
117         String notificationChannelName = context.getResources().getString(
118                 R.string.hun_suppression_channel_name);
119         mNotificationTitleInParkState = context.getResources().getString(
120                 R.string.hun_suppression_notification_title_park);
121         mNotificationTitleInDriveState = context.getResources().getString(
122                 R.string.hun_suppression_notification_title_drive);
123         mNotificationDescription = context.getResources().getString(
124                 R.string.hun_suppression_notification_description);
125 
126         mPriorityQueue = new PriorityQueue<>(
127                 new PrioritisedNotifications(context.getResources().getStringArray(
128                         R.array.headsup_category_priority), mKeyToAlertEntryMap));
129 
130         mClock = Clock.systemUTC();
131 
132         mTaskStackListener = new TaskStackListener() {
133             @Override
134             public void onTaskMovedToFront(ActivityManager.RunningTaskInfo taskInfo)
135                     throws RemoteException {
136                 super.onTaskMovedToFront(taskInfo);
137                 if (taskInfo.baseActivity == null) {
138                     return;
139                 }
140                 if (mPackagesToThrottleHeadsUp.contains(taskInfo.baseActivity.getPackageName())) {
141                     mThrottledDisplays.add(taskInfo.displayAreaFeatureId);
142                     return;
143                 }
144 
145                 if (mThrottledDisplays.remove(taskInfo.displayAreaFeatureId)) {
146                     scheduleCallback(mHeadsUpDelayDuration);
147                 }
148 
149             }
150         };
151         mActivityTaskManager.registerTaskStackListener(mTaskStackListener);
152 
153         mNotificationManager.createNotificationChannel(new NotificationChannel(
154                 NOTIFICATION_CHANNEL_ID, notificationChannelName,
155                 NotificationManager.IMPORTANCE_HIGH));
156 
157         mScheduledExecutorService = scheduledExecutorService;
158     }
159 
160     /**
161      * Adds an {@link AlertEntry} into the queue.
162      */
addToQueue(AlertEntry alertEntry, NotificationListenerService.RankingMap rankingMap)163     public void addToQueue(AlertEntry alertEntry,
164             NotificationListenerService.RankingMap rankingMap) {
165         mRankingMap = rankingMap;
166         if (isCategoryImmediateShow(alertEntry.getNotification().category)) {
167             mQueueCallback.getActiveHeadsUpNotifications().forEach(mQueueCallback::dismissHeadsUp);
168             mQueueCallback.showAsHeadsUp(alertEntry, rankingMap);
169             return;
170         }
171         boolean headsUpExistsInQueue = mKeyToAlertEntryMap.containsKey(alertEntry.getKey());
172         mKeyToAlertEntryMap.put(alertEntry.getKey(), alertEntry);
173         if (!headsUpExistsInQueue) {
174             mPriorityQueue.add(alertEntry.getKey());
175         }
176         scheduleCallback(/* delay= */ 0);
177     }
178 
179     /**
180      * Removes the {@link AlertEntry} from the queue if present.
181      */
removeFromQueue(AlertEntry alertEntry)182     public boolean removeFromQueue(AlertEntry alertEntry) {
183         mKeyToAlertEntryMap.remove(alertEntry.getKey());
184         return mPriorityQueue.remove(alertEntry.getKey());
185     }
186 
187     /**
188      * Removes all notifications from the queue and optionally dismisses the active HUNs.
189      * Active HUN is not dismissed if it is not dismissible.
190      */
releaseQueue()191     public void releaseQueue() {
192         mIsOngoingHeadsUpFlush = true;
193 
194         if (mDismissHeadsUpWhenNotificationCenterOpens) {
195             mQueueCallback.getActiveHeadsUpNotifications().stream()
196                     .filter(CarHeadsUpNotificationManager::isHeadsUpDismissible)
197                     .forEach(mQueueCallback::dismissHeadsUp);
198         }
199         while (!mPriorityQueue.isEmpty()) {
200             String key = mPriorityQueue.poll();
201             if (mKeyToAlertEntryMap.containsKey(key)) {
202                 mQueueCallback.removedFromHeadsUpQueue(mKeyToAlertEntryMap.get(key));
203                 mKeyToAlertEntryMap.remove(key);
204             }
205         }
206         mIsOngoingHeadsUpFlush = false;
207     }
208 
209     @VisibleForTesting
scheduleCallback(long delay)210     void scheduleCallback(long delay) {
211         if (!canShowHeadsUp()) {
212             return;
213         }
214 
215         if (mScheduledFuture != null && !mScheduledFuture.isDone()) {
216             long delayLeft = mScheduledFuture.getDelay(TimeUnit.MILLISECONDS);
217             if (delay < delayLeft) {
218                 return;
219             }
220             mScheduledFuture.cancel(/* mayInterruptIfRunning= */ true);
221 
222         }
223         mScheduledFuture = mScheduledExecutorService.schedule(this::triggerCallback,
224                 delay, TimeUnit.MILLISECONDS);
225     }
226 
227     /**
228      * Triggers {@code CarHeadsUpNotificationQueueCallback.showAsHeadsUp} on non expired HUN and
229      * {@code CarHeadsUpNotificationQueueCallback.removedFromHeadsUpQueue} for expired HUN if
230      * there are no active HUNs.
231      */
232     @VisibleForTesting
triggerCallback()233     void triggerCallback() {
234         if (!canShowHeadsUp()) {
235             return;
236         }
237 
238         AlertEntry alertEntry;
239         do {
240             if (mPriorityQueue.isEmpty()) {
241                 if (mAreNotificationsExpired) {
242                     mAreNotificationsExpired = false;
243                     mNotificationManager.notifyAsUser(TAG, NOTIFICATION_ID,
244                             getUserNotificationForExpiredHun(),
245                             UserHandle.of(NotificationUtils.getCurrentUser(mContext)));
246                     mCancelInternalNotificationOnStateChange = true;
247                 }
248                 return;
249             }
250             String key = mPriorityQueue.poll();
251             alertEntry = mKeyToAlertEntryMap.get(key);
252             mKeyToAlertEntryMap.remove(key);
253 
254             if (alertEntry == null) {
255                 continue;
256             }
257 
258             long timeElapsed = mClock.millis() - alertEntry.getPostTime();
259             boolean isExpired = (mIsActiveUxRestriction && mExpireHeadsUpWhileDriving
260                     && mNotificationExpirationTimeFromQueueWhenDriving < timeElapsed) || (
261                     !mIsActiveUxRestriction && mExpireHeadsUpWhileParked
262                             && mNotificationExpirationTimeFromQueueWhenParked < timeElapsed);
263 
264             if (isExpired && !CATEGORY_HUN_QUEUE_INTERNAL.equals(
265                     alertEntry.getNotification().category)) {
266                 mAreNotificationsExpired = true;
267                 mQueueCallback.removedFromHeadsUpQueue(alertEntry);
268                 alertEntry = null;
269             }
270         } while (alertEntry == null);
271         mQueueCallback.showAsHeadsUp(alertEntry, mRankingMap);
272     }
273 
canShowHeadsUp()274     private boolean canShowHeadsUp() {
275         return mQueueCallback.getActiveHeadsUpNotifications().isEmpty()
276                 && mThrottledDisplays.isEmpty()
277                 && !mIsOngoingHeadsUpFlush
278                 && !mPriorityQueue.isEmpty();
279     }
280 
281     /**
282      * Returns {@code true} if the {@code category} should be shown immediately.
283      */
isCategoryImmediateShow(@ullable String category)284     private boolean isCategoryImmediateShow(@Nullable String category) {
285         return category != null && mNotificationCategoriesForImmediateShow.contains(category);
286     }
287 
288     @VisibleForTesting
getUserNotificationForExpiredHun()289     Notification getUserNotificationForExpiredHun() {
290         return new Notification
291                 .Builder(mContext, NOTIFICATION_CHANNEL_ID)
292                 .setCategory(CATEGORY_HUN_QUEUE_INTERNAL)
293                 .setContentTitle(mIsActiveUxRestriction ? mNotificationTitleInDriveState
294                         : mNotificationTitleInParkState)
295                 .setContentText(mNotificationDescription)
296                 .setSmallIcon(R.drawable.car_ui_icon_settings)
297                 .build();
298     }
299 
300     @Override
onStateChange(AlertEntry alertEntry, CarHeadsUpNotificationManager.HeadsUpState headsUpState)301     public void onStateChange(AlertEntry alertEntry,
302             CarHeadsUpNotificationManager.HeadsUpState headsUpState) {
303         if (headsUpState == CarHeadsUpNotificationManager.HeadsUpState.SHOWN
304                 || headsUpState == CarHeadsUpNotificationManager.HeadsUpState.REMOVED_FROM_QUEUE) {
305             return;
306         }
307         if (mCancelInternalNotificationOnStateChange && TextUtils.equals(
308                 alertEntry.getNotification().category, CATEGORY_HUN_QUEUE_INTERNAL)) {
309             mCancelInternalNotificationOnStateChange = false;
310             mAreNotificationsExpired = false;
311             mNotificationManager.cancelAsUser(TAG, NOTIFICATION_ID,
312                     UserHandle.of(NotificationUtils.getCurrentUser(mContext)));
313         }
314         scheduleCallback(mHeadsUpDelayDuration);
315     }
316 
317     /**
318      * Called when distraction optimisation state changes.
319      * {@link CarUxRestrictionsManager} can be used to get this state.
320      */
setActiveUxRestriction(boolean isActiveUxRestriction)321     public void setActiveUxRestriction(boolean isActiveUxRestriction) {
322         mIsActiveUxRestriction = isActiveUxRestriction;
323     }
324 
325     /**
326      * Unregisters all listeners.
327      */
unregisterListeners()328     public void unregisterListeners() {
329         mActivityTaskManager.unregisterTaskStackListener(mTaskStackListener);
330         mNotificationManager.deleteNotificationChannel(NOTIFICATION_CHANNEL_ID);
331     }
332 
333     /**
334      * Clears all local cached variables and cancels scheduled executor tasks.
335      */
clearCache()336     public void clearCache() {
337         mPriorityQueue.clear();
338         mKeyToAlertEntryMap.clear();
339         mThrottledDisplays.clear();
340         if (mScheduledFuture != null) {
341             mScheduledFuture.cancel(/* mayInterruptIfRunning= */ true);
342         }
343     }
344 
345     /**
346      * Callback to communicate status of HUN.
347      */
348     public interface CarHeadsUpNotificationQueueCallback {
349         /**
350          * Show the AlertEntry as HUN.
351          */
352         @AnyThread
showAsHeadsUp(AlertEntry alertEntry, NotificationListenerService.RankingMap rankingMap)353         void showAsHeadsUp(AlertEntry alertEntry,
354                 NotificationListenerService.RankingMap rankingMap);
355 
356         /**
357          * AlertEntry removed from the queue without being shown as HUN.
358          */
removedFromHeadsUpQueue(AlertEntry alertEntry)359         void removedFromHeadsUpQueue(AlertEntry alertEntry);
360 
361         /**
362          * Dismiss the active HUN.
363          *
364          * @param alertEntry the {@link AlertEntry} to be dismissed if present as active HUN
365          */
dismissHeadsUp(@ullable AlertEntry alertEntry)366         void dismissHeadsUp(@Nullable AlertEntry alertEntry);
367 
368         /**
369          * @return list of active HUNs.
370          */
getActiveHeadsUpNotifications()371         List<AlertEntry> getActiveHeadsUpNotifications();
372     }
373 
374     /**
375      * Used to assign priority for {@link AlertEntry} based on category and postTime.
376      */
377     private static class PrioritisedNotifications implements Comparator<String> {
378         private final String[] mNotificationsCategoryInPriorityOrder;
379         private final Map<String, AlertEntry> mKeyToAlertEntryMap;
380 
PrioritisedNotifications(String[] notificationsCategoryInPriorityOrder, Map<String, AlertEntry> mapKeyToAlertEntry)381         PrioritisedNotifications(String[] notificationsCategoryInPriorityOrder,
382                 Map<String, AlertEntry> mapKeyToAlertEntry) {
383             mNotificationsCategoryInPriorityOrder = notificationsCategoryInPriorityOrder;
384             mKeyToAlertEntryMap = mapKeyToAlertEntry;
385         }
386 
compare(String aKey, String bKey)387         public int compare(String aKey, String bKey) {
388             AlertEntry a = mKeyToAlertEntryMap.get(aKey);
389             AlertEntry b = mKeyToAlertEntryMap.get(bKey);
390             if (a == null || b == null) {
391                 return 0;
392             }
393 
394             String categoryA = a.getNotification().category;
395             String categoryB = b.getNotification().category;
396 
397             if (CATEGORY_HUN_QUEUE_INTERNAL.equals(categoryA)) {
398                 return 1;
399             }
400             if (CATEGORY_HUN_QUEUE_INTERNAL.equals(categoryB)) {
401                 return -1;
402             }
403 
404             int priorityA = -1;
405             int priorityB = -1;
406 
407             for (int i = 0; i < mNotificationsCategoryInPriorityOrder.length; i++) {
408                 if (mNotificationsCategoryInPriorityOrder[i].equals(categoryA)) {
409                     priorityA = i;
410                 }
411                 if (mNotificationsCategoryInPriorityOrder[i].equals(categoryB)) {
412                     priorityB = i;
413                 }
414             }
415             if (priorityA != priorityB) {
416                 return Integer.compare(priorityA, priorityB);
417             } else {
418                 return Long.compare(a.getPostTime(), b.getPostTime());
419             }
420         }
421     }
422 
423     @VisibleForTesting
getPriorityQueue()424     PriorityQueue<String> getPriorityQueue() {
425         return mPriorityQueue;
426     }
427 
428     @VisibleForTesting
addToPriorityQueue(AlertEntry alertEntry)429     void addToPriorityQueue(AlertEntry alertEntry) {
430         mKeyToAlertEntryMap.put(alertEntry.getKey(), alertEntry);
431         mPriorityQueue.add(alertEntry.getKey());
432     }
433 
434     @VisibleForTesting
setClock(Clock clock)435     void setClock(Clock clock) {
436         mClock = clock;
437     }
438 }
439