No-op when selecting the selected or pending selected route

If apps call RouteInfo#select() multiple times, we should be
no-op when the to-be-selected route is already a selected route
or there is a pending route selection.

Bug: b/425408032
Test: manually test it with sample apps and presubmit tests
(cherry picked from https://android-review.googlesource.com/q/commit:02314738c04b57dc4efcb76f171956f0238530b4)
Merged-In: I025d473e8bacea1fc90988f6fa78a657b4989c39
Change-Id: I025d473e8bacea1fc90988f6fa78a657b4989c39
diff --git a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouter2Test.java b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouter2Test.java
index 2521b1b..766c5e7 100644
--- a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouter2Test.java
+++ b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouter2Test.java
@@ -31,6 +31,7 @@
 import android.os.Bundle;
 import android.os.Messenger;
 import android.text.TextUtils;
+import android.util.Log;
 
 import androidx.annotation.NonNull;
 import androidx.mediarouter.media.MediaRouter.RouteInfo;
@@ -64,9 +65,11 @@
 
     private Context mContext;
     private MediaRouter mRouter;
-    private MediaRouter.Callback mPlaceholderCallback = new MediaRouter.Callback() {};
-    StubMediaRouteProviderService mService;
-    StubMediaRouteProviderService.StubMediaRouteProvider mProvider;
+    private final MediaRouter.Callback mPlaceholderCallback = new MediaRouter.Callback() {};
+    StubMediaRouteProviderService mMr1ProviderService;
+    StubMediaRouteProviderService.StubMediaRouteProvider mMr1Provider;
+    StubMediaRoute2ProviderService mMr2ProviderService;
+    StubMediaRoute2ProviderService.StubMediaRoute2Provider mMr2Provider;
     MediaRouteProviderService.MediaRouteProviderServiceImplApi30 mServiceImpl;
     MediaRoute2ProviderServiceAdapter mMr2ProviderServiceAdapter;
 
@@ -98,22 +101,41 @@
         new PollingCheck(TIMEOUT_MS) {
             @Override
             protected boolean check() {
-                mService = StubMediaRouteProviderService.getInstance();
-                if (mService != null && mService.getMediaRouteProvider() != null) {
-                    mProvider = (StubMediaRouteProviderService.StubMediaRouteProvider)
-                            mService.getMediaRouteProvider();
-                    mServiceImpl = (MediaRouteProviderService.MediaRouteProviderServiceImplApi30)
-                            mService.mImpl;
+                mMr1ProviderService = StubMediaRouteProviderService.getInstance();
+                boolean isMr1ProviderCreated = false;
+                if (mMr1ProviderService != null
+                        && mMr1ProviderService.getMediaRouteProvider() != null) {
+                    mMr1Provider =
+                            (StubMediaRouteProviderService.StubMediaRouteProvider)
+                                    mMr1ProviderService.getMediaRouteProvider();
+                    mServiceImpl =
+                            (MediaRouteProviderService.MediaRouteProviderServiceImplApi30)
+                                    mMr1ProviderService.mImpl;
                     mMr2ProviderServiceAdapter = mServiceImpl.mMR2ProviderServiceAdapter;
-                    return mMr2ProviderServiceAdapter != null;
+                    isMr1ProviderCreated = mMr2ProviderServiceAdapter != null;
                 }
-                return false;
+
+                mMr2ProviderService = StubMediaRoute2ProviderService.getInstance();
+                boolean isMr2ProviderCreated = false;
+                if (mMr2ProviderService != null
+                        && mMr2ProviderService.getMediaRouteProvider() != null) {
+                    mMr2Provider =
+                            (StubMediaRoute2ProviderService.StubMediaRoute2Provider)
+                                    mMr2ProviderService.getMediaRouteProvider();
+                    isMr2ProviderCreated = mMr2Provider != null;
+                }
+
+                return isMr1ProviderCreated && isMr2ProviderCreated;
             }
         }.run();
-        getInstrumentation().runOnMainSync(() -> {
-            mProvider.initializeRoutes();
-            mProvider.publishRoutes();
-        });
+        getInstrumentation()
+                .runOnMainSync(
+                        () -> {
+                            mMr1Provider.initializeRoutes();
+                            mMr1Provider.publishRoutes();
+                            mMr2Provider.initializeRoutes();
+                            mMr2Provider.publishRoutes();
+                        });
     }
 
     @After
@@ -139,42 +161,196 @@
 
     @Test
     @MediumTest
+    public void selectRoute_withSelectedMr1Route_shouldBeNoOp() throws Exception {
+        String descriptorId = StubMediaRouteProviderService.ROUTE_ID1;
+        waitForRoutesAdded(descriptorId);
+        assertNotNull(mRoutes);
+
+        // Select the route for the first time.
+        waitForRouteSelected(descriptorId, descriptorId, /* routeSelected= */ true);
+
+        // Wait for a session being created.
+        PollingCheck.waitFor(
+                TIMEOUT_MS, () -> !mMr2ProviderServiceAdapter.getAllSessionInfo().isEmpty());
+
+        // Select the route for the second time, which should be no op.
+        waitForRouteSelected(descriptorId, descriptorId, /* routeSelected= */ false);
+
+        // Stop casting the session before casting to the same route again.
+        waitForRouteUnselected(descriptorId, descriptorId);
+
+        // Wait for a session being released.
+        PollingCheck.waitFor(
+                TIMEOUT_MS, () -> mMr2ProviderServiceAdapter.getAllSessionInfo().isEmpty());
+
+        // Select the route for casting again.
+        waitForRouteSelected(descriptorId, descriptorId, /* routeSelected= */ true);
+    }
+
+    @Test
+    @MediumTest
+    public void selectRoute_withSelectedMr2Route_shouldBeNoOp() throws Exception {
+        String descriptorId = StubMediaRoute2ProviderService.MR2_ROUTE_ID1;
+        String mr2DescriptorId = getMediaRoute2DescriptorId(descriptorId);
+        waitForRoutesAdded(mr2DescriptorId);
+        assertNotNull(mRoutes);
+
+        // Select the route for the first time.
+        waitForRouteSelected(
+                mr2DescriptorId,
+                StubMediaRoute2ProviderService.ROUTE_ID_GROUP,
+                /* routeSelected= */ true);
+
+        assertEquals(1, mMr2Provider.getNumberOfCreatedControllers(descriptorId));
+
+        // Select the route for the second time, which should be no op.
+        waitForRouteSelected(
+                mr2DescriptorId,
+                StubMediaRoute2ProviderService.ROUTE_ID_GROUP,
+                /* routeSelected= */ false);
+
+        // Check that only one dynamic group route controller is created.
+        assertEquals(1, mMr2Provider.getNumberOfCreatedControllers(descriptorId));
+
+        // Stop casting the session before casting to the same route again.
+        waitForRouteUnselected(mr2DescriptorId, StubMediaRoute2ProviderService.ROUTE_ID_GROUP);
+        // Wait for the route controller is removed from the media route provider.
+        PollingCheck.waitFor(
+                TIMEOUT_MS, () -> mMr2Provider.getNumberOfCreatedControllers(descriptorId) == 0);
+
+        assertEquals(0, mMr2Provider.getNumberOfCreatedControllers(descriptorId));
+
+        // Select the route for casting again.
+        waitForRouteSelected(
+                mr2DescriptorId,
+                StubMediaRoute2ProviderService.ROUTE_ID_GROUP,
+                /* routeSelected= */ true);
+
+        assertEquals(1, mMr2Provider.getNumberOfCreatedControllers(descriptorId));
+
+        // Unselect the route to prevent it interrupts other tests.
+        waitForRouteUnselected(mr2DescriptorId, StubMediaRoute2ProviderService.ROUTE_ID_GROUP);
+        // Wait for the route controller is removed from the media route provider.
+        PollingCheck.waitFor(
+                TIMEOUT_MS, () -> mMr2Provider.getNumberOfCreatedControllers(descriptorId) == 0);
+    }
+
+    @Test
+    @MediumTest
+    public void selectRoute_withSelectingMr2Route_shouldBeNoOp() throws Exception {
+        String descriptorId = StubMediaRoute2ProviderService.MR2_ROUTE_ID1;
+        String mr2DescriptorId = getMediaRoute2DescriptorId(descriptorId);
+        waitForRoutesAdded(mr2DescriptorId);
+        assertNotNull(mRoutes);
+
+        RouteInfo routeToSelect = mRoutes.get(mr2DescriptorId);
+        assertNotNull(routeToSelect);
+
+        CountDownLatch onRouteSelectedLatch = new CountDownLatch(2);
+        MediaRouter.Callback callback =
+                new MediaRouter.Callback() {
+                    @Override
+                    public void onRouteSelected(
+                            @NonNull MediaRouter router,
+                            @NonNull RouteInfo selectedRoute,
+                            int reason,
+                            @NonNull RouteInfo requestedRoute) {
+                        Log.i(
+                                TAG,
+                                "onRouteSelected with selectedRoute = "
+                                        + selectedRoute
+                                        + ", requestedRoute = "
+                                        + requestedRoute
+                                        + ", reason = "
+                                        + reason);
+                        if (TextUtils.equals(
+                                        selectedRoute.getDescriptorId(),
+                                        StubMediaRoute2ProviderService.ROUTE_ID_GROUP)
+                                && reason == MediaRouter.UNSELECT_REASON_ROUTE_CHANGED) {
+                            onRouteSelectedLatch.countDown();
+                        }
+                    }
+                };
+        addCallback(callback);
+
+        // Select the same route twice.
+        getInstrumentation()
+                .runOnMainSync(
+                        () -> {
+                            mRouter.selectRoute(routeToSelect);
+                            mRouter.selectRoute(routeToSelect);
+                        });
+
+        // Check that only one dynamic group route controller is created.
+        assertFalse(onRouteSelectedLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
+        assertEquals(1, onRouteSelectedLatch.getCount());
+        assertEquals(1, mMr2Provider.getNumberOfCreatedControllers(descriptorId));
+
+        // Stop casting the session before casting to the same route again.
+        waitForRouteUnselected(mr2DescriptorId, StubMediaRoute2ProviderService.ROUTE_ID_GROUP);
+        // Wait for the route controller is removed from the media route provider.
+        PollingCheck.waitFor(
+                TIMEOUT_MS, () -> mMr2Provider.getNumberOfCreatedControllers(descriptorId) == 0);
+
+        assertEquals(0, mMr2Provider.getNumberOfCreatedControllers(descriptorId));
+
+        // Select the route for casting again.
+        waitForRouteSelected(
+                mr2DescriptorId,
+                StubMediaRoute2ProviderService.ROUTE_ID_GROUP,
+                /* routeSelected= */ true);
+
+        assertEquals(1, mMr2Provider.getNumberOfCreatedControllers(descriptorId));
+
+        // Unselect the route to prevent it interrupts other tests.
+        waitForRouteUnselected(mr2DescriptorId, StubMediaRoute2ProviderService.ROUTE_ID_GROUP);
+        // Wait for the route controller is removed from the media route provider.
+        PollingCheck.waitFor(
+                TIMEOUT_MS, () -> mMr2Provider.getNumberOfCreatedControllers(descriptorId) == 0);
+    }
+
+    @Test
+    @MediumTest
     public void selectFromMr1AndStopFromSystem_unselect() throws Exception {
         CountDownLatch onRouteSelectedLatch = new CountDownLatch(1);
         CountDownLatch onRouteUnselectedLatch = new CountDownLatch(1);
         CountDownLatch onRouteEnabledLatch = new CountDownLatch(1);
         String descriptorId = StubMediaRouteProviderService.ROUTE_ID1;
 
-        addCallback(new MediaRouter.Callback() {
-            @Override
-            public void onRouteSelected(@NonNull MediaRouter router,
-                    @NonNull RouteInfo selectedRoute, int reason,
-                    @NonNull RouteInfo requestedRoute) {
-                if (TextUtils.equals(selectedRoute.getDescriptorId(), descriptorId)
-                        && reason == MediaRouter.UNSELECT_REASON_ROUTE_CHANGED) {
-                    onRouteSelectedLatch.countDown();
-                }
-            }
+        addCallback(
+                new MediaRouter.Callback() {
+                    @Override
+                    public void onRouteSelected(
+                            @NonNull MediaRouter router,
+                            @NonNull RouteInfo selectedRoute,
+                            int reason,
+                            @NonNull RouteInfo requestedRoute) {
+                        if (TextUtils.equals(selectedRoute.getDescriptorId(), descriptorId)
+                                && reason == MediaRouter.UNSELECT_REASON_ROUTE_CHANGED) {
+                            onRouteSelectedLatch.countDown();
+                        }
+                    }
 
-            @Override
-            public void onRouteUnselected(
-                    @NonNull MediaRouter router, @NonNull RouteInfo route, int reason) {
-                if (TextUtils.equals(route.getDescriptorId(), descriptorId)
-                        && reason == MediaRouter.UNSELECT_REASON_STOPPED) {
-                    onRouteUnselectedLatch.countDown();
-                }
-            }
+                    @Override
+                    public void onRouteUnselected(
+                            @NonNull MediaRouter router, @NonNull RouteInfo route, int reason) {
+                        if (TextUtils.equals(route.getDescriptorId(), descriptorId)
+                                && reason == MediaRouter.UNSELECT_REASON_STOPPED) {
+                            onRouteUnselectedLatch.countDown();
+                        }
+                    }
 
-            @Override
-            public void onRouteChanged(@NonNull MediaRouter router, @NonNull RouteInfo route) {
-                if (onRouteUnselectedLatch.getCount() == 0
-                        && TextUtils.equals(route.getDescriptorId(), descriptorId)
-                        && route.isEnabled()) {
-                    onRouteEnabledLatch.countDown();
-                }
-            }
-        });
-        waitForRoutesAdded();
+                    @Override
+                    public void onRouteChanged(
+                            @NonNull MediaRouter router, @NonNull RouteInfo route) {
+                        if (onRouteUnselectedLatch.getCount() == 0
+                                && TextUtils.equals(route.getDescriptorId(), descriptorId)
+                                && route.isEnabled()) {
+                            onRouteEnabledLatch.countDown();
+                        }
+                    }
+                });
+        waitForRoutesAdded(descriptorId);
         assertNotNull(mRoutes);
 
         RouteInfo routeToSelect = mRoutes.get(descriptorId);
@@ -184,14 +360,16 @@
         assertTrue(onRouteSelectedLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
 
         // Wait for a session being created.
-        PollingCheck.waitFor(TIMEOUT_MS,
-                () -> !mMr2ProviderServiceAdapter.getAllSessionInfo().isEmpty());
-        //TODO: Find a correct session info
+        PollingCheck.waitFor(
+                TIMEOUT_MS, () -> !mMr2ProviderServiceAdapter.getAllSessionInfo().isEmpty());
+        // TODO: Find a correct session info
         for (RoutingSessionInfo sessionInfo : mMr2ProviderServiceAdapter.getAllSessionInfo()) {
-            getInstrumentation().runOnMainSync(() ->
-                    mMr2ProviderServiceAdapter.onReleaseSession(
-                            MediaRoute2ProviderService.REQUEST_ID_NONE,
-                            sessionInfo.getId()));
+            getInstrumentation()
+                    .runOnMainSync(
+                            () ->
+                                    mMr2ProviderServiceAdapter.onReleaseSession(
+                                            MediaRoute2ProviderService.REQUEST_ID_NONE,
+                                            sessionInfo.getId()));
         }
         assertTrue(onRouteUnselectedLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
         // Make sure the route is enabled
@@ -226,7 +404,6 @@
                                 }
                             }
                         });
-
     }
 
     @Test
