Skip to content

Implement Google Cast sessions on microG - #3845

Open
woahwhattheheck wants to merge 76 commits into
microg:masterfrom
woahwhattheheck:bounty/cast580-w01-20261001
Open

woahwhattheheck wants to merge 76 commits into
microg:masterfrom
woahwhattheheck:bounty/cast580-w01-20261001

Conversation

@woahwhattheheck

@woahwhattheheck woahwhattheheck commented Oct 2, 2026 •

Copy link
Copy Markdown

On current master, apps that use the Cast SDK (CastContext) load microG's com.google.android.gms.cast.framework.dynamite module. That module is mostly stubbed:

  • routes never reach a session;
  • CastState stays at NO_DEVICES_AVAILABLE, so apps hide their cast button;
  • the device controller can't join, register namespaces or set volume, and it doesn't implement the connectionless API that current SDKs use.

This PR implements the whole path: discovery, the device connection, and the framework session.

Device connection (play-services-cast/core)

  • In-tree CastV2 channel (channel/CastChannel.kt, channel/CastDeviceSession.kt, cast_channel.proto via Wire). It replaces chromecast-java-api-v2. It handles:

    • TLS, length-prefixed CastMessage framing and the deviceauth challenge;
    • per-transport virtual connections and heartbeat;
    • receiver status, launch/join/leave/stop and volume/mute;
    • text and binary messages on the application's transport.
  • CastDeviceControllerImpl serves every ICastDeviceController transaction the current client SDK sends: disconnect, leave, stop, requestStatus, setVolume, setMute, sendMessage, sendBinaryMessage, register/unregister namespace, launch, join, connect, addListener and removeListener. It supports both client flows:

    • the legacy flow, with the listener in the service request extras and reconnect via last_application_id;
    • the connectionless flow (addListener, then connect, then onConnectedWithResult).

    A death link on the client's listener tears the connection down when the app dies. The client may reuse the controller binder after disconnecting, and the controller reopens in that case.

  • CastServiceImpl answers CAST_API (161) requests. The bind advertises the features the client checks for: cxless_client_minimal, module_flag_control and the others.

  • CastMediaRouteProvider:

    • discovery runs on passive requests too;
    • resolves are serialized, with a timeout on API 34+;
    • TXT records are parsed tolerantly;
    • the exact requested CATEGORY_CAST/<appId>... categories are echoed in the control filters;
    • routes have variable volume and expire when discovery stops, unless they are still in use;
    • all state is confined to the main thread.
  • CastMediaRouteController follows and sets the device volume over its own CastV2 session. It reconnects on demand if that connection drops while the route stays selected.

Cast framework module (play-services-cast-framework/core)

  • CastContextImpl:
    • builds the merged selector from the app's own session provider categories;
    • registers the module's callbacks with the app's MediaRouter;
    • drives active discovery while an activity of the app is started;
    • implements setReceiverApplicationId and destroy.
  • SessionManagerImpl / SessionImpl / CastSessionImpl:
    • session start on route selection, plus startSession and endCurrentSession(stopCasting);
    • the session state machine and all SessionManagerListener events;
    • CastState transitions;
    • launch or join of the receiver application, and resume of a saved session after the app restarts.
  • ReconnectionServiceImpl, FetchBitmapTaskImpl (notification and controller artwork) and a ModuleDescriptor for the dynamite module. The return 1 placeholder in DynamiteLoaderImpl is removed.

Binder and parcel compatibility

I checked the transaction codes and argument types of these interfaces against the play-services-cast / play-services-cast-framework 22.3.1 client libraries:

  • ICastDeviceController and its listener;
  • ICastDynamiteModule, ICastContext, ISessionManager, ISession, ICastSession;
  • IMediaRouter and its callback;
  • ISessionProxy and the fetch-bitmap interfaces.

Two changes come out of that:

  • ICastSession.onConnectionFailed now takes the ConnectionResult the client sends.
  • New codes such as getSupportedVersion are implemented.

CastDevice, LaunchOptions, ApplicationMetadata and the status parcels already use the client's SafeParcel field ids.

