Merge changes If9b31b3b,Iea400614,I9567aaf5,I6696029f,I3d4f434e, ...

* changes:
  Don't show new groups until their children inflate
  Cleanup PreparationCoordinator a bit
  Add onCleanup() method to Pluggables
  Introduce GroupEntryBuilder
  NotificationEntryBuilder can modify existing entries
  Move creationTime into ListEntry (from NotifEntry)
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/GroupEntry.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/GroupEntry.java
index 81494ed..0ea6857 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/GroupEntry.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/GroupEntry.java
@@ -19,7 +19,6 @@
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 
-import com.android.internal.annotations.VisibleForTesting;
 import com.android.systemui.statusbar.notification.collection.coordinator.PreparationCoordinator;
 
 import java.util.ArrayList;
@@ -39,9 +38,8 @@
             Collections.unmodifiableList(mChildren);
     private int mUntruncatedChildCount;
 
-    @VisibleForTesting
-    public GroupEntry(String key) {
-        super(key);
+    GroupEntry(String key, long creationTime) {
+        super(key, creationTime);
     }
 
     @Override
@@ -59,8 +57,7 @@
         return mUnmodifiableChildren;
     }
 
-    @VisibleForTesting
-    public void setSummary(@Nullable NotificationEntry summary) {
+    void setSummary(@Nullable NotificationEntry summary) {
         mSummary = summary;
     }
 
@@ -98,6 +95,6 @@
         return mChildren;
     }
 
-    public static final GroupEntry ROOT_ENTRY = new GroupEntry("<root>");
+    public static final GroupEntry ROOT_ENTRY = new GroupEntry("<root>", 0);
 
 }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ListEntry.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ListEntry.java
index 837374f..65f5dc4 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ListEntry.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ListEntry.java
@@ -17,6 +17,8 @@
 package com.android.systemui.statusbar.notification.collection;
 
 
+import android.annotation.UptimeMillisLong;
+
 import androidx.annotation.Nullable;
 
 import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifSection;
@@ -27,14 +29,16 @@
  */
 public abstract class ListEntry {
     private final String mKey;
+    private final long mCreationTime;
 
     int mFirstAddedIteration = -1;
 
     private final ListAttachState mPreviousAttachState = ListAttachState.create();
     private final ListAttachState mAttachState = ListAttachState.create();
 
-    ListEntry(String key) {
+    ListEntry(String key, long creationTime) {
         mKey = key;
+        mCreationTime = creationTime;
     }
 
     public String getKey() {
@@ -42,6 +46,19 @@
     }
 
     /**
+     * The SystemClock.uptimeMillis() when this object was created. In general, this means the
+     * moment when NotificationManager notifies our listener about the existence of this entry.
+     *
+     * This value will not change if the notification is updated, although it will change if the
+     * notification is removed and then re-posted. It is also wholly independent from
+     * Notification#when.
+     */
+    @UptimeMillisLong
+    public long getCreationTime() {
+        return mCreationTime;
+    }
+
+    /**
      * Should return the "representative entry" for this ListEntry. For NotificationEntries, its
      * the entry itself. For groups, it should be the summary (but if a summary doesn't exist,
      * this can return null). This method exists to interface with
@@ -79,6 +96,14 @@
     }
 
     /**
+     * True if this entry has been attached to the shade at least once in its lifetime (it may not
+     * currently be attached).
+     */
+    public boolean hasBeenAttachedBefore() {
+        return mFirstAddedIteration != -1;
+    }
+
+    /**
      * Stores the current attach state into {@link #getPreviousAttachState()}} and then starts a
      * fresh attach state (all entries will be null/default-initialized).
      */
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java
index bd65ef0..387247e 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotificationEntry.java
@@ -35,7 +35,6 @@
 
 import static java.util.Objects.requireNonNull;
 
-import android.annotation.CurrentTimeMillisLong;
 import android.app.Notification;
 import android.app.Notification.MessagingStyle.Message;
 import android.app.NotificationChannel;
@@ -96,7 +95,6 @@
     private final String mKey;
     private StatusBarNotification mSbn;
     private Ranking mRanking;
-    private long mCreationTime;
 
     /*
      * Bookkeeping members
@@ -198,11 +196,10 @@
             boolean allowFgsDismissal,
             long creationTime
     ) {
-        super(requireNonNull(requireNonNull(sbn).getKey()));
+        super(requireNonNull(requireNonNull(sbn).getKey()), creationTime);
 
         requireNonNull(ranking);
 
-        mCreationTime = creationTime;
         mKey = sbn.getKey();
         setSbn(sbn);
         setRanking(ranking);
@@ -255,21 +252,6 @@
     }
 
     /**
-     * A timestamp of SystemClock.uptimeMillis() of when this entry was first created, regardless
-     * of any changes to the data presented. It is set once on creation and will never change, and
-     * allows us to know exactly how long this notification has been alive for in our listener
-     * service. It is entirely unrelated to the information inside of the notification.
-     *
-     * This is different to Notification#when because it persists throughout updates, whereas
-     * system server treats every single call to notify() as a new notification and we handle
-     * updates to NotificationEntry locally.
-     */
-    @CurrentTimeMillisLong
-    public long getCreationTime() {
-        return mCreationTime;
-    }
-
-    /**
      * Should only be called by NotificationEntryManager and friends.
      * TODO: Make this package-private
      */
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ShadeListBuilder.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ShadeListBuilder.java
index a86ab41..6f78411 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ShadeListBuilder.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ShadeListBuilder.java
@@ -47,6 +47,7 @@
 import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifFilter;
 import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifPromoter;
 import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifSection;
+import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.Pluggable;
 import com.android.systemui.statusbar.notification.collection.notifcollection.CollectionReadyForBuildListener;
 import com.android.systemui.util.Assert;
 import com.android.systemui.util.time.SystemClock;
@@ -321,6 +322,7 @@
         mPipelineState.incrementTo(STATE_FINALIZING);
         logChanges();
         freeEmptyGroups();
+        cleanupPluggables();
 
         // Step 8: Dispatch the new list, first to any listeners and then to the view layer
         dispatchOnBeforeRenderList(mReadOnlyNotifList);
@@ -421,7 +423,7 @@
 
                 GroupEntry group = mGroups.get(topLevelKey);
                 if (group == null) {
-                    group = new GroupEntry(topLevelKey);
+                    group = new GroupEntry(topLevelKey, mSystemClock.uptimeMillis());
                     group.mFirstAddedIteration = mIterationCount;
                     mGroups.put(topLevelKey, group);
                 }
@@ -673,6 +675,20 @@
         }
     }
 
