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

Commit bf07abc3 authored by TreeHugger Robot's avatar TreeHugger Robot Committed by Android (Google) Code Review
Browse files

Merge "Add listener for conversation changes" into sc-dev

parents a20761e2 4b65ce05
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