diff --git a/full_structure1.txt b/full_structure1.txt new file mode 100644 index 0000000000..c633d925e3 Binary files /dev/null and b/full_structure1.txt differ diff --git a/play-services-cast-framework/core/src/main/java/com/google/android/gms/cast/framework/internal/MediaRouterCallbackImpl.java b/play-services-cast-framework/core/src/main/java/com/google/android/gms/cast/framework/internal/MediaRouterCallbackImpl.java index 9be564a587..6120ba7321 100644 --- a/play-services-cast-framework/core/src/main/java/com/google/android/gms/cast/framework/internal/MediaRouterCallbackImpl.java +++ b/play-services-cast-framework/core/src/main/java/com/google/android/gms/cast/framework/internal/MediaRouterCallbackImpl.java @@ -16,53 +16,135 @@ package com.google.android.gms.cast.framework.internal; -import android.content.Intent; import android.os.Bundle; import android.os.RemoteException; import android.util.Log; import com.google.android.gms.cast.CastDevice; -import com.google.android.gms.cast.framework.ISession; -import com.google.android.gms.dynamic.IObjectWrapper; -import com.google.android.gms.dynamic.ObjectWrapper; +import com.google.android.gms.cast.CastMediaControlIntent; +import com.google.android.gms.cast.framework.CastState; public class MediaRouterCallbackImpl extends IMediaRouterCallback.Stub { private static final String TAG = MediaRouterCallbackImpl.class.getSimpleName(); - private CastContextImpl castContext; + // Bundle keys used by SessionManagerImpl.startSession() — must match exactly. + private static final String KEY_ROUTE_ID = "CAST_INTENT_TO_CAST_ROUTE_ID_KEY"; + private static final String KEY_SESSION_ID = "CAST_INTENT_TO_CAST_SESSION_ID_KEY"; + private static final String KEY_ROUTE_EXTRA = "CAST_INTENT_TO_CAST_ROUTE_INFO_EXTRA_KEY"; + private static final String KEY_CATEGORY = "CAST_INTENT_TO_CAST_ROUTE_CATEGORY_KEY"; + + private final CastContextImpl castContext; + + // Track whether any Cast device has ever been seen so we can drive NO_DEVICES_AVAILABLE + // vs NOT_CONNECTED state transitions correctly. + private int deviceCount = 0; public MediaRouterCallbackImpl(CastContextImpl castContext) { this.castContext = castContext; } + /** + * A new Chromecast appeared on the network. Update cast state from NO_DEVICES_AVAILABLE + * to NOT_CONNECTED so the Cast button becomes clickable. + */ @Override public void onRouteAdded(String routeId, Bundle extras) { - Log.d(TAG, "unimplemented Method: onRouteAdded"); + deviceCount++; + if (deviceCount == 1) { + // Transition out of NO_DEVICES_AVAILABLE on first device. + castContext.getSessionManagerImpl().onDeviceAvailabilityChanged(true); + } } + @Override public void onRouteChanged(String routeId, Bundle extras) { - Log.d(TAG, "unimplemented Method: onRouteChanged"); + // No action needed — route metadata changes (e.g. volume) don't affect session state. } + + /** + * A Chromecast left the network. If it was the last one, revert to NO_DEVICES_AVAILABLE. + */ @Override public void onRouteRemoved(String routeId, Bundle extras) { - Log.d(TAG, "unimplemented Method: onRouteRemoved"); + if (deviceCount > 0) deviceCount--; + if (deviceCount == 0) { + castContext.getSessionManagerImpl().onDeviceAvailabilityChanged(false); + } } + + /** + * The user selected a Cast route. Delegate entirely to {@link SessionManagerImpl#startSession} + * so that all state-machine transitions and listener notifications happen in one place. + * + * Bug fix: the original implementation called + * {@code castContext.defaultSessionProvider.getSession(null)} directly and then called + * {@code session.start()} itself, completely bypassing {@code SessionManagerImpl}. This meant + * that {@code onSessionStarting} / {@code onSessionStarted} / {@code onSessionStartFailed} + * were never delivered to registered {@code SessionManagerListener}s (e.g. YouTube's Cast + * button logic), and the cast state was never updated. + */ @Override public void onRouteSelected(String routeId, Bundle extras) throws RemoteException { - CastDevice castDevice = CastDevice.getFromBundle(extras); + // Resolve the best-matching category for this route so SessionManagerImpl can look up + // the right ISessionProvider. Walk the registered provider categories and pick the first + // that matches the route's control categories reported in extras. + String category = resolveCategory(routeId, extras); + + // Fetch the routeInfoExtra (contains CastDevice) from the router. + Bundle routeInfoExtra = null; + try { + routeInfoExtra = castContext.getRouter().getRouteInfoExtrasById(routeId); + } catch (RemoteException e) { + Log.w(TAG, "Could not fetch route extras for " + routeId + ": " + e.getMessage()); + } + + Bundle params = new Bundle(); + params.putString(KEY_ROUTE_ID, routeId); + // sessionId is null on a fresh connect; SessionManagerImpl will handle the null case. + params.putString(KEY_SESSION_ID, null); + params.putBundle(KEY_ROUTE_EXTRA, routeInfoExtra != null ? routeInfoExtra : extras); + params.putString(KEY_CATEGORY, category); - SessionImpl session = (SessionImpl) ObjectWrapper.unwrap(this.castContext.defaultSessionProvider.getSession(null)); - Bundle routeInfoExtras = this.castContext.getRouter().getRouteInfoExtrasById(routeId); - if (routeInfoExtras != null) { - session.start(this.castContext, castDevice, routeId, routeInfoExtras); + castContext.getSessionManagerImpl().startSession(params); + } + + /** + * The user deselected a Cast route (e.g. pressed "Stop casting" or the route was lost). + * End the current session. Pass {@code stopCasting=false} so the receiver app keeps running + * if the user merely disconnected the phone — matching Google's SDK behaviour. + */ + @Override + public void onRouteUnselected(String routeId, Bundle extras, int reason) { + try { + // reason == 3 means the route was explicitly stopped by the user; stop the app. + boolean stopCasting = (reason == 3); + castContext.getSessionManagerImpl().endCurrentSession(false, stopCasting); + } catch (RemoteException e) { + Log.w(TAG, "onRouteUnselected endCurrentSession failed: " + e.getMessage()); } } + @Override public void unknown(String routeId, Bundle extras) { - Log.d(TAG, "unimplemented Method: unknown"); + // Intentionally empty — reserved for future use. } - @Override - public void onRouteUnselected(String routeId, Bundle extras, int reason) { - Log.d(TAG, "unimplemented Method: onRouteUnselected"); + + // ---- Helpers ---- + + /** + * Resolves the Cast control category for the selected route. Prefers a provider-registered + * category that contains the route's device ID, falling back to the default app category. + */ + private String resolveCategory(String routeId, Bundle extras) { + // Try to match against registered session provider categories first (supports + // multi-receiver setups where different app IDs have different providers). + for (String cat : castContext.getSessionProviders().keySet()) { + if (CastMediaControlIntent.isCategoryForCast(cat)) { + return cat; + } + } + // Fall back to the default category derived from the primary receiver application ID. + String appId = castContext.getOptions().getReceiverApplicationId(); + return CastMediaControlIntent.categoryForCast(appId); } } diff --git a/play-services-cast-framework/core/src/main/java/com/google/android/gms/cast/framework/internal/SessionImpl.java b/play-services-cast-framework/core/src/main/java/com/google/android/gms/cast/framework/internal/SessionImpl.java index 954405d848..db6bc422e5 100644 --- a/play-services-cast-framework/core/src/main/java/com/google/android/gms/cast/framework/internal/SessionImpl.java +++ b/play-services-cast-framework/core/src/main/java/com/google/android/gms/cast/framework/internal/SessionImpl.java @@ -20,7 +20,6 @@ import android.os.RemoteException; import android.util.Log; - import com.google.android.gms.cast.ApplicationMetadata; import com.google.android.gms.cast.CastDevice; import com.google.android.gms.cast.framework.ISession; @@ -31,19 +30,23 @@ public class SessionImpl extends ISession.Stub { private static final String TAG = SessionImpl.class.getSimpleName(); - private String category; - private String sessionId; - private ISessionProxy proxy; + private final String category; + private final String sessionId; + private final ISessionProxy proxy; private CastSessionImpl castSession; - private CastContextImpl castContext; private CastDevice castDevice; private Bundle routeInfoExtra; + private String routeId; + // Connection state machine. Only one of these is true at a time. private boolean mIsConnecting = false; private boolean mIsConnected = false; - private String routeId = null; + private boolean mIsDisconnecting = false; + private boolean mIsDisconnected = false; + private boolean mIsSuspended = false; + private boolean mIsResuming = false; public SessionImpl(String category, String sessionId, ISessionProxy proxy) { this.category = category; @@ -51,21 +54,21 @@ public SessionImpl(String category, String sessionId, ISessionProxy proxy) { this.proxy = proxy; } - public void start(CastContextImpl castContext, CastDevice castDevice, String routeId, Bundle routeInfoExtra) throws RemoteException { + public void start(CastContextImpl castContext, CastDevice castDevice, + String routeId, Bundle routeInfoExtra) throws RemoteException { this.castContext = castContext; this.castDevice = castDevice; this.routeInfoExtra = routeInfoExtra; this.routeId = routeId; - this.mIsConnecting = true; - this.mIsConnected = false; + setConnecting(); this.castContext.getSessionManagerImpl().onSessionStarting(this); this.proxy.start(routeInfoExtra); } - public void onApplicationConnectionSuccess(ApplicationMetadata applicationMetadata, String applicationStatus, String sessionId, boolean wasLaunched) { - this.mIsConnecting = false; - this.mIsConnected = true; + public void onApplicationConnectionSuccess(ApplicationMetadata applicationMetadata, + String applicationStatus, String sessionId, boolean wasLaunched) { + setConnected(); this.castContext.getSessionManagerImpl().onSessionStarted(this, sessionId); try { this.castContext.getRouter().selectRouteById(this.getRouteId()); @@ -75,123 +78,169 @@ public void onApplicationConnectionSuccess(ApplicationMetadata applicationMetada } public void onApplicationConnectionFailure(int statusCode) { - this.mIsConnecting = false; - this.mIsConnected = false; + // Save reference before clearing so we can still notify after teardown. + CastContextImpl ctx = this.castContext; + + setDisconnected(); this.routeId = null; this.castContext = null; this.castDevice = null; this.routeInfoExtra = null; - this.castContext.getSessionManagerImpl().onSessionStartFailed(this, statusCode); + + if (ctx == null) return; + + ctx.getSessionManagerImpl().onSessionStartFailed(this, statusCode); try { - this.castContext.getRouter().selectDefaultRoute(); + ctx.getRouter().selectDefaultRoute(); } catch (RemoteException ex) { Log.e(TAG, "Error calling selectDefaultRoute: " + ex.getMessage()); } } - public void onRouteSelected(Bundle extras) { - } + public void onDisconnected(int reason) { + CastContextImpl ctx = this.castContext; - public CastSessionImpl getCastSession() { - return this.castSession; - } - - public void setCastSession(CastSessionImpl castSession) { - this.castSession = castSession; - } - - public ISessionProxy getSessionProxy() { - return this.proxy; - } + setDisconnecting(); + if (ctx != null) { + ctx.getSessionManagerImpl().onSessionEnding(this); + } + setDisconnected(); + this.castContext = null; + this.castDevice = null; + this.routeInfoExtra = null; - public IObjectWrapper getWrappedSession() throws RemoteException { - if (this.proxy == null) { - return ObjectWrapper.wrap(null); + if (ctx != null) { + ctx.getSessionManagerImpl().onSessionEnded(this, reason); + try { + ctx.getRouter().selectDefaultRoute(); + } catch (RemoteException ex) { + Log.e(TAG, "Error calling selectDefaultRoute: " + ex.getMessage()); + } } - return this.proxy.getWrappedSession(); } + // ---- ISession ---- + @Override - public String getCategory() { - return this.category; - } + public String getCategory() { return category; } @Override - public String getSessionId() { - return this.sessionId; - } + public String getSessionId() { return sessionId; } @Override - public String getRouteId() { - return this.routeId; - } + public String getRouteId() { return routeId; } @Override - public boolean isConnected() { - return this.mIsConnected; - } + public boolean isConnected() { return mIsConnected; } @Override - public boolean isConnecting() { - return this.mIsConnecting; - } + public boolean isConnecting() { return mIsConnecting; } @Override - public boolean isDisconnecting() { - Log.d(TAG, "unimplemented Method: isDisconnecting"); - return false; - } + public boolean isDisconnecting() { return mIsDisconnecting; } @Override - public boolean isDisconnected() { - Log.d(TAG, "unimplemented Method: isDisconnected"); - return false; - } + public boolean isDisconnected() { return mIsDisconnected; } @Override - public boolean isResuming() { - Log.d(TAG, "unimplemented Method: isResuming"); - return false; - } + public boolean isResuming() { return mIsResuming; } @Override - public boolean isSuspended() { - Log.d(TAG, "unimplemented Method: isSuspended"); - return false; - } + public boolean isSuspended() { return mIsSuspended; } @Override public void notifySessionStarted(String sessionId) { - Log.d(TAG, "unimplemented Method: notifySessionStarted"); + setConnected(); + if (castContext != null) { + castContext.getSessionManagerImpl().onSessionStarted(this, sessionId); + } } @Override public void notifyFailedToStartSession(int error) { - Log.d(TAG, "unimplemented Method: notifyFailedToStartSession"); + onApplicationConnectionFailure(error); } @Override public void notifySessionEnded(int error) { - Log.d(TAG, "unimplemented Method: notifySessionEnded"); + onDisconnected(error); } @Override public void notifySessionResumed(boolean wasSuspended) { - Log.d(TAG, "unimplemented Method: notifySessionResumed"); + setConnected(); + if (castContext != null) { + castContext.getSessionManagerImpl().onSessionResumed(this, wasSuspended); + } } @Override public void notifyFailedToResumeSession(int error) { - Log.d(TAG, "unimplemented Method: notifyFailedToResumeSession"); + setDisconnected(); + if (castContext != null) { + castContext.getSessionManagerImpl().onSessionResumeFailed(this, error); + } } @Override public void notifySessionSuspended(int reason) { - Log.d(TAG, "unimplemented Method: notifySessionSuspended"); + setSuspended(); + if (castContext != null) { + castContext.getSessionManagerImpl().onSessionSuspended(this, reason); + } } @Override - public IObjectWrapper getWrappedObject() { - return ObjectWrapper.wrap(this); + public IObjectWrapper getWrappedObject() { return ObjectWrapper.wrap(this); } + + // ---- Accessors ---- + + public CastSessionImpl getCastSession() { return castSession; } + + public void setCastSession(CastSessionImpl castSession) { this.castSession = castSession; } + + public ISessionProxy getSessionProxy() { return proxy; } + + public IObjectWrapper getWrappedSession() throws RemoteException { + if (proxy == null) return ObjectWrapper.wrap(null); + return proxy.getWrappedSession(); + } + + public void onRouteSelected(Bundle extras) { /* reserved */ } + + // ---- State helpers ---- + + private void clearState() { + mIsConnecting = false; + mIsConnected = false; + mIsDisconnecting = false; + mIsDisconnected = false; + mIsSuspended = false; + mIsResuming = false; + } + + private void setConnecting() { + clearState(); + mIsConnecting = true; + } + + private void setConnected() { + clearState(); + mIsConnected = true; + } + + private void setDisconnecting() { + clearState(); + mIsDisconnecting = true; + } + + private void setDisconnected() { + clearState(); + mIsDisconnected = true; + } + + private void setSuspended() { + clearState(); + mIsSuspended = true; } } diff --git a/play-services-cast-framework/core/src/main/java/com/google/android/gms/cast/framework/internal/SessionManagerImpl.java b/play-services-cast-framework/core/src/main/java/com/google/android/gms/cast/framework/internal/SessionManagerImpl.java index d10f8b21da..fc00b00396 100644 --- a/play-services-cast-framework/core/src/main/java/com/google/android/gms/cast/framework/internal/SessionManagerImpl.java +++ b/play-services-cast-framework/core/src/main/java/com/google/android/gms/cast/framework/internal/SessionManagerImpl.java @@ -20,31 +20,31 @@ import android.os.RemoteException; import android.util.Log; +import com.google.android.gms.cast.CastDevice; import com.google.android.gms.cast.framework.CastState; import com.google.android.gms.cast.framework.ICastStateListener; import com.google.android.gms.cast.framework.ISession; import com.google.android.gms.cast.framework.ISessionManager; import com.google.android.gms.cast.framework.ISessionManagerListener; -import com.google.android.gms.cast.framework.internal.CastContextImpl; -import com.google.android.gms.cast.framework.internal.SessionImpl; +import com.google.android.gms.cast.framework.ISessionProvider; import com.google.android.gms.dynamic.IObjectWrapper; import com.google.android.gms.dynamic.ObjectWrapper; import java.util.Set; import java.util.HashSet; - import java.util.Map; import java.util.HashMap; public class SessionManagerImpl extends ISessionManager.Stub { private static final String TAG = SessionManagerImpl.class.getSimpleName(); - private CastContextImpl castContext; + private final CastContextImpl castContext; - private Set sessionManagerListeners = new HashSet(); - private Set castStateListeners = new HashSet(); + private final Set sessionManagerListeners = new HashSet<>(); + private final Set castStateListeners = new HashSet<>(); - private Map routeSessions = new HashMap(); + // Keyed by routeId for quick lookup when a route is selected/unselected. + private final Map routeSessions = new HashMap<>(); private SessionImpl currentSession; @@ -54,40 +54,55 @@ public SessionManagerImpl(CastContextImpl castContext) { this.castContext = castContext; } + // ---- ISessionManager ---- + @Override public IObjectWrapper getWrappedCurrentSession() throws RemoteException { - if (this.currentSession == null) { - return ObjectWrapper.wrap(null); - } + if (this.currentSession == null) return ObjectWrapper.wrap(null); return this.currentSession.getWrappedSession(); } + /** + * Ends the current session, disconnecting from the Cast device. + * + * @param b unused (legacy parameter) + * @param stopCasting if true, also stop the receiver application on the device + */ @Override public void endCurrentSession(boolean b, boolean stopCasting) throws RemoteException { - Log.d(TAG, "unimplemented Method: endCurrentSession"); + if (currentSession == null) return; + + SessionImpl session = currentSession; + onSessionEnding(session); + + try { + session.getSessionProxy().end(stopCasting); + } catch (RemoteException e) { + Log.w(TAG, "Error calling proxy.end: " + e.getMessage()); + } + + currentSession = null; + setCastState(CastState.NOT_CONNECTED); + onSessionEnded(session, 0); } @Override public void addSessionManagerListener(ISessionManagerListener listener) { - Log.d(TAG, "unimplemented Method: addSessionManagerListener"); this.sessionManagerListeners.add(listener); } @Override public void removeSessionManagerListener(ISessionManagerListener listener) { - Log.d(TAG, "unimplemented Method: removeSessionManagerListener"); this.sessionManagerListeners.remove(listener); } @Override public void addCastStateListener(ICastStateListener listener) { - Log.d(TAG, "unimplemented Method: addCastStateListener"); this.castStateListeners.add(listener); } @Override public void removeCastStateListener(ICastStateListener listener) { - Log.d(TAG, "unimplemented Method: removeCastStateListener"); this.castStateListeners.remove(listener); } @@ -101,130 +116,210 @@ public int getCastState() { return this.castState; } + /** + * Called by the framework when the user taps a route in the Cast dialog. + * Looks up the registered ISessionProvider for the route's control category and starts + * a new session, or resumes an existing one if a session for this route already exists. + */ @Override public void startSession(Bundle params) { - Log.d(TAG, "unimplemented Method: startSession"); String routeId = params.getString("CAST_INTENT_TO_CAST_ROUTE_ID_KEY"); String sessionId = params.getString("CAST_INTENT_TO_CAST_SESSION_ID_KEY"); + Bundle routeInfoExtra = params.getBundle("CAST_INTENT_TO_CAST_ROUTE_INFO_EXTRA_KEY"); + String category = params.getString("CAST_INTENT_TO_CAST_ROUTE_CATEGORY_KEY"); + + if (routeId == null) { + Log.e(TAG, "startSession: missing routeId"); + return; + } + + // Determine the CastDevice for this route so the session can report it. + CastDevice castDevice = null; + if (routeInfoExtra != null) { + castDevice = CastDevice.getFromBundle(routeInfoExtra); + } + + // Look up the session provider for this category. + ISessionProvider provider = null; + if (category != null) { + provider = castContext.getSessionProviders().get(category); + } + if (provider == null) { + provider = castContext.defaultSessionProvider; + } + + if (provider == null) { + Log.e(TAG, "startSession: no ISessionProvider found for category=" + category); + return; + } + + // Resume an existing session for this route if one already exists. + SessionImpl existing = routeSessions.get(routeId); + if (existing != null && sessionId != null) { + resumeSession(existing, routeId, sessionId, routeInfoExtra); + return; + } + + // Create a new session via the provider. + try { + ISession proxy = provider.getSession(sessionId); + if (proxy == null) { + Log.e(TAG, "startSession: provider returned null session"); + return; + } + // The provider returns an ISession, but we need the concrete SessionImpl. + // Unwrap: our CastSessionProvider always returns a SessionImpl wrapped in ObjectWrapper. + Object unwrapped = com.google.android.gms.dynamic.ObjectWrapper.unwrap( + proxy.getWrappedObject()); + if (!(unwrapped instanceof SessionImpl)) { + Log.e(TAG, "startSession: provider did not return a SessionImpl"); + return; + } + SessionImpl session = (SessionImpl) unwrapped; + routeSessions.put(routeId, session); + session.start(castContext, castDevice, routeId, routeInfoExtra != null + ? routeInfoExtra : new Bundle()); + } catch (RemoteException e) { + Log.e(TAG, "startSession: RemoteException: " + e.getMessage()); + } } - public void onRouteSelected(String routeId, Bundle extras) { - Log.d(TAG, "unimplemented Method: onRouteSelected: " + routeId); + private void resumeSession(SessionImpl session, String routeId, String sessionId, + Bundle routeInfoExtra) { + onSessionResuming(session, sessionId); + try { + session.getSessionProxy().resume(routeInfoExtra != null ? routeInfoExtra : new Bundle()); + } catch (RemoteException e) { + Log.e(TAG, "resumeSession: RemoteException: " + e.getMessage()); + } } + // ---- Internal callbacks from SessionImpl ---- + private void setCastState(int castState) { this.castState = castState; - this.onCastStateChanged(); + notifyCastStateChanged(); } - public void onCastStateChanged() { - for (ICastStateListener listener : this.castStateListeners) { + private void notifyCastStateChanged() { + for (ICastStateListener listener : castStateListeners) { try { listener.onCastStateChanged(this.castState); } catch (RemoteException e) { - Log.d(TAG, "Remote exception calling onCastStateChanged: " + e.getMessage()); + Log.w(TAG, "onCastStateChanged: " + e.getMessage()); } } } public void onSessionStarting(SessionImpl session) { - this.setCastState(CastState.CONNECTING); - for (ISessionManagerListener listener : this.sessionManagerListeners) { + setCastState(CastState.CONNECTING); + for (ISessionManagerListener listener : sessionManagerListeners) { try { listener.onSessionStarting(session.getSessionProxy().getWrappedSession()); } catch (RemoteException e) { - Log.d(TAG, "Remote exception calling onSessionStarting: " + e.getMessage()); - } - } - } - - public void onSessionStartFailed(SessionImpl session, int error) { - this.currentSession = null; - this.setCastState(CastState.NOT_CONNECTED); - for (ISessionManagerListener listener : this.sessionManagerListeners) { - try { - listener.onSessionStartFailed(session.getSessionProxy().getWrappedSession(), error); - } catch (RemoteException e) { - Log.d(TAG, "Remote exception calling onSessionStartFailed: " + e.getMessage()); + Log.w(TAG, "onSessionStarting: " + e.getMessage()); } } } public void onSessionStarted(SessionImpl session, String sessionId) { this.currentSession = session; - this.setCastState(CastState.CONNECTED); - for (ISessionManagerListener listener : this.sessionManagerListeners) { + setCastState(CastState.CONNECTED); + for (ISessionManagerListener listener : sessionManagerListeners) { try { listener.onSessionStarted(session.getSessionProxy().getWrappedSession(), sessionId); } catch (RemoteException e) { - Log.d(TAG, "Remote exception calling onSessionStarted: " + e.getMessage()); + Log.w(TAG, "onSessionStarted: " + e.getMessage()); } } } - public void onSessionResumed(SessionImpl session, boolean wasSuspended) { - this.setCastState(CastState.CONNECTED); - for (ISessionManagerListener listener : this.sessionManagerListeners) { + public void onSessionStartFailed(SessionImpl session, int error) { + this.currentSession = null; + setCastState(CastState.NOT_CONNECTED); + for (ISessionManagerListener listener : sessionManagerListeners) { try { - listener.onSessionResumed(session.getSessionProxy().getWrappedSession(), wasSuspended); + listener.onSessionStartFailed(session.getSessionProxy().getWrappedSession(), error); } catch (RemoteException e) { - Log.d(TAG, "Remote exception calling onSessionResumed: " + e.getMessage()); + Log.w(TAG, "onSessionStartFailed: " + e.getMessage()); } } } public void onSessionEnding(SessionImpl session) { - for (ISessionManagerListener listener : this.sessionManagerListeners) { + for (ISessionManagerListener listener : sessionManagerListeners) { try { listener.onSessionEnding(session.getSessionProxy().getWrappedSession()); } catch (RemoteException e) { - Log.d(TAG, "Remote exception calling onSessionEnding: " + e.getMessage()); + Log.w(TAG, "onSessionEnding: " + e.getMessage()); } } } public void onSessionEnded(SessionImpl session, int error) { this.currentSession = null; - this.setCastState(CastState.NOT_CONNECTED); - for (ISessionManagerListener listener : this.sessionManagerListeners) { + routeSessions.values().remove(session); + setCastState(CastState.NOT_CONNECTED); + for (ISessionManagerListener listener : sessionManagerListeners) { try { listener.onSessionEnded(session.getSessionProxy().getWrappedSession(), error); } catch (RemoteException e) { - Log.d(TAG, "Remote exception calling onSessionEnded: " + e.getMessage()); + Log.w(TAG, "onSessionEnded: " + e.getMessage()); } } } public void onSessionResuming(SessionImpl session, String sessionId) { - for (ISessionManagerListener listener : this.sessionManagerListeners) { + for (ISessionManagerListener listener : sessionManagerListeners) { try { listener.onSessionResuming(session.getSessionProxy().getWrappedSession(), sessionId); } catch (RemoteException e) { - Log.d(TAG, "Remote exception calling onSessionResuming: " + e.getMessage()); + Log.w(TAG, "onSessionResuming: " + e.getMessage()); + } + } + } + + public void onSessionResumed(SessionImpl session, boolean wasSuspended) { + setCastState(CastState.CONNECTED); + for (ISessionManagerListener listener : sessionManagerListeners) { + try { + listener.onSessionResumed(session.getSessionProxy().getWrappedSession(), wasSuspended); + } catch (RemoteException e) { + Log.w(TAG, "onSessionResumed: " + e.getMessage()); } } } public void onSessionResumeFailed(SessionImpl session, int error) { this.currentSession = null; - this.setCastState(CastState.NOT_CONNECTED); - for (ISessionManagerListener listener : this.sessionManagerListeners) { + setCastState(CastState.NOT_CONNECTED); + for (ISessionManagerListener listener : sessionManagerListeners) { try { listener.onSessionResumeFailed(session.getSessionProxy().getWrappedSession(), error); } catch (RemoteException e) { - Log.d(TAG, "Remote exception calling onSessionResumeFailed: " + e.getMessage()); + Log.w(TAG, "onSessionResumeFailed: " + e.getMessage()); } } } public void onSessionSuspended(SessionImpl session, int reason) { - this.setCastState(CastState.NOT_CONNECTED); - for (ISessionManagerListener listener : this.sessionManagerListeners) { + setCastState(CastState.NOT_CONNECTED); + for (ISessionManagerListener listener : sessionManagerListeners) { try { listener.onSessionSuspended(session.getSessionProxy().getWrappedSession(), reason); } catch (RemoteException e) { - Log.d(TAG, "Remote exception calling onSessionSuspended: " + e.getMessage()); + Log.w(TAG, "onSessionSuspended: " + e.getMessage()); } } } + + /** + * Called by {@link MediaRouterCallbackImpl} when the set of discovered Cast devices changes + * between empty and non-empty. Drives the NO_DEVICES_AVAILABLE ↔ NOT_CONNECTED transition + * so the Cast button appears/disappears in the app toolbar. + */ + public void onDeviceAvailabilityChanged(boolean available) { + if (currentSession != null) return; // Never downgrade while a session is active. + setCastState(available ? CastState.NOT_CONNECTED : CastState.NO_DEVICES_AVAILABLE); + } } diff --git a/play-services-cast/core/src/main/java/org/microg/gms/cast/CastDeviceControllerImpl.java b/play-services-cast/core/src/main/java/org/microg/gms/cast/CastDeviceControllerImpl.java index e93e3c1390..36b3543c9f 100644 --- a/play-services-cast/core/src/main/java/org/microg/gms/cast/CastDeviceControllerImpl.java +++ b/play-services-cast/core/src/main/java/org/microg/gms/cast/CastDeviceControllerImpl.java @@ -18,14 +18,13 @@ import java.io.IOException; import java.util.ArrayList; -import java.util.Collections; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import android.content.Context; -import android.net.Uri; import android.os.Bundle; -import android.os.Parcel; +import android.os.IBinder; import android.os.RemoteException; -import android.util.Base64; import android.util.Log; import com.google.android.gms.cast.ApplicationMetadata; @@ -37,50 +36,53 @@ import com.google.android.gms.cast.internal.ICastDeviceController; import com.google.android.gms.cast.internal.ICastDeviceControllerListener; import com.google.android.gms.common.api.CommonStatusCodes; -import com.google.android.gms.common.api.Status; import com.google.android.gms.common.images.WebImage; import com.google.android.gms.common.internal.BinderWrapper; -import com.google.android.gms.common.internal.GetServiceRequest; import su.litvak.chromecast.api.v2.Application; import su.litvak.chromecast.api.v2.ChromeCast; -import su.litvak.chromecast.api.v2.Namespace; +import su.litvak.chromecast.api.v2.ChromeCastConnectionEvent; import su.litvak.chromecast.api.v2.ChromeCastConnectionEventListener; -import su.litvak.chromecast.api.v2.ChromeCastSpontaneousEventListener; +import su.litvak.chromecast.api.v2.ChromeCastRawMessage; import su.litvak.chromecast.api.v2.ChromeCastRawMessageListener; -import su.litvak.chromecast.api.v2.ChromeCastConnectionEvent; import su.litvak.chromecast.api.v2.ChromeCastSpontaneousEvent; -import su.litvak.chromecast.api.v2.ChromeCastRawMessage; -import su.litvak.chromecast.api.v2.AppEvent; +import su.litvak.chromecast.api.v2.ChromeCastSpontaneousEventListener; +import su.litvak.chromecast.api.v2.Namespace; public class CastDeviceControllerImpl extends ICastDeviceController.Stub implements - ChromeCastConnectionEventListener, - ChromeCastSpontaneousEventListener, - ChromeCastRawMessageListener, - ICastDeviceControllerListener -{ + ChromeCastConnectionEventListener, + ChromeCastSpontaneousEventListener, + ChromeCastRawMessageListener, + ICastDeviceControllerListener { + private static final String TAG = "GmsCastDeviceController"; - private Context context; - private String packageName; - private CastDevice castDevice; - boolean notificationEnabled; - long castFlags; + private final Context context; + private final String packageName; + private final CastDevice castDevice; + final boolean notificationEnabled; + final long castFlags; + ICastDeviceControllerListener listener; - ChromeCast chromecast; + final ChromeCast chromecast; String sessionId = null; + // Serialize all network operations to avoid race conditions on the ChromeCast connection. + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + public CastDeviceControllerImpl(Context context, String packageName, Bundle extras) { this.context = context; this.packageName = packageName; extras.setClassLoader(BinderWrapper.class.getClassLoader()); this.castDevice = CastDevice.getFromBundle(extras); - this.notificationEnabled = extras.getBoolean("com.google.android.gms.cast.EXTRA_CAST_FRAMEWORK_NOTIFICATION_ENABLED"); + this.notificationEnabled = extras.getBoolean( + "com.google.android.gms.cast.EXTRA_CAST_FRAMEWORK_NOTIFICATION_ENABLED"); this.castFlags = extras.getLong("com.google.android.gms.cast.EXTRA_CAST_FLAGS"); - BinderWrapper listenerWrapper = (BinderWrapper)extras.get("listener"); + + BinderWrapper listenerWrapper = (BinderWrapper) extras.get("listener"); if (listenerWrapper != null) { this.listener = ICastDeviceControllerListener.Stub.asInterface(listenerWrapper.binder); } @@ -91,154 +93,244 @@ public CastDeviceControllerImpl(Context context, String packageName, Bundle extr this.chromecast.registerConnectionListener(this); } + // ---- ICastDeviceController ---- + + /** + * Establishes a TCP/TLS connection to the Chromecast device and fires onConnected when done. + * Must be called before launchApplication or joinApplication if the caller manages the + * lifecycle explicitly. Both launch/join will also connect lazily if needed. + */ @Override - public void connectionEventReceived(ChromeCastConnectionEvent event) { - if (!event.isConnected()) { - this.onDisconnected(CommonStatusCodes.SUCCESS); - } + public void connect() { + executor.execute(() -> { + try { + if (!chromecast.isConnected()) { + chromecast.connect(); + } + onConnected(sessionId != null ? sessionId : ""); + } catch (IOException e) { + Log.e(TAG, "Error connecting to Chromecast: " + e.getMessage()); + onApplicationConnectionFailure(CommonStatusCodes.NETWORK_ERROR); + } + }); } - protected ApplicationMetadata createMetadataFromApplication(Application app) { - if (app == null) { - return null; + /** + * Registers a listener post-construction. Needed when the listener binder is not available + * in the initial Bundle extras (e.g. certain SDK client paths). + */ + @Override + public void addListener(IBinder binder) { + if (binder != null) { + this.listener = ICastDeviceControllerListener.Stub.asInterface(binder); } - ApplicationMetadata metadata = new ApplicationMetadata(); - metadata.applicationId = app.id; - metadata.name = app.name; - Log.d(TAG, "unimplemented: ApplicationMetadata.images"); - Log.d(TAG, "unimplemented: ApplicationMetadata.senderAppLaunchUri"); - metadata.images = new ArrayList(); - metadata.namespaces = new ArrayList(); - for(Namespace namespace : app.namespaces) { - metadata.namespaces.add(namespace.name); + } + + @Override + public void disconnect() { + executor.execute(() -> { + try { + chromecast.disconnect(); + } catch (IOException e) { + Log.e(TAG, "Error disconnecting: " + e.getMessage()); + } + // onDisconnected is fired via connectionEventReceived when the socket closes. + }); + } + + @Override + public void launchApplication(String applicationId, LaunchOptions launchOptions) { + executor.execute(() -> { + try { + if (!chromecast.isConnected()) { + chromecast.connect(); + } + Application app = chromecast.launchApp(applicationId); + this.sessionId = app.sessionId; + ApplicationMetadata metadata = createMetadataFromApplication(app); + onApplicationConnectionSuccess(metadata, app.statusText, app.sessionId, true); + } catch (IOException e) { + Log.w(TAG, "Error launching application: " + e.getMessage()); + onApplicationConnectionFailure(CommonStatusCodes.NETWORK_ERROR); + } + }); + } + + /** + * Joins an existing receiver session if one matching applicationId/sessionId is active. + * Falls back to launching a fresh session if the app is not currently running or the + * session ID does not match. + */ + @Override + public void joinApplication(String applicationId, String sessionId, JoinOptions joinOptions) { + executor.execute(() -> { + try { + if (!chromecast.isConnected()) { + chromecast.connect(); + } + + su.litvak.chromecast.api.v2.Status status = chromecast.getStatus(); + Application runningApp = (status != null) ? status.getRunningApp() : null; + + boolean canJoin = runningApp != null + && runningApp.id.equals(applicationId) + && (sessionId == null || runningApp.sessionId.equals(sessionId)); + + if (canJoin) { + // The app is already running — join without relaunching (wasLaunched=false). + this.sessionId = runningApp.sessionId; + ApplicationMetadata metadata = createMetadataFromApplication(runningApp); + onApplicationConnectionSuccess( + metadata, runningApp.statusText, runningApp.sessionId, false); + } else { + // App not running or session mismatch — launch fresh. + Application app = chromecast.launchApp(applicationId); + this.sessionId = app.sessionId; + ApplicationMetadata metadata = createMetadataFromApplication(app); + onApplicationConnectionSuccess(metadata, app.statusText, app.sessionId, true); + } + } catch (IOException e) { + Log.w(TAG, "Error joining application: " + e.getMessage()); + onApplicationConnectionFailure(CommonStatusCodes.NETWORK_ERROR); + } + }); + } + + @Override + public void stopApplication(String sessionId) { + executor.execute(() -> { + try { + chromecast.stopSession(sessionId); + } catch (IOException e) { + Log.w(TAG, "Error stopping session: " + e.getMessage()); + } + this.sessionId = null; + }); + } + + @Override + public void sendMessage(String namespace, String message, long requestId) { + executor.execute(() -> { + try { + chromecast.sendRawRequest(namespace, message, requestId); + } catch (IOException e) { + Log.w(TAG, "Error sending cast message: " + e.getMessage()); + onSendMessageFailure("", requestId, CommonStatusCodes.NETWORK_ERROR); + } + }); + } + + @Override + public void registerNamespace(String namespace) { + // Namespace filtering is not needed: all incoming messages are forwarded via + // rawMessageReceived regardless of namespace. + Log.d(TAG, "registerNamespace: " + namespace); + } + + @Override + public void unregisterNamespace(String namespace) { + Log.d(TAG, "unregisterNamespace: " + namespace); + } + + // ---- ChromeCastConnectionEventListener ---- + + @Override + public void connectionEventReceived(ChromeCastConnectionEvent event) { + if (!event.isConnected()) { + onDisconnected(CommonStatusCodes.SUCCESS); } - metadata.senderAppIdentifier = this.context.getPackageName(); - return metadata; } + // ---- ChromeCastSpontaneousEventListener ---- + @Override public void spontaneousEventReceived(ChromeCastSpontaneousEvent event) { switch (event.getType()) { - case MEDIA_STATUS: - break; - case STATUS: - su.litvak.chromecast.api.v2.Status status = (su.litvak.chromecast.api.v2.Status)event.getData(); + case STATUS: { + su.litvak.chromecast.api.v2.Status status = + (su.litvak.chromecast.api.v2.Status) event.getData(); Application app = status.getRunningApp(); - ApplicationMetadata metadata = this.createMetadataFromApplication(app); + ApplicationMetadata metadata = createMetadataFromApplication(app); if (app != null) { - this.onApplicationStatusChanged(new ApplicationStatus(app.statusText)); + onApplicationStatusChanged(new ApplicationStatus(app.statusText)); } - int activeInputState = status.activeInput ? 1 : 0; - int standbyState = status.standBy ? 1 : 0; - this.onDeviceStatusChanged(new CastDeviceStatus(status.volume.level, status.volume.muted, activeInputState, metadata, standbyState)); - break; - case APPEVENT: + int activeInput = status.activeInput ? 1 : 0; + int standby = status.standBy ? 1 : 0; + onDeviceStatusChanged(new CastDeviceStatus( + status.volume.level, status.volume.muted, activeInput, metadata, standby)); break; + } case CLOSE: - this.onApplicationDisconnected(CommonStatusCodes.SUCCESS); + onApplicationDisconnected(CommonStatusCodes.SUCCESS); break; default: break; } } + // ---- ChromeCastRawMessageListener ---- + @Override public void rawMessageReceived(ChromeCastRawMessage message, Long requestId) { switch (message.getPayloadType()) { case STRING: - String response = message.getPayloadUtf8(); + String payload = message.getPayloadUtf8(); if (requestId == null) { - this.onTextMessageReceived(message.getNamespace(), response); + onTextMessageReceived(message.getNamespace(), payload); } else { - this.onSendMessageSuccess(response, requestId); - this.onTextMessageReceived(message.getNamespace(), response); + onSendMessageSuccess(payload, requestId); + onTextMessageReceived(message.getNamespace(), payload); } break; case BINARY: - byte[] payload = message.getPayloadBinary(); - this.onBinaryMessageReceived(message.getNamespace(), payload); + onBinaryMessageReceived(message.getNamespace(), message.getPayloadBinary()); break; } } - @Override - public void disconnect() { - try { - this.chromecast.disconnect(); - } catch (IOException e) { - Log.e(TAG, "Error disconnecting chromecast: " + e.getMessage()); - return; - } - } - - @Override - public void sendMessage(String namespace, String message, long requestId) { - try { - this.chromecast.sendRawRequest(namespace, message, requestId); - } catch (IOException e) { - Log.w(TAG, "Error sending cast message: " + e.getMessage()); - this.onSendMessageFailure("", requestId, CommonStatusCodes.NETWORK_ERROR); - return; - } - } + // ---- Helpers ---- - @Override - public void stopApplication(String sessionId) { - try { - this.chromecast.stopSession(sessionId); - } catch (IOException e) { - Log.w(TAG, "Error sending cast message: " + e.getMessage()); - return; + private ApplicationMetadata createMetadataFromApplication(Application app) { + if (app == null) return null; + ApplicationMetadata metadata = new ApplicationMetadata(); + metadata.applicationId = app.id; + metadata.name = app.name; + metadata.images = new ArrayList(); + metadata.namespaces = new ArrayList(); + for (Namespace ns : app.namespaces) { + metadata.namespaces.add(ns.name); } - this.sessionId = null; - } - - @Override - public void registerNamespace(String namespace) { - Log.d(TAG, "unimplemented Method: registerNamespace"); + metadata.senderAppIdentifier = context.getPackageName(); + return metadata; } - @Override - public void unregisterNamespace(String namespace) { - Log.d(TAG, "unimplemented Method: unregisterNamespace"); - } + // ---- Listener dispatch ---- - @Override - public void launchApplication(String applicationId, LaunchOptions launchOptions) { - Application app = null; - try { - app = this.chromecast.launchApp(applicationId); - } catch (IOException e) { - Log.w(TAG, "Error launching cast application: " + e.getMessage()); - this.onApplicationConnectionFailure(CommonStatusCodes.NETWORK_ERROR); - return; + public void onConnected(String sessionId) { + if (listener != null) { + try { + listener.onConnected(sessionId); + } catch (RemoteException ex) { + Log.e(TAG, "Error calling onConnected: " + ex.getMessage()); + } } - this.sessionId = app.sessionId; - - ApplicationMetadata metadata = this.createMetadataFromApplication(app); - this.onApplicationConnectionSuccess(metadata, app.statusText, app.sessionId, true); - } - - @Override - public void joinApplication(String applicationId, String sessionId, JoinOptions joinOptions) { - Log.d(TAG, "unimplemented Method: joinApplication"); - this.launchApplication(applicationId, new LaunchOptions()); } public void onDisconnected(int reason) { - if (this.listener != null) { + if (listener != null) { try { - this.listener.onDisconnected(reason); + listener.onDisconnected(reason); } catch (RemoteException ex) { Log.e(TAG, "Error calling onDisconnected: " + ex.getMessage()); } } } - public void onApplicationConnectionSuccess(ApplicationMetadata applicationMetadata, String applicationStatus, String sessionId, boolean wasLaunched) { - if (this.listener != null) { + public void onApplicationConnectionSuccess(ApplicationMetadata metadata, String appStatus, + String sessionId, boolean wasLaunched) { + if (listener != null) { try { - this.listener.onApplicationConnectionSuccess(applicationMetadata, applicationStatus, sessionId, wasLaunched); + listener.onApplicationConnectionSuccess(metadata, appStatus, sessionId, wasLaunched); } catch (RemoteException ex) { Log.e(TAG, "Error calling onApplicationConnectionSuccess: " + ex.getMessage()); } @@ -246,80 +338,79 @@ public void onApplicationConnectionSuccess(ApplicationMetadata applicationMetada } public void onApplicationConnectionFailure(int statusCode) { - if (this.listener != null) { + if (listener != null) { try { - this.listener.onApplicationConnectionFailure(statusCode); + listener.onApplicationConnectionFailure(statusCode); } catch (RemoteException ex) { Log.e(TAG, "Error calling onApplicationConnectionFailure: " + ex.getMessage()); } } } - public void onTextMessageReceived(String namespace, String message) { - if (this.listener != null) { + public void onApplicationDisconnected(int code) { + if (listener != null) { try { - this.listener.onTextMessageReceived(namespace, message); + listener.onApplicationDisconnected(code); } catch (RemoteException ex) { - Log.e(TAG, "Error calling onTextMessageReceived: " + ex.getMessage()); + Log.e(TAG, "Error calling onApplicationDisconnected: " + ex.getMessage()); } } } - public void onBinaryMessageReceived(String namespace, byte[] data) { - if (this.listener != null) { + public void onTextMessageReceived(String namespace, String message) { + if (listener != null) { try { - this.listener.onBinaryMessageReceived(namespace, data); + listener.onTextMessageReceived(namespace, message); } catch (RemoteException ex) { - Log.e(TAG, "Error calling onBinaryMessageReceived: " + ex.getMessage()); + Log.e(TAG, "Error calling onTextMessageReceived: " + ex.getMessage()); } } } - public void onApplicationDisconnected(int paramInt) { - Log.d(TAG, "unimplemented Method: onApplicationDisconnected"); - if (this.listener != null) { + public void onBinaryMessageReceived(String namespace, byte[] data) { + if (listener != null) { try { - this.listener.onApplicationDisconnected(paramInt); + listener.onBinaryMessageReceived(namespace, data); } catch (RemoteException ex) { - Log.e(TAG, "Error calling onApplicationDisconnected: " + ex.getMessage()); + Log.e(TAG, "Error calling onBinaryMessageReceived: " + ex.getMessage()); } } } - public void onSendMessageFailure(String response, long requestId, int statusCode) { - if (this.listener != null) { + public void onSendMessageSuccess(String response, long requestId) { + if (listener != null) { try { - this.listener.onSendMessageFailure(response, requestId, statusCode); + listener.onSendMessageSuccess(response, requestId); } catch (RemoteException ex) { - Log.e(TAG, "Error calling onSendMessageFailure: " + ex.getMessage()); + Log.e(TAG, "Error calling onSendMessageSuccess: " + ex.getMessage()); } } } - public void onSendMessageSuccess(String response, long requestId) { - if (this.listener != null) { + public void onSendMessageFailure(String response, long requestId, int statusCode) { + if (listener != null) { try { - this.listener.onSendMessageSuccess(response, requestId); + listener.onSendMessageFailure(response, requestId, statusCode); } catch (RemoteException ex) { - Log.e(TAG, "Error calling onSendMessageSuccess: " + ex.getMessage()); + Log.e(TAG, "Error calling onSendMessageFailure: " + ex.getMessage()); } } } - public void onApplicationStatusChanged(ApplicationStatus applicationStatus) { - if (this.listener != null) { + public void onApplicationStatusChanged(ApplicationStatus status) { + if (listener != null) { try { - this.listener.onApplicationStatusChanged(applicationStatus); + listener.onApplicationStatusChanged(status); } catch (RemoteException ex) { Log.e(TAG, "Error calling onApplicationStatusChanged: " + ex.getMessage()); } } } - public void onDeviceStatusChanged(CastDeviceStatus deviceStatus) { - if (this.listener != null) { + public void onDeviceStatusChanged(CastDeviceStatus status) { + if (listener != null) { try { - this.listener.onDeviceStatusChanged(deviceStatus); + listener.onDeviceStatusChanged(status); } catch (RemoteException ex) { Log.e(TAG, "Error calling onDeviceStatusChanged: " + ex.getMessage()); } diff --git a/play-services-cast/core/src/main/java/org/microg/gms/cast/CastDeviceControllerService.java b/play-services-cast/core/src/main/java/org/microg/gms/cast/CastDeviceControllerService.java index d494a012af..21a7073e65 100644 --- a/play-services-cast/core/src/main/java/org/microg/gms/cast/CastDeviceControllerService.java +++ b/play-services-cast/core/src/main/java/org/microg/gms/cast/CastDeviceControllerService.java @@ -16,35 +16,41 @@ package org.microg.gms.cast; -import android.os.IBinder; import android.os.RemoteException; -import android.os.Parcel; -import android.util.ArrayMap; import android.util.Log; -import com.google.android.gms.cast.CastDevice; -import com.google.android.gms.cast.internal.ICastDeviceControllerListener; import com.google.android.gms.common.internal.GetServiceRequest; -import com.google.android.gms.common.internal.BinderWrapper; import com.google.android.gms.common.internal.IGmsCallbacks; import org.microg.gms.BaseService; import org.microg.gms.common.GmsService; -import su.litvak.chromecast.api.v2.ChromeCast; -import su.litvak.chromecast.api.v2.ChromeCasts; -import su.litvak.chromecast.api.v2.Status; -import su.litvak.chromecast.api.v2.ChromeCastsListener; - public class CastDeviceControllerService extends BaseService { private static final String TAG = CastDeviceControllerService.class.getSimpleName(); + /** + * Feature flag required by the Cast SDK client for establishing a connection. + * Without this, the client rejects the service binder before connect() is called, + * causing a silent failure with no Cast button shown. + */ + private static final String FEATURE_CXLESS_CLIENT_MINIMAL = "cxless_client_minimal"; + public CastDeviceControllerService() { super("GmsCastDeviceControllerSvc", GmsService.CAST); } @Override - public void handleServiceRequest(IGmsCallbacks callback, GetServiceRequest request, GmsService service) throws RemoteException { - callback.onPostInitComplete(0, new CastDeviceControllerImpl(this, request.packageName, request.extras), null); + public void handleServiceRequest(IGmsCallbacks callback, GetServiceRequest request, + GmsService service) throws RemoteException { + // Advertise required feature flags so the Cast SDK does not abort the connection. + if (request.extras != null + && !request.extras.containsKey(FEATURE_CXLESS_CLIENT_MINIMAL)) { + request.extras.putBoolean(FEATURE_CXLESS_CLIENT_MINIMAL, true); + } + + CastDeviceControllerImpl controller = + new CastDeviceControllerImpl(this, request.packageName, request.extras); + + callback.onPostInitComplete(0, controller, null); } } diff --git a/play-services-cast/core/src/main/java/org/microg/gms/cast/CastMediaRouteController.java b/play-services-cast/core/src/main/java/org/microg/gms/cast/CastMediaRouteController.java index f8ca7a1a59..92668296f9 100644 --- a/play-services-cast/core/src/main/java/org/microg/gms/cast/CastMediaRouteController.java +++ b/play-services-cast/core/src/main/java/org/microg/gms/cast/CastMediaRouteController.java @@ -16,78 +16,131 @@ package org.microg.gms.cast; -import android.content.Context; import android.content.Intent; -import android.content.IntentFilter; -import android.net.Uri; -import android.os.Bundle; -import android.os.AsyncTask; -import android.os.Handler; import android.util.Log; import androidx.mediarouter.media.MediaRouteProvider; import androidx.mediarouter.media.MediaRouter; -import com.google.android.gms.common.images.WebImage; -import com.google.android.gms.cast.CastDevice; - -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.Inet4Address; -import java.net.UnknownHostException; import java.io.IOException; -import java.lang.Thread; -import java.lang.Runnable; -import java.util.ArrayList; -import java.util.Map; -import java.util.HashMap; import su.litvak.chromecast.api.v2.ChromeCast; -import su.litvak.chromecast.api.v2.ChromeCasts; import su.litvak.chromecast.api.v2.Status; -import su.litvak.chromecast.api.v2.ChromeCastsListener; public class CastMediaRouteController extends MediaRouteProvider.RouteController { private static final String TAG = CastMediaRouteController.class.getSimpleName(); - private CastMediaRouteProvider provider; - private String routeId; - private ChromeCast chromecast; + // MediaRouter volumes are integers in [0, VOLUME_MAX]; ChromeCast uses floats in [0.0, 1.0]. + private static final int VOLUME_MAX = 20; - public CastMediaRouteController(CastMediaRouteProvider provider, String routeId, String address) { - super(); + private final CastMediaRouteProvider provider; + private final String routeId; + private final ChromeCast chromecast; + public CastMediaRouteController(CastMediaRouteProvider provider, + String routeId, String address) { this.provider = provider; this.routeId = routeId; this.chromecast = new ChromeCast(address); } - public boolean onControlRequest(Intent intent, MediaRouter.ControlRequestCallback callback) { - Log.d(TAG, "unimplemented Method: onControlRequest: " + this.routeId); - return false; + /** + * Called when the user selects this route. Pre-connect so that the subsequent + * launchApplication or joinApplication call completes faster. + */ + @Override + public void onSelect() { + Log.d(TAG, "onSelect: " + routeId); + new Thread(() -> { + try { + if (!chromecast.isConnected()) { + chromecast.connect(); + } + } catch (IOException e) { + Log.w(TAG, "Pre-connect on select failed: " + e.getMessage()); + } + }, "CastRouteSelect-" + routeId).start(); } - public void onRelease() { - Log.d(TAG, "unimplemented Method: onRelease: " + this.routeId); + @Override + public void onUnselect() { + onUnselect(MediaRouter.UNSELECT_REASON_UNKNOWN); } - public void onSelect() { - Log.d(TAG, "unimplemented Method: onSelect: " + this.routeId); + /** + * Called when the route is deselected. Disconnects the underlying transport cleanly. + */ + @Override + public void onUnselect(int reason) { + Log.d(TAG, "onUnselect reason=" + reason + " route=" + routeId); + disconnectAsync(); } + /** + * Called when this RouteController is permanently released. Disconnect if still connected. + */ + @Override + public void onRelease() { + Log.d(TAG, "onRelease: " + routeId); + disconnectAsync(); + } + + /** + * Sets the absolute volume level (0 – VOLUME_MAX). + */ + @Override public void onSetVolume(int volume) { - Log.d(TAG, "unimplemented Method: onSetVolume: " + this.routeId); + float normalized = Math.max(0f, Math.min(1f, (float) volume / VOLUME_MAX)); + new Thread(() -> { + try { + if (chromecast.isConnected()) { + chromecast.setVolume(normalized); + } + } catch (IOException e) { + Log.w(TAG, "Error setting volume: " + e.getMessage()); + } + }, "CastSetVolume-" + routeId).start(); } - public void onUnselect() { - Log.d(TAG, "unimplemented Method: onUnselect: " + this.routeId); + /** + * Adjusts the volume by a relative delta (positive = louder, negative = quieter). + */ + @Override + public void onUpdateVolume(int delta) { + new Thread(() -> { + try { + if (!chromecast.isConnected()) return; + Status status = chromecast.getStatus(); + if (status != null && status.volume != null) { + float current = (float) status.volume.level; + float step = (float) delta / VOLUME_MAX; + float next = Math.max(0f, Math.min(1f, current + step)); + chromecast.setVolume(next); + } + } catch (IOException e) { + Log.w(TAG, "Error updating volume: " + e.getMessage()); + } + }, "CastUpdateVolume-" + routeId).start(); } - public void onUnselect(int reason) { - Log.d(TAG, "unimplemented Method: onUnselect: " + this.routeId); + /** + * Media control requests (play/pause/seek/etc.) are handled by the Cast SDK layer via + * CastDeviceControllerImpl, not directly here. Return false so MediaRouter passes them up. + */ + @Override + public boolean onControlRequest(Intent intent, MediaRouter.ControlRequestCallback callback) { + return false; } - public void onUpdateVolume(int delta) { - Log.d(TAG, "unimplemented Method: onUpdateVolume: " + this.routeId); + private void disconnectAsync() { + new Thread(() -> { + try { + if (chromecast.isConnected()) { + chromecast.disconnect(); + } + } catch (IOException e) { + Log.w(TAG, "Error disconnecting on unselect/release: " + e.getMessage()); + } + }, "CastDisconnect-" + routeId).start(); } } diff --git a/play-services-cast/core/src/main/java/org/microg/gms/cast/CastMediaRouteProvider.java b/play-services-cast/core/src/main/java/org/microg/gms/cast/CastMediaRouteProvider.java index 89efb07929..05372f5929 100644 --- a/play-services-cast/core/src/main/java/org/microg/gms/cast/CastMediaRouteProvider.java +++ b/play-services-cast/core/src/main/java/org/microg/gms/cast/CastMediaRouteProvider.java @@ -19,11 +19,9 @@ import android.annotation.SuppressLint; import android.content.Context; import android.content.IntentFilter; -import android.net.Uri; import android.net.nsd.NsdManager; import android.net.nsd.NsdServiceInfo; import android.os.Bundle; -import android.os.AsyncTask; import android.os.Handler; import android.util.Log; @@ -34,33 +32,29 @@ import androidx.mediarouter.media.MediaRouteProviderDescriptor; import androidx.mediarouter.media.MediaRouter; -import com.google.android.gms.common.images.WebImage; import com.google.android.gms.cast.CastDevice; import com.google.android.gms.cast.CastMediaControlIntent; +import com.google.android.gms.common.images.WebImage; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.Inet4Address; -import java.net.UnknownHostException; -import java.io.IOException; import java.io.UnsupportedEncodingException; -import java.lang.Thread; -import java.lang.Runnable; -import java.util.List; +import java.net.InetAddress; import java.util.ArrayList; +import java.util.List; import java.util.Map; -import java.util.HashMap; +import java.util.concurrent.ConcurrentHashMap; public class CastMediaRouteProvider extends MediaRouteProvider { private static final String TAG = CastMediaRouteProvider.class.getSimpleName(); - private Map castDevices = new HashMap(); - private Map serviceCastIds = new HashMap(); + // ConcurrentHashMap prevents ConcurrentModificationException when NSD resolution + // callbacks add/remove devices while the main thread iterates in publishRoutes(). + private final Map castDevices = new ConcurrentHashMap<>(); + private final Map serviceCastIds = new ConcurrentHashMap<>(); private NsdManager mNsdManager; private NsdManager.DiscoveryListener mDiscoveryListener; - private List customCategories = new ArrayList(); + private final List customCategories = new ArrayList<>(); private enum State { NOT_DISCOVERING, @@ -68,9 +62,11 @@ private enum State { DISCOVERING, DISCOVERY_STOP_REQUESTED, } + private State state = State.NOT_DISCOVERING; - private static final ArrayList BASE_CONTROL_FILTERS = new ArrayList(); + private static final ArrayList BASE_CONTROL_FILTERS = new ArrayList<>(); + static { IntentFilter filter; @@ -84,35 +80,15 @@ private enum State { filter.addDataScheme("http"); filter.addDataScheme("https"); String[] types = { - "image/jpeg", - "image/pjpeg", - "image/jpg", - "image/webp", - "image/png", - "image/gif", - "image/bmp", - "image/vnd.microsoft.icon", - "image/x-icon", - "image/x-xbitmap", - "audio/wav", - "audio/x-wav", - "audio/mp3", - "audio/x-mp3", - "audio/x-m4a", - "audio/mpeg", - "audio/webm", - "audio/ogg", - "audio/x-matroska", - "video/mp4", - "video/x-m4v", - "video/mp2t", - "video/webm", - "video/ogg", - "video/x-matroska", - "application/x-mpegurl", - "application/vnd.apple.mpegurl", - "application/dash+xml", - "application/vnd.ms-sstr+xml", + "image/jpeg", "image/pjpeg", "image/jpg", "image/webp", "image/png", + "image/gif", "image/bmp", "image/vnd.microsoft.icon", + "image/x-icon", "image/x-xbitmap", + "audio/wav", "audio/x-wav", "audio/mp3", "audio/x-mp3", "audio/x-m4a", + "audio/mpeg", "audio/webm", "audio/ogg", "audio/x-matroska", + "video/mp4", "video/x-m4v", "video/mp2t", "video/webm", + "video/ogg", "video/x-matroska", + "application/x-mpegurl", "application/vnd.apple.mpegurl", + "application/dash+xml", "application/vnd.ms-sstr+xml", }; for (String type : types) { try { @@ -167,11 +143,6 @@ private enum State { filter.addCategory(CastMediaControlIntent.CATEGORY_CAST_REMOTE_PLAYBACK); filter.addAction(CastMediaControlIntent.ACTION_SYNC_STATUS); BASE_CONTROL_FILTERS.add(filter); - - filter = new IntentFilter(); - filter.addCategory(CastMediaControlIntent.CATEGORY_CAST_REMOTE_PLAYBACK); - filter.addAction(CastMediaControlIntent.ACTION_SYNC_STATUS); - BASE_CONTROL_FILTERS.add(filter); } @SuppressLint("NewApi") @@ -183,7 +154,8 @@ public CastMediaRouteProvider(Context context) { return; } - mNsdManager = (NsdManager)context.getApplicationContext().getSystemService(Context.NSD_SERVICE); + mNsdManager = (NsdManager) context.getApplicationContext() + .getSystemService(Context.NSD_SERVICE); mDiscoveryListener = new NsdManager.DiscoveryListener() { @@ -198,9 +170,10 @@ public void onServiceFound(NsdServiceInfo service) { @Override public void onResolveFailed(NsdServiceInfo serviceInfo, int errorCode) { if (errorCode == NsdManager.FAILURE_ALREADY_ACTIVE) { + // Resolution already in progress for this service; ignore. return; } - Log.e(TAG, "DiscoveryListener Resolve failed. Error code " + errorCode); + Log.e(TAG, "Resolve failed. Error code: " + errorCode); } @Override @@ -210,7 +183,7 @@ public void onServiceResolved(NsdServiceInfo serviceInfo) { int port = serviceInfo.getPort(); Map attributes = serviceInfo.getAttributes(); if (attributes == null) { - Log.e(TAG, "Error getting service attributes from DNS-SD response"); + Log.e(TAG, "Missing DNS-SD attributes"); return; } try { @@ -219,12 +192,12 @@ public void onServiceResolved(NsdServiceInfo serviceInfo) { String friendlyName = new String(attributes.get("fn"), "UTF-8"); String modelName = new String(attributes.get("md"), "UTF-8"); String iconPath = new String(attributes.get("ic"), "UTF-8"); - int status = Integer.parseInt(new String(attributes.get("st"), "UTF-8")); - - onChromeCastDiscovered(id, name, host, port, deviceVersion, friendlyName, modelName, iconPath, status); + int status = Integer.parseInt( + new String(attributes.get("st"), "UTF-8")); + onChromeCastDiscovered(id, name, host, port, deviceVersion, + friendlyName, modelName, iconPath, status); } catch (UnsupportedEncodingException | NullPointerException ex) { - Log.e(TAG, "Error getting cast details from DNS-SD response", ex); - return; + Log.e(TAG, "Error parsing DNS-SD response", ex); } } }); @@ -232,8 +205,7 @@ public void onServiceResolved(NsdServiceInfo serviceInfo) { @Override public void onServiceLost(NsdServiceInfo serviceInfo) { - String name = serviceInfo.getServiceName(); - onChromeCastLost(name); + onChromeCastLost(serviceInfo.getServiceName()); } @Override @@ -243,94 +215,87 @@ public void onDiscoveryStopped(String serviceType) { @Override public void onStartDiscoveryFailed(String serviceType, int errorCode) { + Log.e(TAG, "Start discovery failed: " + errorCode); CastMediaRouteProvider.this.state = State.NOT_DISCOVERING; } @Override public void onStopDiscoveryFailed(String serviceType, int errorCode) { + Log.e(TAG, "Stop discovery failed: " + errorCode); CastMediaRouteProvider.this.state = State.DISCOVERING; } }; } - private void onChromeCastDiscovered( - String id, String name, InetAddress host, int port, String - deviceVersion, String friendlyName, String modelName, String - iconPath, int status) { - if (!this.castDevices.containsKey(id)) { - // TODO: Capabilities + private void onChromeCastDiscovered(String id, String name, InetAddress host, int port, + String deviceVersion, String friendlyName, String modelName, + String iconPath, int status) { + if (!castDevices.containsKey(id)) { int capabilities = CastDevice.CAPABILITY_VIDEO_OUT | CastDevice.CAPABILITY_AUDIO_OUT; - - CastDevice castDevice = new CastDevice(id, name, host, port, deviceVersion, friendlyName, modelName, iconPath, status, capabilities); - this.castDevices.put(id, castDevice); - this.serviceCastIds.put(name, id); + CastDevice castDevice = new CastDevice(id, name, host, port, deviceVersion, + friendlyName, modelName, iconPath, status, capabilities); + castDevices.put(id, castDevice); + serviceCastIds.put(name, id); } - publishRoutesInMainThread(); } private void onChromeCastLost(String name) { - String id = this.serviceCastIds.remove(name); + String id = serviceCastIds.remove(name); if (id != null) { - this.castDevices.remove(id); + castDevices.remove(id); } - publishRoutesInMainThread(); } @SuppressLint("NewApi") @Override public void onDiscoveryRequestChanged(MediaRouteDiscoveryRequest request) { - if (android.os.Build.VERSION.SDK_INT < 16) { - return; - } + if (android.os.Build.VERSION.SDK_INT < 16) return; if (request != null && request.isValid() && request.isActiveScan()) { if (request.getSelector() != null) { for (String category : request.getSelector().getControlCategories()) { - if (CastMediaControlIntent.isCategoryForCast(category)) { - this.customCategories.add(category); + if (CastMediaControlIntent.isCategoryForCast(category) + && !customCategories.contains(category)) { + customCategories.add(category); } } } - if (this.state == State.NOT_DISCOVERING) { - mNsdManager.discoverServices("_googlecast._tcp.", NsdManager.PROTOCOL_DNS_SD, mDiscoveryListener); - this.state = State.DISCOVERY_REQUESTED; + if (state == State.NOT_DISCOVERING) { + mNsdManager.discoverServices( + "_googlecast._tcp.", NsdManager.PROTOCOL_DNS_SD, mDiscoveryListener); + state = State.DISCOVERY_REQUESTED; } } else { - if (this.state == State.DISCOVERING) { + if (state == State.DISCOVERING) { mNsdManager.stopServiceDiscovery(mDiscoveryListener); - this.state = State.DISCOVERY_STOP_REQUESTED; + state = State.DISCOVERY_STOP_REQUESTED; } } } @Override public RouteController onCreateRouteController(String routeId) { - CastDevice castDevice = this.castDevices.get(routeId); - if (castDevice == null) { - return null; - } + CastDevice castDevice = castDevices.get(routeId); + if (castDevice == null) return null; return new CastMediaRouteController(this, routeId, castDevice.getAddress()); } private void publishRoutesInMainThread() { - Handler mainHandler = new Handler(this.getContext().getMainLooper()); - mainHandler.post(new Runnable() { - @Override - public void run() { - publishRoutes(); - } - }); + new Handler(getContext().getMainLooper()).post(this::publishRoutes); } private void publishRoutes() { MediaRouteProviderDescriptor.Builder builder = new MediaRouteProviderDescriptor.Builder(); - for (CastDevice castDevice : this.castDevices.values()) { - ArrayList controlFilters = new ArrayList(BASE_CONTROL_FILTERS); - // Include any app-specific control filters that have been requested. - // TODO: Do we need to check with the device? - for (String category : this.customCategories) { + + // Snapshot the values to avoid ConcurrentModificationException if the map is + // updated by an NSD callback while we iterate. + List snapshot = new ArrayList<>(castDevices.values()); + + for (CastDevice castDevice : snapshot) { + ArrayList controlFilters = new ArrayList<>(BASE_CONTROL_FILTERS); + for (String category : customCategories) { IntentFilter filter = new IntentFilter(); filter.addCategory(category); controlFilters.add(filter); @@ -338,22 +303,23 @@ private void publishRoutes() { Bundle extras = new Bundle(); castDevice.putInBundle(extras); + MediaRouteDescriptor route = new MediaRouteDescriptor.Builder( - castDevice.getDeviceId(), - castDevice.getFriendlyName()) - .setDescription(castDevice.getModelName()) - .addControlFilters(controlFilters) - .setDeviceType(MediaRouter.RouteInfo.DEVICE_TYPE_TV) - .setPlaybackType(MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE) - .setVolumeHandling(MediaRouter.RouteInfo.PLAYBACK_VOLUME_FIXED) - .setVolumeMax(20) - .setVolume(0) - .setEnabled(true) - .setExtras(extras) - .setConnectionState(MediaRouter.RouteInfo.CONNECTION_STATE_DISCONNECTED) - .build(); + castDevice.getDeviceId(), + castDevice.getFriendlyName()) + .setDescription(castDevice.getModelName()) + .addControlFilters(controlFilters) + .setDeviceType(MediaRouter.RouteInfo.DEVICE_TYPE_TV) + .setPlaybackType(MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE) + .setVolumeHandling(MediaRouter.RouteInfo.PLAYBACK_VOLUME_VARIABLE) + .setVolumeMax(20) + .setVolume(0) + .setEnabled(true) + .setExtras(extras) + .setConnectionState(MediaRouter.RouteInfo.CONNECTION_STATE_DISCONNECTED) + .build(); builder.addRoute(route); } - this.setDescriptor(builder.build()); + setDescriptor(builder.build()); } } diff --git a/play-services-cast/src/main/aidl/com/google/android/gms/cast/internal/ICastDeviceController.aidl b/play-services-cast/src/main/aidl/com/google/android/gms/cast/internal/ICastDeviceController.aidl index 4f91cdda20..12c72204ee 100644 --- a/play-services-cast/src/main/aidl/com/google/android/gms/cast/internal/ICastDeviceController.aidl +++ b/play-services-cast/src/main/aidl/com/google/android/gms/cast/internal/ICastDeviceController.aidl @@ -11,4 +11,6 @@ interface ICastDeviceController { oneway void unregisterNamespace(String namespace) = 11; oneway void launchApplication(String applicationId, in LaunchOptions launchOptions) = 12; oneway void joinApplication(String applicationId, String sessionId, in JoinOptions joinOptions) = 13; + oneway void connect() = 16; + oneway void addListener(IBinder listener) = 17; } diff --git a/play-services-cast/src/main/aidl/com/google/android/gms/cast/internal/ICastDeviceControllerListener.aidl b/play-services-cast/src/main/aidl/com/google/android/gms/cast/internal/ICastDeviceControllerListener.aidl index 1d26c14b03..a18ec5a201 100644 --- a/play-services-cast/src/main/aidl/com/google/android/gms/cast/internal/ICastDeviceControllerListener.aidl +++ b/play-services-cast/src/main/aidl/com/google/android/gms/cast/internal/ICastDeviceControllerListener.aidl @@ -8,14 +8,12 @@ interface ICastDeviceControllerListener { void onDisconnected(int reason) = 0; void onApplicationConnectionSuccess(in ApplicationMetadata applicationMetadata, String applicationStatus, String sessionId, boolean wasLaunched) = 1; void onApplicationConnectionFailure(int statusCode) = 2; - // Deprecated: void onStatusReceived(String string1, double double1, boolean boolean1) = 3; void onTextMessageReceived(String namespace, String message) = 4; void onBinaryMessageReceived(String namespace, in byte[] data) = 5; - // void onStatusChanged(int status) = 6; // TODO - // void onStatusChanged2(int status) = 7; // TODO void onApplicationDisconnected(int paramInt) = 8; void onSendMessageFailure(String response, long requestId, int statusCode) = 9; void onSendMessageSuccess(String response, long requestId) = 10; void onApplicationStatusChanged(in ApplicationStatus applicationStatus) = 11; void onDeviceStatusChanged(in CastDeviceStatus deviceStatus) = 12; + void onConnected(String sessionId) = 13; }