/*
 * 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 static com.android.internal.util.function.pooled.PooledLambda.obtainMessage;

import android.Manifest;
import android.annotation.CallbackExecutor;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.RequiresPermission;
import android.annotation.SystemApi;
import android.annotation.TestApi;
import android.content.Context;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.RemoteException;
import android.os.ServiceManager;
import android.text.TextUtils;
import android.util.ArrayMap;
import android.util.ArraySet;
import android.util.Log;

import com.android.internal.annotations.GuardedBy;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

/**
 * This API is not generally intended for third party application developers. Use the
 * <a href="{@docRoot}jetpack/androidx.html">AndroidX</a>
 * <a href="{@docRoot}reference/androidx/mediarouter/media/package-summary.html">Media Router
 * Library</a> for consistent behavior across all devices.
 *
 * <p>MediaRouter2 allows applications to control the routing of media channels and streams from
 * the current device to remote speakers and devices.
 */
// TODO(b/157873330): Add method names at the beginning of log messages. (e.g. selectRoute)
//       Not only MediaRouter2, but also to service / manager / provider.
// TODO: ensure thread-safe and document it
public final class MediaRouter2 {
    private static final String TAG = "MR2";
    private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
    private static final Object sSystemRouterLock = new Object();
    private static final Object sRouterLock = new Object();

    // The maximum time for the old routing controller available after transfer.
    private static final int TRANSFER_TIMEOUT_MS = 30_000;
    // The manager request ID representing that no manager is involved.
    private static final long MANAGER_REQUEST_ID_NONE = MediaRoute2ProviderService.REQUEST_ID_NONE;

    @GuardedBy("sSystemRouterLock")
    private static Map<String, MediaRouter2> sSystemMediaRouter2Map = new ArrayMap<>();

    private static MediaRouter2Manager sManager;

    @GuardedBy("sRouterLock")
    private static MediaRouter2 sInstance;

    private final Context mContext;
    private final IMediaRouterService mMediaRouterService;
    private final Object mLock = new Object();

    private final CopyOnWriteArrayList<RouteCallbackRecord> mRouteCallbackRecords =
            new CopyOnWriteArrayList<>();
    private final CopyOnWriteArrayList<TransferCallbackRecord> mTransferCallbackRecords =
            new CopyOnWriteArrayList<>();
    private final CopyOnWriteArrayList<ControllerCallbackRecord> mControllerCallbackRecords =
            new CopyOnWriteArrayList<>();

    private final CopyOnWriteArrayList<ControllerCreationRequest> mControllerCreationRequests =
            new CopyOnWriteArrayList<>();

    // TODO: Specify the fields that are only used (or not used) by system media router.
    private final String mClientPackageName;
    final ManagerCallback mManagerCallback;

    private final String mPackageName;

    /**
     * Stores the latest copy of all routes received from the system server, without any filtering,
     * sorting, or deduplication.
     *
     * <p>Uses {@link MediaRoute2Info#getId()} to set each entry's key.
     */
    @GuardedBy("mLock")
    final Map<String, MediaRoute2Info> mRoutes = new ArrayMap<>();

    @GuardedBy("mLock")
    @Nullable
    private RouteListingPreference mRouteListingPreference;

    final RoutingController mSystemController;

    @GuardedBy("mLock")
    private RouteDiscoveryPreference mDiscoveryPreference = RouteDiscoveryPreference.EMPTY;

    // TODO: Make MediaRouter2 is always connected to the MediaRouterService.
    @GuardedBy("mLock")
    MediaRouter2Stub mStub;

    @GuardedBy("mLock")
    private final Map<String, RoutingController> mNonSystemRoutingControllers = new ArrayMap<>();

    private final AtomicInteger mNextRequestId = new AtomicInteger(1);
    private final AtomicBoolean mIsScanning = new AtomicBoolean(/* initialValue= */ false);

    final Handler mHandler;

    /**
     * Stores an auxiliary copy of {@link #mFilteredRoutes} at the time of the last route callback
     * dispatch. This is only used to determine what callback a route should be assigned to (added,
     * removed, changed) in {@link #dispatchFilteredRoutesUpdatedOnHandler(List)}.
     */
    private volatile ArrayMap<String, MediaRoute2Info> mPreviousRoutes = new ArrayMap<>();

    /**
     * Stores the latest copy of exposed routes after filtering, sorting, and deduplication. Can be
     * accessed through {@link #getRoutes()}.
     *
     * <p>This list is a copy of {@link #mRoutes} which has undergone filtering, sorting, and
     * deduplication using criteria in {@link #mDiscoveryPreference}.
     *
     * @see #filterRoutesWithCompositePreferenceLocked(List)
     */
    private volatile List<MediaRoute2Info> mFilteredRoutes = Collections.emptyList();
    private volatile OnGetControllerHintsListener mOnGetControllerHintsListener;

    /** Gets an instance of the media router associated with the context. */
    @NonNull
    public static MediaRouter2 getInstance(@NonNull Context context) {
        Objects.requireNonNull(context, "context must not be null");
        synchronized (sRouterLock) {
            if (sInstance == null) {
                sInstance = new MediaRouter2(context.getApplicationContext());
            }
            return sInstance;
        }
    }

    /**
     * Gets an instance of the system media router which controls the app's media routing. Returns
     * {@code null} if the given package name is invalid. There are several things to note when
     * using the media routers created with this method.
     *
     * <p>First of all, the discovery preference passed to {@link #registerRouteCallback} will have
     * no effect. The callback will be called accordingly with the client app's discovery
     * preference. Therefore, it is recommended to pass {@link RouteDiscoveryPreference#EMPTY}
     * there.
     *
     * <p>Also, do not keep/compare the instances of the {@link RoutingController}, since they are
     * always newly created with the latest session information whenever below methods are called:
     *
     * <ul>
     *   <li>{@link #getControllers()}
     *   <li>{@link #getController(String)}}
     *   <li>{@link TransferCallback#onTransfer(RoutingController, RoutingController)}
     *   <li>{@link TransferCallback#onStop(RoutingController)}
     *   <li>{@link ControllerCallback#onControllerUpdated(RoutingController)}
     * </ul>
     *
     * Therefore, in order to track the current routing status, keep the controller's ID instead,
     * and use {@link #getController(String)} and {@link #getSystemController()} for getting
     * controllers.
     *
     * <p>Finally, it will have no effect to call {@link #setOnGetControllerHintsListener}.
     *
     * @param clientPackageName the package name of the app to control
     * @throws SecurityException if the caller doesn't have MODIFY_AUDIO_ROUTING permission.
     * @hide
     */
    @SystemApi
    @RequiresPermission(Manifest.permission.MEDIA_CONTENT_CONTROL)
    @Nullable
    public static MediaRouter2 getInstance(
            @NonNull Context context, @NonNull String clientPackageName) {
        Objects.requireNonNull(context, "context must not be null");
        Objects.requireNonNull(clientPackageName, "clientPackageName must not be null");

        // Note: Even though this check could be somehow bypassed, the other permission checks
        // in system server will not allow MediaRouter2Manager to be registered.
        IMediaRouterService serviceBinder =
                IMediaRouterService.Stub.asInterface(
                        ServiceManager.getService(Context.MEDIA_ROUTER_SERVICE));
        try {
            // verifyPackageExists throws SecurityException if the caller doesn't hold
            // MEDIA_CONTENT_CONTROL permission.
            if (!serviceBinder.verifyPackageExists(clientPackageName)) {
                Log.e(TAG, "Package " + clientPackageName + " not found. Ignoring.");
                return null;
            }
        } catch (RemoteException e) {
            e.rethrowFromSystemServer();
        }

        synchronized (sSystemRouterLock) {
            MediaRouter2 instance = sSystemMediaRouter2Map.get(clientPackageName);
            if (instance == null) {
                if (sManager == null) {
                    sManager = MediaRouter2Manager.getInstance(context.getApplicationContext());
                }
                instance = new MediaRouter2(context, clientPackageName);
                sSystemMediaRouter2Map.put(clientPackageName, instance);
                // Using direct executor here, since MediaRouter2Manager also posts
                // to the main handler.
                sManager.registerCallback(Runnable::run, instance.mManagerCallback);
            }
            return instance;
        }
    }

    /**
     * Starts scanning remote routes.
     *
     * <p>Route discovery can happen even when the {@link #startScan()} is not called. This is
     * because the scanning could be started before by other apps. Therefore, calling this method
     * after calling {@link #stopScan()} does not necessarily mean that the routes found before are
     * removed and added again.
     *
     * <p>Use {@link RouteCallback} to get the route related events.
     *
     * <p>Note that calling start/stopScan is applied to all system routers in the same process.
     *
     * <p>This will be no-op for non-system media routers.
     *
     * @see #stopScan()
     * @see #getInstance(Context, String)
     * @hide
     */
    @SystemApi
    @RequiresPermission(Manifest.permission.MEDIA_CONTENT_CONTROL)
    public void startScan() {
        if (isSystemRouter()) {
            if (!mIsScanning.getAndSet(true)) {
                sManager.registerScanRequest();
            }
        }
    }

