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