From 4b65ce05ca7c95e8e21550267ff8ee42611ccbff Mon Sep 17 00:00:00 2001 From: Anna Zappone Date: Wed, 17 Feb 2021 12:22:09 +0000 Subject: [PATCH] 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 --- .../app/people/IConversationListener.aidl | 33 +++ .../android/app/people/IPeopleManager.aidl | 3 + .../android/app/people/PeopleManager.java | 141 ++++++++++++- .../android/app/people/PeopleManagerTest.java | 120 +++++++++++ .../android/server/people/PeopleService.java | 143 ++++++++++++- .../server/people/data/DataManager.java | 59 +++++- .../server/people/PeopleServiceTest.java | 192 +++++++++++++++++- .../server/people/data/DataManagerTest.java | 89 +++++++- 8 files changed, 748 insertions(+), 32 deletions(-) create mode 100644 core/java/android/app/people/IConversationListener.aidl create mode 100644 core/tests/coretests/src/android/app/people/PeopleManagerTest.java diff --git a/core/java/android/app/people/IConversationListener.aidl b/core/java/android/app/people/IConversationListener.aidl new file mode 100644 index 0000000000000..7cbd66dd6617a --- /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 d000f3b0b01da..496ca82bb61f6 100644 --- a/core/java/android/app/people/IPeopleManager.aidl +++ b/core/java/android/app/people/IPeopleManager.aidl @@ -18,6 +18,7 @@ package android.app.people; 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 @@ interface IPeopleManager { 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 d348edb6dfbf5..108437eb06491 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.ParceledListSlice; 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 @@ public final class PeopleManager { private static final String LOG_TAG = PeopleManager.class.getSimpleName(); - @NonNull - private final Context mContext; + /** + * @hide + */ + @VisibleForTesting + public Map> + mConversationListeners = new HashMap<>(); @NonNull - private final IPeopleManager mService; + private Context mContext; + + @NonNull + private IPeopleManager mService; /** * @hide @@ -55,6 +70,15 @@ public final class PeopleManager { Context.PEOPLE_SERVICE)); } + /** + * @hide + */ + @VisibleForTesting + public PeopleManager(@NonNull Context context, IPeopleManager service) { + mContext = context; + mService = service; + } + /** * Returns whether a shortcut has a conversation associated. * @@ -66,9 +90,8 @@ public final class PeopleManager { * 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 @@ public final class PeopleManager { * * @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 @@ public final class PeopleManager { * * @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 @@ public final class PeopleManager { try { final ParceledListSlice parceledList = mService.getStatuses( - mContext.getPackageName(), mContext.getUserId(), conversationId); + mContext.getPackageName(), mContext.getUserId(), conversationId); if (parceledList != null) { return parceledList.getList(); } @@ -164,4 +186,103 @@ public final class PeopleManager { } 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) + * + *

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 0000000000000..a2afc777a5525 --- /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> + listenersToProxy = + mPeopleManager.mConversationListeners; + Pair 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 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 966633701e6fd..e7d01219f6c1a 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.annotation.UserIdInt; 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.Context; 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.LocalServices; 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 @@ public class PeopleService extends SystemService { private static final String TAG = "PeopleService"; - private final DataManager mDataManager; + private DataManager mDataManager; + @VisibleForTesting + ConversationListenerHelper mConversationListenerHelper; private PackageManagerInternal mPackageManagerInternal; @@ -71,6 +77,8 @@ public class PeopleService extends SystemService { super(context); mDataManager = new DataManager(context); + mConversationListenerHelper = new ConversationListenerHelper(); + mDataManager.addConversationsListener(mConversationListenerHelper); } @Override @@ -148,12 +156,14 @@ public class PeopleService extends SystemService { * @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 @@ public class PeopleService extends SystemService { 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 conversations) { + } + } + + /** + * Implements {@code ConversationListenerHelper} to dispatch conversation updates to registered + * clients. + */ + public static class ConversationListenerHelper implements ConversationsListener { + + ConversationListenerHelper() { + } + + @VisibleForTesting + final RemoteCallbackList 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 conversations) { + int count = mListeners.beginBroadcast(); + // Early opt-out if no listeners are registered. + if (count == 0) { + return; + } + Map 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 1048dfdea8e5e..75614d60ab9e7 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.database.ContentObserver; 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.internal.telephony.SmsApplication; 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 @@ public class DataManager { private final SparseArray> mUsageStatsQueryFutures = new SparseArray<>(); private final SparseArray mNotificationListeners = new SparseArray<>(); private final SparseArray mPackageMonitors = new SparseArray<>(); + @GuardedBy("mLock") + private final List mConversationsListeners = + new ArrayList<>(1); + private final Handler mHandler; + private ContentObserver mCallLogContentObserver; private ContentObserver mMmsSmsContentObserver; @@ -128,14 +137,14 @@ public class DataManager { 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 @@ public class DataManager { 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 @@ public class DataManager { 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 @@ public class DataManager { 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 @@ public class DataManager { } } + /** 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 changedConversations) { + mHandler.post(() -> { + try { + final List 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 a112b145fdc99..ecff409ccaf1b 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 @@ public final class PeopleServiceTest { 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 @@ public final class PeopleServiceTest { 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 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 50d9f61652041..7709edbbbb31e 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.CancellationSignal; 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.app.ChooserActivity; 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 @@ public final class DataManagerTest { 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 @@ public final class DataManagerTest { private ShortcutChangeCallback mShortcutChangeCallback; private ShortcutInfo mShortcutInfo; private TestInjector mInjector; + private TestLooper mLooper; @Before public void setUp() throws PackageManager.NameNotFoundException { @@ -237,7 +243,8 @@ public final class DataManagerTest { 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(), @@ -514,6 +521,84 @@ public final class DataManagerTest { assertTrue(mDataManager.getRecentConversations(USER_ID_PRIMARY).isEmpty()); } + @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 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 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> 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> 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 @@ public final class DataManagerTest { 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)