@@ -258,7 +435,7 @@
                                         StubMediaRouteProviderService.ROUTE_ID1,
                                         /* sessionHints= */ null));
         StubMediaRouteProviderService.StubMediaRouteProvider.StubRouteController createdController =
-                mProvider.mControllers.get(StubMediaRouteProviderService.ROUTE_ID1);
+                mMr1Provider.mControllers.get(StubMediaRouteProviderService.ROUTE_ID1);
         assertNotNull(createdController); // Avoids nullability warning.
         assertNull(createdController.mLastSetVolume);
         mMr2ProviderServiceAdapter.setRouteVolume(StubMediaRouteProviderService.ROUTE_ID1, 100);
@@ -274,7 +451,7 @@
     public void onBinderDied_releaseRoutingSessions() throws Exception {
         String descriptorId = StubMediaRouteProviderService.ROUTE_ID1;
 
-        waitForRoutesAdded();
+        waitForRoutesAdded(descriptorId);
         assertNotNull(mRoutes);
 
         RouteInfo routeToSelect = mRoutes.get(descriptorId);
@@ -283,26 +460,27 @@
         getInstrumentation().runOnMainSync(() -> mRouter.selectRoute(routeToSelect));
 
         // Wait for a session being created.
-        PollingCheck.waitFor(TIMEOUT_MS,
-                () -> !mMr2ProviderServiceAdapter.getAllSessionInfo().isEmpty());
+        PollingCheck.waitFor(
+                TIMEOUT_MS, () -> !mMr2ProviderServiceAdapter.getAllSessionInfo().isEmpty());
 
         try {
             List<Messenger> messengers =
                     mServiceImpl.mClients.stream()
                             .map(client -> client.mMessenger)
                             .collect(Collectors.toList());
-            getInstrumentation().runOnMainSync(() ->
-                    messengers.forEach(mServiceImpl::onBinderDied));
+            getInstrumentation()
+                    .runOnMainSync(() -> messengers.forEach(mServiceImpl::onBinderDied));
             // It should have no session info.
-            PollingCheck.waitFor(TIMEOUT_MS,
-                    () -> mMr2ProviderServiceAdapter.getAllSessionInfo().isEmpty());
+            PollingCheck.waitFor(
+                    TIMEOUT_MS, () -> mMr2ProviderServiceAdapter.getAllSessionInfo().isEmpty());
         } finally {
             // Rebind for future tests
-            getInstrumentation().runOnMainSync(
-                    () -> {
-                        MediaRouter.sGlobal.mRegisteredProviderWatcher.stop();
-                        MediaRouter.sGlobal.mRegisteredProviderWatcher.start();
-                    });
+            getInstrumentation()
+                    .runOnMainSync(
+                            () -> {
+                                MediaRouter.sGlobal.mRegisteredProviderWatcher.stop();
+                                MediaRouter.sGlobal.mRegisteredProviderWatcher.start();
+                            });
         }
     }
 
