From 72852f65d8a0bdf202ec3b8e10b15407be0acd52 Mon Sep 17 00:00:00 2001 From: FloodExLLC Date: Sat, 21 Mar 2026 14:10:29 -0400 Subject: [PATCH 1/5] Cast: establish TCP connection before device operations The ChromeCast object was created but connect() was never called before launchApp(), sendRawRequest() etc., causing all outgoing operations to fail with IOException because no socket existed. - Add ensureConnected() helper; call it at the top of every outgoing operation (launchApplication, joinApplication, sendMessage, stopApplication) - Fix launchApplication() to guard against a null Application response instead of NPE-ing on app.sessionId - Implement joinApplication() properly: query device status, join an already-running matching session (wasLaunched=false), and only fall back to launching when the app is absent --- .../gms/cast/CastDeviceControllerImpl.java | 67 ++++++++++++++----- 1 file changed, 51 insertions(+), 16 deletions(-) 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..d43ab31e95 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,10 @@ import java.io.IOException; import java.util.ArrayList; -import java.util.Collections; import android.content.Context; -import android.net.Uri; import android.os.Bundle; -import android.os.Parcel; import android.os.RemoteException; -import android.util.Base64; import android.util.Log; import com.google.android.gms.cast.ApplicationMetadata; @@ -37,10 +33,8 @@ 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; @@ -51,7 +45,6 @@ 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; public class CastDeviceControllerImpl extends ICastDeviceController.Stub implements ChromeCastConnectionEventListener, @@ -91,6 +84,18 @@ public CastDeviceControllerImpl(Context context, String packageName, Bundle extr this.chromecast.registerConnectionListener(this); } + /** + * Ensures a TCP/TLS connection to the Cast device is established. + * Must be called before any operation that communicates with the device. + * + * @throws IOException if the connection cannot be established + */ + private void ensureConnected() throws IOException { + if (!this.chromecast.isConnected()) { + this.chromecast.connect(); + } + } + @Override public void connectionEventReceived(ChromeCastConnectionEvent event) { if (!event.isConnected()) { @@ -109,7 +114,7 @@ protected ApplicationMetadata createMetadataFromApplication(Application app) { Log.d(TAG, "unimplemented: ApplicationMetadata.senderAppLaunchUri"); metadata.images = new ArrayList(); metadata.namespaces = new ArrayList(); - for(Namespace namespace : app.namespaces) { + for (Namespace namespace : app.namespaces) { metadata.namespaces.add(namespace.name); } metadata.senderAppIdentifier = this.context.getPackageName(); @@ -122,7 +127,7 @@ public void spontaneousEventReceived(ChromeCastSpontaneousEvent event) { case MEDIA_STATUS: break; case STATUS: - su.litvak.chromecast.api.v2.Status status = (su.litvak.chromecast.api.v2.Status)event.getData(); + su.litvak.chromecast.api.v2.Status status = (su.litvak.chromecast.api.v2.Status) event.getData(); Application app = status.getRunningApp(); ApplicationMetadata metadata = this.createMetadataFromApplication(app); if (app != null) { @@ -167,27 +172,27 @@ public void disconnect() { 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 { + ensureConnected(); 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; } } @Override public void stopApplication(String sessionId) { try { + ensureConnected(); this.chromecast.stopSession(sessionId); } catch (IOException e) { - Log.w(TAG, "Error sending cast message: " + e.getMessage()); + Log.w(TAG, "Error stopping cast session: " + e.getMessage()); return; } this.sessionId = null; @@ -205,6 +210,14 @@ public void unregisterNamespace(String namespace) { @Override public void launchApplication(String applicationId, LaunchOptions launchOptions) { + try { + ensureConnected(); + } catch (IOException e) { + Log.w(TAG, "Error connecting to cast device: " + e.getMessage()); + this.onApplicationConnectionFailure(CommonStatusCodes.NETWORK_ERROR); + return; + } + Application app = null; try { app = this.chromecast.launchApp(applicationId); @@ -213,16 +226,39 @@ public void launchApplication(String applicationId, LaunchOptions launchOptions) this.onApplicationConnectionFailure(CommonStatusCodes.NETWORK_ERROR); return; } - this.sessionId = app.sessionId; + if (app == null) { + Log.w(TAG, "launchApplication returned null for id: " + applicationId); + this.onApplicationConnectionFailure(CommonStatusCodes.ERROR); + return; + } + + 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()); + try { + ensureConnected(); + su.litvak.chromecast.api.v2.Status status = this.chromecast.getStatus(); + Application runningApp = (status != null) ? status.getRunningApp() : null; + + if (runningApp != null && runningApp.id.equals(applicationId) + && (sessionId == null || runningApp.sessionId.equals(sessionId))) { + // The requested app is already running — join it without relaunching. + this.sessionId = runningApp.sessionId; + ApplicationMetadata metadata = this.createMetadataFromApplication(runningApp); + this.onApplicationConnectionSuccess(metadata, runningApp.statusText, runningApp.sessionId, false); + } else { + // App not running or session mismatch — fall back to launching. + this.launchApplication(applicationId, new LaunchOptions()); + } + } catch (IOException e) { + Log.w(TAG, "Error joining cast application: " + e.getMessage()); + this.onApplicationConnectionFailure(CommonStatusCodes.NETWORK_ERROR); + } } public void onDisconnected(int reason) { @@ -276,7 +312,6 @@ public void onBinaryMessageReceived(String namespace, byte[] data) { } public void onApplicationDisconnected(int paramInt) { - Log.d(TAG, "unimplemented Method: onApplicationDisconnected"); if (this.listener != null) { try { this.listener.onApplicationDisconnected(paramInt); From ab270710b08f1e69100d3ddd2a0aa4d20c14e70c Mon Sep 17 00:00:00 2001 From: FloodExLLC Date: Sun, 22 Mar 2026 09:04:05 -0400 Subject: [PATCH 2/5] fix(cast): catch GeneralSecurityException in ensureConnected() chromecast.connect() throws both IOException and GeneralSecurityException (a checked exception). The ensureConnected() helper only declared throws IOException, causing a compile error. Wrap the connect() call to catch GeneralSecurityException and rethrow as IOException so all callers remain unchanged. Fixes: https://github.com/microg/GmsCore/pull/3351 --- .../java/org/microg/gms/cast/CastDeviceControllerImpl.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 d43ab31e95..8654c07477 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 @@ -17,6 +17,7 @@ package org.microg.gms.cast; import java.io.IOException; +import java.security.GeneralSecurityException; import java.util.ArrayList; import android.content.Context; @@ -92,7 +93,11 @@ public CastDeviceControllerImpl(Context context, String packageName, Bundle extr */ private void ensureConnected() throws IOException { if (!this.chromecast.isConnected()) { - this.chromecast.connect(); + try { + this.chromecast.connect(); + } catch (GeneralSecurityException e) { + throw new IOException("SSL error connecting to cast device", e); + } } } From 0b88135aff165a835b099936a20645eb9dc851b1 Mon Sep 17 00:00:00 2001 From: FloodExLLC Date: Sat, 21 Mar 2026 14:10:29 -0400 Subject: [PATCH 3/5] Cast: implement route controller connect/disconnect lifecycle onSelect() and onUnselect() were stubs. The Cast device connection lifecycle must mirror the route selection lifecycle so the socket is open before the Cast session begins and closed when the user switches away. - onSelect(): open TCP/TLS connection to the Cast device - onUnselect() / onUnselect(int): close the connection - onRelease(): close the connection when the controller is destroyed --- .../gms/cast/CastMediaRouteController.java | 63 +++++++++++-------- 1 file changed, 37 insertions(+), 26 deletions(-) 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..33afd592c7 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,36 +16,15 @@ 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(); @@ -56,37 +35,69 @@ public class CastMediaRouteController extends MediaRouteProvider.RouteController public CastMediaRouteController(CastMediaRouteProvider provider, String routeId, String address) { super(); - this.provider = provider; this.routeId = routeId; this.chromecast = new ChromeCast(address); } + @Override public boolean onControlRequest(Intent intent, MediaRouter.ControlRequestCallback callback) { Log.d(TAG, "unimplemented Method: onControlRequest: " + this.routeId); return false; } + @Override public void onRelease() { - Log.d(TAG, "unimplemented Method: onRelease: " + this.routeId); + try { + if (this.chromecast.isConnected()) { + this.chromecast.disconnect(); + } + } catch (IOException e) { + Log.e(TAG, "Error releasing cast route controller: " + e.getMessage()); + } } + /** + * Called when the user selects this route. Opens the TCP/TLS connection + * to the Cast device so subsequent operations succeed immediately. + */ + @Override public void onSelect() { - Log.d(TAG, "unimplemented Method: onSelect: " + this.routeId); + try { + if (!this.chromecast.isConnected()) { + this.chromecast.connect(); + } + } catch (IOException e) { + Log.e(TAG, "Error connecting to cast device on route select: " + e.getMessage()); + } } + @Override public void onSetVolume(int volume) { Log.d(TAG, "unimplemented Method: onSetVolume: " + this.routeId); } + /** + * Called when the user deselects or disconnects from this route. + * Closes the TCP/TLS connection to the Cast device. + */ + @Override public void onUnselect() { - Log.d(TAG, "unimplemented Method: onUnselect: " + this.routeId); + try { + if (this.chromecast.isConnected()) { + this.chromecast.disconnect(); + } + } catch (IOException e) { + Log.e(TAG, "Error disconnecting from cast device on route unselect: " + e.getMessage()); + } } + @Override public void onUnselect(int reason) { - Log.d(TAG, "unimplemented Method: onUnselect: " + this.routeId); + onUnselect(); } + @Override public void onUpdateVolume(int delta) { Log.d(TAG, "unimplemented Method: onUpdateVolume: " + this.routeId); } From 86291a481405e8aea9d231c6d723f5e1c065c266 Mon Sep 17 00:00:00 2001 From: FloodExLLC Date: Sun, 22 Mar 2026 09:45:30 -0400 Subject: [PATCH 4/5] fix(cast): catch GeneralSecurityException in CastMediaRouteController.onSelect() Same fix as CastDeviceControllerImpl: chromecast.connect() throws both IOException and GeneralSecurityException. Update the catch clause to handle both using a multi-catch. --- .../java/org/microg/gms/cast/CastMediaRouteController.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 33afd592c7..e90d7d2863 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 @@ -23,6 +23,7 @@ import androidx.mediarouter.media.MediaRouter; import java.io.IOException; +import java.security.GeneralSecurityException; import su.litvak.chromecast.api.v2.ChromeCast; @@ -67,7 +68,7 @@ public void onSelect() { if (!this.chromecast.isConnected()) { this.chromecast.connect(); } - } catch (IOException e) { + } catch (IOException | GeneralSecurityException e) { Log.e(TAG, "Error connecting to cast device on route select: " + e.getMessage()); } } From 4eff63db98cd615c9500f5292e17d8520ab396ab Mon Sep 17 00:00:00 2001 From: Jordi Lores Date: Wed, 13 May 2026 12:55:48 +0400 Subject: [PATCH 5/5] fix(cast): move route socket operations off main thread --- .../gms/cast/CastMediaRouteController.java | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) 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 e90d7d2863..ebbfd3c147 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 @@ -24,6 +24,9 @@ import java.io.IOException; import java.security.GeneralSecurityException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import su.litvak.chromecast.api.v2.ChromeCast; @@ -33,6 +36,7 @@ public class CastMediaRouteController extends MediaRouteProvider.RouteController private CastMediaRouteProvider provider; private String routeId; private ChromeCast chromecast; + private final ExecutorService castExecutor = Executors.newSingleThreadExecutor(); public CastMediaRouteController(CastMediaRouteProvider provider, String routeId, String address) { super(); @@ -49,13 +53,12 @@ public boolean onControlRequest(Intent intent, MediaRouter.ControlRequestCallbac @Override public void onRelease() { - try { + runCastOperation("releasing cast route controller", () -> { if (this.chromecast.isConnected()) { this.chromecast.disconnect(); } - } catch (IOException e) { - Log.e(TAG, "Error releasing cast route controller: " + e.getMessage()); - } + }); + this.castExecutor.shutdown(); } /** @@ -64,13 +67,11 @@ public void onRelease() { */ @Override public void onSelect() { - try { + runCastOperation("connecting to cast device on route select", () -> { if (!this.chromecast.isConnected()) { this.chromecast.connect(); } - } catch (IOException | GeneralSecurityException e) { - Log.e(TAG, "Error connecting to cast device on route select: " + e.getMessage()); - } + }); } @Override @@ -84,13 +85,11 @@ public void onSetVolume(int volume) { */ @Override public void onUnselect() { - try { + runCastOperation("disconnecting from cast device on route unselect", () -> { if (this.chromecast.isConnected()) { this.chromecast.disconnect(); } - } catch (IOException e) { - Log.e(TAG, "Error disconnecting from cast device on route unselect: " + e.getMessage()); - } + }); } @Override @@ -102,4 +101,22 @@ public void onUnselect(int reason) { public void onUpdateVolume(int delta) { Log.d(TAG, "unimplemented Method: onUpdateVolume: " + this.routeId); } + + private void runCastOperation(String operation, CastOperation castOperation) { + try { + this.castExecutor.execute(() -> { + try { + castOperation.run(); + } catch (IOException | GeneralSecurityException e) { + Log.e(TAG, "Error " + operation + ": " + e.getMessage()); + } + }); + } catch (RejectedExecutionException e) { + Log.w(TAG, "Ignoring cast operation after release: " + operation); + } + } + + private interface CastOperation { + void run() throws IOException, GeneralSecurityException; + } }