/* * 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 static com.android.media.flags.Flags.FLAG_ENABLE_BUILT_IN_SPEAKER_ROUTE_SUITABILITY_STATUSES; import static com.android.media.flags.Flags.FLAG_ENABLE_GET_TRANSFERABLE_ROUTES; import static com.android.media.flags.Flags.FLAG_ENABLE_PRIVILEGED_ROUTING_FOR_MEDIA_ROUTING_CONTROL; import static com.android.media.flags.Flags.FLAG_ENABLE_RLP_CALLBACKS_IN_MEDIA_ROUTER2; import static com.android.media.flags.Flags.FLAG_ENABLE_SCREEN_OFF_SCANNING; import android.Manifest; import android.annotation.CallbackExecutor; import android.annotation.FlaggedApi; import android.annotation.IntDef; import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.RequiresPermission; import android.annotation.SystemApi; import android.annotation.TestApi; import android.app.AppOpsManager; import android.content.Context; import android.content.pm.PackageManager; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.os.Message; import android.os.Process; import android.os.RemoteException; import android.os.ServiceManager; import android.os.UserHandle; import android.text.TextUtils; import android.util.ArrayMap; import android.util.ArraySet; import android.util.Log; import android.util.SparseArray; import com.android.internal.annotations.GuardedBy; import com.android.media.flags.Flags; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; 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.function.Consumer; import java.util.stream.Collectors; /** * This API is not generally intended for third party application developers. Use the * AndroidX * Media Router * Library for consistent behavior across all devices. * *

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 { /** * The state of a router not requesting route scanning. * * @hide */ public static final int SCANNING_STATE_NOT_SCANNING = 0; /** * The state of a router requesting scanning only while the user interacts with its owner app. * *

The device's screen must be on and the app must be in the foreground to trigger scanning * under this state. * * @hide */ public static final int SCANNING_STATE_WHILE_INTERACTIVE = 1; /** * The state of a router requesting unrestricted scanning. * *

This state triggers scanning regardless of the restrictions required for {@link * #SCANNING_STATE_WHILE_INTERACTIVE}. * *

Routers requesting unrestricted scanning must hold {@link * Manifest.permission#MEDIA_ROUTING_CONTROL}. * * @hide */ public static final int SCANNING_STATE_SCANNING_FULL = 2; /** @hide */ @IntDef( prefix = "SCANNING_STATE", value = { SCANNING_STATE_NOT_SCANNING, SCANNING_STATE_WHILE_INTERACTIVE, SCANNING_STATE_SCANNING_FULL }) @Retention(RetentionPolicy.SOURCE) public @interface ScanningState {} 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; private record PackageNameUserHandlePair(String packageName, UserHandle user) {} private record InstanceInvalidatedCallbackRecord(Executor executor, Runnable runnable) {} @GuardedBy("sSystemRouterLock") private static final Map sAppToProxyRouterMap = new ArrayMap<>(); @GuardedBy("sRouterLock") private static MediaRouter2 sInstance; private final Context mContext; private final IMediaRouterService mMediaRouterService; private final Object mLock = new Object(); private final MediaRouter2Impl mImpl; private final CopyOnWriteArrayList mRouteCallbackRecords = new CopyOnWriteArrayList<>(); private final CopyOnWriteArrayList mListingPreferenceCallbackRecords = new CopyOnWriteArrayList<>(); private final CopyOnWriteArrayList mTransferCallbackRecords = new CopyOnWriteArrayList<>(); private final CopyOnWriteArrayList mControllerCallbackRecords = new CopyOnWriteArrayList<>(); private final CopyOnWriteArrayList mControllerCreationRequests = new CopyOnWriteArrayList<>(); /** * Stores the latest copy of all routes received from the system server, without any filtering, * sorting, or deduplication. * *

Uses {@link MediaRoute2Info#getId()} to set each entry's key. */ @GuardedBy("mLock") private final Map mRoutes = new ArrayMap<>(); private final RoutingController mSystemController; @GuardedBy("mLock") private final Map mNonSystemRoutingControllers = new ArrayMap<>(); @GuardedBy("mLock") private int mScreenOffScanRequestCount = 0; @GuardedBy("mLock") private int mScreenOnScanRequestCount = 0; private final SparseArray mScanRequestsMap = new SparseArray<>(); private final AtomicInteger mNextRequestId = new AtomicInteger(1); private final Handler mHandler; @GuardedBy("mLock") private RouteDiscoveryPreference mDiscoveryPreference = RouteDiscoveryPreference.EMPTY; // TODO: Make MediaRouter2 is always connected to the MediaRouterService. @GuardedBy("mLock") private MediaRouter2Stub mStub; @GuardedBy("mLock") @Nullable private RouteListingPreference mRouteListingPreference; /** * 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 mPreviousFilteredRoutes = new ArrayMap<>(); private final Map mPreviousUnfilteredRoutes = new ArrayMap<>(); /** * Stores the latest copy of exposed routes after filtering, sorting, and deduplication. Can be * accessed through {@link #getRoutes()}. * *

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 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; } } /** * Returns a proxy MediaRouter2 instance that allows you to control the routing of an app * specified by {@code clientPackageName}. Returns {@code null} if the specified package name * does not exist. * *

Proxy MediaRouter2 instances operate differently than regular MediaRouter2 instances: * *

* *

Callers that only hold the revocable form of {@link * Manifest.permission#MEDIA_ROUTING_CONTROL} must use {@link #getInstance(Context, String, * Executor, Runnable)} instead of this method. * * @param clientPackageName the package name of the app to control * @return a proxy MediaRouter2 instance if {@code clientPackageName} exists or {@code null}. * @throws IllegalStateException if the caller only holds a revocable version of {@link * Manifest.permission#MEDIA_ROUTING_CONTROL}. * @hide */ @SuppressWarnings("RequiresPermission") @RequiresPermission( anyOf = { Manifest.permission.MEDIA_CONTENT_CONTROL, Manifest.permission.MEDIA_ROUTING_CONTROL }) @SystemApi @Nullable public static MediaRouter2 getInstance( @NonNull Context context, @NonNull String clientPackageName) { // Capturing the IAE here to not break nullability. try { return findOrCreateProxyInstanceForCallingUser( context, clientPackageName, context.getUser(), /* executor */ null, /* onInstanceInvalidatedListener */ null); } catch (IllegalArgumentException ex) { Log.e(TAG, "Package " + clientPackageName + " not found. Ignoring."); return null; } } /** * Returns a proxy MediaRouter2 instance that allows you to control the routing of an app * specified by {@code clientPackageName}. Returns {@code null} if the specified package name * does not exist. * *

Proxy MediaRouter2 instances operate differently than regular MediaRouter2 instances: * *

* *

Use this method when you only hold a revocable version of {@link * Manifest.permission#MEDIA_ROUTING_CONTROL} (e.g. acquired via the {@link AppOpsManager}). * Otherwise, use {@link #getInstance(Context, String)}. * *

{@code onInstanceInvalidatedListener} is called when the instance is invalidated because * the calling app has lost {@link Manifest.permission#MEDIA_ROUTING_CONTROL} and does not hold * {@link Manifest.permission#MEDIA_CONTENT_CONTROL}. Do not use the invalidated instance after * receiving this callback, as the system will ignore all operations. Call {@link * #getInstance(Context, String, Executor, Runnable)} again after reacquiring the relevant * permissions. * * @param context The {@link Context} of the caller. * @param clientPackageName The package name of the app you want to control the routing of. * @param executor The {@link Executor} on which to invoke {@code * onInstanceInvalidatedListener}. * @param onInstanceInvalidatedListener Callback for when the {@link MediaRouter2} instance is * invalidated due to lost permissions. * @throws IllegalArgumentException if {@code clientPackageName} does not exist in the calling * user. */ @SuppressWarnings("RequiresPermission") @FlaggedApi(FLAG_ENABLE_PRIVILEGED_ROUTING_FOR_MEDIA_ROUTING_CONTROL) @RequiresPermission( anyOf = { Manifest.permission.MEDIA_CONTENT_CONTROL, Manifest.permission.MEDIA_ROUTING_CONTROL }) @NonNull public static MediaRouter2 getInstance( @NonNull Context context, @NonNull String clientPackageName, @NonNull Executor executor, @NonNull Runnable onInstanceInvalidatedListener) { Objects.requireNonNull(executor, "Executor must not be null"); Objects.requireNonNull( onInstanceInvalidatedListener, "onInstanceInvalidatedListener must not be null."); return findOrCreateProxyInstanceForCallingUser( context, clientPackageName, context.getUser(), executor, onInstanceInvalidatedListener); } /** * Returns a proxy MediaRouter2 instance that allows you to control the routing of an app * specified by {@code clientPackageName} and {@code user}. * *

Proxy MediaRouter2 instances operate differently than regular MediaRouter2 instances: * *

* * @param context The {@link Context} of the caller. * @param clientPackageName The package name of the app you want to control the routing of. * @param user The {@link UserHandle} of the user running the app for which to get the proxy * router instance. Must match {@link Process#myUserHandle()} if the caller doesn't hold * {@code Manifest.permission#INTERACT_ACROSS_USERS_FULL}. * @throws SecurityException if {@code user} does not match {@link Process#myUserHandle()} and * the caller does not hold {@code Manifest.permission#INTERACT_ACROSS_USERS_FULL}. * @throws IllegalArgumentException if {@code clientPackageName} does not exist in {@code user}. * @hide */ @RequiresPermission( anyOf = { Manifest.permission.MEDIA_CONTENT_CONTROL, Manifest.permission.MEDIA_ROUTING_CONTROL }) @NonNull public static MediaRouter2 getInstance( @NonNull Context context, @NonNull String clientPackageName, @NonNull UserHandle user) { return findOrCreateProxyInstanceForCallingUser( context, clientPackageName, user, /* executor */ null, /* onInstanceInvalidatedListener */ null); } /** * Returns the per-process singleton proxy router instance for the {@code clientPackageName} and * {@code user} if it exists, or otherwise it creates the appropriate instance. * *

If no instance has been created previously, the method will create an instance via {@link * #MediaRouter2(Context, Looper, String, UserHandle)}. */ @NonNull private static MediaRouter2 findOrCreateProxyInstanceForCallingUser( Context context, String clientPackageName, UserHandle user, @Nullable Executor executor, @Nullable Runnable onInstanceInvalidatedListener) { Objects.requireNonNull(context, "context must not be null"); Objects.requireNonNull(user, "user must not be null"); if (TextUtils.isEmpty(clientPackageName)) { throw new IllegalArgumentException("clientPackageName must not be null or empty"); } if (executor == null || onInstanceInvalidatedListener == null) { if (checkCallerHasOnlyRevocablePermissions(context)) { throw new IllegalStateException( "Use getInstance(Context, String, Executor, Runnable) to obtain a proxy" + " MediaRouter2 instance."); } } PackageNameUserHandlePair key = new PackageNameUserHandlePair(clientPackageName, user); synchronized (sSystemRouterLock) { MediaRouter2 instance = sAppToProxyRouterMap.get(key); if (instance == null) { instance = new MediaRouter2(context, Looper.getMainLooper(), clientPackageName, user); // Register proxy router after instantiation to avoid race condition. ((ProxyMediaRouter2Impl) instance.mImpl).registerProxyRouter(); sAppToProxyRouterMap.put(key, instance); } ((ProxyMediaRouter2Impl) instance.mImpl) .registerInstanceInvalidatedCallback(executor, onInstanceInvalidatedListener); return instance; } } private static boolean checkCallerHasOnlyRevocablePermissions(@NonNull Context context) { boolean hasMediaContentControl = context.checkSelfPermission(Manifest.permission.MEDIA_CONTENT_CONTROL) == PackageManager.PERMISSION_GRANTED; boolean hasRegularMediaRoutingControl = context.checkSelfPermission(Manifest.permission.MEDIA_ROUTING_CONTROL) == PackageManager.PERMISSION_GRANTED; AppOpsManager appOpsManager = context.getSystemService(AppOpsManager.class); boolean hasAppOpMediaRoutingControl = appOpsManager.unsafeCheckOp( AppOpsManager.OPSTR_MEDIA_ROUTING_CONTROL, context.getApplicationInfo().uid, context.getOpPackageName()) == AppOpsManager.MODE_ALLOWED; return !hasMediaContentControl && !hasRegularMediaRoutingControl && hasAppOpMediaRoutingControl; } /** * Starts scanning remote routes. * *

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. * *

Use {@link RouteCallback} to get the route related events. * *

Note that calling start/stopScan is applied to all system routers in the same process. * *

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() { mImpl.startScan(); } /** * Stops scanning remote routes to reduce resource consumption. * *

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. * *

Use {@link RouteCallback} to get the route related events. * *

Note that calling start/stopScan is applied to all system routers in the same process. * *

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() { mImpl.stopScan(); } /** * Requests the system to actively scan for routes based on the router's {@link * RouteDiscoveryPreference route discovery preference}. * *

You must call {@link #cancelScanRequest(ScanToken)} promptly to preserve system resources * like battery. Avoid scanning unless there is clear intention from the user to start routing * their media. * *

{@code scanRequest} specifies relevant scanning options, like whether the system should * scan with the screen off. Screen off scanning requires {@link * Manifest.permission#MEDIA_ROUTING_CONTROL} * *

Proxy routers use the registered {@link RouteDiscoveryPreference} of their target routers. * * @return A unique {@link ScanToken} that identifies the scan request. */ @FlaggedApi(FLAG_ENABLE_SCREEN_OFF_SCANNING) @NonNull public ScanToken requestScan(@NonNull ScanRequest scanRequest) { Objects.requireNonNull(scanRequest, "scanRequest must not be null."); ScanToken token = new ScanToken(mNextRequestId.getAndIncrement()); synchronized (mLock) { boolean shouldUpdate = mScreenOffScanRequestCount == 0 && (scanRequest.isScreenOffScan() || mScreenOnScanRequestCount == 0); if (shouldUpdate) { try { mImpl.updateScanningState( scanRequest.isScreenOffScan() ? SCANNING_STATE_SCANNING_FULL : SCANNING_STATE_WHILE_INTERACTIVE); } catch (RemoteException ex) { throw ex.rethrowFromSystemServer(); } } if (scanRequest.isScreenOffScan()) { mScreenOffScanRequestCount++; } else { mScreenOnScanRequestCount++; } mScanRequestsMap.put(token.mId, scanRequest); return token; } } /** * Releases the active scan request linked to the provided {@link ScanToken}. * * @see #requestScan(ScanRequest) * @param token {@link ScanToken} of the {@link ScanRequest} to release. * @throws IllegalArgumentException if the token does not match any active scan request. */ @FlaggedApi(FLAG_ENABLE_SCREEN_OFF_SCANNING) public void cancelScanRequest(@NonNull ScanToken token) { Objects.requireNonNull(token, "token must not be null"); synchronized (mLock) { ScanRequest request = mScanRequestsMap.get(token.mId); if (request == null) { throw new IllegalArgumentException( "The token does not match any active scan request"); } boolean shouldUpdate = request.isScreenOffScan() ? mScreenOffScanRequestCount == 1 : mScreenOnScanRequestCount == 1 && mScreenOffScanRequestCount == 0; if (shouldUpdate) { try { if (!request.isScreenOffScan() || mScreenOnScanRequestCount == 0) { mImpl.updateScanningState(SCANNING_STATE_NOT_SCANNING); } else { mImpl.updateScanningState(SCANNING_STATE_WHILE_INTERACTIVE); } } catch (RemoteException ex) { ex.rethrowFromSystemServer(); } } if (request.isScreenOffScan()) { mScreenOffScanRequestCount--; } else { mScreenOnScanRequestCount--; } mScanRequestsMap.remove(token.mId); } } private MediaRouter2(Context appContext) { mContext = appContext; mMediaRouterService = IMediaRouterService.Stub.asInterface( ServiceManager.getService(Context.MEDIA_ROUTER_SERVICE)); mImpl = new LocalMediaRouter2Impl(mContext.getPackageName()); mHandler = new Handler(Looper.getMainLooper()); loadSystemRoutes(/* isProxyRouter */ false); RoutingSessionInfo currentSystemSessionInfo = mImpl.getSystemSessionInfo(); if (currentSystemSessionInfo == null) { throw new RuntimeException("Null currentSystemSessionInfo. Something is wrong."); } mSystemController = new SystemRoutingController(currentSystemSessionInfo); } private MediaRouter2( Context context, Looper looper, String clientPackageName, UserHandle user) { mContext = context; mHandler = new Handler(looper); mMediaRouterService = IMediaRouterService.Stub.asInterface( ServiceManager.getService(Context.MEDIA_ROUTER_SERVICE)); loadSystemRoutes(/* isProxyRouter */ true); mSystemController = new SystemRoutingController( ProxyMediaRouter2Impl.getSystemSessionInfoImpl( mMediaRouterService, mContext.getPackageName(), clientPackageName)); mImpl = new ProxyMediaRouter2Impl(context, clientPackageName, user); } @GuardedBy("mLock") private void loadSystemRoutes(boolean isProxyRouter) { List currentSystemRoutes = null; try { currentSystemRoutes = mMediaRouterService.getSystemRoutes(mContext.getPackageName(), isProxyRouter); } catch (RemoteException ex) { ex.rethrowFromSystemServer(); } if (currentSystemRoutes == null || currentSystemRoutes.isEmpty()) { throw new RuntimeException("Null or empty currentSystemRoutes. Something is wrong."); } for (MediaRoute2Info route : currentSystemRoutes) { mRoutes.put(route.getId(), route); } } /** * Gets the client package name of the app which this media router controls. * *

This will return null for non-system media routers. * * @see #getInstance(Context, String) * @hide */ @SystemApi @Nullable public String getClientPackageName() { return mImpl.getClientPackageName(); } /** * Registers a callback to discover routes and to receive events when they change. * *

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"); RouteCallbackRecord record = mImpl.createRouteCallbackRecord(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); mImpl.registerRouteCallback(); } /** * 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; } mImpl.unregisterRouteCallback(); } /** * Registers the given callback to be invoked when the {@link RouteListingPreference} of the * target router changes. * *

Calls using a previously registered callback will overwrite the previous executor. * * @see #setRouteListingPreference(RouteListingPreference) */ @FlaggedApi(FLAG_ENABLE_RLP_CALLBACKS_IN_MEDIA_ROUTER2) public void registerRouteListingPreferenceUpdatedCallback( @NonNull @CallbackExecutor Executor executor, @NonNull Consumer routeListingPreferenceCallback) { Objects.requireNonNull(executor, "executor must not be null"); Objects.requireNonNull(routeListingPreferenceCallback, "callback must not be null"); RouteListingPreferenceCallbackRecord record = new RouteListingPreferenceCallbackRecord(executor, routeListingPreferenceCallback); mListingPreferenceCallbackRecords.remove(record); mListingPreferenceCallbackRecords.add(record); } /** * Unregisters the given callback to not receive {@link RouteListingPreference} change events. * * @see #registerRouteListingPreferenceUpdatedCallback(Executor, Consumer) */ @FlaggedApi(FLAG_ENABLE_RLP_CALLBACKS_IN_MEDIA_ROUTER2) public void unregisterRouteListingPreferenceUpdatedCallback( @NonNull Consumer callback) { Objects.requireNonNull(callback, "callback must not be null"); if (!mListingPreferenceCallbackRecords.remove( new RouteListingPreferenceCallbackRecord(/* executor */ null, callback))) { Log.w( TAG, "unregisterRouteListingPreferenceUpdatedCallback: Ignoring an unknown" + " callback"); } } /** * Shows the system output switcher dialog. * *

Should only be called when the context of MediaRouter2 is in the foreground and visible on * the screen. * *

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. * *

See Output * Switcher documentation 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() { return mImpl.showSystemOutputSwitcher(); } /** * Sets the {@link RouteListingPreference} of the app associated to this media router. * *

Use this method to inform the system UI of the routes that you would like to list for * media routing, via the Output Switcher. * *

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. * *

Any calls to this method from a privileged router will throw an {@link * UnsupportedOperationException}. * *

Notes: * *

    *
  1. 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. *
  2. 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. *
  3. 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. *
* * @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) { mImpl.setRouteListingPreference(routeListingPreference); } /** * Returns the current {@link RouteListingPreference} of the target router. * *

If this instance was created using {@code #getInstance(Context, String)}, then it returns * the last {@link RouteListingPreference} set by the process this router was created for. * * @see #setRouteListingPreference(RouteListingPreference) */ @FlaggedApi(FLAG_ENABLE_RLP_CALLBACKS_IN_MEDIA_ROUTER2) @Nullable public RouteListingPreference getRouteListingPreference() { synchronized (mLock) { return mRouteListingPreference; } } @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. * *

This will return an empty list for non-system media routers. * * @hide */ @SystemApi @NonNull public List getAllRoutes() { return mImpl.getAllRoutes(); } /** * Gets the unmodifiable list of {@link MediaRoute2Info routes} currently known to the media * router. * *

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 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"); } } /** * 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"); } } /** * 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"); } } /** * 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"); } } /** * Sets an {@link OnGetControllerHintsListener} to send hints when creating a * {@link RoutingController}. To send the hints, listener should be set BEFORE 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) { mImpl.setOnGetControllerHintsListener(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) { mImpl.transferTo(route); } /** * Stops the current media routing. If the {@link #getSystemController() system controller} * controls the media routing, this method is a no-op. */ public void stop() { mImpl.stop(); } /** * Transfers the media of a routing controller to the given route. * *

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(android.Manifest.permission.MEDIA_CONTENT_CONTROL) public void transfer(@NonNull RoutingController controller, @NonNull MediaRoute2Info route) { mImpl.transfer(controller.getRoutingSessionInfo(), route); } void requestCreateController( @NonNull RoutingController controller, @NonNull MediaRoute2Info route, long managerRequestId, @NonNull UserHandle transferInitiatorUserHandle, @NonNull String transferInitiatorPackageName) { 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, transferInitiatorUserHandle, transferInitiatorPackageName); } 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 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. * *

Note: The system controller can't be released. Calling {@link RoutingController#release()} * will be ignored. * *

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. * *

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 getControllers() { return mImpl.getControllers(); } /** * Sets the volume for a specific route. * *

The call may have no effect if the route is currently not selected. * *

This method is only supported by {@link #getInstance(Context, String) proxy MediaRouter2 * instances}. Use {@link RoutingController#setVolume(int) RoutingController#setVolume(int)} * instead for {@link #getInstance(Context) local MediaRouter2 instances}.

* * @param volume The new volume value between 0 and {@link MediaRoute2Info#getVolumeMax}. * @throws UnsupportedOperationException If called on a {@link #getInstance(Context) local * router instance}. */ @FlaggedApi(FLAG_ENABLE_PRIVILEGED_ROUTING_FOR_MEDIA_ROUTING_CONTROL) @RequiresPermission( anyOf = { Manifest.permission.MEDIA_CONTENT_CONTROL, Manifest.permission.MEDIA_ROUTING_CONTROL }) public void setRouteVolume(@NonNull MediaRoute2Info route, int volume) { Objects.requireNonNull(route, "route must not be null"); mImpl.setRouteVolume(route, volume); } void syncRoutesOnHandler( List currentRoutes, RoutingSessionInfo currentSystemSessionInfo) { if (currentRoutes == null || currentRoutes.isEmpty() || currentSystemSessionInfo == null) { Log.e(TAG, "syncRoutesOnHandler: Received wrong data. currentRoutes=" + currentRoutes + ", currentSystemSessionInfo=" + currentSystemSessionInfo); return; } updateRoutesOnHandler(currentRoutes); RoutingSessionInfo oldInfo = mSystemController.getRoutingSessionInfo(); mSystemController.setRoutingSessionInfo(ensureClientPackageNameForSystemSession( currentSystemSessionInfo, mContext.getPackageName())); if (!oldInfo.equals(currentSystemSessionInfo)) { notifyControllerUpdated(mSystemController); } } void dispatchFilteredRoutesUpdatedOnHandler(List newRoutes) { List addedRoutes = new ArrayList<>(); List removedRoutes = new ArrayList<>(); List changedRoutes = new ArrayList<>(); Set newRouteIds = newRoutes.stream().map(MediaRoute2Info::getId).collect(Collectors.toSet()); for (MediaRoute2Info route : newRoutes) { MediaRoute2Info prevRoute = mPreviousFilteredRoutes.get(route.getId()); if (prevRoute == null) { addedRoutes.add(route); } else if (!prevRoute.equals(route)) { changedRoutes.add(route); } } for (int i = 0; i < mPreviousFilteredRoutes.size(); i++) { if (!newRouteIds.contains(mPreviousFilteredRoutes.keyAt(i))) { removedRoutes.add(mPreviousFilteredRoutes.valueAt(i)); } } // update previous routes for (MediaRoute2Info route : removedRoutes) { mPreviousFilteredRoutes.remove(route.getId()); } for (MediaRoute2Info route : addedRoutes) { mPreviousFilteredRoutes.put(route.getId(), route); } for (MediaRoute2Info route : changedRoutes) { mPreviousFilteredRoutes.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 dispatchControllerUpdatedIfNeededOnHandler(Map routesMap) { List controllers = getControllers(); for (RoutingController controller : controllers) { for (String selectedRoute : controller.getRoutingSessionInfo().getSelectedRoutes()) { if (routesMap.containsKey(selectedRoute) && mPreviousUnfilteredRoutes.containsKey(selectedRoute)) { MediaRoute2Info currentRoute = routesMap.get(selectedRoute); MediaRoute2Info oldRoute = mPreviousUnfilteredRoutes.get(selectedRoute); if (!currentRoute.equals(oldRoute)) { notifyControllerUpdated(controller); break; } } } } mPreviousUnfilteredRoutes.clear(); mPreviousUnfilteredRoutes.putAll(routesMap); } void updateRoutesOnHandler(List newRoutes) { synchronized (mLock) { mRoutes.clear(); for (MediaRoute2Info route : newRoutes) { 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)); mHandler.sendMessage( obtainMessage( MediaRouter2::dispatchControllerUpdatedIfNeededOnHandler, this, new HashMap<>(mRoutes))); } /** * Creates a controller and calls the {@link TransferCallback#onTransfer}. If the controller * creation has failed, then it calls {@link TransferCallback#onTransferFailure}. * *

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 = addRoutingController(sessionInfo); notifyTransfer(oldController, newController); } @NonNull private RoutingController addRoutingController(@NonNull RoutingSessionInfo session) { RoutingController controller; if (session.isSystemSession()) { // mSystemController is never released, so we only need to update its status. mSystemController.setRoutingSessionInfo(session); controller = mSystemController; } else { controller = new RoutingController(session); synchronized (mLock) { mNonSystemRoutingControllers.put(controller.getId(), controller); } } return controller; } void updateControllerOnHandler(RoutingSessionInfo sessionInfo) { if (sessionInfo == null) { Log.w(TAG, "updateControllerOnHandler: Ignoring null sessionInfo."); return; } RoutingController controller = getMatchingController(sessionInfo, /* logPrefix */ "updateControllerOnHandler"); if (controller != null) { controller.setRoutingSessionInfo(sessionInfo); notifyControllerUpdated(controller); } } void releaseControllerOnHandler(RoutingSessionInfo sessionInfo) { if (sessionInfo == null) { Log.w(TAG, "releaseControllerOnHandler: Ignoring null sessionInfo."); return; } RoutingController matchingController = getMatchingController(sessionInfo, /* logPrefix */ "releaseControllerOnHandler"); if (matchingController != null) { matchingController.releaseInternal(/* shouldReleaseSession= */ false); } } @Nullable private RoutingController getMatchingController( RoutingSessionInfo sessionInfo, String logPrefix) { if (sessionInfo.isSystemSession()) { return getSystemController(); } else { RoutingController controller; synchronized (mLock) { controller = mNonSystemRoutingControllers.get(sessionInfo.getId()); } if (controller == null) { Log.w( TAG, logPrefix + ": Matching controller not found. uniqueSessionId=" + sessionInfo.getId()); return null; } RoutingSessionInfo oldInfo = controller.getRoutingSessionInfo(); if (!TextUtils.equals(oldInfo.getProviderId(), sessionInfo.getProviderId())) { Log.w( TAG, logPrefix + ": Provider IDs are not matched. old=" + oldInfo.getProviderId() + ", new=" + sessionInfo.getProviderId()); return null; } return controller; } } void onRequestCreateControllerByManagerOnHandler( RoutingSessionInfo oldSession, MediaRoute2Info route, long managerRequestId, @NonNull UserHandle transferInitiatorUserHandle, @NonNull String transferInitiatorPackageName) { Log.i( TAG, TextUtils.formatSimple( "requestCreateSessionByManager | requestId: %d, oldSession: %s, route: %s", managerRequestId, oldSession, route)); RoutingController controller; if (oldSession.isSystemSession()) { controller = getSystemController(); } else { synchronized (mLock) { controller = mNonSystemRoutingControllers.get(oldSession.getId()); } } if (controller == null) { return; } requestCreateController(controller, route, managerRequestId, transferInitiatorUserHandle, transferInitiatorPackageName); } private List getSortedRoutes( List routes, List packageOrder) { if (packageOrder.isEmpty()) { return routes; } Map 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 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 filterRoutesWithCompositePreferenceLocked( List routes) { Set deduplicationIdSet = new ArraySet<>(); List 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; } @NonNull private List getRoutesWithIds(@NonNull List routeIds) { synchronized (mLock) { return routeIds.stream() .map(mRoutes::get) .filter(Objects::nonNull) .collect(Collectors.toList()); } } private void notifyRoutesAdded(List routes) { for (RouteCallbackRecord record : mRouteCallbackRecords) { List filteredRoutes = mImpl.filterRoutesWithIndividualPreference(routes, record.mPreference); if (!filteredRoutes.isEmpty()) { record.mExecutor.execute(() -> record.mRouteCallback.onRoutesAdded(filteredRoutes)); } } } private void notifyRoutesRemoved(List routes) { for (RouteCallbackRecord record : mRouteCallbackRecords) { List filteredRoutes = mImpl.filterRoutesWithIndividualPreference(routes, record.mPreference); if (!filteredRoutes.isEmpty()) { record.mExecutor.execute( () -> record.mRouteCallback.onRoutesRemoved(filteredRoutes)); } } } private void notifyRoutesChanged(List routes) { for (RouteCallbackRecord record : mRouteCallbackRecords) { List filteredRoutes = mImpl.filterRoutesWithIndividualPreference(routes, record.mPreference); if (!filteredRoutes.isEmpty()) { record.mExecutor.execute( () -> record.mRouteCallback.onRoutesChanged(filteredRoutes)); } } } private void notifyRoutesUpdated(List routes) { for (RouteCallbackRecord record : mRouteCallbackRecords) { List filteredRoutes = mImpl.filterRoutesWithIndividualPreference(routes, record.mPreference); record.mExecutor.execute(() -> record.mRouteCallback.onRoutesUpdated(filteredRoutes)); } } private void notifyPreferredFeaturesChanged(List features) { for (RouteCallbackRecord record : mRouteCallbackRecords) { record.mExecutor.execute( () -> record.mRouteCallback.onPreferredFeaturesChanged(features)); } } private void notifyRouteListingPreferenceUpdated(@Nullable RouteListingPreference preference) { for (RouteListingPreferenceCallbackRecord record : mListingPreferenceCallbackRecords) { record.mExecutor.execute( () -> record.mRouteListingPreferenceCallback.accept(preference)); } } 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 notifyRequestFailed(int reason) { for (TransferCallbackRecord record : mTransferCallbackRecords) { record.mExecutor.execute(() -> record.mTransferCallback.onRequestFailed(reason)); } } 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)); } } /** * Sets the routing session's {@linkplain RoutingSessionInfo#getClientPackageName() client * package name} to {@code packageName} if empty and returns the session. * *

This method must only be used for {@linkplain RoutingSessionInfo#isSystemSession() * system routing sessions}. */ private static RoutingSessionInfo ensureClientPackageNameForSystemSession( @NonNull RoutingSessionInfo sessionInfo, @NonNull String packageName) { if (!sessionInfo.isSystemSession() || !TextUtils.isEmpty(sessionInfo.getClientPackageName())) { return sessionInfo; } return new RoutingSessionInfo.Builder(sessionInfo) .setClientPackageName(packageName) .build(); } /** 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 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 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 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 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 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)}. * *

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) {} /** * Called when a routing request fails. * * @param reason Reason for failure as per {@link * android.media.MediaRoute2ProviderService.Reason} * @hide */ public void onRequestFailed(int reason) {} } /** * 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. * *

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) {} } /** * Represents an active scan request registered in the system. * *

See {@link #requestScan(ScanRequest)} for more information. */ @FlaggedApi(FLAG_ENABLE_SCREEN_OFF_SCANNING) public static final class ScanToken { private final int mId; private ScanToken(int id) { mId = id; } } /** * Represents a set of parameters for scanning requests. * *

See {@link #requestScan(ScanRequest)} for more details. */ @FlaggedApi(FLAG_ENABLE_SCREEN_OFF_SCANNING) public static final class ScanRequest { private final boolean mIsScreenOffScan; private ScanRequest(boolean isScreenOffScan) { mIsScreenOffScan = isScreenOffScan; } /** * Returns whether the scan request corresponds to a screen-off scan. * * @see #requestScan(ScanRequest) */ public boolean isScreenOffScan() { return mIsScreenOffScan; } /** * Builder class for {@link ScanRequest}. * * @see #requestScan(ScanRequest) */ public static final class Builder { boolean mIsScreenOffScan; /** * Creates a builder for a {@link ScanRequest} instance. * * @see #requestScan(ScanRequest) */ public Builder() {} /** * Sets whether the app is requesting to scan even while the screen is off, bypassing * default scanning restrictions. Only companion apps holding {@link * Manifest.permission#MEDIA_ROUTING_CONTROL} should set this to {@code true}. * * @see #requestScan(ScanRequest) */ @NonNull public Builder setScreenOffScan(boolean isScreenOffScan) { mIsScreenOffScan = isScreenOffScan; return this; } /** Returns a new {@link ScanRequest} instance. */ @NonNull public ScanRequest build() { return new ScanRequest(mIsScreenOffScan); } } } /** * 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 getSelectedRoutes() { List selectedRouteIds; synchronized (mControllerLock) { selectedRouteIds = mSessionInfo.getSelectedRoutes(); } return getRoutesWithIds(selectedRouteIds); } /** * @return the unmodifiable list of selectable routes for the session. */ @NonNull public List getSelectableRoutes() { List selectableRouteIds; synchronized (mControllerLock) { selectableRouteIds = mSessionInfo.getSelectableRoutes(); } return getRoutesWithIds(selectableRouteIds); } /** * @return the unmodifiable list of deselectable routes for the session. */ @NonNull public List getDeselectableRoutes() { List deselectableRouteIds; synchronized (mControllerLock) { deselectableRouteIds = mSessionInfo.getDeselectableRoutes(); } return getRoutesWithIds(deselectableRouteIds); } /** * Returns the unmodifiable list of transferable routes for the session. * * @see RoutingSessionInfo#getTransferableRoutes() */ @FlaggedApi(FLAG_ENABLE_GET_TRANSFERABLE_ROUTES) @NonNull public List getTransferableRoutes() { List transferableRoutes; synchronized (mControllerLock) { transferableRoutes = mSessionInfo.getTransferableRoutes(); } return getRoutesWithIds(transferableRoutes); } /** * Returns whether the transfer was initiated by the calling app (as determined by comparing * {@link UserHandle} and package name). */ @FlaggedApi(FLAG_ENABLE_BUILT_IN_SPEAKER_ROUTE_SUITABILITY_STATUSES) public boolean wasTransferInitiatedBySelf() { return mImpl.wasTransferredBySelf(getRoutingSessionInfo()); } /** * 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. * *

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. * *

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. * *

The given route must satisfy all of the following conditions: * *

    *
  • It should not be included in {@link #getSelectedRoutes()} *
  • It should be included in {@link #getSelectableRoutes()} *
* * 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 selectedRoutes = getSelectedRoutes(); if (containsRouteInfoWithId(selectedRoutes, route.getId())) { Log.w(TAG, "Ignoring selecting a route that is already selected. route=" + route); return; } List selectableRoutes = getSelectableRoutes(); if (!containsRouteInfoWithId(selectableRoutes, route.getId())) { Log.w(TAG, "Ignoring selecting a non-selectable route=" + route); return; } mImpl.selectRoute(route, getRoutingSessionInfo()); } /** * Deselects a route from the remote session. After a route is deselected, the media is * expected to be stopped on the deselected route. * *

The given route must satisfy all of the following conditions: * *

    *
  • It should be included in {@link #getSelectedRoutes()} *
  • It should be included in {@link #getDeselectableRoutes()} *
* * 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 selectedRoutes = getSelectedRoutes(); if (!containsRouteInfoWithId(selectedRoutes, route.getId())) { Log.w(TAG, "Ignoring deselecting a route that is not selected. route=" + route); return; } List deselectableRoutes = getDeselectableRoutes(); if (!containsRouteInfoWithId(deselectableRoutes, route.getId())) { Log.w(TAG, "Ignoring deselecting a non-deselectable route=" + route); return; } mImpl.deselectRoute(route, getRoutingSessionInfo()); } /** * Attempts a transfer to a {@link RoutingSessionInfo#getTransferableRoutes() transferable * route}. * *

Transferring to a transferable route does not require the app to transfer the playback * state from one route to the other. The route provider completely manages the transfer. An * example of provider-managed transfers are the switches between the system's routes, like * the built-in speakers and a BT headset. * * @return True if the transfer is handled by this controller, or false if a new controller * should be created instead. * @see RoutingSessionInfo#getSelectedRoutes() * @see RoutingSessionInfo#getTransferableRoutes() * @see ControllerCallback#onControllerUpdated */ boolean tryTransferWithinProvider(@NonNull MediaRoute2Info route) { Objects.requireNonNull(route, "route must not be null"); synchronized (mControllerLock) { if (isReleased()) { Log.w( TAG, "tryTransferWithinProvider: Called on released controller. Ignoring."); return true; } // If this call is trying to transfer to a selected system route, we let them // through as a provider driven transfer in order to update the transfer reason and // initiator data. boolean isSystemRouteReselection = Flags.enableBuiltInSpeakerRouteSuitabilityStatuses() && mSessionInfo.isSystemSession() && route.isSystemRoute() && mSessionInfo.getSelectedRoutes().contains(route.getId()); if (!isSystemRouteReselection && !mSessionInfo.getTransferableRoutes().contains(route.getId())) { Log.i( TAG, "Transferring to a non-transferable route=" + route + " session= " + mSessionInfo.getId()); return false; } } 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); } } return true; } /** * 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; } mImpl.setSessionVolume(volume, getRoutingSessionInfo()); } /** * 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; } mImpl.releaseSession(shouldReleaseSession, shouldNotifyStop, this); } @Override public String toString() { // To prevent logging spam, we only print the ID of each route. List selectedRoutes = getSelectedRoutes().stream() .map(MediaRoute2Info::getId) .collect(Collectors.toList()); List selectableRoutes = getSelectableRoutes().stream() .map(MediaRoute2Info::getId) .collect(Collectors.toList()); List 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; } } /** Returns whether any route in {@code routeList} has a same unique ID with given route. */ private static boolean containsRouteInfoWithId( @NonNull List routeList, @NonNull String routeId) { for (MediaRoute2Info info : routeList) { if (TextUtils.equals(routeId, info.getId())) { return true; } } return false; } } 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(); } } private static final class RouteListingPreferenceCallbackRecord { public final Executor mExecutor; public final Consumer mRouteListingPreferenceCallback; /* package */ RouteListingPreferenceCallbackRecord( @NonNull Executor executor, @NonNull Consumer routeListingPreferenceCallback) { mExecutor = executor; mRouteListingPreferenceCallback = routeListingPreferenceCallback; } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (!(obj instanceof RouteListingPreferenceCallbackRecord)) { return false; } return mRouteListingPreferenceCallback == ((RouteListingPreferenceCallbackRecord) obj).mRouteListingPreferenceCallback; } @Override public int hashCode() { return mRouteListingPreferenceCallback.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 currentRoutes, RoutingSessionInfo currentSystemSessionInfo) { mHandler.sendMessage( obtainMessage( MediaRouter2::syncRoutesOnHandler, MediaRouter2.this, currentRoutes, currentSystemSessionInfo)); } @Override public void notifyRoutesUpdated(List 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, UserHandle transferInitiatorUserHandle, String transferInitiatorPackageName) { mHandler.sendMessage( obtainMessage( MediaRouter2::onRequestCreateControllerByManagerOnHandler, MediaRouter2.this, oldSession, route, managerRequestId, transferInitiatorUserHandle, transferInitiatorPackageName)); } } /** * Provides a common interface for separating {@link LocalMediaRouter2Impl local} and {@link * ProxyMediaRouter2Impl proxy} {@link MediaRouter2} instances. */ private interface MediaRouter2Impl { void updateScanningState(@ScanningState int scanningState) throws RemoteException; void startScan(); void stopScan(); String getClientPackageName(); String getPackageName(); RoutingSessionInfo getSystemSessionInfo(); RouteCallbackRecord createRouteCallbackRecord( @NonNull @CallbackExecutor Executor executor, @NonNull RouteCallback routeCallback, @NonNull RouteDiscoveryPreference preference); void registerRouteCallback(); void unregisterRouteCallback(); void setRouteListingPreference(@Nullable RouteListingPreference preference); boolean showSystemOutputSwitcher(); List getAllRoutes(); void setOnGetControllerHintsListener(OnGetControllerHintsListener listener); void transferTo(MediaRoute2Info route); void stop(); void transfer(@NonNull RoutingSessionInfo sessionInfo, @NonNull MediaRoute2Info route); List getControllers(); void setRouteVolume(MediaRoute2Info route, int volume); List filterRoutesWithIndividualPreference( List routes, RouteDiscoveryPreference discoveryPreference); // RoutingController methods. void setSessionVolume(int volume, RoutingSessionInfo sessionInfo); void selectRoute(MediaRoute2Info route, RoutingSessionInfo sessionInfo); void deselectRoute(MediaRoute2Info route, RoutingSessionInfo sessionInfo); void releaseSession( boolean shouldReleaseSession, boolean shouldNotifyStop, RoutingController controller); /** * Returns the value of {@link RoutingController#wasTransferInitiatedBySelf()} for the app * associated with this router. */ boolean wasTransferredBySelf(RoutingSessionInfo sessionInfo); } /** * Implements logic specific to proxy {@link MediaRouter2} instances. * *

A proxy {@link MediaRouter2} instance controls the routing of a different package and can * be obtained by calling {@link #getInstance(Context, String)}. This requires {@link * Manifest.permission#MEDIA_CONTENT_CONTROL MEDIA_CONTENT_CONTROL} permission. * *

Proxy routers behave differently than local routers. See {@link #getInstance(Context, * String)} for more details. */ private class ProxyMediaRouter2Impl implements MediaRouter2Impl { // Fields originating from MediaRouter2Manager. private final IMediaRouter2Manager.Stub mClient; private final CopyOnWriteArrayList mTransferRequests = new CopyOnWriteArrayList<>(); private final AtomicInteger mScanRequestCount = new AtomicInteger(/* initialValue= */ 0); // Fields originating from MediaRouter2. @NonNull private final String mClientPackageName; @NonNull private final UserHandle mClientUser; private final AtomicBoolean mIsScanning = new AtomicBoolean(/* initialValue= */ false); @GuardedBy("mLock") private final List mInstanceInvalidatedCallbackRecords = new ArrayList<>(); ProxyMediaRouter2Impl( @NonNull Context context, @NonNull String clientPackageName, @NonNull UserHandle user) { mClientUser = user; mClientPackageName = clientPackageName; mClient = new Client(); mDiscoveryPreference = RouteDiscoveryPreference.EMPTY; } public void registerProxyRouter() { try { mMediaRouterService.registerProxyRouter( mClient, mContext.getApplicationContext().getPackageName(), mClientPackageName, mClientUser); } catch (RemoteException ex) { throw ex.rethrowFromSystemServer(); } } public void registerInstanceInvalidatedCallback( @Nullable Executor executor, @Nullable Runnable onInstanceInvalidatedListener) { if (executor == null || onInstanceInvalidatedListener == null) { return; } InstanceInvalidatedCallbackRecord record = new InstanceInvalidatedCallbackRecord(executor, onInstanceInvalidatedListener); synchronized (mLock) { if (!mInstanceInvalidatedCallbackRecords.contains(record)) { mInstanceInvalidatedCallbackRecords.add(record); } } } @Override public void updateScanningState(int scanningState) throws RemoteException { mMediaRouterService.updateScanningState(mClient, scanningState); } @Override public void startScan() { if (!mIsScanning.getAndSet(true)) { if (mScanRequestCount.getAndIncrement() == 0) { try { mMediaRouterService.updateScanningState( mClient, SCANNING_STATE_WHILE_INTERACTIVE); } catch (RemoteException ex) { throw ex.rethrowFromSystemServer(); } } } } @Override public void stopScan() { if (mIsScanning.getAndSet(false)) { if (mScanRequestCount.updateAndGet( count -> { if (count == 0) { throw new IllegalStateException( "No active scan requests to unregister."); } else { return --count; } }) == 0) { try { mMediaRouterService.updateScanningState( mClient, SCANNING_STATE_NOT_SCANNING); } catch (RemoteException ex) { throw ex.rethrowFromSystemServer(); } } } } @Override public String getClientPackageName() { return mClientPackageName; } /** * Returns {@code null}. This refers to the package name of the caller app, which is only * relevant for local routers. */ @Override public String getPackageName() { return null; } @Override public RoutingSessionInfo getSystemSessionInfo() { return getSystemSessionInfoImpl( mMediaRouterService, mContext.getPackageName(), mClientPackageName); } /** * {@link RouteDiscoveryPreference Discovery preferences} are ignored for proxy routers, as * their callbacks should receive events related to the media app's preferences. This is * equivalent to setting {@link RouteDiscoveryPreference#EMPTY empty preferences}. */ @Override public RouteCallbackRecord createRouteCallbackRecord( Executor executor, RouteCallback routeCallback, RouteDiscoveryPreference preference) { return new RouteCallbackRecord(executor, routeCallback, RouteDiscoveryPreference.EMPTY); } /** * No-op. Only local routers communicate directly with {@link * com.android.server.media.MediaRouter2ServiceImpl MediaRouter2ServiceImpl} and modify * {@link RouteDiscoveryPreference}. Proxy routers receive callbacks from {@link * MediaRouter2Manager}. */ @Override public void registerRouteCallback() { // Do nothing. } /** No-op. See {@link ProxyMediaRouter2Impl#registerRouteCallback()}. */ @Override public void unregisterRouteCallback() { // Do nothing. } @Override public void setRouteListingPreference(@Nullable RouteListingPreference preference) { throw new UnsupportedOperationException( "RouteListingPreference cannot be set by a privileged MediaRouter2 instance."); } @Override public boolean showSystemOutputSwitcher() { throw new UnsupportedOperationException( "Cannot show system output switcher from a privileged router."); } /** Gets the list of all discovered routes. */ @Override public List getAllRoutes() { synchronized (mLock) { return new ArrayList<>(mRoutes.values()); } } /** No-op. Controller hints can only be provided by the media app through a local router. */ @Override public void setOnGetControllerHintsListener(OnGetControllerHintsListener listener) { // Do nothing. } /** * Transfers the current {@link RoutingSessionInfo routing session} associated with the * router's {@link #mClientPackageName client package name} to a specified {@link * MediaRoute2Info route}. * *

This method is equivalent to {@link #transfer(RoutingSessionInfo, MediaRoute2Info)}, * except that the {@link RoutingSessionInfo routing session} is resolved based on the * router's {@link #mClientPackageName client package name}. * * @param route The route to transfer to. */ @Override public void transferTo(MediaRoute2Info route) { Objects.requireNonNull(route, "route must not be null"); List sessionInfos = getRoutingSessions(); RoutingSessionInfo targetSession = sessionInfos.get(sessionInfos.size() - 1); transfer(targetSession, route); } @Override public void stop() { List sessionInfos = getRoutingSessions(); RoutingSessionInfo sessionToRelease = sessionInfos.get(sessionInfos.size() - 1); releaseSession(sessionToRelease); } /** * Transfers a {@link RoutingSessionInfo routing session} to a {@link MediaRoute2Info * route}. * *

{@link #onTransferred} is called on success or {@link #onTransferFailed} is called if * the request fails. * *

This method will default for in-session transfer if the {@link MediaRoute2Info route} * is a {@link RoutingSessionInfo#getTransferableRoutes() transferable route}. Otherwise, it * will attempt an out-of-session transfer. * * @param sessionInfo The {@link RoutingSessionInfo routing session} to transfer. * @param route The {@link MediaRoute2Info route} to transfer to. * @see #transferToRoute(RoutingSessionInfo, MediaRoute2Info, UserHandle, String) * @see #requestCreateSession(RoutingSessionInfo, MediaRoute2Info) */ @Override @SuppressWarnings("AndroidFrameworkRequiresPermission") public void transfer( @NonNull RoutingSessionInfo sessionInfo, @NonNull MediaRoute2Info route) { Objects.requireNonNull(sessionInfo, "sessionInfo must not be null"); Objects.requireNonNull(route, "route must not be null"); Log.v( TAG, "Transferring routing session. session= " + sessionInfo + ", route=" + route); boolean isUnknownRoute; synchronized (mLock) { isUnknownRoute = !mRoutes.containsKey(route.getId()); } if (isUnknownRoute) { Log.w(TAG, "transfer: Ignoring an unknown route id=" + route.getId()); this.onTransferFailed(sessionInfo, route); return; } // If this call is trying to transfer to a selected system route, we let them // through as a provider driven transfer in order to update the transfer reason and // initiator data. boolean isSystemRouteReselection = Flags.enableBuiltInSpeakerRouteSuitabilityStatuses() && sessionInfo.isSystemSession() && route.isSystemRoute() && sessionInfo.getSelectedRoutes().contains(route.getId()); if (sessionInfo.getTransferableRoutes().contains(route.getId()) || isSystemRouteReselection) { transferToRoute(sessionInfo, route, mClientUser, mClientPackageName); } else { requestCreateSession(sessionInfo, route, mClientUser, mClientPackageName); } } /** * Requests an in-session transfer of a {@link RoutingSessionInfo routing session} to a * {@link MediaRoute2Info route}. * *

The provided {@link MediaRoute2Info route} must be listed in the {@link * RoutingSessionInfo routing session's} {@link RoutingSessionInfo#getTransferableRoutes() * transferable routes list}. Otherwise, the request will fail. * *

Use {@link #requestCreateSession(RoutingSessionInfo, MediaRoute2Info)} to request an * out-of-session transfer. * * @param session The {@link RoutingSessionInfo routing session} to transfer. * @param route The {@link MediaRoute2Info route} to transfer to. Must be one of the {@link * RoutingSessionInfo routing session's} {@link * RoutingSessionInfo#getTransferableRoutes() transferable routes}. */ @RequiresPermission(Manifest.permission.MEDIA_CONTENT_CONTROL) private void transferToRoute( @NonNull RoutingSessionInfo session, @NonNull MediaRoute2Info route, @NonNull UserHandle transferInitiatorUserHandle, @NonNull String transferInitiatorPackageName) { int requestId = createTransferRequest(session, route); try { mMediaRouterService.transferToRouteWithManager( mClient, requestId, session.getId(), route, transferInitiatorUserHandle, transferInitiatorPackageName); } catch (RemoteException ex) { throw ex.rethrowFromSystemServer(); } } /** * Requests an out-of-session transfer of a {@link RoutingSessionInfo routing session} to a * {@link MediaRoute2Info route}. * *

This request creates a new {@link RoutingSessionInfo routing session} regardless of * whether the {@link MediaRoute2Info route} is one of the {@link RoutingSessionInfo current * session's} {@link RoutingSessionInfo#getTransferableRoutes() transferable routes}. * *

Use {@link #transferToRoute(RoutingSessionInfo, MediaRoute2Info)} to request an * in-session transfer. * * @param oldSession The {@link RoutingSessionInfo routing session} to transfer. * @param route The {@link MediaRoute2Info route} to transfer to. */ private void requestCreateSession( @NonNull RoutingSessionInfo oldSession, @NonNull MediaRoute2Info route, @NonNull UserHandle transferInitiatorUserHandle, @NonNull String transferInitiatorPackageName) { if (TextUtils.isEmpty(oldSession.getClientPackageName())) { Log.w(TAG, "requestCreateSession: Can't create a session without package name."); this.onTransferFailed(oldSession, route); return; } int requestId = createTransferRequest(oldSession, route); try { mMediaRouterService.requestCreateSessionWithManager( mClient, requestId, oldSession, route, transferInitiatorUserHandle, transferInitiatorPackageName); } catch (RemoteException ex) { throw ex.rethrowFromSystemServer(); } } @Override public List getControllers() { List result = new ArrayList<>(); /* Unlike local MediaRouter2 instances, controller instances cannot be kept because transfer events initiated from other apps will not come through manager.*/ List sessions = getRoutingSessions(); for (RoutingSessionInfo session : sessions) { RoutingController controller; if (session.isSystemSession()) { mSystemController.setRoutingSessionInfo(session); controller = mSystemController; } else { controller = new RoutingController(session); } result.add(controller); } return result; } /** * Requests a volume change for a {@link MediaRoute2Info route}. * *

It may have no effect if the {@link MediaRoute2Info route} is not currently selected. * * @param volume The desired volume value between 0 and {@link * MediaRoute2Info#getVolumeMax()} (inclusive). */ @Override public void setRouteVolume(@NonNull MediaRoute2Info route, int volume) { if (route.getVolumeHandling() == MediaRoute2Info.PLAYBACK_VOLUME_FIXED) { Log.w(TAG, "setRouteVolume: the route has fixed volume. Ignoring."); return; } if (volume < 0 || volume > route.getVolumeMax()) { Log.w(TAG, "setRouteVolume: the target volume is out of range. Ignoring"); return; } try { int requestId = mNextRequestId.getAndIncrement(); mMediaRouterService.setRouteVolumeWithManager(mClient, requestId, route, volume); } catch (RemoteException ex) { throw ex.rethrowFromSystemServer(); } } /** * Requests a volume change for a {@link RoutingSessionInfo routing session}. * * @param volume The desired volume value between 0 and {@link * RoutingSessionInfo#getVolumeMax()} (inclusive). */ @Override public void setSessionVolume(int volume, RoutingSessionInfo sessionInfo) { Objects.requireNonNull(sessionInfo, "sessionInfo must not be null"); if (sessionInfo.getVolumeHandling() == MediaRoute2Info.PLAYBACK_VOLUME_FIXED) { Log.w(TAG, "setSessionVolume: the route has fixed volume. Ignoring."); return; } if (volume < 0 || volume > sessionInfo.getVolumeMax()) { Log.w(TAG, "setSessionVolume: the target volume is out of range. Ignoring"); return; } try { int requestId = mNextRequestId.getAndIncrement(); mMediaRouterService.setSessionVolumeWithManager( mClient, requestId, sessionInfo.getId(), volume); } catch (RemoteException ex) { throw ex.rethrowFromSystemServer(); } } /** * Returns an exact copy of the routes. Individual {@link RouteDiscoveryPreference * preferences} do not apply to proxy routers. */ @Override public List filterRoutesWithIndividualPreference( List routes, RouteDiscoveryPreference discoveryPreference) { // Individual discovery preferences do not apply for the system router. return new ArrayList<>(routes); } /** * Adds a {@linkplain MediaRoute2Info route} to the routing session's {@linkplain * RoutingSessionInfo#getSelectedRoutes() selected route list}. * *

Upon success, {@link #onSessionUpdated(RoutingSessionInfo)} is invoked. Failed * requests are silently ignored. * *

The {@linkplain RoutingSessionInfo#getSelectedRoutes() selected routes list} of a * routing session contains the group of devices playing media for that {@linkplain * RoutingSessionInfo session}. * *

The given route must not be already selected and must be listed in the session's * {@linkplain RoutingSessionInfo#getSelectableRoutes() selectable routes}. Otherwise, the * request will be ignored. * *

This method should not be confused with {@link #transfer(RoutingSessionInfo, * MediaRoute2Info)}. * * @see RoutingSessionInfo#getSelectedRoutes() * @see RoutingSessionInfo#getSelectableRoutes() */ @Override public void selectRoute(MediaRoute2Info route, RoutingSessionInfo sessionInfo) { Objects.requireNonNull(sessionInfo, "sessionInfo must not be null"); Objects.requireNonNull(route, "route must not be null"); if (sessionInfo.getSelectedRoutes().contains(route.getId())) { Log.w(TAG, "Ignoring selecting a route that is already selected. route=" + route); return; } if (!sessionInfo.getSelectableRoutes().contains(route.getId())) { Log.w(TAG, "Ignoring selecting a non-selectable route=" + route); return; } try { int requestId = mNextRequestId.getAndIncrement(); mMediaRouterService.selectRouteWithManager( mClient, requestId, sessionInfo.getId(), route); } catch (RemoteException ex) { throw ex.rethrowFromSystemServer(); } } /** * Removes a route from a session's {@linkplain RoutingSessionInfo#getSelectedRoutes() * selected routes list}. Calls {@link #onSessionUpdated(RoutingSessionInfo)} on success. * *

The given route must be selected and must be listed in the session's {@linkplain * RoutingSessionInfo#getDeselectableRoutes() deselectable route list}. Otherwise, the * request will be ignored. * * @see RoutingSessionInfo#getSelectedRoutes() * @see RoutingSessionInfo#getDeselectableRoutes() */ @Override public void deselectRoute(MediaRoute2Info route, RoutingSessionInfo sessionInfo) { Objects.requireNonNull(sessionInfo, "sessionInfo must not be null"); Objects.requireNonNull(route, "route must not be null"); if (!sessionInfo.getSelectedRoutes().contains(route.getId())) { Log.w(TAG, "Ignoring deselecting a route that is not selected. route=" + route); return; } if (!sessionInfo.getDeselectableRoutes().contains(route.getId())) { Log.w(TAG, "Ignoring deselecting a non-deselectable route=" + route); return; } try { int requestId = mNextRequestId.getAndIncrement(); mMediaRouterService.deselectRouteWithManager( mClient, requestId, sessionInfo.getId(), route); } catch (RemoteException ex) { throw ex.rethrowFromSystemServer(); } } @Override public void releaseSession( boolean shouldReleaseSession, boolean shouldNotifyStop, RoutingController controller) { releaseSession(controller.getRoutingSessionInfo()); } @Override public boolean wasTransferredBySelf(RoutingSessionInfo sessionInfo) { UserHandle transferInitiatorUserHandle = sessionInfo.getTransferInitiatorUserHandle(); String transferInitiatorPackageName = sessionInfo.getTransferInitiatorPackageName(); return Objects.equals(mClientUser, transferInitiatorUserHandle) && Objects.equals(mClientPackageName, transferInitiatorPackageName); } /** * Retrieves the system session info for the given package. * *

The returned routing session is guaranteed to have a non-null {@link * RoutingSessionInfo#getClientPackageName() client package name}. * *

Extracted into a static method to allow calling this from the constructor. */ /* package */ static RoutingSessionInfo getSystemSessionInfoImpl( @NonNull IMediaRouterService service, @NonNull String callerPackageName, @NonNull String clientPackageName) { try { return service.getSystemSessionInfoForPackage(callerPackageName, clientPackageName); } catch (RemoteException ex) { throw ex.rethrowFromSystemServer(); } } /** * Requests the release of a {@linkplain RoutingSessionInfo routing session}. Calls {@link * #onSessionReleasedOnHandler(RoutingSessionInfo)} on success. * *

Once released, a routing session ignores incoming requests. */ private void releaseSession(@NonNull RoutingSessionInfo sessionInfo) { Objects.requireNonNull(sessionInfo, "sessionInfo must not be null"); try { int requestId = mNextRequestId.getAndIncrement(); mMediaRouterService.releaseSessionWithManager( mClient, requestId, sessionInfo.getId()); } catch (RemoteException ex) { throw ex.rethrowFromSystemServer(); } } private int createTransferRequest( @NonNull RoutingSessionInfo session, @NonNull MediaRoute2Info route) { int requestId = mNextRequestId.getAndIncrement(); MediaRouter2Manager.TransferRequest transferRequest = new MediaRouter2Manager.TransferRequest(requestId, session, route); mTransferRequests.add(transferRequest); Message timeoutMessage = obtainMessage( ProxyMediaRouter2Impl::handleTransferTimeout, this, transferRequest); mHandler.sendMessageDelayed(timeoutMessage, TRANSFER_TIMEOUT_MS); return requestId; } private void handleTransferTimeout(MediaRouter2Manager.TransferRequest request) { boolean removed = mTransferRequests.remove(request); if (removed) { this.onTransferFailed(request.mOldSessionInfo, request.mTargetRoute); } } /** * Returns the {@linkplain RoutingSessionInfo routing sessions} associated with {@link * #mClientPackageName}. The first element of the returned list is the {@linkplain * #getSystemSessionInfo() system routing session}. * * @see #getSystemSessionInfo() */ @NonNull private List getRoutingSessions() { List sessions = new ArrayList<>(); sessions.add(getSystemSessionInfo()); List remoteSessions; try { remoteSessions = mMediaRouterService.getRemoteSessions(mClient); } catch (RemoteException ex) { throw ex.rethrowFromSystemServer(); } for (RoutingSessionInfo sessionInfo : remoteSessions) { if (TextUtils.equals(sessionInfo.getClientPackageName(), mClientPackageName)) { sessions.add(sessionInfo); } } return sessions; } private void onTransferred( @NonNull RoutingSessionInfo oldSession, @NonNull RoutingSessionInfo newSession) { if (!isSessionRelatedToTargetPackageName(oldSession) || !isSessionRelatedToTargetPackageName(newSession)) { return; } RoutingController oldController; if (oldSession.isSystemSession()) { mSystemController.setRoutingSessionInfo( ensureClientPackageNameForSystemSession(oldSession, mClientPackageName)); oldController = mSystemController; } else { oldController = new RoutingController(oldSession); } RoutingController newController; if (newSession.isSystemSession()) { mSystemController.setRoutingSessionInfo( ensureClientPackageNameForSystemSession(newSession, mClientPackageName)); newController = mSystemController; } else { newController = new RoutingController(newSession); } notifyTransfer(oldController, newController); } private void onTransferFailed( @NonNull RoutingSessionInfo session, @NonNull MediaRoute2Info route) { if (!isSessionRelatedToTargetPackageName(session)) { return; } notifyTransferFailure(route); } private void onSessionUpdated(@NonNull RoutingSessionInfo session) { if (!isSessionRelatedToTargetPackageName(session)) { return; } RoutingController controller; if (session.isSystemSession()) { mSystemController.setRoutingSessionInfo( ensureClientPackageNameForSystemSession(session, mClientPackageName)); controller = mSystemController; } else { controller = new RoutingController(session); } notifyControllerUpdated(controller); } /** * Returns {@code true} if the session is a system session or if its client package name * matches the proxy router's target package name. */ private boolean isSessionRelatedToTargetPackageName(@NonNull RoutingSessionInfo session) { return session.isSystemSession() || TextUtils.equals(getClientPackageName(), session.getClientPackageName()); } private void onSessionCreatedOnHandler( int requestId, @NonNull RoutingSessionInfo sessionInfo) { MediaRouter2Manager.TransferRequest matchingRequest = null; for (MediaRouter2Manager.TransferRequest request : mTransferRequests) { if (request.mRequestId == requestId) { matchingRequest = request; break; } } if (matchingRequest == null) { return; } mTransferRequests.remove(matchingRequest); MediaRoute2Info requestedRoute = matchingRequest.mTargetRoute; if (!sessionInfo.getSelectedRoutes().contains(requestedRoute.getId())) { Log.w( TAG, "The session does not contain the requested route. " + "(requestedRouteId=" + requestedRoute.getId() + ", actualRoutes=" + sessionInfo.getSelectedRoutes() + ")"); this.onTransferFailed(matchingRequest.mOldSessionInfo, requestedRoute); } 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() + ")"); this.onTransferFailed(matchingRequest.mOldSessionInfo, requestedRoute); } else { this.onTransferred(matchingRequest.mOldSessionInfo, sessionInfo); } } private void onSessionUpdatedOnHandler(@NonNull RoutingSessionInfo updatedSession) { for (MediaRouter2Manager.TransferRequest request : mTransferRequests) { String sessionId = request.mOldSessionInfo.getId(); if (!TextUtils.equals(sessionId, updatedSession.getId())) { continue; } if (updatedSession.getSelectedRoutes().contains(request.mTargetRoute.getId())) { mTransferRequests.remove(request); break; } } this.onSessionUpdated(updatedSession); } private void onSessionReleasedOnHandler(@NonNull RoutingSessionInfo session) { if (session.isSystemSession()) { Log.e(TAG, "onSessionReleasedOnHandler: Called on system session. Ignoring."); return; } if (!TextUtils.equals(getClientPackageName(), session.getClientPackageName())) { return; } notifyStop(new RoutingController(session, RoutingController.CONTROLLER_STATE_RELEASED)); } private void onDiscoveryPreferenceChangedOnHandler( @NonNull String packageName, @Nullable RouteDiscoveryPreference preference) { if (!TextUtils.equals(getClientPackageName(), packageName)) { return; } if (preference == null) { return; } synchronized (mLock) { if (Objects.equals(preference, mDiscoveryPreference)) { return; } mDiscoveryPreference = preference; updateFilteredRoutesLocked(); } notifyPreferredFeaturesChanged(preference.getPreferredFeatures()); } private void onRouteListingPreferenceChangedOnHandler( @NonNull String packageName, @Nullable RouteListingPreference routeListingPreference) { if (!TextUtils.equals(getClientPackageName(), packageName)) { return; } synchronized (mLock) { if (Objects.equals(mRouteListingPreference, routeListingPreference)) { return; } mRouteListingPreference = routeListingPreference; } notifyRouteListingPreferenceUpdated(routeListingPreference); } private void onRequestFailedOnHandler(int requestId, int reason) { MediaRouter2Manager.TransferRequest matchingRequest = null; for (MediaRouter2Manager.TransferRequest request : mTransferRequests) { if (request.mRequestId == requestId) { matchingRequest = request; break; } } if (matchingRequest != null) { mTransferRequests.remove(matchingRequest); onTransferFailed(matchingRequest.mOldSessionInfo, matchingRequest.mTargetRoute); } else { notifyRequestFailed(reason); } } private void onInvalidateInstanceOnHandler() { Log.w( TAG, "MEDIA_ROUTING_CONTROL has been revoked for this package. Invalidating" + " instance."); // After this block, all following getInstance() calls should throw a SecurityException, // so no new onInstanceInvalidatedListeners can be registered to this instance. synchronized (sSystemRouterLock) { PackageNameUserHandlePair key = new PackageNameUserHandlePair(mClientPackageName, mClientUser); sAppToProxyRouterMap.remove(key); } synchronized (mLock) { for (InstanceInvalidatedCallbackRecord record : mInstanceInvalidatedCallbackRecords) { record.executor.execute(record.runnable); } } mRouteCallbackRecords.clear(); mControllerCallbackRecords.clear(); mTransferCallbackRecords.clear(); } private class Client extends IMediaRouter2Manager.Stub { @Override public void notifySessionCreated(int requestId, RoutingSessionInfo routingSessionInfo) { mHandler.sendMessage( obtainMessage( ProxyMediaRouter2Impl::onSessionCreatedOnHandler, ProxyMediaRouter2Impl.this, requestId, routingSessionInfo)); } @Override public void notifySessionUpdated(RoutingSessionInfo routingSessionInfo) { mHandler.sendMessage( obtainMessage( ProxyMediaRouter2Impl::onSessionUpdatedOnHandler, ProxyMediaRouter2Impl.this, routingSessionInfo)); } @Override public void notifySessionReleased(RoutingSessionInfo routingSessionInfo) { mHandler.sendMessage( obtainMessage( ProxyMediaRouter2Impl::onSessionReleasedOnHandler, ProxyMediaRouter2Impl.this, routingSessionInfo)); } @Override public void notifyDiscoveryPreferenceChanged( String packageName, RouteDiscoveryPreference routeDiscoveryPreference) { mHandler.sendMessage( obtainMessage( ProxyMediaRouter2Impl::onDiscoveryPreferenceChangedOnHandler, ProxyMediaRouter2Impl.this, packageName, routeDiscoveryPreference)); } @Override public void notifyRouteListingPreferenceChange( String packageName, RouteListingPreference routeListingPreference) { mHandler.sendMessage( obtainMessage( ProxyMediaRouter2Impl::onRouteListingPreferenceChangedOnHandler, ProxyMediaRouter2Impl.this, packageName, routeListingPreference)); } @Override public void notifyRoutesUpdated(List routes) { mHandler.sendMessage( obtainMessage( MediaRouter2::updateRoutesOnHandler, MediaRouter2.this, routes)); } @Override public void notifyRequestFailed(int requestId, int reason) { mHandler.sendMessage( obtainMessage( ProxyMediaRouter2Impl::onRequestFailedOnHandler, ProxyMediaRouter2Impl.this, requestId, reason)); } @Override public void invalidateInstance() { mHandler.sendMessage( obtainMessage( ProxyMediaRouter2Impl::onInvalidateInstanceOnHandler, ProxyMediaRouter2Impl.this)); } } } /** * Implements logic specific to local {@link MediaRouter2} instances. * *

Local routers allow an app to control its own routing without any special permissions. * Apps can obtain an instance by calling {@link #getInstance(Context)}. */ private class LocalMediaRouter2Impl implements MediaRouter2Impl { private final String mPackageName; LocalMediaRouter2Impl(@NonNull String packageName) { mPackageName = packageName; } /** * No-op. Local routers cannot explicitly control route scanning. * *

Local routers can control scanning indirectly through {@link * #registerRouteCallback(Executor, RouteCallback, RouteDiscoveryPreference)}. */ @Override public void startScan() { // Do nothing. } /** * No-op. Local routers cannot explicitly control route scanning. * *

Local routers can control scanning indirectly through {@link * #registerRouteCallback(Executor, RouteCallback, RouteDiscoveryPreference)}. */ @Override public void stopScan() { // Do nothing. } @Override @GuardedBy("mLock") public void updateScanningState(int scanningState) throws RemoteException { if (scanningState != SCANNING_STATE_NOT_SCANNING) { registerRouterStubIfNeededLocked(); } mMediaRouterService.updateScanningStateWithRouter2(mStub, scanningState); if (scanningState == SCANNING_STATE_NOT_SCANNING) { unregisterRouterStubIfNeededLocked(/* isScanningStopping */ true); } } /** * Returns {@code null}. The client package name is only associated to proxy {@link * MediaRouter2} instances. */ @Override public String getClientPackageName() { return null; } @Override public String getPackageName() { return mPackageName; } @Override public RoutingSessionInfo getSystemSessionInfo() { RoutingSessionInfo currentSystemSessionInfo = null; try { currentSystemSessionInfo = ensureClientPackageNameForSystemSession( mMediaRouterService.getSystemSessionInfo(), mContext.getPackageName()); } catch (RemoteException ex) { ex.rethrowFromSystemServer(); } return currentSystemSessionInfo; } @Override public RouteCallbackRecord createRouteCallbackRecord( Executor executor, RouteCallback routeCallback, RouteDiscoveryPreference preference) { return new RouteCallbackRecord(executor, routeCallback, preference); } @Override public void registerRouteCallback() { synchronized (mLock) { try { registerRouterStubIfNeededLocked(); if (updateDiscoveryPreferenceIfNeededLocked()) { mMediaRouterService.setDiscoveryRequestWithRouter2( mStub, mDiscoveryPreference); } } catch (RemoteException ex) { ex.rethrowFromSystemServer(); } } } @Override public void unregisterRouteCallback() { synchronized (mLock) { if (mStub == null) { return; } try { if (updateDiscoveryPreferenceIfNeededLocked()) { mMediaRouterService.setDiscoveryRequestWithRouter2( mStub, mDiscoveryPreference); } unregisterRouterStubIfNeededLocked(/* isScanningStopping */ false); } catch (RemoteException ex) { Log.e(TAG, "unregisterRouteCallback: Unable to set discovery request.", ex); } } } @Override public void setRouteListingPreference(@Nullable RouteListingPreference preference) { synchronized (mLock) { if (Objects.equals(mRouteListingPreference, preference)) { // Nothing changed. We return early to save a call to the system server. return; } mRouteListingPreference = preference; try { registerRouterStubIfNeededLocked(); mMediaRouterService.setRouteListingPreference(mStub, mRouteListingPreference); } catch (RemoteException ex) { ex.rethrowFromSystemServer(); } notifyRouteListingPreferenceUpdated(preference); } } @Override public boolean showSystemOutputSwitcher() { synchronized (mLock) { try { return mMediaRouterService.showMediaOutputSwitcher(mImpl.getPackageName()); } catch (RemoteException ex) { ex.rethrowFromSystemServer(); } } return false; } /** * Returns {@link Collections#emptyList()}. Local routes can only access routes related to * their {@link RouteDiscoveryPreference} through {@link #getRoutes()}. */ @Override public List getAllRoutes() { return Collections.emptyList(); } @Override public void setOnGetControllerHintsListener(OnGetControllerHintsListener listener) { mOnGetControllerHintsListener = listener; } @Override public void transferTo(MediaRoute2Info route) { 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.tryTransferWithinProvider(route)) { requestCreateController( controller, route, MANAGER_REQUEST_ID_NONE, Process.myUserHandle(), mContext.getPackageName()); } } @Override public void stop() { getCurrentController().release(); } /** * No-op. Local routers cannot request transfers of specific {@link RoutingSessionInfo}. * This operation is only available to proxy routers. * *

Local routers can only transfer the current {@link RoutingSessionInfo} using {@link * #transferTo(MediaRoute2Info)}. */ @Override public void transfer( @NonNull RoutingSessionInfo sessionInfo, @NonNull MediaRoute2Info route) { // Do nothing. } @Override public List getControllers() { List result = new ArrayList<>(); result.add(0, mSystemController); synchronized (mLock) { result.addAll(mNonSystemRoutingControllers.values()); } return result; } /** Local routers cannot modify the volume of specific routes. */ @Override public void setRouteVolume(MediaRoute2Info route, int volume) { throw new UnsupportedOperationException( "setRouteVolume is only supported by proxy routers. See javadoc."); // If this API needs to be public, use IMediaRouterService#setRouteVolumeWithRouter2() } @Override public void setSessionVolume(int volume, RoutingSessionInfo sessionInfo) { MediaRouter2Stub stub; synchronized (mLock) { stub = mStub; } if (stub != null) { try { mMediaRouterService.setSessionVolumeWithRouter2( stub, sessionInfo.getId(), volume); } catch (RemoteException ex) { Log.e(TAG, "setVolume: Failed to deliver request.", ex); } } } @Override public List filterRoutesWithIndividualPreference( List routes, RouteDiscoveryPreference discoveryPreference) { List filteredRoutes = new ArrayList<>(); 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; } @Override public void selectRoute(MediaRoute2Info route, RoutingSessionInfo sessionInfo) { MediaRouter2Stub stub; synchronized (mLock) { stub = mStub; } if (stub != null) { try { mMediaRouterService.selectRouteWithRouter2(stub, sessionInfo.getId(), route); } catch (RemoteException ex) { Log.e(TAG, "Unable to select route for session.", ex); } } } @Override public void deselectRoute(MediaRoute2Info route, RoutingSessionInfo sessionInfo) { MediaRouter2Stub stub; synchronized (mLock) { stub = mStub; } if (stub != null) { try { mMediaRouterService.deselectRouteWithRouter2(stub, sessionInfo.getId(), route); } catch (RemoteException ex) { Log.e(TAG, "Unable to deselect route from session.", ex); } } } @Override public void releaseSession( boolean shouldReleaseSession, boolean shouldNotifyStop, RoutingController controller) { synchronized (mLock) { mNonSystemRoutingControllers.remove(controller.getId(), controller); if (shouldReleaseSession && mStub != null) { try { mMediaRouterService.releaseSessionWithRouter2(mStub, controller.getId()); } catch (RemoteException ex) { ex.rethrowFromSystemServer(); } } if (shouldNotifyStop) { mHandler.sendMessage( obtainMessage(MediaRouter2::notifyStop, MediaRouter2.this, controller)); } try { unregisterRouterStubIfNeededLocked(/* isScanningStopping */ false); } catch (RemoteException ex) { ex.rethrowFromSystemServer(); } } } @Override public boolean wasTransferredBySelf(RoutingSessionInfo sessionInfo) { UserHandle transferInitiatorUserHandle = sessionInfo.getTransferInitiatorUserHandle(); String transferInitiatorPackageName = sessionInfo.getTransferInitiatorPackageName(); return Objects.equals(Process.myUserHandle(), transferInitiatorUserHandle) && Objects.equals(mContext.getPackageName(), transferInitiatorPackageName); } @GuardedBy("mLock") private void registerRouterStubIfNeededLocked() throws RemoteException { if (mStub == null) { MediaRouter2Stub stub = new MediaRouter2Stub(); mMediaRouterService.registerRouter2(stub, mPackageName); mStub = stub; } } @GuardedBy("mLock") private void unregisterRouterStubIfNeededLocked(boolean isScanningStopping) throws RemoteException { if (mStub != null && mRouteCallbackRecords.isEmpty() && mNonSystemRoutingControllers.isEmpty() && (mScanRequestsMap.size() == 0 || isScanningStopping)) { mMediaRouterService.unregisterRouter2(mStub); mStub = null; } } } }