| // Copyright 2018 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| package org.chromium.chrome.browser.media.router.caf; |
| |
| import android.os.Handler; |
| import android.support.annotation.NonNull; |
| import android.support.annotation.Nullable; |
| import android.support.annotation.VisibleForTesting; |
| import android.support.v7.media.MediaRouteSelector; |
| import android.support.v7.media.MediaRouter; |
| import android.support.v7.media.MediaRouter.RouteInfo; |
| |
| import com.google.android.gms.cast.framework.CastSession; |
| import com.google.android.gms.cast.framework.SessionManagerListener; |
| |
| import org.chromium.base.Log; |
| import org.chromium.chrome.browser.media.router.DiscoveryCallback; |
| import org.chromium.chrome.browser.media.router.DiscoveryDelegate; |
| import org.chromium.chrome.browser.media.router.FlingingController; |
| import org.chromium.chrome.browser.media.router.MediaRoute; |
| import org.chromium.chrome.browser.media.router.MediaRouteManager; |
| import org.chromium.chrome.browser.media.router.MediaRouteProvider; |
| import org.chromium.chrome.browser.media.router.MediaSink; |
| import org.chromium.chrome.browser.media.router.MediaSource; |
| |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| |
| /** |
| * A base provider containing common implementation for CAF-based {@link MediaRouteProvider}s. |
| */ |
| public abstract class CafBaseMediaRouteProvider |
| implements MediaRouteProvider, DiscoveryDelegate, SessionManagerListener<CastSession> { |
| private static final String TAG = "CafMR"; |
| |
| protected static final List<MediaSink> NO_SINKS = Collections.emptyList(); |
| private final @NonNull MediaRouter mAndroidMediaRouter; |
| protected final MediaRouteManager mManager; |
| protected final Map<String, DiscoveryCallback> mDiscoveryCallbacks = |
| new HashMap<String, DiscoveryCallback>(); |
| @VisibleForTesting |
| protected final Map<String, MediaRoute> mRoutes = new HashMap<String, MediaRoute>(); |
| protected Handler mHandler = new Handler(); |
| |
| private CreateRouteRequestInfo mPendingCreateRouteRequestInfo; |
| |
| protected CafBaseMediaRouteProvider(MediaRouter androidMediaRouter, MediaRouteManager manager) { |
| mAndroidMediaRouter = androidMediaRouter; |
| mManager = manager; |
| } |
| |
| /** |
| * @return A MediaSource object constructed from |sourceId|, or null if the derived class does |
| * not support the source. |
| */ |
| @Nullable |
| protected abstract MediaSource getSourceFromId(@NonNull String sourceId); |
| |
| /** |
| * Forward the sinks back to the native counterpart. |
| */ |
| private final void onSinksReceivedInternal(String sourceId, @NonNull List<MediaSink> sinks) { |
| Log.d(TAG, "Reporting %d sinks for source: %s", sinks.size(), sourceId); |
| mManager.onSinksReceived(sourceId, this, sinks); |
| } |
| |
| /** |
| * {@link DiscoveryDelegate} implementation. |
| */ |
| @Override |
| public final void onSinksReceived(String sourceId, @NonNull List<MediaSink> sinks) { |
| Log.d(TAG, "Received %d sinks for sourceId: %s", sinks.size(), sourceId); |
| mHandler.post(() -> { onSinksReceivedInternal(sourceId, sinks); }); |
| } |
| |
| @Override |
| public final boolean supportsSource(String sourceId) { |
| return getSourceFromId(sourceId) != null; |
| } |
| |
| @Override |
| public final void startObservingMediaSinks(String sourceId) { |
| Log.d(TAG, "startObservingMediaSinks: " + sourceId); |
| |
| MediaSource source = getSourceFromId(sourceId); |
| if (source == null) { |
| // If the source is invalid or not supported by this provider, report no devices |
| // available. |
| onSinksReceived(sourceId, NO_SINKS); |
| return; |
| } |
| |
| // No-op, if already monitoring the application for this source. |
| String applicationId = source.getApplicationId(); |
| DiscoveryCallback callback = mDiscoveryCallbacks.get(applicationId); |
| if (callback != null) { |
| callback.addSourceUrn(sourceId); |
| return; |
| } |
| |
| MediaRouteSelector routeSelector = source.buildRouteSelector(); |
| if (routeSelector == null) { |
| // If the application invalid, report no devices available. |
| onSinksReceived(sourceId, NO_SINKS); |
| return; |
| } |
| |
| List<MediaSink> knownSinks = new ArrayList<MediaSink>(); |
| for (RouteInfo route : mAndroidMediaRouter.getRoutes()) { |
| if (route.matchesSelector(routeSelector)) { |
| knownSinks.add(MediaSink.fromRoute(route)); |
| } |
| } |
| |
| callback = new DiscoveryCallback(sourceId, knownSinks, this, routeSelector); |
| mAndroidMediaRouter.addCallback( |
| routeSelector, callback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY); |
| mDiscoveryCallbacks.put(applicationId, callback); |
| } |
| |
| @Override |
| public final void stopObservingMediaSinks(String sourceId) { |
| Log.d(TAG, "startObservingMediaSinks: " + sourceId); |
| |
| MediaSource source = getSourceFromId(sourceId); |
| if (source == null) return; |
| |
| // No-op, if not monitoring the application for this source. |
| String applicationId = source.getApplicationId(); |
| DiscoveryCallback callback = mDiscoveryCallbacks.get(applicationId); |
| if (callback == null) return; |
| |
| callback.removeSourceUrn(sourceId); |
| |
| if (callback.isEmpty()) { |
| mAndroidMediaRouter.removeCallback(callback); |
| mDiscoveryCallbacks.remove(applicationId); |
| } |
| } |
| |
| @Override |
| public final void createRoute(String sourceId, String sinkId, String presentationId, |
| String origin, int tabId, boolean isIncognito, int nativeRequestId) { |
| Log.d(TAG, "createRoute"); |
| if (sessionController().isConnected()) { |
| // If there is an active session or a pending create route request, force end the |
| // current session and clean up the routes (can't wait for session ending as the signal |
| // might be delayed). |
| sessionController().endSession(); |
| handleSessionEnd(); |
| } |
| if (mPendingCreateRouteRequestInfo != null) { |
| cancelPendingRequest("Request replaced"); |
| } |
| |
| MediaSink sink = MediaSink.fromSinkId(sinkId, mAndroidMediaRouter); |
| if (sink == null) { |
| mManager.onRouteRequestError("No sink", nativeRequestId); |
| return; |
| } |
| |
| MediaSource source = getSourceFromId(sourceId); |
| if (source == null) { |
| mManager.onRouteRequestError("Unsupported source URL", nativeRequestId); |
| return; |
| } |
| |
| MediaRouter.RouteInfo targetRouteInfo = null; |
| for (MediaRouter.RouteInfo routeInfo : getAndroidMediaRouter().getRoutes()) { |
| if (routeInfo.getId().equals(sink.getId())) { |
| targetRouteInfo = routeInfo; |
| break; |
| } |
| } |
| if (targetRouteInfo == null) { |
| mManager.onRouteRequestError("The sink does not exist", nativeRequestId); |
| } |
| |
| CastUtils.getCastContext().getSessionManager().addSessionManagerListener( |
| this, CastSession.class); |
| |
| mPendingCreateRouteRequestInfo = new CreateRouteRequestInfo(source, sink, presentationId, |
| origin, tabId, isIncognito, nativeRequestId, targetRouteInfo); |
| |
| sessionController().requestSessionLaunch(); |
| } |
| |
| @Override |
| public void closeRoute(String routeId) { |
| MediaRoute route = mRoutes.get(routeId); |
| if (route == null) return; |
| |
| if (!hasSession()) { |
| removeRoute(routeId, /* error= */ null); |
| return; |
| } |
| |
| // Don't remove the route while the session is still active. All the routes will be removed |
| // upon session end. |
| sessionController().endSession(); |
| } |
| |
| @Override |
| public void detachRoute(String routeId) { |
| removeRoute(routeId, /* error= */ null); |
| } |
| |
| /////////////////////////////////////////////////////// |
| // SessionManagerListener implementation begin |
| /////////////////////////////////////////////////////// |
| |
| @Override |
| public final void onSessionStarting(CastSession session) { |
| // The session is not connected yet at this point so this is no-op. |
| } |
| |
| @Override |
| public void onSessionStartFailed(CastSession session, int error) { |
| removeAllRoutes("Launch error"); |
| cancelPendingRequest("Launch error"); |
| } |
| |
| @Override |
| public void onSessionStarted(CastSession session, String sessionId) { |
| Log.d(TAG, "onSessionStarted"); |
| sessionController().attachToCastSession(session); |
| sessionController().onSessionStarted(); |
| |
| MediaSink sink = mPendingCreateRouteRequestInfo.sink; |
| MediaSource source = mPendingCreateRouteRequestInfo.source; |
| MediaRoute route = new MediaRoute( |
| sink.getId(), source.getSourceId(), mPendingCreateRouteRequestInfo.presentationId); |
| addRoute(route, mPendingCreateRouteRequestInfo.origin, mPendingCreateRouteRequestInfo.tabId, |
| mPendingCreateRouteRequestInfo.nativeRequestId, /* wasLaunched= */ true); |
| |
| mPendingCreateRouteRequestInfo = null; |
| } |
| |
| @Override |
| public final void onSessionResumed(CastSession session, boolean wasSuspended) { |
| sessionController().attachToCastSession(session); |
| } |
| |
| @Override |
| public final void onSessionResuming(CastSession session, String sessionId) {} |
| |
| @Override |
| public final void onSessionResumeFailed(CastSession session, int error) {} |
| |
| @Override |
| public final void onSessionEnding(CastSession session) { |
| handleSessionEnd(); |
| } |
| |
| @Override |
| public final void onSessionEnded(CastSession session, int error) { |
| Log.d(TAG, "Session ended with error code " + error); |
| handleSessionEnd(); |
| } |
| |
| @Override |
| public final void onSessionSuspended(CastSession session, int reason) { |
| sessionController().detachFromCastSession(); |
| } |
| |
| /////////////////////////////////////////////////////// |
| // SessionManagerListener implementation end |
| /////////////////////////////////////////////////////// |
| |
| private void handleSessionEnd() { |
| if (mPendingCreateRouteRequestInfo != null) { |
| // The Cast SDK notifies about session ending when a route is unselected, even when |
| // there's no current session. Because CastSessionController unselects the route to set |
| // the receiver app ID, this needs to be guarded by a pending request null check to make |
| // sure the listener is not unregistered during a session relaunch. |
| return; |
| } |
| sessionController().onSessionEnded(); |
| sessionController().detachFromCastSession(); |
| getAndroidMediaRouter().selectRoute(getAndroidMediaRouter().getDefaultRoute()); |
| terminateAllRoutes(); |
| CastUtils.getCastContext().getSessionManager().removeSessionManagerListener( |
| this, CastSession.class); |
| } |
| |
| private void cancelPendingRequest(String error) { |
| if (mPendingCreateRouteRequestInfo == null) return; |
| |
| mManager.onRouteRequestError(error, mPendingCreateRouteRequestInfo.nativeRequestId); |
| mPendingCreateRouteRequestInfo = null; |
| } |
| |
| public @NonNull MediaRouter getAndroidMediaRouter() { |
| return mAndroidMediaRouter; |
| } |
| |
| protected boolean hasSession() { |
| return sessionController().isConnected(); |
| } |
| |
| abstract public BaseSessionController sessionController(); |
| |
| /** Adds a route for bookkeeping. */ |
| protected void addRoute( |
| MediaRoute route, String origin, int tabId, int nativeRequestId, boolean wasLaunched) { |
| mRoutes.put(route.id, route); |
| mManager.onRouteCreated(route.id, route.sinkId, nativeRequestId, this, wasLaunched); |
| } |
| |
| /** |
| * Removes a route for bookkeeping and notify the reason. This should be called whenever a route |
| * is closed. |
| * |
| * @param error the reason for the route close, {@code null} indicates no error. |
| */ |
| protected void removeRoute(String routeId, @Nullable String error) { |
| removeRouteFromRecord(routeId); |
| mManager.onRouteClosed(routeId, error); |
| } |
| |
| /** |
| * Removes all routes for bookkeeping and notify the reason. This should be called whenever all |
| * routes should be closed. |
| * |
| * @param error the reason for the route close, {@code null} indicates no error. |
| */ |
| protected void removeAllRoutes(@Nullable String error) { |
| Set<String> routeIds = new HashSet<>(mRoutes.keySet()); |
| for (String routeId : routeIds) { |
| removeRoute(routeId, error); |
| } |
| } |
| |
| /** |
| * Removes all routes for bookkeeping. This should be called whenever the receiver app is |
| * terminated. |
| */ |
| protected void terminateAllRoutes() { |
| Set<String> routeIds = new HashSet<>(mRoutes.keySet()); |
| for (String routeId : routeIds) { |
| removeRouteFromRecord(routeId); |
| mManager.onRouteTerminated(routeId); |
| } |
| } |
| |
| /** |
| * Removes a route for bookkeeping. This method can only be called from {@link #removeRoute()}, |
| * {@link #removeAllRoutes()} and {@link #terminateAllRoutes()}. |
| * |
| * Sub-classes can extend this method for additional cleanup. |
| */ |
| protected void removeRouteFromRecord(String routeId) { |
| mRoutes.remove(routeId); |
| } |
| |
| @Override |
| @Nullable |
| public FlingingController getFlingingController(String routeId) { |
| return null; |
| } |
| |
| public CreateRouteRequestInfo getPendingCreateRouteRequestInfo() { |
| return mPendingCreateRouteRequestInfo; |
| } |
| } |