Home | History | Annotate | Download | only in policy
      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 
     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      */
    100     public void addListener(@NonNull OnHeadsUpChangedListener listener) {
    101         mListeners.add(listener);
    102     }
    103 
    104     /**
    105      * Removes the OnHeadUpChangedListener from the observer list.
    106      */
    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      */
    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      */
    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 
    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 
    154     protected boolean shouldHeadsUpBecomePinned(@NonNull NotificationData.Entry entry) {
    155         return hasFullScreenIntent(entry);
    156     }
    157 
    158     protected boolean hasFullScreenIntent(@NonNull NotificationData.Entry entry) {
    159         return entry.notification.getNotification().fullScreenIntent != null;
    160     }
    161 
    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 
    179     protected void removeHeadsUpEntry(@NonNull NotificationData.Entry entry) {
    180         HeadsUpEntry remove = mHeadsUpEntries.remove(entry.key);
    181         onHeadsUpEntryRemoved(remove);
    182     }
    183 
    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 
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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
    286     private static String snoozeKey(@NonNull String packageName, int user) {
    287         return user + "," + packageName;
    288     }
    289 
    290     @Nullable
    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
    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
    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
    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      */
    326     public boolean hasHeadsUpNotifications() {
    327         return !mHeadsUpEntries.isEmpty();
    328     }
    329 
    330     @Nullable
    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      */
    347     public void setUser(int user) {
    348         mUser = user;
    349     }
    350 
    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 
    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      */
    376     public boolean hasPinnedHeadsUp() {
    377         return mHasPinnedNotification;
    378     }
    379 
    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      */
    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      */
    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      */
    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      */
    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
    438     protected HeadsUpEntry createHeadsUpEntry() {
    439         return new HeadsUpEntry();
    440     }
    441 
    442     protected void releaseHeadsUpEntry(@NonNull HeadsUpEntry entry) {
    443         entry.reset();
    444     }
    445 
    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 
    462         public void setEntry(@Nullable final NotificationData.Entry entry) {
    463             setEntry(entry, null);
    464         }
    465 
    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 
    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 
    493         private boolean isSticky() {
    494             return (entry.row.isPinned() && expanded)
    495                     || remoteInputActive || hasFullScreenIntent(entry);
    496         }
    497 
    498         @Override
    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 
    526         public void expanded(boolean expanded) {
    527             this.expanded = expanded;
    528         }
    529 
    530         public void reset() {
    531             entry = null;
    532             expanded = false;
    533             remoteInputActive = false;
    534             removeAutoRemovalCallbacks();
    535             mRemoveHeadsUpRunnable = null;
    536         }
    537 
    538         public void removeAutoRemovalCallbacks() {
    539             if (mRemoveHeadsUpRunnable != null)
    540                 mHandler.removeCallbacks(mRemoveHeadsUpRunnable);
    541         }
    542 
    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 {
    553         public long currentTimeMillis() {
    554             return SystemClock.elapsedRealtime();
    555         }
    556     }
    557 }
    558