1 // Copyright 2014 The Chromium Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 package org.chromium.chromoting; 6 7 import android.app.Activity; 8 import android.content.Context; 9 import android.os.Bundle; 10 import android.support.v4.view.MenuItemCompat; 11 import android.support.v7.app.MediaRouteActionProvider; 12 import android.support.v7.media.MediaRouteSelector; 13 import android.support.v7.media.MediaRouter; 14 import android.support.v7.media.MediaRouter.RouteInfo; 15 import android.util.Log; 16 import android.view.Menu; 17 import android.view.MenuItem; 18 import android.widget.Toast; 19 20 import com.google.android.gms.cast.Cast; 21 import com.google.android.gms.cast.Cast.Listener; 22 import com.google.android.gms.cast.CastDevice; 23 import com.google.android.gms.cast.CastMediaControlIntent; 24 import com.google.android.gms.cast.CastStatusCodes; 25 import com.google.android.gms.common.ConnectionResult; 26 import com.google.android.gms.common.api.GoogleApiClient; 27 import com.google.android.gms.common.api.GoogleApiClient.ConnectionCallbacks; 28 import com.google.android.gms.common.api.GoogleApiClient.OnConnectionFailedListener; 29 import com.google.android.gms.common.api.ResultCallback; 30 import com.google.android.gms.common.api.Status; 31 32 import org.chromium.chromoting.jni.JniInterface; 33 34 import java.io.IOException; 35 import java.util.ArrayList; 36 import java.util.List; 37 38 /** 39 * A handler that interacts with the Cast Extension of the Chromoting host using extension messages. 40 * It uses the Cast Android Sender API to start our registered Cast Receiver App on a nearby Cast 41 * device, if the user chooses to do so. 42 */ 43 public class CastExtensionHandler implements ClientExtension, ActivityLifecycleListener { 44 45 /** Extension messages of this type will be handled by the CastExtensionHandler. */ 46 public static final String EXTENSION_MSG_TYPE = "cast_message"; 47 48 /** Tag used for logging. */ 49 private static final String TAG = "CastExtensionHandler"; 50 51 /** Application Id of the Cast Receiver App that will be run on the Cast device. */ 52 private static final String RECEIVER_APP_ID = "8A1211E3"; 53 54 /** 55 * Custom namespace that will be used to communicate with the Cast device. 56 * TODO(aiguha): Use com.google.chromeremotedesktop for official builds. 57 */ 58 private static final String CHROMOTOCAST_NAMESPACE = "urn:x-cast:com.chromoting.cast.all"; 59 60 /** Context that wil be used to initialize the MediaRouter and the GoogleApiClient. */ 61 private Context mContext = null; 62 63 /** True if the application has been launched on the Cast device. */ 64 private boolean mApplicationStarted; 65 66 /** True if the client is temporarily in a disconnected state. */ 67 private boolean mWaitingForReconnect; 68 69 /** Object that allows routing of media to external devices including Google Cast devices. */ 70 private MediaRouter mMediaRouter; 71 72 /** Describes the capabilities of routes that the application might want to use. */ 73 private MediaRouteSelector mMediaRouteSelector; 74 75 /** Cast device selected by the user. */ 76 private CastDevice mSelectedDevice; 77 78 /** Object to receive callbacks about media routing changes. */ 79 private MediaRouter.Callback mMediaRouterCallback; 80 81 /** Listener for events related to the connected Cast device.*/ 82 private Listener mCastClientListener; 83 84 /** Object that handles Google Play Services integration. */ 85 private GoogleApiClient mApiClient; 86 87 /** Callback objects for connection changes with Google Play Services. */ 88 private ConnectionCallbacks mConnectionCallbacks; 89 private OnConnectionFailedListener mConnectionFailedListener; 90 91 /** Channel for receiving messages from the Cast device. */ 92 private ChromotocastChannel mChromotocastChannel; 93 94 /** Current session ID, if there is one. */ 95 private String mSessionId; 96 97 /** Queue of messages that are yet to be delivered to the Receiver App. */ 98 private List<String> mChromotocastMessageQueue; 99 100 /** Current status of the application, if any. */ 101 private String mApplicationStatus; 102 103 /** 104 * A callback class for receiving events about media routing. 105 */ 106 private class CustomMediaRouterCallback extends MediaRouter.Callback { 107 @Override 108 public void onRouteSelected(MediaRouter router, RouteInfo info) { 109 mSelectedDevice = CastDevice.getFromBundle(info.getExtras()); 110 connectApiClient(); 111 } 112 113 @Override 114 public void onRouteUnselected(MediaRouter router, RouteInfo info) { 115 tearDown(); 116 mSelectedDevice = null; 117 } 118 } 119 120 /** 121 * A callback class for receiving the result of launching an application on the user-selected 122 * Google Cast device. 123 */ 124 private class ApplicationConnectionResultCallback implements 125 ResultCallback<Cast.ApplicationConnectionResult> { 126 @Override 127 public void onResult(Cast.ApplicationConnectionResult result) { 128 Status status = result.getStatus(); 129 if (!status.isSuccess()) { 130 tearDown(); 131 return; 132 } 133 134 mSessionId = result.getSessionId(); 135 mApplicationStatus = result.getApplicationStatus(); 136 mApplicationStarted = result.getWasLaunched(); 137 mChromotocastChannel = new ChromotocastChannel(); 138 139 try { 140 Cast.CastApi.setMessageReceivedCallbacks(mApiClient, 141 mChromotocastChannel.getNamespace(), mChromotocastChannel); 142 sendPendingMessagesToCastDevice(); 143 } catch (IOException e) { 144 showToast(R.string.connection_to_cast_failed, Toast.LENGTH_SHORT); 145 tearDown(); 146 } catch (IllegalStateException e) { 147 showToast(R.string.connection_to_cast_failed, Toast.LENGTH_SHORT); 148 tearDown(); 149 } 150 } 151 } 152 153 /** 154 * A callback class for receiving events about client connections and disconnections from 155 * Google Play Services. 156 */ 157 private class ConnectionCallbacks implements GoogleApiClient.ConnectionCallbacks { 158 @Override 159 public void onConnected(Bundle connectionHint) { 160 if (mWaitingForReconnect) { 161 mWaitingForReconnect = false; 162 reconnectChannels(); 163 return; 164 } 165 Cast.CastApi.launchApplication(mApiClient, RECEIVER_APP_ID, false).setResultCallback( 166 new ApplicationConnectionResultCallback()); 167 } 168 169 @Override 170 public void onConnectionSuspended(int cause) { 171 mWaitingForReconnect = true; 172 } 173 } 174 175 /** 176 * A listener for failures to connect with Google Play Services. 177 */ 178 private class ConnectionFailedListener implements GoogleApiClient.OnConnectionFailedListener { 179 @Override 180 public void onConnectionFailed(ConnectionResult result) { 181 Log.e(TAG, String.format("Google Play Service connection failed: %s", result)); 182 183 tearDown(); 184 } 185 186 } 187 188 /** 189 * A channel for communication with the Cast device on the CHROMOTOCAST_NAMESPACE. 190 */ 191 private class ChromotocastChannel implements Cast.MessageReceivedCallback { 192 193 /** 194 * Returns the namespace associated with this channel. 195 */ 196 public String getNamespace() { 197 return CHROMOTOCAST_NAMESPACE; 198 } 199 200 @Override 201 public void onMessageReceived(CastDevice castDevice, String namespace, String message) { 202 if (namespace.equals(CHROMOTOCAST_NAMESPACE)) { 203 sendMessageToHost(message); 204 } 205 } 206 } 207 208 /** 209 * A listener for changes when connected to a Google Cast device. 210 */ 211 private class CastClientListener extends Cast.Listener { 212 @Override 213 public void onApplicationStatusChanged() { 214 try { 215 if (mApiClient != null) { 216 mApplicationStatus = Cast.CastApi.getApplicationStatus(mApiClient); 217 } 218 } catch (IllegalStateException e) { 219 showToast(R.string.connection_to_cast_failed, Toast.LENGTH_SHORT); 220 tearDown(); 221 } 222 } 223 224 @Override 225 public void onVolumeChanged() {} // Changes in volume do not affect us. 226 227 @Override 228 public void onApplicationDisconnected(int errorCode) { 229 if (errorCode != CastStatusCodes.SUCCESS) { 230 Log.e(TAG, String.format("Application disconnected with: %d", errorCode)); 231 } 232 tearDown(); 233 } 234 } 235 236 /** 237 * Constructs a CastExtensionHandler with an empty message queue. 238 */ 239 public CastExtensionHandler() { 240 mChromotocastMessageQueue = new ArrayList<String>(); 241 } 242 243 // 244 // ClientExtension implementation. 245 // 246 247 @Override 248 public String getCapability() { 249 return Capabilities.CAST_CAPABILITY; 250 } 251 252 @Override 253 public boolean onExtensionMessage(String type, String data) { 254 if (type.equals(EXTENSION_MSG_TYPE)) { 255 mChromotocastMessageQueue.add(data); 256 if (mApplicationStarted) { 257 sendPendingMessagesToCastDevice(); 258 } 259 return true; 260 } 261 return false; 262 } 263 264 @Override 265 public ActivityLifecycleListener onActivityAcceptingListener(Activity activity) { 266 return this; 267 } 268 269 // 270 // ActivityLifecycleListener implementation. 271 // 272 273 /** Initializes the MediaRouter and related objects using the provided activity Context. */ 274 @Override 275 public void onActivityCreated(Activity activity, Bundle savedInstanceState) { 276 if (activity == null) { 277 return; 278 } 279 mContext = activity; 280 mMediaRouter = MediaRouter.getInstance(activity); 281 mMediaRouteSelector = new MediaRouteSelector.Builder() 282 .addControlCategory(CastMediaControlIntent.categoryForCast(RECEIVER_APP_ID)) 283 .build(); 284 mMediaRouterCallback = new CustomMediaRouterCallback(); 285 } 286 287 @Override 288 public void onActivityDestroyed(Activity activity) { 289 tearDown(); 290 } 291 292 @Override 293 public void onActivityPaused(Activity activity) { 294 removeMediaRouterCallback(); 295 } 296 297 @Override 298 public void onActivityResumed(Activity activity) { 299 addMediaRouterCallback(); 300 } 301 302 @Override 303 public void onActivitySaveInstanceState (Activity activity, Bundle outState) {} 304 305 @Override 306 public void onActivityStarted(Activity activity) { 307 addMediaRouterCallback(); 308 } 309 310 @Override 311 public void onActivityStopped(Activity activity) { 312 removeMediaRouterCallback(); 313 } 314 315 @Override 316 public boolean onActivityCreatedOptionsMenu(Activity activity, Menu menu) { 317 // Find the cast button in the menu. 318 MenuItem mediaRouteMenuItem = menu.findItem(R.id.media_route_menu_item); 319 if (mediaRouteMenuItem == null) { 320 return false; 321 } 322 323 // Setup a MediaRouteActionProvider using the button. 324 MediaRouteActionProvider mediaRouteActionProvider = 325 (MediaRouteActionProvider) MenuItemCompat.getActionProvider(mediaRouteMenuItem); 326 mediaRouteActionProvider.setRouteSelector(mMediaRouteSelector); 327 328 return true; 329 } 330 331 @Override 332 public boolean onActivityOptionsItemSelected(Activity activity, MenuItem item) { 333 if (item.getItemId() == R.id.actionbar_disconnect) { 334 removeMediaRouterCallback(); 335 showToast(R.string.connection_to_cast_closed, Toast.LENGTH_SHORT); 336 tearDown(); 337 return true; 338 } 339 return false; 340 } 341 342 // 343 // Extension Message Handling logic 344 // 345 346 /** Sends a message to the Chromoting host. */ 347 private void sendMessageToHost(String data) { 348 JniInterface.sendExtensionMessage(EXTENSION_MSG_TYPE, data); 349 } 350 351 /** Sends any messages in the message queue to the Cast device. */ 352 private void sendPendingMessagesToCastDevice() { 353 for (String msg : mChromotocastMessageQueue) { 354 sendMessageToCastDevice(msg); 355 } 356 mChromotocastMessageQueue.clear(); 357 } 358 359 // 360 // Cast Sender API logic 361 // 362 363 /** 364 * Initializes and connects to Google Play Services. 365 */ 366 private void connectApiClient() { 367 if (mContext == null) { 368 return; 369 } 370 mCastClientListener = new CastClientListener(); 371 mConnectionCallbacks = new ConnectionCallbacks(); 372 mConnectionFailedListener = new ConnectionFailedListener(); 373 374 Cast.CastOptions.Builder apiOptionsBuilder = Cast.CastOptions 375 .builder(mSelectedDevice, mCastClientListener) 376 .setVerboseLoggingEnabled(true); 377 378 mApiClient = new GoogleApiClient.Builder(mContext) 379 .addApi(Cast.API, apiOptionsBuilder.build()) 380 .addConnectionCallbacks(mConnectionCallbacks) 381 .addOnConnectionFailedListener(mConnectionFailedListener) 382 .build(); 383 mApiClient.connect(); 384 } 385 386 /** 387 * Adds the callback object to the MediaRouter. Called when the owning activity starts/resumes. 388 */ 389 private void addMediaRouterCallback() { 390 if (mMediaRouter != null && mMediaRouteSelector != null && mMediaRouterCallback != null) { 391 mMediaRouter.addCallback(mMediaRouteSelector, mMediaRouterCallback, 392 MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN); 393 } 394 } 395 396 /** 397 * Removes the callback object from the MediaRouter. Called when the owning activity 398 * stops/pauses. 399 */ 400 private void removeMediaRouterCallback() { 401 if (mMediaRouter != null && mMediaRouterCallback != null) { 402 mMediaRouter.removeCallback(mMediaRouterCallback); 403 } 404 } 405 406 /** 407 * Sends a message to the Cast device on the CHROMOTOCAST_NAMESPACE. 408 */ 409 private void sendMessageToCastDevice(String message) { 410 if (mApiClient == null || mChromotocastChannel == null) { 411 return; 412 } 413 Cast.CastApi.sendMessage(mApiClient, mChromotocastChannel.getNamespace(), message) 414 .setResultCallback(new ResultCallback<Status>() { 415 @Override 416 public void onResult(Status result) { 417 if (!result.isSuccess()) { 418 Log.e(TAG, "Failed to send message to cast device."); 419 } 420 } 421 }); 422 423 } 424 425 /** 426 * Restablishes the chromotocast message channel, so we can continue communicating with the 427 * Google Cast device. This must be called when resuming a connection. 428 */ 429 private void reconnectChannels() { 430 if (mApiClient == null && mChromotocastChannel == null) { 431 return; 432 } 433 try { 434 Cast.CastApi.setMessageReceivedCallbacks( 435 mApiClient, mChromotocastChannel.getNamespace(), mChromotocastChannel); 436 sendPendingMessagesToCastDevice(); 437 } catch (IOException e) { 438 showToast(R.string.connection_to_cast_failed, Toast.LENGTH_SHORT); 439 } catch (IllegalStateException e) { 440 showToast(R.string.connection_to_cast_failed, Toast.LENGTH_SHORT); 441 } 442 } 443 444 /** 445 * Stops the running application on the Google Cast device and performs the required tearDown 446 * sequence. 447 */ 448 private void tearDown() { 449 if (mApiClient != null && mApplicationStarted && mApiClient.isConnected()) { 450 Cast.CastApi.stopApplication(mApiClient, mSessionId); 451 if (mChromotocastChannel != null) { 452 try { 453 Cast.CastApi.removeMessageReceivedCallbacks( 454 mApiClient, mChromotocastChannel.getNamespace()); 455 } catch (IOException e) { 456 Log.e(TAG, "Failed to remove chromotocast channel."); 457 } 458 } 459 mApiClient.disconnect(); 460 } 461 mChromotocastChannel = null; 462 mApplicationStarted = false; 463 mApiClient = null; 464 mSelectedDevice = null; 465 mWaitingForReconnect = false; 466 mSessionId = null; 467 } 468 469 /** 470 * Makes a toast using the given message and duration. 471 */ 472 private void showToast(int messageId, int duration) { 473 if (mContext != null) { 474 Toast.makeText(mContext, mContext.getString(messageId), duration).show(); 475 } 476 } 477 } 478