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 android.annotation.NonNull; 20 import android.annotation.Nullable; 21 import android.content.Context; 22 import android.content.res.Resources; 23 import android.database.ContentObserver; 24 import android.os.SystemClock; 25 import android.os.Handler; 26 import android.os.Looper; 27 import android.util.ArrayMap; 28 import android.provider.Settings; 29 import android.util.Log; 30 import android.view.accessibility.AccessibilityEvent; 31 32 import com.android.internal.logging.MetricsLogger; 33 import com.android.systemui.R; 34 import com.android.systemui.statusbar.ExpandableNotificationRow; 35 import com.android.systemui.statusbar.NotificationData; 36 37 import java.io.FileDescriptor; 38 import java.io.PrintWriter; 39 import java.util.Iterator; 40 import java.util.stream.Stream; 41 import java.util.HashMap; 42 import java.util.HashSet; 43 44 /** 45 * A manager which handles heads up notifications which is a special mode where 46 * they simply peek from the top of the screen. 47 */ 48 public class HeadsUpManager { 49 private static final String TAG = "HeadsUpManager"; 50 private static final boolean DEBUG = false; 51 private static final String SETTING_HEADS_UP_SNOOZE_LENGTH_MS = "heads_up_snooze_length_ms"; 52 53 protected final Clock mClock = new Clock(); 54 protected final HashSet<OnHeadsUpChangedListener> mListeners = new HashSet<>(); 55 protected final Handler mHandler = new Handler(Looper.getMainLooper()); 56 57 protected final Context mContext; 58 59 protected int mHeadsUpNotificationDecay; 60 protected int mMinimumDisplayTime; 61 protected int mTouchAcceptanceDelay; 62 protected int mSnoozeLengthMs; 63 protected boolean mHasPinnedNotification; 64 protected int mUser; 65 66 private final HashMap<String, HeadsUpEntry> mHeadsUpEntries = new HashMap<>(); 67 private final ArrayMap<String, Long> mSnoozedPackages; 68 HeadsUpManager(@onNull final Context context)69 public HeadsUpManager(@NonNull final Context context) { 70 mContext = context; 71 Resources resources = context.getResources(); 72 mMinimumDisplayTime = resources.getInteger(R.integer.heads_up_notification_minimum_time); 73 mHeadsUpNotificationDecay = resources.getInteger(R.integer.heads_up_notification_decay); 74 mTouchAcceptanceDelay = resources.getInteger(R.integer.touch_acceptance_delay); 75 mSnoozedPackages = new ArrayMap<>(); 76 int defaultSnoozeLengthMs = 77 resources.getInteger(R.integer.heads_up_default_snooze_length_ms); 78 79 mSnoozeLengthMs = Settings.Global.getInt(context.getContentResolver(), 80 SETTING_HEADS_UP_SNOOZE_LENGTH_MS, defaultSnoozeLengthMs); 81 ContentObserver settingsObserver = new ContentObserver(mHandler) { 82 @Override 83 public void onChange(boolean selfChange) { 84 final int packageSnoozeLengthMs = Settings.Global.getInt( 85 context.getContentResolver(), SETTING_HEADS_UP_SNOOZE_LENGTH_MS, -1); 86 if (packageSnoozeLengthMs > -1 && packageSnoozeLengthMs != mSnoozeLengthMs) { 87 mSnoozeLengthMs = packageSnoozeLengthMs; 88 if (DEBUG) Log.v(TAG, "mSnoozeLengthMs = " + mSnoozeLengthMs); 89 } 90 } 91 }; 92 context.getContentResolver().registerContentObserver( 93 Settings.Global.getUriFor(SETTING_HEADS_UP_SNOOZE_LENGTH_MS), false, 94 settingsObserver); 95 } 96 97 /** 98 * Adds an OnHeadUpChangedListener to observe events. 99 */ addListener(@onNull OnHeadsUpChangedListener listener)100 public void addListener(@NonNull OnHeadsUpChangedListener listener) { 101 mListeners.add(listener); 102 } 103 104 /** 105 * Removes the OnHeadUpChangedListener from the observer list. 106 */ removeListener(@onNull OnHeadsUpChangedListener listener)107 public void removeListener(@NonNull OnHeadsUpChangedListener listener) { 108 mListeners.remove(listener); 109 } 110 111 /** 112 * Called when posting a new notification to the heads up. 113 */ showNotification(@onNull NotificationData.Entry headsUp)114 public void showNotification(@NonNull NotificationData.Entry headsUp) { 115 if (DEBUG) Log.v(TAG, "showNotification"); 116 addHeadsUpEntry(headsUp); 117 updateNotification(headsUp, true); 118 headsUp.setInterruption(); 119 } 120 121 /** 122 * Called when updating or posting a notification to the heads up. 123 */ updateNotification(@onNull NotificationData.Entry headsUp, boolean alert)124 public void updateNotification(@NonNull NotificationData.Entry headsUp, boolean alert) { 125 if (DEBUG) Log.v(TAG, "updateNotification"); 126 127 headsUp.row.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); 128 129 if (alert) { 130 HeadsUpEntry headsUpEntry = mHeadsUpEntries.get(headsUp.key); 131 if (headsUpEntry == null) { 132 // the entry was released before this update (i.e by a listener) This can happen 133 // with the groupmanager 134 return; 135 } 136 headsUpEntry.updateEntry(true /* updatePostTime */); 137 setEntryPinned(headsUpEntry, shouldHeadsUpBecomePinned(headsUp)); 138 } 139 } 140 addHeadsUpEntry(@onNull NotificationData.Entry entry)141 private void addHeadsUpEntry(@NonNull NotificationData.Entry entry) { 142 HeadsUpEntry headsUpEntry = createHeadsUpEntry(); 143 // This will also add the entry to the sortedList 144 headsUpEntry.setEntry(entry); 145 mHeadsUpEntries.put(entry.key, headsUpEntry); 146 entry.row.setHeadsUp(true); 147 setEntryPinned(headsUpEntry, shouldHeadsUpBecomePinned(entry)); 148 for (OnHeadsUpChangedListener listener : mListeners) { 149 listener.onHeadsUpStateChanged(entry, true); 150 } 151 entry.row.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); 152 } 153 shouldHeadsUpBecomePinned(@onNull NotificationData.Entry entry)154 protected boolean shouldHeadsUpBecomePinned(@NonNull NotificationData.Entry entry) { 155 return hasFullScreenIntent(entry); 156 } 157 hasFullScreenIntent(@onNull NotificationData.Entry entry)158 protected boolean hasFullScreenIntent(@NonNull NotificationData.Entry entry) { 159 return entry.notification.getNotification().fullScreenIntent != null; 160 } 161 setEntryPinned( @onNull HeadsUpManager.HeadsUpEntry headsUpEntry, boolean isPinned)162 protected void setEntryPinned( 163 @NonNull HeadsUpManager.HeadsUpEntry headsUpEntry, boolean isPinned) { 164 if (DEBUG) Log.v(TAG, "setEntryPinned: " + isPinned); 165 ExpandableNotificationRow row = headsUpEntry.entry.row; 166 if (row.isPinned() != isPinned) { 167 row.setPinned(isPinned); 168 updatePinnedMode(); 169 for (OnHeadsUpChangedListener listener : mListeners) { 170 if (isPinned) { 171 listener.onHeadsUpPinned(row); 172 } else { 173 listener.onHeadsUpUnPinned(row); 174 } 175 } 176 } 177 } 178 removeHeadsUpEntry(@onNull NotificationData.Entry entry)179 protected void removeHeadsUpEntry(@NonNull NotificationData.Entry entry) { 180 HeadsUpEntry remove = mHeadsUpEntries.remove(entry.key); 181 onHeadsUpEntryRemoved(remove); 182 } 183 onHeadsUpEntryRemoved(@onNull HeadsUpEntry remove)184 protected void onHeadsUpEntryRemoved(@NonNull HeadsUpEntry remove) { 185 NotificationData.Entry entry = remove.entry; 186 entry.row.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); 187 entry.row.setHeadsUp(false); 188 setEntryPinned(remove, false /* isPinned */); 189 for (OnHeadsUpChangedListener listener : mListeners) { 190 listener.onHeadsUpStateChanged(entry, false); 191 } 192 releaseHeadsUpEntry(remove); 193 } 194 updatePinnedMode()195 protected void updatePinnedMode() { 196 boolean hasPinnedNotification = hasPinnedNotificationInternal(); 197 if (hasPinnedNotification == mHasPinnedNotification) { 198 return; 199 } 200 if (DEBUG) { 201 Log.v(TAG, "Pinned mode changed: " + mHasPinnedNotification + " -> " + 202 hasPinnedNotification); 203 } 204 mHasPinnedNotification = hasPinnedNotification; 205 if (mHasPinnedNotification) { 206 MetricsLogger.count(mContext, "note_peek", 1); 207 } 208 for (OnHeadsUpChangedListener listener : mListeners) { 209 listener.onHeadsUpPinnedModeChanged(hasPinnedNotification); 210 } 211 } 212 213 /** 214 * React to the removal of the notification in the heads up. 215 * 216 * @return true if the notification was removed and false if it still needs to be kept around 217 * for a bit since it wasn't shown long enough 218 */ removeNotification(@onNull String key, boolean ignoreEarliestRemovalTime)219 public boolean removeNotification(@NonNull String key, boolean ignoreEarliestRemovalTime) { 220 if (DEBUG) Log.v(TAG, "removeNotification"); 221 releaseImmediately(key); 222 return true; 223 } 224 225 /** 226 * Returns if the given notification is in the Heads Up Notification list or not. 227 */ isHeadsUp(@onNull String key)228 public boolean isHeadsUp(@NonNull String key) { 229 return mHeadsUpEntries.containsKey(key); 230 } 231 232 /** 233 * Pushes any current Heads Up notification down into the shade. 234 */ releaseAllImmediately()235 public void releaseAllImmediately() { 236 if (DEBUG) Log.v(TAG, "releaseAllImmediately"); 237 Iterator<HeadsUpEntry> iterator = mHeadsUpEntries.values().iterator(); 238 while (iterator.hasNext()) { 239 HeadsUpEntry entry = iterator.next(); 240 iterator.remove(); 241 onHeadsUpEntryRemoved(entry); 242 } 243 } 244 245 /** 246 * Pushes the given Heads Up notification down into the shade. 247 */ releaseImmediately(@onNull String key)248 public void releaseImmediately(@NonNull String key) { 249 HeadsUpEntry headsUpEntry = getHeadsUpEntry(key); 250 if (headsUpEntry == null) { 251 return; 252 } 253 NotificationData.Entry shadeEntry = headsUpEntry.entry; 254 removeHeadsUpEntry(shadeEntry); 255 } 256 257 /** 258 * Returns if the given notification is snoozed or not. 259 */ isSnoozed(@onNull String packageName)260 public boolean isSnoozed(@NonNull String packageName) { 261 final String key = snoozeKey(packageName, mUser); 262 Long snoozedUntil = mSnoozedPackages.get(key); 263 if (snoozedUntil != null) { 264 if (snoozedUntil > mClock.currentTimeMillis()) { 265 if (DEBUG) Log.v(TAG, key + " snoozed"); 266 return true; 267 } 268 mSnoozedPackages.remove(packageName); 269 } 270 return false; 271 } 272 273 /** 274 * Snoozes all current Heads Up Notifications. 275 */ snooze()276 public void snooze() { 277 for (String key : mHeadsUpEntries.keySet()) { 278 HeadsUpEntry entry = mHeadsUpEntries.get(key); 279 String packageName = entry.entry.notification.getPackageName(); 280 mSnoozedPackages.put(snoozeKey(packageName, mUser), 281 mClock.currentTimeMillis() + mSnoozeLengthMs); 282 } 283 } 284 285 @NonNull snoozeKey(@onNull String packageName, int user)286 private static String snoozeKey(@NonNull String packageName, int user) { 287 return user + "," + packageName; 288 } 289 290 @Nullable getHeadsUpEntry(@onNull String key)291 protected HeadsUpEntry getHeadsUpEntry(@NonNull String key) { 292 return mHeadsUpEntries.get(key); 293 } 294 295 /** 296 * Returns the entry of given Heads Up Notification. 297 * 298 * @param key Key of heads up notification 299 */ 300 @Nullable getEntry(@onNull String key)301 public NotificationData.Entry getEntry(@NonNull String key) { 302 HeadsUpEntry entry = mHeadsUpEntries.get(key); 303 return entry != null ? entry.entry : null; 304 } 305 306 /** 307 * Returns the stream of all current Heads Up Notifications. 308 */ 309 @NonNull getAllEntries()310 public Stream<NotificationData.Entry> getAllEntries() { 311 return mHeadsUpEntries.values().stream().map(headsUpEntry -> headsUpEntry.entry); 312 } 313 314 /** 315 * Returns the top Heads Up Notification, which appeares to show at first. 316 */ 317 @Nullable getTopEntry()318 public NotificationData.Entry getTopEntry() { 319 HeadsUpEntry topEntry = getTopHeadsUpEntry(); 320 return (topEntry != null) ? topEntry.entry : null; 321 } 322 323 /** 324 * Returns if any heads up notification is available or not. 325 */ hasHeadsUpNotifications()326 public boolean hasHeadsUpNotifications() { 327 return !mHeadsUpEntries.isEmpty(); 328 } 329 330 @Nullable getTopHeadsUpEntry()331 protected HeadsUpEntry getTopHeadsUpEntry() { 332 if (mHeadsUpEntries.isEmpty()) { 333 return null; 334 } 335 HeadsUpEntry topEntry = null; 336 for (HeadsUpEntry entry: mHeadsUpEntries.values()) { 337 if (topEntry == null || entry.compareTo(topEntry) < 0) { 338 topEntry = entry; 339 } 340 } 341 return topEntry; 342 } 343 344 /** 345 * Sets the current user. 346 */ setUser(int user)347 public void setUser(int user) { 348 mUser = user; 349 } 350 dump(@onNull FileDescriptor fd, @NonNull PrintWriter pw, @NonNull String[] args)351 public void dump(@NonNull FileDescriptor fd, @NonNull PrintWriter pw, @NonNull String[] args) { 352 pw.println("HeadsUpManager state:"); 353 dumpInternal(fd, pw, args); 354 } 355 dumpInternal( @onNull FileDescriptor fd, @NonNull PrintWriter pw, @NonNull String[] args)356 protected void dumpInternal( 357 @NonNull FileDescriptor fd, @NonNull PrintWriter pw, @NonNull String[] args) { 358 pw.print(" mTouchAcceptanceDelay="); pw.println(mTouchAcceptanceDelay); 359 pw.print(" mSnoozeLengthMs="); pw.println(mSnoozeLengthMs); 360 pw.print(" now="); pw.println(mClock.currentTimeMillis()); 361 pw.print(" mUser="); pw.println(mUser); 362 for (HeadsUpEntry entry: mHeadsUpEntries.values()) { 363 pw.print(" HeadsUpEntry="); pw.println(entry.entry); 364 } 365 int N = mSnoozedPackages.size(); 366 pw.println(" snoozed packages: " + N); 367 for (int i = 0; i < N; i++) { 368 pw.print(" "); pw.print(mSnoozedPackages.valueAt(i)); 369 pw.print(", "); pw.println(mSnoozedPackages.keyAt(i)); 370 } 371 } 372 373 /** 374 * Returns if there are any pinned Heads Up Notifications or not. 375 */ hasPinnedHeadsUp()376 public boolean hasPinnedHeadsUp() { 377 return mHasPinnedNotification; 378 } 379 hasPinnedNotificationInternal()380 private boolean hasPinnedNotificationInternal() { 381 for (String key : mHeadsUpEntries.keySet()) { 382 HeadsUpEntry entry = mHeadsUpEntries.get(key); 383 if (entry.entry.row.isPinned()) { 384 return true; 385 } 386 } 387 return false; 388 } 389 390 /** 391 * Unpins all pinned Heads Up Notifications. 392 */ unpinAll()393 public void unpinAll() { 394 for (String key : mHeadsUpEntries.keySet()) { 395 HeadsUpEntry entry = mHeadsUpEntries.get(key); 396 setEntryPinned(entry, false /* isPinned */); 397 // maybe it got un sticky 398 entry.updateEntry(false /* updatePostTime */); 399 } 400 } 401 402 /** 403 * Returns the value of the tracking-heads-up flag. See the doc of {@code setTrackingHeadsUp} as 404 * well. 405 */ isTrackingHeadsUp()406 public boolean isTrackingHeadsUp() { 407 // Might be implemented in subclass. 408 return false; 409 } 410 411 /** 412 * Compare two entries and decide how they should be ranked. 413 * 414 * @return -1 if the first argument should be ranked higher than the second, 1 if the second 415 * one should be ranked higher and 0 if they are equal. 416 */ compare(@onNull NotificationData.Entry a, @NonNull NotificationData.Entry b)417 public int compare(@NonNull NotificationData.Entry a, @NonNull NotificationData.Entry b) { 418 HeadsUpEntry aEntry = getHeadsUpEntry(a.key); 419 HeadsUpEntry bEntry = getHeadsUpEntry(b.key); 420 if (aEntry == null || bEntry == null) { 421 return aEntry == null ? 1 : -1; 422 } 423 return aEntry.compareTo(bEntry); 424 } 425 426 /** 427 * Set an entry to be expanded and therefore stick in the heads up area if it's pinned 428 * until it's collapsed again. 429 */ setExpanded(@onNull NotificationData.Entry entry, boolean expanded)430 public void setExpanded(@NonNull NotificationData.Entry entry, boolean expanded) { 431 HeadsUpManager.HeadsUpEntry headsUpEntry = mHeadsUpEntries.get(entry.key); 432 if (headsUpEntry != null && entry.row.isPinned()) { 433 headsUpEntry.expanded(expanded); 434 } 435 } 436 437 @NonNull createHeadsUpEntry()438 protected HeadsUpEntry createHeadsUpEntry() { 439 return new HeadsUpEntry(); 440 } 441 releaseHeadsUpEntry(@onNull HeadsUpEntry entry)442 protected void releaseHeadsUpEntry(@NonNull HeadsUpEntry entry) { 443 entry.reset(); 444 } 445 onDensityOrFontScaleChanged()446 public void onDensityOrFontScaleChanged() { 447 } 448 449 /** 450 * This represents a notification and how long it is in a heads up mode. It also manages its 451 * lifecycle automatically when created. 452 */ 453 protected class HeadsUpEntry implements Comparable<HeadsUpEntry> { 454 @Nullable public NotificationData.Entry entry; 455 public long postTime; 456 public boolean remoteInputActive; 457 public long earliestRemovaltime; 458 public boolean expanded; 459 460 @Nullable private Runnable mRemoveHeadsUpRunnable; 461 setEntry(@ullable final NotificationData.Entry entry)462 public void setEntry(@Nullable final NotificationData.Entry entry) { 463 setEntry(entry, null); 464 } 465 setEntry(@ullable final NotificationData.Entry entry, @Nullable Runnable removeHeadsUpRunnable)466 public void setEntry(@Nullable final NotificationData.Entry entry, 467 @Nullable Runnable removeHeadsUpRunnable) { 468 this.entry = entry; 469 this.mRemoveHeadsUpRunnable = removeHeadsUpRunnable; 470 471 // The actual post time will be just after the heads-up really slided in 472 postTime = mClock.currentTimeMillis() + mTouchAcceptanceDelay; 473 updateEntry(true /* updatePostTime */); 474 } 475 updateEntry(boolean updatePostTime)476 public void updateEntry(boolean updatePostTime) { 477 if (DEBUG) Log.v(TAG, "updateEntry"); 478 479 long currentTime = mClock.currentTimeMillis(); 480 earliestRemovaltime = currentTime + mMinimumDisplayTime; 481 if (updatePostTime) { 482 postTime = Math.max(postTime, currentTime); 483 } 484 removeAutoRemovalCallbacks(); 485 486 if (!isSticky()) { 487 long finishTime = postTime + mHeadsUpNotificationDecay; 488 long removeDelay = Math.max(finishTime - currentTime, mMinimumDisplayTime); 489 mHandler.postDelayed(mRemoveHeadsUpRunnable, removeDelay); 490 } 491 } 492 isSticky()493 private boolean isSticky() { 494 return (entry.row.isPinned() && expanded) 495 || remoteInputActive || hasFullScreenIntent(entry); 496 } 497 498 @Override compareTo(@onNull HeadsUpEntry o)499 public int compareTo(@NonNull HeadsUpEntry o) { 500 boolean isPinned = entry.row.isPinned(); 501 boolean otherPinned = o.entry.row.isPinned(); 502 if (isPinned && !otherPinned) { 503 return -1; 504 } else if (!isPinned && otherPinned) { 505 return 1; 506 } 507 boolean selfFullscreen = hasFullScreenIntent(entry); 508 boolean otherFullscreen = hasFullScreenIntent(o.entry); 509 if (selfFullscreen && !otherFullscreen) { 510 return -1; 511 } else if (!selfFullscreen && otherFullscreen) { 512 return 1; 513 } 514 515 if (remoteInputActive && !o.remoteInputActive) { 516 return -1; 517 } else if (!remoteInputActive && o.remoteInputActive) { 518 return 1; 519 } 520 521 return postTime < o.postTime ? 1 522 : postTime == o.postTime ? entry.key.compareTo(o.entry.key) 523 : -1; 524 } 525 expanded(boolean expanded)526 public void expanded(boolean expanded) { 527 this.expanded = expanded; 528 } 529 reset()530 public void reset() { 531 entry = null; 532 expanded = false; 533 remoteInputActive = false; 534 removeAutoRemovalCallbacks(); 535 mRemoveHeadsUpRunnable = null; 536 } 537 removeAutoRemovalCallbacks()538 public void removeAutoRemovalCallbacks() { 539 if (mRemoveHeadsUpRunnable != null) 540 mHandler.removeCallbacks(mRemoveHeadsUpRunnable); 541 } 542 removeAsSoonAsPossible()543 public void removeAsSoonAsPossible() { 544 if (mRemoveHeadsUpRunnable != null) { 545 removeAutoRemovalCallbacks(); 546 mHandler.postDelayed(mRemoveHeadsUpRunnable, 547 earliestRemovaltime - mClock.currentTimeMillis()); 548 } 549 } 550 } 551 552 public static class Clock { currentTimeMillis()553 public long currentTimeMillis() { 554 return SystemClock.elapsedRealtime(); 555 } 556 } 557 } 558