+    private void cleanupPluggables() {
+        callOnCleanup(mNotifPreGroupFilters);
+        callOnCleanup(mNotifPromoters);
+        callOnCleanup(mNotifFinalizeFilters);
+        callOnCleanup(mNotifComparators);
+        callOnCleanup(mNotifSections);
+    }
+
+    private void callOnCleanup(List<? extends Pluggable<?>> pluggables) {
+        for (int i = 0; i < pluggables.size(); i++) {
+            pluggables.get(i).onCleanup();
+        }
+    }
+
     private final Comparator<ListEntry> mTopLevelComparator = (o1, o2) -> {
 
         int cmp = Integer.compare(o1.getSection(), o2.getSection());
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinator.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinator.java
index 4159d43..9baaddd 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinator.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinator.java
@@ -18,6 +18,8 @@
 
 import static com.android.systemui.statusbar.notification.stack.NotificationChildrenContainer.NUMBER_OF_CHILDREN_WHEN_CHILDREN_EXPANDED;
 
+import static java.util.Objects.requireNonNull;
+
 import android.annotation.IntDef;
 import android.os.RemoteException;
 import android.service.notification.StatusBarNotification;
@@ -28,7 +30,6 @@
 import com.android.internal.statusbar.IStatusBarService;
 import com.android.systemui.statusbar.notification.collection.GroupEntry;
 import com.android.systemui.statusbar.notification.collection.ListEntry;
-import com.android.systemui.statusbar.notification.collection.NotifInflaterImpl;
 import com.android.systemui.statusbar.notification.collection.NotifPipeline;
 import com.android.systemui.statusbar.notification.collection.NotifViewBarn;
 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
@@ -38,12 +39,12 @@
 import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifFilter;
 import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener;
 import com.android.systemui.statusbar.notification.row.NotifInflationErrorManager;
+import com.android.systemui.statusbar.notification.row.NotifInflationErrorManager.NotifInflationErrorListener;
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.Set;
 
 import javax.inject.Inject;
@@ -81,35 +82,48 @@
      */
     private final int mChildBindCutoff;
 
+    /** How long we can delay a group while waiting for all children to inflate */
+    private final long mMaxGroupInflationDelay;
+
     @Inject
     public PreparationCoordinator(
             PreparationCoordinatorLogger logger,
-            NotifInflaterImpl notifInflater,
+            NotifInflater notifInflater,
             NotifInflationErrorManager errorManager,
             NotifViewBarn viewBarn,
             IStatusBarService service) {
-        this(logger, notifInflater, errorManager, viewBarn, service, CHILD_BIND_CUTOFF);
+        this(
+                logger,
+                notifInflater,
+                errorManager,
+                viewBarn,
+                service,
+                CHILD_BIND_CUTOFF,
+                MAX_GROUP_INFLATION_DELAY);
     }
 
     @VisibleForTesting
     PreparationCoordinator(
             PreparationCoordinatorLogger logger,
-            NotifInflaterImpl notifInflater,
+            NotifInflater notifInflater,
             NotifInflationErrorManager errorManager,
             NotifViewBarn viewBarn,
             IStatusBarService service,
-            int childBindCutoff) {
+            int childBindCutoff,
+            long maxGroupInflationDelay) {
         mLogger = logger;
         mNotifInflater = notifInflater;
         mNotifErrorManager = errorManager;
-        mNotifErrorManager.addInflationErrorListener(mInflationErrorListener);
         mViewBarn = viewBarn;
         mStatusBarService = service;
         mChildBindCutoff = childBindCutoff;
+        mMaxGroupInflationDelay = maxGroupInflationDelay;
     }
 
     @Override
     public void attach(NotifPipeline pipeline) {
+        mNotifErrorManager.addInflationErrorListener(mInflationErrorListener);
+
         pipeline.addCollectionListener(mNotifCollectionListener);
         // Inflate after grouping/sorting since that affects what views to inflate.
         pipeline.addOnBeforeFinalizeFilterListener(mOnBeforeFinalizeFilterListener);
@@ -127,7 +141,6 @@
         @Override
         public void onEntryUpdated(NotificationEntry entry) {
             abortInflation(entry, "entryUpdated");
-            mInflatingNotifs.remove(entry);
             @InflationState int state = getInflationState(entry);
             if (state == STATE_INFLATED) {
                 mInflationStates.put(entry, STATE_INFLATED_INVALID);
@@ -145,7 +158,6 @@
         @Override
         public void onEntryCleanUp(NotificationEntry entry) {
             mInflationStates.remove(entry);
-            mInflatingNotifs.remove(entry);
             mViewBarn.removeViewForEntry(entry);
         }
     };
@@ -165,17 +177,32 @@
     };
 
     private final NotifFilter mNotifInflatingFilter = new NotifFilter(TAG + "Inflating") {
+        private final Map<GroupEntry, Boolean> mIsDelayedGroupCache = new ArrayMap<>();
+
         /**
-         * Filters out notifications that aren't inflated
+         * Filters out notifications that either (a) aren't inflated or (b) are part of a group
+         * that isn't completely inflated yet
          */
         @Override
         public boolean shouldFilterOut(NotificationEntry entry, long now) {
-            return !isInflated(entry);
+            final GroupEntry parent = requireNonNull(entry.getParent());
+            Boolean isMemberOfDelayedGroup = mIsDelayedGroupCache.get(parent);
+            if (isMemberOfDelayedGroup == null) {
+                isMemberOfDelayedGroup = shouldWaitForGroupToInflate(parent, now);
+                mIsDelayedGroupCache.put(parent, isMemberOfDelayedGroup);
+            }
+
+            return !isInflated(entry) || isMemberOfDelayedGroup;
+        }
+
+        @Override
+        public void onCleanup() {
+            mIsDelayedGroupCache.clear();
         }
     };
 
-    private final NotifInflationErrorManager.NotifInflationErrorListener mInflationErrorListener =
-            new NotifInflationErrorManager.NotifInflationErrorListener() {
+    private final NotifInflationErrorListener mInflationErrorListener =
+            new NotifInflationErrorListener() {
         @Override
         public void onNotifInflationError(NotificationEntry entry, Exception e) {
             mViewBarn.removeViewForEntry(entry);
@@ -191,8 +218,9 @@
                         sbn.getUid(),
                         sbn.getInitialPid(),
                         e.getMessage(),
-                        sbn.getUserId());
+                        sbn.getUser().getIdentifier());
             } catch (RemoteException ex) {
+                // System server is dead, nothing to do about that
             }
             mNotifInflationErrorFilter.invalidateList();
         }
@@ -297,11 +325,36 @@
 
     private @InflationState int getInflationState(NotificationEntry entry) {
         Integer stateObj = mInflationStates.get(entry);
-        Objects.requireNonNull(stateObj,
+        requireNonNull(stateObj,
                 "Asking state of a notification preparation coordinator doesn't know about");
         return stateObj;
     }
 
+    private boolean shouldWaitForGroupToInflate(GroupEntry group, long now) {
+        if (group == GroupEntry.ROOT_ENTRY || group.hasBeenAttachedBefore()) {
+            return false;
+        }
+        if (isBeyondGroupInitializationWindow(group, now)) {
+            mLogger.logGroupInflationTookTooLong(group.getKey());
+            return false;
+        }
+        if (mInflatingNotifs.contains(group.getSummary())) {
+            mLogger.logDelayingGroupRelease(group.getKey(), group.getSummary().getKey());
+            return true;
+        }
+        for (NotificationEntry child : group.getChildren()) {
+            if (mInflatingNotifs.contains(child) && !child.hasBeenAttachedBefore()) {
+                mLogger.logDelayingGroupRelease(group.getKey(), child.getKey());
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean isBeyondGroupInitializationWindow(GroupEntry entry, long now) {
+        return now - entry.getCreationTime() > mMaxGroupInflationDelay;
+    }
+
     @Retention(RetentionPolicy.SOURCE)
     @IntDef(prefix = {"STATE_"},
             value = {STATE_UNINFLATED, STATE_INFLATED_INVALID, STATE_INFLATED, STATE_ERROR})
@@ -328,6 +381,8 @@
      */
     private static final int EXTRA_VIEW_BUFFER_COUNT = 1;
 
+    private static final long MAX_GROUP_INFLATION_DELAY = 500;
+
     private static final int CHILD_BIND_CUTOFF =
             NUMBER_OF_CHILDREN_WHEN_CHILDREN_EXPANDED + EXTRA_VIEW_BUFFER_COUNT;
 }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinatorLogger.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinatorLogger.kt
index 75e7bc9..dd4794f 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinatorLogger.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinatorLogger.kt
@@ -40,6 +40,23 @@
             "NOTIF INFLATION ABORTED $str1 reason=$str2"
         })
     }
+
+    fun logGroupInflationTookTooLong(groupKey: String) {
+        buffer.log(TAG, LogLevel.WARNING, {
+            str1 = groupKey
+        }, {
+            "Group inflation took too long far $str1, releasing children early"
+        })
+    }
+
+    fun logDelayingGroupRelease(groupKey: String, childKey: String) {
+        buffer.log(TAG, LogLevel.DEBUG, {
+            str1 = groupKey
+            str2 = childKey
+        }, {
+            "Delaying release of group $str1 because child $str2 is still inflating"
+        })
+    }
 }
 
 private const val TAG = "PreparationCoordinator"
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/pluggable/Pluggable.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/pluggable/Pluggable.java
index 4270408..8e4fb75 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/pluggable/Pluggable.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/pluggable/Pluggable.java
@@ -55,11 +55,17 @@
     }
 
     /** Set a listener to be notified when a pluggable is invalidated. */
