org.chromium.chrome.browser.media.router.cast.CastMessageHandler.java Source code

Java tutorial

Introduction

Here is the source code for org.chromium.chrome.browser.media.router.cast.CastMessageHandler.java

Source

// 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.chromium.base.Log;
import org.chromium.base.VisibleForTesting;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

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.
 */
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;
    static final String MEDIA_NAMESPACE = "urn:x-cast:com.google.cast.media";
    static final String GAMES_NAMESPACE = "urn:x-cast:com.google.cast.games";

    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 = null;
    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, 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 (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;
    }
}