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.phone;
18
19import android.app.Notification;
20import android.os.SystemClock;
21import android.service.notification.StatusBarNotification;
22import android.support.annotation.Nullable;
23import android.util.Log;
24
25import com.android.systemui.statusbar.ExpandableNotificationRow;
26import com.android.systemui.statusbar.NotificationData;
27import com.android.systemui.statusbar.StatusBarState;
28import com.android.systemui.statusbar.policy.HeadsUpManager;
29import com.android.systemui.statusbar.policy.OnHeadsUpChangedListener;
30
31import java.io.FileDescriptor;
32import java.io.PrintWriter;
33import java.util.ArrayList;
34import java.util.Collection;
35import java.util.HashMap;
36import java.util.Iterator;
37import java.util.Map;
38import java.util.Objects;
39
40/**
41 * A class to handle notifications and their corresponding groups.
42 */
43public 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