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