HeadsUpManager.java revision acd0df65dd8be97aae5617c9a8346d4a4ab88abd
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 17package com.android.systemui.statusbar.policy; 18 19import android.content.Context; 20import android.content.res.Resources; 21import android.database.ContentObserver; 22import android.os.Handler; 23import android.os.SystemClock; 24import android.provider.Settings; 25import android.util.ArrayMap; 26import android.util.Log; 27import android.util.Pools; 28import android.view.View; 29import android.view.ViewTreeObserver; 30import android.view.accessibility.AccessibilityEvent; 31 32import com.android.internal.logging.MetricsLogger; 33import com.android.systemui.R; 34import com.android.systemui.statusbar.ExpandableNotificationRow; 35import com.android.systemui.statusbar.NotificationData; 36import com.android.systemui.statusbar.phone.PhoneStatusBar; 37 38import java.io.FileDescriptor; 39import java.io.PrintWriter; 40import java.util.ArrayList; 41import java.util.HashMap; 42import java.util.HashSet; 43import java.util.Stack; 44import java.util.TreeSet; 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 */ 50public class HeadsUpManager implements ViewTreeObserver.OnComputeInternalInsetsListener { 51 private static final String TAG = "HeadsUpManager"; 52 private static final boolean DEBUG = false; 53 private static final String SETTING_HEADS_UP_SNOOZE_LENGTH_MS = "heads_up_snooze_length_ms"; 54 55 private final int mHeadsUpNotificationDecay; 56 private final int mMinimumDisplayTime; 57 58 private final int mTouchAcceptanceDelay; 59 private final ArrayMap<String, Long> mSnoozedPackages; 60 private final HashSet<OnHeadsUpChangedListener> mListeners = new HashSet<>(); 61 private final int mDefaultSnoozeLengthMs; 62 private final Handler mHandler = new Handler(); 63 private final Pools.Pool<HeadsUpEntry> mEntryPool = new Pools.Pool<HeadsUpEntry>() { 64 65 private Stack<HeadsUpEntry> mPoolObjects = new Stack<>(); 66 67 @Override 68 public HeadsUpEntry acquire() { 69 if (!mPoolObjects.isEmpty()) { 70 return mPoolObjects.pop(); 71 } 72 return new HeadsUpEntry(); 73 } 74 75 @Override 76 public boolean release(HeadsUpEntry instance) { 77 instance.reset(); 78 mPoolObjects.push(instance); 79 return true; 80 } 81 }; 82 83 private final View mStatusBarWindowView; 84 private final int mStatusBarHeight; 85 private final int mNotificationsTopPadding; 86 private final Context mContext; 87 private PhoneStatusBar mBar; 88 private int mSnoozeLengthMs; 89 private ContentObserver mSettingsObserver; 90 private HashMap<String, HeadsUpEntry> mHeadsUpEntries = new HashMap<>(); 91 private TreeSet<HeadsUpEntry> mSortedEntries = new TreeSet<>(); 92 private HashSet<String> mSwipedOutKeys = new HashSet<>(); 93 private int mUser; 94 private Clock mClock; 95 private boolean mReleaseOnExpandFinish; 96 private boolean mTrackingHeadsUp; 97 private HashSet<NotificationData.Entry> mEntriesToRemoveAfterExpand = new HashSet<>(); 98 private boolean mIsExpanded; 99 private boolean mHasPinnedNotification; 100 private int[] mTmpTwoArray = new int[2]; 101 private boolean mHeadsUpGoingAway; 102 private boolean mWaitingOnCollapseWhenGoingAway; 103 private boolean mIsObserving; 104 105 public HeadsUpManager(final Context context, View statusBarWindowView) { 106 mContext = context; 107 Resources resources = mContext.getResources(); 108 mTouchAcceptanceDelay = resources.getInteger(R.integer.touch_acceptance_delay); 109 mSnoozedPackages = new ArrayMap<>(); 110 mDefaultSnoozeLengthMs = resources.getInteger(R.integer.heads_up_default_snooze_length_ms); 111 mSnoozeLengthMs = mDefaultSnoozeLengthMs; 112 mMinimumDisplayTime = resources.getInteger(R.integer.heads_up_notification_minimum_time); 113 mHeadsUpNotificationDecay = resources.getInteger(R.integer.heads_up_notification_decay); 114 mClock = new Clock(); 115 116 mSnoozeLengthMs = Settings.Global.getInt(context.getContentResolver(), 117 SETTING_HEADS_UP_SNOOZE_LENGTH_MS, mDefaultSnoozeLengthMs); 118 mSettingsObserver = new ContentObserver(mHandler) { 119 @Override 120 public void onChange(boolean selfChange) { 121 final int packageSnoozeLengthMs = Settings.Global.getInt( 122 context.getContentResolver(), SETTING_HEADS_UP_SNOOZE_LENGTH_MS, -1); 123 if (packageSnoozeLengthMs > -1 && packageSnoozeLengthMs != mSnoozeLengthMs) { 124 mSnoozeLengthMs = packageSnoozeLengthMs; 125 if (DEBUG) Log.v(TAG, "mSnoozeLengthMs = " + mSnoozeLengthMs); 126 } 127 } 128 }; 129 context.getContentResolver().registerContentObserver( 130 Settings.Global.getUriFor(SETTING_HEADS_UP_SNOOZE_LENGTH_MS), false, 131 mSettingsObserver); 132 mStatusBarWindowView = statusBarWindowView; 133 mStatusBarHeight = resources.getDimensionPixelSize( 134 com.android.internal.R.dimen.status_bar_height); 135 mNotificationsTopPadding = context.getResources() 136 .getDimensionPixelSize(R.dimen.notifications_top_padding); 137 } 138 139 private void updateTouchableRegionListener() { 140 boolean shouldObserve = mHasPinnedNotification || mHeadsUpGoingAway 141 || mWaitingOnCollapseWhenGoingAway; 142 if (shouldObserve == mIsObserving) { 143 return; 144 } 145 if (shouldObserve) { 146 mStatusBarWindowView.getViewTreeObserver().addOnComputeInternalInsetsListener(this); 147 mStatusBarWindowView.requestLayout(); 148 } else { 149 mStatusBarWindowView.getViewTreeObserver().removeOnComputeInternalInsetsListener(this); 150 } 151 mIsObserving = shouldObserve; 152 } 153 154 public void setBar(PhoneStatusBar bar) { 155 mBar = bar; 156 } 157 158 public void addListener(OnHeadsUpChangedListener listener) { 159 mListeners.add(listener); 160 } 161 162 public PhoneStatusBar getBar() { 163 return mBar; 164 } 165 166 /** 167 * Called when posting a new notification to the heads up. 168 */ 169 public void showNotification(NotificationData.Entry headsUp) { 170 if (DEBUG) Log.v(TAG, "showNotification"); 171 MetricsLogger.count(mContext, "note_peek", 1); 172 addHeadsUpEntry(headsUp); 173 updateNotification(headsUp, true); 174 headsUp.setInterruption(); 175 } 176 177 /** 178 * Called when updating or posting a notification to the heads up. 179 */ 180 public void updateNotification(NotificationData.Entry headsUp, boolean alert) { 181 if (DEBUG) Log.v(TAG, "updateNotification"); 182 183 headsUp.row.setChildrenExpanded(false /* expanded */, false /* animated */); 184 headsUp.row.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); 185 186 if (alert) { 187 HeadsUpEntry headsUpEntry = mHeadsUpEntries.get(headsUp.key); 188 headsUpEntry.updateEntry(); 189 setEntryPinned(headsUpEntry, shouldHeadsUpBecomePinned(headsUp)); 190 } 191 } 192 193 private void addHeadsUpEntry(NotificationData.Entry entry) { 194 HeadsUpEntry headsUpEntry = mEntryPool.acquire(); 195 196 // This will also add the entry to the sortedList 197 headsUpEntry.setEntry(entry); 198 mHeadsUpEntries.put(entry.key, headsUpEntry); 199 entry.row.setHeadsUp(true); 200 setEntryPinned(headsUpEntry, shouldHeadsUpBecomePinned(entry)); 201 for (OnHeadsUpChangedListener listener : mListeners) { 202 listener.onHeadsUpStateChanged(entry, true); 203 } 204 entry.row.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); 205 } 206 207 private boolean shouldHeadsUpBecomePinned(NotificationData.Entry entry) { 208 return !mIsExpanded || hasFullScreenIntent(entry); 209 } 210 211 private boolean hasFullScreenIntent(NotificationData.Entry entry) { 212 return entry.notification.getNotification().fullScreenIntent != null; 213 } 214 215 private void setEntryPinned(HeadsUpEntry headsUpEntry, boolean isPinned) { 216 ExpandableNotificationRow row = headsUpEntry.entry.row; 217 if (row.isPinned() != isPinned) { 218 row.setPinned(isPinned); 219 updatePinnedMode(); 220 for (OnHeadsUpChangedListener listener : mListeners) { 221 if (isPinned) { 222 listener.onHeadsUpPinned(row); 223 } else { 224 listener.onHeadsUpUnPinned(row); 225 } 226 } 227 } 228 } 229 230 private void removeHeadsUpEntry(NotificationData.Entry entry) { 231 HeadsUpEntry remove = mHeadsUpEntries.remove(entry.key); 232 mSortedEntries.remove(remove); 233 entry.row.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); 234 entry.row.setHeadsUp(false); 235 setEntryPinned(remove, false /* isPinned */); 236 for (OnHeadsUpChangedListener listener : mListeners) { 237 listener.onHeadsUpStateChanged(entry, false); 238 } 239 mEntryPool.release(remove); 240 } 241 242 private void updatePinnedMode() { 243 boolean hasPinnedNotification = hasPinnedNotificationInternal(); 244 if (hasPinnedNotification == mHasPinnedNotification) { 245 return; 246 } 247 mHasPinnedNotification = hasPinnedNotification; 248 updateTouchableRegionListener(); 249 for (OnHeadsUpChangedListener listener : mListeners) { 250 listener.onHeadsUpPinnedModeChanged(hasPinnedNotification); 251 } 252 } 253 254 /** 255 * React to the removal of the notification in the heads up. 256 * 257 * @return true if the notification was removed and false if it still needs to be kept around 258 * for a bit since it wasn't shown long enough 259 */ 260 public boolean removeNotification(String key) { 261 if (DEBUG) Log.v(TAG, "remove"); 262 if (wasShownLongEnough(key)) { 263 releaseImmediately(key); 264 return true; 265 } else { 266 getHeadsUpEntry(key).removeAsSoonAsPossible(); 267 return false; 268 } 269 } 270 271 private boolean wasShownLongEnough(String key) { 272 HeadsUpEntry headsUpEntry = getHeadsUpEntry(key); 273 HeadsUpEntry topEntry = getTopEntry(); 274 if (mSwipedOutKeys.contains(key)) { 275 // We always instantly dismiss views being manually swiped out. 276 mSwipedOutKeys.remove(key); 277 return true; 278 } 279 if (headsUpEntry != topEntry) { 280 return true; 281 } 282 return headsUpEntry.wasShownLongEnough(); 283 } 284 285 public boolean isHeadsUp(String key) { 286 return mHeadsUpEntries.containsKey(key); 287 } 288 289 /** 290 * Push any current Heads Up notification down into the shade. 291 */ 292 public void releaseAllImmediately() { 293 if (DEBUG) Log.v(TAG, "releaseAllImmediately"); 294 ArrayList<String> keys = new ArrayList<>(mHeadsUpEntries.keySet()); 295 for (String key : keys) { 296 releaseImmediately(key); 297 } 298 } 299 300 public void releaseImmediately(String key) { 301 HeadsUpEntry headsUpEntry = getHeadsUpEntry(key); 302 if (headsUpEntry == null) { 303 return; 304 } 305 NotificationData.Entry shadeEntry = headsUpEntry.entry; 306 removeHeadsUpEntry(shadeEntry); 307 } 308 309 public boolean isSnoozed(String packageName) { 310 final String key = snoozeKey(packageName, mUser); 311 Long snoozedUntil = mSnoozedPackages.get(key); 312 if (snoozedUntil != null) { 313 if (snoozedUntil > SystemClock.elapsedRealtime()) { 314 if (DEBUG) Log.v(TAG, key + " snoozed"); 315 return true; 316 } 317 mSnoozedPackages.remove(packageName); 318 } 319 return false; 320 } 321 322 public void snooze() { 323 for (String key : mHeadsUpEntries.keySet()) { 324 HeadsUpEntry entry = mHeadsUpEntries.get(key); 325 String packageName = entry.entry.notification.getPackageName(); 326 mSnoozedPackages.put(snoozeKey(packageName, mUser), 327 SystemClock.elapsedRealtime() + mSnoozeLengthMs); 328 } 329 mReleaseOnExpandFinish = true; 330 } 331 332 private static String snoozeKey(String packageName, int user) { 333 return user + "," + packageName; 334 } 335 336 private HeadsUpEntry getHeadsUpEntry(String key) { 337 return mHeadsUpEntries.get(key); 338 } 339 340 public NotificationData.Entry getEntry(String key) { 341 return mHeadsUpEntries.get(key).entry; 342 } 343 344 public TreeSet<HeadsUpEntry> getSortedEntries() { 345 return mSortedEntries; 346 } 347 348 public HeadsUpEntry getTopEntry() { 349 return mSortedEntries.isEmpty() ? null : mSortedEntries.first(); 350 } 351 352 /** 353 * Decides whether a click is invalid for a notification, i.e it has not been shown long enough 354 * that a user might have consciously clicked on it. 355 * 356 * @param key the key of the touched notification 357 * @return whether the touch is invalid and should be discarded 358 */ 359 public boolean shouldSwallowClick(String key) { 360 HeadsUpEntry entry = mHeadsUpEntries.get(key); 361 if (entry != null && mClock.currentTimeMillis() < entry.postTime) { 362 return true; 363 } 364 return false; 365 } 366 367 public void onComputeInternalInsets(ViewTreeObserver.InternalInsetsInfo info) { 368 if (mIsExpanded) { 369 // The touchable region is always the full area when expanded 370 return; 371 } 372 if (mHasPinnedNotification) { 373 int minX = Integer.MAX_VALUE; 374 int maxX = 0; 375 int minY = Integer.MAX_VALUE; 376 int maxY = 0; 377 for (HeadsUpEntry entry : mSortedEntries) { 378 ExpandableNotificationRow row = entry.entry.row; 379 if (row.isPinned()) { 380 row.getLocationOnScreen(mTmpTwoArray); 381 minX = Math.min(minX, mTmpTwoArray[0]); 382 minY = Math.min(minY, 0); 383 maxX = Math.max(maxX, mTmpTwoArray[0] + row.getWidth()); 384 maxY = Math.max(maxY, row.getHeadsUpHeight()); 385 } 386 } 387 388 info.setTouchableInsets(ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION); 389 info.touchableRegion.set(minX, minY, maxX, maxY + mNotificationsTopPadding); 390 } else if (mHeadsUpGoingAway || mWaitingOnCollapseWhenGoingAway) { 391 info.setTouchableInsets(ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION); 392 info.touchableRegion.set(0, 0, mStatusBarWindowView.getWidth(), mStatusBarHeight); 393 } 394 } 395 396 public void setUser(int user) { 397 mUser = user; 398 } 399 400 public void dump(FileDescriptor fd, PrintWriter pw, String[] args) { 401 pw.println("HeadsUpManager state:"); 402 pw.print(" mTouchAcceptanceDelay="); pw.println(mTouchAcceptanceDelay); 403 pw.print(" mSnoozeLengthMs="); pw.println(mSnoozeLengthMs); 404 pw.print(" now="); pw.println(SystemClock.elapsedRealtime()); 405 pw.print(" mUser="); pw.println(mUser); 406 for (HeadsUpEntry entry: mSortedEntries) { 407 pw.print(" HeadsUpEntry="); pw.println(entry.entry); 408 } 409 int N = mSnoozedPackages.size(); 410 pw.println(" snoozed packages: " + N); 411 for (int i = 0; i < N; i++) { 412 pw.print(" "); pw.print(mSnoozedPackages.valueAt(i)); 413 pw.print(", "); pw.println(mSnoozedPackages.keyAt(i)); 414 } 415 } 416 417 public boolean hasPinnedHeadsUp() { 418 return mHasPinnedNotification; 419 } 420 421 private boolean hasPinnedNotificationInternal() { 422 for (String key : mHeadsUpEntries.keySet()) { 423 HeadsUpEntry entry = mHeadsUpEntries.get(key); 424 if (entry.entry.row.isPinned()) { 425 return true; 426 } 427 } 428 return false; 429 } 430 431 /** 432 * Notifies that a notification was swiped out and will be removed. 433 * 434 * @param key the notification key 435 */ 436 public void addSwipedOutNotification(String key) { 437 mSwipedOutKeys.add(key); 438 } 439 440 public void unpinAll() { 441 for (String key : mHeadsUpEntries.keySet()) { 442 HeadsUpEntry entry = mHeadsUpEntries.get(key); 443 setEntryPinned(entry, false /* isPinned */); 444 } 445 } 446 447 public void onExpandingFinished() { 448 if (mReleaseOnExpandFinish) { 449 releaseAllImmediately(); 450 mReleaseOnExpandFinish = false; 451 } else { 452 for (NotificationData.Entry entry : mEntriesToRemoveAfterExpand) { 453 removeHeadsUpEntry(entry); 454 } 455 } 456 mEntriesToRemoveAfterExpand.clear(); 457 } 458 459 public void setTrackingHeadsUp(boolean trackingHeadsUp) { 460 mTrackingHeadsUp = trackingHeadsUp; 461 } 462 463 public void setIsExpanded(boolean isExpanded) { 464 if (isExpanded != mIsExpanded) { 465 mIsExpanded = isExpanded; 466 if (isExpanded) { 467 // make sure our state is sane 468 mWaitingOnCollapseWhenGoingAway = false; 469 mHeadsUpGoingAway = false; 470 updateTouchableRegionListener(); 471 } 472 } 473 } 474 475 public int getTopHeadsUpHeight() { 476 HeadsUpEntry topEntry = getTopEntry(); 477 return topEntry != null ? topEntry.entry.row.getHeadsUpHeight() : 0; 478 } 479 480 /** 481 * Compare two entries and decide how they should be ranked. 482 * 483 * @return -1 if the first argument should be ranked higher than the second, 1 if the second 484 * one should be ranked higher and 0 if they are equal. 485 */ 486 public int compare(NotificationData.Entry a, NotificationData.Entry b) { 487 HeadsUpEntry aEntry = getHeadsUpEntry(a.key); 488 HeadsUpEntry bEntry = getHeadsUpEntry(b.key); 489 if (aEntry == null || bEntry == null) { 490 return aEntry == null ? 1 : -1; 491 } 492 return aEntry.compareTo(bEntry); 493 } 494 495 /** 496 * Set that we are exiting the headsUp pinned mode, but some notifications might still be 497 * animating out. This is used to keep the touchable regions in a sane state. 498 */ 499 public void setHeadsUpGoingAway(boolean headsUpGoingAway) { 500 if (headsUpGoingAway != mHeadsUpGoingAway) { 501 mHeadsUpGoingAway = headsUpGoingAway; 502 if (!headsUpGoingAway) { 503 waitForStatusBarLayout(); 504 } 505 updateTouchableRegionListener(); 506 } 507 } 508 509 /** 510 * We need to wait on the whole panel to collapse, before we can remove the touchable region 511 * listener. 512 */ 513 private void waitForStatusBarLayout() { 514 mWaitingOnCollapseWhenGoingAway = true; 515 mStatusBarWindowView.addOnLayoutChangeListener(new View.OnLayoutChangeListener() { 516 @Override 517 public void onLayoutChange(View v, int left, int top, int right, int bottom, 518 int oldLeft, 519 int oldTop, int oldRight, int oldBottom) { 520 if (mStatusBarWindowView.getHeight() <= mStatusBarHeight) { 521 mStatusBarWindowView.removeOnLayoutChangeListener(this); 522 mWaitingOnCollapseWhenGoingAway = false; 523 updateTouchableRegionListener(); 524 } 525 } 526 }); 527 } 528 529 /** 530 * This represents a notification and how long it is in a heads up mode. It also manages its 531 * lifecycle automatically when created. 532 */ 533 public class HeadsUpEntry implements Comparable<HeadsUpEntry> { 534 public NotificationData.Entry entry; 535 public long postTime; 536 public long earliestRemovaltime; 537 private Runnable mRemoveHeadsUpRunnable; 538 539 public void setEntry(final NotificationData.Entry entry) { 540 this.entry = entry; 541 542 // The actual post time will be just after the heads-up really slided in 543 postTime = mClock.currentTimeMillis() + mTouchAcceptanceDelay; 544 mRemoveHeadsUpRunnable = new Runnable() { 545 @Override 546 public void run() { 547 if (!mTrackingHeadsUp) { 548 removeHeadsUpEntry(entry); 549 } else { 550 mEntriesToRemoveAfterExpand.add(entry); 551 } 552 } 553 }; 554 updateEntry(); 555 } 556 557 public void updateEntry() { 558 mSortedEntries.remove(HeadsUpEntry.this); 559 long currentTime = mClock.currentTimeMillis(); 560 earliestRemovaltime = currentTime + mMinimumDisplayTime; 561 postTime = Math.max(postTime, currentTime); 562 removeAutoRemovalCallbacks(); 563 if (!hasFullScreenIntent(entry)) { 564 long finishTime = postTime + mHeadsUpNotificationDecay; 565 long removeDelay = Math.max(finishTime - currentTime, mMinimumDisplayTime); 566 mHandler.postDelayed(mRemoveHeadsUpRunnable, removeDelay); 567 } 568 mSortedEntries.add(HeadsUpEntry.this); 569 } 570 571 @Override 572 public int compareTo(HeadsUpEntry o) { 573 return postTime < o.postTime ? 1 574 : postTime == o.postTime ? entry.key.compareTo(o.entry.key) 575 : -1; 576 } 577 578 public void removeAutoRemovalCallbacks() { 579 mHandler.removeCallbacks(mRemoveHeadsUpRunnable); 580 } 581 582 public boolean wasShownLongEnough() { 583 return earliestRemovaltime < mClock.currentTimeMillis(); 584 } 585 586 public void removeAsSoonAsPossible() { 587 removeAutoRemovalCallbacks(); 588 mHandler.postDelayed(mRemoveHeadsUpRunnable, 589 earliestRemovaltime - mClock.currentTimeMillis()); 590 } 591 592 public void reset() { 593 removeAutoRemovalCallbacks(); 594 entry = null; 595 mRemoveHeadsUpRunnable = null; 596 } 597 } 598 599 public static class Clock { 600 public long currentTimeMillis() { 601 return SystemClock.elapsedRealtime(); 602 } 603 } 604 605 public interface OnHeadsUpChangedListener { 606 /** 607 * The state whether there exist pinned heads-ups or not changed. 608 * 609 * @param inPinnedMode whether there are any pinned heads-ups 610 */ 611 void onHeadsUpPinnedModeChanged(boolean inPinnedMode); 612 613 /** 614 * A notification was just pinned to the top. 615 */ 616 void onHeadsUpPinned(ExpandableNotificationRow headsUp); 617 618 /** 619 * A notification was just unpinned from the top. 620 */ 621 void onHeadsUpUnPinned(ExpandableNotificationRow headsUp); 622 623 /** 624 * A notification just became a heads up or turned back to its normal state. 625 * 626 * @param entry the entry of the changed notification 627 * @param isHeadsUp whether the notification is now a headsUp notification 628 */ 629 void onHeadsUpStateChanged(NotificationData.Entry entry, boolean isHeadsUp); 630 } 631} 632