    /**
     * Stops scanning remote routes to reduce resource consumption.
     *
     * <p>Route discovery can be continued even after this method is called. This is because the
     * scanning is only turned off when all the apps stop scanning. Therefore, calling this method
     * does not necessarily mean the routes are removed. Also, for the same reason it does not mean
     * that {@link RouteCallback#onRoutesAdded(List)} is not called afterwards.
     *
     * <p>Use {@link RouteCallback} to get the route related events.
     *
     * <p>Note that calling start/stopScan is applied to all system routers in the same process.
     *
     * <p>This will be no-op for non-system media routers.
     *
     * @see #startScan()
     * @see #getInstance(Context, String)
     * @hide
     */
    @SystemApi
    @RequiresPermission(Manifest.permission.MEDIA_CONTENT_CONTROL)
    public void stopScan() {
        if (isSystemRouter()) {
            if (mIsScanning.getAndSet(false)) {
                sManager.unregisterScanRequest();
            }
        }
    }

    private MediaRouter2(Context appContext) {
        mContext = appContext;
        mMediaRouterService =
                IMediaRouterService.Stub.asInterface(
                        ServiceManager.getService(Context.MEDIA_ROUTER_SERVICE));
        mPackageName = mContext.getPackageName();
        mHandler = new Handler(Looper.getMainLooper());

        List<MediaRoute2Info> currentSystemRoutes = null;
        RoutingSessionInfo currentSystemSessionInfo = null;
        try {
            currentSystemRoutes = mMediaRouterService.getSystemRoutes();
            currentSystemSessionInfo = mMediaRouterService.getSystemSessionInfo();
        } catch (RemoteException ex) {
            ex.rethrowFromSystemServer();
        }

        if (currentSystemRoutes == null || currentSystemRoutes.isEmpty()) {
            throw new RuntimeException("Null or empty currentSystemRoutes. Something is wrong.");
        }

        if (currentSystemSessionInfo == null) {
            throw new RuntimeException("Null currentSystemSessionInfo. Something is wrong.");
        }

        for (MediaRoute2Info route : currentSystemRoutes) {
            mRoutes.put(route.getId(), route);
        }
        mSystemController = new SystemRoutingController(currentSystemSessionInfo);

        // Only used by system MediaRouter2.
        mClientPackageName = null;
        mManagerCallback = null;
    }

    private MediaRouter2(Context context, String clientPackageName) {
        mContext = context;
        mClientPackageName = clientPackageName;
        mManagerCallback = new ManagerCallback();
        mHandler = new Handler(Looper.getMainLooper());
        mSystemController =
                new SystemRoutingController(
                        ensureClientPackageNameForSystemSession(
                                sManager.getSystemRoutingSession(clientPackageName)));
        mDiscoveryPreference = sManager.getDiscoveryPreference(clientPackageName);
        updateAllRoutesFromManager();

        // Only used by non-system MediaRouter2.
        mMediaRouterService = null;
        mPackageName = null;
    }

    /**
     * Returns whether any route in {@code routeList} has a same unique ID with given route.
     *
     * @hide
     */
    static boolean checkRouteListContainsRouteId(
            @NonNull List<MediaRoute2Info> routeList, @NonNull String routeId) {
        for (MediaRoute2Info info : routeList) {
            if (TextUtils.equals(routeId, info.getId())) {
                return true;
            }
        }
        return false;
    }

    /**
     * Gets the client package name of the app which this media router controls.
     *
     * <p>This will return null for non-system media routers.
     *
     * @see #getInstance(Context, String)
     * @hide
     */
    @SystemApi
    @Nullable
    public String getClientPackageName() {
        return mClientPackageName;
    }

    /**
     * Registers a callback to discover routes and to receive events when they change.
     *
     * <p>If the specified callback is already registered, its registration will be updated for the
     * given {@link Executor executor} and {@link RouteDiscoveryPreference discovery preference}.
     */
    public void registerRouteCallback(
            @NonNull @CallbackExecutor Executor executor,
            @NonNull RouteCallback routeCallback,
            @NonNull RouteDiscoveryPreference preference) {
        Objects.requireNonNull(executor, "executor must not be null");
        Objects.requireNonNull(routeCallback, "callback must not be null");
        Objects.requireNonNull(preference, "preference must not be null");
        if (isSystemRouter()) {
            preference = RouteDiscoveryPreference.EMPTY;
        }

        RouteCallbackRecord record = new RouteCallbackRecord(executor, routeCallback, preference);

        mRouteCallbackRecords.remove(record);
        // It can fail to add the callback record if another registration with the same callback
        // is happening but it's okay because either this or the other registration should be done.
        mRouteCallbackRecords.addIfAbsent(record);

        if (isSystemRouter()) {
            return;
        }

        synchronized (mLock) {
            if (mStub == null) {
                MediaRouter2Stub stub = new MediaRouter2Stub();
                try {
                    mMediaRouterService.registerRouter2(stub, mPackageName);
                    mStub = stub;
                } catch (RemoteException ex) {
                    ex.rethrowFromSystemServer();
                }
            }
            if (mStub != null && updateDiscoveryPreferenceIfNeededLocked()) {
                try {
                    mMediaRouterService.setDiscoveryRequestWithRouter2(mStub, mDiscoveryPreference);
                } catch (RemoteException ex) {
                    ex.rethrowFromSystemServer();
                }
            }
        }
    }

    /**
     * Unregisters 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 routeCallback the callback to unregister
     * @see #registerRouteCallback
     */
    public void unregisterRouteCallback(@NonNull RouteCallback routeCallback) {
        Objects.requireNonNull(routeCallback, "callback must not be null");

        if (!mRouteCallbackRecords.remove(new RouteCallbackRecord(null, routeCallback, null))) {
            Log.w(TAG, "unregisterRouteCallback: Ignoring unknown callback");
            return;
        }

        if (isSystemRouter()) {
            return;
        }

        synchronized (mLock) {
            if (mStub == null) {
                return;
            }
            if (updateDiscoveryPreferenceIfNeededLocked()) {
                try {
                    mMediaRouterService.setDiscoveryRequestWithRouter2(mStub, mDiscoveryPreference);
                } catch (RemoteException ex) {
                    Log.e(TAG, "unregisterRouteCallback: Unable to set discovery request.", ex);
                }
            }
            if (mRouteCallbackRecords.isEmpty() && mNonSystemRoutingControllers.isEmpty()) {
                try {
                    mMediaRouterService.unregisterRouter2(mStub);
                } catch (RemoteException ex) {
                    ex.rethrowFromSystemServer();
                }
                mStub = null;
            }
        }
    }

    /**
     * Shows the system output switcher dialog.
     *
     * <p>Should only be called when the context of MediaRouter2 is in the foreground and visible on
     * the screen.
     *
     * <p>The appearance and precise behaviour of the system output switcher dialog may vary across
     * different devices, OS versions, and form factors, but the basic functionality stays the same.
     *
     * <p>See <a
     * href="https://developer.android.com/guide/topics/media/media-routing#output-switcher">Output
     * Switcher documentation</a> for more details.
     *
     * @return {@code true} if the output switcher dialog is being shown, or {@code false} if the
     * call is ignored because the app is in the background.
     */
    public boolean showSystemOutputSwitcher() {
        synchronized (mLock) {
            try {
                return mMediaRouterService.showMediaOutputSwitcher(mPackageName);
            } catch (RemoteException ex) {
                ex.rethrowFromSystemServer();
            }
        }
        return false;
    }

    /**
     * Sets the {@link RouteListingPreference} of the app associated to this media router.
     *
     * <p>Use this method to inform the system UI of the routes that you would like to list for
     * media routing, via the Output Switcher.
     *
     * <p>You should call this method before {@link #registerRouteCallback registering any route
     * callbacks} and immediately after receiving any {@link RouteCallback#onRoutesUpdated route
     * updates} in order to keep the system UI in a consistent state. You can also call this method
     * at any other point to update the listing preference dynamically.
     *
     * <p>Notes:
     *
     * <ol>
     *   <li>You should not include the ids of two or more routes with a match in their {@link
     *       MediaRoute2Info#getDeduplicationIds() deduplication ids}. If you do, the system will
     *       deduplicate them using its own criteria.
     *   <li>You can use this method to rank routes in the output switcher, placing the more
     *       important routes first. The system might override the proposed ranking.
     *   <li>You can use this method to avoid listing routes using dynamic criteria. For example,
     *       you can limit access to a specific type of device according to runtime criteria.
     * </ol>
     *
     * @param routeListingPreference The {@link RouteListingPreference} for the system to use for
     *     route listing. When null, the system uses its default listing criteria.
     */
    public void setRouteListingPreference(@Nullable RouteListingPreference routeListingPreference) {
        synchronized (mLock) {
            if (Objects.equals(mRouteListingPreference, routeListingPreference)) {
                // Nothing changed. We return early to save a call to the system server.
                return;
            }
            mRouteListingPreference = routeListingPreference;
            try {
                if (mStub == null) {
                    MediaRouter2Stub stub = new MediaRouter2Stub();
                    mMediaRouterService.registerRouter2(stub, mPackageName);
                    mStub = stub;
                }
                mMediaRouterService.setRouteListingPreference(mStub, mRouteListingPreference);
            } catch (RemoteException ex) {
                ex.rethrowFromSystemServer();
            }
        }
    }