@@ -312,21 +490,24 @@
         CountDownLatch onRouterParamsChangedLatch = new CountDownLatch(1);
         final MediaRouterParams[] routerParams = {null};
 
-        addCallback(new MediaRouter.Callback() {
-            @Override
-            public void onRouterParamsChanged(
-                    @NonNull MediaRouter router, MediaRouterParams params) {
-                routerParams[0] = params;
-                onRouterParamsChangedLatch.countDown();
-            }
-        });
+        addCallback(
+                new MediaRouter.Callback() {
+                    @Override
+                    public void onRouterParamsChanged(
+                            @NonNull MediaRouter router, MediaRouterParams params) {
+                        routerParams[0] = params;
+                        onRouterParamsChangedLatch.countDown();
+                    }
+                });
 
         Bundle extras = new Bundle();
         extras.putString("test-key", "test-value");
         MediaRouterParams params = new MediaRouterParams.Builder().setExtras(extras).build();
-        getInstrumentation().runOnMainSync(() -> {
-            mRouter.setRouterParams(params);
-        });
+        getInstrumentation()
+                .runOnMainSync(
+                        () -> {
+                            mRouter.setRouterParams(params);
+                        });
 
         assertTrue(onRouterParamsChangedLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
         Bundle actualExtras = routerParams[0].getExtras();
@@ -335,29 +516,116 @@
     }
 
     void addCallback(MediaRouter.Callback callback) {
-        getInstrumentation().runOnMainSync(() -> {
-            mRouter.addCallback(mSelector, callback,
-                    MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY
-                            | MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN);
-        });
+        getInstrumentation()
+                .runOnMainSync(
+                        () -> {
+                            mRouter.addCallback(
+                                    mSelector,
+                                    callback,
+                                    MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY
+                                            | MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN);
+                        });
         mCallbacks.add(callback);
     }
 
