Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 4b65ce05 authored by Anna Zappone's avatar Anna Zappone
Browse files

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
parent 08458888
Loading
Loading
Loading
Loading
+33 −0
Original line number Diff line number Diff line
/**
 * 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
+3 −0
Original line number Diff line number Diff line
@@ -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);
}
+130 −9
Original line number Diff line number Diff line
@@ -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();

    /**
     * @hide
     */
    @VisibleForTesting
    public Map<ConversationListener, Pair<Executor, IConversationListener>>
            mConversationListeners = new HashMap<>();

    @NonNull
    private final Context mContext;
    private Context mContext;

    @NonNull
    private final IPeopleManager mService;
    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.
     *
@@ -68,7 +92,6 @@ public final class PeopleManager {
     * @param packageName name of the package the conversation is part of
     * @param shortcutId  the shortcut id backing the conversation
     * @return whether the {@shortcutId} is backed by a Conversation.
     *
     * @hide
     */
    @SystemApi
@@ -95,7 +118,6 @@ 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
     *
     * @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);
@@ -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)
         *
         * <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));
        }
    }
}
+120 −0
Original line number Diff line number Diff line
/*
 * 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());
    }
}
+141 −2
Original line number Diff line number Diff line
@@ -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<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 {

Loading