Add listener for conversation changes

Add hidden ConversationListener and registration in PeopleManager
for People Tiles to register a listener to individual
conversation storage changes for targetted updates.

Test: DataManagerTest, PeopleServiceTest, PeopleManagerTest
Bug: 178792356
Change-Id: I0cab6913c138d6ac515fed74741dd62bf967772b
diff --git a/core/java/android/app/people/IConversationListener.aidl b/core/java/android/app/people/IConversationListener.aidl
new file mode 100644
index 0000000..7cbd66d
--- /dev/null
+++ b/core/java/android/app/people/IConversationListener.aidl
@@ -0,0 +1,33 @@
+/**
+ * Copyright (c) 2021, 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 android.app.people;
+
+import android.app.people.ConversationChannel;
+import android.content.pm.ParceledListSlice;
+import android.os.UserHandle;
+
+import java.util.List;
+
+/**
+ * Interface for PeopleManager#ConversationListener.
+ *
+ * @hide
+ */
+oneway interface IConversationListener
+{
+    void onConversationUpdate(in ConversationChannel conversation);
+}
\ No newline at end of file
diff --git a/core/java/android/app/people/IPeopleManager.aidl b/core/java/android/app/people/IPeopleManager.aidl
index d000f3b..496ca82 100644
--- a/core/java/android/app/people/IPeopleManager.aidl
+++ b/core/java/android/app/people/IPeopleManager.aidl
@@ -18,6 +18,7 @@
 
 import android.app.people.ConversationStatus;
 import android.app.people.ConversationChannel;
+import android.app.people.IConversationListener;
 import android.content.pm.ParceledListSlice;
 import android.net.Uri;
 import android.os.IBinder;
@@ -62,4 +63,6 @@
     void clearStatus(in String packageName, int userId, in String conversationId, in String statusId);
     void clearStatuses(in String packageName, int userId, in String conversationId);
     ParceledListSlice getStatuses(in String packageName, int userId, in String conversationId);
+    void registerConversationListener(in String packageName, int userId, in String shortcutId, in IConversationListener callback);
+    void unregisterConversationListener(in IConversationListener callback);
 }
diff --git a/core/java/android/app/people/PeopleManager.java b/core/java/android/app/people/PeopleManager.java
index d348edb..108437e 100644
--- a/core/java/android/app/people/PeopleManager.java
+++ b/core/java/android/app/people/PeopleManager.java
@@ -16,6 +16,8 @@
 
 package android.app.people;
 
+import static java.util.Objects.requireNonNull;
+
 import android.annotation.NonNull;
 import android.annotation.RequiresPermission;
 import android.annotation.SystemApi;
@@ -25,12 +27,18 @@
 import android.content.pm.ShortcutInfo;
 import android.os.RemoteException;
 import android.os.ServiceManager;
+import android.util.Pair;
+import android.util.Slog;
 
+import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.util.Preconditions;
 
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
+import java.util.concurrent.Executor;
 
 /**
  * This class allows interaction with conversation and people data.
@@ -40,11 +48,18 @@
 
     private static final String LOG_TAG = PeopleManager.class.getSimpleName();
 
-    @NonNull
-    private final Context mContext;
+    /**
+     * @hide
+     */
+    @VisibleForTesting
+    public Map<ConversationListener, Pair<Executor, IConversationListener>>
+            mConversationListeners = new HashMap<>();
 
     @NonNull
-    private final IPeopleManager mService;
+    private Context mContext;
+
+    @NonNull
+    private IPeopleManager mService;
 
     /**
      * @hide
@@ -56,6 +71,15 @@
     }
 
     /**
+     * @hide
+     */
+    @VisibleForTesting
+    public PeopleManager(@NonNull Context context, IPeopleManager service) {
+        mContext = context;
+        mService = service;
+    }
+
+    /**
      * Returns whether a shortcut has a conversation associated.
      *
      * <p>Requires android.permission.READ_PEOPLE_DATA permission.
@@ -66,9 +90,8 @@
      * clients.
      *
      * @param packageName name of the package the conversation is part of
-     * @param shortcutId the shortcut id backing the conversation
+     * @param shortcutId  the shortcut id backing the conversation
      * @return whether the {@shortcutId} is backed by a Conversation.
-     *
      * @hide
      */
     @SystemApi
