diff --git a/play-services-cast-framework/core/src/main/java/com/google/android/gms/cast/framework/internal/CastContextImpl.java b/play-services-cast-framework/core/src/main/java/com/google/android/gms/cast/framework/internal/CastContextImpl.java index be1c5935c0..59a04812d8 100644 --- a/play-services-cast-framework/core/src/main/java/com/google/android/gms/cast/framework/internal/CastContextImpl.java +++ b/play-services-cast-framework/core/src/main/java/com/google/android/gms/cast/framework/internal/CastContextImpl.java @@ -24,6 +24,7 @@ import androidx.mediarouter.media.MediaControlIntent; import androidx.mediarouter.media.MediaRouteSelector; +import androidx.mediarouter.media.MediaRouter; import com.google.android.gms.cast.CastMediaControlIntent; import com.google.android.gms.cast.framework.CastOptions; @@ -64,6 +65,17 @@ public CastContextImpl(IObjectWrapper context, CastOptions options, IMediaRouter String defaultCategory = CastMediaControlIntent.categoryForCast(receiverApplicationId); this.defaultSessionProvider = this.sessionProviders.get(defaultCategory); + if (this.defaultSessionProvider == null) { + // The provider map can be keyed by the full control category, which carries extra + // namespace/flag suffixes (e.g. ".../CC1AD845///ALLOW_IPV6"), rather than the bare + // categoryForCast(appId). Fall back to matching by category prefix. + for (Map.Entry entry : this.sessionProviders.entrySet()) { + if (entry.getKey() != null && entry.getKey().startsWith(defaultCategory)) { + this.defaultSessionProvider = entry.getValue(); + break; + } + } + } // TODO: This should incorporate passed options this.mergedSelector = new MediaRouteSelector.Builder() @@ -71,6 +83,20 @@ public CastContextImpl(IObjectWrapper context, CastOptions options, IMediaRouter .addControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK) .addControlCategory(defaultCategory) .build(); + + // Observe route selection so that choosing a Cast route actually starts a session. + // This goes through the app's MediaRouterProxy (IMediaRouter), which runs androidx + // MediaRouter in the app process; touching MediaRouter directly from the dynamite would + // fail with a Resources$NotFoundException. On selection the app invokes + // MediaRouterCallbackImpl.onRouteSelected(), which starts the session. + try { + this.router.registerMediaRouterCallbackImpl(this.mergedSelector.asBundle(), + new MediaRouterCallbackImpl(this)); + this.router.addCallback(this.mergedSelector.asBundle(), + MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY); + } catch (RemoteException e) { + Log.w(TAG, "Failed to register media router callback: " + e.getMessage()); + } } @Override 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..4995a3f3e0 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 @@ -50,12 +50,18 @@ public void onRouteRemoved(String routeId, Bundle extras) { @Override public void onRouteSelected(String routeId, Bundle extras) throws RemoteException { CastDevice castDevice = CastDevice.getFromBundle(extras); - - 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); + if (this.castContext.defaultSessionProvider == null) { + Log.w(TAG, "No session provider for selected route " + routeId + "; cannot start session"); + return; } + Object session = ObjectWrapper.unwrap(this.castContext.defaultSessionProvider.getSession(null)); + if (!(session instanceof SessionImpl)) { + Log.w(TAG, "Session provider did not yield a SessionImpl for route " + routeId); + return; + } + Bundle routeInfoExtras = this.castContext.getRouter().getRouteInfoExtrasById(routeId); + ((SessionImpl) session).start(this.castContext, castDevice, routeId, + routeInfoExtras != null ? routeInfoExtras : extras); } @Override public void unknown(String routeId, Bundle extras) { 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..5f6b9d7d1a 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 @@ -23,6 +23,7 @@ import android.content.Context; import android.net.Uri; import android.os.Bundle; +import android.os.IBinder; import android.os.Parcel; import android.os.RemoteException; import android.util.Base64; @@ -72,6 +73,16 @@ public class CastDeviceControllerImpl extends ICastDeviceController.Stub impleme String sessionId = null; + // Fires if the client process dies without a clean disconnect(), so we can tear down the + // CastV2 connection instead of leaking it (and its reader thread) and spamming a dead listener. + private final IBinder.DeathRecipient deathRecipient = new IBinder.DeathRecipient() { + @Override + public void binderDied() { + Log.d(TAG, "Cast client died; disconnecting from device"); + disconnect(); + } + }; + public CastDeviceControllerImpl(Context context, String packageName, Bundle extras) { this.context = context; this.packageName = packageName; @@ -147,12 +158,12 @@ public void rawMessageReceived(ChromeCastRawMessage message, Long requestId) { switch (message.getPayloadType()) { case STRING: String response = message.getPayloadUtf8(); - if (requestId == null) { - this.onTextMessageReceived(message.getNamespace(), response); - } else { - this.onSendMessageSuccess(response, requestId); - this.onTextMessageReceived(message.getNamespace(), response); - } + // Every inbound text message is an application-level message (e.g. MEDIA_STATUS) + // and must be delivered via onTextMessageReceived. Do NOT report it as a send + // success here: onSendMessageSuccess is keyed by the *outgoing* send's requestId + // (signalled in sendMessage), not the response's requestId — conflating them + // completes a non-existent client task and throws a RemoteException. + this.onTextMessageReceived(message.getNamespace(), response); break; case BINARY: byte[] payload = message.getPayloadBinary(); @@ -161,8 +172,50 @@ public void rawMessageReceived(ChromeCastRawMessage message, Long requestId) { } } + @Override + public void connect() { + // Connectionless (cxless) entry point. The classic path connects lazily on first + // launch/sendMessage, but the cxless client blocks until it receives + // onConnectedWithResult, so open the CastV2 channel now and signal readiness. + Log.d(TAG, "connect()"); + try { + if (!this.chromecast.isConnected()) { + this.chromecast.connect(); + } + this.onConnectedWithResult(CommonStatusCodes.SUCCESS); + } catch (Exception e) { + Log.w(TAG, "Error connecting to chromecast: " + e.getMessage()); + this.onConnectedWithResult(CommonStatusCodes.NETWORK_ERROR); + } + } + + @Override + public void setListener(ICastDeviceControllerListener listener) { + // cxless delivers the listener here instead of via the GetServiceRequest "listener" extra. + Log.d(TAG, "setListener()"); + this.listener = listener; + try { + listener.asBinder().linkToDeath(deathRecipient, 0); + } catch (RemoteException e) { + Log.w(TAG, "Failed to link Cast client death: " + e.getMessage()); + } + } + + @Override + public void unregisterListener() { + Log.d(TAG, "unregisterListener()"); + this.listener = null; + } + @Override public void disconnect() { + if (this.listener != null) { + try { + this.listener.asBinder().unlinkToDeath(deathRecipient, 0); + } catch (Exception ignored) { + // listener may already be dead or never linked + } + } try { this.chromecast.disconnect(); } catch (IOException e) { @@ -175,6 +228,10 @@ public void disconnect() { public void sendMessage(String namespace, String message, long requestId) { try { this.chromecast.sendRawRequest(namespace, message, requestId); + // Signal transport-level send success keyed by the outgoing send's requestId so the + // client's sendMessage Task completes; the receiver's reply arrives separately as an + // inbound message via onTextMessageReceived. + this.onSendMessageSuccess("", requestId); } catch (IOException e) { Log.w(TAG, "Error sending cast message: " + e.getMessage()); this.onSendMessageFailure("", requestId, CommonStatusCodes.NETWORK_ERROR); @@ -325,4 +382,14 @@ public void onDeviceStatusChanged(CastDeviceStatus deviceStatus) { } } } + + public void onConnectedWithResult(int statusCode) { + if (this.listener != null) { + try { + this.listener.onConnectedWithResult(statusCode); + } catch (RemoteException ex) { + Log.e(TAG, "Error calling onConnectedWithResult: " + 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..1e3bea4b5f 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 @@ -24,6 +24,7 @@ import com.google.android.gms.cast.CastDevice; import com.google.android.gms.cast.internal.ICastDeviceControllerListener; +import com.google.android.gms.common.internal.ConnectionInfo; import com.google.android.gms.common.internal.GetServiceRequest; import com.google.android.gms.common.internal.BinderWrapper; import com.google.android.gms.common.internal.IGmsCallbacks; @@ -45,6 +46,17 @@ public CastDeviceControllerService() { @Override public void handleServiceRequest(IGmsCallbacks callback, GetServiceRequest request, GmsService service) throws RemoteException { - callback.onPostInitComplete(0, new CastDeviceControllerImpl(this, request.packageName, request.extras), null); + // Advertise the API features the client requested so its availability check passes + // (otherwise the SDK deems microG too old -> ConnectionResult=2). Echo all of them, + // including the connectionless (cxless) features: CastDeviceControllerImpl now implements + // the connectionless connect/setListener handshake, so the cxless path works. + ConnectionInfo info = new ConnectionInfo(); + if (request.apiFeatures != null && request.apiFeatures.length > 0) { + info.features = request.apiFeatures; + } else { + info.features = request.defaultFeatures; + } + callback.onPostInitCompleteWithConnectionInfo( + 0, new CastDeviceControllerImpl(this, request.packageName, request.extras), info); } } 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..9c417529c3 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 @@ -2,6 +2,7 @@ package com.google.android.gms.cast.internal; import com.google.android.gms.cast.LaunchOptions; import com.google.android.gms.cast.JoinOptions; +import com.google.android.gms.cast.internal.ICastDeviceControllerListener; interface ICastDeviceController { oneway void disconnect() = 0; @@ -11,4 +12,10 @@ 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; + // Connectionless (Cast.API_CXLESS) path used by the modern Cast SDK: the client delivers its + // listener out-of-band via setListener (txn 18) then calls connect (txn 17), and waits for the + // service to reply ICastDeviceControllerListener.onConnectedWithResult before launching. + oneway void connect() = 16; + oneway void setListener(ICastDeviceControllerListener listener) = 17; + oneway void unregisterListener() = 18; } 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..855b17c2a7 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 @@ -18,4 +18,7 @@ interface ICastDeviceControllerListener { void onSendMessageSuccess(String response, long requestId) = 10; void onApplicationStatusChanged(in ApplicationStatus applicationStatus) = 11; void onDeviceStatusChanged(in CastDeviceStatus deviceStatus) = 12; + // Connectionless readiness signal: the cxless client stays "not connected" until the service + // calls this with statusCode 0 (SUCCESS) after connect(). Without it the session never starts. + void onConnectedWithResult(int statusCode) = 13; }