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