@@ -94,8 +117,7 @@
      *
      * @param conversationId the {@link ShortcutInfo#getId() id} of the shortcut backing the
      *                       conversation that has an active status
-     * @param status the current status for the given conversation
-     *
+     * @param status         the current status for the given conversation
      * @return whether the role is available in the system
      */
     public void addOrUpdateStatus(@NonNull String conversationId,
@@ -115,8 +137,8 @@
      *
      * @param conversationId the {@link ShortcutInfo#getId() id} of the shortcut backing the
      *                       conversation that has an active status
-     * @param statusId the {@link ConversationStatus#getId() id} of a published status for the given
-     *                 conversation
+     * @param statusId       the {@link ConversationStatus#getId() id} of a published status for the
+     *                       given conversation
      */
     public void clearStatus(@NonNull String conversationId, @NonNull String statusId) {
         Preconditions.checkStringNotEmpty(conversationId);
@@ -155,7 +177,7 @@
         try {
             final ParceledListSlice<ConversationStatus> parceledList
                     = mService.getStatuses(
-                            mContext.getPackageName(), mContext.getUserId(), conversationId);
+                    mContext.getPackageName(), mContext.getUserId(), conversationId);
             if (parceledList != null) {
                 return parceledList.getList();
             }
@@ -164,4 +186,103 @@
         }
         return new ArrayList<>();
     }
+
+    /**
+     * Listeners for conversation changes.
+     *
+     * @hide
+     */
+    public interface ConversationListener {
+        /**
+         * Triggers when the conversation registered for a listener has been updated.
+         *
+         * @param conversation The conversation with modified data
+         * @see IPeopleManager#registerConversationListener(String, int, String,
+         * android.app.people.ConversationListener)
+         *
+         * <p>Only system root and SysUI have access to register the listener.
+         */
+        default void onConversationUpdate(@NonNull ConversationChannel conversation) {
+        }
+    }
+
+    /**
+     * Register a listener to watch for changes to the conversation identified by {@code
+     * packageName}, {@code userId}, and {@code shortcutId}.
+     *
+     * @param packageName The package name to match and filter the conversation to send updates for.
+     * @param userId      The user ID to match and filter the conversation to send updates for.
+     * @param shortcutId  The shortcut ID to match and filter the conversation to send updates for.
+     * @param listener    The listener to register to receive conversation updates.
+     * @param executor    {@link Executor} to handle the listeners. To dispatch listeners to the
+     *                    main thread of your application, you can use
+     *                    {@link android.content.Context#getMainExecutor()}.
+     * @hide
+     */
+    public void registerConversationListener(String packageName, int userId, String shortcutId,
+            ConversationListener listener, Executor executor) {
+        requireNonNull(listener, "Listener cannot be null");
+        requireNonNull(packageName, "Package name cannot be null");
+        requireNonNull(shortcutId, "Shortcut ID cannot be null");
+        synchronized (mConversationListeners) {
+            IConversationListener proxy = (IConversationListener) new ConversationListenerProxy(
+                    executor, listener);
+            try {
+                mService.registerConversationListener(
+                        packageName, userId, shortcutId, proxy);
+                mConversationListeners.put(listener,
+                        new Pair<>(executor, proxy));
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            }
+        }
+    }
+
+    /**
+     * Unregisters the listener previously registered to watch conversation changes.
+     *
+     * @param listener The listener to register to receive conversation updates.
+     * @hide
+     */
+    public void unregisterConversationListener(
+            ConversationListener listener) {
+        requireNonNull(listener, "Listener cannot be null");
+
+        synchronized (mConversationListeners) {
+            if (mConversationListeners.containsKey(listener)) {
+                IConversationListener proxy = mConversationListeners.remove(listener).second;
+                try {
+                    mService.unregisterConversationListener(proxy);
+                } catch (RemoteException e) {
+                    throw e.rethrowFromSystemServer();
+                }
+            }
+        }
+    }
+
+    /**
+     * Listener proxy class for {@link ConversationListener}
+     *
+     * @hide
+     */
+    private static class ConversationListenerProxy extends
+            IConversationListener.Stub {
+        private final Executor mExecutor;
+        private final ConversationListener mListener;
+
+        ConversationListenerProxy(Executor executor, ConversationListener listener) {
+            mExecutor = executor;
+            mListener = listener;
+        }
+
+        @Override
+        public void onConversationUpdate(@NonNull ConversationChannel conversation) {
+            if (mListener == null || mExecutor == null) {
+                // Binder is dead.
+                Slog.e(LOG_TAG, "Binder is dead");
+                return;
+            }
+            mExecutor.execute(() -> mListener.onConversationUpdate(conversation));
+        }
+    }
 }
diff --git a/core/tests/coretests/src/android/app/people/PeopleManagerTest.java b/core/tests/coretests/src/android/app/people/PeopleManagerTest.java
new file mode 100644
index 0000000..a2afc77
--- /dev/null
+++ b/core/tests/coretests/src/android/app/people/PeopleManagerTest.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2021 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 android.app.people;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.content.Context;
+import android.content.pm.ShortcutInfo;
+import android.os.test.TestLooper;
+import android.util.Pair;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Map;
+import java.util.concurrent.Executor;
+
+/**
+ * Tests for {@link android.app.people.PeopleManager.ConversationListener} and relevant APIs.
+ */
+@RunWith(AndroidJUnit4.class)
+public class PeopleManagerTest {
+
+    private static final String CONVERSATION_ID_1 = "12";
+    private static final String CONVERSATION_ID_2 = "123";
+
+    private Context mContext;
+
+    private final TestLooper mTestLooper = new TestLooper();
+
+    @Mock
+    private IPeopleManager mService;
+    private PeopleManager mPeopleManager;
+
+    @Before
+    public void setUp() throws Exception {
+        mContext = InstrumentationRegistry.getContext();
+        MockitoAnnotations.initMocks(this);
+
+        mPeopleManager = new PeopleManager(mContext, mService);
+    }
+
+    @Test
+    public void testCorrectlyMapsToProxyConversationListener() throws Exception {
+        PeopleManager.ConversationListener listenerForConversation1 = mock(
+                PeopleManager.ConversationListener.class);
+        registerListener(CONVERSATION_ID_1, listenerForConversation1);
+        PeopleManager.ConversationListener listenerForConversation2 = mock(
+                PeopleManager.ConversationListener.class);
+        registerListener(CONVERSATION_ID_2, listenerForConversation2);
+
+        Map<PeopleManager.ConversationListener, Pair<Executor, IConversationListener>>
+                listenersToProxy =
+                mPeopleManager.mConversationListeners;
+        Pair<Executor, IConversationListener> listener = listenersToProxy.get(
+                listenerForConversation1);
+        ConversationChannel conversation = getConversation(CONVERSATION_ID_1);
+        listener.second.onConversationUpdate(getConversation(CONVERSATION_ID_1));
+        mTestLooper.dispatchAll();
+
+        // Only call the associated listener.
+        verify(listenerForConversation2, never()).onConversationUpdate(any());
+        // Should update the listeners mapped to the proxy.
+        ArgumentCaptor<ConversationChannel> capturedConversation = ArgumentCaptor.forClass(
+                ConversationChannel.class);
+        verify(listenerForConversation1, times(1)).onConversationUpdate(
+                capturedConversation.capture());
+        ConversationChannel conversationChannel = capturedConversation.getValue();
+        assertEquals(conversationChannel.getShortcutInfo().getId(), CONVERSATION_ID_1);
+        assertEquals(conversationChannel.getShortcutInfo().getLabel(),
+                conversation.getShortcutInfo().getLabel());
+    }
+
+    private ConversationChannel getConversation(String shortcutId) {
+        ShortcutInfo shortcutInfo = new ShortcutInfo.Builder(mContext,
+                shortcutId).setLongLabel(
+                "name").build();
+        NotificationChannel notificationChannel = new NotificationChannel("123",
+                "channel",
+                NotificationManager.IMPORTANCE_DEFAULT);
+        return new ConversationChannel(shortcutInfo, 0,
+                notificationChannel, null,
+                123L, false);
+    }
+
+    private void registerListener(String conversationId,
+            PeopleManager.ConversationListener listener) {
+        mPeopleManager.registerConversationListener(mContext.getPackageName(), mContext.getUserId(),
+                conversationId, listener,
+                mTestLooper.getNewExecutor());
+    }
+}
diff --git a/services/people/java/com/android/server/people/PeopleService.java b/services/people/java/com/android/server/people/PeopleService.java
index 9666337..e7d0121 100644
--- a/services/people/java/com/android/server/people/PeopleService.java
+++ b/services/people/java/com/android/server/people/PeopleService.java
@@ -23,6 +23,7 @@
 import android.app.ActivityManager;
 import android.app.people.ConversationChannel;
 import android.app.people.ConversationStatus;
+import android.app.people.IConversationListener;
 import android.app.people.IPeopleManager;
 import android.app.prediction.AppPredictionContext;
 import android.app.prediction.AppPredictionSessionId;
@@ -33,10 +34,12 @@
 import android.content.pm.PackageManager;
 import android.content.pm.PackageManagerInternal;
 import android.content.pm.ParceledListSlice;
+import android.content.pm.ShortcutInfo;
 import android.os.Binder;
 import android.os.CancellationSignal;
 import android.os.IBinder;
 import android.os.Process;
+import android.os.RemoteCallbackList;
 import android.os.RemoteException;
 import android.os.UserHandle;
 import android.util.ArrayMap;
@@ -47,6 +50,7 @@
 import com.android.server.SystemService;
 import com.android.server.people.data.DataManager;
 
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.function.Consumer;
@@ -58,7 +62,9 @@
 
     private static final String TAG = "PeopleService";
 
-    private final DataManager mDataManager;
+    private DataManager mDataManager;
+    @VisibleForTesting
+    ConversationListenerHelper mConversationListenerHelper;
 
     private PackageManagerInternal mPackageManagerInternal;
 
@@ -71,6 +77,8 @@
         super(context);
 
         mDataManager = new DataManager(context);
+        mConversationListenerHelper = new ConversationListenerHelper();
+        mDataManager.addConversationsListener(mConversationListenerHelper);
     }
 
     @Override
@@ -148,12 +156,14 @@
      * @param message used as message if SecurityException is thrown
      * @throws SecurityException if the caller is not system or root
      */
-    private static void enforceSystemRootOrSystemUI(Context context, String message) {
+    @VisibleForTesting
+    protected void enforceSystemRootOrSystemUI(Context context, String message) {
         if (isSystemOrRoot()) return;
         context.enforceCallingPermission(android.Manifest.permission.STATUS_BAR_SERVICE,
                 message);
     }
 
+    @VisibleForTesting
     final IBinder mService = new IPeopleManager.Stub() {
 
         @Override
@@ -241,8 +251,137 @@
             return new ParceledListSlice<>(
                     mDataManager.getStatuses(packageName, userId, conversationId));
         }
+
+        @Override
+        public void registerConversationListener(
+                String packageName, int userId, String shortcutId, IConversationListener listener) {
+            enforceSystemRootOrSystemUI(getContext(), "register conversation listener");
+            mConversationListenerHelper.addConversationListener(
+                    new ListenerKey(packageName, userId, shortcutId), listener);
+        }
+
+        @Override
+        public void unregisterConversationListener(IConversationListener listener) {
+            enforceSystemRootOrSystemUI(getContext(), "unregister conversation listener");
+            mConversationListenerHelper.removeConversationListener(listener);
+        }
     };
 
+    /**
+     * Listeners for conversation changes.
+     *
+     * @hide
+     */
+    public interface ConversationsListener {
+        /**
+         * Triggers with the list of modified conversations from {@link DataManager} for dispatching
+         * relevant updates to clients.
+         *
+         * @param conversations The conversations with modified data
+         * @see IPeopleManager#registerConversationListener(String, int, String,
+         * android.app.people.ConversationListener)
+         */
+        default void onConversationsUpdate(@NonNull List<ConversationChannel> conversations) {
+        }
+    }
+
+    /**
+     * Implements {@code ConversationListenerHelper} to dispatch conversation updates to registered
+     * clients.
+     */
+    public static class ConversationListenerHelper implements ConversationsListener {
+
+        ConversationListenerHelper() {
+        }
+
+        @VisibleForTesting
+        final RemoteCallbackList<IConversationListener> mListeners =
+                new RemoteCallbackList<>();
+
+        /** Adds {@code listener} with {@code key} associated. */
+        public synchronized void addConversationListener(ListenerKey key,
+                IConversationListener listener) {
+            mListeners.unregister(listener);
+            mListeners.register(listener, key);
+        }
+
+        /** Removes {@code listener}. */
+        public synchronized void removeConversationListener(
+                IConversationListener listener) {
+            mListeners.unregister(listener);
+        }
+
+        @Override
+        /** Dispatches updates to {@code mListeners} with keys mapped to {@code conversations}. */
+        public void onConversationsUpdate(List<ConversationChannel> conversations) {
+            int count = mListeners.beginBroadcast();
+            // Early opt-out if no listeners are registered.
+            if (count == 0) {
+                return;
+            }
+            Map<ListenerKey, ConversationChannel> keyedConversations = new HashMap<>();
+            for (ConversationChannel conversation : conversations) {
+                keyedConversations.put(getListenerKey(conversation), conversation);
+            }
+            for (int i = 0; i < count; i++) {
+                final ListenerKey listenerKey = (ListenerKey) mListeners.getBroadcastCookie(i);
+                if (!keyedConversations.containsKey(listenerKey)) {
+                    continue;
+                }
+                final IConversationListener listener = mListeners.getBroadcastItem(i);
+                try {
+                    ConversationChannel channel = keyedConversations.get(listenerKey);
+                    listener.onConversationUpdate(channel);
+                } catch (RemoteException e) {
+                    // The RemoteCallbackList will take care of removing the dead object.
+                }
+            }
+            mListeners.finishBroadcast();
+        }
+
+        private ListenerKey getListenerKey(ConversationChannel conversation) {
+            ShortcutInfo info = conversation.getShortcutInfo();
+            return new ListenerKey(info.getPackage(), info.getUserId(),
+                    info.getId());
+        }
+    }
+
+    private static class ListenerKey {
+        private final String mPackageName;
+        private final Integer mUserId;
+        private final String mShortcutId;
+
+        ListenerKey(String packageName, Integer userId, String shortcutId) {
+            this.mPackageName = packageName;
+            this.mUserId = userId;
+            this.mShortcutId = shortcutId;
+        }
+
+        public String getPackageName() {
+            return mPackageName;
+        }
+
+        public Integer getUserId() {
+            return mUserId;
+        }
+
+        public String getShortcutId() {
+            return mShortcutId;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            ListenerKey key = (ListenerKey) o;
+            return key.getPackageName().equals(mPackageName) && key.getUserId() == mUserId
+                    && key.getShortcutId().equals(mShortcutId);
+        }
+
+        @Override
+        public int hashCode() {
+            return mPackageName.hashCode() + mUserId.hashCode() + mShortcutId.hashCode();
+        }
+    }
+
     @VisibleForTesting
     final class LocalService extends PeopleServiceInternal {
 
diff --git a/services/people/java/com/android/server/people/data/DataManager.java b/services/people/java/com/android/server/people/data/DataManager.java
index 1048dfd..75614d6 100644
--- a/services/people/java/com/android/server/people/data/DataManager.java
+++ b/services/people/java/com/android/server/people/data/DataManager.java
@@ -48,6 +48,7 @@
 import android.net.Uri;
 import android.os.CancellationSignal;
 import android.os.Handler;
+import android.os.Looper;
 import android.os.Process;
 import android.os.RemoteException;
 import android.os.UserHandle;
@@ -75,14 +76,17 @@
 import com.android.server.LocalServices;
 import com.android.server.notification.NotificationManagerInternal;
 import com.android.server.notification.ShortcutHelper;
+import com.android.server.people.PeopleService;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.PriorityQueue;
 import java.util.Set;
 import java.util.concurrent.Executor;
@@ -118,6 +122,11 @@
     private final SparseArray<ScheduledFuture<?>> mUsageStatsQueryFutures = new SparseArray<>();
     private final SparseArray<NotificationListener> mNotificationListeners = new SparseArray<>();
     private final SparseArray<PackageMonitor> mPackageMonitors = new SparseArray<>();
+    @GuardedBy("mLock")
+    private final List<PeopleService.ConversationsListener> mConversationsListeners =
+            new ArrayList<>(1);
+    private final Handler mHandler;
+
     private ContentObserver mCallLogContentObserver;
     private ContentObserver mMmsSmsContentObserver;
 
@@ -128,14 +137,14 @@
     private ConversationStatusExpirationBroadcastReceiver mStatusExpReceiver;
 
     public DataManager(Context context) {
-        this(context, new Injector());
+        this(context, new Injector(), BackgroundThread.get().getLooper());
     }
 
-    @VisibleForTesting
-    DataManager(Context context, Injector injector) {
+    DataManager(Context context, Injector injector, Looper looper) {
         mContext = context;
         mInjector = injector;
         mScheduledExecutor = mInjector.createScheduledExecutor();
+        mHandler = new Handler(looper);
     }
 
     /** Initialization. Called when the system services are up running. */
@@ -234,20 +243,19 @@
             PackageData packageData = userData.getPackageData(packageName);
             // App may have been uninstalled.
             if (packageData != null) {
-                return getConversationChannel(packageData, shortcutId);
+                ConversationInfo conversationInfo = packageData.getConversationInfo(shortcutId);
+                return getConversationChannel(packageName, userId, shortcutId, conversationInfo);
             }
         }
         return null;
     }
 
     @Nullable
-    private ConversationChannel getConversationChannel(PackageData packageData, String shortcutId) {
-        ConversationInfo conversationInfo = packageData.getConversationInfo(shortcutId);
+    private ConversationChannel getConversationChannel(String packageName, int userId,
+            String shortcutId, ConversationInfo conversationInfo) {
         if (conversationInfo == null) {
             return null;
         }
-        int userId = packageData.getUserId();
-        String packageName = packageData.getPackageName();
         ShortcutInfo shortcutInfo = getShortcut(packageName, userId, shortcutId);
         if (shortcutInfo == null) {
             return null;
@@ -278,7 +286,8 @@
                     return;
                 }
                 String shortcutId = conversationInfo.getShortcutId();
-                ConversationChannel channel = getConversationChannel(packageData, shortcutId);
+                ConversationChannel channel = getConversationChannel(packageData.getPackageName(),
+                        packageData.getUserId(), shortcutId, conversationInfo);
                 if (channel == null || channel.getParentNotificationChannel() == null) {
                     return;
                 }
@@ -384,7 +393,11 @@
         ConversationInfo convToModify = getConversationInfoOrThrow(cs, conversationId);
         ConversationInfo.Builder builder = new ConversationInfo.Builder(convToModify);
         builder.addOrUpdateStatus(status);
-        cs.addOrUpdate(builder.build());
+        ConversationInfo modifiedConv = builder.build();
+        cs.addOrUpdate(modifiedConv);
+        ConversationChannel conversation = getConversationChannel(packageName, userId,
+                conversationId, modifiedConv);
+        notifyConversationsListeners(Arrays.asList(conversation));
 
         if (status.getEndTimeMillis() >= 0) {
             mStatusExpReceiver.scheduleExpiration(
@@ -1235,6 +1248,32 @@
         }
     }
 
+    /** Adds {@code listener} to be notified on conversation changes. */
+    public void addConversationsListener(
+            @NonNull PeopleService.ConversationsListener listener) {
+        synchronized (mConversationsListeners) {
+            mConversationsListeners.add(Objects.requireNonNull(listener));
+        }
+    }
+
+    // TODO(b/178792356): Trigger ConversationsListener on all-related data changes.
+    @VisibleForTesting
+    void notifyConversationsListeners(
+            @Nullable final List<ConversationChannel> changedConversations) {
+        mHandler.post(() -> {
+            try {
+                final List<PeopleService.ConversationsListener> copy;
+                synchronized (mLock) {
+                    copy = new ArrayList<>(mConversationsListeners);
+                }
+                for (PeopleService.ConversationsListener listener : copy) {
+                    listener.onConversationsUpdate(changedConversations);
+                }
+            } catch (Exception e) {
+            }
+        });
+    }
+
     /** A {@link BroadcastReceiver} that receives the intents for a specified user. */
     private class PerUserBroadcastReceiver extends BroadcastReceiver {
 
diff --git a/services/tests/servicestests/src/com/android/server/people/PeopleServiceTest.java b/services/tests/servicestests/src/com/android/server/people/PeopleServiceTest.java
index a112b14..ecff409 100644
--- a/services/tests/servicestests/src/com/android/server/people/PeopleServiceTest.java
+++ b/services/tests/servicestests/src/com/android/server/people/PeopleServiceTest.java
@@ -16,58 +16,104 @@
 
 package com.android.server.people;
 
+import static android.app.people.ConversationStatus.ACTIVITY_GAME;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertEquals;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.people.ConversationChannel;
+import android.app.people.ConversationStatus;
+import android.app.people.IConversationListener;
+import android.app.people.IPeopleManager;
+import android.app.people.PeopleManager;
 import android.app.prediction.AppPredictionContext;
 import android.app.prediction.AppPredictionSessionId;
 import android.app.prediction.AppTarget;
 import android.app.prediction.IPredictionCallback;
 import android.content.Context;
 import android.content.pm.ParceledListSlice;
+import android.content.pm.ShortcutInfo;
 import android.os.Binder;
 import android.os.Bundle;
 import android.os.RemoteException;
+import android.os.test.TestLooper;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableContext;
+import android.testing.TestableLooper;
+
+import androidx.test.InstrumentationRegistry;
 
 import com.android.server.LocalServices;
 
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
-import org.junit.runners.JUnit4;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import java.util.function.Consumer;
 
-@RunWith(JUnit4.class)
+@RunWith(AndroidTestingRunner.class)
+@TestableLooper.RunWithLooper
 public final class PeopleServiceTest {
-
     private static final String APP_PREDICTION_SHARE_UI_SURFACE = "share";
     private static final int APP_PREDICTION_TARGET_COUNT = 4;
     private static final String TEST_PACKAGE_NAME = "com.example";
     private static final int USER_ID = 0;
+    private static final String CONVERSATION_ID_1 = "12";
+    private static final String CONVERSATION_ID_2 = "123";
 
     private PeopleServiceInternal mServiceInternal;
     private PeopleService.LocalService mLocalService;
     private AppPredictionSessionId mSessionId;
     private AppPredictionContext mPredictionContext;
 
-    @Mock private Context mContext;
-    @Mock private IPredictionCallback mCallback;
+    @Mock
+    private Context mMockContext;
+
+    @Rule
+    public final TestableContext mContext =
+            new TestableContext(InstrumentationRegistry.getContext(), null);
+
+    protected TestableContext getContext() {
+        return mContext;
+    }
+
+    @Mock
+    private IPredictionCallback mCallback;
+    private TestableLooper mTestableLooper;
+    private final TestLooper mTestLooper = new TestLooper();
+
+    private TestablePeopleService mPeopleService;
+    private IPeopleManager mIPeopleManager;
+    private PeopleManager mPeopleManager;
 
     @Before
     public void setUp() {
         MockitoAnnotations.initMocks(this);
 
-        when(mContext.getPackageName()).thenReturn(TEST_PACKAGE_NAME);
+        mPeopleService = new TestablePeopleService(mContext);
+        mTestableLooper = TestableLooper.get(this);
+        mIPeopleManager = ((IPeopleManager) mPeopleService.mService);
+        mPeopleManager = new PeopleManager(mContext, mIPeopleManager);
+        when(mMockContext.getPackageName()).thenReturn(TEST_PACKAGE_NAME);
         when(mCallback.asBinder()).thenReturn(new Binder());
-
         PeopleService service = new PeopleService(mContext);
         service.onStart(/* isForTesting= */ true);
 
@@ -75,7 +121,7 @@
         mLocalService = (PeopleService.LocalService) mServiceInternal;
 
         mSessionId = new AppPredictionSessionId("abc", USER_ID);
-        mPredictionContext = new AppPredictionContext.Builder(mContext)
+        mPredictionContext = new AppPredictionContext.Builder(mMockContext)
                 .setUiSurface(APP_PREDICTION_SHARE_UI_SURFACE)
                 .setPredictedTargetCount(APP_PREDICTION_TARGET_COUNT)
                 .setExtras(new Bundle())
@@ -111,4 +157,134 @@
 
         mServiceInternal.onDestroyPredictionSession(mSessionId);
     }
+
+    @Test
+    public void testRegisterConversationListener() throws Exception {
+        assertEquals(0,
+                mPeopleService.mConversationListenerHelper.mListeners.getRegisteredCallbackCount());
+
+        mIPeopleManager.registerConversationListener(TEST_PACKAGE_NAME, 0, CONVERSATION_ID_1,
+                new TestableConversationListener());
+        mTestableLooper.processAllMessages();
+        assertEquals(1,
+                mPeopleService.mConversationListenerHelper.mListeners.getRegisteredCallbackCount());
+
+        mIPeopleManager.registerConversationListener(TEST_PACKAGE_NAME, 0, CONVERSATION_ID_1,
+                new TestableConversationListener());
+        mTestableLooper.processAllMessages();
+        assertEquals(2,
+                mPeopleService.mConversationListenerHelper.mListeners.getRegisteredCallbackCount());
+
+        mIPeopleManager.registerConversationListener(TEST_PACKAGE_NAME, 0, CONVERSATION_ID_2,
+                new TestableConversationListener());
+        mTestableLooper.processAllMessages();
+        assertEquals(3,
+                mPeopleService.mConversationListenerHelper.mListeners.getRegisteredCallbackCount());
+    }
+
+    @Test
+    public void testUnregisterConversationListener() throws Exception {
+        TestableConversationListener listener1 = new TestableConversationListener();
+        mIPeopleManager.registerConversationListener(TEST_PACKAGE_NAME, 0, CONVERSATION_ID_1,
+                listener1);
+        TestableConversationListener listener2 = new TestableConversationListener();
+        mIPeopleManager.registerConversationListener(TEST_PACKAGE_NAME, 0, CONVERSATION_ID_1,
+                listener2);
+        TestableConversationListener listener3 = new TestableConversationListener();
+        mIPeopleManager.registerConversationListener(TEST_PACKAGE_NAME, 0, CONVERSATION_ID_2,
+                listener3);
+        mTestableLooper.processAllMessages();
+        assertEquals(3,
+                mPeopleService.mConversationListenerHelper.mListeners.getRegisteredCallbackCount());
+
+        mIPeopleManager.unregisterConversationListener(
+                listener2);
+        assertEquals(2,
+                mPeopleService.mConversationListenerHelper.mListeners.getRegisteredCallbackCount());
+        mIPeopleManager.unregisterConversationListener(
+                listener1);
+        assertEquals(1,
+                mPeopleService.mConversationListenerHelper.mListeners.getRegisteredCallbackCount());
+        mIPeopleManager.unregisterConversationListener(
+                listener3);
+        assertEquals(0,
+                mPeopleService.mConversationListenerHelper.mListeners.getRegisteredCallbackCount());
+    }
+
+    @Test
+    public void testOnlyTriggersConversationListenersForRegisteredConversation() {
+        PeopleManager.ConversationListener listenerForConversation1 = mock(
+                PeopleManager.ConversationListener.class);
+        registerListener(CONVERSATION_ID_1, listenerForConversation1);
+        PeopleManager.ConversationListener secondListenerForConversation1 = mock(
+                PeopleManager.ConversationListener.class);
+        registerListener(CONVERSATION_ID_1, secondListenerForConversation1);
+        PeopleManager.ConversationListener listenerForConversation2 = mock(
+                PeopleManager.ConversationListener.class);
+        registerListener(CONVERSATION_ID_2, listenerForConversation2);
+        assertEquals(3,
+                mPeopleService.mConversationListenerHelper.mListeners.getRegisteredCallbackCount());
+
+        // Update conversation with two listeners.
+        ConversationStatus status = new ConversationStatus.Builder(CONVERSATION_ID_1,
+                ACTIVITY_GAME).build();
+        mPeopleService.mConversationListenerHelper.onConversationsUpdate(
+                Arrays.asList(getConversation(CONVERSATION_ID_1, status)));
+        mTestLooper.dispatchAll();
+
+        // Never update listeners for other conversations.
+        verify(listenerForConversation2, never()).onConversationUpdate(any());
+        // Should update both listeners for the conversation.
+        ArgumentCaptor<ConversationChannel> capturedConversation = ArgumentCaptor.forClass(
+                ConversationChannel.class);
+        verify(listenerForConversation1, times(1)).onConversationUpdate(
+                capturedConversation.capture());
+        ConversationChannel conversationChannel = capturedConversation.getValue();
+        verify(secondListenerForConversation1, times(1)).onConversationUpdate(
+                eq(conversationChannel));
+        assertEquals(conversationChannel.getShortcutInfo().getId(), CONVERSATION_ID_1);
+        assertThat(conversationChannel.getStatuses()).containsExactly(status);
+    }
+
+    private void registerListener(String conversationId,
+            PeopleManager.ConversationListener listener) {
+        mPeopleManager.registerConversationListener(mContext.getPackageName(), mContext.getUserId(),
+                conversationId, listener,
+                mTestLooper.getNewExecutor());
+    }
+
+    private ConversationChannel getConversation(String shortcutId, ConversationStatus status) {
+        ShortcutInfo shortcutInfo = new ShortcutInfo.Builder(mContext,
+                shortcutId).setLongLabel(
+                "name").build();
+        NotificationChannel notificationChannel = new NotificationChannel("123",
+                "channel",
+                NotificationManager.IMPORTANCE_DEFAULT);
+        return new ConversationChannel(shortcutInfo, 0,
+                notificationChannel, null,
+                123L, false, false, Arrays.asList(status));
+    }
+
+    private class TestableConversationListener extends IConversationListener.Stub {
+        @Override
+        public void onConversationUpdate(ConversationChannel conversation) {
+        }
+    }
+
+    // Use a Testable subclass so we can simulate calls from the system without failing.
+    private static class TestablePeopleService extends PeopleService {
+        TestablePeopleService(Context context) {
+            super(context);
+        }
+
+        @Override
+        public void onStart() {
+            super.onStart(true);
+        }
+
+        @Override
+        protected void enforceSystemRootOrSystemUI(Context context, String message) {
+            return;
+        }
+    }
 }
diff --git a/services/tests/servicestests/src/com/android/server/people/data/DataManagerTest.java b/services/tests/servicestests/src/com/android/server/people/data/DataManagerTest.java
index 50d9f61..7709edb 100644
--- a/services/tests/servicestests/src/com/android/server/people/data/DataManagerTest.java
+++ b/services/tests/servicestests/src/com/android/server/people/data/DataManagerTest.java
@@ -79,6 +79,7 @@
 import android.os.Looper;
 import android.os.UserHandle;
 import android.os.UserManager;
+import android.os.test.TestLooper;
 import android.provider.ContactsContract;
 import android.service.notification.NotificationListenerService;
 import android.service.notification.StatusBarNotification;
@@ -91,8 +92,11 @@
 import com.android.internal.content.PackageMonitor;
 import com.android.server.LocalServices;
 import com.android.server.notification.NotificationManagerInternal;
+import com.android.server.people.PeopleService;
 import com.android.server.pm.parsing.pkg.AndroidPackage;
 
+import com.google.common.collect.Iterables;
+
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -123,6 +127,7 @@
     private static final String TEST_PKG_NAME = "pkg";
     private static final String TEST_CLASS_NAME = "class";
     private static final String TEST_SHORTCUT_ID = "sc";
+    private static final String TEST_SHORTCUT_ID_2 = "sc2";
     private static final int TEST_PKG_UID = 35;
     private static final String CONTACT_URI = "content://com.android.contacts/contacts/lookup/123";
     private static final String PHONE_NUMBER = "+1234567890";
@@ -157,6 +162,7 @@
     private ShortcutChangeCallback mShortcutChangeCallback;
     private ShortcutInfo mShortcutInfo;
     private TestInjector mInjector;
+    private TestLooper mLooper;
 
     @Before
     public void setUp() throws PackageManager.NameNotFoundException {
@@ -237,7 +243,8 @@
         mCancellationSignal = new CancellationSignal();
 
         mInjector = new TestInjector();
-        mDataManager = new DataManager(mContext, mInjector);
+        mLooper = new TestLooper();
+        mDataManager = new DataManager(mContext, mInjector, mLooper.getLooper());
         mDataManager.initialize();
 
         when(mShortcutServiceInternal.isSharingShortcut(anyInt(), anyString(), anyString(),
@@ -515,6 +522,84 @@
     }
 
     @Test
+    public void testAddConversationsListener() throws Exception {
+        mDataManager.onUserUnlocked(USER_ID_PRIMARY);
+        ShortcutInfo shortcut = buildShortcutInfo(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID,
+                buildPerson());
+        mDataManager.addOrUpdateConversationInfo(shortcut);
+        ConversationChannel conversationChannel = mDataManager.getConversation(TEST_PKG_NAME,
+                USER_ID_PRIMARY,
+                TEST_SHORTCUT_ID);
+
+        PeopleService.ConversationsListener listener = mock(
+                PeopleService.ConversationsListener.class);
+        mDataManager.addConversationsListener(listener);
+
+        List<ConversationChannel> changedConversations = Arrays.asList(conversationChannel);
+        verify(listener, times(0)).onConversationsUpdate(eq(changedConversations));
+        mDataManager.notifyConversationsListeners(changedConversations);
+        mLooper.dispatchAll();
+
+        verify(listener, times(1)).onConversationsUpdate(eq(changedConversations));
+    }
+
+    @Test
+    public void testAddConversationListenersNotifiesMultipleConversations() throws Exception {
+        mDataManager.onUserUnlocked(USER_ID_PRIMARY);
+        ShortcutInfo shortcut = buildShortcutInfo(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID,
+                buildPerson());
+        mDataManager.addOrUpdateConversationInfo(shortcut);
+        ConversationChannel conversationChannel = mDataManager.getConversation(TEST_PKG_NAME,
+                USER_ID_PRIMARY,
+                TEST_SHORTCUT_ID);
+        ShortcutInfo shortcut2 = buildShortcutInfo(TEST_PKG_NAME, USER_ID_PRIMARY,
+                TEST_SHORTCUT_ID_2,
+                buildPerson());
+        mDataManager.addOrUpdateConversationInfo(shortcut2);
+        ConversationChannel conversationChannel2 = mDataManager.getConversation(TEST_PKG_NAME,
+                USER_ID_PRIMARY,
+                TEST_SHORTCUT_ID_2);
+        PeopleService.ConversationsListener listener = mock(
+                PeopleService.ConversationsListener.class);
+        mDataManager.addConversationsListener(listener);
+
+        List<ConversationChannel> changedConversations = Arrays.asList(conversationChannel,
+                conversationChannel2);
+        verify(listener, times(0)).onConversationsUpdate(eq(changedConversations));
+        mDataManager.notifyConversationsListeners(changedConversations);
+        mLooper.dispatchAll();
+
+        verify(listener, times(1)).onConversationsUpdate(eq(changedConversations));
+        ArgumentCaptor<List<ConversationChannel>> capturedConversation = ArgumentCaptor.forClass(
+                List.class);
+        verify(listener, times(1)).onConversationsUpdate(capturedConversation.capture());
+        assertThat(capturedConversation.getValue()).containsExactly(conversationChannel,
+                conversationChannel2);
+    }
+
+    @Test
+    public void testAddOrUpdateStatusNotifiesConversationsListeners() throws Exception {
+        mDataManager.onUserUnlocked(USER_ID_PRIMARY);
+        ShortcutInfo shortcut = buildShortcutInfo(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID,
+                buildPerson());
+        mDataManager.addOrUpdateConversationInfo(shortcut);
+        PeopleService.ConversationsListener listener = mock(
+                PeopleService.ConversationsListener.class);
+        mDataManager.addConversationsListener(listener);
+
+        ConversationStatus status = new ConversationStatus.Builder("cs1", ACTIVITY_GAME).build();
+        mDataManager.addOrUpdateStatus(TEST_PKG_NAME, USER_ID_PRIMARY, TEST_SHORTCUT_ID, status);
+        mLooper.dispatchAll();
+
+        ArgumentCaptor<List<ConversationChannel>> capturedConversation = ArgumentCaptor.forClass(
+                List.class);
+        verify(listener, times(1)).onConversationsUpdate(capturedConversation.capture());
+        ConversationChannel result = Iterables.getOnlyElement(capturedConversation.getValue());
+        assertThat(result.getStatuses()).containsExactly(status);
+        assertEquals(result.getShortcutInfo().getId(), TEST_SHORTCUT_ID);
+    }
+
+    @Test
     public void testGetConversationReturnsCustomizedConversation() {
         mDataManager.onUserUnlocked(USER_ID_PRIMARY);
 
@@ -975,7 +1060,7 @@
         mDataManager.reportShareTargetEvent(appTargetEvent, intentFilter);
         byte[] payload = mDataManager.getBackupPayload(USER_ID_PRIMARY);
 
-        DataManager dataManager = new DataManager(mContext, mInjector);
+        DataManager dataManager = new DataManager(mContext, mInjector, mLooper.getLooper());
         dataManager.onUserUnlocked(USER_ID_PRIMARY);
         dataManager.restore(USER_ID_PRIMARY, payload);
         ConversationInfo conversationInfo = dataManager.getPackage(TEST_PKG_NAME, USER_ID_PRIMARY)