-    void waitForRoutesAdded() throws Exception {
+    void waitForRoutesAdded(String descriptorId) throws Exception {
         CountDownLatch latch = new CountDownLatch(1);
-        MediaRouter.Callback callback = new MediaRouter.Callback() {
-            @Override
-            public void onRouteAdded(@NonNull MediaRouter router, @NonNull RouteInfo route) {
-                if (!route.isDefaultOrBluetooth()) {
-                    latch.countDown();
-                }
-            }
-        };
+        MediaRouter.Callback callback =
+                new MediaRouter.Callback() {
+                    @Override
+                    public void onRouteAdded(
+                            @NonNull MediaRouter router, @NonNull RouteInfo route) {
+                        if (!route.isDefaultOrBluetooth()) {
+                            MediaRouteDescriptor routeDescriptor = route.getMediaRouteDescriptor();
+                            if (routeDescriptor != null
+                                    && TextUtils.equals(routeDescriptor.getId(), descriptorId)) {
+                                latch.countDown();
+                            }
+                        }
+                    }
+                };
 
         addCallback(callback);
 
         latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS);
-        getInstrumentation().runOnMainSync(() -> mRoutes = mRouter.getRoutes().stream().collect(
-                Collectors.toMap(route -> route.getDescriptorId(), route -> route)));
+        getInstrumentation()
+                .runOnMainSync(
+                        () ->
+                                mRoutes =
+                                        mRouter.getRoutes().stream()
+                                                .collect(
+                                                        Collectors.toMap(
+                                                                route -> route.getDescriptorId(),
+                                                                route -> route)));
+    }
+
+    void waitForRouteSelected(
+            String descriptorIdToSelect, String selectedDescriptorId, boolean routeSelected)
+            throws Exception {
+        CountDownLatch onRouteSelectedLatch = new CountDownLatch(1);
+        MediaRouter.Callback callback =
+                new MediaRouter.Callback() {
+                    @Override
+                    public void onRouteSelected(
+                            @NonNull MediaRouter router,
+                            @NonNull RouteInfo selectedRoute,
+                            int reason,
+                            @NonNull RouteInfo requestedRoute) {
+                        Log.i(
+                                TAG,
+                                "onRouteSelected with selectedRoute = "
+                                        + selectedRoute
+                                        + ", requestedRoute = "
+                                        + requestedRoute
+                                        + ", reason = "
+                                        + reason);
+                        if (TextUtils.equals(selectedRoute.getDescriptorId(), selectedDescriptorId)
+                                && reason == MediaRouter.UNSELECT_REASON_ROUTE_CHANGED) {
+                            onRouteSelectedLatch.countDown();
+                        }
+                    }
+                };
+        addCallback(callback);
+
+        RouteInfo routeToSelect = mRoutes.get(descriptorIdToSelect);
+        assertNotNull(routeToSelect);
+
+        getInstrumentation().runOnMainSync(() -> mRouter.selectRoute(routeToSelect));
+        assertEquals(routeSelected, onRouteSelectedLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
+    }
+
+    void waitForRouteUnselected(String descriptorIdToUnselect, String deselectedDescriptorId)
+            throws Exception {
+        CountDownLatch onRouteUnselectedLatch = new CountDownLatch(1);
+        MediaRouter.Callback callback =
+                new MediaRouter.Callback() {
+                    @Override
+                    public void onRouteUnselected(
+                            @NonNull MediaRouter router, @NonNull RouteInfo route, int reason) {
+                        Log.i(
+                                TAG,
+                                "onRouteUnselected with route = " + route + ", reason = " + reason);
+                        if (TextUtils.equals(route.getDescriptorId(), deselectedDescriptorId)
+                                && reason == MediaRouter.UNSELECT_REASON_STOPPED) {
+                            onRouteUnselectedLatch.countDown();
+                        }
+                    }
+                };
+        addCallback(callback);
+
+        RouteInfo routeToUnselect = mRoutes.get(descriptorIdToUnselect);
+        assertNotNull(routeToUnselect);
+
+        getInstrumentation()
+                .runOnMainSync(() -> mRouter.unselect(MediaRouter.UNSELECT_REASON_STOPPED));
+        assertTrue(onRouteUnselectedLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
+    }
+
+    private String getMediaRoute2DescriptorId(String descriptorId) {
+        return StubMediaRoute2ProviderService.CLIENT_PACKAGE_NAME
+                + "/"
+                + StubMediaRoute2ProviderService.CLIENT_CLASS_NAME
+                + ":"
+                + descriptorId;
     }
 }
diff --git a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/StubMediaRoute2ProviderService.java b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/StubMediaRoute2ProviderService.java
index 01026cd..5177e23 100644
--- a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/StubMediaRoute2ProviderService.java
+++ b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/StubMediaRoute2ProviderService.java
@@ -17,19 +17,251 @@
 package androidx.mediarouter.media;
 
 import android.content.Context;
+import android.content.IntentFilter;
+import android.util.Log;
 
+import androidx.annotation.GuardedBy;
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.collection.ArrayMap;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
 public class StubMediaRoute2ProviderService extends MediaRouteProviderService {
+    private static final Object sLock = new Object();
+
+    public static final String CLIENT_PACKAGE_NAME = "androidx.mediarouter.test";
+    public static final String CLIENT_CLASS_NAME =
+            "androidx.mediarouter.media.StubMediaRoute2ProviderService";
+    public static final String CATEGORY_TEST = "androidx.mediarouter.media.CATEGORY_TEST";
+
+    public static final String ROUTE_ID_GROUP = "route_id_group";
+    public static final String ROUTE_NAME_GROUP = "Group route name";
+    public static final int VOLUME_INITIAL_VALUE = 8;
+    public static final int VOLUME_MAX = 20;
+    public static final String MR2_ROUTE_ID1 = "media_route2_id1";
+    public static final String MR2_ROUTE_NAME1 = "MR2 Sample Route 1";
+    public static final String MR2_ROUTE_ID2 = "media_route2_id2";
+    public static final String MR2_ROUTE_NAME2 = "MR2 Sample Route 2";
+
+    @GuardedBy("sLock")
+    private static StubMediaRoute2ProviderService sInstance;
+
+    private static final List<IntentFilter> CONTROL_FILTERS_TEST;
+
+    static {
+        IntentFilter f1 = new IntentFilter();
+        f1.addCategory(CATEGORY_TEST);
+
+        CONTROL_FILTERS_TEST = new ArrayList<>();
+        CONTROL_FILTERS_TEST.add(f1);
+    }
+
+    /** Gets the instance of StubMediaRoute2ProviderService. */
+    public static StubMediaRoute2ProviderService getInstance() {
+        synchronized (sLock) {
+            return sInstance;
+        }
+    }
+
+    @Override
+    public void onCreate() {
+        super.onCreate();
+        synchronized (sLock) {
+            sInstance = this;
+        }
+    }
+
+    @Override
+    public void onDestroy() {
+        super.onDestroy();
+        synchronized (sLock) {
+            if (sInstance == this) {
+                sInstance = null;
+            }
+        }
+    }
+
     @Override
     public MediaRouteProvider onCreateMediaRouteProvider() {
         return new StubMediaRoute2Provider(this);
     }
 
     class StubMediaRoute2Provider extends MediaRouteProvider {
+        Map<String, MediaRouteDescriptor> mRoutes = new ArrayMap<>();
+        Map<String, List<StubDynamicGroupRouteController>> mDescriptorIdToControllers =
+                new ArrayMap<>();
+        private final MediaRouteDescriptor mGroupDescriptor;
+        boolean mSupportsDynamicGroup = true;
 
         StubMediaRoute2Provider(@NonNull Context context) {
             super(context);
+            mGroupDescriptor =
+                    new MediaRouteDescriptor.Builder(ROUTE_ID_GROUP, ROUTE_NAME_GROUP)
+                            .addControlFilters(CONTROL_FILTERS_TEST)
+                            .setVolumeMax(VOLUME_MAX)
+                            .setVolume(VOLUME_INITIAL_VALUE)
+                            .build();
+        }
+
+        @Override
+        public DynamicGroupRouteController onCreateDynamicGroupRouteController(
+                @NonNull String initialMemberRouteId,
+                @NonNull RouteControllerOptions routeControllerOptions) {
+            Log.i(
+                    TAG,
+                    "onCreateDynamicGroupRouteController with initialMemberRouteId = "
+                            + initialMemberRouteId);
+            StubDynamicGroupRouteController newDynamicRouteController =
+                    new StubDynamicGroupRouteController(
+                            initialMemberRouteId, routeControllerOptions);
+            addController(initialMemberRouteId, newDynamicRouteController);
+
+            return newDynamicRouteController;
+        }
+
+        public void initializeRoutes() {
+            MediaRouteDescriptor route1 =
+                    new MediaRouteDescriptor.Builder(MR2_ROUTE_ID1, MR2_ROUTE_NAME1)
+                            .addControlFilters(CONTROL_FILTERS_TEST)
+                            .build();
+            MediaRouteDescriptor route2 =
+                    new MediaRouteDescriptor.Builder(MR2_ROUTE_ID2, MR2_ROUTE_NAME2)
+                            .addControlFilters(CONTROL_FILTERS_TEST)
+                            .build();
+            mRoutes.put(route1.getId(), route1);
+            mRoutes.put(route2.getId(), route2);
+        }
+
+        public void publishRoutes() {
+            setDescriptor(
+                    new MediaRouteProviderDescriptor.Builder()
+                            .addRoutes(mRoutes.values())
+                            .setSupportsDynamicGroupRoute(mSupportsDynamicGroup)
+                            .build());
+        }
+
+        public void addController(String routeId, StubDynamicGroupRouteController controller) {
+            Log.i(TAG, "addController with routeId = " + routeId + ", controller = " + controller);
+            List<StubDynamicGroupRouteController> controllers =
+                    mDescriptorIdToControllers.get(routeId);
+            if (controllers == null) {
+                controllers = new ArrayList<>();
+            }
+            controllers.add(controller);
+            mDescriptorIdToControllers.put(routeId, controllers);
+        }
+
+        public void removeController(String routeId, StubDynamicGroupRouteController controller) {
+            Log.i(
+                    TAG,
+                    "removeController with routeId = " + routeId + ", controller = " + controller);
+            List<StubDynamicGroupRouteController> controllers =
+                    mDescriptorIdToControllers.get(routeId);
+            if (controllers == null) {
+                return;
+            }
+            if (controllers.contains(controller)) {
+                controllers.remove(controller);
+                if (controllers.isEmpty()) {
+                    mDescriptorIdToControllers.remove(routeId);
+                } else {
+                    mDescriptorIdToControllers.put(routeId, controllers);
+                }
+            }
+        }
+
+        public int getNumberOfCreatedControllers(String descriptorId) {
+            List<StubDynamicGroupRouteController> controllers =
+                    mDescriptorIdToControllers.get(descriptorId);
+            return (controllers != null) ? controllers.size() : 0;
+        }
+
+        class StubDynamicGroupRouteController extends DynamicGroupRouteController {
+            final String mRouteId;
+            final RouteControllerOptions mRouteControllerOptions;
+            private final Set<String> mCurrentSelectedRouteIds = new HashSet<>();
+
+            StubDynamicGroupRouteController(
+                    String routeId, RouteControllerOptions routeControllerOptions) {
+                mRouteId = routeId;
+                mRouteControllerOptions = routeControllerOptions;
+                mCurrentSelectedRouteIds.add(routeId);
+            }
+
+            private void publishState() {
+                Collection<DynamicRouteDescriptor> dynamicRoutes = buildDynamicRouteDescriptors();
+                Log.i(
+                        TAG,
+                        "StubDynamicGroupRouteController.publishState() with dynamicRoutes.size() ="
+                                + " "
+                                + dynamicRoutes.size());
+                notifyDynamicRoutesChanged(mGroupDescriptor, dynamicRoutes);
+            }
+
+            private Collection<DynamicGroupRouteController.DynamicRouteDescriptor>
+                    buildDynamicRouteDescriptors() {
+                ArrayList<DynamicGroupRouteController.DynamicRouteDescriptor> result =
+                        new ArrayList<>();
+                for (MediaRouteDescriptor route : mRoutes.values()) {
+                    DynamicGroupRouteController.DynamicRouteDescriptor dynamicDescriptor =
+                            new DynamicGroupRouteController.DynamicRouteDescriptor.Builder(route)
+                                    .setSelectionState(
+                                            mCurrentSelectedRouteIds.contains(route.getId())
+                                                    ? DynamicGroupRouteController
+                                                            .DynamicRouteDescriptor.SELECTED
+                                                    : DynamicGroupRouteController
+                                                            .DynamicRouteDescriptor.UNSELECTED)
+                                    .setIsUnselectable(
+                                            mCurrentSelectedRouteIds.contains(route.getId()))
+                                    .build();
+                    result.add(dynamicDescriptor);
+                }
+                return result;
+            }
+
+            @Override
+            public void onSelect() {
+                Log.i(TAG, "StubDynamicGroupRouteController.onSelect() with routeId = " + mRouteId);
+                publishState();
+            }
+
+            @Override
+            public void onRelease() {
+                Log.i(
+                        TAG,
+                        "StubDynamicGroupRouteController.onRelease() with routeId = " + mRouteId);
+                removeController(mRouteId, this);
+            }
+
+            @Override
+            public void onUpdateMemberRoutes(@Nullable List<String> routeIds) {
+                if (routeIds == null) {
+                    return;
+                }
+                Log.i(TAG, "StubDynamicGroupRouteController.onUpdateMemberRoutes()");
+            }
+
+            @Override
+            public void onAddMemberRoute(@NonNull String routeId) {
+                Log.i(
+                        TAG,
+                        "StubDynamicGroupRouteController.onAddMemberRoute with routeId = "
+                                + routeId);
+            }
+
+            @Override
+            public void onRemoveMemberRoute(@NonNull String routeId) {
+                Log.i(
+                        TAG,
+                        "StubDynamicGroupRouteController.onRemoveMemberRoute with routeId = "
+                                + routeId);
+            }
         }
     }
 }
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java
index 30d44ee..e5d6b5c 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java
@@ -101,7 +101,7 @@
 
     @VisibleForTesting
     RegisteredMediaRouteProviderWatcher mRegisteredProviderWatcher;
-    MediaRouter.RouteInfo mSelectedRoute;
+    @Nullable MediaRouter.RouteInfo mSelectedRoute;
     MediaRouteProvider.RouteController mSelectedRouteController;
     MediaRouter.OnPrepareTransferListener mOnPrepareTransferListener;
     MediaRouter.PrepareTransferNotifier mTransferNotifier;
@@ -376,7 +376,7 @@
     }
 
     @NonNull