    @GuardedBy("mLock")
    private boolean updateDiscoveryPreferenceIfNeededLocked() {
        RouteDiscoveryPreference newDiscoveryPreference = new RouteDiscoveryPreference.Builder(
                mRouteCallbackRecords.stream().map(record -> record.mPreference).collect(
                        Collectors.toList())).build();

        if (Objects.equals(mDiscoveryPreference, newDiscoveryPreference)) {
            return false;
        }
        mDiscoveryPreference = newDiscoveryPreference;
        updateFilteredRoutesLocked();
        return true;
    }

    /**
     * Gets the list of all discovered routes. This list includes the routes that are not related to
     * the client app.
     *
     * <p>This will return an empty list for non-system media routers.
     *
     * @hide
     */
    @SystemApi
    @NonNull
    public List<MediaRoute2Info> getAllRoutes() {
        if (isSystemRouter()) {
            return sManager.getAllRoutes();
        }
        return Collections.emptyList();
    }

    /**
     * Gets the unmodifiable list of {@link MediaRoute2Info routes} currently known to the media
     * router.
     *
     * <p>Please note that the list can be changed before callbacks are invoked.
     *
     * @return the list of routes that contains at least one of the route features in discovery
     *     preferences registered by the application
     */
    @NonNull
    public List<MediaRoute2Info> getRoutes() {
        synchronized (mLock) {
            return mFilteredRoutes;
        }
    }

    /**
     * Registers a callback to get the result of {@link #transferTo(MediaRoute2Info)}.
     * If you register the same callback twice or more, it will be ignored.
     *
     * @param executor the executor to execute the callback on
     * @param callback the callback to register
     * @see #unregisterTransferCallback
     */
    public void registerTransferCallback(
            @NonNull @CallbackExecutor Executor executor, @NonNull TransferCallback callback) {
        Objects.requireNonNull(executor, "executor must not be null");
        Objects.requireNonNull(callback, "callback must not be null");

        TransferCallbackRecord record = new TransferCallbackRecord(executor, callback);
        if (!mTransferCallbackRecords.addIfAbsent(record)) {
            Log.w(TAG, "registerTransferCallback: Ignoring the same callback");
            return;
        }
    }

    /**
     * Unregisters 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 unregister
     * @see #registerTransferCallback
     */
    public void unregisterTransferCallback(@NonNull TransferCallback callback) {
        Objects.requireNonNull(callback, "callback must not be null");

        if (!mTransferCallbackRecords.remove(new TransferCallbackRecord(null, callback))) {
            Log.w(TAG, "unregisterTransferCallback: Ignoring an unknown callback");
            return;
        }
    }

    /**
     * Registers a {@link ControllerCallback}. If you register the same callback twice or more, it
     * will be ignored.
     *
     * @see #unregisterControllerCallback(ControllerCallback)
     */
    public void registerControllerCallback(
            @NonNull @CallbackExecutor Executor executor, @NonNull ControllerCallback callback) {
        Objects.requireNonNull(executor, "executor must not be null");
        Objects.requireNonNull(callback, "callback must not be null");

        ControllerCallbackRecord record = new ControllerCallbackRecord(executor, callback);
        if (!mControllerCallbackRecords.addIfAbsent(record)) {
            Log.w(TAG, "registerControllerCallback: Ignoring the same callback");
            return;
        }
    }

    /**
     * Unregisters a {@link ControllerCallback}. The callback will no longer receive events.
     * If the callback has not been added or been removed already, it is ignored.
     *
     * @see #registerControllerCallback(Executor, ControllerCallback)
     */
    public void unregisterControllerCallback(@NonNull ControllerCallback callback) {
        Objects.requireNonNull(callback, "callback must not be null");

        if (!mControllerCallbackRecords.remove(new ControllerCallbackRecord(null, callback))) {
            Log.w(TAG, "unregisterControllerCallback: Ignoring an unknown callback");
            return;
        }
    }

    /**
     * Sets an {@link OnGetControllerHintsListener} to send hints when creating a
     * {@link RoutingController}. To send the hints, listener should be set <em>BEFORE</em> calling
     * {@link #transferTo(MediaRoute2Info)}.
     *
     * @param listener A listener to send optional app-specific hints when creating a controller.
     *     {@code null} for unset.
     */
    public void setOnGetControllerHintsListener(@Nullable OnGetControllerHintsListener listener) {
        if (isSystemRouter()) {
            return;
        }
        mOnGetControllerHintsListener = listener;
    }

    /**
     * Transfers the current media to the given route. If it's necessary a new
     * {@link RoutingController} is created or it is handled within the current routing controller.
     *
     * @param route the route you want to transfer the current media to. Pass {@code null} to
     *              stop routing of the current media.
     * @see TransferCallback#onTransfer
     * @see TransferCallback#onTransferFailure
     */
    public void transferTo(@NonNull MediaRoute2Info route) {
        if (isSystemRouter()) {
            sManager.transfer(mClientPackageName, route);
            return;
        }

        Log.v(TAG, "Transferring to route: " + route);

        boolean routeFound;
        synchronized (mLock) {
            // TODO: Check thread-safety
            routeFound = mRoutes.containsKey(route.getId());
        }
        if (!routeFound) {
            notifyTransferFailure(route);
            return;
        }

        RoutingController controller = getCurrentController();
        if (controller.getRoutingSessionInfo().getTransferableRoutes().contains(route.getId())) {
            controller.transferToRoute(route);
            return;
        }

        requestCreateController(controller, route, MANAGER_REQUEST_ID_NONE);
    }

    /**
     * Stops the current media routing. If the {@link #getSystemController() system controller}
     * controls the media routing, this method is a no-op.
     */
    public void stop() {
        if (isSystemRouter()) {
            List<RoutingSessionInfo> sessionInfos = sManager.getRoutingSessions(mClientPackageName);
            RoutingSessionInfo sessionToRelease = sessionInfos.get(sessionInfos.size() - 1);
            sManager.releaseSession(sessionToRelease);
            return;
        }
        getCurrentController().release();
    }

    /**
     * Transfers the media of a routing controller to the given route.
     *
     * <p>This will be no-op for non-system media routers.
     *
     * @param controller a routing controller controlling media routing.
     * @param route the route you want to transfer the media to.
     * @hide
     */
    @SystemApi
    @RequiresPermission(Manifest.permission.MEDIA_CONTENT_CONTROL)
    public void transfer(@NonNull RoutingController controller, @NonNull MediaRoute2Info route) {
        if (isSystemRouter()) {
            sManager.transfer(controller.getRoutingSessionInfo(), route);
            return;
        }
    }

    void requestCreateController(
            @NonNull RoutingController controller,
            @NonNull MediaRoute2Info route,
            long managerRequestId) {

        final int requestId = mNextRequestId.getAndIncrement();

        ControllerCreationRequest request =
                new ControllerCreationRequest(requestId, managerRequestId, route, controller);
        mControllerCreationRequests.add(request);

        OnGetControllerHintsListener listener = mOnGetControllerHintsListener;
        Bundle controllerHints = null;
        if (listener != null) {
            controllerHints = listener.onGetControllerHints(route);
            if (controllerHints != null) {
                controllerHints = new Bundle(controllerHints);
            }
        }

        MediaRouter2Stub stub;
        synchronized (mLock) {
            stub = mStub;
        }
        if (stub != null) {
            try {
                mMediaRouterService.requestCreateSessionWithRouter2(
                        stub,
                        requestId,
                        managerRequestId,
                        controller.getRoutingSessionInfo(),
                        route,
                        controllerHints);
            } catch (RemoteException ex) {
                Log.e(TAG, "createControllerForTransfer: "
                                + "Failed to request for creating a controller.", ex);
                mControllerCreationRequests.remove(request);
                if (managerRequestId == MANAGER_REQUEST_ID_NONE) {
                    notifyTransferFailure(route);
                }
            }
        }
    }

    @NonNull
    private RoutingController getCurrentController() {
        List<RoutingController> controllers = getControllers();
        return controllers.get(controllers.size() - 1);
    }

    /**
     * Gets a {@link RoutingController} which can control the routes provided by system.
     * e.g. Phone speaker, wired headset, Bluetooth, etc.
     *
     * <p>Note: The system controller can't be released. Calling {@link RoutingController#release()}
     * will be ignored.
     *
     * <p>This method always returns the same instance.
     */
    @NonNull
    public RoutingController getSystemController() {
        return mSystemController;
    }

    /**
     * Gets a {@link RoutingController} whose ID is equal to the given ID.
     * Returns {@code null} if there is no matching controller.
     */
    @Nullable
    public RoutingController getController(@NonNull String id) {
        Objects.requireNonNull(id, "id must not be null");
        for (RoutingController controller : getControllers()) {
            if (TextUtils.equals(id, controller.getId())) {
                return controller;
            }
        }
        return null;
    }

