1 /* 2 * Copyright (C) 2015 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.systemui.statusbar.policy; 18 19 import static com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.FLAG_CONTENT_VIEW_HEADS_UP; 20 21 import android.annotation.NonNull; 22 import android.annotation.Nullable; 23 import android.app.Notification; 24 import android.content.Context; 25 import android.content.res.Resources; 26 import android.database.ContentObserver; 27 import android.os.Handler; 28 import android.os.SystemClock; 29 import android.provider.Settings; 30 import android.util.ArrayMap; 31 import android.view.accessibility.AccessibilityManager; 32 33 import com.android.internal.logging.MetricsLogger; 34 import com.android.internal.logging.UiEvent; 35 import com.android.internal.logging.UiEventLogger; 36 import com.android.systemui.EventLogTags; 37 import com.android.systemui.R; 38 import com.android.systemui.dagger.qualifiers.Main; 39 import com.android.systemui.statusbar.AlertingNotificationManager; 40 import com.android.systemui.statusbar.notification.collection.NotificationEntry; 41 import com.android.systemui.statusbar.notification.row.NotificationRowContentBinder.InflationFlag; 42 import com.android.systemui.util.ListenerSet; 43 44 import java.io.PrintWriter; 45 46 /** 47 * A manager which handles heads up notifications which is a special mode where 48 * they simply peek from the top of the screen. 49 */ 50 public abstract class HeadsUpManager extends AlertingNotificationManager { 51 private static final String TAG = "HeadsUpManager"; 52 private static final String SETTING_HEADS_UP_SNOOZE_LENGTH_MS = "heads_up_snooze_length_ms"; 53 54 protected final ListenerSet<OnHeadsUpChangedListener> mListeners = new ListenerSet<>(); 55 56 protected final Context mContext; 57 58 protected int mTouchAcceptanceDelay; 59 protected int mSnoozeLengthMs; 60 protected boolean mHasPinnedNotification; 61 protected int mUser; 62 63 private final ArrayMap<String, Long> mSnoozedPackages; 64 private final AccessibilityManagerWrapper mAccessibilityMgr; 65 66 private final UiEventLogger mUiEventLogger; 67 68 /** 69 * Enum entry for notification peek logged from this class. 70 */ 71 enum NotificationPeekEvent implements UiEventLogger.UiEventEnum { 72 @UiEvent(doc = "Heads-up notification peeked on screen.") 73 NOTIFICATION_PEEK(801); 74 75 private final int mId; NotificationPeekEvent(int id)76 NotificationPeekEvent(int id) { 77 mId = id; 78 } getId()79 @Override public int getId() { 80 return mId; 81 } 82 } 83 HeadsUpManager(@onNull final Context context, HeadsUpManagerLogger logger, @Main Handler handler, AccessibilityManagerWrapper accessibilityManagerWrapper, UiEventLogger uiEventLogger)84 public HeadsUpManager(@NonNull final Context context, 85 HeadsUpManagerLogger logger, 86 @Main Handler handler, 87 AccessibilityManagerWrapper accessibilityManagerWrapper, 88 UiEventLogger uiEventLogger) { 89 super(logger, handler); 90 mContext = context; 91 mAccessibilityMgr = accessibilityManagerWrapper; 92 mUiEventLogger = uiEventLogger; 93 Resources resources = context.getResources(); 94 mMinimumDisplayTime = resources.getInteger(R.integer.heads_up_notification_minimum_time); 95 mStickyDisplayTime = resources.getInteger(R.integer.sticky_heads_up_notification_time); 96 mAutoDismissNotificationDecay = resources.getInteger(R.integer.heads_up_notification_decay); 97 mTouchAcceptanceDelay = resources.getInteger(R.integer.touch_acceptance_delay); 98 mSnoozedPackages = new ArrayMap<>(); 99 int defaultSnoozeLengthMs = 100 resources.getInteger(R.integer.heads_up_default_snooze_length_ms); 101 102 mSnoozeLengthMs = Settings.Global.getInt(context.getContentResolver(), 103 SETTING_HEADS_UP_SNOOZE_LENGTH_MS, defaultSnoozeLengthMs); 104 ContentObserver settingsObserver = new ContentObserver(handler) { 105 @Override 106 public void onChange(boolean selfChange) { 107 final int packageSnoozeLengthMs = Settings.Global.getInt( 108 context.getContentResolver(), SETTING_HEADS_UP_SNOOZE_LENGTH_MS, -1); 109 if (packageSnoozeLengthMs > -1 && packageSnoozeLengthMs != mSnoozeLengthMs) { 110 mSnoozeLengthMs = packageSnoozeLengthMs; 111 mLogger.logSnoozeLengthChange(packageSnoozeLengthMs); 112 } 113 } 114 }; 115 context.getContentResolver().registerContentObserver( 116 Settings.Global.getUriFor(SETTING_HEADS_UP_SNOOZE_LENGTH_MS), false, 117 settingsObserver); 118 } 119 120 /** 121 * Adds an OnHeadUpChangedListener to observe events. 122 */ addListener(@onNull OnHeadsUpChangedListener listener)123 public void addListener(@NonNull OnHeadsUpChangedListener listener) { 124 mListeners.addIfAbsent(listener); 125 } 126 127 /** 128 * Removes the OnHeadUpChangedListener from the observer list. 129 */ removeListener(@onNull OnHeadsUpChangedListener listener)130 public void removeListener(@NonNull OnHeadsUpChangedListener listener) { 131 mListeners.remove(listener); 132 } 133 updateNotification(@onNull String key, boolean alert)134 public void updateNotification(@NonNull String key, boolean alert) { 135 super.updateNotification(key, alert); 136 HeadsUpEntry headsUpEntry = getHeadsUpEntry(key); 137 if (alert && headsUpEntry != null) { 138 setEntryPinned(headsUpEntry, shouldHeadsUpBecomePinned(headsUpEntry.mEntry)); 139 } 140 } 141 shouldHeadsUpBecomePinned(@onNull NotificationEntry entry)142 protected boolean shouldHeadsUpBecomePinned(@NonNull NotificationEntry entry) { 143 final HeadsUpEntry headsUpEntry = getHeadsUpEntry(entry.getKey()); 144 if (headsUpEntry == null) { 145 // This should not happen since shouldHeadsUpBecomePinned is always called after adding 146 // the NotificationEntry into AlertingNotificationManager's mAlertEntries map. 147 return hasFullScreenIntent(entry); 148 } 149 return hasFullScreenIntent(entry) && !headsUpEntry.wasUnpinned; 150 } 151 hasFullScreenIntent(@onNull NotificationEntry entry)152 protected boolean hasFullScreenIntent(@NonNull NotificationEntry entry) { 153 return entry.getSbn().getNotification().fullScreenIntent != null; 154 } 155 setEntryPinned( @onNull HeadsUpManager.HeadsUpEntry headsUpEntry, boolean isPinned)156 protected void setEntryPinned( 157 @NonNull HeadsUpManager.HeadsUpEntry headsUpEntry, boolean isPinned) { 158 mLogger.logSetEntryPinned(headsUpEntry.mEntry, isPinned); 159 NotificationEntry entry = headsUpEntry.mEntry; 160 if (!isPinned) { 161 headsUpEntry.wasUnpinned = true; 162 } 163 if (entry.isRowPinned() != isPinned) { 164 entry.setRowPinned(isPinned); 165 updatePinnedMode(); 166 if (isPinned && entry.getSbn() != null) { 167 mUiEventLogger.logWithInstanceId( 168 NotificationPeekEvent.NOTIFICATION_PEEK, entry.getSbn().getUid(), 169 entry.getSbn().getPackageName(), entry.getSbn().getInstanceId()); 170 } 171 for (OnHeadsUpChangedListener listener : mListeners) { 172 if (isPinned) { 173 listener.onHeadsUpPinned(entry); 174 } else { 175 listener.onHeadsUpUnPinned(entry); 176 } 177 } 178 } 179 } 180 getContentFlag()181 public @InflationFlag int getContentFlag() { 182 return FLAG_CONTENT_VIEW_HEADS_UP; 183 } 184 185 @Override onAlertEntryAdded(AlertEntry alertEntry)186 protected void onAlertEntryAdded(AlertEntry alertEntry) { 187 NotificationEntry entry = alertEntry.mEntry; 188 entry.setHeadsUp(true); 189 190 final boolean shouldPin = shouldHeadsUpBecomePinned(entry); 191 setEntryPinned((HeadsUpEntry) alertEntry, shouldPin); 192 EventLogTags.writeSysuiHeadsUpStatus(entry.getKey(), 1 /* visible */); 193 for (OnHeadsUpChangedListener listener : mListeners) { 194 listener.onHeadsUpStateChanged(entry, true); 195 } 196 } 197 198 @Override onAlertEntryRemoved(AlertEntry alertEntry)199 protected void onAlertEntryRemoved(AlertEntry alertEntry) { 200 NotificationEntry entry = alertEntry.mEntry; 201 entry.setHeadsUp(false); 202 setEntryPinned((HeadsUpEntry) alertEntry, false /* isPinned */); 203 EventLogTags.writeSysuiHeadsUpStatus(entry.getKey(), 0 /* visible */); 204 mLogger.logNotificationActuallyRemoved(entry); 205 for (OnHeadsUpChangedListener listener : mListeners) { 206 listener.onHeadsUpStateChanged(entry, false); 207 } 208 } 209 updatePinnedMode()210 protected void updatePinnedMode() { 211 boolean hasPinnedNotification = hasPinnedNotificationInternal(); 212 if (hasPinnedNotification == mHasPinnedNotification) { 213 return; 214 } 215 mLogger.logUpdatePinnedMode(hasPinnedNotification); 216 mHasPinnedNotification = hasPinnedNotification; 217 if (mHasPinnedNotification) { 218 MetricsLogger.count(mContext, "note_peek", 1); 219 } 220 for (OnHeadsUpChangedListener listener : mListeners) { 221 listener.onHeadsUpPinnedModeChanged(hasPinnedNotification); 222 } 223 } 224 225 /** 226 * Returns if the given notification is snoozed or not. 227 */ isSnoozed(@onNull String packageName)228 public boolean isSnoozed(@NonNull String packageName) { 229 final String key = snoozeKey(packageName, mUser); 230 Long snoozedUntil = mSnoozedPackages.get(key); 231 if (snoozedUntil != null) { 232 if (snoozedUntil > mClock.currentTimeMillis()) { 233 mLogger.logIsSnoozedReturned(key); 234 return true; 235 } 236 mLogger.logPackageUnsnoozed(key); 237 mSnoozedPackages.remove(key); 238 } 239 return false; 240 } 241 242 /** 243 * Snoozes all current Heads Up Notifications. 244 */ snooze()245 public void snooze() { 246 for (String key : mAlertEntries.keySet()) { 247 AlertEntry entry = getHeadsUpEntry(key); 248 String packageName = entry.mEntry.getSbn().getPackageName(); 249 String snoozeKey = snoozeKey(packageName, mUser); 250 mLogger.logPackageSnoozed(snoozeKey); 251 mSnoozedPackages.put(snoozeKey, mClock.currentTimeMillis() + mSnoozeLengthMs); 252 } 253 } 254 255 @NonNull snoozeKey(@onNull String packageName, int user)256 private static String snoozeKey(@NonNull String packageName, int user) { 257 return user + "," + packageName; 258 } 259 260 @Nullable getHeadsUpEntry(@onNull String key)261 protected HeadsUpEntry getHeadsUpEntry(@NonNull String key) { 262 return (HeadsUpEntry) mAlertEntries.get(key); 263 } 264 265 /** 266 * Returns the top Heads Up Notification, which appears to show at first. 267 */ 268 @Nullable getTopEntry()269 public NotificationEntry getTopEntry() { 270 HeadsUpEntry topEntry = getTopHeadsUpEntry(); 271 return (topEntry != null) ? topEntry.mEntry : null; 272 } 273 274 @Nullable getTopHeadsUpEntry()275 protected HeadsUpEntry getTopHeadsUpEntry() { 276 if (mAlertEntries.isEmpty()) { 277 return null; 278 } 279 HeadsUpEntry topEntry = null; 280 for (AlertEntry entry: mAlertEntries.values()) { 281 if (topEntry == null || entry.compareTo(topEntry) < 0) { 282 topEntry = (HeadsUpEntry) entry; 283 } 284 } 285 return topEntry; 286 } 287 288 /** 289 * Sets the current user. 290 */ setUser(int user)291 public void setUser(int user) { 292 mUser = user; 293 } 294 getUser()295 public int getUser() { 296 return mUser; 297 } 298 dump(@onNull PrintWriter pw, @NonNull String[] args)299 public void dump(@NonNull PrintWriter pw, @NonNull String[] args) { 300 pw.println("HeadsUpManager state:"); 301 dumpInternal(pw, args); 302 } 303 dumpInternal(@onNull PrintWriter pw, @NonNull String[] args)304 protected void dumpInternal(@NonNull PrintWriter pw, @NonNull String[] args) { 305 pw.print(" mTouchAcceptanceDelay="); pw.println(mTouchAcceptanceDelay); 306 pw.print(" mSnoozeLengthMs="); pw.println(mSnoozeLengthMs); 307 pw.print(" now="); pw.println(mClock.currentTimeMillis()); 308 pw.print(" mUser="); pw.println(mUser); 309 for (AlertEntry entry: mAlertEntries.values()) { 310 pw.print(" HeadsUpEntry="); pw.println(entry.mEntry); 311 } 312 int N = mSnoozedPackages.size(); 313 pw.println(" snoozed packages: " + N); 314 for (int i = 0; i < N; i++) { 315 pw.print(" "); pw.print(mSnoozedPackages.valueAt(i)); 316 pw.print(", "); pw.println(mSnoozedPackages.keyAt(i)); 317 } 318 } 319 320 /** 321 * Returns if there are any pinned Heads Up Notifications or not. 322 */ hasPinnedHeadsUp()323 public boolean hasPinnedHeadsUp() { 324 return mHasPinnedNotification; 325 } 326 hasPinnedNotificationInternal()327 private boolean hasPinnedNotificationInternal() { 328 for (String key : mAlertEntries.keySet()) { 329 AlertEntry entry = getHeadsUpEntry(key); 330 if (entry.mEntry.isRowPinned()) { 331 return true; 332 } 333 } 334 return false; 335 } 336 337 /** 338 * Unpins all pinned Heads Up Notifications. 339 * @param userUnPinned The unpinned action is trigger by user real operation. 340 */ unpinAll(boolean userUnPinned)341 public void unpinAll(boolean userUnPinned) { 342 for (String key : mAlertEntries.keySet()) { 343 HeadsUpEntry entry = getHeadsUpEntry(key); 344 setEntryPinned(entry, false /* isPinned */); 345 // maybe it got un sticky 346 entry.updateEntry(false /* updatePostTime */); 347 348 // when the user unpinned all of HUNs by moving one HUN, all of HUNs should not stay 349 // on the screen. 350 if (userUnPinned && entry.mEntry != null) { 351 if (entry.mEntry.mustStayOnScreen()) { 352 entry.mEntry.setHeadsUpIsVisible(); 353 } 354 } 355 } 356 } 357 358 /** 359 * Returns the value of the tracking-heads-up flag. See the doc of {@code setTrackingHeadsUp} as 360 * well. 361 */ isTrackingHeadsUp()362 public boolean isTrackingHeadsUp() { 363 // Might be implemented in subclass. 364 return false; 365 } 366 367 /** 368 * Compare two entries and decide how they should be ranked. 369 * 370 * @return -1 if the first argument should be ranked higher than the second, 1 if the second 371 * one should be ranked higher and 0 if they are equal. 372 */ compare(@ullable NotificationEntry a, @Nullable NotificationEntry b)373 public int compare(@Nullable NotificationEntry a, @Nullable NotificationEntry b) { 374 if (a == null || b == null) { 375 return Boolean.compare(a == null, b == null); 376 } 377 AlertEntry aEntry = getHeadsUpEntry(a.getKey()); 378 AlertEntry bEntry = getHeadsUpEntry(b.getKey()); 379 if (aEntry == null || bEntry == null) { 380 return Boolean.compare(aEntry == null, bEntry == null); 381 } 382 return aEntry.compareTo(bEntry); 383 } 384 385 /** 386 * Set an entry to be expanded and therefore stick in the heads up area if it's pinned 387 * until it's collapsed again. 388 */ setExpanded(@onNull NotificationEntry entry, boolean expanded)389 public void setExpanded(@NonNull NotificationEntry entry, boolean expanded) { 390 HeadsUpEntry headsUpEntry = getHeadsUpEntry(entry.getKey()); 391 if (headsUpEntry != null && entry.isRowPinned()) { 392 headsUpEntry.setExpanded(expanded); 393 } 394 } 395 396 /** 397 * Notes that the user took an action on an entry that might indirectly cause the system or the 398 * app to remove the notification. 399 * 400 * @param entry the entry that might be indirectly removed by the user's action 401 * 402 * @see com.android.systemui.statusbar.notification.collection.coordinator.HeadsUpCoordinator#mActionPressListener 403 * @see #canRemoveImmediately(String) 404 */ setUserActionMayIndirectlyRemove(@onNull NotificationEntry entry)405 public void setUserActionMayIndirectlyRemove(@NonNull NotificationEntry entry) { 406 HeadsUpEntry headsUpEntry = getHeadsUpEntry(entry.getKey()); 407 if (headsUpEntry != null) { 408 headsUpEntry.userActionMayIndirectlyRemove = true; 409 } 410 } 411 412 @Override canRemoveImmediately(@onNull String key)413 public boolean canRemoveImmediately(@NonNull String key) { 414 HeadsUpEntry headsUpEntry = getHeadsUpEntry(key); 415 if (headsUpEntry != null && headsUpEntry.userActionMayIndirectlyRemove) { 416 return true; 417 } 418 return super.canRemoveImmediately(key); 419 } 420 421 @NonNull 422 @Override createAlertEntry()423 protected HeadsUpEntry createAlertEntry() { 424 return new HeadsUpEntry(); 425 } 426 onDensityOrFontScaleChanged()427 public void onDensityOrFontScaleChanged() { 428 } 429 430 /** 431 * Determines if the notification is for a critical call that must display on top of an active 432 * input notification. 433 * The call isOngoing check is for a special case of incoming calls (see b/164291424). 434 */ isCriticalCallNotif(NotificationEntry entry)435 private static boolean isCriticalCallNotif(NotificationEntry entry) { 436 Notification n = entry.getSbn().getNotification(); 437 boolean isIncomingCall = n.isStyle(Notification.CallStyle.class) && n.extras.getInt( 438 Notification.EXTRA_CALL_TYPE) == Notification.CallStyle.CALL_TYPE_INCOMING; 439 return isIncomingCall || (entry.getSbn().isOngoing() 440 && Notification.CATEGORY_CALL.equals(n.category)); 441 } 442 443 /** 444 * This represents a notification and how long it is in a heads up mode. It also manages its 445 * lifecycle automatically when created. 446 */ 447 protected class HeadsUpEntry extends AlertEntry { 448 public boolean remoteInputActive; 449 public boolean userActionMayIndirectlyRemove; 450 451 protected boolean expanded; 452 protected boolean wasUnpinned; 453 454 @Override isSticky()455 public boolean isSticky() { 456 return (mEntry.isRowPinned() && expanded) 457 || remoteInputActive 458 || hasFullScreenIntent(mEntry); 459 } 460 461 @Override isStickyForSomeTime()462 public boolean isStickyForSomeTime() { 463 return mEntry.isStickyAndNotDemoted(); 464 } 465 466 @Override compareTo(@onNull AlertEntry alertEntry)467 public int compareTo(@NonNull AlertEntry alertEntry) { 468 HeadsUpEntry headsUpEntry = (HeadsUpEntry) alertEntry; 469 boolean isPinned = mEntry.isRowPinned(); 470 boolean otherPinned = headsUpEntry.mEntry.isRowPinned(); 471 if (isPinned && !otherPinned) { 472 return -1; 473 } else if (!isPinned && otherPinned) { 474 return 1; 475 } 476 boolean selfFullscreen = hasFullScreenIntent(mEntry); 477 boolean otherFullscreen = hasFullScreenIntent(headsUpEntry.mEntry); 478 if (selfFullscreen && !otherFullscreen) { 479 return -1; 480 } else if (!selfFullscreen && otherFullscreen) { 481 return 1; 482 } 483 484 boolean selfCall = isCriticalCallNotif(mEntry); 485 boolean otherCall = isCriticalCallNotif(headsUpEntry.mEntry); 486 487 if (selfCall && !otherCall) { 488 return -1; 489 } else if (!selfCall && otherCall) { 490 return 1; 491 } 492 493 if (remoteInputActive && !headsUpEntry.remoteInputActive) { 494 return -1; 495 } else if (!remoteInputActive && headsUpEntry.remoteInputActive) { 496 return 1; 497 } 498 499 return super.compareTo(headsUpEntry); 500 } 501 setExpanded(boolean expanded)502 public void setExpanded(boolean expanded) { 503 this.expanded = expanded; 504 } 505 506 @Override reset()507 public void reset() { 508 super.reset(); 509 expanded = false; 510 remoteInputActive = false; 511 } 512 513 @Override calculatePostTime()514 protected long calculatePostTime() { 515 // The actual post time will be just after the heads-up really slided in 516 return super.calculatePostTime() + mTouchAcceptanceDelay; 517 } 518 519 /** 520 * @return When the notification should auto-dismiss itself, based on 521 * {@link SystemClock#elapsedRealTime()} 522 */ 523 @Override calculateFinishTime()524 protected long calculateFinishTime() { 525 final long duration = getRecommendedHeadsUpTimeoutMs( 526 isStickyForSomeTime() ? mStickyDisplayTime : mAutoDismissNotificationDecay); 527 528 return mPostTime + duration; 529 } 530 531 /** 532 * Get user-preferred or default timeout duration. The larger one will be returned. 533 * @return milliseconds before auto-dismiss 534 * @param requestedTimeout 535 */ getRecommendedHeadsUpTimeoutMs(int requestedTimeout)536 protected int getRecommendedHeadsUpTimeoutMs(int requestedTimeout) { 537 return mAccessibilityMgr.getRecommendedTimeoutMillis( 538 requestedTimeout, 539 AccessibilityManager.FLAG_CONTENT_CONTROLS 540 | AccessibilityManager.FLAG_CONTENT_ICONS 541 | AccessibilityManager.FLAG_CONTENT_TEXT); 542 } 543 } 544 } 545