| // Copyright 2016 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.os.Handler; |
| import android.support.v4.util.ArrayMap; |
| import android.util.SparseArray; |
| |
| import org.json.JSONArray; |
| import org.json.JSONException; |
| import org.json.JSONObject; |
| |
| import org.chromium.base.Log; |
| import org.chromium.base.VisibleForTesting; |
| import org.chromium.chrome.browser.media.router.CastRequestIdGenerator; |
| import org.chromium.chrome.browser.media.router.CastSessionUtil; |
| import org.chromium.chrome.browser.media.router.ClientRecord; |
| |
| import java.util.ArrayDeque; |
| import java.util.Arrays; |
| import java.util.HashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Queue; |
| |
| /** |
| * The handler for cast messages. It receives events between the Cast SDK and the page, process and |
| * dispatch the messages accordingly. The handler talks to the Cast SDK via CastSession, and |
| * talks to the pages via the media router. |
| */ |
| // Migrated to CafMessageHandler. See https://crbug.com/711860. |
| public class CastMessageHandler { |
| private static final String TAG = "MediaRouter"; |
| |
| // Sequence number used when no sequence number is required or was initially passed. |
| static final int INVALID_SEQUENCE_NUMBER = -1; |
| |
| private static final String MEDIA_MESSAGE_TYPES[] = { |
| "PLAY", |
| "LOAD", |
| "PAUSE", |
| "SEEK", |
| "STOP_MEDIA", |
| "MEDIA_SET_VOLUME", |
| "MEDIA_GET_STATUS", |
| "EDIT_TRACKS_INFO", |
| "QUEUE_LOAD", |
| "QUEUE_INSERT", |
| "QUEUE_UPDATE", |
| "QUEUE_REMOVE", |
| "QUEUE_REORDER", |
| }; |
| |
| private static final String MEDIA_SUPPORTED_COMMANDS[] = { |
| "pause", |
| "seek", |
| "stream_volume", |
| "stream_mute", |
| }; |
| |
| // Lock used to lazy initialize sMediaOverloadedMessageTypes. |
| private static final Object INIT_LOCK = new Object(); |
| |
| // Map associating types that have a different names outside of the media namespace and inside. |
| // In other words, some types are sent as MEDIA_FOO or FOO_MEDIA by the client by the Cast |
| // expect them to be named FOO. The reason being that FOO might exist in multiple namespaces |
| // but the client isn't aware of namespacing. |
| private static Map<String, String> sMediaOverloadedMessageTypes; |
| |
| private SparseArray<RequestRecord> mRequests; |
| private ArrayMap<String, Queue<Integer>> mStopRequests; |
| private Queue<RequestRecord> mVolumeRequests; |
| |
| // The reference to CastSession, only valid after calling {@link onSessionCreated}, and will be |
| // reset to null when calling {@link onApplicationStopped}. |
| private CastSession mSession; |
| private final CastMediaRouteProvider mRouteProvider; |
| private Handler mHandler; |
| |
| /** |
| * The record for client requests. {@link CastMessageHandler} uses this class to manage the |
| * client requests and match responses to the requests. |
| */ |
| static class RequestRecord { |
| public final String clientId; |
| public final int sequenceNumber; |
| |
| public RequestRecord(String clientId, int sequenceNumber) { |
| this.clientId = clientId; |
| this.sequenceNumber = sequenceNumber; |
| } |
| } |
| |
| /** |
| * Initializes a new {@link CastMessageHandler} instance. |
| * @param session The {@link CastSession} for communicating with the Cast SDK. |
| * @param provider The {@link CastMediaRouteProvider} for communicating with the page. |
| */ |
| public CastMessageHandler(CastMediaRouteProvider provider) { |
| mRouteProvider = provider; |
| mRequests = new SparseArray<RequestRecord>(); |
| mStopRequests = new ArrayMap<String, Queue<Integer>>(); |
| mVolumeRequests = new ArrayDeque<RequestRecord>(); |
| mHandler = new Handler(); |
| |
| synchronized (INIT_LOCK) { |
| if (sMediaOverloadedMessageTypes == null) { |
| sMediaOverloadedMessageTypes = new HashMap<String, String>(); |
| sMediaOverloadedMessageTypes.put("STOP_MEDIA", "STOP"); |
| sMediaOverloadedMessageTypes.put("MEDIA_SET_VOLUME", "SET_VOLUME"); |
| sMediaOverloadedMessageTypes.put("MEDIA_GET_STATUS", "GET_STATUS"); |
| } |
| } |
| } |
| |
| @VisibleForTesting |
| static String[] getMediaMessageTypesForTest() { |
| return MEDIA_MESSAGE_TYPES; |
| } |
| |
| @VisibleForTesting |
| static Map<String, String> getMediaOverloadedMessageTypesForTest() { |
| return sMediaOverloadedMessageTypes; |
| } |
| |
| @VisibleForTesting |
| SparseArray<RequestRecord> getRequestsForTest() { |
| return mRequests; |
| } |
| |
| @VisibleForTesting |
| Queue<RequestRecord> getVolumeRequestsForTest() { |
| return mVolumeRequests; |
| } |
| |
| @VisibleForTesting |
| Map<String, Queue<Integer>> getStopRequestsForTest() { |
| return mStopRequests; |
| } |
| |
| /** |
| * Set the session when a session is created, and notify all clients that are not connected. |
| * @param session The newly created session. |
| */ |
| public void onSessionCreated(CastSession session) { |
| mSession = session; |
| for (ClientRecord client : mRouteProvider.getClientRecords().values()) { |
| if (!client.isConnected) continue; |
| |
| mSession.onClientConnected(client.clientId); |
| } |
| } |
| |
| ///////////////////////////////////////////////////////////////////////////////////////////// |
| // Functions for handling messages from the page to the Cast device. |
| |
| /** |
| * Handles messages related to the cast session, i.e. messages happening on a established |
| * connection. All these messages are sent from the page to the Cast SDK. |
| * @param message The JSONObject message to be handled. |
| */ |
| public boolean handleSessionMessage(JSONObject message) throws JSONException { |
| String messageType = message.getString("type"); |
| if ("v2_message".equals(messageType)) { |
| return handleCastV2Message(message); |
| } else if ("app_message".equals(messageType)) { |
| return handleAppMessage(message); |
| } else { |
| Log.e(TAG, "Unsupported message: %s", message); |
| return false; |
| } |
| } |
| |
| // An example of the Cast V2 message: |
| // { |
| // "type": "v2_message", |
| // "message": { |
| // "type": "...", |
| // ... |
| // }, |
| // "sequenceNumber": 0, |
| // "timeoutMillis": 0, |
| // "clientId": "144042901280235697" |
| // } |
| @VisibleForTesting |
| boolean handleCastV2Message(JSONObject jsonMessage) |
| throws JSONException { |
| assert "v2_message".equals(jsonMessage.getString("type")); |
| |
| final String clientId = jsonMessage.getString("clientId"); |
| if (clientId == null || !mRouteProvider.getClients().contains(clientId)) return false; |
| |
| JSONObject jsonCastMessage = jsonMessage.getJSONObject("message"); |
| String messageType = jsonCastMessage.getString("type"); |
| final int sequenceNumber = jsonMessage.optInt("sequenceNumber", INVALID_SEQUENCE_NUMBER); |
| |
| if ("STOP".equals(messageType)) { |
| handleStopMessage(clientId, sequenceNumber); |
| return true; |
| } |
| |
| if ("SET_VOLUME".equals(messageType)) { |
| CastSession.HandleVolumeMessageResult result = mSession.handleVolumeMessage( |
| jsonCastMessage.getJSONObject("volume"), clientId, sequenceNumber); |
| if (!result.mSucceeded) return false; |
| |
| // For each successful volume message we need to respond with an empty "v2_message" so |
| // the Cast Web SDK can call the success callback of the page. If we expect the volume |
| // to change as the result of the command, we're relying on {@link |
| // Cast.CastListener#onVolumeChanged} to get called by the Android Cast SDK when the |
| // receiver status is updated. We keep the sequence number until then. If the volume |
| // doesn't change as the result of the command, we won't get notified by the Android SDK |
| // when the status update is received so we respond to the volume message immediately. |
| if (result.mShouldWaitForVolumeChange) { |
| mVolumeRequests.add(new RequestRecord(clientId, sequenceNumber)); |
| } else { |
| // It's usually bad to have request and response on the same call stack so post the |
| // response to the Android message loop. |
| mHandler.post(new Runnable() { |
| @Override |
| public void run() { |
| onVolumeChanged(clientId, sequenceNumber); |
| } |
| }); |
| } |
| return true; |
| } |
| |
| if (Arrays.asList(MEDIA_MESSAGE_TYPES).contains(messageType)) { |
| if (sMediaOverloadedMessageTypes.containsKey(messageType)) { |
| messageType = sMediaOverloadedMessageTypes.get(messageType); |
| jsonCastMessage.put("type", messageType); |
| } |
| return sendJsonCastMessage( |
| jsonCastMessage, CastSessionUtil.MEDIA_NAMESPACE, clientId, sequenceNumber); |
| } |
| |
| return true; |
| } |
| |
| @VisibleForTesting |
| void handleStopMessage(String clientId, int sequenceNumber) { |
| Queue<Integer> sequenceNumbersForClient = mStopRequests.get(clientId); |
| if (sequenceNumbersForClient == null) { |
| sequenceNumbersForClient = new ArrayDeque<Integer>(); |
| mStopRequests.put(clientId, sequenceNumbersForClient); |
| } |
| sequenceNumbersForClient.add(sequenceNumber); |
| |
| mSession.stopApplication(); |
| } |
| |
| // An example of the Cast application message: |
| // { |
| // "type":"app_message", |
| // "message": { |
| // "sessionId":"...", |
| // "namespaceName":"...", |
| // "message": ... |
| // }, |
| // "sequenceNumber":0, |
| // "timeoutMillis":3000, |
| // "clientId":"14417311915272175" |
| // } |
| @VisibleForTesting |
| boolean handleAppMessage(JSONObject jsonMessage) throws JSONException { |
| assert "app_message".equals(jsonMessage.getString("type")); |
| |
| String clientId = jsonMessage.getString("clientId"); |
| if (clientId == null || !mRouteProvider.getClients().contains(clientId)) return false; |
| |
| JSONObject jsonAppMessageWrapper = jsonMessage.getJSONObject("message"); |
| |
| if (!mSession.getSessionId().equals(jsonAppMessageWrapper.getString("sessionId"))) { |
| return false; |
| } |
| |
| String namespaceName = jsonAppMessageWrapper.getString("namespaceName"); |
| if (namespaceName == null || namespaceName.isEmpty()) return false; |
| |
| if (!mSession.getNamespaces().contains(namespaceName)) return false; |
| |
| int sequenceNumber = jsonMessage.optInt("sequenceNumber", INVALID_SEQUENCE_NUMBER); |
| |
| Object actualMessageObject = jsonAppMessageWrapper.get("message"); |
| if (actualMessageObject == null) return false; |
| |
| if (actualMessageObject instanceof String) { |
| String actualMessage = jsonAppMessageWrapper.getString("message"); |
| return mSession.sendStringCastMessage( |
| actualMessage, namespaceName, clientId, sequenceNumber); |
| } |
| |
| JSONObject actualMessage = jsonAppMessageWrapper.getJSONObject("message"); |
| return sendJsonCastMessage(actualMessage, namespaceName, clientId, sequenceNumber); |
| } |
| |
| @VisibleForTesting |
| boolean sendJsonCastMessage( |
| JSONObject message, |
| final String namespace, |
| final String clientId, |
| final int sequenceNumber) throws JSONException { |
| if (mSession.isApiClientInvalid()) return false; |
| |
| removeNullFields(message); |
| |
| // Map the request id to a valid sequence number only. |
| if (sequenceNumber != INVALID_SEQUENCE_NUMBER) { |
| // If for some reason, there is already a requestId other than 0, it |
| // is kept. Otherwise, one is generated. In all cases it's associated with the |
| // sequenceNumber passed by the client. |
| int requestId = message.optInt("requestId", 0); |
| if (requestId == 0) { |
| requestId = CastRequestIdGenerator.getNextRequestId(); |
| message.put("requestId", requestId); |
| } |
| mRequests.append(requestId, new RequestRecord(clientId, sequenceNumber)); |
| } |
| |
| return mSession.sendStringCastMessage( |
| message.toString(), namespace, clientId, sequenceNumber); |
| } |
| |
| ///////////////////////////////////////////////////////////////////////////////////////////// |
| // Functions for handling messages from the Cast device to the pages. |
| |
| /** |
| * Forwards the messages from the Cast device to the clients, and perform proper actions if it |
| * is media message. |
| * @param namespace The application specific namespace this message belongs to. |
| * @param message The message within the namespace that's being sent by the receiver |
| */ |
| public void onMessageReceived(String namespace, String message) { |
| RequestRecord request = null; |
| try { |
| JSONObject jsonMessage = new JSONObject(message); |
| int requestId = jsonMessage.getInt("requestId"); |
| if (mRequests.indexOfKey(requestId) >= 0) { |
| request = mRequests.get(requestId); |
| mRequests.delete(requestId); |
| } |
| } catch (JSONException e) { |
| } |
| |
| if (CastSessionUtil.MEDIA_NAMESPACE.equals(namespace)) { |
| onMediaMessage(message, request); |
| return; |
| } |
| |
| onAppMessage(message, namespace, request); |
| } |
| |
| /** |
| * Forwards the media message to the page via the media router. |
| * The MEDIA_STATUS message needs to be sent to all the clients. |
| * @param message The media that's being send by the receiver. |
| * @param request The information about the client and the sequence number to respond with. |
| */ |
| @VisibleForTesting |
| void onMediaMessage(String message, RequestRecord request) { |
| mSession.onMediaMessage(message); |
| |
| if (isMediaStatusMessage(message)) { |
| // MEDIA_STATUS needs to be sent to all the clients. |
| for (String clientId : mRouteProvider.getClients()) { |
| if (request != null && clientId.equals(request.clientId)) continue; |
| |
| sendClientMessageTo( |
| clientId, "v2_message", message, INVALID_SEQUENCE_NUMBER); |
| } |
| } |
| if (request != null) { |
| sendClientMessageTo( |
| request.clientId, "v2_message", message, request.sequenceNumber); |
| } |
| } |
| |
| /** |
| * Forwards the application specific message to the page via the media router. |
| * @param message The message within the namespace that's being sent by the receiver. |
| * @param namespace The application specific namespace this message belongs to. |
| * @param request The information about the client and the sequence number to respond with. |
| */ |
| @VisibleForTesting |
| void onAppMessage(String message, String namespace, RequestRecord request) { |
| try { |
| JSONObject jsonMessage = new JSONObject(); |
| jsonMessage.put("sessionId", mSession.getSessionId()); |
| jsonMessage.put("namespaceName", namespace); |
| jsonMessage.put("message", message); |
| if (request != null) { |
| sendClientMessageTo(request.clientId, "app_message", |
| jsonMessage.toString(), request.sequenceNumber); |
| } else { |
| broadcastClientMessage("app_message", jsonMessage.toString()); |
| } |
| } catch (JSONException e) { |
| Log.e(TAG, "Failed to create the message wrapper", e); |
| } |
| } |
| |
| /** |
| * Notifies the application has stopped to all requesting clients. |
| */ |
| public void onApplicationStopped() { |
| for (String clientId : mRouteProvider.getClients()) { |
| Queue<Integer> sequenceNumbersForClient = mStopRequests.get(clientId); |
| if (sequenceNumbersForClient == null) { |
| sendClientMessageTo( |
| clientId, "remove_session", mSession.getSessionId(), |
| INVALID_SEQUENCE_NUMBER); |
| continue; |
| } |
| |
| for (int sequenceNumber : sequenceNumbersForClient) { |
| sendClientMessageTo( |
| clientId, "remove_session", mSession.getSessionId(), sequenceNumber); |
| } |
| mStopRequests.remove(clientId); |
| } |
| mSession = null; |
| } |
| |
| /** |
| * When the Cast device volume really changed, updates the session status and notify all |
| * requesting clients. |
| */ |
| public void onVolumeChanged() { |
| mSession.updateSessionStatus(); |
| |
| if (mVolumeRequests.isEmpty()) return; |
| |
| for (RequestRecord r : mVolumeRequests) onVolumeChanged(r.clientId, r.sequenceNumber); |
| mVolumeRequests.clear(); |
| } |
| |
| @VisibleForTesting |
| void onVolumeChanged(String clientId, int sequenceNumber) { |
| sendClientMessageTo(clientId, "v2_message", null, sequenceNumber); |
| } |
| |
| /** |
| * Notifies a client that an app message has been sent. |
| * @param clientId The client id the message is sent from. |
| * @param sequenceNumber The sequence number of the message. |
| */ |
| public void onAppMessageSent(String clientId, int sequenceNumber) { |
| sendClientMessageTo(clientId, "app_message", null, sequenceNumber); |
| } |
| |
| /** |
| * Broadcasts the message to all clients. |
| * @param type The type of the message. |
| * @param message The message to broadcast. |
| */ |
| public void broadcastClientMessage(String type, String message) { |
| for (String clientId : mRouteProvider.getClients()) { |
| sendClientMessageTo(clientId, type, message, INVALID_SEQUENCE_NUMBER); |
| } |
| } |
| |
| /** |
| * Sends a message to a specific client. |
| * @param clientId The id of the receiving client. |
| * @param type The type of the message. |
| * @param message The message to be sent. |
| * @param sequenceNumber The sequence number for matching requesting and responding messages. |
| */ |
| public void sendClientMessageTo( |
| String clientId, String type, String message, int sequenceNumber) { |
| mRouteProvider.onMessage(clientId, |
| buildInternalMessage(type, message, clientId, sequenceNumber)); |
| } |
| |
| @VisibleForTesting |
| String buildInternalMessage( |
| String type, String message, String clientId, int sequenceNumber) { |
| JSONObject json = new JSONObject(); |
| try { |
| json.put("type", type); |
| json.put("sequenceNumber", sequenceNumber); |
| json.put("timeoutMillis", 0); |
| json.put("clientId", clientId); |
| |
| // TODO(mlamouri): we should have a more reliable way to handle string, null and Object |
| // messages. |
| if (message == null |
| || "remove_session".equals(type) |
| || "disconnect_session".equals(type)) { |
| json.put("message", message); |
| } else { |
| JSONObject jsonMessage = new JSONObject(message); |
| if ("v2_message".equals(type) |
| && "MEDIA_STATUS".equals(jsonMessage.getString("type"))) { |
| sanitizeMediaStatusMessage(jsonMessage); |
| } |
| json.put("message", jsonMessage); |
| } |
| } catch (JSONException e) { |
| Log.e(TAG, "Failed to build the reply: " + e); |
| } |
| |
| return json.toString(); |
| } |
| |
| /** |
| * @return A message containing the information of the {@link CastSession}. |
| */ |
| public String buildSessionMessage() { |
| if (mSession == null) return "{}"; |
| |
| CastSessionInfo sessionInfo = mSession.getSessionInfo(); |
| if (sessionInfo == null) return "{}"; |
| |
| try { |
| // "volume" is a part of "receiver" initialized below. |
| JSONObject jsonVolume = new JSONObject(); |
| jsonVolume.put("level", sessionInfo.receiver.volume.level); |
| jsonVolume.put("muted", sessionInfo.receiver.volume.muted); |
| |
| // "receiver" is a part of "message" initialized below. |
| JSONObject jsonReceiver = new JSONObject(); |
| jsonReceiver.put("label", sessionInfo.receiver.label); |
| jsonReceiver.put("friendlyName", sessionInfo.receiver.friendlyName); |
| jsonReceiver.put("capabilities", toJSONArray(sessionInfo.receiver.capabilities)); |
| jsonReceiver.put("volume", jsonVolume); |
| jsonReceiver.put("isActiveInput", sessionInfo.receiver.isActiveInput); |
| jsonReceiver.put("displayStatus", sessionInfo.receiver.displayStatus); |
| jsonReceiver.put("receiverType", sessionInfo.receiver.receiverType); |
| |
| JSONArray jsonNamespaces = new JSONArray(); |
| for (String namespace : sessionInfo.namespaces) { |
| JSONObject jsonNamespace = new JSONObject(); |
| jsonNamespace.put("name", namespace); |
| jsonNamespaces.put(jsonNamespace); |
| } |
| |
| JSONObject jsonMessage = new JSONObject(); |
| jsonMessage.put("sessionId", sessionInfo.sessionId); |
| jsonMessage.put("statusText", sessionInfo.statusText); |
| jsonMessage.put("receiver", jsonReceiver); |
| jsonMessage.put("namespaces", jsonNamespaces); |
| jsonMessage.put("media", toJSONArray(sessionInfo.media)); |
| jsonMessage.put("status", sessionInfo.status); |
| jsonMessage.put("transportId", sessionInfo.transportId); |
| jsonMessage.put("appId", sessionInfo.appId); |
| jsonMessage.put("displayName", sessionInfo.displayName); |
| |
| return jsonMessage.toString(); |
| } catch (JSONException e) { |
| Log.w(TAG, "Building session message failed", e); |
| return "{}"; |
| } |
| } |
| |
| ///////////////////////////////////////////////////////////////////////////////////////////// |
| // Utility functions |
| |
| /** |
| * Modifies the received MediaStatus message to match the format expected by the client. |
| */ |
| private void sanitizeMediaStatusMessage(JSONObject object) throws JSONException { |
| object.put("sessionId", mSession.getSessionId()); |
| |
| JSONArray mediaStatus = object.getJSONArray("status"); |
| for (int i = 0; i < mediaStatus.length(); ++i) { |
| JSONObject status = mediaStatus.getJSONObject(i); |
| status.put("sessionId", mSession.getSessionId()); |
| if (!status.has("supportedMediaCommands")) continue; |
| |
| JSONArray commands = new JSONArray(); |
| int bitfieldCommands = status.getInt("supportedMediaCommands"); |
| for (int j = 0; j < 4; ++j) { |
| if ((bitfieldCommands & (1 << j)) != 0) { |
| commands.put(MEDIA_SUPPORTED_COMMANDS[j]); |
| } |
| } |
| |
| status.put("supportedMediaCommands", commands); // Removes current entry. |
| } |
| } |
| |
| /** |
| * Remove 'null' fields from a JSONObject. This method calls itself recursively until all the |
| * fields have been looked at. |
| * TODO(mlamouri): move to some util class? |
| */ |
| private static void removeNullFields(Object object) throws JSONException { |
| if (object instanceof JSONArray) { |
| JSONArray array = (JSONArray) object; |
| for (int i = 0; i < array.length(); ++i) removeNullFields(array.get(i)); |
| } else if (object instanceof JSONObject) { |
| JSONObject json = (JSONObject) object; |
| JSONArray names = json.names(); |
| if (names == null) return; |
| for (int i = 0; i < names.length(); ++i) { |
| String key = names.getString(i); |
| if (json.isNull(key)) { |
| json.remove(key); |
| } else { |
| removeNullFields(json.get(key)); |
| } |
| } |
| } |
| } |
| |
| @VisibleForTesting |
| boolean isMediaStatusMessage(String message) { |
| try { |
| JSONObject jsonMessage = new JSONObject(message); |
| return "MEDIA_STATUS".equals(jsonMessage.getString("type")); |
| } catch (JSONException e) { |
| return false; |
| } |
| } |
| |
| private JSONArray toJSONArray(List<String> from) throws JSONException { |
| JSONArray result = new JSONArray(); |
| for (String entry : from) { |
| result.put(entry); |
| } |
| return result; |
| } |
| } |