Testing

  • Six focused JVM framing tests passed in run 37091777804. JUnit XML reports 6 tests, 0 failures, 0 errors and 0 skipped. They exercise exact 65,536-byte encoded bodies, 65,537-byte rejection before writing/reading the body, and string/binary envelope overflow. The tested CastChannel source blob is 933fb0d2b3dd1549bad5594f6dff5373326e8031; the test files are included in this branch.
  • ./gradlew :play-services-core:assembleVtmDefaultDebug :play-services-core:assembleVtmDefaultRelease succeeds.
  • lintDebug on play-services-cast, play-services-cast-core, play-services-cast-framework and play-services-cast-framework-core reports 0 errors.
  • The compiled CastChannel / CastDeviceSession classes were run on the JVM against a software CastV2 receiver. Every step passed:
    • TLS, device authentication and virtual connections;
    • heartbeat over 25 s idle;
    • launch, join by app and by session id, and launch with relaunchIfRunning=false joining the running instance;
    • leave, stop and relaunch;
    • volume and mute;
    • text, binary and media namespace messages, with unregistered namespaces filtered;
    • the payload limit, a refused platform namespace, and an unknown app id;
    • the receiver dying mid-session.
  • Not yet tested on a phone against a physical Chromecast. The binder layer and the cast framework module have not been run on a device.

Remote-playback validation

The MediaRouter controller now handles the AndroidX remote-playback actions supported by this branch and returns the item ID separately from the item status in the result Bundle. The provider filter advertises only implemented actions.

The focused Cast core unit suite passed on validation commit ca1aa5c91630b72e179f4427432fb32298b1c98c in run 37174065096, with JUnit XML retained as artifact 11291879367. Debug and Release assemble and lint passed on that same validation commit. Only the six source/test files were forwarded into this PR; the validation workflow remains on the validation branch. The full build on PR commit de7c5de9f2e7c314a7b367bb4d0334672a1d471e also passed.

Known limitations

  • A dropped connection ends the cast session. Play services instead suspends it and reconnects.
  • Cast Connect launch options (androidReceiverCompatible, credentials) are not forwarded to the receiver.
  • MediaRouter session-management and queue control intents remain unsupported; the provider filter excludes those actions.
  • Discovery needs API 21 (NsdServiceInfo.getAttributes). Below API 34 a stuck resolve cannot be cancelled.
  • The deviceauth signature is not verified, which matches master.

Related open PRs on the same files: #3351, #3354, #3377, #3470, #3505, #3554, #3567, #3570, #3577, #3668, #3781, #3802.

Closes #580

BountyHub contribution and conditional compensation request

Original PR submitter @woahwhattheheck (GitHub ID 293286387) affirmatively requests review, appropriate credit for the qualifying original Cast integration, and any eligible BountyHub issue #580 compensation, subject to the relevant creator's rules, maintainer acceptance, original human contribution and verified device/app results.

Existing matching BountyHub portal claim (do not resubmit): the $150 advertised BountyHub listing, UUID ef91cb1e-dd69-4a33-bd90-21c9679c9247, publicly includes @woahwhattheheck's original sponsor PR #3845 as a claimant. This is registration, not creator acceptance, an awarded amount, or settlement. The listed $100 promised plus $50 contribution must not be described as earned cash. Creator acceptance requires genuine casting from native/ReVanced YouTube, Crunchyroll and Netflix to Tizen and Chromecast targets; the current software receiver/JVM verification alone does not satisfy that device/application evidence.

Separate creator, not a second automatic claim: the $250 PROMISED listing, UUID 27c3cfe0-da9e-4192-848a-b676402de81c is a distinct creator requirement for /e/OS 3.x+ casting to Roku. The public claimant list checked in the October 8 intake did not include PR #3845, and this CastV2 implementation has no verified Roku compatibility or /e/OS-to-Roku demonstration. Sharing GitHub issue #580 does not transfer eligibility or $250 funding to the already registered $150 claim. Do not submit another portal claim without independent creator-specific technical and human-eligibility verification from the original contributor.

