Split NotificationListener out from StatusBar.

This decouples the part of status bar that listens for notifications
being posted, updated, or removed.

Bug: 63874929
Bug: 62602530
Test: runtest systemui
Test: Compile and run

Change-Id: I4b685d03f6641bdefa297b752c93e5728cb84132
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationListener.java b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationListener.java
new file mode 100644
index 0000000..6bcd174
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationListener.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2017 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;
+
+import static com.android.systemui.statusbar.RemoteInputController.processForRemoteInput;
+import static com.android.systemui.statusbar.phone.StatusBar.DEBUG;
+import static com.android.systemui.statusbar.phone.StatusBar.ENABLE_CHILD_NOTIFICATIONS;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.os.RemoteException;
+import android.os.UserHandle;
+import android.service.notification.StatusBarNotification;
+import android.util.Log;
+
+import com.android.systemui.statusbar.phone.NotificationListenerWithPlugins;
+
+/**
+ * This class handles listening to notification updates and passing them along to
+ * NotificationPresenter to be displayed to the user.
+ */
+public class NotificationListener extends NotificationListenerWithPlugins {
+    private static final String TAG = "NotificationListener";
+
+    private final NotificationPresenter mPresenter;
+    private final Context mContext;
+
+    public NotificationListener(NotificationPresenter presenter, Context context) {
+        mPresenter = presenter;
+        mContext = context;
+    }
+
+    @Override
+    public void onListenerConnected() {
+        if (DEBUG) Log.d(TAG, "onListenerConnected");
+        onPluginConnected();
+        final StatusBarNotification[] notifications = getActiveNotifications();
+        if (notifications == null) {
+            Log.w(TAG, "onListenerConnected unable to get active notifications.");
+            return;
+        }
+        final RankingMap currentRanking = getCurrentRanking();
+        mPresenter.getHandler().post(() -> {
+            for (StatusBarNotification sbn : notifications) {
+                mPresenter.addNotification(sbn, currentRanking);
+            }
+        });
+    }
+
+    @Override
+    public void onNotificationPosted(final StatusBarNotification sbn,
+            final RankingMap rankingMap) {
+        if (DEBUG) Log.d(TAG, "onNotificationPosted: " + sbn);
+        if (sbn != null && !onPluginNotificationPosted(sbn, rankingMap)) {
+            mPresenter.getHandler().post(() -> {
+                processForRemoteInput(sbn.getNotification(), mContext);
+                String key = sbn.getKey();
+                mPresenter.getKeysKeptForRemoteInput().remove(key);
+                boolean isUpdate = mPresenter.getNotificationData().get(key) != null;
+                // In case we don't allow child notifications, we ignore children of
+                // notifications that have a summary, since` we're not going to show them
+                // anyway. This is true also when the summary is canceled,
+                // because children are automatically canceled by NoMan in that case.
+                if (!ENABLE_CHILD_NOTIFICATIONS
+                        && mPresenter.getGroupManager().isChildInGroupWithSummary(sbn)) {
+                    if (DEBUG) {
+                        Log.d(TAG, "Ignoring group child due to existing summary: " + sbn);
+                    }
+
+                    // Remove existing notification to avoid stale data.
+                    if (isUpdate) {
+                        mPresenter.removeNotification(key, rankingMap);
+                    } else {
+                        mPresenter.getNotificationData().updateRanking(rankingMap);
+                    }
+                    return;
+                }
+                if (isUpdate) {
+                    mPresenter.updateNotification(sbn, rankingMap);
+                } else {
+                    mPresenter.addNotification(sbn, rankingMap);
+                }
+            });
+        }
+    }
+
+    @Override
+    public void onNotificationRemoved(StatusBarNotification sbn,
+            final RankingMap rankingMap) {
+        if (DEBUG) Log.d(TAG, "onNotificationRemoved: " + sbn);
+        if (sbn != null && !onPluginNotificationRemoved(sbn, rankingMap)) {
+            final String key = sbn.getKey();
+            mPresenter.getHandler().post(() -> mPresenter.removeNotification(key, rankingMap));
+        }
+    }
+
+    @Override
+    public void onNotificationRankingUpdate(final RankingMap rankingMap) {
+        if (DEBUG) Log.d(TAG, "onRankingUpdate");
+        if (rankingMap != null) {
+            RankingMap r = onPluginRankingUpdate(rankingMap);
+            mPresenter.getHandler().post(() -> mPresenter.updateNotificationRanking(r));
+        }
+    }
+
+    public void register() {
+        try {
+            registerAsSystemService(mContext,
+                    new ComponentName(mContext.getPackageName(), getClass().getCanonicalName()),
+                    UserHandle.USER_ALL);
+        } catch (RemoteException e) {
+            Log.e(TAG, "Unable to register notification listener", e);
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationPresenter.java b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationPresenter.java
index 8670887..4eca241 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationPresenter.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationPresenter.java
@@ -19,6 +19,8 @@
 import android.os.Handler;
 import android.service.notification.NotificationListenerService;
 
+import java.util.Set;
+
 /**
  * An abstraction of something that presents notifications, e.g. StatusBar. Contains methods
  * for both querying the state of the system (some modularised piece of functionality may
@@ -26,7 +28,8 @@
  * for affecting the state of the system (e.g. starting an intent, given that the presenter may
  * want to perform some action before doing so).
  */
-public interface NotificationPresenter {
+public interface NotificationPresenter extends NotificationUpdateHandler,
+        NotificationData.Environment {
 
     /**
      * Returns true if the presenter is not visible. For example, it may not be necessary to do
@@ -66,12 +69,6 @@
      */
     void updateMediaMetaData(boolean metaDataChanged, boolean allowEnterAnimation);
 
-    // TODO: Create NotificationUpdateHandler and move this method to there.
-    /**
-     * Removes a notification.
-     */
-    void removeNotification(String key, NotificationListenerService.RankingMap ranking);
-
     // TODO: Create NotificationEntryManager and move this method to there.
     /**
      * Gets the latest ranking map.
@@ -84,6 +81,14 @@
     void onWorkChallengeChanged();
 
     /**
+     * Notifications in this set are kept around when they were canceled in response to a remote
+     * input interaction. This allows us to show what you replied and allows you to continue typing
+     * into it.
+     */
+    // TODO: Create NotificationEntryManager and move this method to there.
+    Set<String> getKeysKeptForRemoteInput();
+
+    /**
      * Called when the current user changes.
      * @param newUserId new user id
      */
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationUpdateHandler.java b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationUpdateHandler.java
new file mode 100644
index 0000000..0044194
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationUpdateHandler.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2017 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;
+
+import android.service.notification.NotificationListenerService;
+import android.service.notification.StatusBarNotification;
+
+/**
+ * Interface for accepting notification updates from {@link NotificationListener}.
+ */
+public interface NotificationUpdateHandler {
+    /**
+     * Add a new notification and update the current notification ranking map.
+     *
+     * @param notification Notification to add
+     * @param ranking RankingMap to update with
+     */
+    void addNotification(StatusBarNotification notification,
+            NotificationListenerService.RankingMap ranking);
+
+    /**
+     * Remove a notification and update the current notification ranking map.
+     *
+     * @param key Key identifying the notification to remove
+     * @param ranking RankingMap to update with
+     */
+    void removeNotification(String key, NotificationListenerService.RankingMap ranking);
+
+    /**
+     * Update a given notification and the current notification ranking map.
+     *
+     * @param notification Updated notification
+     * @param ranking RankingMap to update with
+     */
+    void updateNotification(StatusBarNotification notification,
+            NotificationListenerService.RankingMap ranking);
+
+    /**
+     * Update with a new notification ranking map.
+     *
+     * @param ranking RankingMap to update with
+     */
+    void updateNotificationRanking(NotificationListenerService.RankingMap ranking);
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/RemoteInputController.java b/packages/SystemUI/src/com/android/systemui/statusbar/RemoteInputController.java
index ff6c775..97e3d22 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/RemoteInputController.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/RemoteInputController.java
@@ -21,17 +21,24 @@
 import com.android.systemui.statusbar.phone.StatusBarWindowManager;
 import com.android.systemui.statusbar.policy.RemoteInputView;
 
+import android.app.Notification;
+import android.app.RemoteInput;
+import android.content.Context;
+import android.os.SystemProperties;
 import android.util.ArrayMap;
 import android.util.ArraySet;
 import android.util.Pair;
 
 import java.lang.ref.WeakReference;
 import java.util.ArrayList;
+import java.util.List;
 
 /**
  * Keeps track of the currently active {@link RemoteInputView}s.
  */
 public class RemoteInputController {
+    private static final boolean ENABLE_REMOTE_INPUT =
+            SystemProperties.getBoolean("debug.enable_remote_input", true);
 
     private final ArrayList<Pair<WeakReference<NotificationData.Entry>, Object>> mOpen
             = new ArrayList<>();
@@ -45,6 +52,53 @@
     }
 
     /**
+     * Adds RemoteInput actions from the WearableExtender; to be removed once more apps support this
+     * via first-class API.
+     *
+     * TODO: Remove once enough apps specify remote inputs on their own.
+     */
+    public static void processForRemoteInput(Notification n, Context context) {
+        if (!ENABLE_REMOTE_INPUT) {
+            return;
+        }
+
+        if (n.extras != null && n.extras.containsKey("android.wearable.EXTENSIONS") &&
+                (n.actions == null || n.actions.length == 0)) {
+            Notification.Action viableAction = null;
+            Notification.WearableExtender we = new Notification.WearableExtender(n);
+
+            List<Notification.Action> actions = we.getActions();
+            final int numActions = actions.size();
+
+            for (int i = 0; i < numActions; i++) {
+                Notification.Action action = actions.get(i);
+                if (action == null) {
+                    continue;
+                }
+                RemoteInput[] remoteInputs = action.getRemoteInputs();
+                if (remoteInputs == null) {
+                    continue;
+                }
+                for (RemoteInput ri : remoteInputs) {
+                    if (ri.getAllowFreeFormInput()) {
+                        viableAction = action;
+                        break;
+                    }
+                }
+                if (viableAction != null) {
+                    break;
+                }
+            }
+
+            if (viableAction != null) {
+                Notification.Builder rebuilder = Notification.Builder.recoverBuilder(context, n);
+                rebuilder.setActions(viableAction);
+                rebuilder.build(); // will rewrite n
+            }
+        }
+    }
+
+    /**
      * Adds a currently active remote input.
      *
      * @param entry the entry for which a remote input is now active.
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 7c33437..6071df0 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java
@@ -200,6 +200,7 @@
 import com.android.systemui.statusbar.NotificationData.Entry;
 import com.android.systemui.statusbar.NotificationGutsManager;
 import com.android.systemui.statusbar.NotificationInfo;
+import com.android.systemui.statusbar.NotificationListener;
 import com.android.systemui.statusbar.NotificationLockscreenUserManager;
 import com.android.systemui.statusbar.NotificationMediaManager;
 import com.android.systemui.statusbar.NotificationPresenter;
@@ -249,6 +250,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.Stack;
 
 public class StatusBar extends SystemUI implements DemoMode,
@@ -819,14 +821,8 @@
         }
 
         // Set up the initial notification state.
-        try {
-            mNotificationListener.registerAsSystemService(mContext,
-                    new ComponentName(mContext.getPackageName(), getClass().getCanonicalName()),
-                    UserHandle.USER_ALL);
-        } catch (RemoteException e) {
-            Log.e(TAG, "Unable to register notification listener", e);
-        }
-
+        mNotificationListener = new NotificationListener(this, mContext);
+        mNotificationListener.register();
 
         if (DEBUG) {
             Log.d(TAG, String.format(
@@ -1519,13 +1515,19 @@
         SystemServicesProxy.getInstance(mContext).awakenDreamsAsync();
     }
 
-    public void addNotification(StatusBarNotification notification, RankingMap ranking)
-            throws InflationException {
+    @Override
+    public void addNotification(StatusBarNotification notification, RankingMap ranking) {
         String key = notification.getKey();
         if (DEBUG) Log.d(TAG, "addNotification key=" + key);
 
         mNotificationData.updateRanking(ranking);
-        Entry shadeEntry = createNotificationViews(notification);
+        Entry shadeEntry = null;
+        try {
+            shadeEntry = createNotificationViews(notification);
+        } catch (InflationException e) {
+            handleInflationException(notification, e);
+            return;
+        }
         boolean isHeadsUped = shouldPeek(shadeEntry);
         if (!isHeadsUped && notification.getNotification().fullScreenIntent != null) {
             if (shouldSuppressFullScreenIntent(key)) {
@@ -1539,11 +1541,11 @@
                             + key);
                 }
             } else {
-                // Stop screensaver if the notification has a full-screen intent.
+                // Stop screensaver if the notification has a fullscreen intent.
                 // (like an incoming phone call)
                 awakenDreams();
 
-                // not immersive & a full-screen alert should be shown
+                // not immersive & a fullscreen alert should be shown
                 if (DEBUG)
                     Log.d(TAG, "Notification has fullScreenIntent; sending fullScreenIntent");
                 try {
@@ -1620,7 +1622,8 @@
         }
     }
 
-    protected void updateNotificationRanking(RankingMap ranking) {
+    @Override
+    public void updateNotificationRanking(RankingMap ranking) {
         mNotificationData.updateRanking(ranking);
         updateNotifications();
     }
@@ -1673,7 +1676,7 @@
                     newNotification, sbn.getUser(), sbn.getOverrideGroupKey(), sbn.getPostTime());
             boolean updated = false;
             try {
-                updateNotification(newSbn, null);
+                updateNotificationInternal(newSbn, null);
                 updated = true;
             } catch (InflationException e) {
                 deferRemoval = false;
@@ -5693,90 +5696,7 @@
         }
     };
 
-    private final NotificationListenerWithPlugins mNotificationListener =
-            new NotificationListenerWithPlugins() {
-        @Override
-        public void onListenerConnected() {
-            if (DEBUG) Log.d(TAG, "onListenerConnected");
-            onPluginConnected();
-            final StatusBarNotification[] notifications = getActiveNotifications();
-            if (notifications == null) {
-                Log.w(TAG, "onListenerConnected unable to get active notifications.");
-                return;
-            }
-            final RankingMap currentRanking = getCurrentRanking();
-            mHandler.post(() -> {
-                for (StatusBarNotification sbn : notifications) {
-                    try {
-                        addNotification(sbn, currentRanking);
-                    } catch (InflationException e) {
-                        handleInflationException(sbn, e);
-                    }
-                }
-            });
-        }
-
-        @Override
-        public void onNotificationPosted(final StatusBarNotification sbn,
-                final RankingMap rankingMap) {
-            if (DEBUG) Log.d(TAG, "onNotificationPosted: " + sbn);
-            if (sbn != null && !onPluginNotificationPosted(sbn, rankingMap)) {
-                mHandler.post(() -> {
-                    processForRemoteInput(sbn.getNotification());
-                    String key = sbn.getKey();
-                    mKeysKeptForRemoteInput.remove(key);
-                    boolean isUpdate = mNotificationData.get(key) != null;
-                    // In case we don't allow child notifications, we ignore children of
-                    // notifications that have a summary, since we're not going to show them
-                    // anyway. This is true also when the summary is canceled,
-                    // because children are automatically canceled by NoMan in that case.
-                    if (!ENABLE_CHILD_NOTIFICATIONS
-                            && mGroupManager.isChildInGroupWithSummary(sbn)) {
-                        if (DEBUG) {
-                            Log.d(TAG, "Ignoring group child due to existing summary: " + sbn);
-                        }
-
-                        // Remove existing notification to avoid stale data.
-                        if (isUpdate) {
-                            removeNotification(key, rankingMap);
-                        } else {
-                            mNotificationData.updateRanking(rankingMap);
-                        }
-                        return;
-                    }
-                    try {
-                        if (isUpdate) {
-                            updateNotification(sbn, rankingMap);
-                        } else {
-                            addNotification(sbn, rankingMap);
-                        }
-                    } catch (InflationException e) {
-                        handleInflationException(sbn, e);
-                    }
-                });
-            }
-        }
-
-        @Override
-        public void onNotificationRemoved(StatusBarNotification sbn,
-                final RankingMap rankingMap) {
-            if (DEBUG) Log.d(TAG, "onNotificationRemoved: " + sbn);
-            if (sbn != null && !onPluginNotificationRemoved(sbn, rankingMap)) {
-                final String key = sbn.getKey();
-                mHandler.post(() -> removeNotification(key, rankingMap));
-            }
-        }
-
-        @Override
-        public void onNotificationRankingUpdate(final RankingMap rankingMap) {
-            if (DEBUG) Log.d(TAG, "onRankingUpdate");
-            if (rankingMap != null) {
-                RankingMap r = onPluginRankingUpdate(rankingMap);
-                mHandler.post(() -> updateNotificationRanking(r));
-            }
-        }
-
-    };
+    protected NotificationListener mNotificationListener;
 
     protected void notifyUserAboutHiddenNotifications() {
         if (0 != Settings.Secure.getInt(mContext.getContentResolver(),
@@ -6063,51 +5983,6 @@
         row.updateNotification(entry);
     }
 
-    /**
-     * Adds RemoteInput actions from the WearableExtender; to be removed once more apps support this
-     * via first-class API.
-     *
-     * TODO: Remove once enough apps specify remote inputs on their own.
-     */
-    private void processForRemoteInput(Notification n) {
-        if (!ENABLE_REMOTE_INPUT) return;
-
-        if (n.extras != null && n.extras.containsKey("android.wearable.EXTENSIONS") &&
-                (n.actions == null || n.actions.length == 0)) {
-            Notification.Action viableAction = null;
-            Notification.WearableExtender we = new Notification.WearableExtender(n);
-
-            List<Notification.Action> actions = we.getActions();
-            final int numActions = actions.size();
-
-            for (int i = 0; i < numActions; i++) {
-                Notification.Action action = actions.get(i);
-                if (action == null) {
-                    continue;
-                }
-                RemoteInput[] remoteInputs = action.getRemoteInputs();
-                if (remoteInputs == null) {
-                    continue;
-                }
-                for (RemoteInput ri : remoteInputs) {
-                    if (ri.getAllowFreeFormInput()) {
-                        viableAction = action;
-                        break;
-                    }
-                }
-                if (viableAction != null) {
-                    break;
-                }
-            }
-
-            if (viableAction != null) {
-                Notification.Builder rebuilder = Notification.Builder.recoverBuilder(mContext, n);
-                rebuilder.setActions(viableAction);
-                rebuilder.build(); // will rewrite n
-            }
-        }
-    }
-
     public void startPendingIntentDismissingKeyguard(final PendingIntent intent) {
         if (!isDeviceProvisioned()) return;
 
@@ -6496,8 +6371,9 @@
         mScrimController.setNotificationCount(mStackScroller.getNotGoneChildCount());
     }
 
-    public void updateNotification(StatusBarNotification notification, RankingMap ranking)
-            throws InflationException {
+    // TODO: Move this to NotificationEntryManager once it is created.
+    private void updateNotificationInternal(StatusBarNotification notification,
+            RankingMap ranking) throws InflationException {
         if (DEBUG) Log.d(TAG, "updateNotification(" + notification + ")");
 
         final String key = notification.getKey();
@@ -6547,6 +6423,15 @@
         setAreThereNotifications();
     }
 
+    @Override
+    public void updateNotification(StatusBarNotification notification, RankingMap ranking) {
+        try {
+            updateNotificationInternal(notification, ranking);
+        } catch (InflationException e) {
+            handleInflationException(notification, e);
+        }
+    }
+
     protected void notifyHeadsUpGoingToSleep() {
         maybeEscalateHeadsUp();
     }
@@ -6743,6 +6628,11 @@
         return mLatestRankingMap;
     }
 
+    @Override
+    public Set<String> getKeysKeptForRemoteInput() {
+        return mKeysKeptForRemoteInput;
+    }
+
     private final NotificationInfo.CheckSaveListener mCheckSaveListener =
             (Runnable saveImportance, StatusBarNotification sbn) -> {
                 // If the user has security enabled, show challenge if the setting is changed.
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/NotificationListenerTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/NotificationListenerTest.java
new file mode 100644
index 0000000..6ecfe3e
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/NotificationListenerTest.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2017 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;
+
+import static junit.framework.Assert.assertTrue;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.Notification;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.UserHandle;
+import android.service.notification.NotificationListenerService;
+import android.service.notification.StatusBarNotification;
+import android.support.test.filters.SmallTest;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper;
+
+import com.android.systemui.SysuiTestCase;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.HashSet;
+import java.util.Set;
+
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+@TestableLooper.RunWithLooper
+public class NotificationListenerTest extends SysuiTestCase {
+    private static final String TEST_PACKAGE_NAME = "test";
+    private static final int TEST_UID = 0;
+
+    private NotificationPresenter mPresenter;
+    private Handler mHandler;
+    private NotificationListener mListener;
+    private StatusBarNotification mSbn;
+    private NotificationListenerService.RankingMap mRanking;
+    private Set<String> mKeysKeptForRemoteInput;
+    private NotificationData mNotificationData;
+
+    @Before
+    public void setUp() {
+        mHandler = new Handler(Looper.getMainLooper());
+        mPresenter = mock(NotificationPresenter.class);
+        mNotificationData = mock(NotificationData.class);
+        mRanking = mock(NotificationListenerService.RankingMap.class);
+        mKeysKeptForRemoteInput = new HashSet<>();
+
+        when(mPresenter.getHandler()).thenReturn(mHandler);
+        when(mPresenter.getNotificationData()).thenReturn(mNotificationData);
+        when(mPresenter.getKeysKeptForRemoteInput()).thenReturn(mKeysKeptForRemoteInput);
+
+        mListener = new NotificationListener(mPresenter, mContext);
+        mSbn = new StatusBarNotification(TEST_PACKAGE_NAME, TEST_PACKAGE_NAME, 0, null, TEST_UID, 0,
+                new Notification(), UserHandle.CURRENT, null, 0);
+    }
+
+    @Test
+    public void testNotificationAddCallsAddNotification() {
+        mListener.onNotificationPosted(mSbn, mRanking);
+        waitForIdleSync(mHandler);
+        verify(mPresenter).addNotification(mSbn, mRanking);
+    }
+
+    @Test
+    public void testPostNotificationRemovesKeyKeptForRemoteInput() {
+        mKeysKeptForRemoteInput.add(mSbn.getKey());
+        mListener.onNotificationPosted(mSbn, mRanking);
+        waitForIdleSync(mHandler);
+        assertTrue(mKeysKeptForRemoteInput.isEmpty());
+    }
+
+    @Test
+    public void testNotificationUpdateCallsUpdateNotification() {
+        when(mNotificationData.get(mSbn.getKey())).thenReturn(new NotificationData.Entry(mSbn));
+        mListener.onNotificationPosted(mSbn, mRanking);
+        waitForIdleSync(mHandler);
+        verify(mPresenter).updateNotification(mSbn, mRanking);
+    }
+
+    @Test
+    public void testNotificationRemovalCallsRemoveNotification() {
+        mListener.onNotificationRemoved(mSbn, mRanking);
+        waitForIdleSync(mHandler);
+        verify(mPresenter).removeNotification(mSbn.getKey(), mRanking);
+    }
+
+    @Test
+    public void testRankingUpdateCallsNotificationRankingUpdate() {
+        mListener.onNotificationRankingUpdate(mRanking);
+        waitForIdleSync(mHandler);
+        // RankingMap may be modified by plugins.
+        verify(mPresenter).updateNotificationRanking(any());
+    }
+}