MediaRouter: add routing controller in MRM

This CL introduces RoutingController into MediaRouter2Manager
to enable it to control remote routing sessions.

Tests are also revised according to MRM changes.

Bug: 147787194
Test: atest mediaroutertest

Change-Id: Id4ab61fcb690fccac42df049e1fb74d89bcf3d6a
diff --git a/media/java/android/media/IMediaRouter2Manager.aidl b/media/java/android/media/IMediaRouter2Manager.aidl
index e9add17..ffad659 100644
--- a/media/java/android/media/IMediaRouter2Manager.aidl
+++ b/media/java/android/media/IMediaRouter2Manager.aidl
@@ -18,12 +18,14 @@
 
 import android.media.MediaRoute2ProviderInfo;
 import android.media.MediaRoute2Info;
+import android.media.RoutingSessionInfo;
 
 /**
  * {@hide}
  */
 oneway interface IMediaRouter2Manager {
-    void notifyRouteSelected(String packageName, in MediaRoute2Info route);
+    void notifySessionCreated(in RoutingSessionInfo sessionInfo);
+    void notifySessionsUpdated();
     void notifyPreferredFeaturesChanged(String packageName, in List<String> preferredFeatures);
     void notifyRoutesAdded(in List<MediaRoute2Info> routes);
     void notifyRoutesRemoved(in List<MediaRoute2Info> routes);
diff --git a/media/java/android/media/IMediaRouterService.aidl b/media/java/android/media/IMediaRouterService.aidl
index 2d3e185..b435f25 100644
--- a/media/java/android/media/IMediaRouterService.aidl
+++ b/media/java/android/media/IMediaRouterService.aidl
@@ -71,4 +71,12 @@
             in MediaRoute2Info route, int direction);
 
     List<RoutingSessionInfo> getActiveSessions(IMediaRouter2Manager manager);
+    void selectClientRoute(IMediaRouter2Manager manager,
+            String sessionId, in MediaRoute2Info route);
+    void deselectClientRoute(IMediaRouter2Manager manager,
+            String sessionId, in MediaRoute2Info route);
+    void transferToClientRoute(IMediaRouter2Manager manager,
+            String sessionId, in MediaRoute2Info route);
+    void releaseClientSession(IMediaRouter2Manager manager, String sessionId);
+
 }
diff --git a/media/java/android/media/MediaRoute2ProviderService.java b/media/java/android/media/MediaRoute2ProviderService.java
index 5a3de6d..1443538 100644
--- a/media/java/android/media/MediaRoute2ProviderService.java
+++ b/media/java/android/media/MediaRoute2ProviderService.java
@@ -177,7 +177,6 @@
             }
             mSessionInfo.put(sessionInfo.getId(), sessionInfo);
         }
-        schedulePublishState();
 
         if (mClient == null) {
             return;
diff --git a/media/java/android/media/MediaRouter2Manager.java b/media/java/android/media/MediaRouter2Manager.java
index 7022933..5cb32d6 100644
--- a/media/java/android/media/MediaRouter2Manager.java
+++ b/media/java/android/media/MediaRouter2Manager.java
@@ -22,6 +22,7 @@
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.content.Context;
+import android.os.Bundle;
 import android.os.Handler;
 import android.os.RemoteException;
 import android.os.ServiceManager;
@@ -131,7 +132,7 @@
         Objects.requireNonNull(callback, "callback must not be null");
 
         if (!mCallbackRecords.remove(new CallbackRecord(null, callback))) {
-            Log.w(TAG, "Ignore removing unknown callback. " + callback);
+            Log.w(TAG, "unregisterCallback: Ignore unknown callback. " + callback);
             return;
         }
 
@@ -175,6 +176,29 @@
         return routes;
     }
 