    /**
     * Gets the list of currently active {@link RoutingController routing controllers} on which
     * media can be played.
     *
     * <p>Note: The list returned here will never be empty. The first element in the list is
     * always the {@link #getSystemController() system controller}.
     */
    @NonNull
    public List<RoutingController> getControllers() {
        List<RoutingController> result = new ArrayList<>();

        if (isSystemRouter()) {
            // Unlike non-system MediaRouter2, controller instances cannot be kept,
            // since the transfer events initiated from other apps will not come through manager.
            List<RoutingSessionInfo> sessions = sManager.getRoutingSessions(mClientPackageName);
            for (RoutingSessionInfo session : sessions) {
                RoutingController controller;
                if (session.isSystemSession()) {
                    mSystemController.setRoutingSessionInfo(
                            ensureClientPackageNameForSystemSession(session));
                    controller = mSystemController;
                } else {
                    controller = new RoutingController(session);
                }
                result.add(controller);
            }
            return result;
        }

        result.add(0, mSystemController);
        synchronized (mLock) {
            result.addAll(mNonSystemRoutingControllers.values());
        }
        return result;
    }

    /**
     * Requests a volume change for the route asynchronously.
     * It may have no effect if the route is currently not selected.
     *
     * <p>This will be no-op for non-system media routers.
     *
     * @param volume The new volume value between 0 and {@link MediaRoute2Info#getVolumeMax}.
     * @see #getInstance(Context, String)
     * @hide
     */
    @SystemApi
    @RequiresPermission(Manifest.permission.MEDIA_CONTENT_CONTROL)
    public void setRouteVolume(@NonNull MediaRoute2Info route, int volume) {
        Objects.requireNonNull(route, "route must not be null");

        if (isSystemRouter()) {
            sManager.setRouteVolume(route, volume);
            return;
        }
        // If this API needs to be public, use IMediaRouterService#setRouteVolumeWithRouter2()
    }

    void syncRoutesOnHandler(
            List<MediaRoute2Info> currentRoutes, RoutingSessionInfo currentSystemSessionInfo) {
        if (currentRoutes == null || currentRoutes.isEmpty() || currentSystemSessionInfo == null) {
            Log.e(TAG, "syncRoutesOnHandler: Received wrong data. currentRoutes=" + currentRoutes
                    + ", currentSystemSessionInfo=" + currentSystemSessionInfo);
            return;
        }

        synchronized (mLock) {
            mRoutes.clear();
            for (MediaRoute2Info route : currentRoutes) {
                mRoutes.put(route.getId(), route);
            }
            updateFilteredRoutesLocked();
        }

        RoutingSessionInfo oldInfo = mSystemController.getRoutingSessionInfo();
        mSystemController.setRoutingSessionInfo(currentSystemSessionInfo);
        if (!oldInfo.equals(currentSystemSessionInfo)) {
            notifyControllerUpdated(mSystemController);
        }
    }

    void dispatchFilteredRoutesUpdatedOnHandler(List<MediaRoute2Info> newRoutes) {
        List<MediaRoute2Info> addedRoutes = new ArrayList<>();
        List<MediaRoute2Info> removedRoutes = new ArrayList<>();
        List<MediaRoute2Info> changedRoutes = new ArrayList<>();

        Set<String> newRouteIds =
                newRoutes.stream().map(MediaRoute2Info::getId).collect(Collectors.toSet());

        for (MediaRoute2Info route : newRoutes) {
            MediaRoute2Info prevRoute = mPreviousRoutes.get(route.getId());
            if (prevRoute == null) {
                addedRoutes.add(route);
            } else if (!prevRoute.equals(route)) {
                changedRoutes.add(route);
            }
        }

        for (int i = 0; i < mPreviousRoutes.size(); i++) {
            if (!newRouteIds.contains(mPreviousRoutes.keyAt(i))) {
                removedRoutes.add(mPreviousRoutes.valueAt(i));
            }
        }

        // update previous routes
        for (MediaRoute2Info route : removedRoutes) {
            mPreviousRoutes.remove(route.getId());
        }
        for (MediaRoute2Info route : addedRoutes) {
            mPreviousRoutes.put(route.getId(), route);
        }
        for (MediaRoute2Info route : changedRoutes) {
            mPreviousRoutes.put(route.getId(), route);
        }

        if (!addedRoutes.isEmpty()) {
            notifyRoutesAdded(addedRoutes);
        }
        if (!removedRoutes.isEmpty()) {
            notifyRoutesRemoved(removedRoutes);
        }
        if (!changedRoutes.isEmpty()) {
            notifyRoutesChanged(changedRoutes);
        }

        // Note: We don't notify clients of changes in route ordering.
        if (!addedRoutes.isEmpty() || !removedRoutes.isEmpty() || !changedRoutes.isEmpty()) {
            notifyRoutesUpdated(newRoutes);
        }
    }

    void updateRoutesOnHandler(List<MediaRoute2Info> routes) {
        synchronized (mLock) {
            mRoutes.clear();
            for (MediaRoute2Info route : routes) {
                mRoutes.put(route.getId(), route);
            }
            updateFilteredRoutesLocked();
        }
    }

    /** Updates filtered routes and dispatch callbacks */
    @GuardedBy("mLock")
    void updateFilteredRoutesLocked() {
        mFilteredRoutes =
                Collections.unmodifiableList(
                        filterRoutesWithCompositePreferenceLocked(List.copyOf(mRoutes.values())));
        mHandler.sendMessage(
                obtainMessage(
                        MediaRouter2::dispatchFilteredRoutesUpdatedOnHandler,
                        this,
                        mFilteredRoutes));
    }

    /**
     * Creates a controller and calls the {@link TransferCallback#onTransfer}. If the controller
     * creation has failed, then it calls {@link TransferCallback#onTransferFailure}.
     *
     * <p>Pass {@code null} to sessionInfo for the failure case.
     */
    void createControllerOnHandler(int requestId, @Nullable RoutingSessionInfo sessionInfo) {
        ControllerCreationRequest matchingRequest = null;
        for (ControllerCreationRequest request : mControllerCreationRequests) {
            if (request.mRequestId == requestId) {
                matchingRequest = request;
                break;
            }
        }

        if (matchingRequest == null) {
            Log.w(TAG, "createControllerOnHandler: Ignoring an unknown request.");
            return;
        }

        mControllerCreationRequests.remove(matchingRequest);
        MediaRoute2Info requestedRoute = matchingRequest.mRoute;

        // TODO: Notify the reason for failure.
        if (sessionInfo == null) {
            notifyTransferFailure(requestedRoute);
            return;
        } else if (!TextUtils.equals(requestedRoute.getProviderId(), sessionInfo.getProviderId())) {
            Log.w(
                    TAG,
                    "The session's provider ID does not match the requested route's. "
                            + "(requested route's providerId="
                            + requestedRoute.getProviderId()
                            + ", actual providerId="
                            + sessionInfo.getProviderId()
                            + ")");
            notifyTransferFailure(requestedRoute);
            return;
        }

        RoutingController oldController = matchingRequest.mOldController;
        // When the old controller is released before transferred, treat it as a failure.
        // This could also happen when transfer is requested twice or more.
        if (!oldController.scheduleRelease()) {
            Log.w(
                    TAG,
                    "createControllerOnHandler: "
                            + "Ignoring controller creation for released old controller. "
                            + "oldController="
                            + oldController);
            if (!sessionInfo.isSystemSession()) {
                new RoutingController(sessionInfo).release();
            }
            notifyTransferFailure(requestedRoute);
            return;
        }

        RoutingController newController;
        if (sessionInfo.isSystemSession()) {
            newController = getSystemController();
            newController.setRoutingSessionInfo(sessionInfo);
        } else {
            newController = new RoutingController(sessionInfo);
            synchronized (mLock) {
                mNonSystemRoutingControllers.put(newController.getId(), newController);
            }
        }

        notifyTransfer(oldController, newController);
    }

    void updateControllerOnHandler(RoutingSessionInfo sessionInfo) {
        if (sessionInfo == null) {
            Log.w(TAG, "updateControllerOnHandler: Ignoring null sessionInfo.");
            return;
        }

        if (sessionInfo.isSystemSession()) {
            // The session info is sent from SystemMediaRoute2Provider.
            RoutingController systemController = getSystemController();
            systemController.setRoutingSessionInfo(sessionInfo);
            notifyControllerUpdated(systemController);
            return;
        }

        RoutingController matchingController;
        synchronized (mLock) {
            matchingController = mNonSystemRoutingControllers.get(sessionInfo.getId());
        }

        if (matchingController == null) {
            Log.w(
                    TAG,
                    "updateControllerOnHandler: Matching controller not found. uniqueSessionId="
                            + sessionInfo.getId());
            return;
        }

        RoutingSessionInfo oldInfo = matchingController.getRoutingSessionInfo();
        if (!TextUtils.equals(oldInfo.getProviderId(), sessionInfo.getProviderId())) {
            Log.w(
                    TAG,
                    "updateControllerOnHandler: Provider IDs are not matched. old="
                            + oldInfo.getProviderId()
                            + ", new="
                            + sessionInfo.getProviderId());
            return;
        }

        matchingController.setRoutingSessionInfo(sessionInfo);
        notifyControllerUpdated(matchingController);
    }

