Media: Introduce MediaRouter2

Instead of extending MediaRouter, this CL introduce MediaRouter2 class.
This will prevent regression from chaing MediaRouter and we can easily
modify and test `new' features.
For MediaRouter2, IMediaRouter2Client is also added to differentiate the
previous router and the new one in MediaRouterService.

This CL also contains MediaRouter2.sendControlRequest which can be used
to manipulate media routes.
(It is temporarily being used to test MediaRouter2Manager callbacks.)

Bug: 132138073
Test: atest mediaroutertest (w/ mediarouteprovider installed)

Change-Id: I895fe456e38d437cec8e3ca9501cd7f105c5f4d6
diff --git a/Android.bp b/Android.bp
index c22bdd3..3409c7d3 100644
--- a/Android.bp
+++ b/Android.bp
@@ -483,8 +483,9 @@
         "media/java/android/media/IMediaResourceMonitor.aidl",
         "media/java/android/media/IMediaRoute2Provider.aidl",
         "media/java/android/media/IMediaRoute2ProviderClient.aidl",
-        "media/java/android/media/IMediaRouterClient.aidl",
+        "media/java/android/media/IMediaRouter2Client.aidl",
         "media/java/android/media/IMediaRouter2Manager.aidl",
+        "media/java/android/media/IMediaRouterClient.aidl",
         "media/java/android/media/IMediaRouterService.aidl",
         "media/java/android/media/IMediaScannerListener.aidl",
         "media/java/android/media/IMediaScannerService.aidl",
diff --git a/media/java/android/media/IMediaRoute2Provider.aidl b/media/java/android/media/IMediaRoute2Provider.aidl
index b6fbf26..4bd5710 100644
--- a/media/java/android/media/IMediaRoute2Provider.aidl
+++ b/media/java/android/media/IMediaRoute2Provider.aidl
@@ -16,6 +16,7 @@
 
 package android.media;
 
+import android.content.Intent;
 import android.media.IMediaRoute2ProviderClient;
 
 /**
@@ -23,5 +24,6 @@
  */
 oneway interface IMediaRoute2Provider {
     void registerClient(IMediaRoute2ProviderClient client);
-    void selectRoute(int uid, String id);
+    void selectRoute(IMediaRoute2ProviderClient client, int uid, String id);
+    void notifyControlRequestSent(IMediaRoute2ProviderClient client, String id, in Intent request);
 }
diff --git a/media/java/android/media/IMediaRouter2Client.aidl b/media/java/android/media/IMediaRouter2Client.aidl
new file mode 100644
index 0000000..774d6a7
--- /dev/null
+++ b/media/java/android/media/IMediaRouter2Client.aidl
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2019 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.media;
+
+/**
+ * @hide
+ */
+oneway interface IMediaRouter2Client {
+    void notifyStateChanged();
+    void notifyRestoreRoute();
+}
diff --git a/media/java/android/media/IMediaRouterService.aidl b/media/java/android/media/IMediaRouterService.aidl
index 9e7bceb..6d5b29a 100644
--- a/media/java/android/media/IMediaRouterService.aidl
+++ b/media/java/android/media/IMediaRouterService.aidl
@@ -16,26 +16,35 @@
 
 package android.media;
 
-import android.media.IMediaRouterClient;
+import android.content.Intent;
+import android.media.IMediaRouter2Client;
 import android.media.IMediaRouter2Manager;
+import android.media.IMediaRouterClient;
+import android.media.MediaRoute2Info;
 import android.media.MediaRouterClientState;
 
 /**
  * {@hide}
  */
 interface IMediaRouterService {
+    //TODO: Merge or remove methods when media router 2 is done.
     void registerClientAsUser(IMediaRouterClient client, String packageName, int userId);
     void unregisterClient(IMediaRouterClient client);
 
     MediaRouterClientState getState(IMediaRouterClient client);
     boolean isPlaybackActive(IMediaRouterClient client);
 
-    void setControlCategories(IMediaRouterClient client, in List<String> categories);
     void setDiscoveryRequest(IMediaRouterClient client, int routeTypes, boolean activeScan);
     void setSelectedRoute(IMediaRouterClient client, String routeId, boolean explicit);
     void requestSetVolume(IMediaRouterClient client, String routeId, int volume);
     void requestUpdateVolume(IMediaRouterClient client, String routeId, int direction);
 
+    // Methods for media router 2
+    void registerClient2AsUser(IMediaRouter2Client client, String packageName, int userId);
+    void unregisterClient2(IMediaRouter2Client client);
+    void sendControlRequest(IMediaRouter2Client client, in MediaRoute2Info route, in Intent request);
+    void setControlCategories(IMediaRouter2Client client, in List<String> categories);
+
     void registerManagerAsUser(IMediaRouter2Manager manager,
             String packageName, int userId);
     void unregisterManager(IMediaRouter2Manager manager);
diff --git a/media/java/android/media/MediaRoute2Info.aidl b/media/java/android/media/MediaRoute2Info.aidl
new file mode 100644
index 0000000..4811c4d
--- /dev/null
+++ b/media/java/android/media/MediaRoute2Info.aidl
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2019 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.media;
+
+parcelable MediaRoute2Info;
diff --git a/media/java/android/media/MediaRoute2ProviderInfo.java b/media/java/android/media/MediaRoute2ProviderInfo.java
index 3ed5abc..57d82d4 100644
--- a/media/java/android/media/MediaRoute2ProviderInfo.java
+++ b/media/java/android/media/MediaRoute2ProviderInfo.java
@@ -137,9 +137,8 @@
         }
 
         public Builder(@NonNull MediaRoute2ProviderInfo descriptor) {
-            if (descriptor == null) {
-                throw new IllegalArgumentException("descriptor must not be null");
-            }
+            Objects.requireNonNull(descriptor, "descriptor must not be null");
+
             mRoutes = new ArrayMap<>(descriptor.mRoutes);
         }
 
diff --git a/media/java/android/media/MediaRoute2ProviderService.java b/media/java/android/media/MediaRoute2ProviderService.java
index fcd90b2..30e0ef1 100644
--- a/media/java/android/media/MediaRoute2ProviderService.java
+++ b/media/java/android/media/MediaRoute2ProviderService.java
@@ -64,7 +64,16 @@
     public abstract void onSelect(int uid, String routeId);
 
     /**
-     * Updates provider info from selected route and appliation.
+     * Called when sendControlRequest is called on a route of the provider.
+     *
+     * @param routeId The id of the target route
+     * @param request The media control request intent
+     */
+    //TODO: Discuss what to use for request (e.g., Intent? Request class?)
+    public abstract void onControlRequest(String routeId, Intent request);
+
+    /**
+     * Updates provider info from selected route and application.
      *
      * TODO: When provider descriptor is defined, this should update the descriptor correctly.
      *
@@ -117,9 +126,16 @@
         }
 
         @Override
-        public void selectRoute(int uid, String id) {
+        public void selectRoute(IMediaRoute2ProviderClient client, int uid, String id) {
             mHandler.sendMessage(obtainMessage(MediaRoute2ProviderService::onSelect,
                     MediaRoute2ProviderService.this, uid, id));
         }
+
+        @Override
+        public void notifyControlRequestSent(IMediaRoute2ProviderClient client, String id,
+                Intent request) {
+            mHandler.sendMessage(obtainMessage(MediaRoute2ProviderService::onControlRequest,
+                    MediaRoute2ProviderService.this, id, request));
+        }
     }
 }
diff --git a/media/java/android/media/MediaRouter.java b/media/java/android/media/MediaRouter.java
index 5a89d8c..3444e92 100644
--- a/media/java/android/media/MediaRouter.java
+++ b/media/java/android/media/MediaRouter.java
@@ -347,17 +347,6 @@
             return mDisplayService.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
         }
 
-        void setControlCategories(List<String> categories) {
-            if (mClient != null) {
-                try {
-                    mMediaRouterService.setControlCategories(mClient,
-                            categories);
-                } catch (RemoteException ex) {
-                    Log.e(TAG, "Unable to set control categories.", ex);
-                }
-            }
-        }
-
         private void updatePresentationDisplays(int changedDisplayId) {
             final int count = mRoutes.size();
             for (int i = 0; i < count; i++) {
@@ -930,25 +919,6 @@
         return -1;
     }
 
-    //TODO: Remove @hide when it is ready.
-    //TODO: Provide pre-defined categories for app developers.
-    /**
-     * Sets control categories of the client application.
-     * Control categories can be used to filter out media routes
-     * that don't correspond with the client application.
-     * The only routes that match any of the categories will be shown on other applications.
-     *
-     * @hide
-     * @param categories Categories to set
-     */
-    public void setControlCategories(@NonNull List<String> categories) {
-        if (categories == null) {
-            throw new IllegalArgumentException("Categories must not be null");
-        }
-        sStatic.setControlCategories(categories);
-    }
-
-
     /**
      * Select the specified route to use for output of the given media types.
      * <p class="note">
diff --git a/media/java/android/media/MediaRouter2.java b/media/java/android/media/MediaRouter2.java
new file mode 100644
index 0000000..d37a832
--- /dev/null
+++ b/media/java/android/media/MediaRouter2.java
@@ -0,0 +1,237 @@
+/*
+ * Copyright 2019 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.media;
+
+import android.annotation.MainThread;
+import android.annotation.NonNull;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.os.ServiceManager;
+import android.os.UserHandle;
+import android.util.Log;
+
+import com.android.internal.annotations.GuardedBy;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.Executor;
+
+
+/**
+ * A new Media Router
+ * @hide
+ */
+public class MediaRouter2 {
+    private static final String TAG = "MediaRouter";
+    private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
+
+    private static final Object sLock = new Object();
+
+    @GuardedBy("sLock")
+    private static MediaRouter2 sInstance;
+
+    private Context mContext;
+    private final IMediaRouterService mMediaRouterService;
+    private List<CallbackRecord> mCallbackRecords = new ArrayList<>();
+    final String mPackageName;
+
+    IMediaRouter2Client mClient;
+
+    /**
+     * Gets an instance of the media router associated with the context.
+     */
+    public static MediaRouter2 getInstance(@NonNull Context context) {
+        synchronized (sLock) {
+            if (sInstance == null) {
+                sInstance = new MediaRouter2(context);
+            }
+            return sInstance;
+        }
+    }
+
+    private MediaRouter2(Context context) {
+        mContext = Objects.requireNonNull(context, "context must not be null");
+        mMediaRouterService = IMediaRouterService.Stub.asInterface(
+                ServiceManager.getService(Context.MEDIA_ROUTER_SERVICE));
+        mPackageName = mContext.getPackageName();
+    }
+
+    /**
+     * Registers a callback to discover routes that match the selector and to receive events
+     * when they change.
+     */
+    @MainThread
+    public void addCallback(@NonNull List<String> controlCategories, @NonNull Executor executor,
+            @NonNull Callback callback) {
+        addCallback(controlCategories, executor, callback, 0);
+    }
+
+    /**
+     * Registers a callback to discover routes that match the selector and to receive events
+     * when they change.
+     * <p>
+     * If you add the same callback twice or more, the previous arguments will be overwritten
+     * with the new arguments.
+     * </p>
+     */
+    @MainThread
+    public void addCallback(@NonNull List<String> controlCategories, @NonNull Executor executor,
+            @NonNull Callback callback, int flags) {
+        checkMainThread();
+
+        Objects.requireNonNull(controlCategories, "control categories must not be null");
+        Objects.requireNonNull(executor, "executor must not be null");
+        Objects.requireNonNull(callback, "callback must not be null");
+
+        if (mCallbackRecords.size() == 0) {
+            Client client = new Client();
+            try {
+                mMediaRouterService.registerClient2AsUser(client, mPackageName,
+                        UserHandle.myUserId());
+                //TODO: We should merge control categories of callbacks.
+                mMediaRouterService.setControlCategories(client, controlCategories);
+                mClient = client;
+            } catch (RemoteException ex) {
+                Log.e(TAG, "Unable to register media router.", ex);
+            }
+        }
+
+        final int index = findCallbackRecordIndex(callback);
+        CallbackRecord record;
+        if (index < 0) {
+            record = new CallbackRecord(callback);
+            mCallbackRecords.add(record);
+        } else {
+            record = mCallbackRecords.get(index);
+        }
+        record.mExecutor = executor;
+        record.mControlCategories = controlCategories;
+        record.mFlags = flags;
+
+        //TODO: Check if we need an update.
+    }
+
+    /**
+     * Removes the given callback. The callback will no longer receive events.
+     * If the callback has not been added or been removed already, it is ignored.
+     * @param callback the callback to remove.
+     * @see #addCallback
+     */
+    @MainThread
+    public void removeCallback(@NonNull Callback callback) {
+        checkMainThread();
+
+        Objects.requireNonNull(callback, "callback must not be null");
+
+        final int index = findCallbackRecordIndex(callback);
+        if (index < 0) {
+            Log.w(TAG, "Ignoring to remove unknown callback. " + callback);
+            return;
+        }
+        mCallbackRecords.remove(index);
+        if (mCallbackRecords.size() == 0 && mClient != null) {
+            try {
+                mMediaRouterService.unregisterClient2(mClient);
+            } catch (RemoteException ex) {
+                Log.e(TAG, "Unable to unregister media router.", ex);
+            }
+            mClient = null;
+        }
+    }
+
+    private int findCallbackRecordIndex(Callback callback) {
+        final int count = mCallbackRecords.size();
+        for (int i = 0; i < count; i++) {
+            CallbackRecord callbackRecord = mCallbackRecords.get(i);
+            if (callbackRecord.mCallback == callback) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    /**
+     * Sends a media control request to be performed asynchronously by the route's destination.
+     * @param route the route that will receive the control request
+     * @param request the media control request
+     */
+    //TODO: Discuss what to use for request (e.g., Intent? Request class?)
+    //TODO: Provide a way to obtain the result
+    public void sendControlRequest(@NonNull MediaRoute2Info route, @NonNull Intent request) {
+        Objects.requireNonNull(route, "route must not be null");
+        Objects.requireNonNull(request, "request must not be null");
+
+        if (mClient != null) {
+            try {
+                mMediaRouterService.sendControlRequest(mClient, route, request);
+            } catch (RemoteException ex) {
+                Log.e(TAG, "Unable to send control request.", ex);
+            }
+        }
+    }
+
+    void checkMainThread() {
+        Looper looper = Looper.myLooper();
+        if (looper == null || looper != Looper.getMainLooper()) {
+            throw new IllegalStateException("the method must be called on the main thread");
+        }
+    }
+
+    /**
+     * Interface for receiving events about media routing changes.
+     */
+    public static class Callback {
+        /**
+         * Called when a route is added.
+         */
+        public void onRouteAdded(MediaRoute2Info routeInfo) {}
+
+        /**
+         * Called when a route is changed.
+         */
+        public void onRouteChanged(MediaRoute2Info routeInfo) {}
+
+        /**
+         * Called when a route is removed.
+         */
+        public void onRouteRemoved(MediaRoute2Info routeInfo) {}
+    }
+
+    final class CallbackRecord {
+        public final Callback mCallback;
+        public Executor mExecutor;
+        public List<String> mControlCategories;
+        public int mFlags;
+
+        CallbackRecord(@NonNull Callback callback) {
+            mCallback = Objects.requireNonNull(callback, "callback must not be null");
+            mControlCategories = Collections.emptyList();
+        }
+    }
+
+    class Client extends IMediaRouter2Client.Stub {
+        @Override
+        public void notifyStateChanged() throws RemoteException {}
+
+        @Override
+        public void notifyRestoreRoute() throws RemoteException {}
+    }
+}
diff --git a/media/java/android/media/MediaRouter2Manager.java b/media/java/android/media/MediaRouter2Manager.java
index 0e16af3..5fcb684 100644
--- a/media/java/android/media/MediaRouter2Manager.java
+++ b/media/java/android/media/MediaRouter2Manager.java
@@ -35,6 +35,7 @@
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.Executor;
 
@@ -62,14 +63,11 @@
     private List<MediaRoute2ProviderInfo> mProviders = Collections.emptyList();
 
     /**
-     * Gets an instance of media router manager that controls media route of other apps.
-     * @param context
-     * @return
+     * Gets an instance of media router manager that controls media route of other applications.
+     * @return The media router manager instance for the context.
      */
     public static MediaRouter2Manager getInstance(@NonNull Context context) {
-        if (context == null) {
-            throw new IllegalArgumentException("context must not be null");
-        }
+        Objects.requireNonNull(context, "context must not be null");
         synchronized (sLock) {
             if (sInstance == null) {
                 sInstance = new MediaRouter2Manager(context);
diff --git a/media/tests/MediaRouteProvider/src/com/android/mediarouteprovider/example/SampleMediaRoute2ProviderService.java b/media/tests/MediaRouteProvider/src/com/android/mediarouteprovider/example/SampleMediaRoute2ProviderService.java
index c3c0c11..21cb93d 100644
--- a/media/tests/MediaRouteProvider/src/com/android/mediarouteprovider/example/SampleMediaRoute2ProviderService.java
+++ b/media/tests/MediaRouteProvider/src/com/android/mediarouteprovider/example/SampleMediaRoute2ProviderService.java
@@ -28,16 +28,23 @@
 public class SampleMediaRoute2ProviderService extends MediaRoute2ProviderService {
     private static final String TAG = "SampleMediaRoute2Serv";
 
-    public static final String ROUTE_ID1 = "route_id";
-    public static final String ROUTE_NAME1 = "route_name";
+    public static final String ROUTE_ID1 = "route_id1";
+    public static final String ROUTE_NAME1 = "Sample Route 1";
+    public static final String ROUTE_ID2 = "route_id2";
+    public static final String ROUTE_NAME2 = "Sample Route 2";
+    public static final String ACTION_REMOVE_ROUTE =
+            "com.android.mediarouteprovider.action_remove_route";
 
     Map<String, MediaRoute2Info> mRoutes = new HashMap<>();
 
     private void initializeRoutes() {
         MediaRoute2Info route1 = new MediaRoute2Info.Builder(ROUTE_ID1, ROUTE_NAME1)
                 .build();
+        MediaRoute2Info route2 = new MediaRoute2Info.Builder(ROUTE_ID2, ROUTE_NAME2)
+                .build();
 
         mRoutes.put(route1.getId(), route1);
+        mRoutes.put(route2.getId(), route2);
     }
 
     @Override
@@ -57,6 +64,18 @@
         publishRoutes();
     }
 
+    @Override
+    public void onControlRequest(String routeId, Intent request) {
+        if (ACTION_REMOVE_ROUTE.equals(request.getAction())) {
+            MediaRoute2Info route = mRoutes.get(routeId);
+            if (route != null) {
+                mRoutes.remove(routeId);
+                publishRoutes();
+                mRoutes.put(routeId, route);
+            }
+        }
+    }
+
     void publishRoutes() {
         MediaRoute2ProviderInfo info = new MediaRoute2ProviderInfo.Builder()
                 .addRoutes(mRoutes.values())
diff --git a/media/tests/MediaRouter/src/com/android/mediaroutertest/MediaRouterManagerTest.java b/media/tests/MediaRouter/src/com/android/mediaroutertest/MediaRouterManagerTest.java
index 65d9d06..41a76bf 100644
--- a/media/tests/MediaRouter/src/com/android/mediaroutertest/MediaRouterManagerTest.java
+++ b/media/tests/MediaRouter/src/com/android/mediaroutertest/MediaRouterManagerTest.java
@@ -23,8 +23,9 @@
 import static org.mockito.Mockito.verify;
 
 import android.content.Context;
+import android.content.Intent;
 import android.media.MediaRoute2Info;
-import android.media.MediaRouter;
+import android.media.MediaRouter2;
 import android.media.MediaRouter2Manager;
 import android.support.test.InstrumentationRegistry;
 import android.support.test.filters.SmallTest;
@@ -49,15 +50,19 @@
     private static final int TARGET_UID = 109992;
 
     // Must be the same as SampleMediaRoute2ProviderService
-    public static final String ROUTE_ID1 = "route_id";
-    public static final String ROUTE_NAME1 = "route_name";
+    public static final String ROUTE_ID1 = "route_id1";
+    public static final String ROUTE_NAME1 = "Sample Route 1";
+    public static final String ROUTE_ID2 = "route_id2";
+    public static final String ROUTE_NAME2 = "Sample Route 2";
+    public static final String ACTION_REMOVE_ROUTE =
+            "com.android.mediarouteprovider.action_remove_route";
 
     private static final int AWAIT_MS = 1000;
     private static final int TIMEOUT_MS = 5000;
 
     private Context mContext;
     private MediaRouter2Manager mManager;
-    private MediaRouter mRouter;
+    private MediaRouter2 mRouter;
     private Executor mExecutor;
 
     private static final List<String> TEST_CONTROL_CATEGORIES = new ArrayList();
@@ -72,15 +77,15 @@
     public void setUp() throws Exception {
         mContext = InstrumentationRegistry.getTargetContext();
         mManager = MediaRouter2Manager.getInstance(mContext);
-        mRouter = (MediaRouter) mContext.getSystemService(Context.MEDIA_ROUTER_SERVICE);
+        mRouter = MediaRouter2.getInstance(mContext);
         mExecutor = new ThreadPoolExecutor(
             1, 20, 3, TimeUnit.SECONDS,
             new SynchronousQueue<Runnable>());
     }
 
-    //TODO: onRouteChanged, onRouteRemoved must be tested
+    //TODO: Test onRouteChanged when it's properly implemented.
     @Test
-    public void testRouteAddedOnce() {
+    public void testRouteAdded() {
         MediaRouter2Manager.Callback mockCallback = mock(MediaRouter2Manager.Callback.class);
 
         mManager.addCallback(mExecutor, mockCallback);
@@ -92,18 +97,49 @@
     }
 
     @Test
+    public void testRouteRemoved() {
+        MediaRouter2Manager.Callback mockCallback = mock(MediaRouter2Manager.Callback.class);
+        mManager.addCallback(mExecutor, mockCallback);
+
+        MediaRouter2.Callback mockRouterCallback = mock(MediaRouter2.Callback.class);
+
+        //TODO: Figure out a more proper way to test.
+        // (Control requests shouldn't be used in this way.)
+        InstrumentationRegistry.getInstrumentation().runOnMainSync(
+                (Runnable) () -> {
+                    mRouter.addCallback(TEST_CONTROL_CATEGORIES, mExecutor, mockRouterCallback);
+                    mRouter.sendControlRequest(
+                            new MediaRoute2Info.Builder(ROUTE_ID2, ROUTE_NAME2).build(),
+                            new Intent(ACTION_REMOVE_ROUTE));
+                    mRouter.removeCallback(mockRouterCallback);
+                }
+        );
+        verify(mockCallback, timeout(TIMEOUT_MS)).onRouteRemoved(argThat(
+                (MediaRoute2Info info) ->
+                        info.getId().equals(ROUTE_ID2) && info.getName().equals(ROUTE_NAME2)));
+        mManager.removeCallback(mockCallback);
+    }
+
+    @Test
     public void controlCategoryTest() throws Exception {
         final int uid = android.os.Process.myUid();
 
         MediaRouter2Manager.Callback mockCallback = mock(MediaRouter2Manager.Callback.class);
         mManager.addCallback(mExecutor, mockCallback);
 
+        MediaRouter2.Callback mockRouterCallback = mock(MediaRouter2.Callback.class);
+
         verify(mockCallback, after(AWAIT_MS).never()).onControlCategoriesChanged(uid,
                 TEST_CONTROL_CATEGORIES);
 
-        mRouter.setControlCategories(TEST_CONTROL_CATEGORIES);
+        InstrumentationRegistry.getInstrumentation().runOnMainSync(
+                (Runnable) () -> {
+                    mRouter.addCallback(TEST_CONTROL_CATEGORIES, mExecutor, mockRouterCallback);
+                    mRouter.removeCallback(mockRouterCallback);
+                }
+        );
         verify(mockCallback, timeout(TIMEOUT_MS).atLeastOnce())
-            .onControlCategoriesChanged(uid, TEST_CONTROL_CATEGORIES);
+                .onControlCategoriesChanged(uid, TEST_CONTROL_CATEGORIES);
 
         mManager.removeCallback(mockCallback);
     }
diff --git a/services/core/java/com/android/server/media/MediaRoute2ProviderProxy.java b/services/core/java/com/android/server/media/MediaRoute2ProviderProxy.java
index 51c16c8..2fd2d74 100644
--- a/services/core/java/com/android/server/media/MediaRoute2ProviderProxy.java
+++ b/services/core/java/com/android/server/media/MediaRoute2ProviderProxy.java
@@ -23,6 +23,7 @@
 import android.content.ServiceConnection;
 import android.media.IMediaRoute2Provider;
 import android.media.IMediaRoute2ProviderClient;
+import android.media.MediaRoute2Info;
 import android.media.MediaRoute2ProviderInfo;
 import android.media.MediaRoute2ProviderService;
 import android.os.Handler;
@@ -90,6 +91,13 @@
         }
     }
 
+    public void sendControlRequest(MediaRoute2Info route, Intent request) {
+        if (mConnectionReady) {
+            mActiveConnection.sendControlRequest(route.getId(), request);
+            updateBinding();
+        }
+    }
+
     public MediaRoute2ProviderInfo getProviderInfo() {
         return mProviderInfo;
     }
@@ -303,13 +311,27 @@
         }
 
         public void selectRoute(int uid, String id) {
+            if (mClient == null) {
+                return;
+            }
             try {
-                mProvider.selectRoute(uid, id);
+                mProvider.selectRoute(mClient, uid, id);
             } catch (RemoteException ex) {
                 Slog.e(TAG, "Failed to deliver request to set discovery mode.", ex);
             }
         }
 
+        public void sendControlRequest(String id, Intent request) {
+            if (mClient == null) {
+                return;
+            }
+            try {
+                mProvider.notifyControlRequestSent(mClient, id, request);
+            } catch (RemoteException ex) {
+                Slog.e(TAG, "Failed to deliver request to send control request.", ex);
+            }
+        }
+
         @Override
         public void binderDied() {
             mHandler.post(() -> onConnectionDied(Connection.this));
diff --git a/services/core/java/com/android/server/media/MediaRouter2ServiceImpl.java b/services/core/java/com/android/server/media/MediaRouter2ServiceImpl.java
index 2ecc082..98fbf75 100644
--- a/services/core/java/com/android/server/media/MediaRouter2ServiceImpl.java
+++ b/services/core/java/com/android/server/media/MediaRouter2ServiceImpl.java
@@ -20,9 +20,11 @@
 import android.annotation.Nullable;
 import android.app.ActivityManager;
 import android.content.Context;
+import android.content.Intent;
 import android.content.pm.PackageManager;
+import android.media.IMediaRouter2Client;
 import android.media.IMediaRouter2Manager;
-import android.media.IMediaRouterClient;
+import android.media.MediaRoute2Info;
 import android.media.MediaRoute2ProviderInfo;
 import android.os.Binder;
 import android.os.Handler;
@@ -68,7 +70,7 @@
         mContext = context;
     }
 
-    public void registerClientAsUser(@NonNull IMediaRouterClient client,
+    public void registerClientAsUser(@NonNull IMediaRouter2Client client,
             @NonNull String packageName, int userId) {
         Objects.requireNonNull(client, "client must not be null");
 
@@ -89,7 +91,7 @@
         }
     }
 
-    public void unregisterClient(@NonNull IMediaRouterClient client) {
+    public void unregisterClient(@NonNull IMediaRouter2Client client) {
         Objects.requireNonNull(client, "client must not be null");
 
         final long token = Binder.clearCallingIdentity();
@@ -135,7 +137,23 @@
         }
     }
 
-    public void setControlCategories(@NonNull IMediaRouterClient client,
+    public void sendControlRequest(@NonNull IMediaRouter2Client client,
+            @NonNull MediaRoute2Info route, @NonNull Intent request) {
+        Objects.requireNonNull(client, "client must not be null");
+        Objects.requireNonNull(route, "route must not be null");
+        Objects.requireNonNull(request, "request must not be null");
+
+        final long token = Binder.clearCallingIdentity();
+        try {
+            synchronized (mLock) {
+                sendControlRequestLocked(client, route, request);
+            }
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+    }
+
+    public void setControlCategories(@NonNull IMediaRouter2Client client,
             @Nullable List<String> categories) {
         Objects.requireNonNull(client, "client must not be null");
 
@@ -195,7 +213,7 @@
         }
     }
 
-    private void registerClientLocked(IMediaRouterClient client,
+    private void registerClientLocked(IMediaRouter2Client client,
             int uid, int pid, String packageName, int userId, boolean trusted) {
         final IBinder binder = client.asBinder();
         ClientRecord clientRecord = mAllClientRecords.get(binder);
@@ -223,7 +241,7 @@
         }
     }
 
-    private void unregisterClientLocked(IMediaRouterClient client, boolean died) {
+    private void unregisterClientLocked(IMediaRouter2Client client, boolean died) {
         ClientRecord clientRecord = mAllClientRecords.remove(client.asBinder());
         if (clientRecord != null) {
             UserRecord userRecord = clientRecord.mUserRecord;
@@ -261,11 +279,11 @@
             mAllManagerRecords.put(binder, managerRecord);
 
             //TODO: remove this when it's unnecessary
-            // Sends published routes to newly added manager
+            // Sends published routes to newly added manager.
             userRecord.mHandler.scheduleUpdateManagerState();
 
             final int count = userRecord.mClientRecords.size();
-            for (int i = 0; i < count; ++i) {
+            for (int i = 0; i < count; i++) {
                 ClientRecord clientRecord = userRecord.mClientRecords.get(i);
                 clientRecord.mUserRecord.mHandler.obtainMessage(
                         UserHandler.MSG_UPDATE_CLIENT_USAGE, clientRecord).sendToTarget();
@@ -295,7 +313,7 @@
         }
     }
 
-    private void setControlCategoriesLocked(IMediaRouterClient client, List<String> categories) {
+    private void setControlCategoriesLocked(IMediaRouter2Client client, List<String> categories) {
         final IBinder binder = client.asBinder();
         ClientRecord clientRecord = mAllClientRecords.get(binder);
 
@@ -306,6 +324,18 @@
         }
     }
 
+    private void sendControlRequestLocked(IMediaRouter2Client client, MediaRoute2Info route,
+            Intent request) {
+        final IBinder binder = client.asBinder();
+        ClientRecord clientRecord = mAllClientRecords.get(binder);
+
+        if (clientRecord != null) {
+            Pair<MediaRoute2Info, Intent> obj = new Pair<>(route, request);
+            clientRecord.mUserRecord.mHandler.obtainMessage(
+                    UserHandler.MSG_SEND_CONTROL_REQUEST, obj).sendToTarget();
+        }
+    }
+
     private void initializeUserLocked(UserRecord userRecord) {
         if (DEBUG) {
             Slog.d(TAG, userRecord + ": Initialized");
@@ -345,14 +375,14 @@
 
     final class ClientRecord implements IBinder.DeathRecipient {
         public final UserRecord mUserRecord;
-        public final IMediaRouterClient mClient;
+        public final IMediaRouter2Client mClient;
         public final int mUid;
         public final int mPid;
         public final String mPackageName;
         public final boolean mTrusted;
         public List<String> mControlCategories;
 
-        ClientRecord(UserRecord userRecord, IMediaRouterClient client,
+        ClientRecord(UserRecord userRecord, IMediaRouter2Client client,
                 int uid, int pid, String packageName, boolean trusted) {
             mUserRecord = userRecord;
             mClient = client;
@@ -424,6 +454,7 @@
         private static final int MSG_SELECT_REMOTE_ROUTE = 10;
         private static final int MSG_UPDATE_CLIENT_USAGE = 11;
         private static final int MSG_UPDATE_MANAGER_STATE = 12;
+        private static final int MSG_SEND_CONTROL_REQUEST = 13;
 
         private final WeakReference<MediaRouter2ServiceImpl> mServiceRef;
         private final UserRecord mUserRecord;
@@ -468,6 +499,10 @@
                     updateManagerState();
                     break;
                 }
+                case MSG_SEND_CONTROL_REQUEST: {
+                    Pair<MediaRoute2Info, Intent> obj = (Pair<MediaRoute2Info, Intent>) msg.obj;
+                    sendControlRequest(obj.first, obj.second);
+                }
             }
         }
 
@@ -512,12 +547,19 @@
                 final int providerCount = mMediaProviders.size();
 
                 //TODO: should find proper provider (currently assumes a single provider)
-                for (int i = 0; i < providerCount; ++i) {
+                for (int i = 0; i < providerCount; i++) {
                     mMediaProviders.get(i).setSelectedRoute(uid, routeId);
                 }
             }
         }
 
+        private void sendControlRequest(MediaRoute2Info route, Intent request) {
+            final int providerCount = mMediaProviders.size();
+            for (int i = 0; i < providerCount; i++) {
+                mMediaProviders.get(i).sendControlRequest(route, request);
+            }
+        }
+
         private void scheduleUpdateManagerState() {
             if (!mManagerStateUpdateScheduled) {
                 mManagerStateUpdateScheduled = true;
diff --git a/services/core/java/com/android/server/media/MediaRouterService.java b/services/core/java/com/android/server/media/MediaRouterService.java
index fade906..aca6bcc 100644
--- a/services/core/java/com/android/server/media/MediaRouterService.java
+++ b/services/core/java/com/android/server/media/MediaRouterService.java
@@ -30,9 +30,11 @@
 import android.media.AudioSystem;
 import android.media.IAudioRoutesObserver;
 import android.media.IAudioService;
+import android.media.IMediaRouter2Client;
 import android.media.IMediaRouter2Manager;
 import android.media.IMediaRouterClient;
 import android.media.IMediaRouterService;
+import android.media.MediaRoute2Info;
 import android.media.MediaRouter;
 import android.media.MediaRouterClientState;
 import android.media.RemoteDisplayState;
@@ -250,7 +252,6 @@
         } finally {
             Binder.restoreCallingIdentity(token);
         }
-        mService2.registerClientAsUser(client, packageName, userId);
     }
 
     // Binder call
@@ -268,7 +269,6 @@
         } finally {
             Binder.restoreCallingIdentity(token);
         }
-        mService2.unregisterClient(client);
     }
 
     // Binder call
@@ -312,12 +312,6 @@
 
     // Binder call
     @Override
-    public void setControlCategories(IMediaRouterClient client, List<String> categories) {
-        mService2.setControlCategories(client, categories);
-    }
-
-    // Binder call
-    @Override
     public void setDiscoveryRequest(IMediaRouterClient client,
             int routeTypes, boolean activeScan) {
         if (client == null) {
@@ -418,6 +412,29 @@
 
     // Binder call
     @Override
+    public void registerClient2AsUser(IMediaRouter2Client client, String packageName, int userId) {
+        final int uid = Binder.getCallingUid();
+        if (!validatePackageName(uid, packageName)) {
+            throw new SecurityException("packageName must match the calling uid");
+        }
+        mService2.registerClientAsUser(client, packageName, userId);
+    }
+
+    // Binder call
+    @Override
+    public void unregisterClient2(IMediaRouter2Client client) {
+        mService2.unregisterClient(client);
+    }
+
+    // Binder call
+    @Override
+    public void sendControlRequest(IMediaRouter2Client client, MediaRoute2Info route,
+            Intent request) {
+        mService2.sendControlRequest(client, route, request);
+    }
+
+    // Binder call
+    @Override
     public void registerManagerAsUser(IMediaRouter2Manager manager,
             String packageName, int userId) {
         final int uid = Binder.getCallingUid();
@@ -440,6 +457,12 @@
         mService2.setRemoteRoute(manager, uid, routeId, explicit);
     }
 
+    // Binder call
+    @Override
+    public void setControlCategories(IMediaRouter2Client client, List<String> categories) {
+        mService2.setControlCategories(client, categories);
+    }
+
     void restoreBluetoothA2dp() {
         try {
             boolean a2dpOn;