/* * 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 Uses {@link MediaRoute2Info#getId()} to set each entry's key.
*/
@GuardedBy("mLock")
private final Map 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 Proxy MediaRouter2 instances operate differently than regular MediaRouter2 instances:
*
* {@link #registerRouteCallback} ignores any {@link RouteDiscoveryPreference discovery
* preference} passed by a proxy router. Use {@link RouteDiscoveryPreference#EMPTY} when
* setting a route callback.
* Methods returning non-system {@link RoutingController controllers} always return new
* instances with the latest data. Do not attempt to compare or store them. Instead, use
* {@link #getController(String)} or {@link #getControllers()} to query the most
* up-to-date state.
* Calls to {@link #setOnGetControllerHintsListener} are ignored.
* 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:
*
* {@link #registerRouteCallback} ignores any {@link RouteDiscoveryPreference discovery
* preference} passed by a proxy router. Use {@link RouteDiscoveryPreference#EMPTY} when
* setting a route callback.
* Methods returning non-system {@link RoutingController controllers} always return new
* instances with the latest data. Do not attempt to compare or store them. Instead, use
* {@link #getController(String)} or {@link #getControllers()} to query the most
* up-to-date state.
* Calls to {@link #setOnGetControllerHintsListener} are ignored.
* 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:
*
* {@link #registerRouteCallback} ignores any {@link RouteDiscoveryPreference discovery
* preference} passed by a proxy router. Use a {@link RouteDiscoveryPreference} with empty
* {@link RouteDiscoveryPreference.Builder#setPreferredFeatures(List) preferred features}
* when setting a route callback.
* Methods returning non-system {@link RoutingController controllers} always return new
* instances with the latest data. Do not attempt to compare or store them. Instead, use
* {@link #getController(String)} or {@link #getControllers()} to query the most
* up-to-date state.
* Calls to {@link #setOnGetControllerHintsListener} are ignored.
* 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 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 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:
*
* 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 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 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 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 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}. 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 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 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 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:
*
* The given route must satisfy all of the following conditions:
*
* 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 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 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 {@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 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 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 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 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
*
*
*
*
*
*
*
*
* @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.
*
*
*
*
* @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 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
*
*
* 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