OSDN Git Service

8858381af668cd0509b8b19234677aad261b2dea
[android-x86/frameworks-base.git] / packages / SystemUI / src / com / android / systemui / statusbar / phone / NotificationGroupManager.java
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 androidx.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 }