The PR source and author remain unchanged. No physical Chromecast/Roku/Tizen cross-app acceptance, reward adjudication, approved amount or payout is represented. This correction preserves the original contributor's existing claim and affirmative conditional compensation request, without waiver or duplicate filing.

@Tthecreator

Copy link
Copy Markdown

This look AI-ey again. I'm not reviewing any PR unless there is a proof video that this is working.

@eelcowijbrands

Copy link
Copy Markdown

Nice, this looks much more complete than what's on master. Cross-check from my side, in case it's useful: decompiling the Google Cast SDK side gave me connect() = 17, setListener() = 18, unregisterListener() = 19 on ICastDeviceController, and onConnectedWithResult(int) = 14 on the listener (after onConnected() = 13). If yours already matches, ignore me.
I'll close my own #3802 in favor of this one.

@woahwhattheheck

Copy link
Copy Markdown
Author

Thanks for the SDK cross-check and for offering to consolidate #3802 into this PR. The controller connect/addListener/removeListener calls map to Binder transactions 17/18/19, and onConnectedWithResult(int) maps to 14, matching those numbers.

Checking the official Google Maven play-services-cast:22.3.1 artifact, the listener stub reads a status SafeParcelable at transaction 13 and an int at 14. The status parcel fields 2–6 match this branch's CastDeviceStatus. That matches onDeviceStatusChanged(CastDeviceStatus) at 13 rather than a no-argument callback. Which exact artifact/version did you inspect, and does its transaction 13 read a status parcel or take no arguments? That will help distinguish a naming difference from a version-specific callback layout.

Cover exact encoded body limits, prefix rejection, and string and binary envelope overflow.
@eelcowijbrands

Copy link
Copy Markdown

I checked again, did this against the official Maven artifacts (play-services-cast 21.5.0 and 22.3.1, bytecode of the stub and proxy). You are right about transaction 13, it reads a status parcel, not a no-argument callback. So the onConnected() = 13 in my #3802 was wrong, I'll drop that claim. Transaction 14 reads an int, which matches onConnectedWithResult(int), and the controller transactions are connect() = 17, setListener() = 18 and unregisterListener() = 19 in both versions.
One thing I noticed while checking: the whole numbering currently in microG's AIDL is off by one compared to both artifacts (disconnect is 0 in microG but 1 in the SDK, sendMessage 8 vs 9, launchApplication 12 vs 13, and the listener callbacks are shifted the same way). So a modern app won't cast against microG even if 17, 18 and 19 are added, unless the rest is renumbered too. I take it your implementation covers that, which makes this PR the right one. I'll close #3802 in favor of this.

woahwhattheheck and others added 22 commits October 3, 2026 23:52
Implement MediaRouter remote playback controls for the Cast route, advertise only supported actions, and cover command serialization and status parsing with focused tests.
Track NSD observations by discovery listener and service identity so late resolves cannot republish lost or replaced receivers. Reject receiver messages for another sender before consuming pending requests or changing application state.

Add focused discovery lifecycle and receiver-routing regressions.
Align bitmap sampling with the existing fit-inside resize to avoid large intermediate allocations for wide or tall artwork. Use a long sampling divisor and monotonic receive timestamps while preserving heartbeat intervals.
Refine the prior stale-callback guard: the session that last saved recovery state owns its cleanup, including an initial saved-session resume. Preserve a connected replacement while clearing the old record when a replacement is still starting.

Recheck active-session identity inside deferred route cleanup and honor resumeSavedSession before automatic recovery reads or route selection.

Validation: exact production Java with host Android/Binder collaborators, 39 assertions across seven scenarios pass; prior shared source fails nine. The Android Gradle attempt stopped because this harness has no SDK. No physical receiver result is claimed.

Work order: BH-CAST-SESSION-REPLACEMENT-1FEAD46
Attribution: GPT-6 Astra Pro, ChatGPT cloud harness 1fead46c005a
Reject malformed or unsuccessful status replies before confirming an application from cached receiver state. Preserve timeout, valid join/launch, and existing launch-error behavior.