    void releaseControllerOnHandler(RoutingSessionInfo sessionInfo) {
        if (sessionInfo == null) {
            Log.w(TAG, "releaseControllerOnHandler: Ignoring null sessionInfo.");
            return;
        }

        RoutingController matchingController;
        synchronized (mLock) {
            matchingController = mNonSystemRoutingControllers.get(sessionInfo.getId());
        }

        if (matchingController == null) {
            if (DEBUG) {
                Log.d(
                        TAG,
                        "releaseControllerOnHandler: Matching controller not found. "
                                + "uniqueSessionId="
                                + sessionInfo.getId());
            }
            return;
        }

        RoutingSessionInfo oldInfo = matchingController.getRoutingSessionInfo();
        if (!TextUtils.equals(oldInfo.getProviderId(), sessionInfo.getProviderId())) {
            Log.w(
                    TAG,
                    "releaseControllerOnHandler: Provider IDs are not matched. old="
                            + oldInfo.getProviderId()
                            + ", new="
                            + sessionInfo.getProviderId());
            return;
        }

        matchingController.releaseInternal(/* shouldReleaseSession= */ false);
    }

    void onRequestCreateControllerByManagerOnHandler(
            RoutingSessionInfo oldSession, MediaRoute2Info route, long managerRequestId) {
        RoutingController controller;
        if (oldSession.isSystemSession()) {
            controller = getSystemController();
        } else {
            synchronized (mLock) {
                controller = mNonSystemRoutingControllers.get(oldSession.getId());
            }
        }
        if (controller == null) {
            return;
        }
        requestCreateController(controller, route, managerRequestId);
    }

    /**
     * Returns whether this router is created with {@link #getInstance(Context, String)}. This kind
     * of router can control the target app's media routing.
     */
    private boolean isSystemRouter() {
        return mClientPackageName != null;
    }

    /**
     * Returns a {@link RoutingSessionInfo} which has the client package name. The client package
     * name is set only when the given sessionInfo doesn't have it. Should only used for system
     * media routers.
     */
    private RoutingSessionInfo ensureClientPackageNameForSystemSession(
            @NonNull RoutingSessionInfo sessionInfo) {
        if (!sessionInfo.isSystemSession()
                || !TextUtils.isEmpty(sessionInfo.getClientPackageName())) {
            return sessionInfo;
        }

        return new RoutingSessionInfo.Builder(sessionInfo)
                .setClientPackageName(mClientPackageName)
                .build();
    }

    private List<MediaRoute2Info> getSortedRoutes(
            List<MediaRoute2Info> routes, List<String> packageOrder) {
        if (packageOrder.isEmpty()) {
            return routes;
        }
        Map<String, Integer> packagePriority = new ArrayMap<>();
        int count = packageOrder.size();
        for (int i = 0; i < count; i++) {
            // the last package will have 1 as the priority
            packagePriority.put(packageOrder.get(i), count - i);
        }
        ArrayList<MediaRoute2Info> sortedRoutes = new ArrayList<>(routes);
        // take the negative for descending order
        sortedRoutes.sort(
                Comparator.comparingInt(r -> -packagePriority.getOrDefault(r.getPackageName(), 0)));
        return sortedRoutes;
    }

    @GuardedBy("mLock")
    private List<MediaRoute2Info> filterRoutesWithCompositePreferenceLocked(
            List<MediaRoute2Info> routes) {

        Set<String> deduplicationIdSet = new ArraySet<>();

        List<MediaRoute2Info> filteredRoutes = new ArrayList<>();
        for (MediaRoute2Info route :
                getSortedRoutes(routes, mDiscoveryPreference.getDeduplicationPackageOrder())) {
            if (!route.hasAnyFeatures(mDiscoveryPreference.getPreferredFeatures())) {
                continue;
            }
            if (!mDiscoveryPreference.getAllowedPackages().isEmpty()
                    && (route.getPackageName() == null
                            || !mDiscoveryPreference
                                    .getAllowedPackages()
                                    .contains(route.getPackageName()))) {
                continue;
            }
            if (mDiscoveryPreference.shouldRemoveDuplicates()) {
                if (!Collections.disjoint(deduplicationIdSet, route.getDeduplicationIds())) {
                    continue;
                }
                deduplicationIdSet.addAll(route.getDeduplicationIds());
            }
            filteredRoutes.add(route);
        }
        return filteredRoutes;
    }

    private List<MediaRoute2Info> filterRoutesWithIndividualPreference(
            List<MediaRoute2Info> routes, RouteDiscoveryPreference discoveryPreference) {
        List<MediaRoute2Info> filteredRoutes = new ArrayList<>();
        if (isSystemRouter()) {
            // Individual discovery preferences do not apply for the system router.
            filteredRoutes.addAll(routes);
            return filteredRoutes;
        }
        for (MediaRoute2Info route : routes) {
            if (!route.hasAnyFeatures(discoveryPreference.getPreferredFeatures())) {
                continue;
            }
            if (!discoveryPreference.getAllowedPackages().isEmpty()
                    && (route.getPackageName() == null
                            || !discoveryPreference
                                    .getAllowedPackages()
                                    .contains(route.getPackageName()))) {
                continue;
            }
            filteredRoutes.add(route);
        }
        return filteredRoutes;
    }

    private void updateAllRoutesFromManager() {
        if (!isSystemRouter()) {
            return;
        }
        synchronized (mLock) {
            mRoutes.clear();
            for (MediaRoute2Info route : sManager.getAllRoutes()) {
                mRoutes.put(route.getId(), route);
            }
            updateFilteredRoutesLocked();
        }
    }

    private void notifyRoutesAdded(List<MediaRoute2Info> routes) {
        for (RouteCallbackRecord record : mRouteCallbackRecords) {
            List<MediaRoute2Info> filteredRoutes =
                    filterRoutesWithIndividualPreference(routes, record.mPreference);
            if (!filteredRoutes.isEmpty()) {
                record.mExecutor.execute(() -> record.mRouteCallback.onRoutesAdded(filteredRoutes));
            }
        }
    }

    private void notifyRoutesRemoved(List<MediaRoute2Info> routes) {
        for (RouteCallbackRecord record : mRouteCallbackRecords) {
            List<MediaRoute2Info> filteredRoutes =
                    filterRoutesWithIndividualPreference(routes, record.mPreference);
            if (!filteredRoutes.isEmpty()) {
                record.mExecutor.execute(
                        () -> record.mRouteCallback.onRoutesRemoved(filteredRoutes));
            }
        }
    }

    private void notifyRoutesChanged(List<MediaRoute2Info> routes) {
        for (RouteCallbackRecord record : mRouteCallbackRecords) {
            List<MediaRoute2Info> filteredRoutes =
                    filterRoutesWithIndividualPreference(routes, record.mPreference);
            if (!filteredRoutes.isEmpty()) {
                record.mExecutor.execute(
                        () -> record.mRouteCallback.onRoutesChanged(filteredRoutes));
            }
        }
    }

    private void notifyRoutesUpdated(List<MediaRoute2Info> routes) {
        for (RouteCallbackRecord record : mRouteCallbackRecords) {
            List<MediaRoute2Info> filteredRoutes =
                    filterRoutesWithIndividualPreference(routes, record.mPreference);
            record.mExecutor.execute(() -> record.mRouteCallback.onRoutesUpdated(filteredRoutes));
        }
    }

    private void notifyPreferredFeaturesChanged(List<String> features) {
        for (RouteCallbackRecord record : mRouteCallbackRecords) {
            record.mExecutor.execute(
                    () -> record.mRouteCallback.onPreferredFeaturesChanged(features));
        }
    }

    private void notifyTransfer(RoutingController oldController, RoutingController newController) {
        for (TransferCallbackRecord record : mTransferCallbackRecords) {
            record.mExecutor.execute(
                    () -> record.mTransferCallback.onTransfer(oldController, newController));
        }
    }

    private void notifyTransferFailure(MediaRoute2Info route) {
        for (TransferCallbackRecord record : mTransferCallbackRecords) {
            record.mExecutor.execute(() -> record.mTransferCallback.onTransferFailure(route));
        }
    }

    private void notifyStop(RoutingController controller) {
        for (TransferCallbackRecord record : mTransferCallbackRecords) {
            record.mExecutor.execute(() -> record.mTransferCallback.onStop(controller));
        }
    }

    private void notifyControllerUpdated(RoutingController controller) {
        for (ControllerCallbackRecord record : mControllerCallbackRecords) {
            record.mExecutor.execute(() -> record.mCallback.onControllerUpdated(controller));
        }
    }

    /** Callback for receiving events about media route discovery. */
    public abstract static class RouteCallback {
        /**
         * Called when routes are added. Whenever you register a callback, this will be invoked with
         * known routes.
         *
         * @param routes the list of routes that have been added. It's never empty.
         * @deprecated Use {@link #onRoutesUpdated(List)} instead.
         */
        @Deprecated
        public void onRoutesAdded(@NonNull List<MediaRoute2Info> routes) {}

        /**
         * Called when routes are removed.
         *
         * @param routes the list of routes that have been removed. It's never empty.
         * @deprecated Use {@link #onRoutesUpdated(List)} instead.
         */
        @Deprecated
        public void onRoutesRemoved(@NonNull List<MediaRoute2Info> routes) {}

