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