Add focused session regressions for rejected replies, invalid status objects, fresh transports, and the prelaunch-to-launch flow.
Only launch or join the receiver while starting, resuming, or reconnecting
a suspended session. A queued connection after cancellation or failed start
must not relaunch the receiver; a duplicate callback after connection must
not launch it again.

The five focused lifecycle cases produced four failures on the unchanged
source and pass with this guard. Normal start, saved resume, and suspended
rejoin retain their existing operations. This does not cancel an operation
already racing past the state check, and is not physical receiver proof.

ASTRA-F514 / GPT-6 Astra Pro / ChatGPT cloud harness
Keep media control callbacks pending until a matching receiver reply or timeout.
Return stable MediaRouter session and item identities, reject missing or mismatched
status, clear stopped playback, and preserve unknown media duration.

The focused RemotePlaybackControlTest suite passes all 15 cases.
Route RemoteException from starting and resuming proxy calls through the existing failure notifications. Finish an ending session only if it still remains in ENDING, preserving reentrant state changes, then rethrow the original exception.

Compiled the complete SessionImpl, SessionManagerImpl and CastStatusCodes with recording JVM collaborators. The focused lifecycle regression changes from 2 passed / 6 failed to 8 passed / 0 failed, covering all five throwing proxy calls, normal lifecycle completion and exception identity. This does not claim physical receiver or Binder validation.
Dispatch PING and CLOSE from a STRING envelope's top-level JSON type, so
metadata, malformed payloads and BINARY envelopes do not execute commands.
Valid JSON escapes remain supported.

For STOP, require a receiver status object and confirm the target session
is absent before reporting success. Preserve timeout and rejection results.

Add six framed dispatcher regressions and six actual session STOP cases.
Against the original source, five dispatcher cases and three STOP cases
failed. Both repaired classes and the same 12 tests compiled and passed
together on 33ebfd4 with Kotlin 2.2.21/JDK17/Wire6.4.6 (JUnit 0.341s).
Fresh-head composition retains the later SDK-gated remove-on-cancel
initializer and all other published peer changes.
This is focused JVM evidence; physical Cast acceptance remains separate.

Operations: BH-CAST-CONTROL-TYPE-E9DC, CAST580-STOP-REPLY-E9DC
Handle RemoteException from the asynchronous launch or join in onConnected through the existing INTERNAL_ERROR failure transition and connection cleanup, then propagate the original exception. Preserve the existing lifecycle-admission guard and successful launch/rejoin behavior.

Focused real-framework JVM reproduction: 7 cases, 4 failures on the original callback; 7 passed after the repair. Covers starting, resuming, suspended rejoin, cleanup failure, and successful controls. No physical receiver validation is claimed.

Refs microg#580
## Cast discovery: retain a receiver while another service still advertises it

`onChromeCastDiscovered` coalesces service observations by receiver ID. If two service names identify the same receiver, the first service-loss callback currently removes that shared route even though the second name remains in `serviceCastIds`. The route therefore disappears from the published descriptor while another current observation still advertises it.

The one-line change in `CastMediaRouteProvider.onChromeCastLost` only removes an unused route after its final service-name mapping disappears. This matches the existing remaining-service check in route-controller release/state cleanup. The stale discovery callback guards, discoveryState and resolve lifetime code remain untouched.

Focused actual-class validation: the complete provider and its two unchanged helper classes compiled with JDK17/Java8 target against retained Android/MediaRouter project artifacts. Robolectric4.14.1 on Android API28 ran the same three JUnit4 cases: original source 1 failed/2 passed; candidate 3 passed. The failing history resolves service A and service B to one receiver ID, then loses A; the baseline descriptor becomes empty and the candidate retains the receiver until B is also lost. Ordinary single-service loss, unrelated service loss and unknown-service controls remain correct.

The module already declares JUnit4.13.2 and Robolectric4.14.1; no build-file/dependency change or full Android rebuild is included. These cases execute the real NSD service-info parsing and resolved/lost callback bodies with Android runtime classes, not live mDNS packets or a physical receiver. The existing Cast bounty claimant and receiver-video owner retain those acceptance outcomes.

