blob: c27d26c4b07e1fb4ce7c7d1d0e690e999514f583 [file] [log] [blame]
/*
* Copyright 2020 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 androidx.mediarouter.media;
import static androidx.mediarouter.media.MediaRouteProviderProtocol.CLIENT_DATA_ROUTE_ID;
import static androidx.mediarouter.media.MediaRouteProviderProtocol.CLIENT_DATA_VOLUME;
import static androidx.mediarouter.media.MediaRouteProviderProtocol.CLIENT_MSG_ROUTE_CONTROL_REQUEST;
import static androidx.mediarouter.media.MediaRouteProviderProtocol.CLIENT_MSG_SET_ROUTE_VOLUME;
import static androidx.mediarouter.media.MediaRouteProviderProtocol.CLIENT_MSG_UPDATE_ROUTE_VOLUME;
import static androidx.mediarouter.media.MediaRouteProviderProtocol.SERVICE_DATA_ERROR;
import static androidx.mediarouter.media.MediaRouteProviderProtocol.SERVICE_MSG_CONTROL_REQUEST_FAILED;
import static androidx.mediarouter.media.MediaRouteProviderProtocol.SERVICE_MSG_CONTROL_REQUEST_SUCCEEDED;
import static androidx.mediarouter.media.MediaRouter.UNSELECT_REASON_ROUTE_CHANGED;
import android.content.Context;
import android.content.Intent;
import android.media.MediaRoute2Info;
import android.media.MediaRouter2;
import android.os.Build;
import android.os.Bundle;
import android.os.DeadObjectException;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.os.Messenger;
import android.os.RemoteException;
import android.text.TextUtils;
import android.util.ArrayMap;
import android.util.ArraySet;
import android.util.Log;
import android.util.SparseArray;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.mediarouter.R;
import androidx.mediarouter.media.MediaRouteProvider.DynamicGroupRouteController.DynamicRouteDescriptor;
import androidx.mediarouter.media.MediaRouter.ControlRequestCallback;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Provides non-system routes (and related RouteControllers) by using MediaRouter2.
* This provider is added only when media transfer feature is enabled.
*/
@RequiresApi(Build.VERSION_CODES.R)
@SuppressWarnings({"unused", "ClassCanBeStatic"}) // TODO: Remove this.
class MediaRoute2Provider extends MediaRouteProvider {
static final String TAG = "MR2Provider";
static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
final MediaRouter2 mMediaRouter2;
final Callback mCallback;
final Map<MediaRouter2.RoutingController, GroupRouteController> mControllerMap =
new ArrayMap<>();
private final MediaRouter2.RouteCallback mRouteCallback = new RouteCallback();
private final MediaRouter2.TransferCallback mTransferCallback = new TransferCallback();
private final MediaRouter2.ControllerCallback mControllerCallback = new ControllerCallback();
private final Handler mHandler;
private final Executor mHandlerExecutor;
private List<MediaRoute2Info> mRoutes = new ArrayList<>();
private Map<String, String> mRouteIdToOriginalRouteIdMap = new ArrayMap<>();
MediaRoute2Provider(@NonNull Context context, @NonNull Callback callback) {
super(context);
mMediaRouter2 = MediaRouter2.getInstance(context);
mCallback = callback;
mHandler = new Handler(Looper.getMainLooper());
mHandlerExecutor = mHandler::post;
}
@Override
public void onDiscoveryRequestChanged(@Nullable MediaRouteDiscoveryRequest request) {
if (MediaRouter.getGlobalCallbackCount() > 0) {
request = updateDiscoveryRequest(request, MediaRouter.isTransferToLocalEnabled());
mMediaRouter2.registerRouteCallback(mHandlerExecutor, mRouteCallback,
MediaRouter2Utils.toDiscoveryPreference(request));
mMediaRouter2.registerTransferCallback(mHandlerExecutor, mTransferCallback);
mMediaRouter2.registerControllerCallback(mHandlerExecutor, mControllerCallback);
} else {
mMediaRouter2.unregisterRouteCallback(mRouteCallback);
mMediaRouter2.unregisterTransferCallback(mTransferCallback);
mMediaRouter2.unregisterControllerCallback(mControllerCallback);
}
}
@Nullable
@Override
public RouteController onCreateRouteController(@NonNull String routeId) {
String originalRouteId = mRouteIdToOriginalRouteIdMap.get(routeId);
return new MemberRouteController(originalRouteId, null);
}
@Nullable
@Override
public RouteController onCreateRouteController(@NonNull String routeId,
@NonNull String routeGroupId) {
String originalRouteId = mRouteIdToOriginalRouteIdMap.get(routeId);
for (GroupRouteController groupRouteController : mControllerMap.values()) {
if (TextUtils.equals(routeGroupId, groupRouteController.getGroupRouteId())) {
return new MemberRouteController(originalRouteId, groupRouteController);
}
}
Log.w(TAG, "Could not find the matching GroupRouteController. routeId=" + routeId
+ ", routeGroupId=" + routeGroupId);
return new MemberRouteController(originalRouteId, null);
}
@Nullable
@Override
public DynamicGroupRouteController onCreateDynamicGroupRouteController(
@NonNull String initialMemberRouteId) {
for (Map.Entry<MediaRouter2.RoutingController, GroupRouteController> entry
: mControllerMap.entrySet()) {
GroupRouteController controller = entry.getValue();
if (TextUtils.equals(initialMemberRouteId, controller.mInitialMemberRouteId)) {
return controller;
}
}
return null;
}
public void transferTo(@NonNull String routeId) {
MediaRoute2Info route = getRouteById(routeId);
if (route == null) {
Log.w(TAG, "transferTo: Specified route not found. routeId=" + routeId);
return;
}
mMediaRouter2.transferTo(route);
}
protected void refreshRoutes() {
// Syetem routes should not be published by this provider.
List<MediaRoute2Info> newRoutes = new ArrayList<>();
Set<MediaRoute2Info> route2InfoSet = new ArraySet<>();
for (MediaRoute2Info route : mMediaRouter2.getRoutes()) {
// A route should be unique
if (route == null || route2InfoSet.contains(route) || route.isSystemRoute()) {
continue;
}
route2InfoSet.add(route);
// Not using new ArrayList(route2InfoSet) here for preserving the order.
newRoutes.add(route);
}
if (newRoutes.equals(mRoutes)) {
return;
}
mRoutes = newRoutes;
mRouteIdToOriginalRouteIdMap.clear();
for (MediaRoute2Info route : mRoutes) {
Bundle extras = route.getExtras();
if (extras == null
|| extras.getString(MediaRouter2Utils.KEY_ORIGINAL_ROUTE_ID) == null) {
Log.w(TAG, "Cannot find the original route Id. route=" + route);
continue;
}
mRouteIdToOriginalRouteIdMap.put(route.getId(),
extras.getString(MediaRouter2Utils.KEY_ORIGINAL_ROUTE_ID));
}
List<MediaRouteDescriptor> routeDescriptors = new ArrayList<>();
for (MediaRoute2Info route : mRoutes) {
MediaRouteDescriptor descriptor = MediaRouter2Utils.toMediaRouteDescriptor(route);
if (route != null) {
routeDescriptors.add(descriptor);
}
}
MediaRouteProviderDescriptor descriptor = new MediaRouteProviderDescriptor.Builder()
.setSupportsDynamicGroupRoute(true)
.addRoutes(routeDescriptors)
.build();
setDescriptor(descriptor);
}
@Nullable
MediaRoute2Info getRouteById(@Nullable String routeId) {
if (routeId == null) {
return null;
}
for (MediaRoute2Info route : mRoutes) {
if (TextUtils.equals(route.getId(), routeId)) {
return route;
}
}
return null;
}
@Nullable
static Messenger getMessengerFromRoutingController(
@Nullable MediaRouter2.RoutingController controller) {
if (controller == null) {
return null;
}
Bundle controlHints = controller.getControlHints();
return controlHints == null ? null : controlHints.getParcelable(
MediaRouter2Utils.KEY_MESSENGER);
}
@Nullable
static String getSessionIdForRouteController(@Nullable RouteController controller) {
if (!(controller instanceof GroupRouteController)) {
return null;
}
MediaRouter2.RoutingController routingController =
((GroupRouteController) controller).mRoutingController;
return (routingController == null) ? null : routingController.getId();
}
void setDynamicRouteDescriptors(MediaRouter2.RoutingController routingController) {
GroupRouteController controller = mControllerMap.get(routingController);
if (controller == null) {
Log.w(TAG, "setDynamicRouteDescriptors: No matching routeController found. "
+ "routingController=" + routingController);
return;
}
List<MediaRoute2Info> selectedRoutes = routingController.getSelectedRoutes();
if (selectedRoutes.isEmpty()) {
Log.w(TAG, "setDynamicRouteDescriptors: No selected routes. This may happen "
+ "when the selected routes become invalid."
+ "routingController=" + routingController);
return;
}
List<String> selectedRouteIds = MediaRouter2Utils.getRouteIds(selectedRoutes);
MediaRouteDescriptor initialRouteDescriptor =
MediaRouter2Utils.toMediaRouteDescriptor(selectedRoutes.get(0));
MediaRouteDescriptor groupDescriptor = null;
// TODO: Add RoutingController#getName() and use it in Android S+
Bundle controlHints = routingController.getControlHints();
String groupRouteName = getContext().getString(R.string.mr_dialog_default_group_name);
try {
if (controlHints != null) {
String sessionName = controlHints.getString(MediaRouter2Utils.KEY_SESSION_NAME);
if (!TextUtils.isEmpty(sessionName)) {
groupRouteName = sessionName;
}
Bundle groupRouteBundle = controlHints.getBundle(MediaRouter2Utils.KEY_GROUP_ROUTE);
if (groupRouteBundle != null) {
groupDescriptor = MediaRouteDescriptor.fromBundle(groupRouteBundle);
}
}
} catch (Exception ex) {
Log.w(TAG, "Exception while unparceling control hints.", ex);
}
// Create group route descriptor
if (groupDescriptor == null) {
groupDescriptor = new MediaRouteDescriptor.Builder(
routingController.getId(), groupRouteName)
.setConnectionState(MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED)
.setPlaybackType(MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE)
.setVolume(routingController.getVolume())
.setVolumeMax(routingController.getVolumeMax())
.setVolumeHandling(routingController.getVolumeHandling())
.addControlFilters(initialRouteDescriptor.getControlFilters())
.addGroupMemberIds(selectedRouteIds)
.build();
}
// Create dynamic route descriptors
List<String> selectableRouteIds =
MediaRouter2Utils.getRouteIds(routingController.getSelectableRoutes());
List<String> deselectableRouteIds =
MediaRouter2Utils.getRouteIds(routingController.getDeselectableRoutes());
MediaRouteProviderDescriptor providerDescriptor = getDescriptor();
if (providerDescriptor == null) {
Log.w(TAG, "setDynamicRouteDescriptors: providerDescriptor is not set.");
return;
}
List<DynamicRouteDescriptor> dynamicRouteDescriptors = new ArrayList<>();
List<MediaRouteDescriptor> routeDescriptors = providerDescriptor.getRoutes();
if (!routeDescriptors.isEmpty()) {
for (MediaRouteDescriptor descriptor: routeDescriptors) {
String routeId = descriptor.getId();
DynamicRouteDescriptor.Builder builder =
new DynamicRouteDescriptor.Builder(descriptor)
.setSelectionState(selectedRouteIds.contains(routeId)
? DynamicRouteDescriptor.SELECTED
: DynamicRouteDescriptor.UNSELECTED)
.setIsGroupable(selectableRouteIds.contains(routeId))
.setIsUnselectable(deselectableRouteIds.contains(routeId))
.setIsTransferable(true);
dynamicRouteDescriptors.add(builder.build());
}
}
controller.setGroupRouteDescriptor(groupDescriptor);
controller.notifyDynamicRoutesChanged(groupDescriptor, dynamicRouteDescriptors);
}
/**
* Returns a new discovery request where {@link MediaControlIntent#CATEGORY_LIVE_AUDIO}
* is added to (or removed from) the given request, based on whether the 'transfer to local'
* feature is enabled.
*/
private MediaRouteDiscoveryRequest updateDiscoveryRequest(
@Nullable MediaRouteDiscoveryRequest request, boolean transferToLocalEnabled) {
if (request == null) {
request = new MediaRouteDiscoveryRequest(MediaRouteSelector.EMPTY, false);
}
List<String> controlCategories = request.getSelector().getControlCategories();
if (transferToLocalEnabled) {
// CATEGORY_LIVE_AUDIO should be added.
if (!controlCategories.contains(MediaControlIntent.CATEGORY_LIVE_AUDIO)) {
controlCategories.add(MediaControlIntent.CATEGORY_LIVE_AUDIO);
}
} else {
// CATEGORY_LIVE_AUDIO should be removed.
controlCategories.remove(MediaControlIntent.CATEGORY_LIVE_AUDIO);
}
MediaRouteSelector selector = new MediaRouteSelector.Builder()
.addControlCategories(controlCategories)
.build();
return new MediaRouteDiscoveryRequest(selector, request.isActiveScan());
}
abstract static class Callback {
public abstract void onSelectRoute(@NonNull String routeDescriptorId,
@MediaRouter.UnselectReason int reason);
public abstract void onSelectFallbackRoute(@MediaRouter.UnselectReason int reason);
public abstract void onReleaseController(@NonNull RouteController controller);
}
private class RouteCallback extends MediaRouter2.RouteCallback {
RouteCallback() {}
@Override
public void onRoutesAdded(@NonNull List<MediaRoute2Info> routes) {
refreshRoutes();
}
@Override
public void onRoutesRemoved(@NonNull List<MediaRoute2Info> routes) {
refreshRoutes();
}
@Override
public void onRoutesChanged(@NonNull List<MediaRoute2Info> routes) {
refreshRoutes();
}
}
private class TransferCallback extends MediaRouter2.TransferCallback {
TransferCallback() {}
@Override
public void onTransfer(@NonNull MediaRouter2.RoutingController oldController,
@NonNull MediaRouter2.RoutingController newController) {
mControllerMap.remove(oldController);
if (newController == mMediaRouter2.getSystemController()) {
mCallback.onSelectFallbackRoute(UNSELECT_REASON_ROUTE_CHANGED);
} else {
List<MediaRoute2Info> selectedRoutes = newController.getSelectedRoutes();
if (selectedRoutes.isEmpty()) {
Log.w(TAG, "Selected routes are empty. This shouldn't happen.");
return;
}
// TODO: Handle the case that the initial member is a group
String routeId = selectedRoutes.get(0).getId();
GroupRouteController controller = new GroupRouteController(newController, routeId);
mControllerMap.put(newController, controller);
mCallback.onSelectRoute(routeId, UNSELECT_REASON_ROUTE_CHANGED);
setDynamicRouteDescriptors(newController);
}
}
@Override
public void onTransferFailure(@NonNull MediaRoute2Info requestedRoute) {
Log.w(TAG, "Transfer failed. requestedRoute=" + requestedRoute);
}
@Override
public void onStop(@NonNull MediaRouter2.RoutingController routingController) {
RouteController routeController = mControllerMap.remove(routingController);
if (routeController != null) {
mCallback.onReleaseController(routeController);
} else {
Log.w(TAG, "onStop: No matching routeController found. routingController="
+ routingController);
}
}
}
private class ControllerCallback extends MediaRouter2.ControllerCallback {
ControllerCallback() {}
@Override
public void onControllerUpdated(@NonNull MediaRouter2.RoutingController routingController) {
setDynamicRouteDescriptors(routingController);
}
}
private class MemberRouteController extends RouteController {
final String mOriginalRouteId;
final GroupRouteController mGroupRouteController;
MemberRouteController(@Nullable String originalRouteId,
@Nullable GroupRouteController groupRouteController) {
mOriginalRouteId = originalRouteId;
mGroupRouteController = groupRouteController;
}
@Override
public void onSetVolume(int volume) {
// TODO: Unhide MediaRouter2#setRouteVolume() and use it in Android S+
if (mOriginalRouteId == null || mGroupRouteController == null) {
return;
}
mGroupRouteController.setMemberRouteVolume(mOriginalRouteId, volume);
}
@Override
public void onUpdateVolume(int delta) {
// TODO: Unhide MediaRouter2#setRouteVolume() and use it in Android S+
if (mOriginalRouteId == null || mGroupRouteController == null) {
return;
}
mGroupRouteController.updateMemberRouteVolume(mOriginalRouteId, delta);
}
}
private class GroupRouteController extends DynamicGroupRouteController {
// Time to clear mOptimisticVolume
private static final long OPTIMISTIC_VOLUME_TIMEOUT_MS = 1_000;
final String mInitialMemberRouteId;
final MediaRouter2.RoutingController mRoutingController;
@Nullable
final Messenger mServiceMessenger;
@Nullable
final Messenger mReceiveMessenger;
final SparseArray<ControlRequestCallback> mPendingCallbacks = new SparseArray<>();
final Handler mControllerHandler;
AtomicInteger mNextRequestId = new AtomicInteger(1);
private final Runnable mClearOptimisticVolumeRunnable = () -> mOptimisticVolume = -1;
// The possible current volume set by the user recently or -1 if not.
int mOptimisticVolume = -1;
@Nullable
MediaRouteDescriptor mGroupRouteDescriptor;
GroupRouteController(@NonNull MediaRouter2.RoutingController routingController,
@NonNull String initialMemberRouteId) {
mRoutingController = routingController;
mInitialMemberRouteId = initialMemberRouteId;
mServiceMessenger = getMessengerFromRoutingController(routingController);
mReceiveMessenger = mServiceMessenger == null ? null :
new Messenger(new ReceiveHandler());
mControllerHandler = new Handler(Looper.getMainLooper());
}
public String getGroupRouteId() {
return (mGroupRouteDescriptor != null) ? mGroupRouteDescriptor.getId()
: mRoutingController.getId();
}
@Override
public void onSetVolume(int volume) {
if (mRoutingController == null) {
return;
}
mRoutingController.setVolume(volume);
mOptimisticVolume = volume;
scheduleClearOptimisticVolume();
}
@Override
public void onUpdateVolume(int delta) {
if (mRoutingController == null) {
return;
}
int volumeBefore = mOptimisticVolume < 0 ? mRoutingController.getVolume() :
mOptimisticVolume;
mOptimisticVolume = Math.max(0, Math.min(volumeBefore + delta,
mRoutingController.getVolumeMax()));
mRoutingController.setVolume(mOptimisticVolume);
scheduleClearOptimisticVolume();
}
@Override
public boolean onControlRequest(@NonNull Intent intent,
@Nullable ControlRequestCallback callback) {
if (mRoutingController == null || mRoutingController.isReleased()
|| mServiceMessenger == null) {
return false;
}
int requestId = mNextRequestId.getAndIncrement();
Message msg = Message.obtain();
msg.what = CLIENT_MSG_ROUTE_CONTROL_REQUEST;
msg.arg1 = requestId;
msg.obj = intent;
msg.replyTo = mReceiveMessenger;
try {
mServiceMessenger.send(msg);
// TODO: Clear callbacks for unresponsive requests
if (callback != null) {
mPendingCallbacks.put(requestId, callback);
}
return true;
} catch (DeadObjectException ex) {
// The service died.
} catch (RemoteException ex) {
Log.e(TAG, "Could not send control request to service.", ex);
}
return false;
}
@Override
public void onRelease() {
mRoutingController.release();
}
@Override
public void onUpdateMemberRoutes(@Nullable List<String> routeIds) {
// Assuming only one ID exist in the list
if (routeIds == null || routeIds.isEmpty()) {
Log.w(TAG, "onUpdateMemberRoutes: Ignoring null or empty routeIds.");
return;
}
String routeId = routeIds.get(0);
MediaRoute2Info route = getRouteById(routeId);
if (route == null) {
Log.w(TAG, "onUpdateMemberRoutes: Specified route not found. routeId=" + routeId);
return;
}
mMediaRouter2.transferTo(route);
}
@Override
public void onAddMemberRoute(@NonNull String routeId) {
if (routeId == null || routeId.isEmpty()) {
Log.w(TAG, "onAddMemberRoute: Ignoring null or empty routeId.");
return;
}
MediaRoute2Info route = getRouteById(routeId);
if (route == null) {
Log.w(TAG, "onAddMemberRoute: Specified route not found. routeId=" + routeId);
return;
}
mRoutingController.selectRoute(route);
}
@Override
public void onRemoveMemberRoute(@NonNull String routeId) {
if (routeId == null || routeId.isEmpty()) {
Log.w(TAG, "onRemoveMemberRoute: Ignoring null or empty routeId.");
return;
}
MediaRoute2Info route = getRouteById(routeId);
if (route == null) {
Log.w(TAG, "onRemoveMemberRoute: Specified route not found. routeId=" + routeId);
return;
}
mRoutingController.deselectRoute(route);
}
private void scheduleClearOptimisticVolume() {
mControllerHandler.removeCallbacks(mClearOptimisticVolumeRunnable);
mControllerHandler.postDelayed(mClearOptimisticVolumeRunnable,
OPTIMISTIC_VOLUME_TIMEOUT_MS);
}
void setMemberRouteVolume(@NonNull String memberRouteOriginalId, int volume) {
if (mRoutingController == null || mRoutingController.isReleased()
|| mServiceMessenger == null) {
return;
}
int requestId = mNextRequestId.getAndIncrement();
Message msg = Message.obtain();
msg.what = CLIENT_MSG_SET_ROUTE_VOLUME;
msg.arg1 = requestId;
Bundle data = new Bundle();
data.putInt(CLIENT_DATA_VOLUME, volume);
data.putString(CLIENT_DATA_ROUTE_ID, memberRouteOriginalId);
msg.setData(data);
msg.replyTo = mReceiveMessenger;
try {
mServiceMessenger.send(msg);
} catch (DeadObjectException ex) {
// The service died.
} catch (RemoteException ex) {
Log.e(TAG, "Could not send control request to service.", ex);
}
}
void updateMemberRouteVolume(@NonNull String memberRouteOriginalId, int delta) {
if (mRoutingController == null || mRoutingController.isReleased()
|| mServiceMessenger == null) {
return;
}
int requestId = mNextRequestId.getAndIncrement();
Message msg = Message.obtain();
msg.what = CLIENT_MSG_UPDATE_ROUTE_VOLUME;
msg.arg1 = requestId;
Bundle data = new Bundle();
data.putInt(CLIENT_DATA_VOLUME, delta);
data.putString(CLIENT_DATA_ROUTE_ID, memberRouteOriginalId);
msg.setData(data);
msg.replyTo = mReceiveMessenger;
try {
mServiceMessenger.send(msg);
} catch (DeadObjectException ex) {
// The service died.
} catch (RemoteException ex) {
Log.e(TAG, "Could not send control request to service.", ex);
}
}
void setGroupRouteDescriptor(@NonNull MediaRouteDescriptor descriptor) {
mGroupRouteDescriptor = descriptor;
}
class ReceiveHandler extends Handler {
ReceiveHandler() {
super(Looper.getMainLooper());
}
@Override
public void handleMessage(Message msg) {
final int what = msg.what;
final int requestId = msg.arg1;
final int arg = msg.arg2;
final Object obj = msg.obj;
final Bundle data = msg.peekData();
ControlRequestCallback callback = mPendingCallbacks.get(requestId);
if (callback == null) {
Log.w(TAG, "Pending callback not found for control request.");
return;
}
mPendingCallbacks.remove(requestId);
switch (what) {
case SERVICE_MSG_CONTROL_REQUEST_SUCCEEDED:
callback.onResult((Bundle) obj);
break;
case SERVICE_MSG_CONTROL_REQUEST_FAILED:
String error = data == null ? null : data.getString(SERVICE_DATA_ERROR);
callback.onError(error, (Bundle) obj);
break;
}
}
}
}
}