Add NotifListBuilder to new notif pipeline

Adds the NotifListBuilder, the second half of the new notification
pipeline. The NLB is responsible for building the "notif list", the list
of notifications that are currently visible to the user. This differs
from the current list that is generated by the NEM/NotificationData in a
couple ways:

- It's grouped. Children have already been collected into their parent
groups. This means that the notif list now contains only "top-level
entries" -- i.e. either notification groups or notifications that aren't
part of a group.

- It's completely filtered. Previously, we did some filtering in
NEM/NotificationData and some filtering in ViewHierarchyManager. Now,
all filtering should take place in NotifListBuilder.

In order to build the final list, the NLB executes four distinct stages
of its pipeline:

  1. Filtering: Notifications that shouldn't be shown right now are
     excluded.
  2. Grouping: Notifications that are part of a group are clumped
     together into a single object (GroupEntry).
  3. Group transform: Groups are optionally transformed by splitting
     them apart or promoting single entries to top-level.
  4. Section assignment & sorting: top-level entries are divided into
     major "sections" (e.g. silent notifications vs. people
     notifications vs. ...) and then the contents of each section are
     sorted (as well as the contents of each group).

The NLB tries to avoid having any "business" logic in its own
implementation of the pipeline. Instead, parties that want to
participate in building the notif list can register "pluggables" that
can take part in stages 1, 2, and 4. These are:

  * NotifFilter (stage 1): A pluggable for filtering out notifs from
    the final notif list.
  * NotifPromoter (stage 3): A pluggable for "promoting" a child
    notification out of its enclosing group and up to top-level.
  * SectionsProvider (stage 4): A pluggable for determining the
    overall section that an entry belongs to.
  * NotifComparator (stage 4): A pluggable for sorting notifications
    within sections.

Whenever something about a pluggable changes so that it would like to
give a different answer than the one it gave previously, it should call
invalidate() on itself. This will trigger a new run of the pipeline.

In order to represent a list of top-level entries that might be either
single notifications or groups, this CL introduces a new object
hierarchy:

 - ListEntry (superclass)
   - NotificationEntry (subclass, pre-existing)
   - GroupEntry (subclass, new)

Thus, the output of the NLB is a List<ListEntry>. Consumers will need to
do instanceof checks on each entry to discover if it is a
NotificationEntry or a GroupEntry. We could have just allowed
NotificationEntry to have children and skipped the need for GroupEntry,
but it's usually important to force code to think about whether it needs
to examine just the summary or also the children. Some code just cares
about the summary but some really should look at the children as well,
and it's too easy to forget to think about groups if everything is a
NotificationEntry.

Test: atest
Change-Id: I86ffe97611b0cc9792b6c96f3196061b170f56b7
diff --git a/packages/CarSystemUI/src/com/android/systemui/statusbar/car/CarStatusBar.java b/packages/CarSystemUI/src/com/android/systemui/statusbar/car/CarStatusBar.java
index 10527b2..4e5a3a6 100644
--- a/packages/CarSystemUI/src/com/android/systemui/statusbar/car/CarStatusBar.java
+++ b/packages/CarSystemUI/src/com/android/systemui/statusbar/car/CarStatusBar.java
@@ -96,12 +96,12 @@
 import com.android.systemui.statusbar.VibratorHelper;
 import com.android.systemui.statusbar.notification.BypassHeadsUpNotifier;
 import com.android.systemui.statusbar.notification.DynamicPrivacyController;
-import com.android.systemui.statusbar.notification.NewNotifPipeline;
 import com.android.systemui.statusbar.notification.NotificationAlertingManager;
 import com.android.systemui.statusbar.notification.NotificationEntryManager;
 import com.android.systemui.statusbar.notification.NotificationInterruptionStateProvider;
 import com.android.systemui.statusbar.notification.NotificationWakeUpCoordinator;
 import com.android.systemui.statusbar.notification.VisualStabilityManager;
+import com.android.systemui.statusbar.notification.collection.init.NewNotifPipeline;
 import com.android.systemui.statusbar.notification.logging.NotificationLogger;
 import com.android.systemui.statusbar.notification.row.NotificationGutsManager;
 import com.android.systemui.statusbar.phone.AutoHideController;
diff --git a/packages/CarSystemUI/src/com/android/systemui/statusbar/car/CarStatusBarModule.java b/packages/CarSystemUI/src/com/android/systemui/statusbar/car/CarStatusBarModule.java
index 5418ebe..4813d6d 100644
--- a/packages/CarSystemUI/src/com/android/systemui/statusbar/car/CarStatusBarModule.java
+++ b/packages/CarSystemUI/src/com/android/systemui/statusbar/car/CarStatusBarModule.java
@@ -56,12 +56,12 @@
 import com.android.systemui.statusbar.VibratorHelper;
 import com.android.systemui.statusbar.notification.BypassHeadsUpNotifier;
 import com.android.systemui.statusbar.notification.DynamicPrivacyController;
-import com.android.systemui.statusbar.notification.NewNotifPipeline;
 import com.android.systemui.statusbar.notification.NotificationAlertingManager;
 import com.android.systemui.statusbar.notification.NotificationEntryManager;
 import com.android.systemui.statusbar.notification.NotificationInterruptionStateProvider;
 import com.android.systemui.statusbar.notification.NotificationWakeUpCoordinator;
 import com.android.systemui.statusbar.notification.VisualStabilityManager;
+import com.android.systemui.statusbar.notification.collection.init.NewNotifPipeline;
 import com.android.systemui.statusbar.notification.logging.NotificationLogger;
 import com.android.systemui.statusbar.notification.row.NotificationGutsManager;
 import com.android.systemui.statusbar.phone.AutoHideController;
diff --git a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java
index bcaad22..41ef8fc 100644
--- a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java
+++ b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java
@@ -28,6 +28,8 @@
 import com.android.systemui.recents.Recents;
 import com.android.systemui.stackdivider.Divider;
 import com.android.systemui.statusbar.CommandQueue;
+import com.android.systemui.statusbar.notification.collection.NotifListBuilderImpl;
+import com.android.systemui.statusbar.notification.collection.listbuilder.NotifListBuilder;
 import com.android.systemui.statusbar.notification.people.PeopleHubModule;
 import com.android.systemui.statusbar.phone.KeyguardLiftController;
 import com.android.systemui.statusbar.phone.StatusBar;
@@ -90,4 +92,8 @@
     @Singleton
     @Binds
     abstract SystemClock bindSystemClock(SystemClockImpl systemClock);
+
+    @Singleton
+    @Binds
+    abstract NotifListBuilder bindNotifListBuilder(NotifListBuilderImpl impl);
 }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/NewNotifPipeline.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/NewNotifPipeline.java
deleted file mode 100644
index 31921a4..0000000
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/NewNotifPipeline.java
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright (C) 2019 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;
-
-import android.util.Log;
-
-import com.android.systemui.statusbar.NotificationListener;
-import com.android.systemui.statusbar.notification.collection.NotifCollection;
-
-import javax.inject.Inject;
-import javax.inject.Singleton;
-
-/**
- * Initialization code for the new notification pipeline.
- */
-@Singleton
-public class NewNotifPipeline {
-    private final NotifCollection mNotifCollection;
-
-    @Inject
-    public NewNotifPipeline(
-            NotifCollection notifCollection) {
-        mNotifCollection = notifCollection;
-    }
-
-    /** Hooks the new pipeline up to NotificationManager */
-    public void initialize(
-            NotificationListener notificationService) {
-        mNotifCollection.attach(notificationService);
-
-        Log.d(TAG, "Notif pipeline initialized");
-    }
-
-    private static final String TAG = "NewNotifPipeline";
-}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifListBuilder.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/CollectionReadyForBuildListener.java
similarity index 96%
rename from packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifListBuilder.java
rename to packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/CollectionReadyForBuildListener.java
index 17fef68..cefb506 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifListBuilder.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/CollectionReadyForBuildListener.java
@@ -22,7 +22,7 @@
  * Interface for the class responsible for converting a NotifCollection into the final sorted,
  * filtered, and grouped list of currently visible notifications.
  */
