HeadsUpManager.java revision 737bff3476a3af8f930d29fccce16d033fbc3efa
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.systemui.R;
33import com.android.systemui.statusbar.ExpandableNotificationRow;
34import com.android.systemui.statusbar.NotificationData;
35import com.android.systemui.statusbar.phone.PhoneStatusBar;
36
37import java.io.FileDescriptor;
38import java.io.PrintWriter;
39import java.util.ArrayList;
40import java.util.HashMap;
41import java.util.HashSet;
42import java.util.Stack;
43import java.util.TreeSet;
44
45/**
46 * A manager which handles heads up notifications which is a special mode where
47 * they simply peek from the top of the screen.
48 */
49public class HeadsUpManager implements ViewTreeObserver.OnComputeInternalInsetsListener {
50    private static final String TAG = "HeadsUpManager";
51    private static final boolean DEBUG = false;
52    private static final String SETTING_HEADS_UP_SNOOZE_LENGTH_MS = "heads_up_snooze_length_ms";
53
54    private final int mHeadsUpNotificationDecay;
55    private final int mMinimumDisplayTime;
56
57    private final int mTouchAcceptanceDelay;
58    private final ArrayMap<String, Long> mSnoozedPackages;
59    private final HashSet<OnHeadsUpChangedListener> mListeners = new HashSet<>();
60    private final int mDefaultSnoozeLengthMs;
61    private final Handler mHandler = new Handler();
62    private final Pools.Pool<HeadsUpEntry> mEntryPool = new Pools.Pool<HeadsUpEntry>() {
63
64        private Stack<HeadsUpEntry> mPoolObjects = new Stack<>();
65
66        @Override
67        public HeadsUpEntry acquire() {
68            if (!mPoolObjects.isEmpty()) {
69                return mPoolObjects.pop();
70            }
71            return new HeadsUpEntry();
72        }
73
74        @Override
75        public boolean release(HeadsUpEntry instance) {
76            instance.reset();
77            mPoolObjects.push(instance);
78            return true;
79        }
80    };
81
82    private final View mStatusBarWindowView;
83    private final int mStatusBarHeight;
84    private PhoneStatusBar mBar;
85    private int mSnoozeLengthMs;
86    private ContentObserver mSettingsObserver;
87    private HashMap<String, HeadsUpEntry> mHeadsUpEntries = new HashMap<>();
88    private TreeSet<HeadsUpEntry> mSortedEntries = new TreeSet<>();
89    private HashSet<String> mSwipedOutKeys = new HashSet<>();
90    private int mUser;
91    private Clock mClock;
92    private boolean mReleaseOnExpandFinish;
93    private boolean mTrackingHeadsUp;
94    private HashSet<NotificationData.Entry> mEntriesToRemoveAfterExpand = new HashSet<>();
95    private boolean mIsExpanded;
96    private boolean mHasPinnedNotification;
97    private int[] mTmpTwoArray = new int[2];
98    private boolean mHeadsUpGoingAway;
99    private boolean mWaitingOnCollapseWhenGoingAway;
100    private boolean mIsObserving;
101
102    public HeadsUpManager(final Context context, View statusBarWindowView) {
103        Resources resources = context.getResources();
104        mTouchAcceptanceDelay = resources.getInteger(R.integer.touch_acceptance_delay);
105        mSnoozedPackages = new ArrayMap<>();
106        mDefaultSnoozeLengthMs = resources.getInteger(R.integer.heads_up_default_snooze_length_ms);
107        mSnoozeLengthMs = mDefaultSnoozeLengthMs;
108        mMinimumDisplayTime = resources.getInteger(R.integer.heads_up_notification_minimum_time);
109        mHeadsUpNotificationDecay = resources.getInteger(R.integer.heads_up_notification_decay);
110        mClock = new Clock();
111
112        mSnoozeLengthMs = Settings.Global.getInt(context.getContentResolver(),
113                SETTING_HEADS_UP_SNOOZE_LENGTH_MS, mDefaultSnoozeLengthMs);
114        mSettingsObserver = new ContentObserver(mHandler) {
115            @Override
116            public void onChange(boolean selfChange) {
117                final int packageSnoozeLengthMs = Settings.Global.getInt(
118                        context.getContentResolver(), SETTING_HEADS_UP_SNOOZE_LENGTH_MS, -1);
119                if (packageSnoozeLengthMs > -1 && packageSnoozeLengthMs != mSnoozeLengthMs) {
120                    mSnoozeLengthMs = packageSnoozeLengthMs;
121                    if (DEBUG) Log.v(TAG, "mSnoozeLengthMs = " + mSnoozeLengthMs);
122                }
123            }
124        };
125        context.getContentResolver().registerContentObserver(
126                Settings.Global.getUriFor(SETTING_HEADS_UP_SNOOZE_LENGTH_MS), false,
127                mSettingsObserver);
128        mStatusBarWindowView = statusBarWindowView;
129        mStatusBarHeight = resources.getDimensionPixelSize(
130                com.android.internal.R.dimen.status_bar_height);
131    }
132
133    private void updateTouchableRegionListener() {
134        boolean shouldObserve = mHasPinnedNotification || mHeadsUpGoingAway
135                || mWaitingOnCollapseWhenGoingAway;
136        if (shouldObserve == mIsObserving) {
137            return;
138        }
139        if (shouldObserve) {
140            mStatusBarWindowView.getViewTreeObserver().addOnComputeInternalInsetsListener(this);
141            mStatusBarWindowView.requestLayout();
142        } else {
143            mStatusBarWindowView.getViewTreeObserver().removeOnComputeInternalInsetsListener(this);
144        }
145        mIsObserving = shouldObserve;
146    }
147
148    public void setBar(PhoneStatusBar bar) {
149        mBar = bar;
150    }
151
152    public void addListener(OnHeadsUpChangedListener listener) {
153        mListeners.add(listener);
154    }
155
156    public PhoneStatusBar getBar() {
157        return mBar;
158    }
159
160    /**
161     * Called when posting a new notification to the heads up.
162     */
163    public void showNotification(NotificationData.Entry headsUp) {
164        if (DEBUG) Log.v(TAG, "showNotification");
165        addHeadsUpEntry(headsUp);
166        updateNotification(headsUp, true);
167        headsUp.setInterruption();
168    }
169
170    /**
171     * Called when updating or posting a notification to the heads up.
172     */
173    public void updateNotification(NotificationData.Entry headsUp, boolean alert) {
174        if (DEBUG) Log.v(TAG, "updateNotification");
175
176        headsUp.row.setChildrenExpanded(false /* expanded */, false /* animated */);
177        headsUp.row.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED);
178
179        if (alert) {
180            HeadsUpEntry headsUpEntry = mHeadsUpEntries.get(headsUp.key);
181            headsUpEntry.updateEntry();
182            setEntryPinned(headsUpEntry, !mIsExpanded /* isPinned */);
183        }
184    }
185
186    private void addHeadsUpEntry(NotificationData.Entry entry) {
187        HeadsUpEntry headsUpEntry = mEntryPool.acquire();
188
189        // This will also add the entry to the sortedList
190        headsUpEntry.setEntry(entry);
191        mHeadsUpEntries.put(entry.key, headsUpEntry);
192        entry.row.setHeadsUp(true);
193        setEntryPinned(headsUpEntry, !mIsExpanded /* isPinned */);
194        for (OnHeadsUpChangedListener listener : mListeners) {
195            listener.onHeadsUpStateChanged(entry, true);
196        }
197        entry.row.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED);
198    }
199
200    private void setEntryPinned(HeadsUpEntry headsUpEntry, boolean isPinned) {
201        ExpandableNotificationRow row = headsUpEntry.entry.row;
202        if (row.isPinned() != isPinned) {
203            row.setPinned(isPinned);
204            updatePinnedMode();
205            for (OnHeadsUpChangedListener listener : mListeners) {
206                if (isPinned) {
207                    listener.onHeadsUpPinned(row);
208                } else {
209                    listener.onHeadsUpUnPinned(row);
210                }
211            }
212        }
213    }
214
215    private void removeHeadsUpEntry(NotificationData.Entry entry) {
216        HeadsUpEntry remove = mHeadsUpEntries.remove(entry.key);
217        mSortedEntries.remove(remove);
218        entry.row.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED);
219        entry.row.setHeadsUp(false);
220        setEntryPinned(remove, false /* isPinned */);
221        for (OnHeadsUpChangedListener listener : mListeners) {
222            listener.onHeadsUpStateChanged(entry, false);
223        }
224        mEntryPool.release(remove);
225    }
226
227    private void updatePinnedMode() {
228        boolean hasPinnedNotification = hasPinnedNotificationInternal();
229        if (hasPinnedNotification == mHasPinnedNotification) {
230            return;
231        }
232        mHasPinnedNotification = hasPinnedNotification;
233        updateTouchableRegionListener();
234        for (OnHeadsUpChangedListener listener : mListeners) {
235            listener.onHeadsUpPinnedModeChanged(hasPinnedNotification);
236        }
237    }
238
239    /**
240     * React to the removal of the notification in the heads up.
241     *
242     * @return true if the notification was removed and false if it still needs to be kept around
243     * for a bit since it wasn't shown long enough
244     */
245    public boolean removeNotification(String key) {
246        if (DEBUG) Log.v(TAG, "remove");
247        if (wasShownLongEnough(key)) {
248            releaseImmediately(key);
249            return true;
250        } else {
251            getHeadsUpEntry(key).removeAsSoonAsPossible();
252            return false;
253        }
254    }
255
256    private boolean wasShownLongEnough(String key) {
257        HeadsUpEntry headsUpEntry = getHeadsUpEntry(key);
258        HeadsUpEntry topEntry = getTopEntry();
259        if (mSwipedOutKeys.contains(key)) {
260            // We always instantly dismiss views being manually swiped out.
261            mSwipedOutKeys.remove(key);
262            return true;
263        }
264        if (headsUpEntry != topEntry) {
265            return true;
266        }
267        return headsUpEntry.wasShownLongEnough();
268    }
269
270    public boolean isHeadsUp(String key) {
271        return mHeadsUpEntries.containsKey(key);
272    }
273
274    /**
275     * Push any current Heads Up notification down into the shade.
276     */
277    public void releaseAllImmediately() {
278        if (DEBUG) Log.v(TAG, "releaseAllImmediately");
279        ArrayList<String> keys = new ArrayList<>(mHeadsUpEntries.keySet());
280        for (String key : keys) {
281            releaseImmediately(key);
282        }
283    }
284
285    public void releaseImmediately(String key) {
286        HeadsUpEntry headsUpEntry = getHeadsUpEntry(key);
287        if (headsUpEntry == null) {
288            return;
289        }
290        NotificationData.Entry shadeEntry = headsUpEntry.entry;
291        removeHeadsUpEntry(shadeEntry);
292    }
293
294    public boolean isSnoozed(String packageName) {
295        final String key = snoozeKey(packageName, mUser);
296        Long snoozedUntil = mSnoozedPackages.get(key);
297        if (snoozedUntil != null) {
298            if (snoozedUntil > SystemClock.elapsedRealtime()) {
299                if (DEBUG) Log.v(TAG, key + " snoozed");
300                return true;
301            }
302            mSnoozedPackages.remove(packageName);
303        }
304        return false;
305    }
306
307    public void snooze() {
308        for (String key : mHeadsUpEntries.keySet()) {
309            HeadsUpEntry entry = mHeadsUpEntries.get(key);
310            String packageName = entry.entry.notification.getPackageName();
311            mSnoozedPackages.put(snoozeKey(packageName, mUser),
312                    SystemClock.elapsedRealtime() + mSnoozeLengthMs);
313        }
314        mReleaseOnExpandFinish = true;
315    }
316
317    private static String snoozeKey(String packageName, int user) {
318        return user + "," + packageName;
319    }
320
321    private HeadsUpEntry getHeadsUpEntry(String key) {
322        return mHeadsUpEntries.get(key);
323    }
324
325    public NotificationData.Entry getEntry(String key) {
326        return mHeadsUpEntries.get(key).entry;
327    }
328
329    public TreeSet<HeadsUpEntry> getSortedEntries() {
330        return mSortedEntries;
331    }
332
333    public HeadsUpEntry getTopEntry() {
334        return mSortedEntries.isEmpty() ? null : mSortedEntries.first();
335    }
336
337    /**
338     * Decides whether a click is invalid for a notification, i.e it has not been shown long enough
339     * that a user might have consciously clicked on it.
340     *
341     * @param key the key of the touched notification
342     * @return whether the touch is invalid and should be discarded
343     */
344    public boolean shouldSwallowClick(String key) {
345        HeadsUpEntry entry = mHeadsUpEntries.get(key);
346        if (entry != null && mClock.currentTimeMillis() < entry.postTime) {
347            return true;
348        }
349        return false;
350    }
351
352    public void onComputeInternalInsets(ViewTreeObserver.InternalInsetsInfo info) {
353        if (mHasPinnedNotification) {
354            int minX = Integer.MAX_VALUE;
355            int maxX = 0;
356            int minY = Integer.MAX_VALUE;
357            int maxY = 0;
358            for (HeadsUpEntry entry : mSortedEntries) {
359                ExpandableNotificationRow row = entry.entry.row;
360                if (row.isPinned()) {
361                    row.getLocationOnScreen(mTmpTwoArray);
362                    minX = Math.min(minX, mTmpTwoArray[0]);
363                    minY = Math.min(minY, 0);
364                    maxX = Math.max(maxX, mTmpTwoArray[0] + row.getWidth());
365                    maxY = Math.max(maxY, row.getHeadsUpHeight());
366                }
367            }
368
369            info.setTouchableInsets(ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION);
370            info.touchableRegion.set(minX, minY, maxX, maxY);
371        } else if (mHeadsUpGoingAway || mWaitingOnCollapseWhenGoingAway) {
372            info.setTouchableInsets(ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION);
373            info.touchableRegion.set(0, 0, mStatusBarWindowView.getWidth(), mStatusBarHeight);
374        }
375    }
376
377    public void setUser(int user) {
378        mUser = user;
379    }
380
381    public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
382        pw.println("HeadsUpManager state:");
383        pw.print("  mTouchAcceptanceDelay="); pw.println(mTouchAcceptanceDelay);
384        pw.print("  mSnoozeLengthMs="); pw.println(mSnoozeLengthMs);
385        pw.print("  now="); pw.println(SystemClock.elapsedRealtime());
386        pw.print("  mUser="); pw.println(mUser);
387        for (HeadsUpEntry entry: mSortedEntries) {
388            pw.print("  HeadsUpEntry="); pw.println(entry.entry);
389        }
390        int N = mSnoozedPackages.size();
391        pw.println("  snoozed packages: " + N);
392        for (int i = 0; i < N; i++) {
393            pw.print("    "); pw.print(mSnoozedPackages.valueAt(i));
394            pw.print(", "); pw.println(mSnoozedPackages.keyAt(i));
395        }
396    }
397
398    public boolean hasPinnedHeadsUp() {
399        return mHasPinnedNotification;
400    }
401
402    private boolean hasPinnedNotificationInternal() {
403        for (String key : mHeadsUpEntries.keySet()) {
404            HeadsUpEntry entry = mHeadsUpEntries.get(key);
405            if (entry.entry.row.isPinned()) {
406                return true;
407            }
408        }
409        return false;
410    }
411
412    /**
413     * Notifies that a notification was swiped out and will be removed.
414     *
415     * @param key the notification key
416     */
417    public void addSwipedOutNotification(String key) {
418        mSwipedOutKeys.add(key);
419    }
420
421    public void unpinAll() {
422        for (String key : mHeadsUpEntries.keySet()) {
423            HeadsUpEntry entry = mHeadsUpEntries.get(key);
424            setEntryPinned(entry, false /* isPinned */);
425        }
426    }
427
428    public void onExpandingFinished() {
429        if (mReleaseOnExpandFinish) {
430            releaseAllImmediately();
431            mReleaseOnExpandFinish = false;
432        } else {
433            for (NotificationData.Entry entry : mEntriesToRemoveAfterExpand) {
434                removeHeadsUpEntry(entry);
435            }
436            mEntriesToRemoveAfterExpand.clear();
437        }
438    }
439
440    public void setTrackingHeadsUp(boolean trackingHeadsUp) {
441        mTrackingHeadsUp = trackingHeadsUp;
442    }
443
444    public void setIsExpanded(boolean isExpanded) {
445        if (isExpanded != mIsExpanded) {
446            mIsExpanded = isExpanded;
447            if (isExpanded) {
448                unpinAll();
449                // make sure our state is sane
450                mWaitingOnCollapseWhenGoingAway = false;
451                mHeadsUpGoingAway = false;
452                updateTouchableRegionListener();
453            }
454        }
455    }
456
457    public int getTopHeadsUpHeight() {
458        HeadsUpEntry topEntry = getTopEntry();
459        return topEntry != null ? topEntry.entry.row.getHeadsUpHeight() : 0;
460    }
461
462    /**
463     * Compare two entries and decide how they should be ranked.
464     *
465     * @return -1 if the first argument should be ranked higher than the second, 1 if the second
466     * one should be ranked higher and 0 if they are equal.
467     */
468    public int compare(NotificationData.Entry a, NotificationData.Entry b) {
469        HeadsUpEntry aEntry = getHeadsUpEntry(a.key);
470        HeadsUpEntry bEntry = getHeadsUpEntry(b.key);
471        if (aEntry == null || bEntry == null) {
472            return aEntry == null ? 1 : -1;
473        }
474        return aEntry.compareTo(bEntry);
475    }
476
477    /**
478     * Set that we are exiting the headsUp pinned mode, but some notifications might still be
479     * animating out. This is used to keep the touchable regions in a sane state.
480     */
481    public void setHeadsUpGoingAway(boolean headsUpGoingAway) {
482        if (headsUpGoingAway != mHeadsUpGoingAway) {
483            mHeadsUpGoingAway = headsUpGoingAway;
484            if (!headsUpGoingAway) {
485                waitForStatusBarLayout();
486            }
487            updateTouchableRegionListener();
488        }
489    }
490
491    /**
492     * We need to wait on the whole panel to collapse, before we can remove the touchable region
493     * listener.
494     */
495    private void waitForStatusBarLayout() {
496        mWaitingOnCollapseWhenGoingAway = true;
497        mStatusBarWindowView.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
498            @Override
499            public void onLayoutChange(View v, int left, int top, int right, int bottom,
500                    int oldLeft,
501                    int oldTop, int oldRight, int oldBottom) {
502                if (mStatusBarWindowView.getHeight() <= mStatusBarHeight) {
503                    mStatusBarWindowView.removeOnLayoutChangeListener(this);
504                    mWaitingOnCollapseWhenGoingAway = false;
505                    updateTouchableRegionListener();
506                }
507            }
508        });
509    }
510
511
512    /**
513     * This represents a notification and how long it is in a heads up mode. It also manages its
514     * lifecycle automatically when created.
515     */
516    public class HeadsUpEntry implements Comparable<HeadsUpEntry> {
517        public NotificationData.Entry entry;
518        public long postTime;
519        public long earliestRemovaltime;
520        private Runnable mRemoveHeadsUpRunnable;
521
522        public void setEntry(final NotificationData.Entry entry) {
523            this.entry = entry;
524
525            // The actual post time will be just after the heads-up really slided in
526            postTime = mClock.currentTimeMillis() + mTouchAcceptanceDelay;
527            mRemoveHeadsUpRunnable = new Runnable() {
528                @Override
529                public void run() {
530                    if (!mTrackingHeadsUp) {
531                        removeHeadsUpEntry(entry);
532                    } else {
533                        mEntriesToRemoveAfterExpand.add(entry);
534                    }
535                }
536            };
537            updateEntry();
538        }
539
540        public void updateEntry() {
541            long currentTime = mClock.currentTimeMillis();
542            earliestRemovaltime = currentTime + mMinimumDisplayTime;
543            postTime = Math.max(postTime, currentTime);
544            removeAutoRemovalCallbacks();
545            if (canEntryDecay()) {
546                long finishTime = postTime + mHeadsUpNotificationDecay;
547                long removeDelay = Math.max(finishTime - currentTime, mMinimumDisplayTime);
548                mHandler.postDelayed(mRemoveHeadsUpRunnable, removeDelay);
549            }
550            updateSortOrder(HeadsUpEntry.this);
551        }
552
553        private boolean canEntryDecay() {
554            return entry.notification.getNotification().fullScreenIntent == null;
555        }
556
557        @Override
558        public int compareTo(HeadsUpEntry o) {
559            return postTime < o.postTime ? 1
560                    : postTime == o.postTime ? 0
561                            : -1;
562        }
563
564        public void removeAutoRemovalCallbacks() {
565            mHandler.removeCallbacks(mRemoveHeadsUpRunnable);
566        }
567
568        public boolean wasShownLongEnough() {
569            return earliestRemovaltime < mClock.currentTimeMillis();
570        }
571
572        public void removeAsSoonAsPossible() {
573            removeAutoRemovalCallbacks();
574            mHandler.postDelayed(mRemoveHeadsUpRunnable,
575                    earliestRemovaltime - mClock.currentTimeMillis());
576        }
577
578        public void reset() {
579            removeAutoRemovalCallbacks();
580            entry = null;
581            mRemoveHeadsUpRunnable = null;
582        }
583    }
584
585    /**
586     * Update the sorted heads up order.
587     *
588     * @param headsUpEntry the headsUp that changed
589     */
590    private void updateSortOrder(HeadsUpEntry headsUpEntry) {
591        mSortedEntries.remove(headsUpEntry);
592        mSortedEntries.add(headsUpEntry);
593    }
594
595    public static class Clock {
596        public long currentTimeMillis() {
597            return SystemClock.elapsedRealtime();
598        }
599    }
600
601    public interface OnHeadsUpChangedListener {
602        /**
603         * The state whether there exist pinned heads-ups or not changed.
604         *
605         * @param inPinnedMode whether there are any pinned heads-ups
606         */
607        void onHeadsUpPinnedModeChanged(boolean inPinnedMode);
608
609        /**
610         * A notification was just pinned to the top.
611         */
612        void onHeadsUpPinned(ExpandableNotificationRow headsUp);
613
614        /**
615         * A notification was just unpinned from the top.
616         */
617        void onHeadsUpUnPinned(ExpandableNotificationRow headsUp);
618
619        /**
620         * A notification just became a heads up or turned back to its normal state.
621         *
622         * @param entry the entry of the changed notification
623         * @param isHeadsUp whether the notification is now a headsUp notification
624         */
625        void onHeadsUpStateChanged(NotificationData.Entry entry, boolean isHeadsUp);
626    }
627}
628