+    /**
+     * Gets routing controllers of an application with the given package name.
+     * If the application isn't running or it doesn't use {@link MediaRouter2}, an empty list
+     * will be returned.
+     */
+    @NonNull
+    public List<RoutingController> getRoutingControllers(@NonNull String packageName) {
+        Objects.requireNonNull(packageName, "packageName must not be null");
+
+        List<RoutingController> controllers = new ArrayList<>();
+
+        for (RoutingSessionInfo sessionInfo : getActiveSessions()) {
+            if (TextUtils.equals(sessionInfo.getClientPackageName(), packageName)) {
+                controllers.add(new RoutingController(sessionInfo));
+            }
+        }
+        return controllers;
+    }
+
+    /**
+     * Gets the list of all active routing sessions. It doesn't include default routing sessions
+     * of applications.
+     */
     @NonNull
     public List<RoutingSessionInfo> getActiveSessions() {
         Client client;
@@ -192,23 +216,7 @@
     }
 
     /**
-     * Gets the list of routes that are actively used by {@link MediaRouter2}.
-     */
-    @NonNull
-    public List<MediaRoute2Info> getActiveRoutes() {
-        List<MediaRoute2Info> routes = new ArrayList<>();
-        synchronized (mRoutesLock) {
-            for (MediaRoute2Info route : mRoutes.values()) {
-                if (!TextUtils.isEmpty(route.getClientPackageName())) {
-                    routes.add(route);
-                }
-            }
-        }
-        return routes;
-    }
-
-    /**
-     * Gets the list of discovered routes
+     * Gets the list of all discovered routes
      */
     @NonNull
     public List<MediaRoute2Info> getAllRoutes() {
@@ -222,6 +230,10 @@
     /**
      * Selects media route for the specified package name.
      *
+     * If the given route is {@link RoutingController#getTransferrableRoutes() a transferrable
+     * route} of a routing session of the application, the session will be transferred to
+     * the route. If not, a new routing session will be created.
+     *
      * @param packageName the package name of the application that should change it's media route
      * @param route the route to be selected.
      */
@@ -229,6 +241,13 @@
         Objects.requireNonNull(packageName, "packageName must not be null");
         Objects.requireNonNull(route, "route must not be null");
 
+        for (RoutingController controller : getRoutingControllers(packageName)) {
+            if (controller.getSessionInfo().getTransferrableRoutes().contains(route.getId())) {
+                controller.transferToRoute(route);
+                return;
+            }
+        }
+
         Client client;
         synchronized (sLock) {
             client = mClient;
@@ -238,6 +257,7 @@
                 int requestId = mNextRequestId.getAndIncrement();
                 mMediaRouterService.requestCreateClientSession(
                         client, packageName, route, requestId);
+                //TODO: release the previous session?
             } catch (RemoteException ex) {
                 Log.e(TAG, "Unable to select media route", ex);
             }
@@ -245,7 +265,7 @@
     }
 
     /**
-     * Requests a volume change for the route asynchronously.
+     * Requests a volume change for a route asynchronously.
      * <p>
      * It may have no effect if the route is currently not selected.
      * </p>
@@ -346,9 +366,16 @@
         }
     }
 
-    void notifyRouteSelected(String packageName, MediaRoute2Info route) {
+    void notifySessionCreated(RoutingSessionInfo sessionInfo) {
         for (CallbackRecord record : mCallbackRecords) {
-            record.mExecutor.execute(() -> record.mCallback.onRouteSelected(packageName, route));
+            record.mExecutor.execute(() -> record.mCallback.onSessionCreated(
+                    new RoutingController(sessionInfo)));
+        }
+    }
+
+    void notifySessionInfosChanged() {
+        for (CallbackRecord record : mCallbackRecords) {
+            record.mExecutor.execute(() -> record.mCallback.onSessionsUpdated());
         }
     }
 
@@ -365,6 +392,275 @@
     }
 
     /**
+     * @hide
+     */
+    public RoutingController getControllerForSession(@NonNull RoutingSessionInfo sessionInfo) {
+        return new RoutingController(sessionInfo);
+    }
+
+    /**
+     * A class to control media routing session in media route provider.
+     * With routing controller, an application can select a route into the session or deselect
+     * a route in the session.
+     */
+    public final class RoutingController {
+        private final Object mControllerLock = new Object();
+        @GuardedBy("mControllerLock")
+        private RoutingSessionInfo mSessionInfo;
+
+        RoutingController(@NonNull RoutingSessionInfo sessionInfo) {
+            mSessionInfo = sessionInfo;
+        }
+
+        /**
+         * Gets the ID of the session
+         */
+        @NonNull
+        public String getSessionId() {
+            synchronized (mControllerLock) {
+                return mSessionInfo.getId();
+            }
+        }
+
+        /**
+         * Gets the client package name of the session
+         */
+        @NonNull
+        public String getClientPackageName() {
+            synchronized (mControllerLock) {
+                return mSessionInfo.getClientPackageName();
+            }
+        }
+
+        /**
+         * @return the control hints used to control route session if available.
+         */
+        @Nullable
+        public Bundle getControlHints() {
+            synchronized (mControllerLock) {
+                return mSessionInfo.getControlHints();
+            }
+        }
+
+        /**
+         * @return the unmodifiable list of currently selected routes
+         */
+        @NonNull
+        public List<MediaRoute2Info> getSelectedRoutes() {
+            List<String> routeIds;
+            synchronized (mControllerLock) {
+                routeIds = mSessionInfo.getSelectedRoutes();
+            }
+            return getRoutesWithIds(routeIds);
+        }
+
+        /**
+         * @return the unmodifiable list of selectable routes for the session.
+         */
+        @NonNull
+        public List<MediaRoute2Info> getSelectableRoutes() {
+            List<String> routeIds;
+            synchronized (mControllerLock) {
+                routeIds = mSessionInfo.getSelectableRoutes();
+            }
+            return getRoutesWithIds(routeIds);
+        }
+
+        /**
+         * @return the unmodifiable list of deselectable routes for the session.
+         */
+        @NonNull
+        public List<MediaRoute2Info> getDeselectableRoutes() {
+            List<String> routeIds;
+            synchronized (mControllerLock) {
+                routeIds = mSessionInfo.getDeselectableRoutes();
+            }
+            return getRoutesWithIds(routeIds);
+        }
+
+        /**
+         * @return the unmodifiable list of transferrable routes for the session.
+         */
+        @NonNull
+        public List<MediaRoute2Info> getTransferrableRoutes() {
+            List<String> routeIds;
+            synchronized (mControllerLock) {
+                routeIds = mSessionInfo.getTransferrableRoutes();
+            }
+            return getRoutesWithIds(routeIds);
+        }
+
+        /**
+         * Selects a route for the remote session. The given route must satisfy all of the
+         * following conditions:
+         * <ul>
+         * <li>ID should not be included in {@link #getSelectedRoutes()}</li>
+         * <li>ID should be included in {@link #getSelectableRoutes()}</li>
+         * </ul>
+         * If the route doesn't meet any of above conditions, it will be ignored.
+         *
+         * @see #getSelectedRoutes()
+         * @see #getSelectableRoutes()
+         */
+        public void selectRoute(@NonNull MediaRoute2Info route) {
+            Objects.requireNonNull(route, "route must not be null");
+
+            RoutingSessionInfo sessionInfo;
+            synchronized (mControllerLock) {
+                sessionInfo = mSessionInfo;
+            }
+            if (sessionInfo.getSelectedRoutes().contains(route.getId())) {
+                Log.w(TAG, "Ignoring selecting a route that is already selected. route=" + route);
+                return;
+            }
+
+            if (!sessionInfo.getSelectableRoutes().contains(route.getId())) {
+                Log.w(TAG, "Ignoring selecting a non-selectable route=" + route);
+                return;
+            }
+
+            Client client;
+            synchronized (sLock) {
+                client = mClient;
+            }
+            if (client != null) {
+                try {
+                    mMediaRouterService.selectClientRoute(mClient, getSessionId(), route);
+                } catch (RemoteException ex) {
+                    Log.e(TAG, "Unable to select route for session.", ex);
+                }
+            }
+        }
+
+        /**
+         * Deselects a route from the remote session. The given route must satisfy all of the
+         * following conditions:
+         * <ul>
+         * <li>ID should be included in {@link #getSelectedRoutes()}</li>
+         * <li>ID should be included in {@link #getDeselectableRoutes()}</li>
+         * </ul>
+         * If the route doesn't meet any of above conditions, it will be ignored.
+         *
+         * @see #getSelectedRoutes()
+         * @see #getDeselectableRoutes()
+         */
+        public void deselectRoute(@NonNull MediaRoute2Info route) {
+            Objects.requireNonNull(route, "route must not be null");
+            RoutingSessionInfo sessionInfo;
+            synchronized (mControllerLock) {
+                sessionInfo = mSessionInfo;
+            }
+
+            if (!sessionInfo.getSelectedRoutes().contains(route.getId())) {
+                Log.w(TAG, "Ignoring deselecting a route that is not selected. route=" + route);
+                return;
+            }
+
+            if (!sessionInfo.getDeselectableRoutes().contains(route.getId())) {
+                Log.w(TAG, "Ignoring deselecting a non-deselectable route=" + route);
+                return;
+            }
+
+            Client client;
+            synchronized (sLock) {
+                client = mClient;
+            }
+            if (client != null) {
+                try {
+                    mMediaRouterService.deselectClientRoute(mClient, getSessionId(), route);
+                } catch (RemoteException ex) {
+                    Log.e(TAG, "Unable to remove route from session.", ex);
+                }
+            }
+        }
+
+        /**
+         * Transfers to a given route for the remote session. The given route must satisfy
+         * all of the following conditions:
+         * <ul>
+         * <li>ID should not be included in {@link #getSelectedRoutes()}</li>
+         * <li>ID should be included in {@link #getTransferrableRoutes()}</li>
+         * </ul>
+         * If the route doesn't meet any of above conditions, it will be ignored.
+         *
+         * @see #getSelectedRoutes()
+         * @see #getTransferrableRoutes()
+         */
+        public void transferToRoute(@NonNull MediaRoute2Info route) {
+            Objects.requireNonNull(route, "route must not be null");
+            RoutingSessionInfo sessionInfo;
+            synchronized (mControllerLock) {
+                sessionInfo = mSessionInfo;
+            }
+
+            if (sessionInfo.getSelectedRoutes().contains(route.getId())) {
+                Log.w(TAG, "Ignoring transferring to a route that is already added. route="
+                        + route);
+                return;
+            }
+
+            if (!sessionInfo.getTransferrableRoutes().contains(route.getId())) {
+                Log.w(TAG, "Ignoring transferring to a non-transferrable route=" + route);
+                return;
+            }
+
+            Client client;
+            synchronized (sLock) {
+                client = mClient;
+            }
+            if (client != null) {
+                try {
+                    mMediaRouterService.transferToClientRoute(mClient, getSessionId(), route);
+                } catch (RemoteException ex) {
+                    Log.e(TAG, "Unable to transfer to route for session.", ex);
+                }
+            }
+        }
+
+        /**
+         * Release this session.
+         * Any operation on this session after calling this method will be ignored.
+         */
+        public void release() {
+            Client client;
+            synchronized (sLock) {
+                client = mClient;
+            }
+            if (client != null) {
+                try {
+                    mMediaRouterService.releaseClientSession(mClient, getSessionId());
+                } catch (RemoteException ex) {
+                    Log.e(TAG, "Unable to notify of controller release", ex);
+                }
+            }
+        }
+
+        /**
+         * Gets the session info of the session
+         * @hide
+         */
+        @NonNull
+        public RoutingSessionInfo getSessionInfo() {
+            synchronized (mControllerLock) {
+                return mSessionInfo;
+            }
+        }
+
+        private List<MediaRoute2Info> getRoutesWithIds(List<String> routeIds) {
+            List<MediaRoute2Info> routes = new ArrayList<>();
+            synchronized (mRoutesLock) {
+                for (String routeId : routeIds) {
+                    MediaRoute2Info route = mRoutes.get(routeId);
+                    if (route != null) {
+                        routes.add(route);
+                    }
+                }
+            }
+            return Collections.unmodifiableList(routes);
+        }
+    }
+
+    /**
      * Interface for receiving events about media routing changes.
      */
     public static class Callback {
@@ -388,14 +684,17 @@
         public void onRoutesChanged(@NonNull List<MediaRoute2Info> routes) {}
 
         /**
-         * Called when a route is selected for an application.
+         * Called when a routing session is created.
          *
-         * @param packageName the package name of the application
-         * @param route the selected route of the application.
-         *              It is null if the application has no selected route.
+         * @param controller the controller to control the created session
          */
-        public void onRouteSelected(@NonNull String packageName, @Nullable MediaRoute2Info route) {}
+        public void onSessionCreated(@NonNull RoutingController controller) {}
 
+        /**
+         * Called when at least one session info is changed.
+         * Call {@link #getActiveSessions()} to get current active session info.
+         */
+        public void onSessionsUpdated() {}
 
         /**
          * Called when the preferred route features of an app is changed.
@@ -435,11 +734,19 @@
 
     class Client extends IMediaRouter2Manager.Stub {
         @Override
-        public void notifyRouteSelected(String packageName, MediaRoute2Info route) {
-            mHandler.sendMessage(obtainMessage(MediaRouter2Manager::notifyRouteSelected,
-                    MediaRouter2Manager.this, packageName, route));
+        public void notifySessionCreated(RoutingSessionInfo sessionInfo) {
+            mHandler.sendMessage(obtainMessage(MediaRouter2Manager::notifySessionCreated,
+                    MediaRouter2Manager.this, sessionInfo));
         }
 
+        @Override
+        public void notifySessionsUpdated() {
+            mHandler.sendMessage(obtainMessage(MediaRouter2Manager::notifySessionInfosChanged,
+                    MediaRouter2Manager.this));
+            // do nothing
+        }
+
+        @Override
         public void notifyPreferredFeaturesChanged(String packageName, List<String> features) {
             mHandler.sendMessage(obtainMessage(MediaRouter2Manager::updatePreferredFeatures,
                     MediaRouter2Manager.this, packageName, features));
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 cc2d1b1..d3b5826 100644
--- a/media/tests/MediaRouteProvider/src/com/android/mediarouteprovider/example/SampleMediaRoute2ProviderService.java
+++ b/media/tests/MediaRouteProvider/src/com/android/mediarouteprovider/example/SampleMediaRoute2ProviderService.java
@@ -210,6 +210,7 @@
             }
         }
         notifySessionReleased(sessionId);
+        publishRoutes();
     }
 
     @Override
@@ -265,6 +266,27 @@
     @Override
     public void onTransferToRoute(String sessionId, String routeId) {
         RoutingSessionInfo sessionInfo = getSessionInfo(sessionId);
+        MediaRoute2Info route = mRoutes.get(routeId);
+
+        if (sessionInfo == null || route == null) {
+            return;
+        }
+
+        for (String selectedRouteId : sessionInfo.getSelectedRoutes()) {
+            mRouteIdToSessionId.remove(selectedRouteId);
+            MediaRoute2Info selectedRoute = mRoutes.get(selectedRouteId);
+            if (selectedRoute != null) {
+                mRoutes.put(selectedRouteId, new MediaRoute2Info.Builder(selectedRoute)
+                        .setClientPackageName(null)
+                        .build());
+            }
+        }
+
+        mRoutes.put(routeId, new MediaRoute2Info.Builder(route)
+                .setClientPackageName(sessionInfo.getClientPackageName())
+                .build());
+        mRouteIdToSessionId.put(routeId, sessionId);
+
         RoutingSessionInfo newSessionInfo = new RoutingSessionInfo.Builder(sessionInfo)
                 .clearSelectedRoutes()
                 .addSelectedRoute(routeId)
@@ -272,6 +294,7 @@
                 .removeTransferrableRoute(routeId)
                 .build();
         notifySessionUpdated(newSessionInfo);
+        publishRoutes();
     }
 
     void maybeDeselectRoute(String routeId) {
diff --git a/media/tests/MediaRouter/src/com/android/mediaroutertest/MediaRouterManagerTest.java b/media/tests/MediaRouter/src/com/android/mediaroutertest/MediaRouterManagerTest.java
index 83dd0c0..cba8452 100644
--- a/media/tests/MediaRouter/src/com/android/mediaroutertest/MediaRouterManagerTest.java
+++ b/media/tests/MediaRouter/src/com/android/mediaroutertest/MediaRouterManagerTest.java
@@ -31,6 +31,7 @@
 import android.media.MediaRouter2.SessionCallback;
 import android.media.MediaRouter2Manager;
 import android.media.RouteDiscoveryPreference;
+import android.media.RoutingSessionInfo;
 import android.support.test.InstrumentationRegistry;
 import android.support.test.filters.SmallTest;
 import android.support.test.runner.AndroidJUnit4;
@@ -38,7 +39,6 @@
 
 import org.junit.After;
 import org.junit.Before;
-import org.junit.Ignore;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
@@ -138,6 +138,8 @@
 
     @After
     public void tearDown() {
+        // order matters (callbacks should be cleared at the last)
+        releaseAllSessions();
         // unregister callbacks
         clearCallbacks();
     }
@@ -223,110 +225,83 @@
         MediaRoute2Info routeToSelect = routes.get(ROUTE_ID1);
         assertNotNull(routeToSelect);
 
-        try {
-            mManager.selectRoute(mPackageName, routeToSelect);
-            assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
-        } finally {
-            //TODO: release the session
-            //mManager.selectRoute(mPackageName, null);
-        }
-    }
-
-    /**
-     * Tests if MR2Manager.Callback.onRouteSelected is called
-     * when a route is selected by MR2Manager.
-     */
-    @Test
-    @Ignore("TODO: test session created callback instead of onRouteSelected")
-    public void testManagerOnRouteSelected() throws Exception {
-        CountDownLatch latch = new CountDownLatch(1);
-        Map<String, MediaRoute2Info> routes = waitAndGetRoutesWithManager(FEATURES_ALL);
-
-        addRouterCallback(new RouteCallback());
-        addManagerCallback(new MediaRouter2Manager.Callback() {
-            @Override
-            public void onRouteSelected(String packageName, MediaRoute2Info route) {
-                if (TextUtils.equals(mPackageName, packageName)
-                        && route != null && TextUtils.equals(route.getId(), ROUTE_ID1)) {
-                    latch.countDown();
-                }
-            }
-        });
-
-        MediaRoute2Info routeToSelect = routes.get(ROUTE_ID1);
-        assertNotNull(routeToSelect);
-
-        try {
-            mManager.selectRoute(mPackageName, routeToSelect);
-            assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
-        } finally {
-            //TODO: release the session
-            //mManager.selectRoute(mPackageName, null);
-        }
+        mManager.selectRoute(mPackageName, routeToSelect);
+        assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
+        assertEquals(1, mManager.getActiveSessions().size());
     }
 
     @Test
-    @Ignore("TODO: enable this when 'releasing session' is implemented")
-    public void testGetActiveRoutes() throws Exception {
+    public void testGetRoutingControllers() throws Exception {
         CountDownLatch latch = new CountDownLatch(1);
 
         Map<String, MediaRoute2Info> routes = waitAndGetRoutesWithManager(FEATURES_ALL);
         addRouterCallback(new RouteCallback());
         addManagerCallback(new MediaRouter2Manager.Callback() {
             @Override
-            public void onRouteSelected(String packageName, MediaRoute2Info route) {
-                if (TextUtils.equals(mPackageName, packageName)
-                        && route != null && TextUtils.equals(route.getId(), ROUTE_ID1)) {
+            public void onSessionCreated(MediaRouter2Manager.RoutingController controller) {
+                if (TextUtils.equals(mPackageName, controller.getClientPackageName())
+                        && createRouteMap(controller.getSelectedRoutes()).containsKey(ROUTE_ID1)) {
                     latch.countDown();
                 }
             }
         });
 
-        //TODO: it fails due to not releasing session
-        assertEquals(0, mManager.getActiveSessions().size());
+        assertEquals(0, mManager.getRoutingControllers(mPackageName).size());
 
         mManager.selectRoute(mPackageName, routes.get(ROUTE_ID1));
         latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS);
 
-        assertEquals(1, mManager.getActiveSessions().size());
+        List<MediaRouter2Manager.RoutingController> controllers =
+                mManager.getRoutingControllers(mPackageName);
 
-        //TODO: release the session
-        /*
+        assertEquals(1, controllers.size());
+
+        MediaRouter2Manager.RoutingController routingController = controllers.get(0);
         awaitOnRouteChangedManager(
-                () -> mManager.selectRoute(mPackageName, null),
+                () -> routingController.release(),
                 ROUTE_ID1,
                 route -> TextUtils.equals(route.getClientPackageName(), null));
-        assertEquals(0, mManager.getActiveRoutes().size());
-        */
+        assertEquals(0, mManager.getRoutingControllers(mPackageName).size());
     }
 
     /**
-     * Tests selecting and unselecting routes of a single provider.
+     * Tests select, transfer, release of routes of a provider
      */
     @Test
-    @Ignore("TODO: enable when session is released")
-    public void testSingleProviderSelect() throws Exception {
+    public void testSelectAndTransferAndRelease() throws Exception {
         Map<String, MediaRoute2Info> routes = waitAndGetRoutesWithManager(FEATURES_ALL);
         addRouterCallback(new RouteCallback());
 
+        CountDownLatch onSessionCreatedLatch = new CountDownLatch(1);
+
+        addManagerCallback(new MediaRouter2Manager.Callback() {
+            @Override
+            public void onSessionCreated(MediaRouter2Manager.RoutingController controller) {
+                assertNotNull(controller);
+                onSessionCreatedLatch.countDown();
+            }
+        });
         awaitOnRouteChangedManager(
                 () -> mManager.selectRoute(mPackageName, routes.get(ROUTE_ID1)),
                 ROUTE_ID1,
                 route -> TextUtils.equals(route.getClientPackageName(), mPackageName));
+        assertTrue(onSessionCreatedLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
+
+        List<MediaRouter2Manager.RoutingController> controllers =
+                mManager.getRoutingControllers(mPackageName);
+
+        assertEquals(1, controllers.size());
+        MediaRouter2Manager.RoutingController routingController = controllers.get(0);
 
         awaitOnRouteChangedManager(
-                () -> mManager.selectRoute(mPackageName, routes.get(ROUTE_ID2)),
-                ROUTE_ID2,
+                () -> mManager.selectRoute(mPackageName, routes.get(ROUTE_ID5_TO_TRANSFER_TO)),
+                ROUTE_ID5_TO_TRANSFER_TO,
                 route -> TextUtils.equals(route.getClientPackageName(), mPackageName));
 
-        //TODO: release the session
-        /*
         awaitOnRouteChangedManager(
-                () -> mManager.selectRoute(mPackageName, null),
-                ROUTE_ID2,
+                () -> routingController.release(),
+                ROUTE_ID5_TO_TRANSFER_TO,
                 route -> TextUtils.equals(route.getClientPackageName(), null));
-
-        */
     }
 
     @Test
@@ -460,4 +435,13 @@
         }
         mSessionCallbacks.clear();
     }
+
+    private void releaseAllSessions() {
+        // ensure ManagerRecord in MediaRouter2ServiceImpl
+        addManagerCallback(new MediaRouter2Manager.Callback());
+
+        for (RoutingSessionInfo session : mManager.getActiveSessions()) {
+            mManager.getControllerForSession(session).release();
+        }
+    }
 }
diff --git a/services/core/java/com/android/server/media/MediaRouter2ServiceImpl.java b/services/core/java/com/android/server/media/MediaRouter2ServiceImpl.java
index 45d50b3..6e3154c 100644
--- a/services/core/java/com/android/server/media/MediaRouter2ServiceImpl.java
+++ b/services/core/java/com/android/server/media/MediaRouter2ServiceImpl.java
@@ -22,6 +22,7 @@
 import static com.android.internal.util.function.pooled.PooledLambda.obtainMessage;
 
 import android.annotation.NonNull;
+import android.annotation.Nullable;
 import android.app.ActivityManager;
 import android.content.Context;
 import android.content.Intent;
@@ -34,7 +35,6 @@
 import android.media.RouteDiscoveryPreference;
 import android.media.RoutingSessionInfo;
 import android.os.Binder;
-import android.os.Bundle;
 import android.os.Handler;
 import android.os.IBinder;
 import android.os.Looper;
@@ -63,12 +63,12 @@
 import java.util.concurrent.atomic.AtomicInteger;
 
 /**
- * TODO: Merge this to MediaRouterService once it's finished.
+ * Implements features related to {@link android.media.MediaRouter2} and
+ * {@link android.media.MediaRouter2Manager}.
  */
 class MediaRouter2ServiceImpl {
     private static final String TAG = "MR2ServiceImpl";
     private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
-    private static final long ROUTE_SELECTION_REQUEST_TIMEOUT_MS = 5000L;
 
     private final Context mContext;
     private final Object mLock = new Object();
@@ -377,6 +377,53 @@
         }
     }
 
+    public void selectClientRoute(IMediaRouter2Manager manager, String sessionId,
+            MediaRoute2Info route) {
+        final long token = Binder.clearCallingIdentity();
+        try {
+            synchronized (mLock) {
+                selectClientRouteLocked(manager, sessionId, route);
+            }
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+    }
+
+    public void deselectClientRoute(IMediaRouter2Manager manager, String sessionId,
+            MediaRoute2Info route) {
+        final long token = Binder.clearCallingIdentity();
+        try {
+            synchronized (mLock) {
+                deselectClientRouteLocked(manager, sessionId, route);
+            }
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+    }
+
+    public void transferToClientRoute(IMediaRouter2Manager manager, String sessionId,
+            MediaRoute2Info route) {
+        final long token = Binder.clearCallingIdentity();
+        try {
+            synchronized (mLock) {
+                transferClientRouteLocked(manager, sessionId, route);
+            }
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+    }
+
+    public void releaseClientSession(IMediaRouter2Manager manager, String sessionId) {
+        final long token = Binder.clearCallingIdentity();
+        try {
+            synchronized (mLock) {
+                releaseClientSessionLocked(manager, sessionId);
+            }
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+    }
+
     //TODO: Review this is handling multi-user properly.
     void switchUser() {
         synchronized (mLock) {
@@ -417,6 +464,7 @@
             int uid, int pid, String packageName, int userId, boolean trusted) {
         final IBinder binder = client.asBinder();
         if (mAllClientRecords.get(binder) == null) {
+
             UserRecord userRecord = getOrCreateUserRecordLocked(userId);
             Client2Record clientRecord = new Client2Record(userRecord, client, uid, pid,
                     packageName, trusted);
@@ -653,6 +701,7 @@
         ManagerRecord managerRecord = mAllManagerRecords.get(binder);
 
         if (managerRecord == null) {
+            Slog.w(TAG, "getActiveSessionLocked: Ignoring unknown manager");
             return Collections.emptyList();
         }
 
@@ -676,6 +725,93 @@
         return userRecord;
     }
 
+    private void selectClientRouteLocked(IMediaRouter2Manager manager, String sessionId,
+            MediaRoute2Info route) {
+        final IBinder binder = manager.asBinder();
+        ManagerRecord managerRecord = mAllManagerRecords.get(binder);
+
+        if (managerRecord == null) {
+            Slog.w(TAG, "selectClientRouteLocked: Ignoring unknown manager.");
+            return;
+        }
+        //TODO: we shouldn't ignore selecting request for unknown clients. (RCN?)
+        Client2Record clientRecord = managerRecord.mUserRecord.mHandler
+                .findClientforSessionLocked(sessionId);
+        if (clientRecord == null) {
+            Slog.w(TAG, "selectClientRouteLocked: Ignoring unknown session.");
+            return;
+        }
+
+        clientRecord.mUserRecord.mHandler.sendMessage(
+                obtainMessage(UserHandler::selectRouteOnHandler,
+                            clientRecord.mUserRecord.mHandler,
+                            clientRecord, sessionId, route));
+    }
+
+    private void deselectClientRouteLocked(IMediaRouter2Manager manager, String sessionId,
+            MediaRoute2Info route) {
+        final IBinder binder = manager.asBinder();
+        ManagerRecord managerRecord = mAllManagerRecords.get(binder);
+
+        if (managerRecord == null) {
+            Slog.w(TAG, "deselectClientRouteLocked: Ignoring unknown manager.");
+            return;
+        }
+        //TODO: we shouldn't ignore selecting request for unknown clients. (RCN?)
+        Client2Record clientRecord = managerRecord.mUserRecord.mHandler
+                .findClientforSessionLocked(sessionId);
+        if (clientRecord == null) {
+            Slog.w(TAG, "deslectClientRouteLocked: Ignoring unknown session.");
+            return;
+        }
+
+        clientRecord.mUserRecord.mHandler.sendMessage(
+                obtainMessage(UserHandler::deselectRouteOnHandler,
+                        clientRecord.mUserRecord.mHandler,
+                        clientRecord, sessionId, route));
+    }
+
+    private void transferClientRouteLocked(IMediaRouter2Manager manager, String sessionId,
+            MediaRoute2Info route) {
+        final IBinder binder = manager.asBinder();
+        ManagerRecord managerRecord = mAllManagerRecords.get(binder);
+
+        if (managerRecord == null) {
+            Slog.w(TAG, "transferClientRouteLocked: Ignoring unknown manager.");
+            return;
+        }
+        //TODO: we shouldn't ignore selecting request for unknown clients. (RCN?)
+        Client2Record clientRecord = managerRecord.mUserRecord.mHandler
+                .findClientforSessionLocked(sessionId);
+        if (clientRecord == null) {
+            Slog.w(TAG, "transferClientRouteLocked: Ignoring unknown session.");
+            return;
+        }
+
+        clientRecord.mUserRecord.mHandler.sendMessage(
+                obtainMessage(UserHandler::transferToRouteOnHandler,
+                        clientRecord.mUserRecord.mHandler,
+                        clientRecord, sessionId, route));
+    }
+
+    private void releaseClientSessionLocked(IMediaRouter2Manager manager, String sessionId) {
+        final IBinder binder = manager.asBinder();
+        ManagerRecord managerRecord = mAllManagerRecords.get(binder);
+
+        if (managerRecord == null) {
+            Slog.w(TAG, "releaseClientSessionLocked: Ignoring unknown manager.");
+            return;
+        }
+
+        Client2Record clientRecord = managerRecord.mUserRecord.mHandler
+                .findClientforSessionLocked(sessionId);
+
+        managerRecord.mUserRecord.mHandler.sendMessage(
+                obtainMessage(UserHandler::releaseSessionOnHandler,
+                        managerRecord.mUserRecord.mHandler,
+                        clientRecord, sessionId));
+    }
+
     private void disposeUserIfNeededLocked(UserRecord userRecord) {
         // If there are no records left and the user is no longer current then go ahead
         // and purge the user record and all of its associated state.  If the user is current
@@ -896,6 +1032,11 @@
                     this, provider, sessionInfo));
         }
 
+        @Nullable
+        public Client2Record findClientforSessionLocked(@NonNull String sessionId) {
+            return mSessionToClientMap.get(sessionId);
+        }
+
         //TODO: notify session info updates
         private void onProviderStateChangedOnHandler(MediaRoute2Provider provider) {
             int providerIndex = getProviderInfoIndex(provider.getUniqueId());
@@ -1128,9 +1269,10 @@
         private void onSessionCreatedOnHandler(@NonNull MediaRoute2Provider provider,
                 @NonNull RoutingSessionInfo sessionInfo, long requestId) {
 
+            notifySessionCreatedToManagers(getManagers(), sessionInfo);
+
             if (requestId == MediaRoute2ProviderService.REQUEST_ID_UNKNOWN) {
                 // The session is created without any matching request.
-                // TODO: Tell managers for the session creation
                 return;
             }
 
@@ -1176,7 +1318,6 @@
             notifySessionCreated(matchingRequest.mClientRecord,
                     sessionInfo, toClientRequestId(requestId));
             mSessionToClientMap.put(sessionInfo.getId(), client2Record);
-            // TODO: Tell managers for the session creation
         }
 
         private void onSessionCreationFailedOnHandler(@NonNull MediaRoute2Provider provider,
@@ -1205,29 +1346,29 @@
 
         private void onSessionInfoChangedOnHandler(@NonNull MediaRoute2Provider provider,
                 @NonNull RoutingSessionInfo sessionInfo) {
+            List<IMediaRouter2Manager> managers = getManagers();
+            notifySessionInfosChangedToManagers(managers);
 
             Client2Record client2Record = mSessionToClientMap.get(
                     sessionInfo.getId());
             if (client2Record == null) {
                 Slog.w(TAG, "No matching client found for session=" + sessionInfo);
-                // TODO: Tell managers for the session update
                 return;
             }
             notifySessionInfoChanged(client2Record, sessionInfo);
-            // TODO: Tell managers for the session update
         }
 
         private void onSessionReleasedOnHandler(@NonNull MediaRoute2Provider provider,
                 @NonNull RoutingSessionInfo sessionInfo) {
+            List<IMediaRouter2Manager> managers = getManagers();
+            notifySessionInfosChangedToManagers(managers);
 
             Client2Record client2Record = mSessionToClientMap.get(sessionInfo.getId());
             if (client2Record == null) {
                 Slog.w(TAG, "No matching client found for session=" + sessionInfo);
-                // TODO: Tell managers for the session release
                 return;
             }
             notifySessionReleased(client2Record, sessionInfo);
-            // TODO: Tell managers for the session release
         }
 
         private void notifySessionCreated(Client2Record clientRecord,
@@ -1333,11 +1474,6 @@
             }
         }
 
-        // TODO: Remove notifyRouteSelected* methods
-        private void notifyRouteSelectedToClient(IMediaRouter2Client client,
-                MediaRoute2Info route, int reason, Bundle controlHints) {
-        }
-
         private void notifyRoutesAddedToClients(List<IMediaRouter2Client> clients,
                 List<MediaRoute2Info> routes) {
             for (IMediaRouter2Client client : clients) {
@@ -1419,6 +1555,29 @@
             }
         }
 
+        private void notifySessionCreatedToManagers(List<IMediaRouter2Manager> managers,
+                RoutingSessionInfo sessionInfo) {
+            for (IMediaRouter2Manager manager : managers) {
+                try {
+                    manager.notifySessionCreated(sessionInfo);
+                } catch (RemoteException ex) {
+                    Slog.w(TAG, "notifySessionCreatedToManagers: "
+                            + "failed to notify. Manager probably died.", ex);
+                }
+            }
+        }
+
+        private void notifySessionInfosChangedToManagers(List<IMediaRouter2Manager> managers) {
+            for (IMediaRouter2Manager manager : managers) {
+                try {
+                    manager.notifySessionsUpdated();
+                } catch (RemoteException ex) {
+                    Slog.w(TAG, "notifySessionInfosChangedToManagers: "
+                            + "failed to notify. Manager probably died.", ex);
+                }
+            }
+        }
+
         private void updateClientUsage(Client2Record clientRecord) {
             MediaRouter2ServiceImpl service = mServiceRef.get();
             if (service == null) {
@@ -1432,8 +1591,6 @@
             }
             for (IMediaRouter2Manager manager : managers) {
                 try {
-                    manager.notifyRouteSelected(clientRecord.mPackageName,
-                            clientRecord.mSelectedRoute);
                     manager.notifyPreferredFeaturesChanged(clientRecord.mPackageName,
                             clientRecord.mDiscoveryPreference.getPreferredFeatures());
                 } catch (RemoteException ex) {
diff --git a/services/core/java/com/android/server/media/MediaRouterService.java b/services/core/java/com/android/server/media/MediaRouterService.java
index aad9636..b33f5aa 100644
--- a/services/core/java/com/android/server/media/MediaRouterService.java
+++ b/services/core/java/com/android/server/media/MediaRouterService.java
@@ -556,6 +556,33 @@
         return mService2.getActiveSessions(manager);
     }
 
+    // Binder call
+    @Override
+    public void selectClientRoute(IMediaRouter2Manager manager, String sessionId,
+            MediaRoute2Info route) {
+        mService2.selectClientRoute(manager, sessionId, route);
+    }
+
+    // Binder call
+    @Override
+    public void deselectClientRoute(IMediaRouter2Manager manager, String sessionId,
+            MediaRoute2Info route) {
+        mService2.deselectClientRoute(manager, sessionId, route);
+    }
+
+    // Binder call
+    @Override
+    public void transferToClientRoute(IMediaRouter2Manager manager, String sessionId,
+            MediaRoute2Info route) {
+        mService2.transferToClientRoute(manager, sessionId, route);
+    }
+
+    // Binder call
+    @Override
+    public void releaseClientSession(IMediaRouter2Manager manager, String sessionId) {
+        mService2.releaseClientSession(manager, sessionId);
+    }
+
     void restoreBluetoothA2dp() {
         try {
             boolean a2dpOn;