Files
frameworks_base/media/java/android/media/MediaRouter2.java
Kyunglyul Hyun c036350122 System routes are not automatically exposed
From this CL, even for system routes (phone speaker, bt routes),
features are required to get notified of events on routes or to
get them as "available" routes.

By adding this, apps can disable cast -> phone feature.

Bug: 159090706
Test: cts test && atest mediaroutertest && manually
using support v7 demos such that
  with LIVE_AUDIO : nothing changed
  w/o LIVE_AUDIO : cast -> phone feature is disabled
  unregistering callback : cast -> cast (media transfer) and
                           phone -> phone is only enabled <- this is
                           the expected behavior for apps that updates
                           AndroidX library

Change-Id: I4bd27eb1d4776b9cedb59b10e1bac5868d56d305
2020-06-19 22:54:59 +09:00

1545 lines
59 KiB
Java

/*
* Copyright 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.media;
import static com.android.internal.util.function.pooled.PooledLambda.obtainMessage;
import android.annotation.CallbackExecutor;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.TestApi;
import android.content.Context;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.RemoteException;
import android.os.ServiceManager;
import android.text.TextUtils;
import android.util.ArrayMap;
import android.util.Log;
import com.android.internal.annotations.GuardedBy;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
/**
* This API is not generally intended for third party application developers.
* Use the <a href="{@docRoot}jetpack/androidx.html">AndroidX</a>
<a href="{@docRoot}reference/androidx/mediarouter/media/package-summary.html">Media Router
* Library</a> for consistent behavior across all devices.
*
* Media Router 2 allows applications to control the routing of media channels
* and streams from the current device to remote speakers and devices.
*/
// TODO(b/157873330): Add method names at the beginning of log messages. (e.g. selectRoute)
// Not only MediaRouter2, but also to service / manager / provider.
// TODO: ensure thread-safe and document it
public final class MediaRouter2 {
private static final String TAG = "MR2";
private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
private static final Object sRouterLock = new Object();
@GuardedBy("sRouterLock")
private static MediaRouter2 sInstance;
private final Context mContext;
private final IMediaRouterService mMediaRouterService;
private final CopyOnWriteArrayList<RouteCallbackRecord> mRouteCallbackRecords =
new CopyOnWriteArrayList<>();
private final CopyOnWriteArrayList<TransferCallbackRecord> mTransferCallbackRecords =
new CopyOnWriteArrayList<>();
private final CopyOnWriteArrayList<ControllerCallbackRecord> mControllerCallbackRecords =
new CopyOnWriteArrayList<>();
private final CopyOnWriteArrayList<ControllerCreationRequest> mControllerCreationRequests =
new CopyOnWriteArrayList<>();
private final String mPackageName;
@GuardedBy("sRouterLock")
final Map<String, MediaRoute2Info> mRoutes = new HashMap<>();
final RoutingController mSystemController;
@GuardedBy("sRouterLock")
private RouteDiscoveryPreference mDiscoveryPreference = RouteDiscoveryPreference.EMPTY;
// TODO: Make MediaRouter2 is always connected to the MediaRouterService.
@GuardedBy("sRouterLock")
MediaRouter2Stub mStub;
@GuardedBy("sRouterLock")
private final Map<String, RoutingController> mNonSystemRoutingControllers = new ArrayMap<>();
private final AtomicInteger mControllerCreationRequestCnt = new AtomicInteger(1);
final Handler mHandler;
@GuardedBy("sRouterLock")
private boolean mShouldUpdateRoutes = true;
private volatile List<MediaRoute2Info> mFilteredRoutes = Collections.emptyList();
private volatile OnGetControllerHintsListener mOnGetControllerHintsListener;
/**
* Gets an instance of the media router associated with the context.
*/
@NonNull
public static MediaRouter2 getInstance(@NonNull Context context) {
Objects.requireNonNull(context, "context must not be null");
synchronized (sRouterLock) {
if (sInstance == null) {
sInstance = new MediaRouter2(context.getApplicationContext());
}
return sInstance;
}
}
private MediaRouter2(Context appContext) {
mContext = appContext;
mMediaRouterService = IMediaRouterService.Stub.asInterface(
ServiceManager.getService(Context.MEDIA_ROUTER_SERVICE));
mPackageName = mContext.getPackageName();
mHandler = new Handler(Looper.getMainLooper());
List<MediaRoute2Info> currentSystemRoutes = null;
RoutingSessionInfo currentSystemSessionInfo = null;
try {
currentSystemRoutes = mMediaRouterService.getSystemRoutes();
currentSystemSessionInfo = mMediaRouterService.getSystemSessionInfo();
} catch (RemoteException ex) {
Log.e(TAG, "Unable to get current system's routes / session info", ex);
}
if (currentSystemRoutes == null || currentSystemRoutes.isEmpty()) {
throw new RuntimeException("Null or empty currentSystemRoutes. Something is wrong.");
}
if (currentSystemSessionInfo == null) {
throw new RuntimeException("Null currentSystemSessionInfo. Something is wrong.");
}
for (MediaRoute2Info route : currentSystemRoutes) {
mRoutes.put(route.getId(), route);
}
mSystemController = new SystemRoutingController(currentSystemSessionInfo);
}
/**
* Returns whether any route in {@code routeList} has a same unique ID with given route.
*
* @hide
*/
static boolean checkRouteListContainsRouteId(@NonNull List<MediaRoute2Info> routeList,
@NonNull String routeId) {
for (MediaRoute2Info info : routeList) {
if (TextUtils.equals(routeId, info.getId())) {
return true;
}
}
return false;
}
/**
* Registers a callback to discover routes and to receive events when they change.
* <p>
* If the specified callback is already registered, its registration will be updated for the
* given {@link Executor executor} and {@link RouteDiscoveryPreference discovery preference}.
* </p>
*/
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 = new RouteCallbackRecord(executor, routeCallback, preference);
mRouteCallbackRecords.remove(record);
// It can fail to add the callback record if another registration with the same callback
// is happening but it's okay because either this or the other registration should be done.
mRouteCallbackRecords.addIfAbsent(record);
synchronized (sRouterLock) {
if (mStub == null) {
MediaRouter2Stub stub = new MediaRouter2Stub();
try {
mMediaRouterService.registerRouter2(stub, mPackageName);
mStub = stub;
} catch (RemoteException ex) {
Log.e(TAG, "registerRouteCallback: Unable to register MediaRouter2.", ex);
}
}
if (mStub != null && updateDiscoveryPreferenceIfNeededLocked()) {
try {
mMediaRouterService.setDiscoveryRequestWithRouter2(mStub, mDiscoveryPreference);
} catch (RemoteException ex) {
Log.e(TAG, "registerRouteCallback: Unable to set discovery request.", ex);
}
}
}
}
/**
* 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;
}
synchronized (sRouterLock) {
if (mStub == null) {
return;
}
if (updateDiscoveryPreferenceIfNeededLocked()) {
try {
mMediaRouterService.setDiscoveryRequestWithRouter2(
mStub, mDiscoveryPreference);
} catch (RemoteException ex) {
Log.e(TAG, "unregisterRouteCallback: Unable to set discovery request.", ex);
}
}
if (mRouteCallbackRecords.isEmpty() && mNonSystemRoutingControllers.isEmpty()) {
try {
mMediaRouterService.unregisterRouter2(mStub);
} catch (RemoteException ex) {
Log.e(TAG, "Unable to unregister media router.", ex);
}
mStub = null;
}
mShouldUpdateRoutes = true;
}
}
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;
mShouldUpdateRoutes = true;
return true;
}
/**
* Gets the unmodifiable list of {@link MediaRoute2Info routes} currently
* known to the media router.
* <p>
* Please note that the list can be changed before callbacks are invoked.
* </p>
*
* @return the list of routes that contains at least one of the route features in discovery
* preferences registered by the application
*/
@NonNull
public List<MediaRoute2Info> getRoutes() {
synchronized (sRouterLock) {
if (mShouldUpdateRoutes) {
mShouldUpdateRoutes = false;
List<MediaRoute2Info> filteredRoutes = new ArrayList<>();
for (MediaRoute2Info route : mRoutes.values()) {
if (route.hasAnyFeatures(mDiscoveryPreference.getPreferredFeatures())) {
filteredRoutes.add(route);
}
}
mFilteredRoutes = Collections.unmodifiableList(filteredRoutes);
}
}
return mFilteredRoutes;
}
/**
* Registers a callback to get the result of {@link #transferTo(MediaRoute2Info)}.
* If you register the same callback twice or more, it will be ignored.
*
* @param executor the executor to execute the callback on
* @param callback the callback to register
* @see #unregisterTransferCallback
*/
public void registerTransferCallback(@NonNull @CallbackExecutor Executor executor,
@NonNull TransferCallback callback) {
Objects.requireNonNull(executor, "executor must not be null");
Objects.requireNonNull(callback, "callback must not be null");
TransferCallbackRecord record = new TransferCallbackRecord(executor, callback);
if (!mTransferCallbackRecords.addIfAbsent(record)) {
Log.w(TAG, "registerTransferCallback: Ignoring the same callback");
return;
}
}
/**
* Unregisters the given callback. The callback will no longer receive events.
* If the callback has not been added or been removed already, it is ignored.
*
* @param callback the callback to unregister
* @see #registerTransferCallback
*/
public void unregisterTransferCallback(@NonNull TransferCallback callback) {
Objects.requireNonNull(callback, "callback must not be null");
if (!mTransferCallbackRecords.remove(new TransferCallbackRecord(null, callback))) {
Log.w(TAG, "unregisterTransferCallback: Ignoring an unknown callback");
return;
}
}
/**
* Registers a {@link ControllerCallback}.
* If you register the same callback twice or more, it will be ignored.
* @see #unregisterControllerCallback(ControllerCallback)
*/
public void registerControllerCallback(@NonNull @CallbackExecutor Executor executor,
@NonNull ControllerCallback callback) {
Objects.requireNonNull(executor, "executor must not be null");
Objects.requireNonNull(callback, "callback must not be null");
ControllerCallbackRecord record = new ControllerCallbackRecord(executor, callback);
if (!mControllerCallbackRecords.addIfAbsent(record)) {
Log.w(TAG, "registerControllerCallback: Ignoring the same callback");
return;
}
}
/**
* Unregisters a {@link ControllerCallback}. The callback will no longer receive
* events. If the callback has not been added or been removed already, it is ignored.
* @see #registerControllerCallback(Executor, ControllerCallback)
*/
public void unregisterControllerCallback(
@NonNull ControllerCallback callback) {
Objects.requireNonNull(callback, "callback must not be null");
if (!mControllerCallbackRecords.remove(new ControllerCallbackRecord(null, callback))) {
Log.w(TAG, "unregisterControllerCallback: Ignoring an unknown callback");
return;
}
}
/**
* Sets an {@link OnGetControllerHintsListener} to send hints when creating a
* {@link RoutingController}. To send the hints, listener should be set <em>BEFORE</em> calling
* {@link #transferTo(MediaRoute2Info)}.
*
* @param listener A listener to send optional app-specific hints when creating a controller.
* {@code null} for unset.
*/
public void setOnGetControllerHintsListener(@Nullable OnGetControllerHintsListener listener) {
mOnGetControllerHintsListener = listener;
}
/**
* Transfers the current media to the given route.
* If it's necessary a new {@link RoutingController} is created or it is handled within
* the current routing controller.
*
* @param route the route you want to transfer the current media to. Pass {@code null} to
* stop routing of the current media.
*
* @see TransferCallback#onTransfer
* @see TransferCallback#onTransferFailure
*/
public void transferTo(@NonNull MediaRoute2Info route) {
Objects.requireNonNull(route, "route must not be null");
Log.v(TAG, "Transferring to route: " + route);
transfer(getCurrentController(), 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() {
getCurrentController().release();
}
/**
* Transfers the media of a routing controller to the given route.
* @param controller a routing controller controlling media routing.
* @param route the route you want to transfer the media to.
* @hide
*/
void transfer(@NonNull RoutingController controller, @NonNull MediaRoute2Info route) {
Objects.requireNonNull(controller, "controller must not be null");
Objects.requireNonNull(route, "route must not be null");
boolean routeFound;
synchronized (sRouterLock) {
// TODO: Check thread-safety
routeFound = mRoutes.containsKey(route.getId());
}
if (!routeFound) {
notifyTransferFailure(route);
return;
}
if (controller.getRoutingSessionInfo().getTransferableRoutes().contains(route.getId())) {
controller.transferToRoute(route);
return;
}
final int requestId = mControllerCreationRequestCnt.getAndIncrement();
ControllerCreationRequest request = new ControllerCreationRequest(requestId, route);
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 (sRouterLock) {
stub = mStub;
}
if (stub != null) {
try {
mMediaRouterService.requestCreateSessionWithRouter2(
stub, requestId, route, controllerHints);
} catch (RemoteException ex) {
Log.e(TAG, "transfer: Unable to request to create controller.", ex);
mHandler.sendMessage(obtainMessage(MediaRouter2::createControllerOnHandler,
MediaRouter2.this, requestId, null));
}
}
}
@NonNull
private RoutingController getCurrentController() {
List<RoutingController> controllers = getControllers();
return controllers.get(controllers.size() - 1);
}
/**
* Gets a {@link RoutingController} which can control the routes provided by system.
* e.g. Phone speaker, wired headset, Bluetooth, etc.
* <p>
* Note: The system controller can't be released. Calling {@link RoutingController#release()}
* will be ignored.
* <p>
* This method always returns the same instance.
*/
@NonNull
public RoutingController getSystemController() {
return mSystemController;
}
/**
* Gets the list of currently non-released {@link RoutingController routing controllers}.
* <p>
* Note: The list returned here will never be empty. The first element in the list is
* always the {@link #getSystemController() system controller}.
*/
@NonNull
public List<RoutingController> getControllers() {
List<RoutingController> result = new ArrayList<>();
result.add(0, mSystemController);
synchronized (sRouterLock) {
result.addAll(mNonSystemRoutingControllers.values());
}
return result;
}
/**
* Requests a volume change for the route asynchronously.
* <p>
* It may have no effect if the route is currently not selected.
* </p>
*
* @param volume The new volume value between 0 and {@link MediaRoute2Info#getVolumeMax}.
* @hide
*/
public void setRouteVolume(@NonNull MediaRoute2Info route, int volume) {
Objects.requireNonNull(route, "route must not be null");
MediaRouter2Stub stub;
synchronized (sRouterLock) {
stub = mStub;
}
if (stub != null) {
try {
mMediaRouterService.setRouteVolumeWithRouter2(stub, route, volume);
} catch (RemoteException ex) {
Log.e(TAG, "Unable to set route volume.", ex);
}
}
}
void syncRoutesOnHandler(List<MediaRoute2Info> currentRoutes,
RoutingSessionInfo currentSystemSessionInfo) {
if (currentRoutes == null || currentRoutes.isEmpty() || currentSystemSessionInfo == null) {
Log.e(TAG, "syncRoutesOnHandler: Received wrong data. currentRoutes=" + currentRoutes
+ ", currentSystemSessionInfo=" + currentSystemSessionInfo);
return;
}
List<MediaRoute2Info> addedRoutes = new ArrayList<>();
List<MediaRoute2Info> removedRoutes = new ArrayList<>();
List<MediaRoute2Info> changedRoutes = new ArrayList<>();
synchronized (sRouterLock) {
List<String> currentRoutesIds = currentRoutes.stream().map(MediaRoute2Info::getId)
.collect(Collectors.toList());
for (String routeId : mRoutes.keySet()) {
if (!currentRoutesIds.contains(routeId)) {
// This route is removed while the callback is unregistered.
MediaRoute2Info route = mRoutes.get(routeId);
if (route.hasAnyFeatures(mDiscoveryPreference.getPreferredFeatures())) {
removedRoutes.add(mRoutes.get(routeId));
}
}
}
for (MediaRoute2Info route : currentRoutes) {
if (mRoutes.containsKey(route.getId())) {
if (!route.equals(mRoutes.get(route.getId()))) {
// This route is changed while the callback is unregistered.
if (route.hasAnyFeatures(
mDiscoveryPreference.getPreferredFeatures())) {
changedRoutes.add(route);
}
}
} else {
// This route is added while the callback is unregistered.
if (route.hasAnyFeatures(mDiscoveryPreference.getPreferredFeatures())) {
addedRoutes.add(route);
}
}
}
mRoutes.clear();
for (MediaRoute2Info route : currentRoutes) {
mRoutes.put(route.getId(), route);
}
mShouldUpdateRoutes = true;
}
if (addedRoutes.size() > 0) {
notifyRoutesAdded(addedRoutes);
}
if (removedRoutes.size() > 0) {
notifyRoutesRemoved(removedRoutes);
}
if (changedRoutes.size() > 0) {
notifyRoutesChanged(changedRoutes);
}
RoutingSessionInfo oldInfo = mSystemController.getRoutingSessionInfo();
mSystemController.setRoutingSessionInfo(currentSystemSessionInfo);
if (!oldInfo.equals(currentSystemSessionInfo)) {
notifyControllerUpdated(mSystemController);
}
}
void addRoutesOnHandler(List<MediaRoute2Info> routes) {
List<MediaRoute2Info> addedRoutes = new ArrayList<>();
synchronized (sRouterLock) {
for (MediaRoute2Info route : routes) {
mRoutes.put(route.getId(), route);
if (route.hasAnyFeatures(mDiscoveryPreference.getPreferredFeatures())) {
addedRoutes.add(route);
}
}
mShouldUpdateRoutes = true;
}
if (addedRoutes.size() > 0) {
notifyRoutesAdded(addedRoutes);
}
}
void removeRoutesOnHandler(List<MediaRoute2Info> routes) {
List<MediaRoute2Info> removedRoutes = new ArrayList<>();
synchronized (sRouterLock) {
for (MediaRoute2Info route : routes) {
mRoutes.remove(route.getId());
if (route.hasAnyFeatures(mDiscoveryPreference.getPreferredFeatures())) {
removedRoutes.add(route);
}
}
mShouldUpdateRoutes = true;
}
if (removedRoutes.size() > 0) {
notifyRoutesRemoved(removedRoutes);
}
}
void changeRoutesOnHandler(List<MediaRoute2Info> routes) {
List<MediaRoute2Info> changedRoutes = new ArrayList<>();
synchronized (sRouterLock) {
for (MediaRoute2Info route : routes) {
mRoutes.put(route.getId(), route);
if (route.hasAnyFeatures(mDiscoveryPreference.getPreferredFeatures())) {
changedRoutes.add(route);
}
}
mShouldUpdateRoutes = true;
}
if (changedRoutes.size() > 0) {
notifyRoutesChanged(changedRoutes);
}
}
/**
* Creates a controller and calls the {@link TransferCallback#onTransfer}.
* If the controller creation has failed, then it calls
* {@link TransferCallback#onTransferFailure}.
* <p>
* Pass {@code null} to sessionInfo for the failure case.
*/
void createControllerOnHandler(int requestId, @Nullable RoutingSessionInfo sessionInfo) {
ControllerCreationRequest matchingRequest = null;
for (ControllerCreationRequest request : mControllerCreationRequests) {
if (request.mRequestId == requestId) {
matchingRequest = request;
break;
}
}
if (matchingRequest != null) {
mControllerCreationRequests.remove(matchingRequest);
MediaRoute2Info requestedRoute = matchingRequest.mRoute;
if (sessionInfo == null) {
// TODO: We may need to distinguish between failure and rejection.
// One way can be introducing 'reason'.
notifyTransferFailure(requestedRoute);
return;
} else if (!sessionInfo.getSelectedRoutes().contains(requestedRoute.getId())) {
Log.w(TAG, "The session does not contain the requested route. "
+ "(requestedRouteId=" + requestedRoute.getId()
+ ", actualRoutes=" + sessionInfo.getSelectedRoutes()
+ ")");
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;
}
}
if (sessionInfo == null) {
return;
}
RoutingController oldController = getCurrentController();
if (!oldController.releaseInternal(
/* shouldReleaseSession= */ matchingRequest != null,
/* shouldNotifyStop= */ false)) {
// Could not release the controller since it was just released by other thread.
oldController = getSystemController();
}
RoutingController newController;
if (sessionInfo.isSystemSession()) {
newController = getSystemController();
newController.setRoutingSessionInfo(sessionInfo);
} else {
newController = new RoutingController(sessionInfo);
synchronized (sRouterLock) {
mNonSystemRoutingControllers.put(newController.getId(), newController);
}
}
// Two controller can be same if stop() is called before the result of Cast -> Phone comes.
if (oldController != newController) {
notifyTransfer(oldController, newController);
} else if (matchingRequest != null) {
notifyTransferFailure(matchingRequest.mRoute);
}
}
void updateControllerOnHandler(RoutingSessionInfo sessionInfo) {
if (sessionInfo == null) {
Log.w(TAG, "updateControllerOnHandler: Ignoring null sessionInfo.");
return;
}
if (sessionInfo.isSystemSession()) {
// The session info is sent from SystemMediaRoute2Provider.
RoutingController systemController = getSystemController();
systemController.setRoutingSessionInfo(sessionInfo);
notifyControllerUpdated(systemController);
return;
}
RoutingController matchingController;
synchronized (sRouterLock) {
matchingController = mNonSystemRoutingControllers.get(sessionInfo.getId());
}
if (matchingController == null) {
Log.w(TAG, "updateControllerOnHandler: Matching controller not found. uniqueSessionId="
+ sessionInfo.getId());
return;
}
RoutingSessionInfo oldInfo = matchingController.getRoutingSessionInfo();
if (!TextUtils.equals(oldInfo.getProviderId(), sessionInfo.getProviderId())) {
Log.w(TAG, "updateControllerOnHandler: Provider IDs are not matched. old="
+ oldInfo.getProviderId() + ", new=" + sessionInfo.getProviderId());
return;
}
matchingController.setRoutingSessionInfo(sessionInfo);
notifyControllerUpdated(matchingController);
}
void releaseControllerOnHandler(RoutingSessionInfo sessionInfo) {
if (sessionInfo == null) {
Log.w(TAG, "releaseControllerOnHandler: Ignoring null sessionInfo.");
return;
}
final String uniqueSessionId = sessionInfo.getId();
RoutingController matchingController;
synchronized (sRouterLock) {
matchingController = mNonSystemRoutingControllers.get(uniqueSessionId);
}
if (matchingController == null) {
if (DEBUG) {
Log.d(TAG, "releaseControllerOnHandler: Matching controller not found. "
+ "uniqueSessionId=" + sessionInfo.getId());
}
return;
}
RoutingSessionInfo oldInfo = matchingController.getRoutingSessionInfo();
if (!TextUtils.equals(oldInfo.getProviderId(), sessionInfo.getProviderId())) {
Log.w(TAG, "releaseControllerOnHandler: Provider IDs are not matched. old="
+ oldInfo.getProviderId() + ", new=" + sessionInfo.getProviderId());
return;
}
matchingController.releaseInternal(
/* shouldReleaseSession= */ false, /* shouldNotifyStop= */ true);
}
void onGetControllerHintsForCreatingSessionOnHandler(long uniqueRequestId,
MediaRoute2Info route) {
OnGetControllerHintsListener listener = mOnGetControllerHintsListener;
Bundle controllerHints = null;
if (listener != null) {
controllerHints = listener.onGetControllerHints(route);
if (controllerHints != null) {
controllerHints = new Bundle(controllerHints);
}
}
MediaRouter2Stub stub;
synchronized (sRouterLock) {
stub = mStub;
}
if (stub != null) {
try {
mMediaRouterService.notifySessionHintsForCreatingSession(
stub, uniqueRequestId, route, controllerHints);
} catch (RemoteException ex) {
Log.e(TAG, "onGetControllerHintsForCreatingSessionOnHandler: Unable to notify "
+ " session hints for creating session.", ex);
}
}
}
private List<MediaRoute2Info> filterRoutes(List<MediaRoute2Info> routes,
RouteDiscoveryPreference discoveryRequest) {
return routes.stream()
.filter(route -> route.hasAnyFeatures(discoveryRequest.getPreferredFeatures()))
.collect(Collectors.toList());
}
private void notifyRoutesAdded(List<MediaRoute2Info> routes) {
for (RouteCallbackRecord record: mRouteCallbackRecords) {
List<MediaRoute2Info> filteredRoutes = filterRoutes(routes, record.mPreference);
if (!filteredRoutes.isEmpty()) {
record.mExecutor.execute(
() -> record.mRouteCallback.onRoutesAdded(filteredRoutes));
}
}
}
private void notifyRoutesRemoved(List<MediaRoute2Info> routes) {
for (RouteCallbackRecord record: mRouteCallbackRecords) {
List<MediaRoute2Info> filteredRoutes = filterRoutes(routes, record.mPreference);
if (!filteredRoutes.isEmpty()) {
record.mExecutor.execute(
() -> record.mRouteCallback.onRoutesRemoved(filteredRoutes));
}
}
}
private void notifyRoutesChanged(List<MediaRoute2Info> routes) {
for (RouteCallbackRecord record: mRouteCallbackRecords) {
List<MediaRoute2Info> filteredRoutes = filterRoutes(routes, record.mPreference);
if (!filteredRoutes.isEmpty()) {
record.mExecutor.execute(
() -> record.mRouteCallback.onRoutesChanged(filteredRoutes));
}
}
}
private void notifyTransfer(RoutingController oldController, RoutingController newController) {
for (TransferCallbackRecord record: mTransferCallbackRecords) {
record.mExecutor.execute(
() -> record.mTransferCallback.onTransfer(oldController, newController));
}
}
private void notifyTransferFailure(MediaRoute2Info route) {
for (TransferCallbackRecord record: mTransferCallbackRecords) {
record.mExecutor.execute(
() -> record.mTransferCallback.onTransferFailure(route));
}
}
private void notifyStop(RoutingController controller) {
for (TransferCallbackRecord record: mTransferCallbackRecords) {
record.mExecutor.execute(
() -> record.mTransferCallback.onStop(controller));
}
}
private void notifyControllerUpdated(RoutingController controller) {
for (ControllerCallbackRecord record: mControllerCallbackRecords) {
record.mExecutor.execute(() -> record.mCallback.onControllerUpdated(controller));
}
}
/**
* Callback for receiving events about media route discovery.
*/
public abstract static class RouteCallback {
/**
* Called when routes are added. Whenever you registers a callback, this will
* be invoked with known routes.
*
* @param routes the list of routes that have been added. It's never empty.
*/
public void onRoutesAdded(@NonNull List<MediaRoute2Info> routes) {}
/**
* Called when routes are removed.
*
* @param routes the list of routes that have been removed. It's never empty.
*/
public void onRoutesRemoved(@NonNull List<MediaRoute2Info> routes) {}
/**
* Called when routes are changed. For example, it is called when the route's name
* or volume have been changed.
*
* @param routes the list of routes that have been changed. It's never empty.
*/
public void onRoutesChanged(@NonNull List<MediaRoute2Info> routes) {}
}
/**
* 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)}.
* The {@code oldController} is released before this method is called, except for the
* {@link #getSystemController() system controller}.
*
* @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 oldController} is released before this method is called, except for the
* {@link #getSystemController() system controller}.
*
* @param controller the controller that controlled the stopped media routing.
*/
public void onStop(@NonNull RoutingController controller) { }
}
/**
* A listener interface to send an optional app-specific hints when creating the
* {@link RoutingController}.
*/
public interface OnGetControllerHintsListener {
/**
* Called when the {@link MediaRouter2} or the system is about to request
* a media route provider service to create a controller with the given route.
* The {@link Bundle} returned here will be sent to media route provider service as a hint.
* <p>
* Since controller creation can be requested by the {@link MediaRouter2} and the system,
* set the listener as soon as possible after acquiring {@link MediaRouter2} instance.
* The method will be called on the same thread that calls
* {@link #transferTo(MediaRoute2Info)} or the main thread if it is requested by the system.
*
* @param route The route to create controller with
* @return An optional bundle of app-specific arguments to send to the provider,
* or 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., the selected routes of the
* controller is changed or the volume of the controller is changed.)
*
* @param controller the updated controller. Can be the system controller.
* @see #getSystemController()
*/
public void onControllerUpdated(@NonNull RoutingController controller) { }
}
/**
* A class to control media routing session in media route provider.
* For example, selecting/deselecting/transferring routes to a session can be done through this
* class. Instances are created by {@link #transferTo(MediaRoute2Info)}.
*/
public class RoutingController {
private final Object mControllerLock = new Object();
@GuardedBy("mControllerLock")
private RoutingSessionInfo mSessionInfo;
@GuardedBy("mControllerLock")
private volatile boolean mIsReleased;
RoutingController(@NonNull RoutingSessionInfo sessionInfo) {
mSessionInfo = sessionInfo;
}
/**
* @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();
}
}
/**
* @return the control hints used to control routing session if available.
*/
@Nullable
public Bundle getControlHints() {
synchronized (mControllerLock) {
return mSessionInfo.getControlHints();
}
}
/**
* @return the unmodifiable list of currently selected routes
*/
@NonNull
public List<MediaRoute2Info> getSelectedRoutes() {
List<String> selectedRouteIds;
synchronized (mControllerLock) {
selectedRouteIds = mSessionInfo.getSelectedRoutes();
}
return getRoutesWithIds(selectedRouteIds);
}
/**
* @return the unmodifiable list of selectable routes for the session.
*/
@NonNull
public List<MediaRoute2Info> getSelectableRoutes() {
List<String> selectableRouteIds;
synchronized (mControllerLock) {
selectableRouteIds = mSessionInfo.getSelectableRoutes();
}
return getRoutesWithIds(selectableRouteIds);
}
/**
* @return the unmodifiable list of deselectable routes for the session.
*/
@NonNull
public List<MediaRoute2Info> getDeselectableRoutes() {
List<String> deselectableRouteIds;
synchronized (mControllerLock) {
deselectableRouteIds = mSessionInfo.getDeselectableRoutes();
}
return getRoutesWithIds(deselectableRouteIds);
}
/**
* Gets information about how volume is handled on the session.
*
* @return {@link MediaRoute2Info#PLAYBACK_VOLUME_FIXED} or
* {@link MediaRoute2Info#PLAYBACK_VOLUME_VARIABLE}
*/
@MediaRoute2Info.PlaybackVolume
public int getVolumeHandling() {
synchronized (mControllerLock) {
return mSessionInfo.getVolumeHandling();
}
}
/**
* Gets the maximum volume of the session.
*/
public int getVolumeMax() {
synchronized (mControllerLock) {
return mSessionInfo.getVolumeMax();
}
}
/**
* Gets the current volume of the session.
* <p>
* When it's available, it represents the volume of routing session, which is a group
* of selected routes. To get the volume of a route,
* use {@link MediaRoute2Info#getVolume()}.
* </p>
* @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 mIsReleased;
}
}
/**
* Selects a route for the remote session. After a route is selected, the media is expected
* to be played to the all the selected routes. This is different from {@link
* MediaRouter2#transferTo(MediaRoute2Info)} transferring to a route},
* where the media is expected to 'move' from one route to another.
* <p>
* The given route must satisfy all of the following conditions:
* <ul>
* <li>ID should not be included in {@link #getSelectedRoutes()}</li>
* <li>ID should be included in {@link #getSelectableRoutes()}</li>
* </ul>
* If the route doesn't meet any of above conditions, it will be ignored.
*
* @see #deselectRoute(MediaRoute2Info)
* @see #getSelectedRoutes()
* @see #getSelectableRoutes()
* @see ControllerCallback#onControllerUpdated
*/
public void selectRoute(@NonNull MediaRoute2Info route) {
Objects.requireNonNull(route, "route must not be null");
synchronized (mControllerLock) {
if (mIsReleased) {
Log.w(TAG, "selectRoute: Called on released controller. Ignoring.");
return;
}
}
List<MediaRoute2Info> selectedRoutes = getSelectedRoutes();
if (checkRouteListContainsRouteId(selectedRoutes, route.getId())) {
Log.w(TAG, "Ignoring selecting a route that is already selected. route=" + route);
return;
}
List<MediaRoute2Info> selectableRoutes = getSelectableRoutes();
if (!checkRouteListContainsRouteId(selectableRoutes, route.getId())) {
Log.w(TAG, "Ignoring selecting a non-selectable route=" + route);
return;
}
MediaRouter2Stub stub;
synchronized (sRouterLock) {
stub = mStub;
}
if (stub != null) {
try {
mMediaRouterService.selectRouteWithRouter2(stub, getId(), route);
} catch (RemoteException ex) {
Log.e(TAG, "Unable to select route for session.", ex);
}
}
}
/**
* Deselects a route from the remote session. After a route is deselected, the media is
* expected to be stopped on the deselected routes.
* <p>
* The given route must satisfy all of the following conditions:
* <ul>
* <li>ID should be included in {@link #getSelectedRoutes()}</li>
* <li>ID should be included in {@link #getDeselectableRoutes()}</li>
* </ul>
* If the route doesn't meet any of above conditions, it will be ignored.
*
* @see #getSelectedRoutes()
* @see #getDeselectableRoutes()
* @see ControllerCallback#onControllerUpdated
*/
public void deselectRoute(@NonNull MediaRoute2Info route) {
Objects.requireNonNull(route, "route must not be null");
synchronized (mControllerLock) {
if (mIsReleased) {
Log.w(TAG, "deselectRoute: called on released controller. Ignoring.");
return;
}
}
List<MediaRoute2Info> selectedRoutes = getSelectedRoutes();
if (!checkRouteListContainsRouteId(selectedRoutes, route.getId())) {
Log.w(TAG, "Ignoring deselecting a route that is not selected. route=" + route);
return;
}
List<MediaRoute2Info> deselectableRoutes = getDeselectableRoutes();
if (!checkRouteListContainsRouteId(deselectableRoutes, route.getId())) {
Log.w(TAG, "Ignoring deselecting a non-deselectable route=" + route);
return;
}
MediaRouter2Stub stub;
synchronized (sRouterLock) {
stub = mStub;
}
if (stub != null) {
try {
mMediaRouterService.deselectRouteWithRouter2(stub, getId(), route);
} catch (RemoteException ex) {
Log.e(TAG, "Unable to deselect route from session.", ex);
}
}
}
/**
* Transfers to a given route for the remote session. The given route must satisfy
* all of the following conditions:
* <ul>
* <li>ID should not be included in {@link RoutingSessionInfo#getSelectedRoutes()}</li>
* <li>ID should be included in {@link RoutingSessionInfo#getTransferableRoutes()}</li>
* </ul>
* If the route doesn't meet any of above conditions, it will be ignored.
*
* @see RoutingSessionInfo#getSelectedRoutes()
* @see RoutingSessionInfo#getTransferableRoutes()
* @see ControllerCallback#onControllerUpdated
*/
void transferToRoute(@NonNull MediaRoute2Info route) {
Objects.requireNonNull(route, "route must not be null");
synchronized (mControllerLock) {
if (mIsReleased) {
Log.w(TAG, "transferToRoute: Called on released controller. Ignoring.");
return;
}
if (mSessionInfo.getSelectedRoutes().contains(route.getId())) {
Log.w(TAG, "Ignoring transferring to a route that is already added. "
+ "route=" + route);
return;
}
if (!mSessionInfo.getTransferableRoutes().contains(route.getId())) {
Log.w(TAG, "Ignoring transferring to a non-transferrable route=" + route);
return;
}
}
MediaRouter2Stub stub;
synchronized (sRouterLock) {
stub = mStub;
}
if (stub != null) {
try {
mMediaRouterService.transferToRouteWithRouter2(stub, getId(), route);
} catch (RemoteException ex) {
Log.e(TAG, "Unable to transfer to route for session.", ex);
}
}
}
/**
* Requests a volume change for the remote session asynchronously.
*
* @param volume The new volume value between 0 and {@link RoutingController#getVolumeMax}
* (inclusive).
* @see #getVolume()
*/
public void setVolume(int volume) {
if (getVolumeHandling() == MediaRoute2Info.PLAYBACK_VOLUME_FIXED) {
Log.w(TAG, "setVolume: The routing session has fixed volume. Ignoring.");
return;
}
if (volume < 0 || volume > getVolumeMax()) {
Log.w(TAG, "setVolume: The target volume is out of range. Ignoring");
return;
}
synchronized (mControllerLock) {
if (mIsReleased) {
Log.w(TAG, "setVolume: Called on released controller. Ignoring.");
return;
}
}
MediaRouter2Stub stub;
synchronized (sRouterLock) {
stub = mStub;
}
if (stub != null) {
try {
mMediaRouterService.setSessionVolumeWithRouter2(stub, getId(), volume);
} catch (RemoteException ex) {
Log.e(TAG, "setVolume: Failed to deliver request.", ex);
}
}
}
/**
* Release this controller and corresponding session.
* Any operations on this controller after calling this method will be ignored.
* The devices that are playing media will stop playing it.
*/
// TODO(b/157872573): Add tests using {@link MediaRouter2Manager#getActiveSessions()}.
public void release() {
releaseInternal(/* shouldReleaseSession= */ true, /* shouldNotifyStop= */ true);
}
/**
* Returns {@code true} when succeeded to release, {@code false} if the controller is
* already released.
*/
boolean releaseInternal(boolean shouldReleaseSession, boolean shouldNotifyStop) {
synchronized (mControllerLock) {
if (mIsReleased) {
Log.w(TAG, "releaseInternal: Called on released controller. Ignoring.");
return false;
}
mIsReleased = true;
}
synchronized (sRouterLock) {
if (!mNonSystemRoutingControllers.remove(getId(), this)) {
Log.w(TAG, "releaseInternal: Ignoring unknown controller.");
return false;
}
if (shouldReleaseSession && mStub != null) {
try {
mMediaRouterService.releaseSessionWithRouter2(mStub, getId());
} catch (RemoteException ex) {
Log.e(TAG, "Unable to release session", ex);
}
}
if (shouldNotifyStop) {
mHandler.sendMessage(obtainMessage(MediaRouter2::notifyStop, MediaRouter2.this,
RoutingController.this));
}
if (mRouteCallbackRecords.isEmpty() && mNonSystemRoutingControllers.isEmpty()
&& mStub != null) {
try {
mMediaRouterService.unregisterRouter2(mStub);
} catch (RemoteException ex) {
Log.e(TAG, "releaseInternal: Unable to unregister media router.", ex);
}
mStub = null;
}
}
return true;
}
@Override
public String toString() {
// To prevent logging spam, we only print the ID of each route.
List<String> selectedRoutes = getSelectedRoutes().stream()
.map(MediaRoute2Info::getId).collect(Collectors.toList());
List<String> selectableRoutes = getSelectableRoutes().stream()
.map(MediaRoute2Info::getId).collect(Collectors.toList());
List<String> deselectableRoutes = getDeselectableRoutes().stream()
.map(MediaRoute2Info::getId).collect(Collectors.toList());
StringBuilder result = new StringBuilder()
.append("RoutingController{ ")
.append("id=").append(getId())
.append(", selectedRoutes={")
.append(selectedRoutes)
.append("}")
.append(", selectableRoutes={")
.append(selectableRoutes)
.append("}")
.append(", deselectableRoutes={")
.append(deselectableRoutes)
.append("}")
.append(" }");
return result.toString();
}
@NonNull
RoutingSessionInfo getRoutingSessionInfo() {
synchronized (mControllerLock) {
return mSessionInfo;
}
}
void setRoutingSessionInfo(@NonNull RoutingSessionInfo info) {
synchronized (mControllerLock) {
mSessionInfo = info;
}
}
private List<MediaRoute2Info> getRoutesWithIds(List<String> routeIds) {
synchronized (sRouterLock) {
return routeIds.stream().map(mRoutes::get)
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
}
}
class SystemRoutingController extends RoutingController {
SystemRoutingController(@NonNull RoutingSessionInfo sessionInfo) {
super(sessionInfo);
}
@Override
public boolean isReleased() {
// SystemRoutingController will never be released
return false;
}
@Override
boolean releaseInternal(boolean shouldReleaseSession, boolean shouldNotifyStop) {
// Do nothing. SystemRoutingController will never be released
return false;
}
}
static final class RouteCallbackRecord {
public final Executor mExecutor;
public final RouteCallback mRouteCallback;
public final RouteDiscoveryPreference mPreference;
RouteCallbackRecord(@Nullable Executor executor, @NonNull RouteCallback routeCallback,
@Nullable RouteDiscoveryPreference preference) {
mRouteCallback = routeCallback;
mExecutor = executor;
mPreference = preference;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof RouteCallbackRecord)) {
return false;
}
return mRouteCallback == ((RouteCallbackRecord) obj).mRouteCallback;
}
@Override
public int hashCode() {
return mRouteCallback.hashCode();
}
}
static final class TransferCallbackRecord {
public final Executor mExecutor;
public final TransferCallback mTransferCallback;
TransferCallbackRecord(@NonNull Executor executor,
@NonNull TransferCallback transferCallback) {
mTransferCallback = transferCallback;
mExecutor = executor;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof TransferCallbackRecord)) {
return false;
}
return mTransferCallback
== ((TransferCallbackRecord) obj).mTransferCallback;
}
@Override
public int hashCode() {
return mTransferCallback.hashCode();
}
}
static final class ControllerCallbackRecord {
public final Executor mExecutor;
public final ControllerCallback mCallback;
ControllerCallbackRecord(@Nullable Executor executor,
@NonNull ControllerCallback callback) {
mCallback = callback;
mExecutor = executor;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof ControllerCallbackRecord)) {
return false;
}
return mCallback == ((ControllerCallbackRecord) obj).mCallback;
}
@Override
public int hashCode() {
return mCallback.hashCode();
}
}
static final class ControllerCreationRequest {
public final int mRequestId;
public final MediaRoute2Info mRoute;
ControllerCreationRequest(int requestId, @NonNull MediaRoute2Info route) {
mRequestId = requestId;
mRoute = route;
}
}
class MediaRouter2Stub extends IMediaRouter2.Stub {
@Override
public void notifyRouterRegistered(List<MediaRoute2Info> currentRoutes,
RoutingSessionInfo currentSystemSessionInfo) {
mHandler.sendMessage(obtainMessage(MediaRouter2::syncRoutesOnHandler,
MediaRouter2.this, currentRoutes, currentSystemSessionInfo));
}
@Override
public void notifyRoutesAdded(List<MediaRoute2Info> routes) {
mHandler.sendMessage(obtainMessage(MediaRouter2::addRoutesOnHandler,
MediaRouter2.this, routes));
}
@Override
public void notifyRoutesRemoved(List<MediaRoute2Info> routes) {
mHandler.sendMessage(obtainMessage(MediaRouter2::removeRoutesOnHandler,
MediaRouter2.this, routes));
}
@Override
public void notifyRoutesChanged(List<MediaRoute2Info> routes) {
mHandler.sendMessage(obtainMessage(MediaRouter2::changeRoutesOnHandler,
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 getSessionHintsForCreatingSession(long uniqueRequestId,
@NonNull MediaRoute2Info route) {
mHandler.sendMessage(obtainMessage(
MediaRouter2::onGetControllerHintsForCreatingSessionOnHandler,
MediaRouter2.this, uniqueRequestId, route));
}
}
}