/* * Copyright (C) 2015 Google Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.android.libraries.cast.companionlibrary.cast; import static com.google.android.libraries.cast.companionlibrary.utils.LogUtils.LOGD; import static com.google.android.libraries.cast.companionlibrary.utils.LogUtils.LOGE; import com.google.android.gms.cast.ApplicationMetadata; import com.google.android.gms.cast.Cast; import com.google.android.gms.cast.Cast.ApplicationConnectionResult; 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.cast.LaunchOptions; 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 com.google.android.libraries.cast.companionlibrary.R; import com.google.android.libraries.cast.companionlibrary.cast.callbacks.BaseCastConsumer; import com.google.android.libraries.cast.companionlibrary.cast.exceptions.CastException; import com.google.android.libraries.cast.companionlibrary.cast.exceptions.NoConnectionException; import com.google.android.libraries.cast.companionlibrary.cast.exceptions.OnFailedListener; import com.google.android.libraries.cast.companionlibrary.cast.exceptions.TransientNetworkDisconnectionException; import com.google.android.libraries.cast.companionlibrary.cast.reconnection.ReconnectionService; import com.google.android.libraries.cast.companionlibrary.utils.LogUtils; import com.google.android.libraries.cast.companionlibrary.utils.PreferenceAccessor; import com.google.android.libraries.cast.companionlibrary.utils.Utils; import android.annotation.TargetApi; import android.app.Activity; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.os.SystemClock; import android.support.annotation.IntDef; import android.support.v4.view.MenuItemCompat; import android.support.v7.app.MediaRouteActionProvider; import android.support.v7.app.MediaRouteButton; import android.support.v7.app.MediaRouteDialogFactory; import android.support.v7.media.MediaRouteSelector; import android.support.v7.media.MediaRouter; import android.support.v7.media.MediaRouter.RouteInfo; import android.view.Menu; import android.view.MenuItem; import java.io.IOException; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.List; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; /** * An abstract class that manages connectivity to a cast device. Subclasses are expected to extend * the functionality of this class based on their purpose. */ public abstract class BaseCastManager implements ConnectionCallbacks, OnConnectionFailedListener, OnFailedListener { private static final String TAG = LogUtils.makeLogTag(BaseCastManager.class); public static final int RECONNECTION_STATUS_STARTED = 1; public static final int RECONNECTION_STATUS_IN_PROGRESS = 2; public static final int RECONNECTION_STATUS_FINALIZED = 3; public static final int RECONNECTION_STATUS_INACTIVE = 4; public static final String PREFS_KEY_SESSION_ID = "session-id"; public static final String PREFS_KEY_SSID = "ssid"; public static final String PREFS_KEY_MEDIA_END = "media-end"; public static final String PREFS_KEY_APPLICATION_ID = "application-id"; public static final String PREFS_KEY_CAST_ACTIVITY_NAME = "cast-activity-name"; public static final String PREFS_KEY_CAST_CUSTOM_DATA_NAMESPACE = "cast-custom-data-namespace"; public static final String PREFS_KEY_ROUTE_ID = "route-id"; public static final int CLEAR_ALL = 0; public static final int CLEAR_ROUTE = 1; public static final int CLEAR_WIFI = 1 << 1; public static final int CLEAR_SESSION = 1 << 2; public static final int CLEAR_MEDIA_END = 1 << 3; public static final int DISCONNECT_REASON_OTHER = 0; public static final int DISCONNECT_REASON_CONNECTIVITY = 1; public static final int DISCONNECT_REASON_APP_NOT_RUNNING = 2; public static final int DISCONNECT_REASON_EXPLICIT = 3; protected CastConfiguration mCastConfiguration; /** * Enumerates the reasons behind a disconnect */ @Retention(RetentionPolicy.SOURCE) @IntDef({DISCONNECT_REASON_OTHER, DISCONNECT_REASON_CONNECTIVITY, DISCONNECT_REASON_APP_NOT_RUNNING, DISCONNECT_REASON_EXPLICIT}) public @interface DisconnectReason {} public static final int NO_APPLICATION_ERROR = 0; public static final int NO_STATUS_CODE = -1; private static final int SESSION_RECOVERY_TIMEOUT_S = 10; private static final int WHAT_UI_VISIBLE = 0; private static final int WHAT_UI_HIDDEN = 1; private static final int UI_VISIBILITY_DELAY_MS = 300; private static String sCclVersion; protected Context mContext; protected MediaRouter mMediaRouter; protected MediaRouteSelector mMediaRouteSelector; protected CastMediaRouterCallback mMediaRouterCallback; protected CastDevice mSelectedCastDevice; protected String mDeviceName; protected PreferenceAccessor mPreferenceAccessor; private final Set<BaseCastConsumer> mBaseCastConsumers = new CopyOnWriteArraySet<>(); private boolean mDestroyOnDisconnect = false; protected String mApplicationId; protected int mReconnectionStatus = RECONNECTION_STATUS_INACTIVE; protected int mVisibilityCounter; protected boolean mUiVisible; protected GoogleApiClient mApiClient; protected AsyncTask<Void, Integer, Boolean> mReconnectionTask; protected int mCapabilities; protected boolean mConnectionSuspended; protected String mSessionId; private Handler mUiVisibilityHandler; private RouteInfo mRouteInfo; protected int mApplicationErrorCode = NO_APPLICATION_ERROR; protected BaseCastManager() { } /** * Since application lifecycle callbacks are managed by subclasses, this abstract method needs * to be implemented by each subclass independently. * * @param device The Cast receiver device returned from the MediaRouteProvider. Should not be * {@code null}. */ protected abstract Cast.CastOptions.Builder getCastOptionBuilder(CastDevice device); /** * Subclasses should implement this to react appropriately to the successful launch of their * application. This is called when the application is successfully launched. */ protected abstract void onApplicationConnected(ApplicationMetadata applicationMetadata, String applicationStatus, String sessionId, boolean wasLaunched); /** * Called when the launch of application has failed. Subclasses need to handle this by doing * appropriate clean up. */ protected abstract void onApplicationConnectionFailed(int statusCode); /** * Called when the attempt to stop application has failed. */ protected abstract void onApplicationStopFailed(int statusCode); /** * Called when a Cast device is unselected (i.e. disconnected). Most of the logic is handled by * the {@link BaseCastManager} but each subclass may have some additional logic that can be * done, e.g. detaching data or media channels that they may have set up. */ protected void onDeviceUnselected() { // no-op implementation } protected BaseCastManager(Context context, CastConfiguration castConfiguration) { mCastConfiguration = castConfiguration; mCapabilities = castConfiguration.getCapabilities(); LogUtils.setDebug(isFeatureEnabled(CastConfiguration.FEATURE_DEBUGGING)); sCclVersion = context.getString(R.string.ccl_version); mApplicationId = castConfiguration.getApplicationId(); LOGD(TAG, "BaseCastManager is instantiated\nVersion: " + sCclVersion + "\nApplication ID: " + mApplicationId); mContext = context.getApplicationContext(); mPreferenceAccessor = new PreferenceAccessor(mContext); mUiVisibilityHandler = new Handler(new UpdateUiVisibilityHandlerCallback()); mPreferenceAccessor.saveStringToPreference(PREFS_KEY_APPLICATION_ID, mApplicationId); mMediaRouter = MediaRouter.getInstance(mContext); mMediaRouteSelector = new MediaRouteSelector.Builder().addControlCategory( CastMediaControlIntent.categoryForCast(mApplicationId)).build(); mMediaRouterCallback = new CastMediaRouterCallback(this); mMediaRouter.addCallback(mMediaRouteSelector, mMediaRouterCallback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY); } /** * Returns the {@link MediaRouteDialogFactory} that defines the chooser and controller dialogs * for selecting a route and controlling the route when connected. The default factory will be * used unless a different one is configured in {@link CastConfiguration}. */ private MediaRouteDialogFactory getMediaRouteDialogFactory() { return mCastConfiguration.getMediaRouteDialogFactory(); } /** * Called when a {@link CastDevice} is extracted from the {@link RouteInfo}. This is where all * the fun starts! */ public final void onDeviceSelected(CastDevice device, RouteInfo routeInfo) { for (BaseCastConsumer consumer : mBaseCastConsumers) { consumer.onDeviceSelected(device, routeInfo); } if (device == null) { disconnectDevice(mDestroyOnDisconnect, true, false); } else { setDevice(device); } } /** * This is called from * {@link com.google.android.libraries.cast.companionlibrary.cast.CastMediaRouterCallback} to * signal the change in presence of cast devices on network. * * @param castDevicePresent Indicates where a cast device is present, <code>true</code>, or not, * <code>false</code>. */ public final void onCastAvailabilityChanged(boolean castDevicePresent) { for (BaseCastConsumer consumer : mBaseCastConsumers) { consumer.onCastAvailabilityChanged(castDevicePresent); } } /** * Disconnects from the connected device. * * @param stopAppOnExit If {@code true}, the application running on the cast device will be * stopped when disconnected. * @param clearPersistedConnectionData If {@code true}, the persisted connection information * will be cleared as part of this call. * @param setDefaultRoute If {@code true}, after disconnection, the selected route will be set * to the Default Route. */ public final void disconnectDevice(boolean stopAppOnExit, boolean clearPersistedConnectionData, boolean setDefaultRoute) { LOGD(TAG, "disconnectDevice(" + clearPersistedConnectionData + "," + setDefaultRoute + ")"); if (mSelectedCastDevice == null) { return; } mSelectedCastDevice = null; mDeviceName = null; String message = "disconnectDevice() Disconnect Reason: "; int reason; if (mConnectionSuspended) { message += "Connectivity lost"; reason = DISCONNECT_REASON_CONNECTIVITY; } else { switch (mApplicationErrorCode) { case CastStatusCodes.APPLICATION_NOT_RUNNING: message += "App was taken over or not available anymore"; reason = DISCONNECT_REASON_APP_NOT_RUNNING; break; case NO_APPLICATION_ERROR: message += "Intentional disconnect"; reason = DISCONNECT_REASON_EXPLICIT; break; default: message += "Other"; reason = DISCONNECT_REASON_OTHER; } } LOGD(TAG, message); for (BaseCastConsumer consumer : mBaseCastConsumers) { consumer.onDisconnectionReason(reason); } LOGD(TAG, "mConnectionSuspended: " + mConnectionSuspended); if (!mConnectionSuspended && clearPersistedConnectionData) { clearPersistedConnectionInfo(CLEAR_ALL); stopReconnectionService(); } try { if ((isConnected() || isConnecting()) && stopAppOnExit) { LOGD(TAG, "Calling stopApplication"); stopApplication(); } } catch (NoConnectionException | TransientNetworkDisconnectionException e) { LOGE(TAG, "Failed to stop the application after disconnecting route", e); } onDeviceUnselected(); if (mApiClient != null) { // the following check is currently required, without including a check for // isConnecting() due to a bug in the current play services library and will be removed // when that bug is addressed; calling disconnect() while we are in "connecting" state // will throw an exception if (mApiClient.isConnected()) { LOGD(TAG, "Trying to disconnect"); mApiClient.disconnect(); } if ((mMediaRouter != null) && setDefaultRoute) { LOGD(TAG, "disconnectDevice(): Setting route to default"); mMediaRouter.selectRoute(mMediaRouter.getDefaultRoute()); } mApiClient = null; } mSessionId = null; onDisconnected(stopAppOnExit, clearPersistedConnectionData, setDefaultRoute); } /** * Returns {@code true} if and only if the selected cast device is on the local network. * * @throws CastException if no cast device has been selected. */ public final boolean isDeviceOnLocalNetwork() throws CastException { if (mSelectedCastDevice == null) { throw new CastException("No cast device has yet been selected"); } return mSelectedCastDevice.isOnLocalNetwork(); } private void setDevice(CastDevice device) { mSelectedCastDevice = device; mDeviceName = mSelectedCastDevice.getFriendlyName(); if (mApiClient == null) { LOGD(TAG, "acquiring a connection to Google Play services for " + mSelectedCastDevice); Cast.CastOptions.Builder apiOptionsBuilder = getCastOptionBuilder(mSelectedCastDevice); mApiClient = new GoogleApiClient.Builder(mContext) .addApi(Cast.API, apiOptionsBuilder.build()) .addConnectionCallbacks(this) .addOnConnectionFailedListener(this) .build(); mApiClient.connect(); } else if (!mApiClient.isConnected() && !mApiClient.isConnecting()) { mApiClient.connect(); } } /** * Called as soon as a non-default {@link RouteInfo} is discovered. The main usage for this is * to provide a hint to clients that the cast button is going to become visible/available soon. * A client, for example, can use this to show a quick help screen to educate the user on the * cast concept and the usage of the cast button. */ public final void onCastDeviceDetected(RouteInfo info) { for (BaseCastConsumer consumer : mBaseCastConsumers) { consumer.onCastDeviceDetected(info); } } /** * Called when a route is removed. */ public final void onRouteRemoved(RouteInfo info) { for (BaseCastConsumer consumer : mBaseCastConsumers) { consumer.onRouteRemoved(info); } } /** * Adds and wires up the Media Router cast button. It returns a reference to the Media Router * menu item if the caller needs such reference. It is assumed that the enclosing * {@link android.app.Activity} inherits (directly or indirectly) from * {@link android.support.v7.app.AppCompatActivity}. * * @param menu Menu reference * @param menuResourceId The resource id of the cast button in the xml menu descriptor file */ public final MenuItem addMediaRouterButton(Menu menu, int menuResourceId) { MenuItem mediaRouteMenuItem = menu.findItem(menuResourceId); MediaRouteActionProvider mediaRouteActionProvider = (MediaRouteActionProvider) MenuItemCompat.getActionProvider(mediaRouteMenuItem); mediaRouteActionProvider.setRouteSelector(mMediaRouteSelector); if (getMediaRouteDialogFactory() != null) { mediaRouteActionProvider.setDialogFactory(getMediaRouteDialogFactory()); } return mediaRouteMenuItem; } /** * Adds and wires up the {@link android.support.v7.app.MediaRouteButton} instance that is passed * as an argument. This requires that * <ul> * <li>The enclosing {@link android.app.Activity} inherits (directly or indirectly) from * {@link android.support.v4.app.FragmentActivity}</li> * <li>User adds the {@link android.support.v7.app.MediaRouteButton} to the layout and passes a * reference to that instance to this method</li> * <li>User is in charge of controlling the visibility of this button. However, this library * makes it easier to do so: use the callback <code>onCastAvailabilityChanged(boolean)</code> * to change the visibility of the button in your client. For example, extend * {@link com.google.android.libraries.cast.companionlibrary.cast.callbacks.VideoCastConsumerImpl} * and override that method: * * <pre> public void onCastAvailabilityChanged(boolean castPresent) { mMediaRouteButton.setVisibility(castPresent ? View.VISIBLE : View.INVISIBLE); } * </pre> * </li> * </ul> */ public final void addMediaRouterButton(MediaRouteButton button) { button.setRouteSelector(mMediaRouteSelector); if (getMediaRouteDialogFactory() != null) { button.setDialogFactory(getMediaRouteDialogFactory()); } } /** * Calling this method signals the library that an activity page is made visible. In common * cases, this should be called in the "onResume()" method of each activity of the application. * The library keeps a counter and when at least one page of the application becomes visible, * the {@link #onUiVisibilityChanged(boolean)} method is called. */ public final synchronized void incrementUiCounter() { mVisibilityCounter++; if (!mUiVisible) { mUiVisible = true; mUiVisibilityHandler.removeMessages(WHAT_UI_HIDDEN); mUiVisibilityHandler.sendEmptyMessageDelayed(WHAT_UI_VISIBLE, UI_VISIBILITY_DELAY_MS); } if (mVisibilityCounter == 0) { LOGD(TAG, "UI is no longer visible"); } else { LOGD(TAG, "UI is visible"); } } /** * Calling this method signals the library that an activity page is made invisible. In common * cases, this should be called in the "onPause()" method of each activity of the application. * The library keeps a counter and when all pages of the application become invisible, the * {@link #onUiVisibilityChanged(boolean)} method is called. */ public final synchronized void decrementUiCounter() { if (--mVisibilityCounter == 0) { LOGD(TAG, "UI is no longer visible"); if (mUiVisible) { mUiVisible = false; mUiVisibilityHandler.removeMessages(WHAT_UI_VISIBLE); mUiVisibilityHandler.sendEmptyMessageDelayed(WHAT_UI_HIDDEN, UI_VISIBILITY_DELAY_MS); } } else { LOGD(TAG, "UI is visible"); } } /** * This is called when UI visibility of the client has changed * * @param visible The updated visibility status */ protected void onUiVisibilityChanged(boolean visible) { if (visible) { if (mMediaRouter != null && mMediaRouterCallback != null) { LOGD(TAG, "onUiVisibilityChanged() addCallback called"); startCastDiscovery(); if (isFeatureEnabled(CastConfiguration.FEATURE_AUTO_RECONNECT)) { reconnectSessionIfPossible(); } } } else { if (mMediaRouter != null) { LOGD(TAG, "onUiVisibilityChanged() removeCallback called"); stopCastDiscovery(); } } for (BaseCastConsumer consumer : mBaseCastConsumers) { consumer.onUiVisibilityChanged(visible); } } /** * Starts the discovery of cast devices by registering a {@link android.support.v7.media * .MediaRouter.Callback} */ public final void startCastDiscovery() { mMediaRouter.addCallback(mMediaRouteSelector, mMediaRouterCallback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY); } /** * Stops the process of cast discovery by removing the registered * {@link android.support.v7.media.MediaRouter.Callback} */ public final void stopCastDiscovery() { mMediaRouter.removeCallback(mMediaRouterCallback); } /** * A utility method to validate that the appropriate version of the Google Play Services is * available on the device. If not, it will open a dialog to address the issue. The dialog * displays a localized message about the error and upon user confirmation (by tapping on * dialog) will direct them to the Play Store if Google Play services is out of date or missing, * or to system settings if Google Play services is disabled on the device. */ public static boolean checkGooglePlayServices(final Activity activity) { return Utils.checkGooglePlayServices(activity); } /** * can be used to find out if the application is connected to the service or not. * * @return <code>true</code> if connected, <code>false</code> otherwise. */ public final boolean isConnected() { return (mApiClient != null) && mApiClient.isConnected(); } /** * Returns <code>true</code> only if application is connecting to the Cast service. */ public final boolean isConnecting() { return (mApiClient != null) && mApiClient.isConnecting(); } /** * Disconnects from the cast device. */ public final void disconnect() { if (isConnected() || isConnecting()) { disconnectDevice(mDestroyOnDisconnect, true, true); } } /** * Returns the assigned human-readable name of the device, or <code>null</code> if no device is * connected. */ public final String getDeviceName() { return mDeviceName; } /** * Sets a flag to control whether disconnection form a cast device should result in stopping * the running application or not. If <code>true</code> is passed, then application will be * stopped. Default behavior is not to stop the app. */ public final void setStopOnDisconnect(boolean stopOnExit) { mDestroyOnDisconnect = stopOnExit; } /** * Returns the {@link MediaRouteSelector} object. */ public final MediaRouteSelector getMediaRouteSelector() { return mMediaRouteSelector; } /** * Returns the {@link android.support.v7.media.MediaRouter.RouteInfo} corresponding to the * selected route. */ public final RouteInfo getRouteInfo() { return mRouteInfo; } /** * Sets the {@link android.support.v7.media.MediaRouter.RouteInfo} corresponding to the * selected route. */ public final void setRouteInfo(RouteInfo routeInfo) { mRouteInfo = routeInfo; } /* * Returns true if and only if the feature is turned on */ public final boolean isFeatureEnabled(int feature) { return (feature & mCapabilities) == feature; } /** * Sets the device (system) volume. * * @param volume Should be a value between 0 and 1, inclusive. * @throws CastException * @throws NoConnectionException * @throws TransientNetworkDisconnectionException */ public final void setDeviceVolume(double volume) throws CastException, TransientNetworkDisconnectionException, NoConnectionException { checkConnectivity(); try { Cast.CastApi.setVolume(mApiClient, volume); } catch (IOException e) { throw new CastException("Failed to set volume", e); } catch (IllegalStateException e) { throw new NoConnectionException("setDeviceVolume()", e); } } /** * Gets the remote's system volume, a number between 0 and 1, inclusive. * * @throws NoConnectionException * @throws TransientNetworkDisconnectionException */ public final double getDeviceVolume() throws TransientNetworkDisconnectionException, NoConnectionException { checkConnectivity(); try { return Cast.CastApi.getVolume(mApiClient); } catch (IllegalStateException e) { throw new NoConnectionException("getDeviceVolume()", e); } } /** * Increments (or decrements) the device volume by the given amount. * * @throws CastException * @throws NoConnectionException * @throws TransientNetworkDisconnectionException */ public final void adjustDeviceVolume(double delta) throws CastException, TransientNetworkDisconnectionException, NoConnectionException { checkConnectivity(); double vol = getDeviceVolume(); if (vol >= 0) { setDeviceVolume(vol + delta); } } /** * Returns <code>true</code> if remote device is muted. It internally determines if this should * be done for <code>stream</code> or <code>device</code> volume. * * @throws NoConnectionException * @throws TransientNetworkDisconnectionException */ public final boolean isDeviceMute() throws TransientNetworkDisconnectionException, NoConnectionException { checkConnectivity(); try { return Cast.CastApi.isMute(mApiClient); } catch (IllegalStateException e) { throw new NoConnectionException("isDeviceMute()", e); } } /** * Mutes or un-mutes the device volume. * * @throws CastException * @throws NoConnectionException * @throws TransientNetworkDisconnectionException */ public final void setDeviceMute(boolean mute) throws CastException, TransientNetworkDisconnectionException, NoConnectionException { checkConnectivity(); try { Cast.CastApi.setMute(mApiClient, mute); } catch (IOException e) { throw new CastException("setDeviceMute", e); } catch (IllegalStateException e) { throw new NoConnectionException("setDeviceMute()", e); } } /** * Returns the current reconnection status */ public final int getReconnectionStatus() { return mReconnectionStatus; } /** * Sets the reconnection status */ public final void setReconnectionStatus(int status) { if (mReconnectionStatus != status) { mReconnectionStatus = status; onReconnectionStatusChanged(mReconnectionStatus); } } private void onReconnectionStatusChanged(int status) { for (BaseCastConsumer consumer : mBaseCastConsumers) { consumer.onReconnectionStatusChanged(status); } } /** * Returns <code>true</code> if there is enough persisted information to attempt a session * recovery. For this to return <code>true</code>, there needs to be a persisted session ID and * a route ID from the last successful launch. */ protected final boolean canConsiderSessionRecovery() { return canConsiderSessionRecovery(null); } /** * Returns <code>true</code> if there is enough persisted information to attempt a session * recovery. For this to return <code>true</code>, there needs to be persisted session ID and * route ID from the last successful launch. In addition, if <code>ssidName</code> is non-null, * then an additional check is also performed to make sure the persisted wifi name is the same * as the <code>ssidName</code> */ public final boolean canConsiderSessionRecovery(String ssidName) { String sessionId = mPreferenceAccessor.getStringFromPreference(PREFS_KEY_SESSION_ID); String routeId = mPreferenceAccessor.getStringFromPreference(PREFS_KEY_ROUTE_ID); String ssid = mPreferenceAccessor.getStringFromPreference(PREFS_KEY_SSID); if (sessionId == null || routeId == null) { return false; } if (ssidName != null && (ssid == null || (!ssid.equals(ssidName)))) { return false; } LOGD(TAG, "Found session info in the preferences, so proceed with an " + "attempt to reconnect if possible"); return true; } private void reconnectSessionIfPossibleInternal(RouteInfo theRoute) { if (isConnected()) { return; } String sessionId = mPreferenceAccessor.getStringFromPreference(PREFS_KEY_SESSION_ID); String routeId = mPreferenceAccessor.getStringFromPreference(PREFS_KEY_ROUTE_ID); LOGD(TAG, "reconnectSessionIfPossible() Retrieved from preferences: " + "sessionId=" + sessionId + ", routeId=" + routeId); if (sessionId == null || routeId == null) { return; } setReconnectionStatus(RECONNECTION_STATUS_IN_PROGRESS); CastDevice device = CastDevice.getFromBundle(theRoute.getExtras()); if (device != null) { LOGD(TAG, "trying to acquire Cast Client for " + device); onDeviceSelected(device, theRoute); } } /* * Cancels the task responsible for recovery of prior sessions, is used internally. */ public final void cancelReconnectionTask() { LOGD(TAG, "cancelling reconnection task"); if (mReconnectionTask != null && !mReconnectionTask.isCancelled()) { mReconnectionTask.cancel(true); } } /** * This method tries to automatically re-establish re-establish connection to a session if * <ul> * <li>User had not done a manual disconnect in the last session * <li>Device that user had connected to previously is still running the same session * </ul> * Under these conditions, a best-effort attempt will be made to continue with the same * session. This attempt will go on for {@code SESSION_RECOVERY_TIMEOUT} seconds. */ public final void reconnectSessionIfPossible() { reconnectSessionIfPossible(SESSION_RECOVERY_TIMEOUT_S); } /** * This method tries to automatically re-establish connection to a session if * <ul> * <li>User had not done a manual disconnect in the last session * <li>The Cast Device that user had connected to previously is still running the same session * </ul> * Under these conditions, a best-effort attempt will be made to continue with the same * session. This attempt will go on for <code>timeoutInSeconds</code> seconds. */ public final void reconnectSessionIfPossible(final int timeoutInSeconds) { reconnectSessionIfPossible(timeoutInSeconds, null); } /** * This method tries to automatically re-establish connection to a session if * <ul> * <li>User had not done a manual disconnect in the last session * <li>The Cast Device that user had connected to previously is still running the same session * </ul> * Under these conditions, a best-effort attempt will be made to continue with the same * session. * This attempt will go on for <code>timeoutInSeconds</code> seconds. * * @param timeoutInSeconds the length of time, in seconds, to attempt reconnection before giving * up * @param ssidName The name of Wifi SSID */ @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) public void reconnectSessionIfPossible(final int timeoutInSeconds, String ssidName) { LOGD(TAG, String.format("reconnectSessionIfPossible(%d, %s)", timeoutInSeconds, ssidName)); if (isConnected()) { return; } String routeId = mPreferenceAccessor.getStringFromPreference(PREFS_KEY_ROUTE_ID); if (canConsiderSessionRecovery(ssidName)) { List<RouteInfo> routes = mMediaRouter.getRoutes(); RouteInfo theRoute = null; if (routes != null) { for (RouteInfo route : routes) { if (route.getId().equals(routeId)) { theRoute = route; break; } } } if (theRoute != null) { // route has already been discovered, so lets just get the device reconnectSessionIfPossibleInternal(theRoute); } else { // we set a flag so if the route is discovered within a short period, we let // onRouteAdded callback of CastMediaRouterCallback take care of that setReconnectionStatus(RECONNECTION_STATUS_STARTED); } // cancel any prior reconnection task if (mReconnectionTask != null && !mReconnectionTask.isCancelled()) { mReconnectionTask.cancel(true); } // we may need to reconnect to an existing session mReconnectionTask = new AsyncTask<Void, Integer, Boolean>() { @Override protected Boolean doInBackground(Void... params) { for (int i = 0; i < timeoutInSeconds; i++) { LOGD(TAG, "Reconnection: Attempt " + (i + 1)); if (isCancelled()) { return true; } try { if (isConnected()) { cancel(true); } Thread.sleep(1000); } catch (InterruptedException e) { // ignore } } return false; } @Override protected void onPostExecute(Boolean result) { if (result == null || !result) { LOGD(TAG, "Couldn't reconnect, dropping connection"); setReconnectionStatus(RECONNECTION_STATUS_INACTIVE); onDeviceSelected(null /* CastDevice */, null /* RouteInfo */); } } }; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { mReconnectionTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } else { mReconnectionTask.execute(); } } } /** * This is called by the library when a connection is re-established after a transient * disconnect. Note: this is not called by SDK. */ public void onConnectivityRecovered() { for (BaseCastConsumer consumer : mBaseCastConsumers) { consumer.onConnectivityRecovered(); } } /* * (non-Javadoc) * @see com.google.android.gms.GoogleApiClient.ConnectionCallbacks#onConnected * (android.os.Bundle) */ @Override public final void onConnected(Bundle hint) { LOGD(TAG, "onConnected() reached with prior suspension: " + mConnectionSuspended); if (mConnectionSuspended) { mConnectionSuspended = false; if (hint != null && hint.getBoolean(Cast.EXTRA_APP_NO_LONGER_RUNNING)) { // the same app is not running any more LOGD(TAG, "onConnected(): App no longer running, so disconnecting"); disconnect(); } else { onConnectivityRecovered(); } return; } if (!isConnected()) { if (mReconnectionStatus == RECONNECTION_STATUS_IN_PROGRESS) { setReconnectionStatus(RECONNECTION_STATUS_INACTIVE); } return; } try { if (isFeatureEnabled(CastConfiguration.FEATURE_WIFI_RECONNECT)) { String ssid = Utils.getWifiSsid(mContext); mPreferenceAccessor.saveStringToPreference(PREFS_KEY_SSID, ssid); } Cast.CastApi.requestStatus(mApiClient); if (!mCastConfiguration.isDisableLaunchOnConnect()) { launchApp(); } for (BaseCastConsumer consumer : mBaseCastConsumers) { consumer.onConnected(); } } catch (IOException | IllegalStateException e) { LOGE(TAG, "requestStatus()", e); } } /* * Note: this is not called by the SDK anymore but this library calls this in the appropriate * time. */ protected void onDisconnected(boolean stopAppOnExit, boolean clearPersistedConnectionData, boolean setDefaultRoute) { LOGD(TAG, "onDisconnected() reached"); mDeviceName = null; for (BaseCastConsumer consumer : mBaseCastConsumers) { consumer.onDisconnected(); } } /* * (non-Javadoc) * @see com.google.android.gms.GoogleApiClient.OnConnectionFailedListener# * onConnectionFailed(com.google.android.gms.common.ConnectionResult) */ @Override public void onConnectionFailed(ConnectionResult result) { LOGD(TAG, "onConnectionFailed() reached, error code: " + result.getErrorCode() + ", reason: " + result.toString()); disconnectDevice(mDestroyOnDisconnect, false /* clearPersistentConnectionData */, false /* setDefaultRoute */); mConnectionSuspended = false; if (mMediaRouter != null) { mMediaRouter.selectRoute(mMediaRouter.getDefaultRoute()); } for (BaseCastConsumer consumer : mBaseCastConsumers) { consumer.onConnectionFailed(result); } PendingIntent pendingIntent = result.getResolution(); if (pendingIntent != null) { try { pendingIntent.send(); } catch (PendingIntent.CanceledException e) { LOGE(TAG, "Failed to show recovery from the recoverable error", e); } } } @Override public void onConnectionSuspended(int cause) { mConnectionSuspended = true; LOGD(TAG, "onConnectionSuspended() was called with cause: " + cause); for (BaseCastConsumer consumer : mBaseCastConsumers) { consumer.onConnectionSuspended(cause); } } /** * Launches application with the given {@code applicationId} and {@link LaunchOptions}. * * @throws TransientNetworkDisconnectionException * @throws NoConnectionException */ public final void launchApp(String applicationId, LaunchOptions launchOptions) throws TransientNetworkDisconnectionException, NoConnectionException { LOGD(TAG, "launchApp(applicationId, launchOptions) is called"); if (!isConnected()) { if (mReconnectionStatus == RECONNECTION_STATUS_IN_PROGRESS) { setReconnectionStatus(RECONNECTION_STATUS_INACTIVE); return; } checkConnectivity(); } if (mReconnectionStatus == RECONNECTION_STATUS_IN_PROGRESS) { LOGD(TAG, "Attempting to join a previously interrupted session..."); String sessionId = mPreferenceAccessor.getStringFromPreference(PREFS_KEY_SESSION_ID); LOGD(TAG, "joinApplication() -> start"); Cast.CastApi.joinApplication(mApiClient, applicationId, sessionId).setResultCallback( new ResultCallback<Cast.ApplicationConnectionResult>() { @Override public void onResult(ApplicationConnectionResult result) { if (result.getStatus().isSuccess()) { LOGD(TAG, "joinApplication() -> success"); onApplicationConnected(result.getApplicationMetadata(), result.getApplicationStatus(), result.getSessionId(), result.getWasLaunched()); } else { LOGD(TAG, "joinApplication() -> failure"); clearPersistedConnectionInfo(CLEAR_SESSION | CLEAR_MEDIA_END); cancelReconnectionTask(); onApplicationConnectionFailed(result.getStatus().getStatusCode()); } } } ); } else { LOGD(TAG, "Launching app"); Cast.CastApi.launchApplication(mApiClient, applicationId, launchOptions) .setResultCallback( new ResultCallback<Cast.ApplicationConnectionResult>() { @Override public void onResult(ApplicationConnectionResult result) { if (result.getStatus().isSuccess()) { LOGD(TAG, "launchApplication() -> success result"); onApplicationConnected(result.getApplicationMetadata(), result.getApplicationStatus(), result.getSessionId(), result.getWasLaunched()); } else { LOGD(TAG, "launchApplication() -> failure result"); onApplicationConnectionFailed( result.getStatus().getStatusCode()); } } } ); } } /* * Launches application. For this to succeed, a connection should be already established by the * CastClient. */ private void launchApp() throws TransientNetworkDisconnectionException, NoConnectionException { LOGD(TAG, "launchApp() is called"); launchApp(mCastConfiguration.getApplicationId(), mCastConfiguration.getLaunchOptions()); } /** * Stops the application on the receiver device. * * @throws NoConnectionException * @throws TransientNetworkDisconnectionException */ public final void stopApplication() throws TransientNetworkDisconnectionException, NoConnectionException { checkConnectivity(); Cast.CastApi.stopApplication(mApiClient, mSessionId).setResultCallback( new ResultCallback<Status>() { @Override public void onResult(Status result) { if (!result.isSuccess()) { LOGD(TAG, "stopApplication -> onResult: stopping " + "application failed"); onApplicationStopFailed(result.getStatusCode()); } else { LOGD(TAG, "stopApplication -> onResult Stopped application " + "successfully"); } } }); } /** * Registers a {@link BaseCastConsumer} interface with this class. Registered listeners will be * notified of changes to a variety of lifecycle callbacks that the interface provides. * * @see {@code BaseCastConsumerImpl} */ public final void addBaseCastConsumer(BaseCastConsumer listener) { if (listener != null) { if (mBaseCastConsumers.add(listener)) { LOGD(TAG, "Successfully added the new BaseCastConsumer listener " + listener); } } } /** * Unregisters a {@link BaseCastConsumer}. */ public final void removeBaseCastConsumer(BaseCastConsumer listener) { if (listener != null) { if (mBaseCastConsumers.remove(listener)) { LOGD(TAG, "Successfully removed the existing BaseCastConsumer listener " + listener); } } } /** * A simple method that throws an exception if there is no connectivity to the cast device. * * @throws TransientNetworkDisconnectionException If framework is still trying to recover * @throws NoConnectionException If no connectivity to the device exists */ public final void checkConnectivity() throws TransientNetworkDisconnectionException, NoConnectionException { if (!isConnected()) { if (mConnectionSuspended) { throw new TransientNetworkDisconnectionException(); } else { throw new NoConnectionException(); } } } @Override public void onFailed(int resourceId, int statusCode) { LOGD(TAG, "onFailed() was called with statusCode: " + statusCode); for (BaseCastConsumer consumer : mBaseCastConsumers) { consumer.onFailed(resourceId, statusCode); } } /** * Returns the version of this library. */ public static String getCclVersion() { return sCclVersion; } public PreferenceAccessor getPreferenceAccessor() { return mPreferenceAccessor; } /** * Clears the persisted connection information. Bitwise OR combination of the following options * should be passed as the argument: * <ul> * <li>CLEAR_SESSION</li> * <li>CLEAR_ROUTE</li> * <li>CLEAR_WIFI</li> * <li>CLEAR_MEDIA_END</li> * <li>CLEAR_ALL</li> * </ul> * Clients can form an or */ public final void clearPersistedConnectionInfo(int what) { LOGD(TAG, "clearPersistedConnectionInfo(): Clearing persisted data for " + what); if (isFlagSet(what, CLEAR_SESSION)) { mPreferenceAccessor.saveStringToPreference(PREFS_KEY_SESSION_ID, null); } if (isFlagSet(what, CLEAR_ROUTE)) { mPreferenceAccessor.saveStringToPreference(PREFS_KEY_ROUTE_ID, null); } if (isFlagSet(what, CLEAR_WIFI)) { mPreferenceAccessor.saveStringToPreference(PREFS_KEY_SSID, null); } if (isFlagSet(what, CLEAR_MEDIA_END)) { mPreferenceAccessor.saveLongToPreference(PREFS_KEY_MEDIA_END, null); } } private static boolean isFlagSet(int mask, int flag) { return (mask == CLEAR_ALL) || ((mask & flag) == flag); } protected void startReconnectionService(long mediaDurationLeft) { if (!isFeatureEnabled(CastConfiguration.FEATURE_WIFI_RECONNECT)) { return; } LOGD(TAG, "startReconnectionService() for media length lef = " + mediaDurationLeft); long endTime = SystemClock.elapsedRealtime() + mediaDurationLeft; mPreferenceAccessor.saveLongToPreference(PREFS_KEY_MEDIA_END, endTime); Context applicationContext = mContext.getApplicationContext(); Intent service = new Intent(applicationContext, ReconnectionService.class); service.setPackage(applicationContext.getPackageName()); applicationContext.startService(service); } protected void stopReconnectionService() { if (!isFeatureEnabled(CastConfiguration.FEATURE_WIFI_RECONNECT)) { return; } LOGD(TAG, "stopReconnectionService()"); Context applicationContext = mContext.getApplicationContext(); Intent service = new Intent(applicationContext, ReconnectionService.class); service.setPackage(applicationContext.getPackageName()); applicationContext.stopService(service); } /** * A Handler.Callback to receive individual messages when UI goes hidden or becomes visible. */ private class UpdateUiVisibilityHandlerCallback implements Handler.Callback { @Override public boolean handleMessage(Message msg) { onUiVisibilityChanged(msg.what == WHAT_UI_VISIBLE); return true; } } /** * Returns {@code true} if and only if there is at least one route matching the * {@link #getMediaRouteSelector()}. */ public boolean isAnyRouteAvailable() { return mMediaRouterCallback.isRouteAvailable(); } public CastConfiguration getCastConfiguration() { return mCastConfiguration; } }