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)