blob: 174d9bed778a1502f4aefee431bf8e9b6b41bdfa [file] [log] [blame]
// 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.Intent;
import com.google.android.gms.cast.ApplicationMetadata;
import com.google.android.gms.cast.Cast;
import com.google.android.gms.cast.CastDevice;
import com.google.android.gms.cast.MediaStatus;
import com.google.android.gms.cast.RemoteMediaPlayer;
import com.google.android.gms.common.api.GoogleApiClient;
import com.google.android.gms.common.api.ResultCallback;
import com.google.android.gms.common.api.Status;
import org.json.JSONException;
import org.json.JSONObject;
import org.chromium.base.Log;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.media.router.MediaController;
import org.chromium.chrome.browser.media.router.MediaSource;
import org.chromium.chrome.browser.media.ui.MediaNotificationInfo;
import org.chromium.chrome.browser.media.ui.MediaNotificationListener;
import org.chromium.chrome.browser.media.ui.MediaNotificationManager;
import org.chromium.chrome.browser.metrics.MediaNotificationUma;
import org.chromium.chrome.browser.tab.Tab;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* A wrapper around the established Cast application session.
*/
public class CastSessionImpl implements MediaNotificationListener, CastSession {
private static final String TAG = "MediaRouter";
// The value is borrowed from the Android Cast SDK code to match their behavior.
private static final double MIN_VOLUME_LEVEL_DELTA = 1e-7;
private static class CastMessagingChannel implements Cast.MessageReceivedCallback {
private final CastSession mSession;
public CastMessagingChannel(CastSessionImpl session) {
mSession = session;
}
@Override
public void onMessageReceived(CastDevice castDevice, String namespace, String message) {
Log.d(TAG, "Received message from Cast device: namespace=\"" + namespace
+ "\" message=\"" + message + "\"");
mSession.getMessageHandler().onMessageReceived(namespace, message);
}
}
private final CastMessagingChannel mMessageChannel;
private final CastDevice mCastDevice;
private final MediaSource mSource;
private final CastMessageHandler mMessageHandler;
private GoogleApiClient mApiClient;
private String mSessionId;
private String mApplicationStatus;
private ApplicationMetadata mApplicationMetadata;
private boolean mStoppingApplication;
private MediaNotificationInfo.Builder mNotificationBuilder;
private RemoteMediaPlayer mMediaPlayer;
private Set<String> mNamespaces = new HashSet<String>();
/**
* Initializes a new {@link CastSessionImpl} instance.
* @param apiClient The Google Play Services client used to create the session.
* @param sessionId The session identifier to use with the Cast SDK.
* @param origin The origin of the frame requesting the route.
* @param tabId The id of the tab containing the frame requesting the route.
* @param isIncognito Whether the route is beging requested from an Incognito profile.
* @param source The {@link MediaSource} corresponding to this session.
* @param routeProvider The {@link CastMediaRouteProvider} instance managing this session.
*/
public CastSessionImpl(GoogleApiClient apiClient, String sessionId,
ApplicationMetadata metadata, String applicationStatus, CastDevice castDevice,
String origin, int tabId, boolean isIncognito, MediaSource source,
CastMessageHandler messageHandler) {
mSessionId = sessionId;
mApiClient = apiClient;
mSource = source;
mApplicationMetadata = metadata;
mApplicationStatus = applicationStatus;
mCastDevice = castDevice;
mMessageHandler = messageHandler;
mMessageChannel = new CastMessagingChannel(this);
updateNamespaces();
if (mNamespaces.contains(CastSessionUtil.MEDIA_NAMESPACE)) {
mMediaPlayer = new RemoteMediaPlayer();
mMediaPlayer.setOnStatusUpdatedListener(
new RemoteMediaPlayer.OnStatusUpdatedListener() {
@Override
public void onStatusUpdated() {
MediaStatus mediaStatus = mMediaPlayer.getMediaStatus();
if (mediaStatus == null) return;
int playerState = mediaStatus.getPlayerState();
if (playerState == MediaStatus.PLAYER_STATE_PAUSED
|| playerState == MediaStatus.PLAYER_STATE_PLAYING) {
mNotificationBuilder.setPaused(
playerState != MediaStatus.PLAYER_STATE_PLAYING);
mNotificationBuilder.setActions(MediaNotificationInfo.ACTION_STOP
| MediaNotificationInfo.ACTION_PLAY_PAUSE);
} else {
mNotificationBuilder.setActions(MediaNotificationInfo.ACTION_STOP);
}
MediaNotificationManager.show(mNotificationBuilder.build());
}
});
mMediaPlayer.setOnMetadataUpdatedListener(
new RemoteMediaPlayer.OnMetadataUpdatedListener() {
@Override
public void onMetadataUpdated() {
CastSessionUtil.setNotificationMetadata(
mNotificationBuilder, mCastDevice, mMediaPlayer);
MediaNotificationManager.show(mNotificationBuilder.build());
}
});
}
Intent contentIntent = Tab.createBringTabToFrontIntent(tabId);
if (contentIntent != null) {
contentIntent.putExtra(MediaNotificationUma.INTENT_EXTRA_NAME,
MediaNotificationUma.SOURCE_PRESENTATION);
}
mNotificationBuilder =
new MediaNotificationInfo.Builder()
.setPaused(false)
.setOrigin(origin)
// TODO(avayvod): the same session might have more than one tab id. Should
// we track the last foreground alive tab and update the notification with
// it?
.setTabId(tabId)
.setPrivate(isIncognito)
.setActions(MediaNotificationInfo.ACTION_STOP)
.setContentIntent(contentIntent)
.setNotificationSmallIcon(R.drawable.ic_notification_media_route)
.setDefaultNotificationLargeIcon(R.drawable.cast_playing_square)
.setId(R.id.presentation_notification)
.setListener(this);
CastSessionUtil.setNotificationMetadata(mNotificationBuilder, mCastDevice, mMediaPlayer);
MediaNotificationManager.show(mNotificationBuilder.build());
}
/////////////////////////////////////////////////////////////////////////////////////////////
// MediaNotificationListener implementation.
@Override
public void onPlay(int actionSource) {
if (mMediaPlayer == null || isApiClientInvalid()) return;
mMediaPlayer.play(mApiClient);
}
@Override
public void onPause(int actionSource) {
if (mMediaPlayer == null || isApiClientInvalid()) return;
mMediaPlayer.pause(mApiClient);
}
@Override
public void onStop(int actionSource) {
stopApplication();
ChromeCastSessionManager.get().onSessionStopAction();
}
@Override
public void onMediaSessionAction(int action) {}
/////////////////////////////////////////////////////////////////////////////////////////////
// Utility functions.
/**
* @param device The {@link CastDevice} queried for it's capabilities.
* @return The capabilities of the Cast device.
* TODO(zqzhang): move to a CastUtils class?
*/
protected static List<String> getCapabilities(CastDevice device) {
List<String> capabilities = new ArrayList<String>();
if (device.hasCapability(CastDevice.CAPABILITY_AUDIO_IN)) {
capabilities.add("audio_in");
}
if (device.hasCapability(CastDevice.CAPABILITY_AUDIO_OUT)) {
capabilities.add("audio_out");
}
if (device.hasCapability(CastDevice.CAPABILITY_VIDEO_IN)) {
capabilities.add("video_in");
}
if (device.hasCapability(CastDevice.CAPABILITY_VIDEO_OUT)) {
capabilities.add("video_out");
}
return capabilities;
}
/////////////////////////////////////////////////////////////////////////////////////////////
// Namespace handling.
private void updateNamespaces() {
if (mApplicationMetadata == null) return;
List<String> newNamespaces = mApplicationMetadata.getSupportedNamespaces();
Set<String> toRemove = new HashSet<String>(mNamespaces);
toRemove.removeAll(newNamespaces);
for (String namespaceToRemove : toRemove) unregisterNamespace(namespaceToRemove);
for (String newNamespace : newNamespaces) {
if (!mNamespaces.contains(newNamespace)) addNamespace(newNamespace);
}
}
private void addNamespace(String namespace) {
assert !mNamespaces.contains(namespace);
if (isApiClientInvalid()) return;
// If application metadata is null, register the callback anyway.
if (mApplicationMetadata != null && !mApplicationMetadata.isNamespaceSupported(namespace)) {
return;
}
try {
Cast.CastApi.setMessageReceivedCallbacks(mApiClient, namespace, mMessageChannel);
mNamespaces.add(namespace);
} catch (IOException e) {
Log.e(TAG, "Failed to register namespace listener for %s", namespace, e);
}
}
private void unregisterNamespace(String namespace) {
assert mNamespaces.contains(namespace);
if (isApiClientInvalid()) return;
try {
Cast.CastApi.removeMessageReceivedCallbacks(mApiClient, namespace);
mNamespaces.remove(namespace);
} catch (IOException e) {
Log.e(TAG, "Failed to remove the namespace listener for %s", namespace, e);
}
}
/////////////////////////////////////////////////////////////////////////////////////////////
// CastSession implementations.
@Override
public boolean isApiClientInvalid() {
return mApiClient == null || !mApiClient.isConnected();
}
@Override
public String getSourceId() {
return mSource.getSourceId();
}
@Override
public String getSinkId() {
return mCastDevice.getDeviceId();
}
@Override
public String getSessionId() {
return mSessionId;
}
@Override
public Set<String> getNamespaces() {
return mNamespaces;
}
@Override
public CastMessageHandler getMessageHandler() {
return mMessageHandler;
}
@Override
public CastSessionInfo getSessionInfo() {
if (isApiClientInvalid()) return null;
// Due to a Google Play Services issue, the api client might still get disconnected so that
// calls like {@link Cast.CastApi#getVolume()} will throw an {@link IllegalStateException}.
// Until the issue is fixed, catch the exception and return null if it was thrown instead of
// crashing. See https://crbug.com/708964 for details.
try {
CastSessionInfo.VolumeInfo.Builder volumeBuilder =
new CastSessionInfo.VolumeInfo.Builder()
.setLevel(Cast.CastApi.getVolume(mApiClient))
.setMuted(Cast.CastApi.isMute(mApiClient));
CastSessionInfo.ReceiverInfo.Builder receiverBuilder =
new CastSessionInfo.ReceiverInfo.Builder()
.setLabel(mCastDevice.getDeviceId())
.setFriendlyName(mCastDevice.getFriendlyName())
.setVolume(volumeBuilder.build())
.setIsActiveInput(Cast.CastApi.getActiveInputState(mApiClient))
.setDisplayStatus(null)
.setReceiverType("cast")
.addCapabilities(getCapabilities(mCastDevice));
CastSessionInfo.Builder sessionInfoBuilder =
new CastSessionInfo.Builder()
.setSessionId(mSessionId)
.setStatusText(mApplicationStatus)
.setReceiver(receiverBuilder.build())
.setStatus("connected")
.setTransportId("web-4")
.addNamespaces(mNamespaces);
if (mApplicationMetadata != null) {
sessionInfoBuilder.setAppId(mApplicationMetadata.getApplicationId())
.setDisplayName(mApplicationMetadata.getName());
} else {
sessionInfoBuilder.setAppId(mSource.getApplicationId())
.setDisplayName(mCastDevice.getFriendlyName());
}
return sessionInfoBuilder.build();
} catch (IllegalStateException e) {
Log.e(TAG, "Couldn't get session info", e);
return null;
}
}
@Override
public boolean sendStringCastMessage(
final String message,
final String namespace,
final String clientId,
final int sequenceNumber) {
if (isApiClientInvalid()) return false;
Log.d(TAG, "Sending message to Cast device in namespace %s: %s", namespace, message);
try {
Cast.CastApi.sendMessage(mApiClient, namespace, message)
.setResultCallback(
new ResultCallback<Status>() {
@Override
public void onResult(Status result) {
if (!result.isSuccess()) {
// TODO(avayvod): should actually report back to the page.
// See https://crbug.com/550445.
Log.e(TAG, "Failed to send the message: " + result);
return;
}
// Media commands wait for the media status update as a result.
if (CastSessionUtil.MEDIA_NAMESPACE.equals(namespace)) return;
// App messages wait for the empty message with the sequence
// number.
mMessageHandler.onAppMessageSent(clientId, sequenceNumber);
}
});
} catch (Exception e) {
Log.e(TAG, "Exception while sending message", e);
return false;
}
return true;
}
// SET_VOLUME messages have a |level| and |muted| properties. One of them is
// |null| and the other one isn't. |muted| is expected to be a boolean while
// |level| is a float from 0.0 to 1.0.
// Example:
// {
// "volume" {
// "level": 0.9,
// "muted": null
// }
// }
@Override
public HandleVolumeMessageResult handleVolumeMessage(
JSONObject volume, final String clientId, final int sequenceNumber)
throws JSONException {
if (volume == null) return new HandleVolumeMessageResult(false, false);
if (isApiClientInvalid()) return new HandleVolumeMessageResult(false, false);
boolean waitForVolumeChange = false;
try {
if (!volume.isNull("muted")) {
boolean newMuted = volume.getBoolean("muted");
if (Cast.CastApi.isMute(mApiClient) != newMuted) {
Cast.CastApi.setMute(mApiClient, newMuted);
waitForVolumeChange = true;
}
}
if (!volume.isNull("level")) {
double newLevel = volume.getDouble("level");
double currentLevel = Cast.CastApi.getVolume(mApiClient);
if (!Double.isNaN(currentLevel)
&& Math.abs(currentLevel - newLevel) > MIN_VOLUME_LEVEL_DELTA) {
Cast.CastApi.setVolume(mApiClient, newLevel);
waitForVolumeChange = true;
}
}
} catch (IOException e) {
Log.e(TAG, "Failed to send volume command: " + e);
return new HandleVolumeMessageResult(false, false);
}
return new HandleVolumeMessageResult(true, waitForVolumeChange);
}
@Override
public void stopApplication() {
if (mStoppingApplication) return;
if (isApiClientInvalid()) return;
mStoppingApplication = true;
Cast.CastApi.stopApplication(mApiClient, mSessionId)
.setResultCallback(new ResultCallback<Status>() {
@Override
public void onResult(Status status) {
mMessageHandler.onApplicationStopped();
// TODO(avayvod): handle a failure to stop the application.
// https://crbug.com/535577
Set<String> namespaces = new HashSet<String>(mNamespaces);
for (String namespace : namespaces) unregisterNamespace(namespace);
mNamespaces.clear();
mSessionId = null;
mApiClient = null;
ChromeCastSessionManager.get().onSessionEnded();
mStoppingApplication = false;
MediaNotificationManager.clear(R.id.presentation_notification);
}
});
}
@Override
public void onMediaMessage(String message) {
if (mMediaPlayer != null) {
mMediaPlayer.onMessageReceived(mCastDevice, CastSessionUtil.MEDIA_NAMESPACE, message);
}
}
@Override
public void onVolumeChanged() {
mMessageHandler.onVolumeChanged();
}
@Override
public void updateSessionStatus() {
if (isApiClientInvalid()) return;
try {
mApplicationStatus = Cast.CastApi.getApplicationStatus(mApiClient);
mApplicationMetadata = Cast.CastApi.getApplicationMetadata(mApiClient);
updateNamespaces();
mMessageHandler.broadcastClientMessage(
"update_session", mMessageHandler.buildSessionMessage());
} catch (IllegalStateException e) {
Log.e(TAG, "Can't get application status", e);
}
}
@Override
public void onClientConnected(String clientId) {
mMessageHandler.sendClientMessageTo(
clientId, "new_session", mMessageHandler.buildSessionMessage(),
CastMessageHandler.INVALID_SEQUENCE_NUMBER);
if (mMediaPlayer != null && !isApiClientInvalid()) mMediaPlayer.requestStatus(mApiClient);
}
@Override
public MediaController getMediaController() {
// MediaController is not used with the CastSessionImpl.
return null;
}
}