Attribution: Astra Relay / GPT-6 Astra Pro / ChatGPT cloud e0591c290f4b.
Claim: https://tokenjunkielabs.slack.com/archives/C0BU51F1PL3/p1791098740446649

Publication uses [skip ci] to avoid another automatic full Debug/Release matrix during the shared source integration. The three focused actual-provider cases are the validation for this change; combined APK and physical-receiver acceptance remain with the existing submission contributors.
A route-selected callback for a category that no longer has a session provider currently ends a different live session before checking that provider. CategoryRouterCallback instances can outlive the receiver application/category registration that created them, so an obsolete callback must not tear down the current session when no replacement can be created.

Check that the category has a provider before existing-session teardown. Retain the provider lookup after teardown: synchronous onSessionEnding listeners can remove or replace providers, and caching the earlier lookup would incorrectly start a removed provider. Normal route unselection and stopCasting reason handling are unchanged. This is five added lines in SessionManagerImpl.java, including the reason for the second lookup.

Focused actual-class validation
- JDK 17 javac/java executed complete current SessionImpl, SessionManagerImpl and CastStatusCodes against recording Android/IPC/provider/router/preferences collaborators. JVM limits: javac 96 MiB, runtime 48 MiB.
- Original source: 3 cases passed, 1 failed. A connected saved session on route-A received onRouteSelected("removed-category", "obsolete-route", extras) while that category's provider lookup returned null. The original invoked proxy.end and moved the live session to ENDING.
- Fixed source: all 4 cases passed. The unsupported event emits no ending/ended callback, leaves the same session connected/current and saved preferences unchanged, and does not start another session.
- Controls preserve normal provider-backed replacement; an unsupported event with no current session remains a no-op; removing the provider synchronously in onSessionEnding does not start that removed provider, and the original session completes normally afterward.
- Both complete source classes compiled successfully. This is isolated JVM lifecycle evidence with recording collaborators, not device execution, Binder transport, live route discovery or a full Android build.

Tested source: parent 72aba4d, original manager blob d56c863, unchanged SessionImpl blob 5de4cf7. Candidate manager blob 2a0b7fd, SHA-256 734ea2851414ccaa3df6f7610a76e3db99d062c2a25db7723af94ccbe82a3c5e. The manager blob is still identical at publication parent 753183d, preserving the intervening service-alias repair.

Existing microg/GmsCore PR3845, claimant and BountyHub attachment retained. No second submission or upstream-comment retry. Skip the automatic full Debug/Release matrix for this small proven lifecycle correction; combined APK and physical receiver acceptance remain with their existing contributors.

Attribution: Astra-4614 / GPT-6 Astra Pro / ChatGPT cloud harness; implementation and regression by bountyhub_scout, reentrancy review by whitebox_performance.
Claim: https://tokenjunkielabs.slack.com/archives/C0BU51F1PL3/p1791099478320419
Check frame destinations before control dispatch and heartbeat accounting. A receiver or application CLOSE addressed to another sender must not close this channel or its transport, and a foreign PING must not elicit a PONG. Direct and '*' broadcast messages retain their behavior. The existing CastDeviceSession routing check remains unchanged.

Add two focused cases to the existing CastChannelControlMessageTest suite, retaining its six parsing and control cases. The tests run the complete current CastChannel reader against real Wire-encoded frames and byte streams.

Validation on parent 2c3b3ff: compilation succeeded; 8 tests ran with the 2 new cases failing (foreign CLOSE removed a transport and foreign PING produced 3 replies instead of 2). With this change, compilation succeeded and all 8 tests passed in 0.060 s. Runtime: OpenJDK 17.0.20.1, Kotlin JVM compiler 2.2.21, Wire 6.4.6, JUnit 4.13.2; existing dependencies and generated protocol classes were reused. This is focused JVM frame-dispatch evidence; physical receiver behavior was not exercised.
Pass Android receiver compatibility and opaque credential/type strings from LaunchOptions through the session's immediate and status-then-launch paths. Encode supportedAppTypes as WEB plus optional ANDROID_TV, and credentials under appParams.launchCheckerParams.credentialsData. Preserve empty strings, omit absent credential fields, and do not retain options between launches or attach them to GET_STATUS.

