Home | History | Annotate | Download | only in phone
      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.phone;
     18 
     19 import android.app.Notification;
     20 import android.os.SystemClock;
     21 import android.service.notification.StatusBarNotification;
     22 import android.support.annotation.Nullable;
     23 import android.util.Log;
     24 
     25 import com.android.systemui.statusbar.ExpandableNotificationRow;
     26 import com.android.systemui.statusbar.NotificationData;
     27 import com.android.systemui.statusbar.StatusBarState;
     28 import com.android.systemui.statusbar.policy.HeadsUpManager;
     29 import com.android.systemui.statusbar.policy.OnHeadsUpChangedListener;
     30 
     31 import java.io.FileDescriptor;
     32 import java.io.PrintWriter;
     33 import java.util.ArrayList;
     34 import java.util.Collection;
     35 import java.util.HashMap;
     36 import java.util.Iterator;
     37 import java.util.Map;
     38 import java.util.Objects;
     39 
     40 /**
     41  * A class to handle notifications and their corresponding groups.
     42  */
     43 public class NotificationGroupManager implements OnHeadsUpChangedListener {
     44 
     45     private static final String TAG = "NotificationGroupManager";
     46     private static final long HEADS_UP_TRANSFER_TIMEOUT = 300;
     47     private final HashMap<String, NotificationGroup> mGroupMap = new HashMap<>();
     48     private OnGroupChangeListener mListener;
     49     private int mBarState = -1;
     50     private HashMap<String, StatusBarNotification> mIsolatedEntries = new HashMap<>();
     51     private HeadsUpManager mHeadsUpManager;
     52     private boolean mIsUpdatingUnchangedGroup;
     53     private HashMap<String, NotificationData.Entry> mPendingNotifications;
     54 
     55     public void setOnGroupChangeListener(OnGroupChangeListener listener) {
     56         mListener = listener;
     57     }
     58 
     59     public boolean isGroupExpanded(StatusBarNotification sbn) {
     60         NotificationGroup group = mGroupMap.get(getGroupKey(sbn));
     61         if (group == null) {
     62             return false;
     63         }
     64         return group.expanded;
     65     }
     66 
     67     public void setGroupExpanded(StatusBarNotification sbn, boolean expanded) {
     68         NotificationGroup group = mGroupMap.get(getGroupKey(sbn));
     69         if (group == null) {
     70             return;
     71         }
     72         setGroupExpanded(group, expanded);
     73     }
     74 
     75     private void setGroupExpanded(NotificationGroup group, boolean expanded) {
     76         group.expanded = expanded;
     77         if (group.summary != null) {
     78             mListener.onGroupExpansionChanged(group.summary.row, expanded);
     79         }
     80     }
     81 
     82     public void onEntryRemoved(NotificationData.Entry removed) {
     83         onEntryRemovedInternal(removed, removed.notification);
     84         mIsolatedEntries.remove(removed.key);
     85     }
     86 
     87     /**
     88      * An entry was removed.
     89      *
     90      * @param removed the removed entry
     91      * @param sbn the notification the entry has, which doesn't need to be the same as it's internal
     92      *            notification
     93      */
     94     private void onEntryRemovedInternal(NotificationData.Entry removed,
     95             final StatusBarNotification sbn) {
     96         String groupKey = getGroupKey(sbn);
     97         final NotificationGroup group = mGroupMap.get(groupKey);
     98         if (group == null) {
     99             // When an app posts 2 different notifications as summary of the same group, then a
    100             // cancellation of the first notification removes this group.
    101             // This situation is not supported and we will not allow such notifications anymore in
    102             // the close future. See b/23676310 for reference.
    103             return;
    104         }
    105         if (isGroupChild(sbn)) {
    106             group.children.remove(removed.key);
    107         } else {
    108             group.summary = null;
    109         }
    110         updateSuppression(group);
    111         if (group.children.isEmpty()) {
    112             if (group.summary == null) {
    113                 mGroupMap.remove(groupKey);
    114             }
    115         }
    116     }
    117 
    118     public void onEntryAdded(final NotificationData.Entry added) {
    119         if (added.row.isRemoved()) {
    120             added.setDebugThrowable(new Throwable());
    121         }
    122         final StatusBarNotification sbn = added.notification;
    123         boolean isGroupChild = isGroupChild(sbn);
    124         String groupKey = getGroupKey(sbn);
    125         NotificationGroup group = mGroupMap.get(groupKey);
    126         if (group == null) {
    127             group = new NotificationGroup();
    128             mGroupMap.put(groupKey, group);
    129         }
    130         if (isGroupChild) {
    131             NotificationData.Entry existing = group.children.get(added.key);
    132             if (existing != null && existing != added) {
    133                 Throwable existingThrowable = existing.getDebugThrowable();
    134                 Log.wtf(TAG, "Inconsistent entries found with the same key " + added.key
    135                         + "existing removed: " + existing.row.isRemoved()
    136                         + (existingThrowable != null
    137                                 ? Log.getStackTraceString(existingThrowable) + "\n": "")
    138                         + " added removed" + added.row.isRemoved()
    139                         , new Throwable());
    140             }
    141             group.children.put(added.key, added);
    142             updateSuppression(group);
    143         } else {
    144             group.summary = added;
    145             group.expanded = added.row.areChildrenExpanded();
    146             updateSuppression(group);
    147             if (!group.children.isEmpty()) {
    148                 ArrayList<NotificationData.Entry> childrenCopy
    149                         = new ArrayList<>(group.children.values());
    150                 for (NotificationData.Entry child : childrenCopy) {
    151                     onEntryBecomingChild(child);
    152                 }
    153                 mListener.onGroupCreatedFromChildren(group);
    154             }
    155         }
    156         cleanUpHeadsUpStatesOnAdd(group, false /* addIsPending */);
    157     }
    158 
    159     public void onPendingEntryAdded(NotificationData.Entry shadeEntry) {
    160         String groupKey = getGroupKey(shadeEntry.notification);
    161         NotificationGroup group = mGroupMap.get(groupKey);
    162         if (group != null) {
    163             cleanUpHeadsUpStatesOnAdd(group, true /* addIsPending */);
    164         }
    165     }
    166 
    167     /**
    168      * Clean up the heads up states when a new child was added.
    169      * @param group The group where a view was added or will be added.
    170      * @param addIsPending True if is the addition still pending or false has it already been added.
    171      */
    172     private void cleanUpHeadsUpStatesOnAdd(NotificationGroup group, boolean addIsPending) {
    173         if (!addIsPending && group.hunSummaryOnNextAddition) {
    174             if (!mHeadsUpManager.isHeadsUp(group.summary.key)) {
    175                 mHeadsUpManager.showNotification(group.summary);
    176             }
    177             group.hunSummaryOnNextAddition = false;
    178         }
    179         // Because notification groups are not delivered as a whole unit, it may happen that a
    180         // group child gets added quite a bit after the summary got posted. Our guidance is, that
    181         // apps should always post the group summary as well and we'll hide it for them if the child
    182         // is the only child in a group. Because of this, we also have to transfer heads up to the
    183         // child, otherwise the invisible summary would be heads-upped.
    184         // This transfer to the child is not always correct in case the app has just posted another
    185         // child in addition to the existing one, but it hasn't arrived in systemUI yet. In such
    186         // a scenario we would transfer the heads up to the old child and the wrong notification
    187         // would be heads-upped. In oder to avoid this, we'll recover from this issue and hun the
    188         // summary again instead of the old child if it's within a certain timeout.
    189         if (SystemClock.elapsedRealtime() - group.lastHeadsUpTransfer < HEADS_UP_TRANSFER_TIMEOUT) {
    190             if (!onlySummaryAlerts(group.summary)) {
    191                 return;
    192             }
    193             int numChildren = group.children.size();
    194             NotificationData.Entry isolatedChild = getIsolatedChild(getGroupKey(
    195                     group.summary.notification));
    196             int numPendingChildren = getPendingChildrenNotAlerting(group);
    197             numChildren += numPendingChildren;
    198             if (isolatedChild != null) {
    199                 numChildren++;
    200             }
    201             if (numChildren <= 1) {
    202                 return;
    203             }
    204             boolean releasedChild = false;
    205             ArrayList<NotificationData.Entry> children = new ArrayList<>(group.children.values());
    206             int size = children.size();
    207             for (int i = 0; i < size; i++) {
    208                 NotificationData.Entry entry = children.get(i);
    209                 if (onlySummaryAlerts(entry) && entry.row.isHeadsUp()) {
    210                     releasedChild = true;
    211                     mHeadsUpManager.releaseImmediately(entry.key);
    212                 }
    213             }
    214             if (isolatedChild != null && onlySummaryAlerts(isolatedChild)
    215                     && isolatedChild.row.isHeadsUp()) {
    216                 releasedChild = true;
    217                 mHeadsUpManager.releaseImmediately(isolatedChild.key);
    218             }
    219             if (releasedChild && !mHeadsUpManager.isHeadsUp(group.summary.key)) {
    220                 boolean notifyImmediately = (numChildren - numPendingChildren) > 1;
    221                 if (notifyImmediately) {
    222                     mHeadsUpManager.showNotification(group.summary);
    223                 } else {
    224                     group.hunSummaryOnNextAddition = true;
    225                 }
    226                 group.lastHeadsUpTransfer = 0;
    227             }
    228         }
    229     }
    230 
    231     private int getPendingChildrenNotAlerting(NotificationGroup group) {
    232         if (mPendingNotifications == null) {
    233             return 0;
    234         }
    235         int number = 0;
    236         String groupKey = getGroupKey(group.summary.notification);
    237         Collection<NotificationData.Entry> values = mPendingNotifications.values();
    238         for (NotificationData.Entry entry : values) {
    239             if (!isGroupChild(entry.notification)) {
    240                 continue;
    241             }
    242             if (!Objects.equals(getGroupKey(entry.notification), groupKey)) {
    243                 continue;
    244             }
    245             if (group.children.containsKey(entry.key)) {
    246                 continue;
    247             }
    248             if (onlySummaryAlerts(entry)) {
    249                 number++;
    250             }
    251         }
    252         return number;
    253     }
    254 
    255     private void onEntryBecomingChild(NotificationData.Entry entry) {
    256         if (entry.row.isHeadsUp()) {
    257             onHeadsUpStateChanged(entry, true);
    258         }
    259     }
    260 
    261     private void updateSuppression(NotificationGroup group) {
    262         if (group == null) {
    263             return;
    264         }
    265         boolean prevSuppressed = group.suppressed;
    266         group.suppressed = group.summary != null && !group.expanded
    267                 && (group.children.size() == 1
    268                 || (group.children.size() == 0
    269                         && group.summary.notification.getNotification().isGroupSummary()
    270                         && hasIsolatedChildren(group)));
    271         if (prevSuppressed != group.suppressed) {
    272             if (group.suppressed) {
    273                 handleSuppressedSummaryHeadsUpped(group.summary);
    274             }
    275             if (!mIsUpdatingUnchangedGroup && mListener != null) {
    276                 mListener.onGroupsChanged();
    277             }
    278         }
    279     }
    280 
    281     private boolean hasIsolatedChildren(NotificationGroup group) {
    282         return getNumberOfIsolatedChildren(group.summary.notification.getGroupKey()) != 0;
    283     }
    284 
    285     private int getNumberOfIsolatedChildren(String groupKey) {
    286         int count = 0;
    287         for (StatusBarNotification sbn : mIsolatedEntries.values()) {
    288             if (sbn.getGroupKey().equals(groupKey) && isIsolated(sbn)) {
    289                 count++;
    290             }
    291         }
    292         return count;
    293     }
    294 
    295     private NotificationData.Entry getIsolatedChild(String groupKey) {
    296         for (StatusBarNotification sbn : mIsolatedEntries.values()) {
    297             if (sbn.getGroupKey().equals(groupKey) && isIsolated(sbn)) {
    298                 return mGroupMap.get(sbn.getKey()).summary;
    299             }
    300         }
    301         return null;
    302     }
    303 
    304     public void onEntryUpdated(NotificationData.Entry entry,
    305             StatusBarNotification oldNotification) {
    306         String oldKey = oldNotification.getGroupKey();
    307         String newKey = entry.notification.getGroupKey();
    308         boolean groupKeysChanged = !oldKey.equals(newKey);
    309         boolean wasGroupChild = isGroupChild(oldNotification);
    310         boolean isGroupChild = isGroupChild(entry.notification);
    311         mIsUpdatingUnchangedGroup = !groupKeysChanged && wasGroupChild == isGroupChild;
    312         if (mGroupMap.get(getGroupKey(oldNotification)) != null) {
    313             onEntryRemovedInternal(entry, oldNotification);
    314         }
    315         onEntryAdded(entry);
    316         mIsUpdatingUnchangedGroup = false;
    317         if (isIsolated(entry.notification)) {
    318             mIsolatedEntries.put(entry.key, entry.notification);
    319             if (groupKeysChanged) {
    320                 updateSuppression(mGroupMap.get(oldKey));
    321                 updateSuppression(mGroupMap.get(newKey));
    322             }
    323         } else if (!wasGroupChild && isGroupChild) {
    324             onEntryBecomingChild(entry);
    325         }
    326     }
    327 
    328     public boolean isSummaryOfSuppressedGroup(StatusBarNotification sbn) {
    329         return isGroupSuppressed(getGroupKey(sbn)) && sbn.getNotification().isGroupSummary();
    330     }
    331 
    332     private boolean isOnlyChild(StatusBarNotification sbn) {
    333         return !sbn.getNotification().isGroupSummary()
    334                 && getTotalNumberOfChildren(sbn) == 1;
    335     }
    336 
    337     public boolean isOnlyChildInGroup(StatusBarNotification sbn) {
    338         if (!isOnlyChild(sbn)) {
    339             return false;
    340         }
    341         ExpandableNotificationRow logicalGroupSummary = getLogicalGroupSummary(sbn);
    342         return logicalGroupSummary != null
    343                 && !logicalGroupSummary.getStatusBarNotification().equals(sbn);
    344     }
    345 
    346     private int getTotalNumberOfChildren(StatusBarNotification sbn) {
    347         int isolatedChildren = getNumberOfIsolatedChildren(sbn.getGroupKey());
    348         NotificationGroup group = mGroupMap.get(sbn.getGroupKey());
    349         int realChildren = group != null ? group.children.size() : 0;
    350         return isolatedChildren + realChildren;
    351     }
    352 
    353     private boolean isGroupSuppressed(String groupKey) {
    354         NotificationGroup group = mGroupMap.get(groupKey);
    355         return group != null && group.suppressed;
    356     }
    357 
    358     public void setStatusBarState(int newState) {
    359         if (mBarState == newState) {
    360             return;
    361         }
    362         mBarState = newState;
    363         if (mBarState == StatusBarState.KEYGUARD) {
    364             collapseAllGroups();
    365         }
    366     }
    367 
    368     public void collapseAllGroups() {
    369         // Because notifications can become isolated when the group becomes suppressed it can
    370         // lead to concurrent modifications while looping. We need to make a copy.
    371         ArrayList<NotificationGroup> groupCopy = new ArrayList<>(mGroupMap.values());
    372         int size = groupCopy.size();
    373         for (int i = 0; i < size; i++) {
    374             NotificationGroup group =  groupCopy.get(i);
    375             if (group.expanded) {
    376                 setGroupExpanded(group, false);
    377             }
    378             updateSuppression(group);
    379         }
    380     }
    381 
    382     /**
    383      * @return whether a given notification is a child in a group which has a summary
    384      */
    385     public boolean isChildInGroupWithSummary(StatusBarNotification sbn) {
    386         if (!isGroupChild(sbn)) {
    387             return false;
    388         }
    389         NotificationGroup group = mGroupMap.get(getGroupKey(sbn));
    390         if (group == null || group.summary == null || group.suppressed) {
    391             return false;
    392         }
    393         if (group.children.isEmpty()) {
    394             // If the suppression of a group changes because the last child was removed, this can
    395             // still be called temporarily because the child hasn't been fully removed yet. Let's
    396             // make sure we still return false in that case.
    397             return false;
    398         }
    399         return true;
    400     }
    401 
    402     /**
    403      * @return whether a given notification is a summary in a group which has children
    404      */
    405     public boolean isSummaryOfGroup(StatusBarNotification sbn) {
    406         if (!isGroupSummary(sbn)) {
    407             return false;
    408         }
    409         NotificationGroup group = mGroupMap.get(getGroupKey(sbn));
    410         if (group == null) {
    411             return false;
    412         }
    413         return !group.children.isEmpty();
    414     }
    415 
    416     /**
    417      * Get the summary of a specified status bar notification. For isolated notification this return
    418      * itself.
    419      */
    420     public ExpandableNotificationRow getGroupSummary(StatusBarNotification sbn) {
    421         return getGroupSummary(getGroupKey(sbn));
    422     }
    423 
    424     /**
    425      * Similar to {@link #getGroupSummary(StatusBarNotification)} but doesn't get the visual summary
    426      * but the logical summary, i.e when a child is isolated, it still returns the summary as if
    427      * it wasn't isolated.
    428      */
    429     public ExpandableNotificationRow getLogicalGroupSummary(
    430             StatusBarNotification sbn) {
    431         return getGroupSummary(sbn.getGroupKey());
    432     }
    433 
    434     @Nullable
    435     private ExpandableNotificationRow getGroupSummary(String groupKey) {
    436         NotificationGroup group = mGroupMap.get(groupKey);
    437         return group == null ? null
    438                 : group.summary == null ? null
    439                         : group.summary.row;
    440     }
    441 
    442     /** @return group expansion state after toggling. */
    443     public boolean toggleGroupExpansion(StatusBarNotification sbn) {
    444         NotificationGroup group = mGroupMap.get(getGroupKey(sbn));
    445         if (group == null) {
    446             return false;
    447         }
    448         setGroupExpanded(group, !group.expanded);
    449         return group.expanded;
    450     }
    451 
    452     private boolean isIsolated(StatusBarNotification sbn) {
    453         return mIsolatedEntries.containsKey(sbn.getKey());
    454     }
    455 
    456     private boolean isGroupSummary(StatusBarNotification sbn) {
    457         if (isIsolated(sbn)) {
    458             return true;
    459         }
    460         return sbn.getNotification().isGroupSummary();
    461     }
    462 
    463     private boolean isGroupChild(StatusBarNotification sbn) {
    464         if (isIsolated(sbn)) {
    465             return false;
    466         }
    467         return sbn.isGroup() && !sbn.getNotification().isGroupSummary();
    468     }
    469 
    470     private String getGroupKey(StatusBarNotification sbn) {
    471         if (isIsolated(sbn)) {
    472             return sbn.getKey();
    473         }
    474         return sbn.getGroupKey();
    475     }
    476 
    477     @Override
    478     public void onHeadsUpPinnedModeChanged(boolean inPinnedMode) {
    479     }
    480 
    481     @Override
    482     public void onHeadsUpPinned(ExpandableNotificationRow headsUp) {
    483     }
    484 
    485     @Override
    486     public void onHeadsUpUnPinned(ExpandableNotificationRow headsUp) {
    487     }
    488 
    489     @Override
    490     public void onHeadsUpStateChanged(NotificationData.Entry entry, boolean isHeadsUp) {
    491         final StatusBarNotification sbn = entry.notification;
    492         if (entry.row.isHeadsUp()) {
    493             if (shouldIsolate(sbn)) {
    494                 // We will be isolated now, so lets update the groups
    495                 onEntryRemovedInternal(entry, entry.notification);
    496 
    497                 mIsolatedEntries.put(sbn.getKey(), sbn);
    498 
    499                 onEntryAdded(entry);
    500                 // We also need to update the suppression of the old group, because this call comes
    501                 // even before the groupManager knows about the notification at all.
    502                 // When the notification gets added afterwards it is already isolated and therefore
    503                 // it doesn't lead to an update.
    504                 updateSuppression(mGroupMap.get(entry.notification.getGroupKey()));
    505                 mListener.onGroupsChanged();
    506             } else {
    507                 handleSuppressedSummaryHeadsUpped(entry);
    508             }
    509         } else {
    510             if (mIsolatedEntries.containsKey(sbn.getKey())) {
    511                 // not isolated anymore, we need to update the groups
    512                 onEntryRemovedInternal(entry, entry.notification);
    513                 mIsolatedEntries.remove(sbn.getKey());
    514                 onEntryAdded(entry);
    515                 mListener.onGroupsChanged();
    516             }
    517         }
    518     }
    519 
    520     private void handleSuppressedSummaryHeadsUpped(NotificationData.Entry entry) {
    521         StatusBarNotification sbn = entry.notification;
    522         if (!isGroupSuppressed(sbn.getGroupKey())
    523                 || !sbn.getNotification().isGroupSummary()
    524                 || !entry.row.isHeadsUp()) {
    525             return;
    526         }
    527 
    528         // The parent of a suppressed group got huned, lets hun the child!
    529         NotificationGroup notificationGroup = mGroupMap.get(sbn.getGroupKey());
    530 
    531         if (pendingInflationsWillAddChildren(notificationGroup)) {
    532             // New children will actually be added to this group, let's not transfer the heads
    533             // up
    534             return;
    535         }
    536 
    537         if (notificationGroup != null) {
    538             Iterator<NotificationData.Entry> iterator
    539                     = notificationGroup.children.values().iterator();
    540             NotificationData.Entry child = iterator.hasNext() ? iterator.next() : null;
    541             if (child == null) {
    542                 child = getIsolatedChild(sbn.getGroupKey());
    543             }
    544             if (child != null) {
    545                 if (child.row.keepInParent() || child.row.isRemoved() || child.row.isDismissed()) {
    546                     // the notification is actually already removed, no need to do heads-up on it.
    547                     return;
    548                 }
    549                 if (mHeadsUpManager.isHeadsUp(child.key)) {
    550                     mHeadsUpManager.updateNotification(child, true);
    551                 } else {
    552                     if (onlySummaryAlerts(entry)) {
    553                         notificationGroup.lastHeadsUpTransfer = SystemClock.elapsedRealtime();
    554                     }
    555                     mHeadsUpManager.showNotification(child);
    556                 }
    557             }
    558         }
    559         mHeadsUpManager.releaseImmediately(entry.key);
    560     }
    561 
    562     private boolean onlySummaryAlerts(NotificationData.Entry entry) {
    563         return entry.notification.getNotification().getGroupAlertBehavior()
    564                 == Notification.GROUP_ALERT_SUMMARY;
    565     }
    566 
    567     /**
    568      * Check if the pending inflations will add children to this group.
    569      * @param group The group to check.
    570      */
    571     private boolean pendingInflationsWillAddChildren(NotificationGroup group) {
    572         if (mPendingNotifications == null) {
    573             return false;
    574         }
    575         Collection<NotificationData.Entry> values = mPendingNotifications.values();
    576         String groupKey = getGroupKey(group.summary.notification);
    577         for (NotificationData.Entry entry : values) {
    578             if (!isGroupChild(entry.notification)) {
    579                 continue;
    580             }
    581             if (!Objects.equals(getGroupKey(entry.notification), groupKey)) {
    582                 continue;
    583             }
    584             if (!group.children.containsKey(entry.key)) {
    585                 return true;
    586             }
    587         }
    588         return false;
    589     }
    590 
    591     private boolean shouldIsolate(StatusBarNotification sbn) {
    592         NotificationGroup notificationGroup = mGroupMap.get(sbn.getGroupKey());
    593         return (sbn.isGroup() && !sbn.getNotification().isGroupSummary())
    594                 && (sbn.getNotification().fullScreenIntent != null
    595                         || notificationGroup == null
    596                         || !notificationGroup.expanded
    597                         || isGroupNotFullyVisible(notificationGroup));
    598     }
    599 
    600     private boolean isGroupNotFullyVisible(NotificationGroup notificationGroup) {
    601         return notificationGroup.summary == null
    602                 || notificationGroup.summary.row.getClipTopAmount() > 0
    603                 || notificationGroup.summary.row.getTranslationY() < 0;
    604     }
    605 
    606     public void setHeadsUpManager(HeadsUpManager headsUpManager) {
    607         mHeadsUpManager = headsUpManager;
    608     }
    609 
    610     public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
    611         pw.println("GroupManager state:");
    612         pw.println("  number of groups: " +  mGroupMap.size());
    613         for (Map.Entry<String, NotificationGroup>  entry : mGroupMap.entrySet()) {
    614             pw.println("\n    key: " + entry.getKey()); pw.println(entry.getValue());
    615         }
    616         pw.println("\n    isolated entries: " +  mIsolatedEntries.size());
    617         for (Map.Entry<String, StatusBarNotification> entry : mIsolatedEntries.entrySet()) {
    618             pw.print("      "); pw.print(entry.getKey());
    619             pw.print(", "); pw.println(entry.getValue());
    620         }
    621     }
    622 
    623     public void setPendingEntries(HashMap<String, NotificationData.Entry> pendingNotifications) {
    624         mPendingNotifications = pendingNotifications;
    625     }
    626 
    627     public static class NotificationGroup {
    628         public final HashMap<String, NotificationData.Entry> children = new HashMap<>();
    629         public NotificationData.Entry summary;
    630         public boolean expanded;
    631         /**
    632          * Is this notification group suppressed, i.e its summary is hidden
    633          */
    634         public boolean suppressed;
    635         /**
    636          * The time when the last heads transfer from group to child happened, while the summary
    637          * has the flags to heads up on its own.
    638          */
    639         public long lastHeadsUpTransfer;
    640         public boolean hunSummaryOnNextAddition;
    641 
    642         @Override
    643         public String toString() {
    644             String result = "    summary:\n      "
    645                     + (summary != null ? summary.notification : "null")
    646                     + (summary != null && summary.getDebugThrowable() != null
    647                             ? Log.getStackTraceString(summary.getDebugThrowable())
    648                             : "");
    649             result += "\n    children size: " + children.size();
    650             for (NotificationData.Entry child : children.values()) {
    651                 result += "\n      " + child.notification
    652                 + (child.getDebugThrowable() != null
    653                         ? Log.getStackTraceString(child.getDebugThrowable())
    654                         : "");
    655             }
    656             return result;
    657         }
    658     }
    659 
    660     public interface OnGroupChangeListener {
    661         /**
    662          * The expansion of a group has changed.
    663          *
    664          * @param changedRow the row for which the expansion has changed, which is also the summary
    665          * @param expanded a boolean indicating the new expanded state
    666          */
    667         void onGroupExpansionChanged(ExpandableNotificationRow changedRow, boolean expanded);
    668 
    669         /**
    670          * A group of children just received a summary notification and should therefore become
    671          * children of it.
    672          *
    673          * @param group the group created
    674          */
    675         void onGroupCreatedFromChildren(NotificationGroup group);
    676 
    677         /**
    678          * The groups have changed. This can happen if the isolation of a child has changes or if a
    679          * group became suppressed / unsuppressed
    680          */
    681         void onGroupsChanged();
    682     }
    683 }
    684