        /**
         * Called when the properties of one or more existing routes are changed. For example, it is
         * called when a route's name or volume have changed.
         *
         * @param routes the list of routes that have been changed. It's never empty.
         * @deprecated Use {@link #onRoutesUpdated(List)} instead.
         */
        @Deprecated
        public void onRoutesChanged(@NonNull List<MediaRoute2Info> routes) {}

        /**
         * Called when the route list is updated, which can happen when routes are added, removed,
         * or modified. It will also be called when a route callback is registered.
         *
         * @param routes the updated list of routes filtered by the callback's individual discovery
         *     preferences.
         */
        public void onRoutesUpdated(@NonNull List<MediaRoute2Info> routes) {}

        /**
         * Called when the client app's preferred features are changed. When this is called, it is
         * recommended to {@link #getRoutes()} to get the routes that are currently available to the
         * app.
         *
         * @param preferredFeatures the new preferred features set by the application
         * @hide
         */
        @SystemApi
        public void onPreferredFeaturesChanged(@NonNull List<String> preferredFeatures) {}
    }

    /** Callback for receiving events on media transfer. */
    public abstract static class TransferCallback {
        /**
         * Called when a media is transferred between two different routing controllers. This can
         * happen by calling {@link #transferTo(MediaRoute2Info)}.
         *
         * <p>Override this to start playback with {@code newController}. You may want to get the
         * status of the media that is being played with {@code oldController} and resume it
         * continuously with {@code newController}. After this is called, any callbacks with {@code
         * oldController} will not be invoked unless {@code oldController} is the {@link
         * #getSystemController() system controller}. You need to {@link RoutingController#release()
         * release} {@code oldController} before playing the media with {@code newController}.
         *
         * @param oldController the previous controller that controlled routing
         * @param newController the new controller to control routing
         * @see #transferTo(MediaRoute2Info)
         */
        public void onTransfer(
                @NonNull RoutingController oldController,
                @NonNull RoutingController newController) {}

        /**
         * Called when {@link #transferTo(MediaRoute2Info)} failed.
         *
         * @param requestedRoute the route info which was used for the transfer
         */
        public void onTransferFailure(@NonNull MediaRoute2Info requestedRoute) {}

        /**
         * Called when a media routing stops. It can be stopped by a user or a provider. App should
         * not continue playing media locally when this method is called. The {@code controller} is
         * released before this method is called.
         *
         * @param controller the controller that controlled the stopped media routing
         */
        public void onStop(@NonNull RoutingController controller) {}
    }

    /**
     * A listener interface to send optional app-specific hints when creating a {@link
     * RoutingController}.
     */
    public interface OnGetControllerHintsListener {
        /**
         * Called when the {@link MediaRouter2} or the system is about to request a media route
         * provider service to create a controller with the given route. The {@link Bundle} returned
         * here will be sent to media route provider service as a hint.
         *
         * <p>Since controller creation can be requested by the {@link MediaRouter2} and the system,
         * set the listener as soon as possible after acquiring {@link MediaRouter2} instance. The
         * method will be called on the same thread that calls {@link #transferTo(MediaRoute2Info)}
         * or the main thread if it is requested by the system.
         *
         * @param route the route to create a controller with
         * @return An optional bundle of app-specific arguments to send to the provider, or {@code
         *     null} if none. The contents of this bundle may affect the result of controller
         *     creation.
         * @see MediaRoute2ProviderService#onCreateSession(long, String, String, Bundle)
         */
        @Nullable
        Bundle onGetControllerHints(@NonNull MediaRoute2Info route);
    }

    /** Callback for receiving {@link RoutingController} updates. */
    public abstract static class ControllerCallback {
        /**
         * Called when a controller is updated. (e.g., when the selected routes of the controller is
         * changed or when the volume of the controller is changed.)
         *
         * @param controller the updated controller. It may be the {@link #getSystemController()
         *     system controller}.
         * @see #getSystemController()
         */
        public void onControllerUpdated(@NonNull RoutingController controller) {}
    }

    /**
     * A class to control media routing session in media route provider. For example,
     * selecting/deselecting/transferring to routes of a session can be done through this. Instances
     * are created when {@link TransferCallback#onTransfer(RoutingController, RoutingController)} is
     * called, which is invoked after {@link #transferTo(MediaRoute2Info)} is called.
     */
    public class RoutingController {
        private final Object mControllerLock = new Object();

        private static final int CONTROLLER_STATE_UNKNOWN = 0;
        private static final int CONTROLLER_STATE_ACTIVE = 1;
        private static final int CONTROLLER_STATE_RELEASING = 2;
        private static final int CONTROLLER_STATE_RELEASED = 3;

        @GuardedBy("mControllerLock")
        private RoutingSessionInfo mSessionInfo;

        @GuardedBy("mControllerLock")
        private int mState;

        RoutingController(@NonNull RoutingSessionInfo sessionInfo) {
            mSessionInfo = sessionInfo;
            mState = CONTROLLER_STATE_ACTIVE;
        }

        RoutingController(@NonNull RoutingSessionInfo sessionInfo, int state) {
            mSessionInfo = sessionInfo;
            mState = state;
        }

        /**
         * @return the ID of the controller. It is globally unique.
         */
        @NonNull
        public String getId() {
            synchronized (mControllerLock) {
                return mSessionInfo.getId();
            }
        }

        /**
         * Gets the original session ID set by {@link RoutingSessionInfo.Builder#Builder(String,
         * String)}.
         *
         * @hide
         */
        @NonNull
        @TestApi
        public String getOriginalId() {
            synchronized (mControllerLock) {
                return mSessionInfo.getOriginalId();
            }
        }

        /**
         * Gets the control hints used to control routing session if available. It is set by the
         * media route provider.
         */
        @Nullable
        public Bundle getControlHints() {
            synchronized (mControllerLock) {
                return mSessionInfo.getControlHints();
            }
        }

        /**
         * @return the unmodifiable list of currently selected routes
         */
        @NonNull
        public List<MediaRoute2Info> getSelectedRoutes() {
            List<String> selectedRouteIds;
            synchronized (mControllerLock) {
                selectedRouteIds = mSessionInfo.getSelectedRoutes();
            }
            return getRoutesWithIds(selectedRouteIds);
        }

        /**
         * @return the unmodifiable list of selectable routes for the session.
         */
        @NonNull
        public List<MediaRoute2Info> getSelectableRoutes() {
            List<String> selectableRouteIds;
            synchronized (mControllerLock) {
                selectableRouteIds = mSessionInfo.getSelectableRoutes();
            }
            return getRoutesWithIds(selectableRouteIds);
        }

        /**
         * @return the unmodifiable list of deselectable routes for the session.
         */
        @NonNull
        public List<MediaRoute2Info> getDeselectableRoutes() {
            List<String> deselectableRouteIds;
            synchronized (mControllerLock) {
                deselectableRouteIds = mSessionInfo.getDeselectableRoutes();
            }
            return getRoutesWithIds(deselectableRouteIds);
        }

        /**
         * Returns the current {@link RoutingSessionInfo} associated to this controller.
         */
        @NonNull
        public RoutingSessionInfo getRoutingSessionInfo() {
            synchronized (mControllerLock) {
                return mSessionInfo;
            }
        }

        /**
         * Gets the information about how volume is handled on the session.
         *
         * <p>Please note that you may not control the volume of the session even when you can
         * control the volume of each selected route in the session.
         *
         * @return {@link MediaRoute2Info#PLAYBACK_VOLUME_FIXED} or {@link
         *     MediaRoute2Info#PLAYBACK_VOLUME_VARIABLE}
         */
        @MediaRoute2Info.PlaybackVolume
        public int getVolumeHandling() {
            synchronized (mControllerLock) {
                return mSessionInfo.getVolumeHandling();
            }
        }

        /** Gets the maximum volume of the session. */
        public int getVolumeMax() {
            synchronized (mControllerLock) {
                return mSessionInfo.getVolumeMax();
            }
        }

        /**
         * Gets the current volume of the session.
         *
         * <p>When it's available, it represents the volume of routing session, which is a group of
         * selected routes. Use {@link MediaRoute2Info#getVolume()} to get the volume of a route,
         *
         * @see MediaRoute2Info#getVolume()
         */
        public int getVolume() {
            synchronized (mControllerLock) {
                return mSessionInfo.getVolume();
            }
        }

        /**
         * Returns true if this controller is released, false otherwise. If it is released, then all
         * other getters from this instance may return invalid values. Also, any operations to this
         * instance will be ignored once released.
         *
         * @see #release
         */
        public boolean isReleased() {
            synchronized (mControllerLock) {
                return mState == CONTROLLER_STATE_RELEASED;
            }
        }