-        /* package */ MediaRouter.RouteInfo getSelectedRoute() {
+    /* package */ MediaRouter.RouteInfo getSelectedRoute() {
         if (mSelectedRoute == null) {
             // This should never happen once the media router has been fully
             // initialized but it is good to check for the error in case there
@@ -600,7 +600,10 @@
             Log.w(TAG, "Ignoring attempt to select disabled route: " + route);
             return;
         }
-
+        if (isRouteSelected(route)) {
+            Log.w(TAG, "Ignoring attempt to select selected route: " + route);
+            return;
+        }
         // Check whether the route comes from MediaRouter2. The SDK check is required to avoid a
         // lint error but is not needed.
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
@@ -669,6 +672,25 @@
         }
     }
 
+    private boolean isRouteSelected(MediaRouter.RouteInfo route) {
+        if (mSelectedRoute == route) {
+            return true;
+        }
+        MediaRouter.GroupRouteInfo selectedGroupRoute =
+                (mSelectedRoute != null) ? mSelectedRoute.asGroup() : null;
+        if (selectedGroupRoute != null
+                && selectedGroupRoute.getSelectedRoutesInGroup().size() == 1) {
+            int selectionState = selectedGroupRoute.getSelectionState(route);
+            return selectionState
+                            == MediaRouteProvider.DynamicGroupRouteController.DynamicRouteDescriptor
+                                    .SELECTED
+                    || selectionState
+                            == MediaRouteProvider.DynamicGroupRouteController.DynamicRouteDescriptor
+                                    .SELECTING;
+        }
+        return false;
+    }
+
     private void notifyRouteConnectionFailed(
             @NonNull MediaRouter.RouteInfo route, @MediaRouter.DisconnectReason int reason) {
         mCallbackHandler.postRouteDisconnectedMessage(route, /* disconnectedRoute= */ null, reason);
@@ -1645,7 +1667,9 @@
                 // Nothing to do.
                 Log.d(
                         TAG,
-                        "A RouteController unrelated to the selected route is released."
+                        "A RouteController unrelated to the selected route ("
+                                + mSelectedRouteController
+                                + ") is released."
                                 + " controller="
                                 + controller);
             }
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRoute2Provider.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRoute2Provider.java
index 6ff3bef..f037f77 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRoute2Provider.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRoute2Provider.java
@@ -82,6 +82,7 @@
     private boolean mMediaTransferRestrictedToSelfProviders;
     private List<MediaRoute2Info> mRoutes = new ArrayList<>();
     private Map<String, String> mRouteIdToOriginalRouteIdMap = new ArrayMap<>();
