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