| /* | |
| * 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.content.Context; | |
| import android.media.session.MediaController; | |
| import android.media.session.MediaSessionManager; | |
| import android.os.Handler; | |
| import android.os.Message; | |
| import android.os.RemoteException; | |
| import android.os.ServiceManager; | |
| import android.text.TextUtils; | |
| import android.util.ArrayMap; | |
| import android.util.ArraySet; | |
| import android.util.Log; | |
| import com.android.internal.annotations.GuardedBy; | |
| import com.android.internal.annotations.VisibleForTesting; | |
| import com.android.internal.util.Preconditions; | |
| 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.ConcurrentHashMap; | |
| import java.util.concurrent.ConcurrentMap; | |
| import java.util.concurrent.CopyOnWriteArrayList; | |
| import java.util.concurrent.Executor; | |
| import java.util.concurrent.atomic.AtomicInteger; | |
| import java.util.function.Predicate; | |
| import java.util.stream.Collectors; | |
| /** | |
| * A class that monitors and controls media routing of other apps. | |
| * {@link android.Manifest.permission#MEDIA_CONTENT_CONTROL} is required to use this class, | |
| * or {@link SecurityException} will be thrown. | |
| * @hide | |
| */ | |
| public final class MediaRouter2Manager { | |
| private static final String TAG = "MR2Manager"; | |
| private static final Object sLock = new Object(); | |
| /** | |
| * The request ID for requests not asked by this instance. | |
| * Shouldn't be used for a valid request. | |
| * @hide | |
| */ | |
| public static final int REQUEST_ID_NONE = 0; | |
| /** @hide */ | |
| @VisibleForTesting | |
| public static final int TRANSFER_TIMEOUT_MS = 30_000; | |
| @GuardedBy("sLock") | |
| private static MediaRouter2Manager sInstance; | |
| private final MediaSessionManager mMediaSessionManager; | |
| private final Client mClient; | |
| private final IMediaRouterService mMediaRouterService; | |
| private final AtomicInteger mScanRequestCount = new AtomicInteger(/* initialValue= */ 0); | |
| final Handler mHandler; | |
| final CopyOnWriteArrayList<CallbackRecord> mCallbackRecords = new CopyOnWriteArrayList<>(); | |
| private final Object mRoutesLock = new Object(); | |
| @GuardedBy("mRoutesLock") | |
| private final Map<String, MediaRoute2Info> mRoutes = new HashMap<>(); | |
| @NonNull | |
| final ConcurrentMap<String, RouteDiscoveryPreference> mDiscoveryPreferenceMap = | |
| new ConcurrentHashMap<>(); | |
| // TODO(b/241888071): Merge mDiscoveryPreferenceMap and mPackageToRouteListingPreferenceMap into | |
| // a single record object maintained by a single package-to-record map. | |
| @NonNull | |
| private final ConcurrentMap<String, RouteListingPreference> | |
| mPackageToRouteListingPreferenceMap = new ConcurrentHashMap<>(); | |
| private final AtomicInteger mNextRequestId = new AtomicInteger(1); | |
| private final CopyOnWriteArrayList<TransferRequest> mTransferRequests = | |
| new CopyOnWriteArrayList<>(); | |
| /** | |
| * Gets an instance of media router manager that controls media route of other applications. | |
| * | |
| * @return The media router manager instance for the context. | |
| */ | |
| public static MediaRouter2Manager getInstance(@NonNull Context context) { | |
| Objects.requireNonNull(context, "context must not be null"); | |
| synchronized (sLock) { | |
| if (sInstance == null) { | |
| sInstance = new MediaRouter2Manager(context); | |
| } | |
| return sInstance; | |
| } | |
| } | |
| private MediaRouter2Manager(Context context) { | |
| mMediaRouterService = IMediaRouterService.Stub.asInterface( | |
| ServiceManager.getService(Context.MEDIA_ROUTER_SERVICE)); | |
| mMediaSessionManager = (MediaSessionManager) context | |
| .getSystemService(Context.MEDIA_SESSION_SERVICE); | |
| mHandler = new Handler(context.getMainLooper()); | |
| mClient = new Client(); | |
| try { | |
| mMediaRouterService.registerManager(mClient, context.getPackageName()); | |
| } catch (RemoteException ex) { | |
| throw ex.rethrowFromSystemServer(); | |
| } | |
| } | |
| /** | |
| * Registers a callback to listen route info. | |
| * | |
| * @param executor the executor that runs the callback | |
| * @param callback the callback to add | |
| */ | |
| public void registerCallback(@NonNull @CallbackExecutor Executor executor, | |
| @NonNull Callback callback) { | |
| Objects.requireNonNull(executor, "executor must not be null"); | |
| Objects.requireNonNull(callback, "callback must not be null"); | |
| CallbackRecord callbackRecord = new CallbackRecord(executor, callback); | |
| if (!mCallbackRecords.addIfAbsent(callbackRecord)) { | |
| Log.w(TAG, "Ignoring to register the same callback twice."); | |
| return; | |
| } | |
| } | |
| /** | |
| * Unregisters the specified callback. | |
| * | |
| * @param callback the callback to unregister | |
| */ | |
| public void unregisterCallback(@NonNull Callback callback) { | |
| Objects.requireNonNull(callback, "callback must not be null"); | |
| if (!mCallbackRecords.remove(new CallbackRecord(null, callback))) { | |
| Log.w(TAG, "unregisterCallback: Ignore unknown callback. " + callback); | |
| return; | |
| } | |
| } | |
| /** | |
| * Registers a request to scan for remote routes. | |
| * | |
| * <p>Increases the count of active scanning requests. When the count transitions from zero to | |
| * one, sends a request to the system server to start scanning. | |
| * | |
| * <p>Clients must {@link #unregisterScanRequest() unregister their scan requests} when scanning | |
| * is no longer needed, to avoid unnecessary resource usage. | |
| */ | |
| public void registerScanRequest() { | |
| if (mScanRequestCount.getAndIncrement() == 0) { | |
| try { | |
| mMediaRouterService.startScan(mClient); | |
| } catch (RemoteException ex) { | |
| throw ex.rethrowFromSystemServer(); | |
| } | |
| } | |
| } | |
| /** | |
| * Unregisters a scan request made by {@link #registerScanRequest()}. | |
| * | |
| * <p>Decreases the count of active scanning requests. When the count transitions from one to | |
| * zero, sends a request to the system server to stop scanning. | |
| * | |
| * @throws IllegalStateException If called while there are no active scan requests. | |
| */ | |
| public void unregisterScanRequest() { | |
| if (mScanRequestCount.updateAndGet( | |
| count -> { | |
| if (count == 0) { | |
| throw new IllegalStateException( | |
| "No active scan requests to unregister."); | |
| } else { | |
| return --count; | |
| } | |
| }) | |
| == 0) { | |
| try { | |
| mMediaRouterService.stopScan(mClient); | |
| } catch (RemoteException ex) { | |
| throw ex.rethrowFromSystemServer(); | |
| } | |
| } | |
| } | |
| /** | |
| * Gets a {@link android.media.session.MediaController} associated with the | |
| * given routing session. | |
| * If there is no matching media session, {@code null} is returned. | |
| */ | |
| @Nullable | |
| public MediaController getMediaControllerForRoutingSession( | |
| @NonNull RoutingSessionInfo sessionInfo) { | |
| for (MediaController controller : mMediaSessionManager.getActiveSessions(null)) { | |
| if (areSessionsMatched(controller, sessionInfo)) { | |
| return controller; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * Gets available routes for an application. | |
| * | |
| * @param packageName the package name of the application | |
| */ | |
| @NonNull | |
| public List<MediaRoute2Info> getAvailableRoutes(@NonNull String packageName) { | |
| Objects.requireNonNull(packageName, "packageName must not be null"); | |
| List<RoutingSessionInfo> sessions = getRoutingSessions(packageName); | |
| return getAvailableRoutes(sessions.get(sessions.size() - 1)); | |
| } | |
| /** | |
| * Gets routes that can be transferable seamlessly for an application. | |
| * | |
| * @param packageName the package name of the application | |
| */ | |
| @NonNull | |
| public List<MediaRoute2Info> getTransferableRoutes(@NonNull String packageName) { | |
| Objects.requireNonNull(packageName, "packageName must not be null"); | |
| List<RoutingSessionInfo> sessions = getRoutingSessions(packageName); | |
| return getTransferableRoutes(sessions.get(sessions.size() - 1)); | |
| } | |
| /** | |
| * Gets available routes for the given routing session. | |
| * The returned routes can be passed to | |
| * {@link #transfer(RoutingSessionInfo, MediaRoute2Info)} for transferring the routing session. | |
| * | |
| * @param sessionInfo the routing session that would be transferred | |
| */ | |
| @NonNull | |
| public List<MediaRoute2Info> getAvailableRoutes(@NonNull RoutingSessionInfo sessionInfo) { | |
| return getFilteredRoutes(sessionInfo, /*includeSelectedRoutes=*/true, | |
| /*additionalFilter=*/null); | |
| } | |
| /** | |
| * Gets routes that can be transferable seamlessly for the given routing session. | |
| * The returned routes can be passed to | |
| * {@link #transfer(RoutingSessionInfo, MediaRoute2Info)} for transferring the routing session. | |
| * <p> | |
| * This includes routes that are {@link RoutingSessionInfo#getTransferableRoutes() transferable} | |
| * by provider itself and routes that are different playback type (e.g. local/remote) | |
| * from the given routing session. | |
| * | |
| * @param sessionInfo the routing session that would be transferred | |
| */ | |
| @NonNull | |
| public List<MediaRoute2Info> getTransferableRoutes(@NonNull RoutingSessionInfo sessionInfo) { | |
| return getFilteredRoutes(sessionInfo, /*includeSelectedRoutes=*/false, | |
| (route) -> sessionInfo.isSystemSession() ^ route.isSystemRoute()); | |
| } | |
| private List<MediaRoute2Info> getSortedRoutes(RouteDiscoveryPreference preference) { | |
| if (!preference.shouldRemoveDuplicates()) { | |
| synchronized (mRoutesLock) { | |
| return List.copyOf(mRoutes.values()); | |
| } | |
| } | |
| Map<String, Integer> packagePriority = new ArrayMap<>(); | |
| int count = preference.getDeduplicationPackageOrder().size(); | |
| for (int i = 0; i < count; i++) { | |
| // the last package will have 1 as the priority | |
| packagePriority.put(preference.getDeduplicationPackageOrder().get(i), count - i); | |
| } | |
| ArrayList<MediaRoute2Info> routes; | |
| synchronized (mRoutesLock) { | |
| routes = new ArrayList<>(mRoutes.values()); | |
| } | |
| // take the negative for descending order | |
| routes.sort(Comparator.comparingInt( | |
| r -> -packagePriority.getOrDefault(r.getPackageName(), 0))); | |
| return routes; | |
| } | |
| private List<MediaRoute2Info> getFilteredRoutes(@NonNull RoutingSessionInfo sessionInfo, | |
| boolean includeSelectedRoutes, | |
| @Nullable Predicate<MediaRoute2Info> additionalFilter) { | |
| Objects.requireNonNull(sessionInfo, "sessionInfo must not be null"); | |
| List<MediaRoute2Info> routes = new ArrayList<>(); | |
| Set<String> deduplicationIdSet = new ArraySet<>(); | |
| String packageName = sessionInfo.getClientPackageName(); | |
| RouteDiscoveryPreference discoveryPreference = | |
| mDiscoveryPreferenceMap.getOrDefault(packageName, RouteDiscoveryPreference.EMPTY); | |
| for (MediaRoute2Info route : getSortedRoutes(discoveryPreference)) { | |
| if (!route.isVisibleTo(packageName)) { | |
| continue; | |
| } | |
| boolean transferableRoutesContainRoute = | |
| sessionInfo.getTransferableRoutes().contains(route.getId()); | |
| boolean selectedRoutesContainRoute = | |
| sessionInfo.getSelectedRoutes().contains(route.getId()); | |
| if (transferableRoutesContainRoute | |
| || (includeSelectedRoutes && selectedRoutesContainRoute)) { | |
| routes.add(route); | |
| continue; | |
| } | |
| if (!route.hasAnyFeatures(discoveryPreference.getPreferredFeatures())) { | |
| continue; | |
| } | |
| if (!discoveryPreference.getAllowedPackages().isEmpty() | |
| && (route.getPackageName() == null | |
| || !discoveryPreference.getAllowedPackages() | |
| .contains(route.getPackageName()))) { | |
| continue; | |
| } | |
| if (additionalFilter != null && !additionalFilter.test(route)) { | |
| continue; | |
| } | |
| if (discoveryPreference.shouldRemoveDuplicates()) { | |
| if (!Collections.disjoint(deduplicationIdSet, route.getDeduplicationIds())) { | |
| continue; | |
| } | |
| deduplicationIdSet.addAll(route.getDeduplicationIds()); | |
| } | |
| routes.add(route); | |
| } | |
| return routes; | |
| } | |
| /** | |
| * Returns the preferred features of the specified package name. | |
| */ | |
| @NonNull | |
| public RouteDiscoveryPreference getDiscoveryPreference(@NonNull String packageName) { | |
| Objects.requireNonNull(packageName, "packageName must not be null"); | |
| return mDiscoveryPreferenceMap.getOrDefault(packageName, RouteDiscoveryPreference.EMPTY); | |
| } | |
| /** | |
| * Returns the {@link RouteListingPreference} of the app with the given {@code packageName}, or | |
| * null if the app has not set any. | |
| */ | |
| @Nullable | |
| public RouteListingPreference getRouteListingPreference(@NonNull String packageName) { | |
| Preconditions.checkArgument(!TextUtils.isEmpty(packageName)); | |
| return mPackageToRouteListingPreferenceMap.get(packageName); | |
| } | |
| /** | |
| * Gets the system routing session for the given {@code packageName}. | |
| * Apps can select a route that is not the global route. (e.g. an app can select the device | |
| * route while BT route is available.) | |
| * | |
| * @param packageName the package name of the application. | |
| */ | |
| @Nullable | |
| public RoutingSessionInfo getSystemRoutingSession(@Nullable String packageName) { | |
| try { | |
| return mMediaRouterService.getSystemSessionInfoForPackage(mClient, packageName); | |
| } catch (RemoteException ex) { | |
| throw ex.rethrowFromSystemServer(); | |
| } | |
| } | |
| /** | |
| * Gets the routing session of a media session. | |
| * If the session is using {#link PlaybackInfo#PLAYBACK_TYPE_LOCAL local playback}, | |
| * the system routing session is returned. | |
| * If the session is using {#link PlaybackInfo#PLAYBACK_TYPE_REMOTE remote playback}, | |
| * it returns the corresponding routing session or {@code null} if it's unavailable. | |
| */ | |
| @Nullable | |
| public RoutingSessionInfo getRoutingSessionForMediaController(MediaController mediaController) { | |
| MediaController.PlaybackInfo playbackInfo = mediaController.getPlaybackInfo(); | |
| if (playbackInfo == null) { | |
| return null; | |
| } | |
| if (playbackInfo.getPlaybackType() == MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL) { | |
| return getSystemRoutingSession(mediaController.getPackageName()); | |
| } | |
| for (RoutingSessionInfo sessionInfo : getRemoteSessions()) { | |
| if (areSessionsMatched(mediaController, sessionInfo)) { | |
| return sessionInfo; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * Gets routing sessions of an application with the given package name. | |
| * The first element of the returned list is the system routing session. | |
| * | |
| * @param packageName the package name of the application that is routing. | |
| * @see #getSystemRoutingSession(String) | |
| */ | |
| @NonNull | |
| public List<RoutingSessionInfo> getRoutingSessions(@NonNull String packageName) { | |
| Objects.requireNonNull(packageName, "packageName must not be null"); | |
| List<RoutingSessionInfo> sessions = new ArrayList<>(); | |
| sessions.add(getSystemRoutingSession(packageName)); | |
| for (RoutingSessionInfo sessionInfo : getRemoteSessions()) { | |
| if (TextUtils.equals(sessionInfo.getClientPackageName(), packageName)) { | |
| sessions.add(sessionInfo); | |
| } | |
| } | |
| return sessions; | |
| } | |
| /** | |
| * Gets the list of all routing sessions except the system routing session. | |
| * <p> | |
| * If you want to transfer media of an application, use {@link #getRoutingSessions(String)}. | |
| * If you want to get only the system routing session, use | |
| * {@link #getSystemRoutingSession(String)}. | |
| * | |
| * @see #getRoutingSessions(String) | |
| * @see #getSystemRoutingSession(String) | |
| */ | |
| @NonNull | |
| public List<RoutingSessionInfo> getRemoteSessions() { | |
| try { | |
| return mMediaRouterService.getRemoteSessions(mClient); | |
| } catch (RemoteException ex) { | |
| throw ex.rethrowFromSystemServer(); | |
| } | |
| } | |
| /** | |
| * Gets the list of all discovered routes. | |
| */ | |
| @NonNull | |
| public List<MediaRoute2Info> getAllRoutes() { | |
| List<MediaRoute2Info> routes = new ArrayList<>(); | |
| synchronized (mRoutesLock) { | |
| routes.addAll(mRoutes.values()); | |
| } | |
| return routes; | |
| } | |
| /** | |
| * Transfers a {@link RoutingSessionInfo routing session} belonging to a specified package name | |
| * to a {@link MediaRoute2Info media route}. | |
| * | |
| * <p>Same as {@link #transfer(RoutingSessionInfo, MediaRoute2Info)}, but resolves the routing | |
| * session based on the provided package name. | |
| */ | |
| public void transfer(@NonNull String packageName, @NonNull MediaRoute2Info route) { | |
| Objects.requireNonNull(packageName, "packageName must not be null"); | |
| Objects.requireNonNull(route, "route must not be null"); | |
| List<RoutingSessionInfo> sessionInfos = getRoutingSessions(packageName); | |
| RoutingSessionInfo targetSession = sessionInfos.get(sessionInfos.size() - 1); | |
| transfer(targetSession, route); | |
| } | |
| /** | |
| * Transfers a routing session to a media route. | |
| * <p>{@link Callback#onTransferred} or {@link Callback#onTransferFailed} will be called | |
| * depending on the result. | |
| * | |
| * @param sessionInfo the routing session info to transfer | |
| * @param route the route transfer to | |
| * | |
| * @see Callback#onTransferred(RoutingSessionInfo, RoutingSessionInfo) | |
| * @see Callback#onTransferFailed(RoutingSessionInfo, MediaRoute2Info) | |
| */ | |
| 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); | |
| synchronized (mRoutesLock) { | |
| if (!mRoutes.containsKey(route.getId())) { | |
| Log.w(TAG, "transfer: Ignoring an unknown route id=" + route.getId()); | |
| notifyTransferFailed(sessionInfo, route); | |
| return; | |
| } | |
| } | |
| if (sessionInfo.getTransferableRoutes().contains(route.getId())) { | |
| transferToRoute(sessionInfo, route); | |
| } else { | |
| requestCreateSession(sessionInfo, route); | |
| } | |
| } | |
| /** | |
| * Requests a volume change for a 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} | |
| * (inclusive). | |
| */ | |
| public void setRouteVolume(@NonNull MediaRoute2Info route, int volume) { | |
| Objects.requireNonNull(route, "route must not be null"); | |
| 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 routing session asynchronously. | |
| * | |
| * @param volume The new volume value between 0 and {@link RoutingSessionInfo#getVolumeMax} | |
| * (inclusive). | |
| */ | |
| public void setSessionVolume(@NonNull RoutingSessionInfo sessionInfo, int volume) { | |
| 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(); | |
| } | |
| } | |
| void updateRoutesOnHandler(@NonNull List<MediaRoute2Info> routes) { | |
| synchronized (mRoutesLock) { | |
| mRoutes.clear(); | |
| for (MediaRoute2Info route : routes) { | |
| mRoutes.put(route.getId(), route); | |
| } | |
| } | |
| notifyRoutesUpdated(); | |
| } | |
| void createSessionOnHandler(int requestId, RoutingSessionInfo sessionInfo) { | |
| TransferRequest matchingRequest = null; | |
| for (TransferRequest request : mTransferRequests) { | |
| if (request.mRequestId == requestId) { | |
| matchingRequest = request; | |
| break; | |
| } | |
| } | |
| if (matchingRequest == null) { | |
| return; | |
| } | |
| mTransferRequests.remove(matchingRequest); | |
| MediaRoute2Info requestedRoute = matchingRequest.mTargetRoute; | |
| if (sessionInfo == null) { | |
| notifyTransferFailed(matchingRequest.mOldSessionInfo, 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() | |
| + ")"); | |
| notifyTransferFailed(matchingRequest.mOldSessionInfo, 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() | |
| + ")"); | |
| notifyTransferFailed(matchingRequest.mOldSessionInfo, requestedRoute); | |
| return; | |
| } | |
| notifyTransferred(matchingRequest.mOldSessionInfo, sessionInfo); | |
| } | |
| void handleFailureOnHandler(int requestId, int reason) { | |
| TransferRequest matchingRequest = null; | |
| for (TransferRequest request : mTransferRequests) { | |
| if (request.mRequestId == requestId) { | |
| matchingRequest = request; | |
| break; | |
| } | |
| } | |
| if (matchingRequest != null) { | |
| mTransferRequests.remove(matchingRequest); | |
| notifyTransferFailed(matchingRequest.mOldSessionInfo, matchingRequest.mTargetRoute); | |
| return; | |
| } | |
| notifyRequestFailed(reason); | |
| } | |
| void handleSessionsUpdatedOnHandler(RoutingSessionInfo sessionInfo) { | |
| for (TransferRequest request : mTransferRequests) { | |
| String sessionId = request.mOldSessionInfo.getId(); | |
| if (!TextUtils.equals(sessionId, sessionInfo.getId())) { | |
| continue; | |
| } | |
| if (sessionInfo.getSelectedRoutes().contains(request.mTargetRoute.getId())) { | |
| mTransferRequests.remove(request); | |
| notifyTransferred(request.mOldSessionInfo, sessionInfo); | |
| break; | |
| } | |
| } | |
| notifySessionUpdated(sessionInfo); | |
| } | |
| private void notifyRoutesUpdated() { | |
| for (CallbackRecord record: mCallbackRecords) { | |
| record.mExecutor.execute(() -> record.mCallback.onRoutesUpdated()); | |
| } | |
| } | |
| void notifySessionUpdated(RoutingSessionInfo sessionInfo) { | |
| for (CallbackRecord record : mCallbackRecords) { | |
| record.mExecutor.execute(() -> record.mCallback.onSessionUpdated(sessionInfo)); | |
| } | |
| } | |
| void notifySessionReleased(RoutingSessionInfo session) { | |
| for (CallbackRecord record : mCallbackRecords) { | |
| record.mExecutor.execute(() -> record.mCallback.onSessionReleased(session)); | |
| } | |
| } | |
| void notifyRequestFailed(int reason) { | |
| for (CallbackRecord record : mCallbackRecords) { | |
| record.mExecutor.execute(() -> record.mCallback.onRequestFailed(reason)); | |
| } | |
| } | |
| void notifyTransferred(RoutingSessionInfo oldSession, RoutingSessionInfo newSession) { | |
| for (CallbackRecord record : mCallbackRecords) { | |
| record.mExecutor.execute(() -> record.mCallback.onTransferred(oldSession, newSession)); | |
| } | |
| } | |
| void notifyTransferFailed(RoutingSessionInfo sessionInfo, MediaRoute2Info route) { | |
| for (CallbackRecord record : mCallbackRecords) { | |
| record.mExecutor.execute(() -> record.mCallback.onTransferFailed(sessionInfo, route)); | |
| } | |
| } | |
| void updateDiscoveryPreference(String packageName, RouteDiscoveryPreference preference) { | |
| if (preference == null) { | |
| mDiscoveryPreferenceMap.remove(packageName); | |
| return; | |
| } | |
| RouteDiscoveryPreference prevPreference = | |
| mDiscoveryPreferenceMap.put(packageName, preference); | |
| if (Objects.equals(preference, prevPreference)) { | |
| return; | |
| } | |
| for (CallbackRecord record : mCallbackRecords) { | |
| record.mExecutor.execute(() -> record.mCallback | |
| .onDiscoveryPreferenceChanged(packageName, preference)); | |
| } | |
| } | |
| private void updateRouteListingPreference( | |
| @NonNull String packageName, @Nullable RouteListingPreference routeListingPreference) { | |
| RouteListingPreference oldRouteListingPreference = | |
| routeListingPreference == null | |
| ? mPackageToRouteListingPreferenceMap.remove(packageName) | |
| : mPackageToRouteListingPreferenceMap.put( | |
| packageName, routeListingPreference); | |
| if (Objects.equals(oldRouteListingPreference, routeListingPreference)) { | |
| return; | |
| } | |
| for (CallbackRecord record : mCallbackRecords) { | |
| record.mExecutor.execute( | |
| () -> | |
| record.mCallback.onRouteListingPreferenceUpdated( | |
| packageName, routeListingPreference)); | |
| } | |
| } | |
| /** | |
| * Gets the unmodifiable list of selected routes for the session. | |
| */ | |
| @NonNull | |
| public List<MediaRoute2Info> getSelectedRoutes(@NonNull RoutingSessionInfo sessionInfo) { | |
| Objects.requireNonNull(sessionInfo, "sessionInfo must not be null"); | |
| synchronized (mRoutesLock) { | |
| return sessionInfo.getSelectedRoutes().stream().map(mRoutes::get) | |
| .filter(Objects::nonNull) | |
| .collect(Collectors.toList()); | |
| } | |
| } | |
| /** | |
| * Gets the unmodifiable list of selectable routes for the session. | |
| */ | |
| @NonNull | |
| public List<MediaRoute2Info> getSelectableRoutes(@NonNull RoutingSessionInfo sessionInfo) { | |
| Objects.requireNonNull(sessionInfo, "sessionInfo must not be null"); | |
| List<String> selectedRouteIds = sessionInfo.getSelectedRoutes(); | |
| synchronized (mRoutesLock) { | |
| return sessionInfo.getSelectableRoutes().stream() | |
| .filter(routeId -> !selectedRouteIds.contains(routeId)) | |
| .map(mRoutes::get) | |
| .filter(Objects::nonNull) | |
| .collect(Collectors.toList()); | |
| } | |
| } | |
| /** | |
| * Gets the unmodifiable list of deselectable routes for the session. | |
| */ | |
| @NonNull | |
| public List<MediaRoute2Info> getDeselectableRoutes(@NonNull RoutingSessionInfo sessionInfo) { | |
| Objects.requireNonNull(sessionInfo, "sessionInfo must not be null"); | |
| List<String> selectedRouteIds = sessionInfo.getSelectedRoutes(); | |
| synchronized (mRoutesLock) { | |
| return sessionInfo.getDeselectableRoutes().stream() | |
| .filter(routeId -> selectedRouteIds.contains(routeId)) | |
| .map(mRoutes::get) | |
| .filter(Objects::nonNull) | |
| .collect(Collectors.toList()); | |
| } | |
| } | |
| /** | |
| * 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 | |
| * #transfer(RoutingSessionInfo, MediaRoute2Info)} transferring to a route}, | |
| * where the media is expected to 'move' from one route to another. | |
| * <p> | |
| * The given route must satisfy all of the following conditions: | |
| * <ul> | |
| * <li>it should not be included in {@link #getSelectedRoutes(RoutingSessionInfo)}</li> | |
| * <li>it should be included in {@link #getSelectableRoutes(RoutingSessionInfo)}</li> | |
| * </ul> | |
| * If the route doesn't meet any of above conditions, it will be ignored. | |
| * | |
| * @see #getSelectedRoutes(RoutingSessionInfo) | |
| * @see #getSelectableRoutes(RoutingSessionInfo) | |
| * @see Callback#onSessionUpdated(RoutingSessionInfo) | |
| */ | |
| public void selectRoute(@NonNull RoutingSessionInfo sessionInfo, | |
| @NonNull MediaRoute2Info route) { | |
| 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(); | |
| } | |
| } | |
| /** | |
| * 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>it should be included in {@link #getSelectedRoutes(RoutingSessionInfo)}</li> | |
| * <li>it should be included in {@link #getDeselectableRoutes(RoutingSessionInfo)}</li> | |
| * </ul> | |
| * If the route doesn't meet any of above conditions, it will be ignored. | |
| * | |
| * @see #getSelectedRoutes(RoutingSessionInfo) | |
| * @see #getDeselectableRoutes(RoutingSessionInfo) | |
| * @see Callback#onSessionUpdated(RoutingSessionInfo) | |
| */ | |
| public void deselectRoute(@NonNull RoutingSessionInfo sessionInfo, | |
| @NonNull MediaRoute2Info route) { | |
| 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(); | |
| } | |
| } | |
| /** | |
| * Requests releasing a session. | |
| * <p> | |
| * If a session is released, any operation on the session will be ignored. | |
| * {@link Callback#onSessionReleased(RoutingSessionInfo)} will be called | |
| * when the session is released. | |
| * </p> | |
| * | |
| * @see Callback#onTransferred(RoutingSessionInfo, RoutingSessionInfo) | |
| */ | |
| public 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(); | |
| } | |
| } | |
| /** | |
| * Transfers the remote session to the given route. | |
| * | |
| * @hide | |
| */ | |
| private void transferToRoute(@NonNull RoutingSessionInfo session, | |
| @NonNull MediaRoute2Info route) { | |
| int requestId = createTransferRequest(session, route); | |
| try { | |
| mMediaRouterService.transferToRouteWithManager( | |
| mClient, requestId, session.getId(), route); | |
| } catch (RemoteException ex) { | |
| throw ex.rethrowFromSystemServer(); | |
| } | |
| } | |
| private void requestCreateSession(RoutingSessionInfo oldSession, MediaRoute2Info route) { | |
| if (TextUtils.isEmpty(oldSession.getClientPackageName())) { | |
| Log.w(TAG, "requestCreateSession: Can't create a session without package name."); | |
| notifyTransferFailed(oldSession, route); | |
| return; | |
| } | |
| int requestId = createTransferRequest(oldSession, route); | |
| try { | |
| mMediaRouterService.requestCreateSessionWithManager( | |
| mClient, requestId, oldSession, route); | |
| } catch (RemoteException ex) { | |
| throw ex.rethrowFromSystemServer(); | |
| } | |
| } | |
| private int createTransferRequest(RoutingSessionInfo session, MediaRoute2Info route) { | |
| int requestId = mNextRequestId.getAndIncrement(); | |
| TransferRequest transferRequest = new TransferRequest(requestId, session, route); | |
| mTransferRequests.add(transferRequest); | |
| Message timeoutMessage = | |
| obtainMessage(MediaRouter2Manager::handleTransferTimeout, this, transferRequest); | |
| mHandler.sendMessageDelayed(timeoutMessage, TRANSFER_TIMEOUT_MS); | |
| return requestId; | |
| } | |
| private void handleTransferTimeout(TransferRequest request) { | |
| boolean removed = mTransferRequests.remove(request); | |
| if (removed) { | |
| notifyTransferFailed(request.mOldSessionInfo, request.mTargetRoute); | |
| } | |
| } | |
| private boolean areSessionsMatched(MediaController mediaController, | |
| RoutingSessionInfo sessionInfo) { | |
| MediaController.PlaybackInfo playbackInfo = mediaController.getPlaybackInfo(); | |
| if (playbackInfo == null) { | |
| return false; | |
| } | |
| String volumeControlId = playbackInfo.getVolumeControlId(); | |
| if (volumeControlId == null) { | |
| return false; | |
| } | |
| if (TextUtils.equals(volumeControlId, sessionInfo.getId())) { | |
| return true; | |
| } | |
| // Workaround for provider not being able to know the unique session ID. | |
| return TextUtils.equals(volumeControlId, sessionInfo.getOriginalId()) | |
| && TextUtils.equals(mediaController.getPackageName(), | |
| sessionInfo.getOwnerPackageName()); | |
| } | |
| /** | |
| * Interface for receiving events about media routing changes. | |
| */ | |
| public interface Callback { | |
| /** | |
| * Called when the routes list changes. This includes adding, modifying, or removing | |
| * individual routes. | |
| */ | |
| default void onRoutesUpdated() {} | |
| /** | |
| * Called when a session is changed. | |
| * @param session the updated session | |
| */ | |
| default void onSessionUpdated(@NonNull RoutingSessionInfo session) {} | |
| /** | |
| * Called when a session is released. | |
| * @param session the released session. | |
| * @see #releaseSession(RoutingSessionInfo) | |
| */ | |
| default void onSessionReleased(@NonNull RoutingSessionInfo session) {} | |
| /** | |
| * Called when media is transferred. | |
| * | |
| * @param oldSession the previous session | |
| * @param newSession the new session | |
| */ | |
| default void onTransferred(@NonNull RoutingSessionInfo oldSession, | |
| @NonNull RoutingSessionInfo newSession) { } | |
| /** | |
| * Called when {@link #transfer(RoutingSessionInfo, MediaRoute2Info)} fails. | |
| */ | |
| default void onTransferFailed(@NonNull RoutingSessionInfo session, | |
| @NonNull MediaRoute2Info route) { } | |
| /** | |
| * Called when the preferred route features of an app is changed. | |
| * | |
| * @param packageName the package name of the application | |
| * @param preferredFeatures the list of preferred route features set by an application. | |
| */ | |
| default void onPreferredFeaturesChanged(@NonNull String packageName, | |
| @NonNull List<String> preferredFeatures) {} | |
| /** | |
| * Called when the preferred route features of an app is changed. | |
| * | |
| * @param packageName the package name of the application | |
| * @param discoveryPreference the new discovery preference set by the application. | |
| */ | |
| default void onDiscoveryPreferenceChanged(@NonNull String packageName, | |
| @NonNull RouteDiscoveryPreference discoveryPreference) { | |
| onPreferredFeaturesChanged(packageName, discoveryPreference.getPreferredFeatures()); | |
| } | |
| /** | |
| * Called when the app with the given {@code packageName} updates its {@link | |
| * MediaRouter2#setRouteListingPreference route listing preference}. | |
| * | |
| * @param packageName The package name of the app that changed its listing preference. | |
| * @param routeListingPreference The new {@link RouteListingPreference} set by the app with | |
| * the given {@code packageName}. Maybe null if an app has unset its preference (by | |
| * passing null to {@link MediaRouter2#setRouteListingPreference}). | |
| */ | |
| default void onRouteListingPreferenceUpdated( | |
| @NonNull String packageName, | |
| @Nullable RouteListingPreference routeListingPreference) {} | |
| /** | |
| * Called when a previous request has failed. | |
| * | |
| * @param reason the reason that the request has failed. Can be one of followings: | |
| * {@link MediaRoute2ProviderService#REASON_UNKNOWN_ERROR}, | |
| * {@link MediaRoute2ProviderService#REASON_REJECTED}, | |
| * {@link MediaRoute2ProviderService#REASON_NETWORK_ERROR}, | |
| * {@link MediaRoute2ProviderService#REASON_ROUTE_NOT_AVAILABLE}, | |
| * {@link MediaRoute2ProviderService#REASON_INVALID_COMMAND}, | |
| */ | |
| default void onRequestFailed(int reason) {} | |
| } | |
| final class CallbackRecord { | |
| public final Executor mExecutor; | |
| public final Callback mCallback; | |
| CallbackRecord(Executor executor, Callback callback) { | |
| mExecutor = executor; | |
| mCallback = callback; | |
| } | |
| @Override | |
| public boolean equals(Object obj) { | |
| if (this == obj) { | |
| return true; | |
| } | |
| if (!(obj instanceof CallbackRecord)) { | |
| return false; | |
| } | |
| return mCallback == ((CallbackRecord) obj).mCallback; | |
| } | |
| @Override | |
| public int hashCode() { | |
| return mCallback.hashCode(); | |
| } | |
| } | |
| static final class TransferRequest { | |
| public final int mRequestId; | |
| public final RoutingSessionInfo mOldSessionInfo; | |
| public final MediaRoute2Info mTargetRoute; | |
| TransferRequest(int requestId, @NonNull RoutingSessionInfo oldSessionInfo, | |
| @NonNull MediaRoute2Info targetRoute) { | |
| mRequestId = requestId; | |
| mOldSessionInfo = oldSessionInfo; | |
| mTargetRoute = targetRoute; | |
| } | |
| } | |
| class Client extends IMediaRouter2Manager.Stub { | |
| @Override | |
| public void notifySessionCreated(int requestId, RoutingSessionInfo session) { | |
| mHandler.sendMessage(obtainMessage(MediaRouter2Manager::createSessionOnHandler, | |
| MediaRouter2Manager.this, requestId, session)); | |
| } | |
| @Override | |
| public void notifySessionUpdated(RoutingSessionInfo session) { | |
| mHandler.sendMessage(obtainMessage(MediaRouter2Manager::handleSessionsUpdatedOnHandler, | |
| MediaRouter2Manager.this, session)); | |
| } | |
| @Override | |
| public void notifySessionReleased(RoutingSessionInfo session) { | |
| mHandler.sendMessage(obtainMessage(MediaRouter2Manager::notifySessionReleased, | |
| MediaRouter2Manager.this, session)); | |
| } | |
| @Override | |
| public void notifyRequestFailed(int requestId, int reason) { | |
| // Note: requestId is not used. | |
| mHandler.sendMessage(obtainMessage(MediaRouter2Manager::handleFailureOnHandler, | |
| MediaRouter2Manager.this, requestId, reason)); | |
| } | |
| @Override | |
| public void notifyDiscoveryPreferenceChanged(String packageName, | |
| RouteDiscoveryPreference discoveryPreference) { | |
| mHandler.sendMessage(obtainMessage(MediaRouter2Manager::updateDiscoveryPreference, | |
| MediaRouter2Manager.this, packageName, discoveryPreference)); | |
| } | |
| @Override | |
| public void notifyRouteListingPreferenceChange( | |
| String packageName, @Nullable RouteListingPreference routeListingPreference) { | |
| mHandler.sendMessage( | |
| obtainMessage( | |
| MediaRouter2Manager::updateRouteListingPreference, | |
| MediaRouter2Manager.this, | |
| packageName, | |
| routeListingPreference)); | |
| } | |
| @Override | |
| public void notifyRoutesUpdated(List<MediaRoute2Info> routes) { | |
| mHandler.sendMessage( | |
| obtainMessage( | |
| MediaRouter2Manager::updateRoutesOnHandler, | |
| MediaRouter2Manager.this, | |
| routes)); | |
| } | |
| } | |
| } |