+    @Nullable private String mPendingTransferRouteId;
 
     @SuppressWarnings({"SyntheticAccessor"})
     MediaRoute2Provider(@NonNull Context context, @NonNull Callback callback) {
@@ -173,6 +174,11 @@
             Log.w(TAG, "transferTo: Specified route not found. routeId=" + routeId);
             return;
         }
+        if (TextUtils.equals(mPendingTransferRouteId, routeId)) {
+            Log.w(TAG, "Ignoring attempt to transfer to pending transfer route: " + route);
+            return;
+        }
+        mPendingTransferRouteId = routeId;
         mMediaRouter2.transferTo(route);
     }
 
@@ -438,6 +444,7 @@
         @Override
         public void onTransfer(@NonNull MediaRouter2.RoutingController oldController,
                 @NonNull MediaRouter2.RoutingController newController) {
+            mPendingTransferRouteId = null;
             mControllerMap.remove(oldController);
             if (newController == mMediaRouter2.getSystemController()) {
                 mCallback.onSelectFallbackRoute(UNSELECT_REASON_ROUTE_CHANGED);
@@ -458,11 +465,13 @@
 
         @Override
         public void onTransferFailure(@NonNull MediaRoute2Info requestedRoute) {
+            mPendingTransferRouteId = null;
             Log.w(TAG, "Transfer failed. requestedRoute=" + requestedRoute);
         }
 
         @Override
         public void onStop(@NonNull MediaRouter2.RoutingController routingController) {
+            mPendingTransferRouteId = null;
             RouteController routeController = mControllerMap.remove(routingController);
             if (routeController != null) {
                 mCallback.onReleaseController(routeController);