-public interface NotifListBuilder {
+public interface CollectionReadyForBuildListener {
     /**
      * Called after the NotifCollection has received an update from NotificationManager but before
      * it dispatches any change events to its listeners. This is to inform the list builder that
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
new file mode 100644
index 0000000..f9f3266
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/GroupEntry.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2019 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 android.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * Represents a set of grouped notifications. The final notification list is usually a mix of
+ * GroupEntries and NotificationEntries.
+ */
+public class GroupEntry extends ListEntry {
+    @Nullable private NotificationEntry mSummary;
+    private final List<NotificationEntry> mChildren = new ArrayList<>();
+
+    private final List<NotificationEntry> mUnmodifiableChildren =
+            Collections.unmodifiableList(mChildren);
+
+    GroupEntry(String key) {
+        super(key);
+    }
+
+    @Override
+    public NotificationEntry getRepresentativeEntry() {
+        return mSummary;
+    }
+
+    @Nullable
+    public NotificationEntry getSummary() {
+        return mSummary;
+    }
+
+    public List<NotificationEntry> getChildren() {
+        return mUnmodifiableChildren;
+    }
+
+    void setSummary(@Nullable NotificationEntry summary) {
+        mSummary = summary;
+    }
+
+    void clearChildren() {
+        mChildren.clear();
+    }
+
+    void addChild(NotificationEntry child) {
+        mChildren.add(child);
+    }
+
+    void sortChildren(Comparator<? super NotificationEntry> c) {
+        mChildren.sort(c);
+    }
+
+    List<NotificationEntry> getRawChildren() {
+        return mChildren;
+    }
+
+    public static final GroupEntry ROOT_ENTRY = new GroupEntry("<root>");
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ListDumper.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ListDumper.java
new file mode 100644
index 0000000..e1268f6
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ListDumper.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2019 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 com.android.systemui.statusbar.notification.collection.listbuilder.NotifListBuilder;
+
+import java.util.List;
+
+
+/**
+ * Utility class for dumping the results of a {@link NotifListBuilder} to a debug string.
+ */
+public class ListDumper {
+
+    /** See class description */
+    public static String dumpList(List<ListEntry> entries) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < entries.size(); i++) {
+            ListEntry entry = entries.get(i);
+            dumpEntry(entry, Integer.toString(i), "", sb);
+            if (entry instanceof GroupEntry) {
+                GroupEntry ge = (GroupEntry) entry;
+                for (int j = 0; j < ge.getChildren().size(); j++) {
+                    dumpEntry(
+                            ge.getChildren().get(j),
+                            Integer.toString(j),
+                            INDENT,
+                            sb);
+                }
+            }
+        }
+        return sb.toString();
+    }
+
+    private static void dumpEntry(
+            ListEntry entry, String index, String indent, StringBuilder sb) {
+        sb.append(indent)
+                .append("[").append(index).append("] ")
+                .append(entry.getKey())
+                .append(" (parent=")
+                .append(entry.getParent() != null ? entry.getParent().getKey() : null)
+                .append(")\n");
+    }
+
+    private static final String INDENT = "  ";
+}
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
new file mode 100644
index 0000000..dc68c4b
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/ListEntry.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2019 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 android.annotation.Nullable;
+
+/**
+ * Abstract superclass for top-level entries, i.e. things that can appear in the final notification
+ * list shown to users. In practice, this means either GroupEntries or NotificationEntries.
+ */
+public abstract class ListEntry {
+    private final String mKey;
+
+    @Nullable private GroupEntry mParent;
+    @Nullable private GroupEntry mPreviousParent;
+    private int mSection;
+    int mFirstAddedIteration = -1;
+
+    ListEntry(String key) {
+        mKey = key;
+    }
+
+    public String getKey() {
+        return mKey;
+    }
+
+    /**
+     * Should return the "representative entry" for this ListEntry. For NotificationEntries, its
+     * the entry itself. For groups, it should be the summary. This method exists to interface with
+     * legacy code that expects groups to also be NotificationEntries.
+     */
+    public abstract NotificationEntry getRepresentativeEntry();
+
+    @Nullable public GroupEntry getParent() {
+        return mParent;
+    }
+
+    void setParent(@Nullable GroupEntry parent) {
+        mParent = parent;
+    }
+
+    @Nullable public GroupEntry getPreviousParent() {
+        return mPreviousParent;
+    }
+
+    void setPreviousParent(@Nullable GroupEntry previousParent) {
+        mPreviousParent = previousParent;
+    }
+
+    /** The section this notification was assigned to (0 to N-1, where N is number of sections). */
+    public int getSection() {
+        return mSection;
+    }
+
+    void setSection(int section) {
+        mSection = section;
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifCollection.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifCollection.java
index b551352..6f085c0 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifCollection.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifCollection.java
@@ -95,7 +95,7 @@
     private final Collection<NotificationEntry> mReadOnlyNotificationSet =
             Collections.unmodifiableCollection(mNotificationSet.values());
 
-    @Nullable private NotifListBuilder mListBuilder;
+    @Nullable private CollectionReadyForBuildListener mBuildListener;
     private final List<NotifCollectionListener> mNotifCollectionListeners = new ArrayList<>();
     private final List<NotifLifetimeExtender> mLifetimeExtenders = new ArrayList<>();
 
@@ -123,9 +123,9 @@
      * Sets the class responsible for converting the collection into the list of currently-visible
      * notifications.
      */
-    public void setListBuilder(NotifListBuilder listBuilder) {
+    public void setBuildListener(CollectionReadyForBuildListener buildListener) {
         Assert.isMainThread();
-        mListBuilder = listBuilder;
+        mBuildListener = buildListener;
     }
 
     /**
@@ -282,8 +282,8 @@
     }
 
     private void rebuildList() {
-        if (mListBuilder != null) {
-            mListBuilder.onBuildList(mReadOnlyNotificationSet);
+        if (mBuildListener != null) {
+            mBuildListener.onBuildList(mReadOnlyNotificationSet);
         }
     }
 
@@ -339,8 +339,8 @@
 
     private void dispatchOnEntryAdded(NotificationEntry entry) {
         mAmDispatchingToOtherCode = true;
-        if (mListBuilder != null) {
-            mListBuilder.onBeginDispatchToListeners();
+        if (mBuildListener != null) {
+            mBuildListener.onBeginDispatchToListeners();
         }
         for (NotifCollectionListener listener : mNotifCollectionListeners) {
             listener.onEntryAdded(entry);
@@ -350,8 +350,8 @@
 
     private void dispatchOnEntryUpdated(NotificationEntry entry) {
         mAmDispatchingToOtherCode = true;
-        if (mListBuilder != null) {
-            mListBuilder.onBeginDispatchToListeners();
+        if (mBuildListener != null) {
+            mBuildListener.onBeginDispatchToListeners();
         }
         for (NotifCollectionListener listener : mNotifCollectionListeners) {
             listener.onEntryUpdated(entry);
@@ -364,8 +364,8 @@
             @CancellationReason int reason,
             boolean removedByUser) {
         mAmDispatchingToOtherCode = true;
-        if (mListBuilder != null) {
-            mListBuilder.onBeginDispatchToListeners();
+        if (mBuildListener != null) {
+            mBuildListener.onBeginDispatchToListeners();
         }
         for (NotifCollectionListener listener : mNotifCollectionListeners) {
             listener.onEntryRemoved(entry, reason, removedByUser);
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifListBuilderImpl.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifListBuilderImpl.java
new file mode 100644
index 0000000..21a4b4f
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/NotifListBuilderImpl.java
@@ -0,0 +1,737 @@
+/*
+ * Copyright (C) 2019 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 static com.android.systemui.statusbar.notification.collection.GroupEntry.ROOT_ENTRY;
+import static com.android.systemui.statusbar.notification.collection.ListDumper.dumpList;
+import static com.android.systemui.statusbar.notification.collection.listbuilder.PipelineState.STATE_BUILD_PENDING;
+import static com.android.systemui.statusbar.notification.collection.listbuilder.PipelineState.STATE_BUILD_STARTED;
+import static com.android.systemui.statusbar.notification.collection.listbuilder.PipelineState.STATE_FILTERING;
+import static com.android.systemui.statusbar.notification.collection.listbuilder.PipelineState.STATE_FINALIZING;
+import static com.android.systemui.statusbar.notification.collection.listbuilder.PipelineState.STATE_IDLE;
+import static com.android.systemui.statusbar.notification.collection.listbuilder.PipelineState.STATE_SORTING;
+import static com.android.systemui.statusbar.notification.collection.listbuilder.PipelineState.STATE_TRANSFORMING;
+
+import android.annotation.MainThread;
+import android.annotation.Nullable;
+import android.util.ArrayMap;
+import android.util.Log;
+
+import com.android.systemui.statusbar.notification.collection.listbuilder.NotifListBuilder;
+import com.android.systemui.statusbar.notification.collection.listbuilder.OnBeforeRenderListListener;
+import com.android.systemui.statusbar.notification.collection.listbuilder.OnBeforeSortListener;
+import com.android.systemui.statusbar.notification.collection.listbuilder.OnBeforeTransformGroupsListener;
+import com.android.systemui.statusbar.notification.collection.listbuilder.PipelineState;
+import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifComparator;
+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.SectionsProvider;
+import com.android.systemui.util.Assert;
+import com.android.systemui.util.time.SystemClock;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+/**
+ * The implementation of {@link NotifListBuilder}.
+ */
+@MainThread
+@Singleton
+public class NotifListBuilderImpl implements NotifListBuilder {
+
+    private final SystemClock mSystemClock;
+
+    private final List<ListEntry> mNotifList = new ArrayList<>();
+
+    private final PipelineState mPipelineState = new PipelineState();
+    private final Map<String, GroupEntry> mGroups = new ArrayMap<>();
+    private Collection<NotificationEntry> mAllEntries = Collections.emptyList();
+    private final List<ListEntry> mNewEntries = new ArrayList<>();
+    private int mIterationCount = 0;
+
+    private final List<NotifFilter> mNotifFilters = new ArrayList<>();
+    private final List<NotifPromoter> mNotifPromoters = new ArrayList<>();
+    private final List<NotifComparator> mNotifComparators = new ArrayList<>();
+    private SectionsProvider mSectionsProvider = new DefaultSectionsProvider();
+
+    private final List<OnBeforeTransformGroupsListener> mOnBeforeTransformGroupsListeners =
+            new ArrayList<>();
+    private final List<OnBeforeSortListener> mOnBeforeSortListeners =
+            new ArrayList<>();
+    private final List<OnBeforeRenderListListener> mOnBeforeRenderListListeners =
+            new ArrayList<>();
+    @Nullable private OnRenderListListener mOnRenderListListener;
+
+    private final List<ListEntry> mReadOnlyNotifList = Collections.unmodifiableList(mNotifList);
+
+    @Inject
+    public NotifListBuilderImpl(SystemClock systemClock) {
+        Assert.isMainThread();
+        mSystemClock = systemClock;
+    }
+
+    /**
+     * Attach the list builder to the NotifCollection. After this is called, it will start building
+     * the notif list in response to changes to the colletion.
+     */
+    public void attach(NotifCollection collection) {
+        Assert.isMainThread();
+        collection.setBuildListener(mReadyForBuildListener);
+    }
+
+    /**
+     * Registers the listener that's responsible for rendering the notif list to the screen. Called
+     * At the very end of pipeline execution, after all other listeners and pluggables have fired.
+     */
+    public void setOnRenderListListener(OnRenderListListener onRenderListListener) {
+        Assert.isMainThread();
+
+        mPipelineState.requireState(STATE_IDLE);
+        mOnRenderListListener = onRenderListListener;
+    }
+
+    @Override
+    public void addOnBeforeTransformGroupsListener(OnBeforeTransformGroupsListener listener) {
+        Assert.isMainThread();
+
+        mPipelineState.requireState(STATE_IDLE);
+        mOnBeforeTransformGroupsListeners.add(listener);
+    }
+
+    @Override
+    public void addOnBeforeSortListener(OnBeforeSortListener listener) {
+        Assert.isMainThread();
+
+        mPipelineState.requireState(STATE_IDLE);
+        mOnBeforeSortListeners.add(listener);
+    }
+
+    @Override
+    public void addOnBeforeRenderListListener(OnBeforeRenderListListener listener) {
+        Assert.isMainThread();
+
+        mPipelineState.requireState(STATE_IDLE);
+        mOnBeforeRenderListListeners.add(listener);
+    }
+
+    @Override
+    public void addFilter(NotifFilter filter) {
+        Assert.isMainThread();
+        mPipelineState.requireState(STATE_IDLE);
+
+        mNotifFilters.add(filter);
+        filter.setInvalidationListener(this::onFilterInvalidated);
+    }
+
+    @Override
+    public void addPromoter(NotifPromoter promoter) {
+        Assert.isMainThread();
+        mPipelineState.requireState(STATE_IDLE);
+
+        mNotifPromoters.add(promoter);
+        promoter.setInvalidationListener(this::onPromoterInvalidated);
+    }
+
+    @Override
+    public void setSectionsProvider(SectionsProvider provider) {
+        Assert.isMainThread();
+        mPipelineState.requireState(STATE_IDLE);
+
+        mSectionsProvider = provider;
+        provider.setInvalidationListener(this::onSectionsProviderInvalidated);
+    }
+
+    @Override
+    public void setComparators(List<NotifComparator> comparators) {
+        Assert.isMainThread();
+        mPipelineState.requireState(STATE_IDLE);
+
+        mNotifComparators.clear();
+        for (NotifComparator comparator : comparators) {
+            mNotifComparators.add(comparator);
+            comparator.setInvalidationListener(this::onNotifComparatorInvalidated);
+        }
+    }
+
+    @Override
+    public List<ListEntry> getActiveNotifs() {
+        Assert.isMainThread();
+        return mReadOnlyNotifList;
+    }
+
+    private final CollectionReadyForBuildListener mReadyForBuildListener =
+            new CollectionReadyForBuildListener() {
+                @Override
+                public void onBeginDispatchToListeners() {
+                    Assert.isMainThread();
+                    mPipelineState.incrementTo(STATE_BUILD_PENDING);
+                }
+
+                @Override
+                public void onBuildList(Collection<NotificationEntry> entries) {
+                    Assert.isMainThread();
+                    mPipelineState.requireIsBefore(STATE_BUILD_STARTED);
+
+                    Log.i(TAG, "Build request received from NotifCollection");
+                    mAllEntries = entries;
+                    buildList();
+                }
+            };
+
+    private void onFilterInvalidated(NotifFilter filter) {
+        Assert.isMainThread();
+
+        // TODO: Convert these log statements (here and elsewhere) into timeline logging
+        Log.i(TAG, String.format(
+                "Filter \"%s\" invalidated; pipeline state is %d",
+                filter.getName(),
+                mPipelineState.getState()));
+
+        rebuildListIfBefore(STATE_FILTERING);
+    }
+
+    private void onPromoterInvalidated(NotifPromoter filter) {
+        Assert.isMainThread();
+
+        Log.i(TAG, String.format(
+                "NotifPromoter \"%s\" invalidated; pipeline state is %d",
+                filter.getName(),
+                mPipelineState.getState()));
+
+        rebuildListIfBefore(STATE_TRANSFORMING);
+    }
+
+    private void onSectionsProviderInvalidated(SectionsProvider provider) {
+        Assert.isMainThread();
+
+        Log.i(TAG, String.format(
+                "Sections provider \"%s\" invalidated; pipeline state is %d",
+                provider.getName(),
+                mPipelineState.getState()));
+
+        rebuildListIfBefore(STATE_SORTING);
+    }
+
+    private void onNotifComparatorInvalidated(NotifComparator comparator) {
+        Assert.isMainThread();
+
+        Log.i(TAG, String.format(
+                "Comparator \"%s\" invalidated; pipeline state is %d",
+                comparator.getName(),
+                mPipelineState.getState()));
+
+        rebuildListIfBefore(STATE_SORTING);
+    }
+
+    /**
+     * The core algorithm of the pipeline. See the top comment in {@link NotifListBuilder} for
+     * details on our contracts with other code.
+     *
+     * Once the build starts we are very careful to protect against reentrant code. Anything that
+     * tries to invalidate itself after the pipeline has passed it by will return in an exception.
+     * In general, we should be extremely sensitive to client code doing things in the wrong order;
+     * if we detect that behavior, we should crash instantly.
+     */
+    private void buildList() {
+        Log.i(TAG, "Starting notif list build #" + mIterationCount + "...");
+
+        mPipelineState.requireIsBefore(STATE_BUILD_STARTED);
+        mPipelineState.setState(STATE_BUILD_STARTED);
+
+        // Step 1: Filtering and initial grouping
+        // Filter out any notifs that shouldn't be shown right now and cluster any that are part of
+        // a group
+        mPipelineState.incrementTo(STATE_FILTERING);
+        mNotifList.clear();
+        mNewEntries.clear();
+        filterAndGroup(mAllEntries, mNotifList, mNewEntries);
+        pruneIncompleteGroups(mNotifList, mNewEntries);
+
+        // Step 2: Group transforming
+        // Move some notifs out of their groups and up to top-level (mostly used for heads-upping)
+        dispatchOnBeforeTransformGroups(mReadOnlyNotifList, mNewEntries);
+        mPipelineState.incrementTo(STATE_TRANSFORMING);
+        promoteNotifs(mNotifList);
+        pruneIncompleteGroups(mNotifList, mNewEntries);
+
+        // Step 3: Sort
+        // Assign each top-level entry a section, then sort the list by section and then within
+        // section by our list of custom comparators
+        dispatchOnBeforeSort(mReadOnlyNotifList);
+        mPipelineState.incrementTo(STATE_SORTING);
+        sortList();
+
+        // Step 4: Lock in our group structure and log anything that's changed since the last run
+        mPipelineState.incrementTo(STATE_FINALIZING);
+        logParentingChanges();
+        freeEmptyGroups();
+
+        // Step 5: Dispatch the new list, first to any listeners and then to the view layer
+        Log.i(TAG, "List finalized, is:\n" + dumpList(mNotifList));
+        Log.i(TAG, "Dispatching final list to listeners...");
+        dispatchOnBeforeRenderList(mReadOnlyNotifList);
+        if (mOnRenderListListener != null) {
+            mOnRenderListListener.onRenderList(mReadOnlyNotifList);
+        }
+
+        // Step 6: We're done!
+        Log.i(TAG, "Notif list build #" + mIterationCount + " completed");
+        mPipelineState.setState(STATE_IDLE);
+        mIterationCount++;
+    }
+
+    private void filterAndGroup(
+            Collection<NotificationEntry> entries,
+            List<ListEntry> out,
+            List<ListEntry> newlyVisibleEntries) {
+
+        long now = mSystemClock.uptimeMillis();
+
+        for (GroupEntry group : mGroups.values()) {
+            group.setPreviousParent(group.getParent());
+            group.setParent(null);
+            group.clearChildren();
+            group.setSummary(null);
+        }
+
+        for (NotificationEntry entry : entries) {
+            entry.setPreviousParent(entry.getParent());
+            entry.setParent(null);
+
+            // See if we should filter out this notification
+            boolean shouldFilterOut = applyFilters(entry, now);
+            if (shouldFilterOut) {
+                continue;
+            }
+
+            if (entry.mFirstAddedIteration == -1) {
+                entry.mFirstAddedIteration = mIterationCount;
+                newlyVisibleEntries.add(entry);
+            }
+
+            // Otherwise, group it
+            if (entry.getSbn().isGroup()) {
+                final String topLevelKey = entry.getSbn().getGroupKey();
+
+                GroupEntry group = mGroups.get(topLevelKey);
+                if (group == null) {
+                    group = new GroupEntry(topLevelKey);
+                    group.mFirstAddedIteration = mIterationCount;
+                    newlyVisibleEntries.add(group);
+                    mGroups.put(topLevelKey, group);
+                }
+                if (group.getParent() == null) {
+                    group.setParent(ROOT_ENTRY);
+                    out.add(group);
+                }
+
+                entry.setParent(group);
+
+                if (entry.getSbn().getNotification().isGroupSummary()) {
+                    final NotificationEntry existingSummary = group.getSummary();
+
+                    if (existingSummary == null) {
+                        group.setSummary(entry);
+                    } else {
+                        Log.w(TAG, String.format(
+                                "Duplicate summary for group '%s': '%s' vs. '%s'",
+                                group.getKey(),
+                                existingSummary.getKey(),
+                                entry.getKey()));
+
+                        // Use whichever one was posted most recently
+                        if (entry.getSbn().getPostTime()
+                                > existingSummary.getSbn().getPostTime()) {
+                            group.setSummary(entry);
+                            annulAddition(existingSummary, out, newlyVisibleEntries);
+                        } else {
+                            annulAddition(entry, out, newlyVisibleEntries);
+                        }
+                    }
+                } else {
+                    group.addChild(entry);
+                }
+
+            } else {
+
+                final String topLevelKey = entry.getKey();
+                if (mGroups.containsKey(topLevelKey)) {
+                    Log.wtf(TAG, "Duplicate non-group top-level key: " + topLevelKey);
+                } else {
+                    entry.setParent(ROOT_ENTRY);
+                    out.add(entry);
+                }
+            }
+        }
+    }
+
+    private void promoteNotifs(List<ListEntry> list) {
+        for (int i = 0; i < list.size(); i++) {
+            final ListEntry tle = list.get(i);
+
+            if (tle instanceof GroupEntry) {
+                final GroupEntry group = (GroupEntry) tle;
+
+                group.getRawChildren().removeIf(child -> {
+                    final boolean shouldPromote = applyTopLevelPromoters(child);
+
+                    if (shouldPromote) {
+                        child.setParent(ROOT_ENTRY);
+                        list.add(child);
+                    }
+
+                    return shouldPromote;
+                });
+            }
+        }
+    }
+
+    private void pruneIncompleteGroups(
+            List<ListEntry> shadeList,
+            List<ListEntry> newlyVisibleEntries) {
+
+        for (int i = 0; i < shadeList.size(); i++) {
+            final ListEntry tle = shadeList.get(i);
+
+            if (tle instanceof GroupEntry) {
+                final GroupEntry group = (GroupEntry) tle;
+                final List<NotificationEntry> children = group.getRawChildren();
+
+                if (group.getSummary() != null && children.size() == 0) {
+                    shadeList.remove(i);
+                    i--;
+
+                    NotificationEntry summary = group.getSummary();
+                    summary.setParent(ROOT_ENTRY);
+                    shadeList.add(summary);
+
+                    group.setSummary(null);
+                    annulAddition(group, shadeList, newlyVisibleEntries);
+
+                } else if (group.getSummary() == null
+                        || children.size() < MIN_CHILDREN_FOR_GROUP) {
+                    // If the group doesn't provide a summary or is too small, ignore it and add
+                    // its children (if any) directly to top-level.
+
+                    shadeList.remove(i);
+                    i--;
+
+                    if (group.getSummary() != null) {
+                        final NotificationEntry summary = group.getSummary();
+                        group.setSummary(null);
+                        annulAddition(summary, shadeList, newlyVisibleEntries);
+                    }
+
+                    for (int j = 0; j < children.size(); j++) {
+                        final NotificationEntry child = children.get(j);
+                        child.setParent(ROOT_ENTRY);
+                        shadeList.add(child);
+                    }
+                    children.clear();
+
+                    annulAddition(group, shadeList, newlyVisibleEntries);
+                }
+            }
+        }
+    }
+
+    /**
+     * If a ListEntry was added to the shade list and then later removed (e.g. because it was a
+     * group that was broken up), this method will erase any bookkeeping traces of that addition
+     * and/or check that they were already erased.
+     *
+     * Before calling this method, the entry must already have been removed from its parent. If
+     * it's a group, its summary must be null and its children must be empty.
+     */
+    private void annulAddition(
+            ListEntry entry,
+            List<ListEntry> shadeList,
+            List<ListEntry> newlyVisibleEntries) {
+
+        // This function does very little, but if any of its assumptions are violated (and it has a
+        // lot of them), it will put the system into an inconsistent state. So we check all of them
+        // here.
+
+        if (entry.getParent() == null || entry.mFirstAddedIteration == -1) {
+            throw new IllegalStateException(
+                    "Cannot nullify addition of " + entry.getKey() + ": no such addition. ("
+                            + entry.getParent() + " " + entry.mFirstAddedIteration + ")");
+        }
+
+        if (entry.getParent() == ROOT_ENTRY) {
+            if (shadeList.contains(entry)) {
+                throw new IllegalStateException("Cannot nullify addition of " + entry.getKey()
+                        + ": it's still in the shade list.");
+            }
+        }
+
+        if (entry instanceof GroupEntry) {
+            GroupEntry ge = (GroupEntry) entry;
+            if (ge.getSummary() != null) {
+                throw new IllegalStateException(
+                        "Cannot nullify group " + ge.getKey() + ": summary is not null");
+            }
+            if (!ge.getChildren().isEmpty()) {
+                throw new IllegalStateException(
+                        "Cannot nullify group " + ge.getKey() + ": still has children");
+            }
+        } else if (entry instanceof NotificationEntry) {
+            if (entry == entry.getParent().getSummary()
+                    || entry.getParent().getChildren().contains(entry)) {
+                throw new IllegalStateException("Cannot nullify addition of child "
+                        + entry.getKey() + ": it's still attached to its parent.");
+            }
+        }
+
+        entry.setParent(null);
+        if (entry.mFirstAddedIteration == mIterationCount) {
+            if (!newlyVisibleEntries.remove(entry)) {
+                throw new IllegalStateException("Cannot late-filter entry " + entry.getKey() + " "
+                        + entry + " from " + newlyVisibleEntries + " "
+                        + entry.mFirstAddedIteration);
+            }
+            entry.mFirstAddedIteration = -1;
+        }
+    }
+
+    private void sortList() {
+        // Assign sections to top-level elements and sort their children
+        for (ListEntry entry : mNotifList) {
+            entry.setSection(mSectionsProvider.getSection(entry));
+            if (entry instanceof GroupEntry) {
+                GroupEntry parent = (GroupEntry) entry;
+                for (NotificationEntry child : parent.getChildren()) {
+                    child.setSection(0);
+                }
+                parent.sortChildren(sChildComparator);
+            }
+        }
+
+        // Finally, sort all top-level elements
+        mNotifList.sort(mTopLevelComparator);
+    }
+
+    private void freeEmptyGroups() {
+        mGroups.values().removeIf(ge -> ge.getSummary() == null && ge.getChildren().isEmpty());
+    }
+
+    private void logParentingChanges() {
+        for (NotificationEntry entry : mAllEntries) {
+            if (entry.getParent() != entry.getPreviousParent()) {
+                Log.i(TAG, String.format(
+                        "%s: parent changed from %s to %s",
+                        entry.getKey(),
+                        entry.getPreviousParent() == null
+                                ? "null" : entry.getPreviousParent().getKey(),
+                        entry.getParent() == null
+                                ? "null" : entry.getParent().getKey()));
+            }
+        }
+        for (GroupEntry group : mGroups.values()) {
+            if (group.getParent() != group.getPreviousParent()) {
+                Log.i(TAG, String.format(
+                        "%s: parent changed from %s to %s",
+                        group.getKey(),
+                        group.getPreviousParent() == null
+                                ? "null" : group.getPreviousParent().getKey(),
+                        group.getParent() == null
+                                ? "null" : group.getParent().getKey()));
+            }
+        }
+    }
+
+    private final Comparator<ListEntry> mTopLevelComparator = (o1, o2) -> {
+
+        int cmp = Integer.compare(o1.getSection(), o2.getSection());
+
+        if (cmp == 0) {
+            for (int i = 0; i < mNotifComparators.size(); i++) {
+                cmp = mNotifComparators.get(i).compare(o1, o2);
+                if (cmp != 0) {
+                    break;
+                }
+            }
+        }
+
+        final NotificationEntry rep1 = o1.getRepresentativeEntry();
+        final NotificationEntry rep2 = o2.getRepresentativeEntry();
+
+        if (cmp == 0) {
+            cmp = rep1.getRanking().getRank() - rep2.getRanking().getRank();
+        }
+
+        if (cmp == 0) {
+            cmp = Long.compare(
+                    rep2.getSbn().getNotification().when,
+                    rep1.getSbn().getNotification().when);
+        }
+
+        return cmp;
+    };
+
+    private static final Comparator<NotificationEntry> sChildComparator = (o1, o2) -> {
+        int cmp = o1.getRanking().getRank() - o2.getRanking().getRank();
+
+        if (cmp == 0) {
+            cmp = Long.compare(
+                    o2.getSbn().getNotification().when,
+                    o1.getSbn().getNotification().when);
+        }
+
+        return cmp;
+    };
+
+    private boolean applyFilters(NotificationEntry entry, long now) {
+        NotifFilter filter = findRejectingFilter(entry, now);
+
+        if (filter != entry.mExcludingFilter) {
+            if (entry.mExcludingFilter == null) {
+                Log.i(TAG, String.format(
+                        "%s: filtered out by '%s'",
+                        entry.getKey(),
+                        filter.getName()));
+            } else if (filter == null) {
+                Log.i(TAG, String.format(
+                        "%s: no longer filtered out (previous filter was '%s')",
+                        entry.getKey(),
+                        entry.mExcludingFilter.getName()));
+            } else {
+                Log.i(TAG, String.format(
+                        "%s: filter changed: '%s' -> '%s'",
+                        entry.getKey(),
+                        entry.mExcludingFilter,
+                        filter));
+            }
+
+            // Note that groups and summaries can also be filtered out later if they're part of a
+            // malformed group. We currently don't have a great way to track that beyond parenting
+            // change logs. Consider adding something similar to mExcludingFilter for them.
+            entry.mExcludingFilter = filter;
+        }
+
+        return filter != null;
+    }
+
+    @Nullable private NotifFilter findRejectingFilter(NotificationEntry entry, long now) {
+        for (int i = 0; i < mNotifFilters.size(); i++) {
+            NotifFilter filter = mNotifFilters.get(i);
+            if (filter.shouldFilterOut(entry, now)) {
+                return filter;
+            }
+        }
+        return null;
+    }
+
+    private boolean applyTopLevelPromoters(NotificationEntry entry) {
+        NotifPromoter promoter = findPromoter(entry);
+
+        if (promoter != entry.mNotifPromoter) {
+            if (entry.mNotifPromoter == null) {
+                Log.i(TAG, String.format(
+                        "%s: Entry promoted to top level by '%s'",
+                        entry.getKey(),
+                        promoter.getName()));
+            } else if (promoter == null) {
+                Log.i(TAG, String.format(
+                        "%s: Entry is no longer promoted to top level (previous promoter was '%s')",
+                        entry.getKey(),
+                        entry.mNotifPromoter.getName()));
+            } else {
+                Log.i(TAG, String.format(
+                        "%s: Top-level promoter changed: '%s' -> '%s'",
+                        entry.getKey(),
+                        entry.mNotifPromoter,
+                        promoter));
+            }
+
+            entry.mNotifPromoter = promoter;
+        }
+
+        return promoter != null;
+    }
+
+    @Nullable private NotifPromoter findPromoter(NotificationEntry entry) {
+        for (int i = 0; i < mNotifPromoters.size(); i++) {
+            NotifPromoter promoter = mNotifPromoters.get(i);
+            if (promoter.shouldPromoteToTopLevel(entry)) {
+                return promoter;
+            }
+        }
+        return null;
+    }
+
+    private void rebuildListIfBefore(@PipelineState.StateName int state) {
+        mPipelineState.requireIsBefore(state);
+        if (mPipelineState.is(STATE_IDLE)) {
+            buildList();
+        }
+    }
+
+    private void dispatchOnBeforeTransformGroups(
+            List<ListEntry> entries,
+            List<ListEntry> newlyVisibleEntries) {
+        for (int i = 0; i < mOnBeforeTransformGroupsListeners.size(); i++) {
+            mOnBeforeTransformGroupsListeners.get(i)
+                    .onBeforeTransformGroups(entries, newlyVisibleEntries);
+        }
+    }
+
+    private void dispatchOnBeforeSort(List<ListEntry> entries) {
+        for (int i = 0; i < mOnBeforeSortListeners.size(); i++) {
+            mOnBeforeSortListeners.get(i).onBeforeSort(entries);
+        }
+    }
+
+    private void dispatchOnBeforeRenderList(List<ListEntry> entries) {
+        for (int i = 0; i < mOnBeforeRenderListListeners.size(); i++) {
+            mOnBeforeRenderListListeners.get(i).onBeforeRenderList(entries);
+        }
+    }
+
+    /** See {@link #setOnRenderListListener(OnRenderListListener)} */
+    public interface OnRenderListListener {
+        /**
+         * Called with the final filtered, grouped, and sorted list.
+         *
+         * @param entries A read-only view into the current notif list. Note that this list is
+         *                backed by the live list and will change in response to new pipeline runs.
+         */
+        void onRenderList(List<ListEntry> entries);
+    }
+
+    private static class DefaultSectionsProvider extends SectionsProvider {
+        DefaultSectionsProvider() {
+            super("DefaultSectionsProvider");
+        }
+
+        @Override
+        public int getSection(ListEntry entry) {
+            return 0;
+        }
+    }
+
+    private static final String TAG = "NotifListBuilderImpl";
+
+    private static final int MIN_CHILDREN_FOR_GROUP = 2;
+}
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 28ccaf5..3eb55ef 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
@@ -60,6 +60,8 @@
 import com.android.systemui.statusbar.InflationTask;
 import com.android.systemui.statusbar.StatusBarIconView;
 import com.android.systemui.statusbar.notification.InflationException;
+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.row.ExpandableNotificationRow;
 import com.android.systemui.statusbar.notification.row.NotificationContentInflater.InflationFlag;
 import com.android.systemui.statusbar.notification.row.NotificationGuts;
@@ -84,7 +86,7 @@
  * At the moment, there are many things here that shouldn't be and vice-versa. Hopefully we can
  * clean this up in the future.
  */
-public final class NotificationEntry {
+public final class NotificationEntry extends ListEntry {
 
     private final String mKey;
     private StatusBarNotification mSbn;
@@ -98,6 +100,12 @@
     /** List of lifetime extenders that are extending the lifetime of this notification. */
     final List<NotifLifetimeExtender> mLifetimeExtenders = new ArrayList<>();
 
+    /** If this notification was filtered out, then the filter that did the filtering. */
+    @Nullable NotifFilter mExcludingFilter;
+
+    /** If this was a group child that was promoted to the top level, then who did the promoting. */
+    @Nullable NotifPromoter mNotifPromoter;
+
 
     /*
     * Old members
@@ -164,8 +172,8 @@
     public NotificationEntry(
             @NonNull StatusBarNotification sbn,
             @NonNull Ranking ranking) {
-        checkNotNull(sbn);
-        checkNotNull(sbn.getKey());
+        super(checkNotNull(checkNotNull(sbn).getKey()));
+
         checkNotNull(ranking);
 
         mKey = sbn.getKey();
@@ -173,6 +181,11 @@
         setRanking(ranking);
     }
 
+    @Override
+    public NotificationEntry getRepresentativeEntry() {
+        return this;
+    }
+
     /** The key for this notification. Guaranteed to be immutable and unique */
     public String getKey() {
         return mKey;
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/init/FakePipelineConsumer.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/init/FakePipelineConsumer.java
new file mode 100644
index 0000000..986ee17
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/init/FakePipelineConsumer.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2019 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.init;
+
+import com.android.systemui.Dumpable;
+import com.android.systemui.statusbar.notification.collection.GroupEntry;
+import com.android.systemui.statusbar.notification.collection.ListEntry;
+import com.android.systemui.statusbar.notification.collection.NotifListBuilderImpl;
+import com.android.systemui.statusbar.notification.collection.NotificationEntry;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Temporary class that tracks the result of the list builder and dumps it to text when requested.
+ *
+ * Eventually, this will be something that hands off the result of the pipeline to the View layer.
+ */
+public class FakePipelineConsumer implements Dumpable {
+    private List<ListEntry> mEntries = Collections.emptyList();
+
+    /** Attach the consumer to the pipeline. */
+    public void attach(NotifListBuilderImpl listBuilder) {
+        listBuilder.setOnRenderListListener(this::onBuildComplete);
+    }
+
+    private void onBuildComplete(List<ListEntry> entries) {
+        mEntries = entries;
+    }
+
+    @Override
+    public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
+        pw.println();
+        pw.println("Active notif tree:");
+        for (int i = 0; i < mEntries.size(); i++) {
+            ListEntry entry = mEntries.get(i);
+            if (entry instanceof GroupEntry) {
+                GroupEntry ge = (GroupEntry) entry;
+                pw.println(dumpGroup(ge, "", i));
+
+                pw.println(dumpEntry(ge.getSummary(), INDENT, -1));
+                for (int j = 0; j < ge.getChildren().size(); j++) {
+                    pw.println(dumpEntry(ge.getChildren().get(j), INDENT, j));
+                }
+            } else {
+                pw.println(dumpEntry(entry.getRepresentativeEntry(), "", i));
+            }
+        }
+    }
+
+    private String dumpGroup(GroupEntry entry, String indent, int index) {
+        return String.format(
+                "%s[%d] %s (group)",
+                indent,
+                index,
+                entry.getKey());
+    }
+
+    private String dumpEntry(NotificationEntry entry, String indent, int index) {
+        return String.format(
+                "%s[%s] %s (channel=%s)",
+                indent,
+                index == -1 ? "*" : Integer.toString(index),
+                entry.getKey(),
+                entry.getChannel() != null ? entry.getChannel().getId() : "");
+    }
+
+    private static final String INDENT = "   ";
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/init/NewNotifPipeline.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/init/NewNotifPipeline.java
new file mode 100644
index 0000000..3b3e7e2
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/init/NewNotifPipeline.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2019 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.init;
+
+import android.util.Log;
+
+import com.android.systemui.DumpController;
+import com.android.systemui.Dumpable;
+import com.android.systemui.statusbar.NotificationListener;
+import com.android.systemui.statusbar.notification.collection.NotifCollection;
+import com.android.systemui.statusbar.notification.collection.NotifListBuilderImpl;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+/**
+ * Initialization code for the new notification pipeline.
+ */
+@Singleton
+public class NewNotifPipeline implements Dumpable {
+    private final NotifCollection mNotifCollection;
+    private final NotifListBuilderImpl mNotifPipeline;
+    private final DumpController mDumpController;
+
+    private final FakePipelineConsumer mFakePipelineConsumer = new FakePipelineConsumer();
+
+    @Inject
+    public NewNotifPipeline(
+            NotifCollection notifCollection,
+            NotifListBuilderImpl notifPipeline,
+            DumpController dumpController) {
+        mNotifCollection = notifCollection;
+        mNotifPipeline = notifPipeline;
+        mDumpController = dumpController;
+    }
+
+    /** Hooks the new pipeline up to NotificationManager */
+    public void initialize(
+            NotificationListener notificationService) {
+        mFakePipelineConsumer.attach(mNotifPipeline);
+        mNotifPipeline.attach(mNotifCollection);
+        mNotifCollection.attach(notificationService);
+
+        Log.d(TAG, "Notif pipeline initialized");
+
+        mDumpController.registerDumpable("NotifPipeline", this);
+    }
+
+    @Override
+    public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
+        mFakePipelineConsumer.dump(fd, pw, args);
+    }
+
+    private static final String TAG = "NewNotifPipeline";
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/NotifListBuilder.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/NotifListBuilder.java
new file mode 100644
index 0000000..15d3b92
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/NotifListBuilder.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2019 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.listbuilder;
+
+import com.android.systemui.statusbar.notification.collection.ListEntry;
+import com.android.systemui.statusbar.notification.collection.NotifCollection;
+import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifComparator;
+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.SectionsProvider;
+
+import java.util.List;
+
+/**
+ * The system that constructs the current "notification list", the list of notifications that are
+ * currently being displayed to the user.
+ *
+ * The pipeline proceeds through a series of stages in order to produce the final list (see below).
+ * Each stage exposes hooks and listeners for other code to participate.
+ *
+ * This list differs from the canonical one we receive from system server in a few ways:
+ * - Filtered: Some notifications are filtered out. For example, we filter out notifications whose
+ *   views haven't been inflated yet. We also filter out some notifications if we're on the lock
+ *   screen. To participate, see {@link #addFilter(NotifFilter)}.
+ * - Grouped: Notifications that are part of the same group are clustered together into a single
+ *   GroupEntry. These groups are then transformed in order to remove children or completely split
+ *   them apart. To participate, see {@link #addPromoter(NotifPromoter)}.
+ * - Sorted: All top-level notifications are sorted. To participate, see
+ *   {@link #setSectionsProvider(SectionsProvider)} and {@link #setComparators(List)}
+ *
+ * The exact order of all hooks is as follows:
+ *  0. Collection listeners are fired (see {@link NotifCollection}).
+ *  1. NotifFilters are called on each notification currently in NotifCollection.
+ *  2. Initial grouping is performed (NotificationEntries will have their parents set
+ *     appropriately).
+ *  3. OnBeforeTransformGroupListeners are fired
+ *  4. NotifPromoters are called on each notification with a parent
+ *  5. OnBeforeSortListeners are fired
+ *  6. SectionsProvider is called on each top-level entry in the list
+ *  7. The top-level entries are sorted using the provided NotifComparators (plus some additional
+ *     built-in logic).
+ *  8. OnBeforeRenderListListeners are fired
+ *  9. The list is handed off to the view layer to be rendered.
+ */
+public interface NotifListBuilder {
+
+    /**
+     * Registers a filter with the pipeline. Filters are called on each notification in the order
+     * that they were registered. If any filter returns true, the notification is removed from the
+     * pipeline (and no other filters are called on that notif).
+     */
+    void addFilter(NotifFilter filter);
+
+    /**
+     * Registers a promoter with the pipeline. Promoters are able to promote child notifications to
+     * top-level, i.e. move a notification that would be a child of a group and make it appear
+     * ungrouped. Promoters are called on each child notification in the order that they are
+     * registered. If any promoter returns true, the notification is removed from the group (and no
+     * other promoters are called on it).
+     */
+    void addPromoter(NotifPromoter promoter);
+
+    /**
+     * Assigns sections to each top-level entry, where a section is simply an integer. Sections are
+     * the primary metric by which top-level entries are sorted; NotifComparators are only consulted
+     * when two entries are in the same section. The pipeline doesn't assign any particular meaning
+     * to section IDs -- from it's perspective they're just numbers and it sorts them by a simple
+     * numerical comparison.
+     */
+    void setSectionsProvider(SectionsProvider provider);
+
+    /**
+     * Comparators that are used to sort top-level entries that share the same section. The
+     * comparators are executed in order until one of them returns a non-zero result. If all return
+     * zero, the pipeline falls back to sorting by rank (and, failing that, Notification.when).
+     */
+    void setComparators(List<NotifComparator> comparators);
+
+    /**
+     * Called after notifications have been filtered and after the initial grouping has been
+     * performed but before NotifPromoters have had a chance to promote children out of groups.
+     */
+    void addOnBeforeTransformGroupsListener(OnBeforeTransformGroupsListener listener);
+
+    /**
+     * Called after notifs have been filtered and groups have been determined but before sections
+     * have been determined or the notifs have been sorted.
+     */
+    void addOnBeforeSortListener(OnBeforeSortListener listener);
+
+    /**
+     * Called at the end of the pipeline after the notif list has been finalized but before it has
+     * been handed off to the view layer.
+     */
+    void addOnBeforeRenderListListener(OnBeforeRenderListListener listener);
+
+    /**
+     * Returns a read-only view in to the current notification list. If this method is called
+     * during pipeline execution it will return the current state of the list, which will likely
+     * be only partially-generated.
+     */
+    List<ListEntry> getActiveNotifs();
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/OnBeforeRenderListListener.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/OnBeforeRenderListListener.java
new file mode 100644
index 0000000..f6ca12d
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/OnBeforeRenderListListener.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2019 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.listbuilder;
+
+import com.android.systemui.statusbar.notification.collection.ListEntry;
+
+import java.util.List;
+
+/** See {@link NotifListBuilder#addOnBeforeRenderListListener(OnBeforeRenderListListener)} */
+public interface OnBeforeRenderListListener {
+    /**
+     * Called at the end of the pipeline after the notif list has been finalized but before it has
+     * been handed off to the view layer.
+     *
+     * @param entries The current list of top-level entries. Note that this is a live view into the
+     *                current list and will change whenever the pipeline is rerun.
+     */
+    void onBeforeRenderList(List<ListEntry> entries);
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/OnBeforeSortListener.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/OnBeforeSortListener.java
new file mode 100644
index 0000000..7be7ac0
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/OnBeforeSortListener.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2019 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.listbuilder;
+
+import com.android.systemui.statusbar.notification.collection.ListEntry;
+
+import java.util.List;
+
+/** See {@link NotifListBuilder#addOnBeforeSortListener(OnBeforeSortListener)} */
+public interface OnBeforeSortListener {
+    /**
+     * Called after the notif list has been filtered and grouped but before sections have been
+     * determined or sorting has taken place.
+     *
+     * @param entries The current list of top-level entries. Note that this is a live view into the
+     *                current list and will change whenever the pipeline is rerun.
+     */
+    void onBeforeSort(List<ListEntry> entries);
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/OnBeforeTransformGroupsListener.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/OnBeforeTransformGroupsListener.java
new file mode 100644
index 0000000..170ff48
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/OnBeforeTransformGroupsListener.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2019 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.listbuilder;
+
+import com.android.systemui.statusbar.notification.collection.ListEntry;
+import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifPromoter;
+
+import java.util.List;
+
+/**
+ * See
+ * {@link NotifListBuilder#addOnBeforeTransformGroupsListener(OnBeforeTransformGroupsListener)}
+ */
+public interface OnBeforeTransformGroupsListener {
+    /**
+     * Called after notifs have been filtered and grouped but before {@link NotifPromoter}s have
+     * been called.
+     *
+     * @param list The current filtered and grouped list of (top-level) entries. Note that this is
+     *             a live view into the current notif list and will change as the list moves through
+     *             the pipeline.
+     * @param newlyVisibleEntries The list of all entries (both top-level and children) who have
+     *                            been added to the list for the first time.
+     */
+    void onBeforeTransformGroups(List<ListEntry> list, List<ListEntry> newlyVisibleEntries);
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/PipelineState.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/PipelineState.java
new file mode 100644
index 0000000..ad4bbd9
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/PipelineState.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2019 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.listbuilder;
+
+import android.annotation.IntDef;
+
+import com.android.systemui.statusbar.notification.collection.NotifListBuilderImpl;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Used by {@link NotifListBuilderImpl} to track its internal state machine.
+ */
+public class PipelineState {
+
+    private @StateName int mState = STATE_IDLE;
+
+    /** Returns true if the current state matches <code>state</code> */
+    public boolean is(@StateName int state) {
+        return state == mState;
+    }
+
+    public @StateName int getState() {
+        return mState;
+    }
+
+    public void setState(@StateName int state) {
+        mState = state;
+    }
+
+    /**
+     * Increments the state from <code>(to - 1)</code> to <code>to</code>. If the current state
+     * isn't <code>(to - 1)</code>, throws an exception.
+     */
+    public void incrementTo(@StateName int to) {
+        if (mState != to - 1) {
+            throw new IllegalStateException(
+                    "Cannot increment from state " + mState + " to state " + to);
+        }
+        mState = to;
+    }
+
+    /**
+     * Throws an exception if the current state is not <code>state</code>.
+     */
+    public void requireState(@StateName int state) {
+        if (state != mState) {
+            throw new IllegalStateException(
+                    "Required state is <" + state + " but actual state is " + mState);
+        }
+    }
+
+    /**
+     * Throws an exception if the current state is >= <code>state</code>.
+     */
+    public void requireIsBefore(@StateName int state) {
+        if (mState >= state) {
+            throw new IllegalStateException(
+                    "Required state is <" + state + " but actual state is " + mState);
+        }
+    }
+
+    public static final int STATE_IDLE = 0;
+    public static final int STATE_BUILD_PENDING = 1;
+    public static final int STATE_BUILD_STARTED = 2;
+    public static final int STATE_FILTERING = 3;
+    public static final int STATE_TRANSFORMING = 4;
+    public static final int STATE_SORTING = 5;
+    public static final int STATE_FINALIZING = 6;
+
+    @IntDef(prefix = { "STATE_" }, value = {
+            STATE_IDLE,
+            STATE_BUILD_PENDING,
+            STATE_BUILD_STARTED,
+            STATE_FILTERING,
+            STATE_TRANSFORMING,
+            STATE_SORTING,
+            STATE_FINALIZING,
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface StateName {}
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/pluggable/NotifComparator.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/pluggable/NotifComparator.java
new file mode 100644
index 0000000..a191c83
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/pluggable/NotifComparator.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2019 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.listbuilder.pluggable;
+
+import com.android.systemui.statusbar.notification.collection.ListEntry;
+import com.android.systemui.statusbar.notification.collection.listbuilder.NotifListBuilder;
+
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * Pluggable for participating in notif sorting. See {@link NotifListBuilder#setComparators(List)}.
+ */
+public abstract class NotifComparator
+        extends Pluggable<NotifComparator>
+        implements Comparator<ListEntry> {
+
+    protected NotifComparator(String name) {
+        super(name);
+    }
+
+    /**
+     * Compare two ListEntries. Note that these might be either NotificationEntries or GroupEntries.
+     *
+     * @return a negative integer, zero, or a positive integer as the first argument is less than
+     *      equal to, or greater than the second (same as standard Comparator<> interface).
+     */
+    public abstract int compare(ListEntry o1, ListEntry o2);
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/pluggable/NotifFilter.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/pluggable/NotifFilter.java
new file mode 100644
index 0000000..685eac8
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/pluggable/NotifFilter.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2019 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.listbuilder.pluggable;
+
+import com.android.systemui.statusbar.notification.collection.NotificationEntry;
+import com.android.systemui.statusbar.notification.collection.listbuilder.NotifListBuilder;
+
+/**
+ * Pluggable for participating in notif filtering. See
+ * {@link NotifListBuilder#addFilter(NotifFilter)}.
+ */
+public abstract class NotifFilter extends Pluggable<NotifFilter> {
+    protected NotifFilter(String name) {
+        super(name);
+    }
+
+    /**
+     * If returns true, this notification will not be included in the final list displayed to the
+     * user. Filtering is performed on each active notification every time the pipeline is run.
+     * This doesn't necessarily mean that your filter will get called on every notification,
+     * however. If another filter returns true before yours, we'll skip straight to the next notif.
+     *
+     * @param entry The entry in question
+     * @param now A timestamp in SystemClock.uptimeMillis that represents "now" for the purposes of
+     *            pipeline execution. This value will be the same for all pluggable calls made
+     *            during this pipeline run, giving pluggables a stable concept of "now" to compare
+     *            various entries against.
+     * @return True if the notif should be removed from the list
+     */
+    public abstract boolean shouldFilterOut(NotificationEntry entry, long now);
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/pluggable/NotifPromoter.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/pluggable/NotifPromoter.java
new file mode 100644
index 0000000..84e16f4
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/pluggable/NotifPromoter.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2019 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.listbuilder.pluggable;
+
+import com.android.systemui.statusbar.notification.collection.NotificationEntry;
+import com.android.systemui.statusbar.notification.collection.listbuilder.NotifListBuilder;
+
+/**
+ *  Pluggable for participating in notif promotion. Notif promoters can upgrade notifications
+ *  from being children of a group to top-level notifications. See
+ *  {@link NotifListBuilder#addPromoter(NotifPromoter)}.
+ */
+public abstract class NotifPromoter extends Pluggable<NotifPromoter> {
+    protected NotifPromoter(String name) {
+        super(name);
+    }
+
+    /**
+     * If true, the child will be removed from its parent and placed at the top level of the notif
+     * list. By the time this method is called, child.getParent() has been set, so you can
+     * examine it (or any other entries in the notif list) for extra information.
+     *
+     * This method is only called on notifs that are currently children of groups. This doesn't
+     * necessarily mean that your promoter will get called on every child notification, however. If
+     * another promoter returns true before yours, we'll skip straight to the next notif.
+     */
+    public abstract boolean shouldPromoteToTopLevel(NotificationEntry child);
+}
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
new file mode 100644
index 0000000..f9ce197
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/pluggable/Pluggable.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2019 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.listbuilder.pluggable;
+
+import android.annotation.Nullable;
+
+import com.android.systemui.statusbar.notification.collection.listbuilder.NotifListBuilder;
+
+/**
+ * Generic superclass for chunks of code that can plug into the {@link NotifListBuilder}.
+ *
+ * A pluggable is fundamentally three things:
+ * 1. A name (for debugging purposes)
+ * 2. The functionality that the pluggable provides to the pipeline (this is determined by the
+ *    subclass).
+ * 3. A way for the pluggable to inform the pipeline that its state has changed and the pipeline
+ *    should be rerun (in this case, the invalidate() method).
+ *
+ * @param <This> The type of the subclass. Subclasses should bind their own type here.
+ */
+public abstract class Pluggable<This> {
+    private final String mName;
+    @Nullable private PluggableListener<This> mListener;
+
+    Pluggable(String name) {
+        mName = name;
+    }
+
+    public final String getName() {
+        return mName;
+    }
+
+    /**
+     * Call this method when something has caused this pluggable's behavior to change. The pipeline
+     * will be re-run.
+     */
+    public final void invalidateList() {
+        if (mListener != null) {
+            mListener.onPluggableInvalidated((This) this);
+        }
+    }
+
+    /** Set a listener to be notified when a pluggable is invalidated. */
+    public void setInvalidationListener(PluggableListener<This> listener) {
+        mListener = listener;
+    }
+
+    /**
+     * Listener interface for when pluggables are invalidated.
+     *
+     * @param <T> The type of pluggable that is being listened to.
+     */
+    public interface PluggableListener<T> {
+        /** Called whenever {@link #invalidateList()} is called on this pluggable. */
+        void onPluggableInvalidated(T pluggable);
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/pluggable/SectionsProvider.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/pluggable/SectionsProvider.java
new file mode 100644
index 0000000..11ea850
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/collection/listbuilder/pluggable/SectionsProvider.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2019 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.listbuilder.pluggable;
+
+import com.android.systemui.statusbar.notification.collection.ListEntry;
+
+/**
+ * Interface for sorting notifications into "sections", such as a heads-upping section, people
+ * section, alerting section, silent section, etc.
+ */
+public abstract class SectionsProvider extends Pluggable<SectionsProvider> {
+
+    protected SectionsProvider(String name) {
+        super(name);
+    }
+
+    /**
+     * Returns the section that this entry belongs to. A section can be any non-negative integer.
+     * When entries are sorted, they are first sorted by section and then by any remainining
+     * comparators.
+     */
+    public abstract int getSection(ListEntry entry);
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java
index adea8c6..6e0a461 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java
@@ -198,7 +198,6 @@
 import com.android.systemui.statusbar.notification.ActivityLaunchAnimator;
 import com.android.systemui.statusbar.notification.BypassHeadsUpNotifier;
 import com.android.systemui.statusbar.notification.DynamicPrivacyController;
-import com.android.systemui.statusbar.notification.NewNotifPipeline;
 import com.android.systemui.statusbar.notification.NotificationActivityStarter;
 import com.android.systemui.statusbar.notification.NotificationAlertingManager;
 import com.android.systemui.statusbar.notification.NotificationClicker;
@@ -210,6 +209,7 @@
 import com.android.systemui.statusbar.notification.VisualStabilityManager;
 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
 import com.android.systemui.statusbar.notification.collection.NotificationRowBinderImpl;
+import com.android.systemui.statusbar.notification.collection.init.NewNotifPipeline;
 import com.android.systemui.statusbar.notification.logging.NotificationLogger;
 import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow;
 import com.android.systemui.statusbar.notification.row.NotificationGutsManager;
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarModule.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarModule.java
index 60e9385..88f1c63 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarModule.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarModule.java
@@ -56,12 +56,12 @@
 import com.android.systemui.statusbar.VibratorHelper;
 import com.android.systemui.statusbar.notification.BypassHeadsUpNotifier;
 import com.android.systemui.statusbar.notification.DynamicPrivacyController;
-import com.android.systemui.statusbar.notification.NewNotifPipeline;
 import com.android.systemui.statusbar.notification.NotificationAlertingManager;
 import com.android.systemui.statusbar.notification.NotificationEntryManager;
 import com.android.systemui.statusbar.notification.NotificationInterruptionStateProvider;
 import com.android.systemui.statusbar.notification.NotificationWakeUpCoordinator;
 import com.android.systemui.statusbar.notification.VisualStabilityManager;
+import com.android.systemui.statusbar.notification.collection.init.NewNotifPipeline;
 import com.android.systemui.statusbar.notification.logging.NotificationLogger;
 import com.android.systemui.statusbar.notification.row.NotificationGutsManager;
 import com.android.systemui.statusbar.policy.BatteryController;
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/NotificationEntryBuilder.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/NotificationEntryBuilder.java
index fcfdd11..d18b16b 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/NotificationEntryBuilder.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/NotificationEntryBuilder.java
@@ -20,6 +20,7 @@
 import android.app.Notification;
 import android.app.NotificationChannel;
 import android.app.NotificationManager.Importance;
+import android.content.Context;
 import android.os.UserHandle;
 import android.service.notification.SnoozeCriterion;
 import android.service.notification.StatusBarNotification;
@@ -92,6 +93,10 @@
         return this;
     }
 
+    public Notification.Builder modifyNotification(Context context) {
+        return mSbnBuilder.modifyNotification(context);
+    }
+
     public NotificationEntryBuilder setUser(UserHandle user) {
         mSbnBuilder.setUser(user);
         return this;
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/SbnBuilder.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/SbnBuilder.java
index fe117fe..94b3ac4 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/SbnBuilder.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/SbnBuilder.java
@@ -16,7 +16,9 @@
 
 package com.android.systemui.statusbar;
 
+import android.annotation.Nullable;
 import android.app.Notification;
+import android.content.Context;
 import android.os.UserHandle;
 import android.service.notification.StatusBarNotification;
 
@@ -32,7 +34,8 @@
     private String mTag;
     private int mUid;
     private int mInitialPid;
-    private Notification mNotification = new Notification();
+    @Nullable private Notification mNotification;
+    @Nullable private Notification.Builder mNotificationBuilder;
     private Notification.BubbleMetadata mBubbleMetadata;
     private UserHandle mUser = UserHandle.of(0);
     private String mOverrideGroupKey;
@@ -55,9 +58,19 @@
     }
 
     public StatusBarNotification build() {
-        if (mBubbleMetadata != null) {
-            mNotification.setBubbleMetadata(mBubbleMetadata);
+        Notification notification;
+        if (mNotificationBuilder != null) {
+            notification = mNotificationBuilder.build();
+        } else if (mNotification != null) {
+            notification = mNotification;
+        } else {
+            notification = new Notification();
         }
+
+        if (mBubbleMetadata != null) {
+            notification.setBubbleMetadata(mBubbleMetadata);
+        }
+
         return new StatusBarNotification(
                 mPkg,
                 mOpPkg,
@@ -65,7 +78,7 @@
                 mTag,
                 mUid,
                 mInitialPid,
-                mNotification,
+                notification,
                 mUser,
                 mOverrideGroupKey,
                 mPostTime);
@@ -106,6 +119,17 @@
         return this;
     }
 
+    public Notification.Builder modifyNotification(Context context) {
+        if (mNotification != null) {
+            mNotificationBuilder = new Notification.Builder(context, mNotification);
+            mNotification = null;
+        } else if (mNotificationBuilder == null) {
+            mNotificationBuilder = new Notification.Builder(context);
+        }
+
+        return mNotificationBuilder;
+    }
+
     public SbnBuilder setUser(UserHandle user) {
         mUser = user;
         return this;
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/NotifListBuilderImplTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/NotifListBuilderImplTest.java
new file mode 100644
index 0000000..7326cd4
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/collection/NotifListBuilderImplTest.java
@@ -0,0 +1,1199 @@
+/*
+ * Copyright (C) 2019 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 static com.android.internal.util.Preconditions.checkNotNull;
+import static com.android.systemui.statusbar.notification.collection.ListDumper.dumpList;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper;
+import android.util.ArrayMap;
+import android.util.Log;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.systemui.SysuiTestCase;
+import com.android.systemui.statusbar.NotificationEntryBuilder;
+import com.android.systemui.statusbar.notification.collection.NotifListBuilderImpl.OnRenderListListener;
+import com.android.systemui.statusbar.notification.collection.listbuilder.OnBeforeRenderListListener;
+import com.android.systemui.statusbar.notification.collection.listbuilder.OnBeforeSortListener;
+import com.android.systemui.statusbar.notification.collection.listbuilder.OnBeforeTransformGroupsListener;
+import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifComparator;
+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.SectionsProvider;
+import com.android.systemui.util.Assert;
+import com.android.systemui.util.time.FakeSystemClock;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+import org.mockito.Spy;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
[email protected]
+public class NotifListBuilderImplTest extends SysuiTestCase {
+
+    private NotifListBuilderImpl mListBuilder;
+    private FakeSystemClock mSystemClock = new FakeSystemClock();
+
+    @Mock private NotifCollection mNotifCollection;
+    @Spy private OnBeforeTransformGroupsListener mOnBeforeTransformGroupsListener;
+    @Spy private OnBeforeSortListener mOnBeforeSortListener;
+    @Spy private OnBeforeRenderListListener mOnBeforeRenderListListener;
+    @Spy private OnRenderListListener mOnRenderListListener = list -> mBuiltList = list;
+
+    @Captor private ArgumentCaptor<CollectionReadyForBuildListener> mBuildListenerCaptor;
+
+    private CollectionReadyForBuildListener mReadyForBuildListener;
+    private List<NotificationEntryBuilder> mPendingSet = new ArrayList<>();
+    private List<NotificationEntry> mEntrySet = new ArrayList<>();
+    private List<ListEntry> mBuiltList;
+
+    private Map<String, Integer> mNextIdMap = new ArrayMap<>();
+    private int mNextRank = 0;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        Assert.sMainLooper = TestableLooper.get(this).getLooper();
+
+        mListBuilder = new NotifListBuilderImpl(mSystemClock);
+        mListBuilder.setOnRenderListListener(mOnRenderListListener);
+
+        mListBuilder.attach(mNotifCollection);
+
+        Mockito.verify(mNotifCollection).setBuildListener(mBuildListenerCaptor.capture());
+        mReadyForBuildListener = checkNotNull(mBuildListenerCaptor.getValue());
+    }
+
+    @Test
+    public void testNotifsAreSortedByRankAndWhen() {
+        // GIVEN a simple pipeline
+
+        // WHEN a series of notifs with jumbled ranks are added
+        addNotif(0, PACKAGE_1).setRank(2);
+        addNotif(1, PACKAGE_2).setRank(4).modifyNotification(mContext).setWhen(22);
+        addNotif(2, PACKAGE_3).setRank(4).modifyNotification(mContext).setWhen(33);
+        addNotif(3, PACKAGE_3).setRank(3);
+        addNotif(4, PACKAGE_5).setRank(4).modifyNotification(mContext).setWhen(11);
+        addNotif(5, PACKAGE_3).setRank(1);
+        addNotif(6, PACKAGE_1).setRank(0);
+        dispatchBuild();
+
+        // The final output is sorted based on rank
+        verifyBuiltList(
+                notif(6),
+                notif(5),
+                notif(0),
+                notif(3),
+                notif(2),
+                notif(1),
+                notif(4)
+        );
+    }
+
+    @Test
+    public void testNotifsAreGrouped() {
+        // GIVEN a simple pipeline
+
+        // WHEN a group is added
+        addGroupChild(0, PACKAGE_1, GROUP_1);
+        addGroupChild(1, PACKAGE_1, GROUP_1);
+        addGroupChild(2, PACKAGE_1, GROUP_1);
+        addGroupSummary(3, PACKAGE_1, GROUP_1);
+        dispatchBuild();
+
+        // THEN the notifs are grouped together
+        verifyBuiltList(
+                group(
+                        summary(3),
+                        child(0),
+                        child(1),
+                        child(2)
+                )
+        );
+    }
+
+    @Test
+    public void testNotifsWithDifferentGroupKeysAreGrouped() {
+        // GIVEN a simple pipeline
+
+        // WHEN a package posts two different groups
+        addGroupChild(0, PACKAGE_1, GROUP_1);
+        addGroupChild(1, PACKAGE_1, GROUP_2);
+        addGroupSummary(2, PACKAGE_1, GROUP_2);
+        addGroupChild(3, PACKAGE_1, GROUP_2);
+        addGroupChild(4, PACKAGE_1, GROUP_1);
+        addGroupChild(5, PACKAGE_1, GROUP_2);
+        addGroupChild(6, PACKAGE_1, GROUP_1);
+        addGroupSummary(7, PACKAGE_1, GROUP_1);
+        dispatchBuild();
+
+        // THEN the groups are separated separately
+        verifyBuiltList(
+                group(
+                        summary(2),
+                        child(1),
+                        child(3),
+                        child(5)
+                ),
+                group(
+                        summary(7),
+                        child(0),
+                        child(4),
+                        child(6)
+                )
+        );
+    }
+
+    @Test
+    public void testNotifsNotifChildrenAreSorted() {
+        // GIVEN a simple pipeline
+
+        // WHEN a group is added
+        addGroupChild(0, PACKAGE_1, GROUP_1).setRank(4);
+        addGroupChild(1, PACKAGE_1, GROUP_1).setRank(2)
+                .modifyNotification(mContext).setWhen(11);
+        addGroupChild(2, PACKAGE_1, GROUP_1).setRank(1);
+        addGroupChild(3, PACKAGE_1, GROUP_1).setRank(2)
+                .modifyNotification(mContext).setWhen(33);
+        addGroupChild(4, PACKAGE_1, GROUP_1).setRank(2)
+                .modifyNotification(mContext).setWhen(22);
+        addGroupChild(5, PACKAGE_1, GROUP_1).setRank(0);
+        addGroupSummary(6, PACKAGE_1, GROUP_1).setRank(3);
+        dispatchBuild();
+
+        // THEN the notifs are grouped together
+        verifyBuiltList(
+                group(
+                        summary(6),
+                        child(5),
+                        child(2),
+                        child(3),
+                        child(4),
+                        child(1),
+                        child(0)
+                )
+        );
+    }
+
+    @Test
+    public void testDuplicateGroupSummariesAreDiscarded() {
+        // GIVEN a simple pipeline
+
+        // WHEN a group with multiple summaries is added
+        addNotif(0, PACKAGE_3);
+        addGroupChild(1, PACKAGE_1, GROUP_1);
+        addGroupChild(2, PACKAGE_1, GROUP_1);
+        addGroupSummary(3, PACKAGE_1, GROUP_1).setPostTime(22);
+        addGroupSummary(4, PACKAGE_1, GROUP_1).setPostTime(33);
+        addNotif(5, PACKAGE_2);
+        addGroupSummary(6, PACKAGE_1, GROUP_1).setPostTime(11);
+        addGroupChild(7, PACKAGE_1, GROUP_1);
+        dispatchBuild();
+
+        // THEN only most recent summary is used
+        verifyBuiltList(
+                notif(0),
+                group(
+                        summary(4),
+                        child(1),
+                        child(2),
+                        child(7)
+                ),
+                notif(5)
+        );
+
+        // THEN the extra summaries have their parents set to null
+        assertNull(mEntrySet.get(3).getParent());
+        assertNull(mEntrySet.get(6).getParent());
+    }
+
+    @Test
+    public void testGroupsWithNoSummaryAreUngrouped() {
+        // GIVEN a group with no summary
+        addNotif(0, PACKAGE_2);
+        addGroupChild(1, PACKAGE_4, GROUP_2);
+        addGroupChild(2, PACKAGE_4, GROUP_2);
+        addGroupChild(3, PACKAGE_4, GROUP_2);
+        addGroupChild(4, PACKAGE_4, GROUP_2);
+
+        // WHEN we build the list
+        dispatchBuild();
+
+        // THEN the children aren't grouped
+        verifyBuiltList(
+                notif(0),
+                notif(1),
+                notif(2),
+                notif(3),
+                notif(4)
+        );
+    }
+
+    @Test
+    public void testGroupsWithNoChildrenAreUngrouped() {
+        // GIVEN a group with a summary but no children
+        addGroupSummary(0, PACKAGE_5, GROUP_1);
+        addNotif(1, PACKAGE_5);
+        addNotif(2, PACKAGE_1);
+
+        // WHEN we build the list
+        dispatchBuild();
+
+        // THEN the summary isn't grouped but is still added to the final list
+        verifyBuiltList(
+                notif(0),
+                notif(1),
+                notif(2)
+        );
+    }
+
+    @Test
+    public void testGroupsWithTooFewChildrenAreSplitUp() {
+        // GIVEN a group with one child
+        addGroupChild(0, PACKAGE_2, GROUP_1);
+        addGroupSummary(1, PACKAGE_2, GROUP_1);
+
+        // WHEN we build the list
+        dispatchBuild();
+
+        // THEN the child is added at top level and the summary is discarded
+        verifyBuiltList(
+                notif(0)
+        );
+
+        assertNull(mEntrySet.get(1).getParent());
+    }
+
+    @Test
+    public void testGroupsWhoLoseChildrenMidPipelineAreSplitUp() {
+        // GIVEN a group with two children
+        addGroupChild(0, PACKAGE_2, GROUP_1);
+        addGroupSummary(1, PACKAGE_2, GROUP_1);
+        addGroupChild(2, PACKAGE_2, GROUP_1);
+
+        // GIVEN a promoter that will promote one of children to top level
+        mListBuilder.addPromoter(new IdPromoter(0));
+
+        // WHEN we build the list
+        dispatchBuild();
+
+        // THEN both children end up at top level (because group is now too small)
+        verifyBuiltList(
+                notif(0),
+                notif(2)
+        );
+
+        // THEN the summary is discarded
+        assertNull(mEntrySet.get(1).getParent());
+    }
+
+    @Test
+    public void testPreviousParentsAreSetProperly() {
+        // GIVEN a notification that is initially added to the list
+        PackageFilter filter = new PackageFilter(PACKAGE_2);
+        filter.setEnabled(false);
+        mListBuilder.addFilter(filter);
+
+        addNotif(0, PACKAGE_1);
+        addNotif(1, PACKAGE_2);
+        addNotif(2, PACKAGE_3);
+        dispatchBuild();
+
+        // WHEN it is suddenly filtered out
+        filter.setEnabled(true);
+        dispatchBuild();
+
+        // THEN its previous parent indicates that it used to be added
+        assertNull(mEntrySet.get(1).getParent());
+        assertEquals(GroupEntry.ROOT_ENTRY, mEntrySet.get(1).getPreviousParent());
+    }
+
+    @Test
+    public void testThatAnnulledGroupsAndSummariesAreProperlyRolledBack() {
+        // GIVEN a registered transform groups listener
+        RecordingOnBeforeTransformGroupsListener listener =
+                new RecordingOnBeforeTransformGroupsListener();
+        mListBuilder.addOnBeforeTransformGroupsListener(listener);
+
+        // GIVEN a malformed group that will be dismantled
+        addGroupChild(0, PACKAGE_2, GROUP_1);
+        addGroupSummary(1, PACKAGE_2, GROUP_1);
+        addNotif(2, PACKAGE_1);
+
+        // WHEN we build the list
+        dispatchBuild();
+
+        // THEN only the child appears in the final list
+        verifyBuiltList(
+                notif(0),
+                notif(2)
+        );
+
+        // THEN the list of newly visible entries doesn't contain the summary or the group
+        assertEquals(
+                Arrays.asList(
+                        mEntrySet.get(0),
+                        mEntrySet.get(2)),
+                listener.newlyVisibleEntries
+        );
+
+        // THEN the summary has a null parent and an unset firstAddedIteration
+        assertNull(mEntrySet.get(1).getParent());
+        assertEquals(-1, mEntrySet.get(1).mFirstAddedIteration);
+    }
+
+    @Test
+    public void testNotifsAreFiltered() {
+        // GIVEN a NotifFilter that filters out a specific package
+        NotifFilter filter1 = spy(new PackageFilter(PACKAGE_2));
+        mListBuilder.addFilter(filter1);
+
+        // WHEN the pipeline is kicked off on a list of notifs
+        addNotif(0, PACKAGE_1);
+        addNotif(1, PACKAGE_2);
+        addNotif(2, PACKAGE_3);
+        addNotif(3, PACKAGE_2);
+        dispatchBuild();
+
+        // THEN the filter is called on each notif in the original set
+        verify(filter1).shouldFilterOut(eq(mEntrySet.get(0)), anyLong());
+        verify(filter1).shouldFilterOut(eq(mEntrySet.get(1)), anyLong());
+        verify(filter1).shouldFilterOut(eq(mEntrySet.get(2)), anyLong());
+        verify(filter1).shouldFilterOut(eq(mEntrySet.get(3)), anyLong());
+
+        // THEN the final list doesn't contain any filtered-out notifs
+        verifyBuiltList(
+                notif(0),
+                notif(2)
+        );
+
+        // THEN each filtered notif records the filter that did it
+        assertEquals(filter1, mEntrySet.get(1).mExcludingFilter);
+        assertEquals(filter1, mEntrySet.get(3).mExcludingFilter);
+    }
+
+    @Test
+    public void testNotifFiltersCanBePreempted() {
+        // GIVEN two notif filters
+        NotifFilter filter1 = spy(new PackageFilter(PACKAGE_2));
+        NotifFilter filter2 = spy(new PackageFilter(PACKAGE_5));
+        mListBuilder.addFilter(filter1);
+        mListBuilder.addFilter(filter2);
+
+        // WHEN the pipeline is kicked off on a list of notifs
+        addNotif(0, PACKAGE_1);
+        addNotif(1, PACKAGE_2);
+        addNotif(2, PACKAGE_5);
+        dispatchBuild();
+
+        // THEN both filters are called on the first notif but the second filter is never called
+        // on the already-filtered second notif
+        verify(filter1).shouldFilterOut(eq(mEntrySet.get(0)), anyLong());
+        verify(filter1).shouldFilterOut(eq(mEntrySet.get(1)), anyLong());
+        verify(filter1).shouldFilterOut(eq(mEntrySet.get(2)), anyLong());
+        verify(filter2).shouldFilterOut(eq(mEntrySet.get(0)), anyLong());
+        verify(filter2).shouldFilterOut(eq(mEntrySet.get(2)), anyLong());
+
+        // THEN the final list doesn't contain any filtered-out notifs
+        verifyBuiltList(
+                notif(0)
+        );
+
+        // THEN each filtered notif records the filter that did it
+        assertEquals(filter1, mEntrySet.get(1).mExcludingFilter);
+        assertEquals(filter2, mEntrySet.get(2).mExcludingFilter);
+    }
+
+    @Test
+    public void testNotifsArePromoted() {
+        // GIVEN a NotifPromoter that promotes certain notif IDs
+        NotifPromoter promoter = spy(new IdPromoter(1, 2));
+        mListBuilder.addPromoter(promoter);
+
+        // WHEN the pipeline is kicked off
+        addNotif(0, PACKAGE_1);
+        addGroupChild(1, PACKAGE_2, GROUP_1);
+        addGroupChild(2, PACKAGE_2, GROUP_1);
+        addGroupChild(3, PACKAGE_2, GROUP_1);
+        addGroupChild(4, PACKAGE_2, GROUP_1);
+        addGroupSummary(5, PACKAGE_2, GROUP_1);
+        addNotif(6, PACKAGE_3);
+        dispatchBuild();
+
+        // THEN the filter is called on each group child
+        verify(promoter).shouldPromoteToTopLevel(mEntrySet.get(1));
+        verify(promoter).shouldPromoteToTopLevel(mEntrySet.get(2));
+        verify(promoter).shouldPromoteToTopLevel(mEntrySet.get(3));
+        verify(promoter).shouldPromoteToTopLevel(mEntrySet.get(4));
+
+        // THEN the final list contains the promoted entries at top level
+        verifyBuiltList(
+                notif(0),
+                notif(2),
+                notif(3),
+                group(
+                        summary(5),
+                        child(1),
+                        child(4)),
+                notif(6)
+        );
+
+        // THEN each promoted notif records the promoter that did it
+        assertEquals(promoter, mEntrySet.get(2).mNotifPromoter);
+        assertEquals(promoter, mEntrySet.get(3).mNotifPromoter);
+    }
+
+    @Test
+    public void testNotifPromotersCanBePreempted() {
+        // GIVEN two notif promoters
+        NotifPromoter promoter1 = spy(new IdPromoter(1));
+        NotifPromoter promoter2 = spy(new IdPromoter(2));
+        mListBuilder.addPromoter(promoter1);
+        mListBuilder.addPromoter(promoter2);
+
+        // WHEN the pipeline is kicked off on some notifs and a group
+        addNotif(0, PACKAGE_1);
+        addGroupChild(1, PACKAGE_2, GROUP_1);
+        addGroupChild(2, PACKAGE_2, GROUP_1);
+        addGroupChild(3, PACKAGE_2, GROUP_1);
+        addGroupSummary(4, PACKAGE_2, GROUP_1);
+        addNotif(5, PACKAGE_3);
+        dispatchBuild();
+
+        for (NotificationEntry entry : mEntrySet) {
+            Log.d("pizza", "entry: " + entry.getKey() + " " + entry);
+        }
+
+        // THEN both promoters are called on each child, except for children that a previous
+        // promoter has already promoted
+        verify(promoter1).shouldPromoteToTopLevel(mEntrySet.get(1));
+        verify(promoter1).shouldPromoteToTopLevel(mEntrySet.get(2));
+        verify(promoter1).shouldPromoteToTopLevel(mEntrySet.get(3));
+
+        verify(promoter2).shouldPromoteToTopLevel(mEntrySet.get(1));
+        verify(promoter2).shouldPromoteToTopLevel(mEntrySet.get(3));
+
+        // THEN each promoter is recorded on each notif it promoted
+        assertEquals(promoter1, mEntrySet.get(2).mNotifPromoter);
+        assertEquals(promoter2, mEntrySet.get(3).mNotifPromoter);
+    }
+
+    @Test
+    public void testNotifsAreSectioned() {
+        // GIVEN a filter that removes all PACKAGE_4 notifs and a SectionsProvider that divides
+        // notifs based on package name
+        mListBuilder.addFilter(new PackageFilter(PACKAGE_4));
+        final SectionsProvider sectionsProvider = spy(new PackageSectioner());
+        mListBuilder.setSectionsProvider(sectionsProvider);
+
+        // WHEN we build a list with different packages
+        addNotif(0, PACKAGE_4);
+        addNotif(1, PACKAGE_2);
+        addNotif(2, PACKAGE_1);
+        addNotif(3, PACKAGE_3);
+        addGroupSummary(4, PACKAGE_2, GROUP_1);
+        addGroupChild(5, PACKAGE_2, GROUP_1);
+        addGroupChild(6, PACKAGE_2, GROUP_1);
+        addNotif(7, PACKAGE_1);
+        addNotif(8, PACKAGE_2);
+        addNotif(9, PACKAGE_5);
+        addNotif(10, PACKAGE_4);
+        dispatchBuild();
+
+        // THEN the list is sorted according to section
+        verifyBuiltList(
+                notif(2),
+                notif(7),
+                notif(1),
+                group(
+                        summary(4),
+                        child(5),
+                        child(6)
+                ),
+                notif(8),
+                notif(3),
+                notif(9)
+        );
+
+        // THEN the sections provider is called on all top level elements (but no children and no
+        // entries that were filtered out)
+        verify(sectionsProvider).getSection(mEntrySet.get(1));
+        verify(sectionsProvider).getSection(mEntrySet.get(2));
+        verify(sectionsProvider).getSection(mEntrySet.get(3));
+        verify(sectionsProvider).getSection(mEntrySet.get(7));
+        verify(sectionsProvider).getSection(mEntrySet.get(8));
+        verify(sectionsProvider).getSection(mEntrySet.get(9));
+        verify(sectionsProvider).getSection(mBuiltList.get(3));
+    }
+
+    @Test
+    public void testThatNotifComparatorsAreCalled() {
+        // GIVEN a set of comparators that care about specific packages
+        mListBuilder.setComparators(Arrays.asList(
+                new HypeComparator(PACKAGE_4),
+                new HypeComparator(PACKAGE_1, PACKAGE_3),
+                new HypeComparator(PACKAGE_2)
+        ));
+
+        // WHEN the pipeline is kicked off on a bunch of notifications
+        addNotif(0, PACKAGE_1);
+        addNotif(1, PACKAGE_5);
+        addNotif(2, PACKAGE_3);
+        addNotif(3, PACKAGE_4);
+        addNotif(4, PACKAGE_2);
+        dispatchBuild();
+
+        // THEN the notifs are sorted according to the hierarchy of comparators
+        verifyBuiltList(
+                notif(3),
+                notif(0),
+                notif(2),
+                notif(4),
+                notif(1)
+        );
+    }
+
+    @Test
+    public void testListenersAndPluggablesAreFiredInOrder() {
+        // GIVEN a bunch of registered listeners and pluggables
+        NotifFilter filter = spy(new PackageFilter(PACKAGE_1));
+        NotifPromoter promoter = spy(new IdPromoter(3));
+        PackageSectioner sectioner = spy(new PackageSectioner());
+        NotifComparator comparator = spy(new HypeComparator(PACKAGE_4));
+        mListBuilder.addFilter(filter);
+        mListBuilder.addOnBeforeTransformGroupsListener(mOnBeforeTransformGroupsListener);
+        mListBuilder.addPromoter(promoter);
+        mListBuilder.addOnBeforeSortListener(mOnBeforeSortListener);
+        mListBuilder.setComparators(Collections.singletonList(comparator));
+        mListBuilder.setSectionsProvider(sectioner);
+        mListBuilder.addOnBeforeRenderListListener(mOnBeforeRenderListListener);
+
+        // WHEN a few new notifs are added
+        addNotif(0, PACKAGE_1);
+        addGroupSummary(1, PACKAGE_2, GROUP_1);
+        addGroupChild(2, PACKAGE_2, GROUP_1);
+        addGroupChild(3, PACKAGE_2, GROUP_1);
+        addNotif(4, PACKAGE_5);
+        addNotif(5, PACKAGE_5);
+        addNotif(6, PACKAGE_4);
+        dispatchBuild();
+
+        // THEN the pluggables and listeners are called in order
+        InOrder inOrder = inOrder(
+                filter,
+                mOnBeforeTransformGroupsListener,
+                promoter,
+                mOnBeforeSortListener,
+                sectioner,
+                comparator,
+                mOnBeforeRenderListListener,
+                mOnRenderListListener);
+
+        inOrder.verify(filter, atLeastOnce())
+                .shouldFilterOut(any(NotificationEntry.class), anyLong());
+        inOrder.verify(mOnBeforeTransformGroupsListener)
+                .onBeforeTransformGroups(anyList(), anyList());
+        inOrder.verify(promoter, atLeastOnce())
+                .shouldPromoteToTopLevel(any(NotificationEntry.class));
+        inOrder.verify(mOnBeforeSortListener).onBeforeSort(anyList());
+        inOrder.verify(sectioner, atLeastOnce()).getSection(any(ListEntry.class));
+        inOrder.verify(comparator, atLeastOnce())
+                .compare(any(ListEntry.class), any(ListEntry.class));
+        inOrder.verify(mOnBeforeRenderListListener).onBeforeRenderList(anyList());
+        inOrder.verify(mOnRenderListListener).onRenderList(anyList());
+    }
+
+    @Test
+    public void testThatPluggableInvalidationsTriggersRerun() {
+        // GIVEN a variety of pluggables
+        NotifFilter packageFilter = new PackageFilter(PACKAGE_1);
+        NotifPromoter idPromoter = new IdPromoter(4);
+        SectionsProvider sectionsProvider = new PackageSectioner();
+        NotifComparator hypeComparator = new HypeComparator(PACKAGE_2);
+
+        mListBuilder.addFilter(packageFilter);
+        mListBuilder.addPromoter(idPromoter);
+        mListBuilder.setSectionsProvider(sectionsProvider);
+        mListBuilder.setComparators(Collections.singletonList(hypeComparator));
+
+        // GIVEN a set of random notifs
+        addNotif(0, PACKAGE_1);
+        addNotif(1, PACKAGE_2);
+        addNotif(2, PACKAGE_3);
+        dispatchBuild();
+
+        // WHEN each pluggable is invalidated THEN the list is re-rendered
+
+        clearInvocations(mOnRenderListListener);
+        packageFilter.invalidateList();
+        verify(mOnRenderListListener).onRenderList(anyList());
+
+        clearInvocations(mOnRenderListListener);
+        idPromoter.invalidateList();
+        verify(mOnRenderListListener).onRenderList(anyList());
+
+        clearInvocations(mOnRenderListListener);
+        sectionsProvider.invalidateList();
+        verify(mOnRenderListListener).onRenderList(anyList());
+
+        clearInvocations(mOnRenderListListener);
+        hypeComparator.invalidateList();
+        verify(mOnRenderListListener).onRenderList(anyList());
+    }
+
+    @Test
+    public void testNotifFiltersAreAllSentTheSameNow() {
+        // GIVEN three notif filters
+        NotifFilter filter1 = spy(new PackageFilter(PACKAGE_5));
+        NotifFilter filter2 = spy(new PackageFilter(PACKAGE_5));
+        NotifFilter filter3 = spy(new PackageFilter(PACKAGE_5));
+        mListBuilder.addFilter(filter1);
+        mListBuilder.addFilter(filter2);
+        mListBuilder.addFilter(filter3);
+
+        // GIVEN the SystemClock is set to a particular time:
+        mSystemClock.setAutoIncrement(true);
+        mSystemClock.setUptimeMillis(47);
+
+        // WHEN the pipeline is kicked off on a list of notifs
+        addNotif(0, PACKAGE_1);
+        addNotif(1, PACKAGE_2);
+        dispatchBuild();
+
+        // THEN the value of `now` is the same for all calls to shouldFilterOut
+        verify(filter1).shouldFilterOut(mEntrySet.get(0), 47);
+        verify(filter2).shouldFilterOut(mEntrySet.get(0), 47);
+        verify(filter3).shouldFilterOut(mEntrySet.get(0), 47);
+        verify(filter1).shouldFilterOut(mEntrySet.get(1), 47);
+        verify(filter2).shouldFilterOut(mEntrySet.get(1), 47);
+        verify(filter3).shouldFilterOut(mEntrySet.get(1), 47);
+    }
+
+    @Test
+    public void testNewlyAddedEntries() {
+        // GIVEN a registered OnBeforeTransformGroupsListener
+        RecordingOnBeforeTransformGroupsListener listener =
+                spy(new RecordingOnBeforeTransformGroupsListener());
+        mListBuilder.addOnBeforeTransformGroupsListener(listener);
+
+        // Given some new notifs
+        addNotif(0, PACKAGE_1);
+        addGroupChild(1, PACKAGE_2, GROUP_1);
+        addGroupSummary(2, PACKAGE_2, GROUP_1);
+        addGroupChild(3, PACKAGE_2, GROUP_1);
+        addNotif(4, PACKAGE_3);
+        addGroupChild(5, PACKAGE_2, GROUP_1);
+
+        // WHEN we run the pipeline
+        dispatchBuild();
+
+        verifyBuiltList(
+                notif(0),
+                group(
+                        summary(2),
+                        child(1),
+                        child(3),
+                        child(5)
+                ),
+                notif(4)
+        );
+
+        // THEN all the new notifs, including the new GroupEntry, are passed to the listener
+        verify(listener).onBeforeTransformGroups(
+                Arrays.asList(
+                        mEntrySet.get(0),
+                        mBuiltList.get(1),
+                        mEntrySet.get(4)
+                ),
+                Arrays.asList(
+                        mEntrySet.get(0),
+                        mEntrySet.get(1),
+                        mBuiltList.get(1),
+                        mEntrySet.get(2),
+                        mEntrySet.get(3),
+                        mEntrySet.get(4),
+                        mEntrySet.get(5)
+                )
+        );
+    }
+
+    @Test
+    public void testNewlyAddedEntriesOnSecondRun() {
+        // GIVEN a registered OnBeforeTransformGroupsListener
+        RecordingOnBeforeTransformGroupsListener listener =
+                spy(new RecordingOnBeforeTransformGroupsListener());
+        mListBuilder.addOnBeforeTransformGroupsListener(listener);
+
+        // Given some notifs that have already been added (two of which are in malformed groups)
+        addNotif(0, PACKAGE_1);
+        addGroupChild(1, PACKAGE_2, GROUP_1);
+        addGroupChild(2, PACKAGE_3, GROUP_2);
+
+        dispatchBuild();
+        clearInvocations(listener);
+
+        // WHEN we run the pipeline
+        addGroupSummary(3, PACKAGE_2, GROUP_1);
+        addGroupChild(4, PACKAGE_3, GROUP_2);
+        addGroupSummary(5, PACKAGE_3, GROUP_2);
+        addGroupChild(6, PACKAGE_3, GROUP_2);
+        addNotif(7, PACKAGE_2);
+
+        dispatchBuild();
+
+        verifyBuiltList(
+                notif(0),
+                notif(1),
+                group(
+                        summary(5),
+                        child(2),
+                        child(4),
+                        child(6)
+                ),
+                notif(7)
+        );
+
+        // THEN all the new notifs, including the new GroupEntry, are passed to the listener
+        verify(listener).onBeforeTransformGroups(
+                Arrays.asList(
+                        mEntrySet.get(0),
+                        mEntrySet.get(1),
+                        mBuiltList.get(2),
+                        mEntrySet.get(7)
+                ),
+                Arrays.asList(
+                        mBuiltList.get(2),
+                        mEntrySet.get(4),
+                        mEntrySet.get(5),
+                        mEntrySet.get(6),
+                        mEntrySet.get(7)
+                )
+        );
+    }
+
+    @Test
+    public void testAnnulledGroupsHaveParentSetProperly() {
+        // GIVEN a list containing a small group that's already been built once
+        addGroupChild(0, PACKAGE_2, GROUP_2);
+        addGroupSummary(1, PACKAGE_2, GROUP_2);
+        addGroupChild(2, PACKAGE_2, GROUP_2);
+        dispatchBuild();
+
+        verifyBuiltList(
+                group(
+                        summary(1),
+                        child(0),
+                        child(2)
+                )
+        );
+        GroupEntry group = (GroupEntry) mBuiltList.get(0);
+
+        // WHEN a child is removed such that the group is no longer big enough
+        mEntrySet.remove(2);
+        dispatchBuild();
+
+        // THEN the group is annulled and its parent is set back to null
+        verifyBuiltList(
+                notif(0)
+        );
+        assertNull(group.getParent());
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testOutOfOrderFilterInvalidationThrows() {
+        // GIVEN a NotifFilter that gets invalidated during the grouping stage
+        NotifFilter filter = new PackageFilter(PACKAGE_5);
+        OnBeforeTransformGroupsListener listener =
+                (list, newlyVisibleEntries) -> filter.invalidateList();
+        mListBuilder.addFilter(filter);
+        mListBuilder.addOnBeforeTransformGroupsListener(listener);
+
+        // WHEN we try to run the pipeline and the filter is invalidated
+        addNotif(0, PACKAGE_1);
+        dispatchBuild();
+
+        // Then an exception is thrown
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testOutOfOrderPrompterInvalidationThrows() {
+        // GIVEN a NotifFilter that gets invalidated during the grouping stage
+        NotifPromoter promoter = new IdPromoter(47);
+        OnBeforeSortListener listener =
+                (list) -> promoter.invalidateList();
+        mListBuilder.addPromoter(promoter);
+        mListBuilder.addOnBeforeSortListener(listener);
+
+        // WHEN we try to run the pipeline and the filter is invalidated
+        addNotif(0, PACKAGE_1);
+        dispatchBuild();
+
+        // Then an exception is thrown
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testOutOfOrderComparatorInvalidationThrows() {
+        // GIVEN a NotifFilter that gets invalidated during the grouping stage
+        NotifComparator comparator = new HypeComparator(PACKAGE_5);
+        OnBeforeRenderListListener listener =
+                (list) -> comparator.invalidateList();
+        mListBuilder.setComparators(Collections.singletonList(comparator));
+        mListBuilder.addOnBeforeRenderListListener(listener);
+
+        // WHEN we try to run the pipeline and the filter is invalidated
+        addNotif(0, PACKAGE_1);
+        dispatchBuild();
+
+        // Then an exception is thrown
+    }
+
+    /**
+     * Adds a notif to the collection that will be passed to the list builder when
+     * {@link #dispatchBuild()}s is called.
+     *
+     * @param index Index of this notification in the set. This must be the current size of the set.
+     *              it exists to improve readability of the resulting code, since later tests will
+     *              have to refer to notifs by index.
+     * @param packageId Package that the notif should be posted under
+     * @return A NotificationEntryBuilder that can be used to further modify the notif. Do not call
+     *         build() on the builder; that will be done on the next dispatchBuild().
+     */
+    private NotificationEntryBuilder addNotif(int index, String packageId) {
+        final NotificationEntryBuilder builder = new NotificationEntryBuilder()
+                .setPkg(packageId)
+                .setId(nextId(packageId))
+                .setRank(nextRank());
+
+        builder.modifyNotification(mContext)
+                .setContentTitle("Top level singleton")
+                .setChannelId("test_channel");
+
+        assertEquals(mEntrySet.size() + mPendingSet.size(), index);
+        mPendingSet.add(builder);
+        return builder;
+    }
+
+    /** Same behavior as {@link #addNotif(int, String)}. */
+    private NotificationEntryBuilder addGroupSummary(int index, String packageId, String groupId) {
+        final NotificationEntryBuilder builder = new NotificationEntryBuilder()
+                .setPkg(packageId)
+                .setId(nextId(packageId))
+                .setRank(nextRank());
+
+        builder.modifyNotification(mContext)
+                .setChannelId("test_channel")
+                .setContentTitle("Group summary")
+                .setGroup(groupId)
+                .setGroupSummary(true);
+
+        assertEquals(mEntrySet.size() + mPendingSet.size(), index);
+        mPendingSet.add(builder);
+        return builder;
+    }
+
+    /** Same behavior as {@link #addNotif(int, String)}. */
+    private NotificationEntryBuilder addGroupChild(int index, String packageId, String groupId) {
+        final NotificationEntryBuilder builder = new NotificationEntryBuilder()
+                .setPkg(packageId)
+                .setId(nextId(packageId))
+                .setRank(nextRank());
+
+        builder.modifyNotification(mContext)
+                .setChannelId("test_channel")
+                .setContentTitle("Group child")
+                .setGroup(groupId);
+
+        assertEquals(mEntrySet.size() + mPendingSet.size(), index);
+        mPendingSet.add(builder);
+        return builder;
+    }
+
+    private int nextId(String packageName) {
+        Integer nextId = mNextIdMap.get(packageName);
+        if (nextId == null) {
+            nextId = 0;
+        }
+        mNextIdMap.put(packageName, nextId + 1);
+        return nextId;
+    }
+
+    private int nextRank() {
+        int nextRank = mNextRank;
+        mNextRank++;
+        return nextRank;
+    }
+
+    private void dispatchBuild() {
+        if (mPendingSet.size() > 0) {
+            for (NotificationEntryBuilder builder : mPendingSet) {
+                mEntrySet.add(builder.build());
+            }
+            mPendingSet.clear();
+        }
+
+        mReadyForBuildListener.onBeginDispatchToListeners();
+        mReadyForBuildListener.onBuildList(mEntrySet);
+    }
+
+    private void verifyBuiltList(ExpectedEntry ...expectedEntries) {
+        try {
+            assertEquals(
+                    "List is the wrong length",
+                    expectedEntries.length,
+                    mBuiltList.size());
+
+            for (int i = 0; i < expectedEntries.length; i++) {
+                ListEntry outEntry = mBuiltList.get(i);
+                ExpectedEntry expectedEntry = expectedEntries[i];
+
+                if (expectedEntry instanceof ExpectedNotif) {
+                    assertEquals(
+                            "Entry " + i + " isn't a NotifEntry",
+                            NotificationEntry.class,
+                            outEntry.getClass());
+                    assertEquals(
+                            "Entry " + i + " doesn't match expected value.",
+                            ((ExpectedNotif) expectedEntry).entry, outEntry);
+                } else {
+                    ExpectedGroup cmpGroup = (ExpectedGroup) expectedEntry;
+
+                    assertEquals(
+                            "Entry " + i + " isn't a GroupEntry",
+                            GroupEntry.class,
+                            outEntry.getClass());
+
+                    GroupEntry outGroup = (GroupEntry) outEntry;
+
+                    assertEquals(
+                            "Summary notif for entry " + i
+                                    + " doesn't match expected value",
+                            cmpGroup.summary,
+                            outGroup.getSummary());
+                    assertEquals(
+                            "Summary notif for entry " + i
+                                        + " doesn't have proper parent",
+                            outGroup,
+                            outGroup.getSummary().getParent());
+
+                    assertEquals("Children for entry " + i,
+                            cmpGroup.children,
+                            outGroup.getChildren());
+
+                    for (int j = 0; j < outGroup.getChildren().size(); j++) {
+                        NotificationEntry child = outGroup.getChildren().get(j);
+                        assertEquals(
+                                "Child " + j + " for entry " + i
+                                        + " doesn't have proper parent",
+                                outGroup,
+                                child.getParent());
+                    }
+                }
+            }
+        } catch (AssertionError err) {
+            throw new AssertionError(
+                    "List under test failed verification:\n" + dumpList(mBuiltList), err);
+        }
+    }
+
+    private ExpectedNotif notif(int index) {
+        return new ExpectedNotif(mEntrySet.get(index));
+    }
+
+    private ExpectedGroup group(ExpectedSummary summary, ExpectedChild...children) {
+        return new ExpectedGroup(
+                summary.entry,
+                Arrays.stream(children)
+                        .map(child -> child.entry)
+                        .collect(Collectors.toList()));
+    }
+
+    private ExpectedSummary summary(int index) {
+        return new ExpectedSummary(mEntrySet.get(index));
+    }
+
+    private ExpectedChild child(int index) {
+        return new ExpectedChild(mEntrySet.get(index));
+    }
+
+    private abstract static class ExpectedEntry {
+    }
+
+    private static class ExpectedNotif extends ExpectedEntry {
+        public final NotificationEntry entry;
+
+        private ExpectedNotif(NotificationEntry entry) {
+            this.entry = entry;
+        }
+    }
+
+    private static class ExpectedGroup extends ExpectedEntry {
+        public final NotificationEntry summary;
+        public final List<NotificationEntry> children;
+
+        private ExpectedGroup(
+                NotificationEntry summary,
+                List<NotificationEntry> children) {
+            this.summary = summary;
+            this.children = children;
+        }
+    }
+
+    private static class ExpectedSummary {
+        public final NotificationEntry entry;
+
+        private ExpectedSummary(NotificationEntry entry) {
+            this.entry = entry;
+        }
+    }
+
+    private static class ExpectedChild {
+        public final NotificationEntry entry;
+
+        private ExpectedChild(NotificationEntry entry) {
+            this.entry = entry;
+        }
+    }
+
+    /** Filters out notifs from a particular package */
+    private static class PackageFilter extends NotifFilter {
+        private final String mPackageName;
+
+        private boolean mEnabled = true;
+
+        PackageFilter(String packageName) {
+            super("PackageFilter");
+
+            mPackageName = packageName;
+        }
+
+        @Override
+        public boolean shouldFilterOut(NotificationEntry entry, long now) {
+            return mEnabled && entry.getSbn().getPackageName().equals(mPackageName);
+        }
+
+        public void setEnabled(boolean enabled) {
+            mEnabled = enabled;
+        }
+    }
+
+    /** Promotes notifs with particular IDs */
+    private static class IdPromoter extends NotifPromoter {
+        private final List<Integer> mIds;
+
+        IdPromoter(Integer... ids) {
+            super("IdPromoter");
+            mIds = Arrays.asList(ids);
+        }
+
+        @Override
+        public boolean shouldPromoteToTopLevel(NotificationEntry child) {
+            return mIds.contains(child.getSbn().getId());
+        }
+    }
+
+    /** Sorts specific notifs above all others. */
+    private static class HypeComparator extends NotifComparator {
+
+        private final List<String> mPreferredPackages;
+
+        HypeComparator(String ...preferredPackages) {
+            super("HypeComparator");
+            mPreferredPackages = Arrays.asList(preferredPackages);
+        }
+
+        @Override
+        public int compare(ListEntry o1, ListEntry o2) {
+            boolean contains1 = mPreferredPackages.contains(
+                    o1.getRepresentativeEntry().getSbn().getPackageName());
+            boolean contains2 = mPreferredPackages.contains(
+                    o2.getRepresentativeEntry().getSbn().getPackageName());
+
+            return Boolean.compare(contains2, contains1);
+        }
+    }
+
+    /** Sorts notifs into sections based on their package name */
+    private static class PackageSectioner extends SectionsProvider {
+
+        PackageSectioner() {
+            super("PackageSectioner");
+        }
+
+        @Override
+        public int getSection(ListEntry entry) {
+            switch (entry.getRepresentativeEntry().getSbn().getPackageName()) {
+                case PACKAGE_1:
+                    return 1;
+                case PACKAGE_2:
+                    return 2;
+                case PACKAGE_3:
+                    return 3;
+                default:
+                    return 4;
+            }
+        }
+    }
+
+    private static class RecordingOnBeforeTransformGroupsListener
+            implements OnBeforeTransformGroupsListener {
+        public List<ListEntry> newlyVisibleEntries;
+
+        @Override
+        public void onBeforeTransformGroups(List<ListEntry> list,
+                List<ListEntry> newlyVisibleEntries) {
+            this.newlyVisibleEntries = newlyVisibleEntries;
+        }
+    }
+
+    private static final String PACKAGE_1 = "com.test1";
+    private static final String PACKAGE_2 = "com.test2";
+    private static final String PACKAGE_3 = "org.test3";
+    private static final String PACKAGE_4 = "com.test4";
+    private static final String PACKAGE_5 = "com.test5";
+
+    private static final String GROUP_1 = "group_1";
+    private static final String GROUP_2 = "group_2";
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarTest.java
index 95929c3a..aecdd83 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarTest.java
@@ -111,7 +111,6 @@
 import com.android.systemui.statusbar.VibratorHelper;
 import com.android.systemui.statusbar.notification.BypassHeadsUpNotifier;
 import com.android.systemui.statusbar.notification.DynamicPrivacyController;
-import com.android.systemui.statusbar.notification.NewNotifPipeline;
 import com.android.systemui.statusbar.notification.NotificationAlertingManager;
 import com.android.systemui.statusbar.notification.NotificationEntryListener;
 import com.android.systemui.statusbar.notification.NotificationEntryManager;
@@ -120,6 +119,7 @@
 import com.android.systemui.statusbar.notification.NotificationWakeUpCoordinator;
 import com.android.systemui.statusbar.notification.VisualStabilityManager;
 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
+import com.android.systemui.statusbar.notification.collection.init.NewNotifPipeline;
 import com.android.systemui.statusbar.notification.logging.NotificationLogger;
 import com.android.systemui.statusbar.notification.row.NotificationGutsManager;
 import com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayout;
diff --git a/tests/StatusBar/src/com/android/statusbartest/StatusBarTest.java b/tests/StatusBar/src/com/android/statusbartest/StatusBarTest.java
index cd04c2e..3d72ee6 100644
--- a/tests/StatusBar/src/com/android/statusbartest/StatusBarTest.java
+++ b/tests/StatusBar/src/com/android/statusbartest/StatusBarTest.java
@@ -18,13 +18,13 @@
 
 import android.app.Notification;
 import android.app.NotificationManager;
-import android.view.View;
-import android.content.Intent;
 import android.app.PendingIntent;
 import android.app.StatusBarManager;
+import android.content.Intent;
 import android.os.Handler;
-import android.util.Log;
 import android.os.SystemClock;
+import android.util.Log;
+import android.view.View;
 import android.view.Window;
 import android.view.WindowManager;