// Copyright 2015 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.cast; import android.content.Context; import android.os.Handler; import android.support.v7.media.MediaRouteSelector; import android.support.v7.media.MediaRouter; import android.support.v7.media.MediaRouter.RouteInfo; import org.chromium.base.Log; import org.chromium.base.VisibleForTesting; import org.chromium.chrome.browser.media.router.ChromeMediaRouter; import org.chromium.chrome.browser.media.router.DiscoveryDelegate; 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.json.JSONException; import org.json.JSONObject; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import javax.annotation.Nullable; /** * A {@link MediaRouteProvider} implementation for Cast devices and applications. */ public class CastMediaRouteProvider implements MediaRouteProvider, DiscoveryDelegate { private static final String TAG = "MediaRouter"; private static final String AUTO_JOIN_PRESENTATION_ID = "auto-join"; private static final String PRESENTATION_ID_SESSION_ID_PREFIX = "cast-session_"; private final Context mApplicationContext; private final MediaRouter mAndroidMediaRouter; private final MediaRouteManager mManager; private final CastMessageHandler mMessageHandler; private final Map<String, DiscoveryCallback> mDiscoveryCallbacks = new HashMap<String, DiscoveryCallback>(); private final Map<String, MediaRoute> mRoutes = new HashMap<String, MediaRoute>(); private ClientRecord mLastRemovedRouteRecord; private final Map<String, ClientRecord> mClientRecords = new HashMap<String, ClientRecord>(); // There can be only one Cast session at the same time on Android. private CastSession mSession; private CreateRouteRequest mPendingCreateRouteRequest; private Handler mHandler = new Handler(); /** * Builder class for {@link CastMediaRouteProvider}. */ public static class Builder implements MediaRouteProvider.Builder { @Override public MediaRouteProvider create(Context applicationContext, MediaRouteManager manager) { return CastMediaRouteProvider.create(applicationContext, manager); } } private static class OnSinksReceivedRunnable implements Runnable { private final WeakReference<MediaRouteManager> mRouteManager; private final MediaRouteProvider mRouteProvider; private final String mSourceId; private final List<MediaSink> mSinks; OnSinksReceivedRunnable(MediaRouteManager manager, MediaRouteProvider routeProvider, String sourceId, List<MediaSink> sinks) { mRouteManager = new WeakReference<MediaRouteManager>(manager); mRouteProvider = routeProvider; mSourceId = sourceId; mSinks = sinks; } @Override public void run() { MediaRouteManager manager = mRouteManager.get(); if (manager != null) manager.onSinksReceived(mSourceId, mRouteProvider, mSinks); } } /** * @param applicationContext The application context to use for this route provider. * @return Initialized {@link CastMediaRouteProvider} object or null if it's not supported. */ @Nullable public static CastMediaRouteProvider create( Context applicationContext, MediaRouteManager manager) { assert applicationContext != null; MediaRouter androidMediaRouter = ChromeMediaRouter.getAndroidMediaRouter(applicationContext); if (androidMediaRouter == null) return null; return new CastMediaRouteProvider(applicationContext, androidMediaRouter, manager); } public void onLaunchError() { for (String routeId : mRoutes.keySet()) { mManager.onRouteClosedWithError(routeId, "Launch error"); } mRoutes.clear(); mClientRecords.clear(); } public void onSessionStopAction() { if (mSession == null) return; for (String routeId : mRoutes.keySet()) closeRoute(routeId); } public void onSessionCreated(CastSession session) { mSession = session; mMessageHandler.onSessionCreated(mSession); } public void onSessionClosed() { if (mSession == null) return; if (mClientRecords.isEmpty()) { mRoutes.clear(); } else { mLastRemovedRouteRecord = mClientRecords.values().iterator().next(); for (ClientRecord client : mClientRecords.values()) { mManager.onRouteClosed(client.routeId); mRoutes.remove(client.routeId); } mClientRecords.clear(); } mSession = null; if (mPendingCreateRouteRequest != null) { launchSession(mPendingCreateRouteRequest); mPendingCreateRouteRequest = null; } else if (mAndroidMediaRouter != null) { mAndroidMediaRouter.selectRoute(mAndroidMediaRouter.getDefaultRoute()); } } public void onMessageSentResult(boolean success, int callbackId) { mManager.onMessageSentResult(success, callbackId); } public void onMessage(String clientId, String message) { ClientRecord clientRecord = mClientRecords.get(clientId); if (clientRecord == null) return; if (!clientRecord.isConnected) { Log.d(TAG, "Queueing message to client %s: %s", clientId, message); clientRecord.pendingMessages.add(message); return; } Log.d(TAG, "Sending message to client %s: %s", clientId, message); mManager.onMessage(clientRecord.routeId, message); } public CastMessageHandler getMessageHandler() { return mMessageHandler; } public Set<String> getClients() { return mClientRecords.keySet(); } public Map<String, ClientRecord> getClientRecords() { return mClientRecords; } @Override public void onSinksReceived(String sourceId, List<MediaSink> sinks) { mHandler.post(new OnSinksReceivedRunnable(mManager, this, sourceId, sinks)); } @Override public boolean supportsSource(String sourceId) { return MediaSource.from(sourceId) != null; } @Override public void startObservingMediaSinks(String sourceId) { if (mAndroidMediaRouter == null) return; MediaSource source = MediaSource.from(sourceId); if (source == null) return; MediaRouteSelector routeSelector = source.buildRouteSelector(); if (routeSelector == null) { // If the application invalid, report no devices available. onSinksReceived(sourceId, new ArrayList<MediaSink>()); return; } String applicationId = source.getApplicationId(); DiscoveryCallback callback = mDiscoveryCallbacks.get(applicationId); if (callback != null) { callback.addSourceUrn(sourceId); 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 void stopObservingMediaSinks(String sourceId) { if (mAndroidMediaRouter == null) return; MediaSource source = MediaSource.from(sourceId); if (source == null) return; 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 void createRoute(String sourceId, String sinkId, String presentationId, String origin, int tabId, boolean isIncognito, int nativeRequestId) { if (mAndroidMediaRouter == null) { mManager.onRouteRequestError("Not supported", nativeRequestId); return; } MediaSink sink = MediaSink.fromSinkId(sinkId, mAndroidMediaRouter); if (sink == null) { mManager.onRouteRequestError("No sink", nativeRequestId); return; } MediaSource source = MediaSource.from(sourceId); if (source == null) { mManager.onRouteRequestError("Unsupported presentation URL", nativeRequestId); return; } CreateRouteRequest createRouteRequest = new CreateRouteRequest( source, sink, presentationId, origin, tabId, isIncognito, nativeRequestId, this); // Since we only have one session, close it before starting a new one. if (mSession != null) { mPendingCreateRouteRequest = createRouteRequest; mSession.stopApplication(); return; } launchSession(createRouteRequest); } private void launchSession(CreateRouteRequest request) { MediaSink sink = request.getSink(); MediaSource source = request.getSource(); MediaRoute route = new MediaRoute( sink.getId(), source.getUrn(), request.getPresentationId()); addRoute(route, request.getOrigin(), request.getTabId()); mManager.onRouteCreated(route.id, route.sinkId, request.getNativeRequestId(), this, true); if (source.getClientId() != null) { ClientRecord clientRecord = mClientRecords.get(source.getClientId()); if (clientRecord != null) { sendReceiverAction(clientRecord.routeId, sink, source.getClientId(), "cast"); } } request.start(mApplicationContext); } @Override public void joinRoute(String sourceId, String presentationId, String origin, int tabId, int nativeRequestId) { MediaSource source = MediaSource.from(sourceId); if (source == null || source.getClientId() == null) { mManager.onRouteRequestError("Unsupported presentation URL", nativeRequestId); return; } if (mSession == null) { mManager.onRouteRequestError("No presentation", nativeRequestId); return; } if (!canJoinExistingSession(presentationId, origin, tabId, source)) { mManager.onRouteRequestError("No matching route", nativeRequestId); return; } MediaRoute route = new MediaRoute(mSession.getSinkId(), sourceId, presentationId); addRoute(route, origin, tabId); mManager.onRouteCreated(route.id, route.sinkId, nativeRequestId, this, false); } @Override public void closeRoute(String routeId) { MediaRoute route = mRoutes.get(routeId); if (route == null) return; if (mSession == null) { mRoutes.remove(routeId); return; } ClientRecord client = getClientRecordByRouteId(routeId); if (client != null) { MediaSink sink = MediaSink.fromSinkId(mSession.getSinkId(), mAndroidMediaRouter); if (sink != null) sendReceiverAction(routeId, sink, client.clientId, "stop"); } mSession.stopApplication(); } @Override public void detachRoute(String routeId) { mRoutes.remove(routeId); removeClient(getClientRecordByRouteId(routeId)); } @Override public void sendStringMessage(String routeId, String message, int nativeCallbackId) { Log.d(TAG, "Received message from client: %s", message); if (!mRoutes.containsKey(routeId)) { mManager.onMessageSentResult(false, nativeCallbackId); return; } boolean success = false; try { JSONObject jsonMessage = new JSONObject(message); String messageType = jsonMessage.getString("type"); // TODO(zqzhang): Move the handling of "client_connect", "client_disconnect" and // "leave_session" from CastMRP to CastMessageHandler. Also, need to have a // ClientManager for client managing. if ("client_connect".equals(messageType)) { success = handleClientConnectMessage(jsonMessage); } else if ("client_disconnect".equals(messageType)) { success = handleClientDisconnectMessage(jsonMessage); } else if ("leave_session".equals(messageType)) { success = handleLeaveSessionMessage(jsonMessage); } else if (mSession != null) { success = mMessageHandler.handleSessionMessage(jsonMessage); } } catch (JSONException e) { Log.e(TAG, "JSONException while handling internal message: " + e); success = false; } mManager.onMessageSentResult(success, nativeCallbackId); } @Override public void sendBinaryMessage(String routeId, byte[] data, int nativeCallbackId) { // TODO(crbug.com/524128): Cast API does not support sending binary message // to receiver application. Binary data may be converted to String and send as // an app_message within it's own message namespace, using the string version. // Sending failure in the result callback for now. mManager.onMessageSentResult(false, nativeCallbackId); } private boolean handleClientConnectMessage(JSONObject jsonMessage) throws JSONException { String clientId = jsonMessage.getString("clientId"); if (clientId == null) return false; ClientRecord clientRecord = mClientRecords.get(clientId); if (clientRecord == null) return false; clientRecord.isConnected = true; if (mSession != null) mSession.onClientConnected(clientId); if (clientRecord.pendingMessages.size() == 0) return true; for (String message : clientRecord.pendingMessages) { Log.d(TAG, "Deqeueing message for client %s: %s", clientId, message); mManager.onMessage(clientRecord.routeId, message); } clientRecord.pendingMessages.clear(); return true; } private boolean handleClientDisconnectMessage(JSONObject jsonMessage) throws JSONException { String clientId = jsonMessage.getString("clientId"); if (clientId == null) return false; ClientRecord client = mClientRecords.get(clientId); if (client == null) return false; mRoutes.remove(client.routeId); removeClient(client); mManager.onRouteClosed(client.routeId); return true; } private boolean handleLeaveSessionMessage(JSONObject jsonMessage) throws JSONException { String clientId = jsonMessage.getString("clientId"); if (clientId == null || mSession == null) return false; String sessionId = jsonMessage.getString("message"); if (!mSession.getSessionId().equals(sessionId)) return false; ClientRecord leavingClient = mClientRecords.get(clientId); if (leavingClient == null) return false; int sequenceNumber = jsonMessage.optInt("sequenceNumber", -1); onMessage(clientId, buildInternalMessage("leave_session", sequenceNumber, clientId, null)); // Send a "disconnect_session" message to all the clients that match with the leaving // client's auto join policy. for (ClientRecord client : mClientRecords.values()) { if ((MediaSource.AUTOJOIN_TAB_AND_ORIGIN_SCOPED.equals(leavingClient.autoJoinPolicy) && client.origin.equals(leavingClient.origin) && client.tabId == leavingClient.tabId) || (MediaSource.AUTOJOIN_ORIGIN_SCOPED.equals(leavingClient.autoJoinPolicy) && client.origin.equals(leavingClient.origin))) { onMessage(client.clientId, buildInternalMessage("disconnect_session", -1, client.clientId, sessionId)); } } return true; } private String buildInternalMessage( String type, int sequenceNumber, String clientId, String message) throws JSONException { JSONObject jsonMessage = new JSONObject(); jsonMessage.put("type", type); jsonMessage.put("sequenceNumber", sequenceNumber); jsonMessage.put("timeoutMillis", 0); jsonMessage.put("clientId", clientId); jsonMessage.put("message", message); return jsonMessage.toString(); } @VisibleForTesting static CastMediaRouteProvider createCastMediaRouteProviderForTest( Context applicationContext, MediaRouter androidMediaRouter, MediaRouteManager manager) { return new CastMediaRouteProvider(applicationContext, androidMediaRouter, manager); } @VisibleForTesting CastMediaRouteProvider( Context applicationContext, MediaRouter androidMediaRouter, MediaRouteManager manager) { mApplicationContext = applicationContext; mAndroidMediaRouter = androidMediaRouter; mManager = manager; mMessageHandler = new CastMessageHandler(this); } @Nullable private boolean canAutoJoin(MediaSource source, String origin, int tabId) { if (source.getAutoJoinPolicy().equals(MediaSource.AUTOJOIN_PAGE_SCOPED)) return false; MediaSource currentSource = MediaSource.from(mSession.getSourceId()); if (!currentSource.getApplicationId().equals(source.getApplicationId())) return false; ClientRecord client = null; if (!mClientRecords.isEmpty()) { client = mClientRecords.values().iterator().next(); } else if (mLastRemovedRouteRecord != null) { client = mLastRemovedRouteRecord; return origin.equals(client.origin) && tabId == client.tabId; } if (client == null) return false; if (source.getAutoJoinPolicy().equals(MediaSource.AUTOJOIN_ORIGIN_SCOPED)) { return origin.equals(client.origin); } else if (source.getAutoJoinPolicy().equals(MediaSource.AUTOJOIN_TAB_AND_ORIGIN_SCOPED)) { return origin.equals(client.origin) && tabId == client.tabId; } return false; } private boolean canJoinExistingSession(String presentationId, String origin, int tabId, MediaSource source) { if (AUTO_JOIN_PRESENTATION_ID.equals(presentationId)) { return canAutoJoin(source, origin, tabId); } else if (presentationId.startsWith(PRESENTATION_ID_SESSION_ID_PREFIX)) { String sessionId = presentationId.substring(PRESENTATION_ID_SESSION_ID_PREFIX.length()); if (mSession.getSessionId().equals(sessionId)) return true; } else { for (MediaRoute route : mRoutes.values()) { if (route.presentationId.equals(presentationId)) return true; } } return false; } @Nullable private ClientRecord getClientRecordByRouteId(String routeId) { for (ClientRecord record : mClientRecords.values()) { if (record.routeId.equals(routeId)) return record; } return null; } private void addRoute(MediaRoute route, String origin, int tabId) { mRoutes.put(route.id, route); MediaSource source = MediaSource.from(route.sourceId); final String clientId = source.getClientId(); if (clientId == null || mClientRecords.get(clientId) != null) return; mClientRecords.put(clientId, new ClientRecord( route.id, clientId, source.getApplicationId(), source.getAutoJoinPolicy(), origin, tabId)); } // TODO(zqzhang): Move this method to CastMessageHandler. private void sendReceiverAction( String routeId, MediaSink sink, String clientId, String action) { try { JSONObject jsonReceiver = new JSONObject(); jsonReceiver.put("label", sink.getId()); jsonReceiver.put("friendlyName", sink.getName()); jsonReceiver.put("capabilities", CastSessionImpl.getCapabilities(sink.getDevice())); jsonReceiver.put("volume", null); jsonReceiver.put("isActiveInput", null); jsonReceiver.put("displayStatus", null); jsonReceiver.put("receiverType", "cast"); JSONObject jsonReceiverAction = new JSONObject(); jsonReceiverAction.put("receiver", jsonReceiver); jsonReceiverAction.put("action", action); JSONObject json = new JSONObject(); json.put("type", "receiver_action"); json.put("sequenceNumber", -1); json.put("timeoutMillis", 0); json.put("clientId", clientId); json.put("message", jsonReceiverAction); onMessage(clientId, json.toString()); } catch (JSONException e) { Log.e(TAG, "Failed to send receiver action message", e); } } private void removeClient(@Nullable ClientRecord client) { if (client == null) return; mLastRemovedRouteRecord = client; mClientRecords.remove(client.clientId); } }