blob: 6b49e9a9c186c6574908a467e5e447ea5457b6fb [file] [log] [blame]
// Copyright 2014 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.chromoting;
import android.app.Activity;
import android.content.Context;
import android.os.Bundle;
import android.support.v4.view.MenuItemCompat;
import android.support.v7.app.MediaRouteActionProvider;
import android.support.v7.media.MediaRouteSelector;
import android.support.v7.media.MediaRouter;
import android.support.v7.media.MediaRouter.RouteInfo;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.widget.Toast;
import com.google.android.gms.cast.Cast;
import com.google.android.gms.cast.Cast.Listener;
import com.google.android.gms.cast.CastDevice;
import com.google.android.gms.cast.CastMediaControlIntent;
import com.google.android.gms.cast.CastStatusCodes;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.api.GoogleApiClient;
import com.google.android.gms.common.api.GoogleApiClient.ConnectionCallbacks;
import com.google.android.gms.common.api.GoogleApiClient.OnConnectionFailedListener;
import com.google.android.gms.common.api.ResultCallback;
import com.google.android.gms.common.api.Status;
import org.chromium.chromoting.jni.JniInterface;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* A handler that interacts with the Cast Extension of the Chromoting host using extension messages.
* It uses the Cast Android Sender API to start our registered Cast Receiver App on a nearby Cast
* device, if the user chooses to do so.
*/
public class CastExtensionHandler implements ClientExtension, ActivityLifecycleListener {
/** Extension messages of this type will be handled by the CastExtensionHandler. */
public static final String EXTENSION_MSG_TYPE = "cast_message";
/** Tag used for logging. */
private static final String TAG = "CastExtensionHandler";
/** Application Id of the Cast Receiver App that will be run on the Cast device. */
private static final String RECEIVER_APP_ID = "8A1211E3";
/**
* Custom namespace that will be used to communicate with the Cast device.
* TODO(aiguha): Use com.google.chromeremotedesktop for official builds.
*/
private static final String CHROMOTOCAST_NAMESPACE = "urn:x-cast:com.chromoting.cast.all";
/** Context that wil be used to initialize the MediaRouter and the GoogleApiClient. */
private Context mContext = null;
/** True if the application has been launched on the Cast device. */
private boolean mApplicationStarted;
/** True if the client is temporarily in a disconnected state. */
private boolean mWaitingForReconnect;
/** Object that allows routing of media to external devices including Google Cast devices. */
private MediaRouter mMediaRouter;
/** Describes the capabilities of routes that the application might want to use. */
private MediaRouteSelector mMediaRouteSelector;
/** Cast device selected by the user. */
private CastDevice mSelectedDevice;
/** Object to receive callbacks about media routing changes. */
private MediaRouter.Callback mMediaRouterCallback;
/** Listener for events related to the connected Cast device.*/
private Listener mCastClientListener;
/** Object that handles Google Play Services integration. */
private GoogleApiClient mApiClient;
/** Callback objects for connection changes with Google Play Services. */
private ConnectionCallbacks mConnectionCallbacks;
private OnConnectionFailedListener mConnectionFailedListener;
/** Channel for receiving messages from the Cast device. */
private ChromotocastChannel mChromotocastChannel;
/** Current session ID, if there is one. */
private String mSessionId;
/** Queue of messages that are yet to be delivered to the Receiver App. */
private List<String> mChromotocastMessageQueue;
/** Current status of the application, if any. */
private String mApplicationStatus;
/**
* A callback class for receiving events about media routing.
*/
private class CustomMediaRouterCallback extends MediaRouter.Callback {
@Override
public void onRouteSelected(MediaRouter router, RouteInfo info) {
mSelectedDevice = CastDevice.getFromBundle(info.getExtras());
connectApiClient();
}
@Override
public void onRouteUnselected(MediaRouter router, RouteInfo info) {
tearDown();
mSelectedDevice = null;
}
}
/**
* A callback class for receiving the result of launching an application on the user-selected
* Google Cast device.
*/
private class ApplicationConnectionResultCallback implements
ResultCallback<Cast.ApplicationConnectionResult> {
@Override
public void onResult(Cast.ApplicationConnectionResult result) {
Status status = result.getStatus();
if (!status.isSuccess()) {
tearDown();
return;
}
mSessionId = result.getSessionId();
mApplicationStatus = result.getApplicationStatus();
mApplicationStarted = result.getWasLaunched();
mChromotocastChannel = new ChromotocastChannel();
try {
Cast.CastApi.setMessageReceivedCallbacks(mApiClient,
mChromotocastChannel.getNamespace(), mChromotocastChannel);
sendPendingMessagesToCastDevice();
} catch (IOException e) {
showToast(R.string.connection_to_cast_failed, Toast.LENGTH_SHORT);
tearDown();
} catch (IllegalStateException e) {
showToast(R.string.connection_to_cast_failed, Toast.LENGTH_SHORT);
tearDown();
}
}
}
/**
* A callback class for receiving events about client connections and disconnections from
* Google Play Services.
*/
private class ConnectionCallbacks implements GoogleApiClient.ConnectionCallbacks {
@Override
public void onConnected(Bundle connectionHint) {
if (mWaitingForReconnect) {
mWaitingForReconnect = false;
reconnectChannels();
return;
}
Cast.CastApi.launchApplication(mApiClient, RECEIVER_APP_ID, false).setResultCallback(
new ApplicationConnectionResultCallback());
}
@Override
public void onConnectionSuspended(int cause) {
mWaitingForReconnect = true;
}
}
/**
* A listener for failures to connect with Google Play Services.
*/
private class ConnectionFailedListener implements GoogleApiClient.OnConnectionFailedListener {
@Override
public void onConnectionFailed(ConnectionResult result) {
Log.e(TAG, String.format("Google Play Service connection failed: %s", result));
tearDown();
}
}
/**
* A channel for communication with the Cast device on the CHROMOTOCAST_NAMESPACE.
*/
private class ChromotocastChannel implements Cast.MessageReceivedCallback {
/**
* Returns the namespace associated with this channel.
*/
public String getNamespace() {
return CHROMOTOCAST_NAMESPACE;
}
@Override
public void onMessageReceived(CastDevice castDevice, String namespace, String message) {
if (namespace.equals(CHROMOTOCAST_NAMESPACE)) {
sendMessageToHost(message);
}
}
}
/**
* A listener for changes when connected to a Google Cast device.
*/
private class CastClientListener extends Cast.Listener {
@Override
public void onApplicationStatusChanged() {
try {
if (mApiClient != null) {
mApplicationStatus = Cast.CastApi.getApplicationStatus(mApiClient);
}
} catch (IllegalStateException e) {
showToast(R.string.connection_to_cast_failed, Toast.LENGTH_SHORT);
tearDown();
}
}
@Override
public void onVolumeChanged() {} // Changes in volume do not affect us.
@Override
public void onApplicationDisconnected(int errorCode) {
if (errorCode != CastStatusCodes.SUCCESS) {
Log.e(TAG, String.format("Application disconnected with: %d", errorCode));
}
tearDown();
}
}
/**
* Constructs a CastExtensionHandler with an empty message queue.
*/
public CastExtensionHandler() {
mChromotocastMessageQueue = new ArrayList<String>();
}
//
// ClientExtension implementation.
//
@Override
public String getCapability() {
return Capabilities.CAST_CAPABILITY;
}
@Override
public boolean onExtensionMessage(String type, String data) {
if (type.equals(EXTENSION_MSG_TYPE)) {
mChromotocastMessageQueue.add(data);
if (mApplicationStarted) {
sendPendingMessagesToCastDevice();
}
return true;
}
return false;
}
@Override
public ActivityLifecycleListener onActivityAcceptingListener(Activity activity) {
return this;
}
//
// ActivityLifecycleListener implementation.
//
/** Initializes the MediaRouter and related objects using the provided activity Context. */
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
if (activity == null) {
return;
}
mContext = activity;
mMediaRouter = MediaRouter.getInstance(activity);
mMediaRouteSelector = new MediaRouteSelector.Builder()
.addControlCategory(CastMediaControlIntent.categoryForCast(RECEIVER_APP_ID))
.build();
mMediaRouterCallback = new CustomMediaRouterCallback();
}
@Override
public void onActivityDestroyed(Activity activity) {
tearDown();
}
@Override
public void onActivityPaused(Activity activity) {
removeMediaRouterCallback();
}
@Override
public void onActivityResumed(Activity activity) {
addMediaRouterCallback();
}
@Override
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {}
@Override
public void onActivityStarted(Activity activity) {
addMediaRouterCallback();
}
@Override
public void onActivityStopped(Activity activity) {
removeMediaRouterCallback();
}
@Override
public boolean onActivityCreatedOptionsMenu(Activity activity, Menu menu) {
// Find the cast button in the menu.
MenuItem mediaRouteMenuItem = menu.findItem(R.id.media_route_menu_item);
if (mediaRouteMenuItem == null) {
return false;
}
// Setup a MediaRouteActionProvider using the button.
MediaRouteActionProvider mediaRouteActionProvider =
(MediaRouteActionProvider) MenuItemCompat.getActionProvider(mediaRouteMenuItem);
mediaRouteActionProvider.setRouteSelector(mMediaRouteSelector);
return true;
}
@Override
public boolean onActivityOptionsItemSelected(Activity activity, MenuItem item) {
if (item.getItemId() == R.id.actionbar_disconnect) {
removeMediaRouterCallback();
showToast(R.string.connection_to_cast_closed, Toast.LENGTH_SHORT);
tearDown();
return true;
}
return false;
}
//
// Extension Message Handling logic
//
/** Sends a message to the Chromoting host. */
private void sendMessageToHost(String data) {
JniInterface.sendExtensionMessage(EXTENSION_MSG_TYPE, data);
}
/** Sends any messages in the message queue to the Cast device. */
private void sendPendingMessagesToCastDevice() {
for (String msg : mChromotocastMessageQueue) {
sendMessageToCastDevice(msg);
}
mChromotocastMessageQueue.clear();
}
//
// Cast Sender API logic
//
/**
* Initializes and connects to Google Play Services.
*/
private void connectApiClient() {
if (mContext == null) {
return;
}
mCastClientListener = new CastClientListener();
mConnectionCallbacks = new ConnectionCallbacks();
mConnectionFailedListener = new ConnectionFailedListener();
Cast.CastOptions.Builder apiOptionsBuilder = Cast.CastOptions
.builder(mSelectedDevice, mCastClientListener)
.setVerboseLoggingEnabled(true);
mApiClient = new GoogleApiClient.Builder(mContext)
.addApi(Cast.API, apiOptionsBuilder.build())
.addConnectionCallbacks(mConnectionCallbacks)
.addOnConnectionFailedListener(mConnectionFailedListener)
.build();
mApiClient.connect();
}
/**
* Adds the callback object to the MediaRouter. Called when the owning activity starts/resumes.
*/
private void addMediaRouterCallback() {
if (mMediaRouter != null && mMediaRouteSelector != null && mMediaRouterCallback != null) {
mMediaRouter.addCallback(mMediaRouteSelector, mMediaRouterCallback,
MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN);
}
}
/**
* Removes the callback object from the MediaRouter. Called when the owning activity
* stops/pauses.
*/
private void removeMediaRouterCallback() {
if (mMediaRouter != null && mMediaRouterCallback != null) {
mMediaRouter.removeCallback(mMediaRouterCallback);
}
}
/**
* Sends a message to the Cast device on the CHROMOTOCAST_NAMESPACE.
*/
private void sendMessageToCastDevice(String message) {
if (mApiClient == null || mChromotocastChannel == null) {
return;
}
Cast.CastApi.sendMessage(mApiClient, mChromotocastChannel.getNamespace(), message)
.setResultCallback(new ResultCallback<Status>() {
@Override
public void onResult(Status result) {
if (!result.isSuccess()) {
Log.e(TAG, "Failed to send message to cast device.");
}
}
});
}
/**
* Restablishes the chromotocast message channel, so we can continue communicating with the
* Google Cast device. This must be called when resuming a connection.
*/
private void reconnectChannels() {
if (mApiClient == null && mChromotocastChannel == null) {
return;
}
try {
Cast.CastApi.setMessageReceivedCallbacks(
mApiClient, mChromotocastChannel.getNamespace(), mChromotocastChannel);
sendPendingMessagesToCastDevice();
} catch (IOException e) {
showToast(R.string.connection_to_cast_failed, Toast.LENGTH_SHORT);
} catch (IllegalStateException e) {
showToast(R.string.connection_to_cast_failed, Toast.LENGTH_SHORT);
}
}
/**
* Stops the running application on the Google Cast device and performs the required tearDown
* sequence.
*/
private void tearDown() {
if (mApiClient != null && mApplicationStarted && mApiClient.isConnected()) {
Cast.CastApi.stopApplication(mApiClient, mSessionId);
if (mChromotocastChannel != null) {
try {
Cast.CastApi.removeMessageReceivedCallbacks(
mApiClient, mChromotocastChannel.getNamespace());
} catch (IOException e) {
Log.e(TAG, "Failed to remove chromotocast channel.");
}
}
mApiClient.disconnect();
}
mChromotocastChannel = null;
mApplicationStarted = false;
mApiClient = null;
mSelectedDevice = null;
mWaitingForReconnect = false;
mSessionId = null;
}
/**
* Makes a toast using the given message and duration.
*/
private void showToast(int messageId, int duration) {
if (mContext != null) {
Toast.makeText(mContext, mContext.getString(messageId), duration).show();
}
}
}