blob: 0e2ea6713c276c24f259faa67739449afbc3a3c6 [file] [log] [blame]
// 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;
}
}