-    public void setInvalidationListener(PluggableListener<This> listener) {
+    public final void setInvalidationListener(PluggableListener<This> listener) {
         mListener = listener;
     }
 
     /**
+     * Called on the pluggable once at the end of every pipeline run. Override this method to
+     * perform any necessary cleanup.
+     */
+    public void onCleanup() { }
+
+    /**
      * Listener interface for when pluggables are invalidated.
      *
      * @param <T> The type of pluggable that is being listened to.
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/dagger/NotificationsModule.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/dagger/NotificationsModule.java
index d661b5e..ee471c3 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/dagger/NotificationsModule.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/dagger/NotificationsModule.java
@@ -40,8 +40,10 @@
 import com.android.systemui.statusbar.notification.NotificationEntryManager;
 import com.android.systemui.statusbar.notification.NotificationEntryManagerLogger;
 import com.android.systemui.statusbar.notification.VisualStabilityManager;
+import com.android.systemui.statusbar.notification.collection.NotifInflaterImpl;
 import com.android.systemui.statusbar.notification.collection.NotifPipeline;
 import com.android.systemui.statusbar.notification.collection.NotificationRankingManager;
+import com.android.systemui.statusbar.notification.collection.inflation.NotifInflater;
 import com.android.systemui.statusbar.notification.collection.inflation.NotificationRowBinder;
 import com.android.systemui.statusbar.notification.collection.notifcollection.CommonNotifCollection;
 import com.android.systemui.statusbar.notification.collection.provider.HighPriorityProvider;
@@ -221,4 +223,8 @@
     @Binds
     NotificationInterruptStateProvider bindNotificationInterruptStateProvider(
             NotificationInterruptStateProviderImpl notificationInterruptStateProviderImpl);
+
+    /** */
+    @Binds
+    NotifInflater bindNotifInflater(NotifInflaterImpl notifInflaterImpl);
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/EntryUtil.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/EntryUtil.kt
new file mode 100644
index 0000000..62667bc
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/EntryUtil.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.statusbar.notification.collection
+
+/**
+ * Modifies a NotificationEntry
+ *
+ * The [modifier] function will be passed an instance of a NotificationEntryBuilder. Any
+ * modifications made to the builder will be applied to the [entry].
+ */
+inline fun modifyEntry(
+    entry: NotificationEntry,
+    crossinline modifier: NotificationEntryBuilder.() -> Unit
+) {
+    val builder = NotificationEntryBuilder(entry)
+    modifier(builder)
+    builder.apply(entry)
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/GroupEntryBuilder.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/GroupEntryBuilder.java
new file mode 100644
index 0000000..2971c05
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/GroupEntryBuilder.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.statusbar.notification.collection;
+
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Builder to construct instances of {@link GroupEntry} for tests.
+ */
+public class GroupEntryBuilder {
+    private String mKey = "test_group_key";
+    private long mCreationTime = 0;
+    @Nullable private GroupEntry mParent = GroupEntry.ROOT_ENTRY;
+    private NotificationEntry mSummary = null;
+    private List<NotificationEntry> mChildren = new ArrayList<>();
+
+    /** Builds a new instance of GroupEntry */
+    public GroupEntry build() {
+        GroupEntry ge = new GroupEntry(mKey, mCreationTime);
+        ge.setParent(mParent);
+
+        ge.setSummary(mSummary);
+        mSummary.setParent(ge);
+
+        for (NotificationEntry child : mChildren) {
+            ge.addChild(child);
+            child.setParent(ge);
+        }
+        return ge;
+    }
+
+    public GroupEntryBuilder setKey(String key) {
+        mKey = key;
+        return this;
+    }
+
+    public GroupEntryBuilder setCreationTime(long creationTime) {
+        mCreationTime = creationTime;
+        return this;
+    }
+
+    public GroupEntryBuilder setParent(@Nullable GroupEntry entry) {
+        mParent = entry;
+        return this;
+    }
+
+    public GroupEntryBuilder setSummary(
+            NotificationEntry summary) {
+        mSummary = summary;
+        return this;
+    }
+
+    public GroupEntryBuilder setChildren(List<NotificationEntry> children) {
+        mChildren.clear();
+        mChildren.addAll(children);
+        return this;
+    }
+
+    /** Adds a child to the existing list of children */
+    public GroupEntryBuilder addChild(NotificationEntry entry) {
+        mChildren.add(entry);
+        return this;
+    }
+
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/GroupEntryHelper.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/GroupEntryHelper.java
deleted file mode 100644
index 038dd17..0000000
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/GroupEntryHelper.java
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.systemui.statusbar.notification.collection;
-
-import java.util.List;
-
-/**
- * Helper class to provide methods for test classes that need {@link GroupEntry}'s for their tests.
- */
-public class GroupEntryHelper {
-    /**
-     * Create a group entry for testing purposes.
-     * @param groupKey group key for the group and all its entries
-     * @param summary summary notification for group
-     * @param children group's children notifications
-     */
-    public static final GroupEntry createGroup(
-            String groupKey,
-            NotificationEntry summary,
-            List<NotificationEntry> children) {
-        GroupEntry groupEntry = new GroupEntry(groupKey);
-        groupEntry.setSummary(summary);
-        for (NotificationEntry child : children) {
-            groupEntry.addChild(child);
-        }
-        return groupEntry;
-    }
-}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/HighPriorityProviderTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/HighPriorityProviderTest.java
index 2b12c22..386c866c 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/HighPriorityProviderTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/HighPriorityProviderTest.java
@@ -191,9 +191,10 @@
     @Test
     public void testIsHighPriority_summaryUpdated() {
         // GIVEN a GroupEntry with a lowPrioritySummary and no children
-        final GroupEntry parentEntry = new GroupEntry("test_group_key");
         final NotificationEntry lowPrioritySummary = createNotifEntry(false);
-        setSummary(parentEntry, lowPrioritySummary);
+        final GroupEntry parentEntry = new GroupEntryBuilder()
+                .setSummary(lowPrioritySummary)
+                .build();
         assertFalse(mHighPriorityProvider.isHighPriority(parentEntry));
 
         // WHEN the summary changes to high priority
@@ -214,10 +215,11 @@
         // GroupEntry = parentEntry, summary = lowPrioritySummary
         //      NotificationEntry = lowPriorityChild
         //      NotificationEntry = highPriorityChild
-        final GroupEntry parentEntry = new GroupEntry("test_group_key");
-        setSummary(parentEntry, createNotifEntry(false));
-        addChild(parentEntry, createNotifEntry(false));
-        addChild(parentEntry, createNotifEntry(true));
+        final GroupEntry parentEntry = new GroupEntryBuilder()
+                .setSummary(createNotifEntry(false))
+                .addChild(createNotifEntry(false))
+                .addChild(createNotifEntry(true))
+                .build();
 
         // THEN the GroupEntry parentEntry is high priority since it has a high priority child
         assertTrue(mHighPriorityProvider.isHighPriority(parentEntry));
@@ -228,10 +230,11 @@
         // GIVEN:
         // GroupEntry = parentEntry, summary = lowPrioritySummary
         //      NotificationEntry = lowPriorityChild
-        final GroupEntry parentEntry = new GroupEntry("test_group_key");
         final NotificationEntry lowPriorityChild = createNotifEntry(false);
-        setSummary(parentEntry, createNotifEntry(false));
-        addChild(parentEntry, lowPriorityChild);
+        final GroupEntry parentEntry = new GroupEntryBuilder()
+                .setSummary(createNotifEntry(false))
+                .addChild(lowPriorityChild)
+                .build();
 
         // WHEN the child entry ranking changes to high priority
         lowPriorityChild.setRanking(
@@ -250,14 +253,4 @@
                 .setImportance(highPriority ? IMPORTANCE_HIGH : IMPORTANCE_MIN)
                 .build();
     }
-
-    private void setSummary(GroupEntry parent, NotificationEntry summary) {
-        parent.setSummary(summary);
-        summary.setParent(parent);
-    }
-
-    private void addChild(GroupEntry parent, NotificationEntry child) {
-        parent.addChild(child);
-        child.setParent(parent);
-    }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/NotificationEntryBuilder.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/NotificationEntryBuilder.java
index 4a7c6c6..1523653 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/NotificationEntryBuilder.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/NotificationEntryBuilder.java
@@ -23,6 +23,7 @@
 import android.content.Context;
 import android.content.pm.ShortcutInfo;
 import android.os.UserHandle;
+import android.service.notification.NotificationListenerService.Ranking;
 import android.service.notification.SnoozeCriterion;
 import android.service.notification.StatusBarNotification;
 
@@ -33,6 +34,8 @@
 
 import java.util.ArrayList;
 
+import kotlin.Unit;
+
 /**
  * Combined builder for constructing a NotificationEntry and its associated StatusBarNotification
  * and Ranking. Is largely a proxy for the SBN and Ranking builders, but does a little extra magic
@@ -43,8 +46,8 @@
  * Only for use in tests.
  */
 public class NotificationEntryBuilder {
-    private final SbnBuilder mSbnBuilder = new SbnBuilder();
-    private final RankingBuilder mRankingBuilder = new RankingBuilder();
+    private final SbnBuilder mSbnBuilder;
+    private final RankingBuilder mRankingBuilder;
     private final FakeSystemClock mClock = new FakeSystemClock();
     private StatusBarNotification mSbn = null;
 
@@ -55,12 +58,49 @@
     /* If set, use this creation time instead of mClock.uptimeMillis */
     private long mCreationTime = -1;
 
+    public NotificationEntryBuilder() {
+        mSbnBuilder = new SbnBuilder();
+        mRankingBuilder = new RankingBuilder();
+    }
+
+    public NotificationEntryBuilder(NotificationEntry source) {
+        mSbnBuilder = new SbnBuilder(source.getSbn());
+        mRankingBuilder = new RankingBuilder(source.getRanking());
+
+        mParent = source.getParent();
+        mSection = source.getSection();
+        mCreationTime = source.getCreationTime();
+    }
+
+    /** Build a new instance of NotificationEntry */
     public NotificationEntry build() {
-        StatusBarNotification sbn = mSbn != null ? mSbn : mSbnBuilder.build();
-        mRankingBuilder.setKey(sbn.getKey());
-        long creationTime = mCreationTime != -1 ? mCreationTime : mClock.uptimeMillis();
-        final NotificationEntry entry = new NotificationEntry(
-                sbn, mRankingBuilder.build(), mClock.uptimeMillis());
+        return buildOrApply(null);
+    }
+
+    /** Modifies [target] to match the contents of this builder */
+    public void apply(NotificationEntry target) {
+        buildOrApply(target);
+    }
+
+    /** Convenience method for Kotlin callbacks that are passed a builder and need to return Unit */
+    public Unit done() {
+        return Unit.INSTANCE;
+    }
+
+    private NotificationEntry buildOrApply(NotificationEntry target) {
+        final StatusBarNotification sbn = mSbn != null ? mSbn : mSbnBuilder.build();
+        final Ranking ranking = mRankingBuilder.setKey(sbn.getKey()).build();
+        final long creationTime = mCreationTime != -1 ? mCreationTime : mClock.uptimeMillis();
+
+        final NotificationEntry entry;
+        if (target == null) {
+            entry = new NotificationEntry(sbn, ranking, creationTime);
+        } else {
+            entry = target;
+            entry.setSbn(sbn);
+            entry.setRanking(ranking);
+            // Note: we can't modify the creation time as it's immutable
+        }
 
         /* ListEntry properties */
         entry.setParent(mParent);
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/KeyguardCoordinatorTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/KeyguardCoordinatorTest.java
index f54252e..917c049 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/KeyguardCoordinatorTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/KeyguardCoordinatorTest.java
@@ -21,9 +21,10 @@
 import static android.app.NotificationManager.IMPORTANCE_HIGH;
 import static android.app.NotificationManager.IMPORTANCE_MIN;
 
-import static junit.framework.Assert.assertFalse;
-import static junit.framework.Assert.assertTrue;
+import static com.android.systemui.statusbar.notification.collection.EntryUtilKt.modifyEntry;
 
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -41,6 +42,7 @@
 import com.android.systemui.statusbar.NotificationLockscreenUserManager;
 import com.android.systemui.statusbar.RankingBuilder;
 import com.android.systemui.statusbar.notification.collection.GroupEntry;
+import com.android.systemui.statusbar.notification.collection.GroupEntryBuilder;
 import com.android.systemui.statusbar.notification.collection.NotifPipeline;
 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
 import com.android.systemui.statusbar.notification.collection.NotificationEntryBuilder;
@@ -71,13 +73,12 @@
     @Mock private NotifPipeline mNotifPipeline;
 
     private NotificationEntry mEntry;
-    private KeyguardCoordinator mKeyguardCoordinator;
     private NotifFilter mKeyguardFilter;
 
     @Before
     public void setup() {
         MockitoAnnotations.initMocks(this);
-        mKeyguardCoordinator = new KeyguardCoordinator(
+        KeyguardCoordinator keyguardCoordinator = new KeyguardCoordinator(
                 mContext, mMainHandler, mKeyguardStateController, mLockscreenUserManager,
                 mBroadcastDispatcher, mStatusBarStateController,
                 mKeyguardUpdateMonitor, mHighPriorityProvider);
@@ -87,7 +88,7 @@
                 .build();
 
         ArgumentCaptor<NotifFilter> filterCaptor = ArgumentCaptor.forClass(NotifFilter.class);
-        mKeyguardCoordinator.attach(mNotifPipeline);
+        keyguardCoordinator.attach(mNotifPipeline);
         verify(mNotifPipeline, times(1)).addFinalizeFilter(filterCaptor.capture());
         mKeyguardFilter = filterCaptor.getValue();
     }
@@ -186,12 +187,18 @@
     public void summaryExceedsThresholdToShow() {
         // GIVEN the notification doesn't exceed the threshold to show on the lockscreen
         // but it's part of a group (has a parent)
-        final GroupEntry parent = new GroupEntry("test_group_key");
         final NotificationEntry entryWithParent = new NotificationEntryBuilder()
-                .setParent(parent)
                 .setUser(new UserHandle(NOTIF_USER_ID))
                 .build();
 
+        final GroupEntry parent = new GroupEntryBuilder()
+                .setKey("test_group_key")
+                .setSummary(new NotificationEntryBuilder()
+                        .setImportance(IMPORTANCE_HIGH)
+                        .build())
+                .addChild(entryWithParent)
+                .build();
+
         setupUnfilteredState(entryWithParent);
         entryWithParent.setRanking(new RankingBuilder()
                 .setKey(entryWithParent.getKey())
@@ -200,18 +207,15 @@
 
         // WHEN its parent does exceed threshold tot show on the lockscreen
         when(mHighPriorityProvider.isHighPriority(parent)).thenReturn(true);
-        parent.setSummary(new NotificationEntryBuilder()
-                .setImportance(IMPORTANCE_HIGH)
-                .build());
 
         // THEN don't filter out the entry
         assertFalse(mKeyguardFilter.shouldFilterOut(entryWithParent, 0));
 
         // WHEN its parent doesn't exceed threshold to show on lockscreen
         when(mHighPriorityProvider.isHighPriority(parent)).thenReturn(false);
-        parent.setSummary(new NotificationEntryBuilder()
+        modifyEntry(parent.getSummary(), builder -> builder
                 .setImportance(IMPORTANCE_MIN)
-                .build());
+                .done());
 
         // THEN filter out the entry
         assertTrue(mKeyguardFilter.shouldFilterOut(entryWithParent, 0));
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinatorTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinatorTest.java
index faf9da3..37561c4 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinatorTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/coordinator/PreparationCoordinatorTest.java
@@ -16,6 +16,8 @@
 
 package com.android.systemui.statusbar.notification.collection.coordinator;
 
+import static com.android.systemui.statusbar.notification.collection.GroupEntry.ROOT_ENTRY;
+
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
@@ -25,6 +27,8 @@
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
+import static java.util.Objects.requireNonNull;
+
 import android.os.RemoteException;
 import android.testing.AndroidTestingRunner;
 import android.testing.TestableLooper;
@@ -34,12 +38,13 @@
 import com.android.internal.statusbar.IStatusBarService;
 import com.android.systemui.SysuiTestCase;
 import com.android.systemui.statusbar.notification.collection.GroupEntry;
-import com.android.systemui.statusbar.notification.collection.GroupEntryHelper;
-import com.android.systemui.statusbar.notification.collection.NotifInflaterImpl;
+import com.android.systemui.statusbar.notification.collection.GroupEntryBuilder;
+import com.android.systemui.statusbar.notification.collection.ListEntry;
 import com.android.systemui.statusbar.notification.collection.NotifPipeline;
 import com.android.systemui.statusbar.notification.collection.NotifViewBarn;
 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
 import com.android.systemui.statusbar.notification.collection.NotificationEntryBuilder;
+import com.android.systemui.statusbar.notification.collection.inflation.NotifInflater;
 import com.android.systemui.statusbar.notification.collection.listbuilder.OnBeforeFinalizeFilterListener;
 import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifFilter;
 import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener;
@@ -52,19 +57,17 @@
 import org.mockito.Captor;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
+import org.mockito.Spy;
 
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 @SmallTest
 @RunWith(AndroidTestingRunner.class)
 @TestableLooper.RunWithLooper
 public class PreparationCoordinatorTest extends SysuiTestCase {
-    private static final String TEST_MESSAGE = "TEST_MESSAGE";
-    private static final String TEST_GROUP_KEY = "TEST_GROUP_KEY";
-    private static final int TEST_CHILD_BIND_CUTOFF = 9;
-
-    private PreparationCoordinator mCoordinator;
     private NotifCollectionListener mCollectionListener;
     private OnBeforeFinalizeFilterListener mBeforeFilterListener;
     private NotifFilter mUninflatedFilter;
@@ -75,30 +78,31 @@
 
     @Captor private ArgumentCaptor<NotifCollectionListener> mCollectionListenerCaptor;
     @Captor private ArgumentCaptor<OnBeforeFinalizeFilterListener> mBeforeFilterListenerCaptor;
-    @Captor private ArgumentCaptor<NotifInflaterImpl.InflationCallback> mCallbackCaptor;
+    @Captor private ArgumentCaptor<NotifInflater.InflationCallback> mCallbackCaptor;
 
     @Mock private NotifPipeline mNotifPipeline;
     @Mock private IStatusBarService mService;
-    @Mock private NotifInflaterImpl mNotifInflater;
+    @Spy private FakeNotifInflater mNotifInflater = new FakeNotifInflater();
 
     @Before
     public void setUp() {
         MockitoAnnotations.initMocks(this);
 
-        mEntry = new NotificationEntryBuilder().build();
+        mEntry = new NotificationEntryBuilder().setParent(ROOT_ENTRY).build();
         mInflationError = new Exception(TEST_MESSAGE);
         mErrorManager = new NotifInflationErrorManager();
 
-        mCoordinator = new PreparationCoordinator(
+        PreparationCoordinator coordinator = new PreparationCoordinator(
                 mock(PreparationCoordinatorLogger.class),
                 mNotifInflater,
                 mErrorManager,
                 mock(NotifViewBarn.class),
                 mService,
-                TEST_CHILD_BIND_CUTOFF);
+                TEST_CHILD_BIND_CUTOFF,
+                TEST_MAX_GROUP_DELAY);
 
         ArgumentCaptor<NotifFilter> filterCaptor = ArgumentCaptor.forClass(NotifFilter.class);
-        mCoordinator.attach(mNotifPipeline);
+        coordinator.attach(mNotifPipeline);
         verify(mNotifPipeline, times(2)).addFinalizeFilter(filterCaptor.capture());
         List<NotifFilter> filters = filterCaptor.getAllValues();
         mInflationErrorFilter = filters.get(0);
@@ -127,7 +131,7 @@
                 eq(mEntry.getSbn().getUid()),
                 eq(mEntry.getSbn().getInitialPid()),
                 eq(mInflationError.getMessage()),
-                eq(mEntry.getSbn().getUserId()));
+                eq(mEntry.getSbn().getUser().getIdentifier()));
     }
 
     @Test
@@ -199,7 +203,11 @@
                     .build();
             children.add(child);
         }
-        GroupEntry groupEntry = GroupEntryHelper.createGroup(TEST_GROUP_KEY, summary, children);
+        GroupEntry groupEntry = new GroupEntryBuilder()
+                .setKey(TEST_GROUP_KEY)
+                .setSummary(summary)
+                .setChildren(children)
+                .build();
 
         mCollectionListener.onEntryInit(summary);
         for (NotificationEntry entry : children) {
@@ -222,4 +230,139 @@
             }
         }
     }
+
+    @Test
+    public void testPartiallyInflatedGroupsAreFilteredOut() {
+        // GIVEN a newly-posted group with a summary and two children
+        final GroupEntry group = new GroupEntryBuilder()
+                .setCreationTime(400)
+                .setSummary(new NotificationEntryBuilder().setId(1).build())
+                .addChild(new NotificationEntryBuilder().setId(2).build())
+                .addChild(new NotificationEntryBuilder().setId(3).build())
+                .build();
+        fireAddEvents(List.of(group));
+        final NotificationEntry child0 = group.getChildren().get(0);
+        mBeforeFilterListener.onBeforeFinalizeFilter(List.of(group));
+
+        // WHEN one of this children finishes inflating
+        mNotifInflater.getInflateCallback(child0).onInflationFinished(child0);
+
+        // THEN the inflated child is still filtered out
+        assertTrue(mUninflatedFilter.shouldFilterOut(child0, 401));
+    }
+
+    @Test
+    public void testPartiallyInflatedGroupsAreFilteredOutSummaryVersion() {
+        // GIVEN a newly-posted group with a summary and two children
+        final GroupEntry group = new GroupEntryBuilder()
+                .setCreationTime(400)
+                .setSummary(new NotificationEntryBuilder().setId(1).build())
+                .addChild(new NotificationEntryBuilder().setId(2).build())
+                .addChild(new NotificationEntryBuilder().setId(3).build())
+                .build();
+        fireAddEvents(List.of(group));
+        final NotificationEntry summary = group.getSummary();
+        final NotificationEntry child0 = group.getChildren().get(0);
+        final NotificationEntry child1 = group.getChildren().get(1);
+        mBeforeFilterListener.onBeforeFinalizeFilter(List.of(group));
+
+        // WHEN all of the children (but not the summary) finish inflating
+        mNotifInflater.getInflateCallback(child0).onInflationFinished(child0);
+        mNotifInflater.getInflateCallback(child1).onInflationFinished(child1);
+
+        // THEN the entire group is still filtered out
+        assertTrue(mUninflatedFilter.shouldFilterOut(summary, 401));
+        assertTrue(mUninflatedFilter.shouldFilterOut(child0, 401));
+        assertTrue(mUninflatedFilter.shouldFilterOut(child1, 401));
+    }
+
+    @Test
+    public void testCompletedInflatedGroupsAreReleased() {
+        // GIVEN a newly-posted group with a summary and two children
+        final GroupEntry group = new GroupEntryBuilder()
+                .setCreationTime(400)
+                .setSummary(new NotificationEntryBuilder().setId(1).build())
+                .addChild(new NotificationEntryBuilder().setId(2).build())
+                .addChild(new NotificationEntryBuilder().setId(3).build())
+                .build();
+        fireAddEvents(List.of(group));
+        final NotificationEntry summary = group.getSummary();
+        final NotificationEntry child0 = group.getChildren().get(0);
+        final NotificationEntry child1 = group.getChildren().get(1);
+        mBeforeFilterListener.onBeforeFinalizeFilter(List.of(group));
+
+        // WHEN all of the children (and the summary) finish inflating
+        mNotifInflater.getInflateCallback(child0).onInflationFinished(child0);
+        mNotifInflater.getInflateCallback(child1).onInflationFinished(child1);
+        mNotifInflater.getInflateCallback(summary).onInflationFinished(summary);
+
+        // THEN the entire group is still filtered out
+        assertFalse(mUninflatedFilter.shouldFilterOut(summary, 401));
+        assertFalse(mUninflatedFilter.shouldFilterOut(child0, 401));
+        assertFalse(mUninflatedFilter.shouldFilterOut(child1, 401));
+    }
+
+    @Test
+    public void testPartiallyInflatedGroupsAreReleasedAfterTimeout() {
+        // GIVEN a newly-posted group with a summary and two children
+        final GroupEntry group = new GroupEntryBuilder()
+                .setCreationTime(400)
+                .setSummary(new NotificationEntryBuilder().setId(1).build())
+                .addChild(new NotificationEntryBuilder().setId(2).build())
+                .addChild(new NotificationEntryBuilder().setId(3).build())
+                .build();
+        fireAddEvents(List.of(group));
+        final NotificationEntry child0 = group.getChildren().get(0);
+        mBeforeFilterListener.onBeforeFinalizeFilter(List.of(group));
+
+        // WHEN one of this children finishes inflating and enough time passes
+        mNotifInflater.getInflateCallback(child0).onInflationFinished(child0);
+
+        // THEN the inflated child is not filtered out even though the rest of the group hasn't
+        // finished inflating yet
+        assertTrue(mUninflatedFilter.shouldFilterOut(child0, TEST_MAX_GROUP_DELAY + 1));
+    }
+
+    private static class FakeNotifInflater implements NotifInflater {
+        private Map<NotificationEntry, InflationCallback> mInflateCallbacks = new HashMap<>();
+
+        @Override
+        public void inflateViews(NotificationEntry entry, InflationCallback callback) {
+            mInflateCallbacks.put(entry, callback);
+        }
+
+        @Override
+        public void rebindViews(NotificationEntry entry, InflationCallback callback) {
+        }
+
+        @Override
+        public void abortInflation(NotificationEntry entry) {
+        }
+
+        public InflationCallback getInflateCallback(NotificationEntry entry) {
+            return requireNonNull(mInflateCallbacks.get(entry));
+        }
+    }
+
+    private void fireAddEvents(List<? extends ListEntry> entries) {
+        for (ListEntry entry : entries) {
+            if (entry instanceof GroupEntry) {
+                GroupEntry ge = (GroupEntry) entry;
+                fireAddEvents(ge.getSummary());
+                fireAddEvents(ge.getChildren());
+            } else {
+                fireAddEvents((NotificationEntry) entry);
+            }
+        }
+    }
+
+    private void fireAddEvents(NotificationEntry entry) {
+        mCollectionListener.onEntryInit(entry);
+        mCollectionListener.onEntryAdded(entry);
+    }
+
+    private static final String TEST_MESSAGE = "TEST_MESSAGE";
+    private static final String TEST_GROUP_KEY = "TEST_GROUP_KEY";
+    private static final int TEST_CHILD_BIND_CUTOFF = 9;
+    private static final int TEST_MAX_GROUP_DELAY = 100;
 }