        /**
         * Selects a route for the remote session. After a route is selected, the media is expected
         * to be played to the all the selected routes. This is different from {@link
         * MediaRouter2#transferTo(MediaRoute2Info)} transferring to a route}, where the media is
         * expected to 'move' from one route to another.
         *
         * <p>The given route must satisfy all of the following conditions:
         *
         * <ul>
         *   <li>It should not be included in {@link #getSelectedRoutes()}
         *   <li>It should be included in {@link #getSelectableRoutes()}
         * </ul>
         *
         * If the route doesn't meet any of above conditions, it will be ignored.
         *
         * @see #deselectRoute(MediaRoute2Info)
         * @see #getSelectedRoutes()
         * @see #getSelectableRoutes()
         * @see ControllerCallback#onControllerUpdated
         */
        public void selectRoute(@NonNull MediaRoute2Info route) {
            Objects.requireNonNull(route, "route must not be null");
            if (isReleased()) {
                Log.w(TAG, "selectRoute: Called on released controller. Ignoring.");
                return;
            }

            List<MediaRoute2Info> selectedRoutes = getSelectedRoutes();
            if (checkRouteListContainsRouteId(selectedRoutes, route.getId())) {
                Log.w(TAG, "Ignoring selecting a route that is already selected. route=" + route);
                return;
            }

            List<MediaRoute2Info> selectableRoutes = getSelectableRoutes();
            if (!checkRouteListContainsRouteId(selectableRoutes, route.getId())) {
                Log.w(TAG, "Ignoring selecting a non-selectable route=" + route);
                return;
            }

            if (isSystemRouter()) {
                sManager.selectRoute(getRoutingSessionInfo(), route);
                return;
            }

            MediaRouter2Stub stub;
            synchronized (mLock) {
                stub = mStub;
            }
            if (stub != null) {
                try {
                    mMediaRouterService.selectRouteWithRouter2(stub, getId(), route);
                } catch (RemoteException ex) {
                    Log.e(TAG, "Unable to select route for session.", ex);
                }
            }
        }

        /**
         * Deselects a route from the remote session. After a route is deselected, the media is
         * expected to be stopped on the deselected route.
         *
         * <p>The given route must satisfy all of the following conditions:
         *
         * <ul>
         *   <li>It should be included in {@link #getSelectedRoutes()}
         *   <li>It should be included in {@link #getDeselectableRoutes()}
         * </ul>
         *
         * If the route doesn't meet any of above conditions, it will be ignored.
         *
         * @see #getSelectedRoutes()
         * @see #getDeselectableRoutes()
         * @see ControllerCallback#onControllerUpdated
         */
        public void deselectRoute(@NonNull MediaRoute2Info route) {
            Objects.requireNonNull(route, "route must not be null");
            if (isReleased()) {
                Log.w(TAG, "deselectRoute: called on released controller. Ignoring.");
                return;
            }

            List<MediaRoute2Info> selectedRoutes = getSelectedRoutes();
            if (!checkRouteListContainsRouteId(selectedRoutes, route.getId())) {
                Log.w(TAG, "Ignoring deselecting a route that is not selected. route=" + route);
                return;
            }

            List<MediaRoute2Info> deselectableRoutes = getDeselectableRoutes();
            if (!checkRouteListContainsRouteId(deselectableRoutes, route.getId())) {
                Log.w(TAG, "Ignoring deselecting a non-deselectable route=" + route);
                return;
            }

            if (isSystemRouter()) {
                sManager.deselectRoute(getRoutingSessionInfo(), route);
                return;
            }

            MediaRouter2Stub stub;
            synchronized (mLock) {
                stub = mStub;
            }
            if (stub != null) {
                try {
                    mMediaRouterService.deselectRouteWithRouter2(stub, getId(), route);
                } catch (RemoteException ex) {
                    Log.e(TAG, "Unable to deselect route from session.", ex);
                }
            }
        }

        /**
         * Transfers to a given route for the remote session. The given route must be included in
         * {@link RoutingSessionInfo#getTransferableRoutes()}.
         *
         * @see RoutingSessionInfo#getSelectedRoutes()
         * @see RoutingSessionInfo#getTransferableRoutes()
         * @see ControllerCallback#onControllerUpdated
         */
        void transferToRoute(@NonNull MediaRoute2Info route) {
            Objects.requireNonNull(route, "route must not be null");
            synchronized (mControllerLock) {
                if (isReleased()) {
                    Log.w(TAG, "transferToRoute: Called on released controller. Ignoring.");
                    return;
                }

                if (!mSessionInfo.getTransferableRoutes().contains(route.getId())) {
                    Log.w(TAG, "Ignoring transferring to a non-transferable route=" + route);
                    return;
                }
            }

            MediaRouter2Stub stub;
            synchronized (mLock) {
                stub = mStub;
            }
            if (stub != null) {
                try {
                    mMediaRouterService.transferToRouteWithRouter2(stub, getId(), route);
                } catch (RemoteException ex) {
                    Log.e(TAG, "Unable to transfer to route for session.", ex);
                }
            }
        }

        /**
         * Requests a volume change for the remote session asynchronously.
         *
         * @param volume The new volume value between 0 and {@link RoutingController#getVolumeMax}
         *     (inclusive).
         * @see #getVolume()
         */
        public void setVolume(int volume) {
            if (getVolumeHandling() == MediaRoute2Info.PLAYBACK_VOLUME_FIXED) {
                Log.w(TAG, "setVolume: The routing session has fixed volume. Ignoring.");
                return;
            }
            if (volume < 0 || volume > getVolumeMax()) {
                Log.w(TAG, "setVolume: The target volume is out of range. Ignoring");
                return;
            }

            if (isReleased()) {
                Log.w(TAG, "setVolume: Called on released controller. Ignoring.");
                return;
            }

            if (isSystemRouter()) {
                sManager.setSessionVolume(getRoutingSessionInfo(), volume);
                return;
            }

            MediaRouter2Stub stub;
            synchronized (mLock) {
                stub = mStub;
            }
            if (stub != null) {
                try {
                    mMediaRouterService.setSessionVolumeWithRouter2(stub, getId(), volume);
                } catch (RemoteException ex) {
                    Log.e(TAG, "setVolume: Failed to deliver request.", ex);
                }
            }
        }

        /**
         * Releases this controller and the corresponding session. Any operations on this controller
         * after calling this method will be ignored. The devices that are playing media will stop
         * playing it.
         */
        public void release() {
            releaseInternal(/* shouldReleaseSession= */ true);
        }

        /**
         * Schedules release of the controller.
         *
         * @return {@code true} if it's successfully scheduled, {@code false} if it's already
         *     scheduled to be released or released.
         */
        boolean scheduleRelease() {
            synchronized (mControllerLock) {
                if (mState != CONTROLLER_STATE_ACTIVE) {
                    return false;
                }
                mState = CONTROLLER_STATE_RELEASING;
            }

            synchronized (mLock) {
                // It could happen if the controller is released by the another thread
                // in between two locks
                if (!mNonSystemRoutingControllers.remove(getId(), this)) {
                    // In that case, onStop isn't called so we return true to call onTransfer.
                    // It's also consistent with that the another thread acquires the lock later.
                    return true;
                }
            }

            mHandler.postDelayed(this::release, TRANSFER_TIMEOUT_MS);

            return true;
        }

        void releaseInternal(boolean shouldReleaseSession) {
            boolean shouldNotifyStop;

            synchronized (mControllerLock) {
                if (mState == CONTROLLER_STATE_RELEASED) {
                    if (DEBUG) {
                        Log.d(TAG, "releaseInternal: Called on released controller. Ignoring.");
                    }
                    return;
                }
                shouldNotifyStop = (mState == CONTROLLER_STATE_ACTIVE);
                mState = CONTROLLER_STATE_RELEASED;
            }

            if (isSystemRouter()) {
                sManager.releaseSession(getRoutingSessionInfo());
                return;
            }

            synchronized (mLock) {
                mNonSystemRoutingControllers.remove(getId(), this);

                if (shouldReleaseSession && mStub != null) {
                    try {
                        mMediaRouterService.releaseSessionWithRouter2(mStub, getId());
                    } catch (RemoteException ex) {
                        ex.rethrowFromSystemServer();
                    }
                }

                if (shouldNotifyStop) {
                    mHandler.sendMessage(
                            obtainMessage(
                                    MediaRouter2::notifyStop,
                                    MediaRouter2.this,
                                    RoutingController.this));
                }

                if (mRouteCallbackRecords.isEmpty()
                        && mNonSystemRoutingControllers.isEmpty()
                        && mStub != null) {
                    try {
                        mMediaRouterService.unregisterRouter2(mStub);
                    } catch (RemoteException ex) {
                        ex.rethrowFromSystemServer();
                    }
                    mStub = null;
                }
            }
        }

        @Override
        public String toString() {
            // To prevent logging spam, we only print the ID of each route.
            List<String> selectedRoutes =
                    getSelectedRoutes().stream()
                            .map(MediaRoute2Info::getId)
                            .collect(Collectors.toList());
            List<String> selectableRoutes =
                    getSelectableRoutes().stream()
                            .map(MediaRoute2Info::getId)
                            .collect(Collectors.toList());
            List<String> deselectableRoutes =
                    getDeselectableRoutes().stream()
                            .map(MediaRoute2Info::getId)
                            .collect(Collectors.toList());

            StringBuilder result =
                    new StringBuilder()
                            .append("RoutingController{ ")
                            .append("id=")
                            .append(getId())
                            .append(", selectedRoutes={")
                            .append(selectedRoutes)
                            .append("}")
                            .append(", selectableRoutes={")
                            .append(selectableRoutes)
                            .append("}")
                            .append(", deselectableRoutes={")
                            .append(deselectableRoutes)
                            .append("}")
                            .append(" }");
            return result.toString();
        }

