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