Keep the existing three-argument JVM overload and existing direct-join behavior. No parcel model, reconnect, routing, or submission changes. Credentialed join remains outside this change.

Add five focused launch payload tests to the existing core JVM test setup. The complete updated session and those test methods compiled and passed locally with recording transport, JSON, envelope, Android Build and assertion collaborators; javap confirmed the three-argument overload remains. This is payload/control-flow evidence, not execution of the Android Gradle task, real org.json/Wire codec, Binder layer, or a physical receiver. Reuse the existing combined Android validation rather than trigger a duplicate full build.
…ip ci]

JoinOptions already carries connectionType in SafeParcel field 2, but the controller dropped the option and CastChannel emitted a fixed CONNECT payload. Expose that existing field and pass it through the controller and session to the first application virtual connection. Encode connType explicitly; platform receiver connections remain strong (0).

Preserve the prior two-argument JVM joinApplication and one-argument connectTransport methods via @jvmoverloads. Preserve first-connection deduplication: an already open application transport keeps its existing mode until it is closed, then the next join may select a different mode. Do not add CLOSE/reconnect behavior or alter launch credentials.

Primary protocol evidence: Chromium CreateVirtualConnectionRequest writes connType, with kStrong=0, kWeak=1 (unused by Chromium), kInvisible=2; platform destinations require strong. Chromium DoEnsureConnection deduplicates existing source/destination connections regardless of a later requested type.
https://github.com/chromium/chromium/blob/main/components/media_router/common/providers/cast/channel/cast_message_util.cc (observed blob 87c413304e7279f9d0a1d4d6ca9fcdd501d19663)
https://github.com/chromium/chromium/blob/main/components/media_router/common/providers/cast/channel/cast_message_util.h (observed blob 771191724811d97daf712ab4b87a219796595af2)
https://github.com/chromium/chromium/blob/main/components/media_router/common/providers/cast/channel/cast_message_handler.cc (observed blob c03807db93917e370a37053901bb5004247dc3f6)

Validation: compiled the complete CastChannel and CastDeviceSession, actual generated Wire 6.4.6 messages, org.json 20231013 and the existing join/control test harness with Kotlin 2.2.21 on OpenJDK 17.0.20. Baseline: 19 cases, one expected failure for the newly required connType field (expected [0], actual [-1] indicating absent). Candidate: 22/22 pass, 0.143 s; three added test methods cover requested modes 0/1/2, existing-connection deduplication/close/rejoin, and a platform connection requested as invisible remaining strong. javap confirms both former JVM signatures.

The only Android collaborator in this focused JVM run is Build.VERSION (SDK_INT=35, LOLLIPOP=21). This proves session/control flow and real encoded wire payloads, not Binder/parcel execution, a full Android build, a physical receiver, credentialed join, bounty acceptance or payment. Existing PR3845, original submitter and combined Android validation ownership are preserved.

Attribution: GPT-6 Astra Pro / ASTRA-3C24-BH / ChatGPT cloud harness 3c24e9bdb7d8.
…skip ci]

Ignore only our completed or expired request replies, preserving other senders broadcasts and spontaneous status. Randomize the initial request id per the media protocol. Reject old-item replies before changing replacement playback state, while allowing STOP acknowledgement after spontaneous completion. Normalize the optional MediaRouter callback without changing actions. Existing focused module checks passed: 15 prior cases plus 13 Android/Robolectric regression and control cases. No APK, physical receiver or bounty award is asserted.
Cover text and binary sends at the exact 65,536-byte encoded-body boundary and one-byte-over rejection with status 2006, original request identity, no emitted prefix, and successful subsequent sends.

Focused maintained framing/send classes: 12 tests passed with zero failures, errors or skips in run 37230626768. Disposable strict-cap and omitted-guard variants each failed the matching new case; production sources were restored and hash-verified. Only the maintained test file is forwarded; the validation workflow remains on its dedicated branch.

Validation: https://github.com/woahwhattheheck/GmsCore/actions/runs/37230626768
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

What's the status on Google Cast implementation?

4 participants