        void setRoutingSessionInfo(@NonNull RoutingSessionInfo info) {
            synchronized (mControllerLock) {
                mSessionInfo = info;
            }
        }

        private List<MediaRoute2Info> getRoutesWithIds(List<String> routeIds) {
            if (isSystemRouter()) {
                return getRoutes().stream()
                        .filter(r -> routeIds.contains(r.getId()))
                        .collect(Collectors.toList());
            }

            synchronized (mLock) {
                return routeIds.stream()
                        .map(mRoutes::get)
                        .filter(Objects::nonNull)
                        .collect(Collectors.toList());
            }
        }
    }

    class SystemRoutingController extends RoutingController {
        SystemRoutingController(@NonNull RoutingSessionInfo sessionInfo) {
            super(sessionInfo);
        }

        @Override
        public boolean isReleased() {
            // SystemRoutingController will never be released
            return false;
        }

        @Override
        boolean scheduleRelease() {
            // SystemRoutingController can be always transferred
            return true;
        }

        @Override
        void releaseInternal(boolean shouldReleaseSession) {
            // Do nothing. SystemRoutingController will never be released
        }
    }

    static final class RouteCallbackRecord {
        public final Executor mExecutor;
        public final RouteCallback mRouteCallback;
        public final RouteDiscoveryPreference mPreference;

        RouteCallbackRecord(
                @Nullable Executor executor,
                @NonNull RouteCallback routeCallback,
                @Nullable RouteDiscoveryPreference preference) {
            mRouteCallback = routeCallback;
            mExecutor = executor;
            mPreference = preference;
        }

        @Override
        public boolean equals(Object obj) {
            if (this == obj) {
                return true;
            }
            if (!(obj instanceof RouteCallbackRecord)) {
                return false;
            }
            return mRouteCallback == ((RouteCallbackRecord) obj).mRouteCallback;
        }

        @Override
        public int hashCode() {
            return mRouteCallback.hashCode();
        }
    }

    static final class TransferCallbackRecord {
        public final Executor mExecutor;
        public final TransferCallback mTransferCallback;

        TransferCallbackRecord(
                @NonNull Executor executor, @NonNull TransferCallback transferCallback) {
            mTransferCallback = transferCallback;
            mExecutor = executor;
        }

        @Override
        public boolean equals(Object obj) {
            if (this == obj) {
                return true;
            }
            if (!(obj instanceof TransferCallbackRecord)) {
                return false;
            }
            return mTransferCallback == ((TransferCallbackRecord) obj).mTransferCallback;
        }

        @Override
        public int hashCode() {
            return mTransferCallback.hashCode();
        }
    }

    static final class ControllerCallbackRecord {
        public final Executor mExecutor;
        public final ControllerCallback mCallback;

        ControllerCallbackRecord(
                @Nullable Executor executor, @NonNull ControllerCallback callback) {
            mCallback = callback;
            mExecutor = executor;
        }

        @Override
        public boolean equals(Object obj) {
            if (this == obj) {
                return true;
            }
            if (!(obj instanceof ControllerCallbackRecord)) {
                return false;
            }
            return mCallback == ((ControllerCallbackRecord) obj).mCallback;
        }

        @Override
        public int hashCode() {
            return mCallback.hashCode();
        }
    }

    static final class ControllerCreationRequest {
        public final int mRequestId;
        public final long mManagerRequestId;
        public final MediaRoute2Info mRoute;
        public final RoutingController mOldController;

        ControllerCreationRequest(
                int requestId,
                long managerRequestId,
                @NonNull MediaRoute2Info route,
                @NonNull RoutingController oldController) {
            mRequestId = requestId;
            mManagerRequestId = managerRequestId;
            mRoute = Objects.requireNonNull(route, "route must not be null");
            mOldController =
                    Objects.requireNonNull(oldController, "oldController must not be null");
        }
    }

    class MediaRouter2Stub extends IMediaRouter2.Stub {
        @Override
        public void notifyRouterRegistered(
                List<MediaRoute2Info> currentRoutes, RoutingSessionInfo currentSystemSessionInfo) {
            mHandler.sendMessage(
                    obtainMessage(
                            MediaRouter2::syncRoutesOnHandler,
                            MediaRouter2.this,
                            currentRoutes,
                            currentSystemSessionInfo));
        }

        @Override
        public void notifyRoutesUpdated(List<MediaRoute2Info> routes) {
            mHandler.sendMessage(
                    obtainMessage(MediaRouter2::updateRoutesOnHandler, MediaRouter2.this, routes));
        }

        @Override
        public void notifySessionCreated(int requestId, @Nullable RoutingSessionInfo sessionInfo) {
            mHandler.sendMessage(
                    obtainMessage(
                            MediaRouter2::createControllerOnHandler,
                            MediaRouter2.this,
                            requestId,
                            sessionInfo));
        }

        @Override
        public void notifySessionInfoChanged(@Nullable RoutingSessionInfo sessionInfo) {
            mHandler.sendMessage(
                    obtainMessage(
                            MediaRouter2::updateControllerOnHandler,
                            MediaRouter2.this,
                            sessionInfo));
        }

        @Override
        public void notifySessionReleased(RoutingSessionInfo sessionInfo) {
            mHandler.sendMessage(
                    obtainMessage(
                            MediaRouter2::releaseControllerOnHandler,
                            MediaRouter2.this,
                            sessionInfo));
        }

        @Override
        public void requestCreateSessionByManager(
                long managerRequestId, RoutingSessionInfo oldSession, MediaRoute2Info route) {
            mHandler.sendMessage(
                    obtainMessage(
                            MediaRouter2::onRequestCreateControllerByManagerOnHandler,
                            MediaRouter2.this,
                            oldSession,
                            route,
                            managerRequestId));
        }
    }

    // Note: All methods are run on main thread.
    class ManagerCallback implements MediaRouter2Manager.Callback {

        @Override
        public void onRoutesUpdated() {
            updateAllRoutesFromManager();
        }

        @Override
        public void onTransferred(
                @NonNull RoutingSessionInfo oldSession, @NonNull RoutingSessionInfo newSession) {
            if (!oldSession.isSystemSession()
                    && !TextUtils.equals(mClientPackageName, oldSession.getClientPackageName())) {
                return;
            }

            if (!newSession.isSystemSession()
                    && !TextUtils.equals(mClientPackageName, newSession.getClientPackageName())) {
                return;
            }

            // For successful in-session transfer, onControllerUpdated() handles it.
            if (TextUtils.equals(oldSession.getId(), newSession.getId())) {
                return;
            }

            RoutingController oldController;
            if (oldSession.isSystemSession()) {
                mSystemController.setRoutingSessionInfo(
                        ensureClientPackageNameForSystemSession(oldSession));
                oldController = mSystemController;
            } else {
                oldController = new RoutingController(oldSession);
            }

            RoutingController newController;
            if (newSession.isSystemSession()) {
                mSystemController.setRoutingSessionInfo(
                        ensureClientPackageNameForSystemSession(newSession));
                newController = mSystemController;
            } else {
                newController = new RoutingController(newSession);
            }

            notifyTransfer(oldController, newController);
        }

        @Override
        public void onTransferFailed(
                @NonNull RoutingSessionInfo session, @NonNull MediaRoute2Info route) {
            if (!session.isSystemSession()
                    && !TextUtils.equals(mClientPackageName, session.getClientPackageName())) {
                return;
            }
            notifyTransferFailure(route);
        }

        @Override
        public void onSessionUpdated(@NonNull RoutingSessionInfo session) {
            if (!session.isSystemSession()
                    && !TextUtils.equals(mClientPackageName, session.getClientPackageName())) {
                return;
            }

            RoutingController controller;
            if (session.isSystemSession()) {
                mSystemController.setRoutingSessionInfo(
                        ensureClientPackageNameForSystemSession(session));
                controller = mSystemController;
            } else {
                controller = new RoutingController(session);
            }
            notifyControllerUpdated(controller);
        }

        @Override
        public void onSessionReleased(@NonNull RoutingSessionInfo session) {
            if (session.isSystemSession()) {
                Log.e(TAG, "onSessionReleased: Called on system session. Ignoring.");
                return;
            }

            if (!TextUtils.equals(mClientPackageName, session.getClientPackageName())) {
                return;
            }

            notifyStop(new RoutingController(session, RoutingController.CONTROLLER_STATE_RELEASED));
        }

        @Override
        public void onDiscoveryPreferenceChanged(
                @NonNull String packageName, @NonNull RouteDiscoveryPreference preference) {
            if (!TextUtils.equals(mClientPackageName, packageName)) {
                return;
            }

            synchronized (mLock) {
                mDiscoveryPreference = preference;
            }
            updateAllRoutesFromManager();
            notifyPreferredFeaturesChanged(preference.getPreferredFeatures());
        }

        @Override
        public void onRequestFailed(int reason) {
            // Does nothing.
        }
    }
}
