Skip to content

Cast: Implement connectionless (Cast.API_CXLESS) device controller - #3570

Open
peterhel wants to merge 5 commits into
microg:masterfrom
peterhel:feat/cast-connectionless
Open

peterhel wants to merge 5 commits into
microg:masterfrom
peterhel:feat/cast-connectionless

Conversation

@peterhel

@peterhel peterhel commented Jun 18, 2026 •

Copy link
Copy Markdown

Cast: Implement connectionless (Cast.API_CXLESS) device controller

Summary

Modern Cast SDK clients — and real apps such as Amazon Prime Video, YouTube,
Netflix — drive the Cast device over the connectionless Cast API
(Cast.API_CXLESS) rather than the classic connection-based path that microG
implemented. As a result, casting from these apps never worked on microG: the
session would start and then stall indefinitely.

This PR implements the connectionless device-controller surface. With it, the
real Amazon Prime Video and Netflix apps launch their own receiver and play a
DRM title end to end
on a Chromecast, cast from a de-Googled phone.

Builds on #3567 (which makes the framework start a session when a Cast route is
selected); that session is the trigger that binds the device controller this PR
completes.

The problem

The connectionless client binds the same ICastDeviceController service, but
its handshake differs from the classic client:

  • The classic client passes its listener in the GetServiceRequest "listener"
    extra and connects lazily on the first launch/sendMessage.
  • The connectionless client instead delivers its listener out-of-band via
    setListener(), then calls connect(), and waits for the service to call
    ICastDeviceControllerListener.onConnectedWithResult before it will launch an
    application.
    It is otherwise purely reactive.

microG implemented only the classic path. So a connectionless client bound the
service, called setListener/connect (transactions microG didn't handle, and
which were silently dropped because they are oneway), never received
onConnectedWithResult, and stayed "not connected" forever. Withholding the
cxless_* features to force the classic path instead just made the SDK decide
microG was too old and fail with ConnectionResult=2.

There is also a second gate, at the bind itself. The connectionless client
issues its GetServiceRequest with serviceId = CAST_API (161), not
CAST (10). The broker resolves that id against each service's supported ids;
the device-controller service declared only CAST, so the id matched nothing
and the broker rejected the bind with "Service not supported" — which the
sender surfaces to the user as "No devices found", before any of the
handshake above can even run.

The fix

The transaction numbers in microG's AIDL already matched the framework — the
connectionless methods were simply absent. The change is additive:

  • ICastDeviceController.aidl — add connect() (txn 17),
    setListener(ICastDeviceControllerListener) (18), unregisterListener() (19).
  • ICastDeviceControllerListener.aidl — add onConnectedWithResult(int)
    (txn 14), the readiness signal.
  • CastDeviceControllerImpl.java:
    • connect() opens the CastV2 channel and emits
      onConnectedWithResult(SUCCESS).
    • setListener() stores the out-of-band listener.
    • onSendMessageSuccess is now reported from the outgoing sendMessage,
      keyed by that send's requestId. Previously it was reported from inbound
      messages using the response's id, which completed a non-existent client task
      and threw a RemoteException. Inbound messages now go solely through
      onTextMessageReceived.
    • The client's listener binder is watched with linkToDeath; if the client
      process dies without a clean disconnect(), the device connection (and its
      reader thread) is torn down instead of leaked.
  • CastDeviceControllerService.java:
    • Advertise the requested API features via ConnectionInfo so the client's
      availability check passes.
    • Accept serviceId CAST_API (161) on the bind, alongside CAST (10),
      so the broker no longer rejects the connectionless client with "Service not
      supported". The same CastDeviceControllerImpl serves both ids — the
      connectionless connect/setListener path handles the request — so no new
      service or implementation is introduced.

Connectionless handshake (for reference)

bind CAST service (serviceId CAST_API, cxless features, EXTRA_CAST_DEVICE, connectionless_client_record_id)
  → setListener(listener)              [txn 18]
  → connect()                          [txn 17]
  ← onConnectedWithResult(0)           [listener txn 14]   // session becomes "connected"
  → launchApplication(appId, opts)     [txn 13]
  ← onApplicationConnectionSuccess     [listener txn 2]
  → sendMessage(LOAD, media namespace) [txn 9]             // + per-app namespaces
  ← onSendMessageSuccess / onTextMessageReceived

Testing

Validated on a Pixel 2 (LineageOS-for-microG, Android 15) against a Chromecast:

  • The real, logged-in Amazon Prime Video app selects the device, microG
    launches Prime's own receiver (appId 17608BC8), Prime completes its
    proprietary registration/settings handshake on
    urn:x-cast:com.amazon.primevideo.cast, LOADs a DRM title, and the title
    plays on the TV — with zero failed listener callbacks.
  • The real, logged-in Netflix app selects the device, microG accepts the
    CAST_API bind, drives the connectionless setListener/connect handshake
    to onConnectedWithResult(0), launches Netflix's receiver, relays its
    private-namespace traffic, LOADs a DRM title, and the title plays on the
    TV
    .
  • The real, logged-in Disney+ app selects the device over the same
    connectionless path, launches its receiver, and the title plays on the
    TV
    .
  • A minimal first-party Cast sender plays a clip end to end
    (playerState=PLAYING, currentTime advancing).
  • Force-killing the sender mid-session triggers the linkToDeath cleanup
    (device disconnected, no leaked connection).

Notes / scope

  • CastMediaRouteController.onSelect remains a stub; it is a separate legacy
    route-control path and does not gate the connectionless flow (the framework's
    session manager binds the device controller directly).
  • The CAST_API (161) id is accepted at the bind (see above), but no
    additional CAST_API-specific surface (settings/relay/logging) is
    implemented. Netflix and Prime carry their app traffic over the device
    controller's namespaces, so that surface was not needed for casting in
    testing; registerNamespace/unregisterNamespace remain logged no-ops and
    did not block playback.

peterhel and others added 2 commits June 17, 2026 00:59
CastContextImpl never registered a route-selection callback, so selecting a
Chromecast via the Cast SDK (CastContext/SessionManager) did nothing: discovery
worked but no session was ever started and the app's SessionManagerListener never
fired. MediaRouterCallbackImpl already had the correct start-on-selection logic but
was never instantiated or registered with the router.

Register MediaRouterCallbackImpl via IMediaRouter (registerMediaRouterCallbackImpl +
addCallback) so route selection starts a session. This goes through the IMediaRouter
binder rather than touching androidx MediaRouter from the dynamite, which would throw
Resources$NotFoundException (the dynamite uses the app's Resources but microG's
resource IDs).

Also resolve defaultSessionProvider by category prefix: the provider map is keyed by
the full control category (with namespace/flag suffixes such as
.../CC1AD845///ALLOW_IPV6), so the exact categoryForCast(appId) lookup missed and
left defaultSessionProvider null. Harden MediaRouterCallbackImpl.onRouteSelected
against a missing provider / non-SessionImpl and fall back to the route extras.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Modern Cast SDK clients — and apps such as Amazon Prime Video — drive the
Cast device over the connectionless API instead of the classic
connection-based path. They bind the existing device-controller service,
but unlike the classic client they deliver their listener out-of-band via
setListener() and then call connect(), and they wait for the service to
reply ICastDeviceControllerListener.onConnectedWithResult before launching
an application. microG only implemented the classic path, so these clients
bound the service and then stalled forever; withholding the cxless features
instead made the SDK report microG as too old (ConnectionResult=2).

Add the missing connectionless surface:

- ICastDeviceController: connect() (txn 17), setListener() (18) and
  unregisterListener() (19). The other transaction numbers already match
  the framework; these methods were simply absent.
- ICastDeviceControllerListener: onConnectedWithResult() (txn 14), the
  readiness signal the connectionless client blocks on.
- CastDeviceControllerImpl: open the CastV2 channel on connect() and emit
  onConnectedWithResult(SUCCESS); store the listener delivered via
  setListener(); report onSendMessageSuccess from the outgoing send keyed
  by its requestId (reporting it from inbound messages completed a
  non-existent client task and threw a RemoteException); and disconnect
  from the device if the client process dies (linkToDeath) so the
  connection and its reader thread are not leaked.
- CastDeviceControllerService: advertise the requested API features via
  ConnectionInfo so the client's availability check passes.

Tested on-device: the real Amazon Prime Video app launches its own
receiver and plays a DRM title end to end through this path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Connectionless senders (Netflix/Prime, Cast.API_CXLESS) bind
BIND_CAST_DEVICE_CONTROLLER_SERVICE with serviceId CAST_API (161),
not CAST (10). The broker resolves the id to a service via its
supportedServices; with only CAST declared it matches none and
rejects the bind with "Service not supported", which the sender
surfaces as "No devices found".

Add CAST_API to this service's supported ids. The same
CastDeviceControllerImpl serves the request -- its connectionless
connect/setListener path already handles it -- so no new service or
implementation is needed.

Verified on a Pixel 2 against a Chromecast: the real Netflix app now
selects the device, launches its receiver, and plays a DRM title end
to end.
@paulcakeface

Copy link
Copy Markdown

I found a listener lifecycle bug in the current connectionless controller implementation.

setListener() links one shared DeathRecipient, but replacing the listener does not unlink the previous binder. unregisterListener() also clears the listener without unlinking it. This means a stale binder death notification from listener A can arrive after listener B has been installed and call disconnect(), tearing down B's active Cast session.

There is a second ordering issue in disconnect(): the listener is unlinked before ChromeCast.disconnect() runs. In the exact info.armills.chromecast-java-api-v2 dependency used here, ChromeCast.disconnect() calls Channel.close(), and Channel.close() synchronously notifies connectionEvent(false) before closing the socket. CastDeviceControllerImpl.connectionEventReceived() uses that event to deliver onDisconnected(SUCCESS). Clearing or unlinking the listener first can therefore suppress the legitimate disconnect callback.

I have a local fix that:

  • keeps the binder and DeathRecipient per listener registration
  • unlinks the exact old registration on replacement and unregister
  • ignores an already queued death callback if its binder is no longer the current listener
  • disconnects only when the currently registered binder dies
  • keeps the live listener through a normal ChromeCast.disconnect() call, then clears it afterwards so onDisconnected() is still delivered

Validation against the current #3570 head:

  • :play-services-cast-core:compileDebugJavaWithJavac passes
  • :play-services-cast-framework-core:compileDebugJavaWithJavac passes
  • release variants for both modules pass
  • git diff --check is clean
  • the change is one production file only: CastDeviceControllerImpl.java

I also checked #3351 and #3470 and did not find equivalent listener death handling there.

If useful, I can send the one file patch as a small follow up on top of this PR.

@paulcakeface

Copy link
Copy Markdown

I have pushed the one-file fix as a PR directly against your feat/cast-connectionless branch so it stays separate from the rest of the Cast work:

peterhel#1

It is based on your current PR head and contains only the listener lifecycle fix described above. Debug and release Cast core/framework compiles both pass.

@peterhel

Copy link
Copy Markdown
Author

Incorporated the connectionless listener lifecycle fix from @paulcakeface — thank you, this was a solid catch. It is now the top commit here (4619902).

It addresses two real bugs:

  • A stale binder-death from a replaced/unregistered listener could call disconnect() and tear down a newer listener's active Cast session (the shared DeathRecipient was never unlinked). Death recipients are now tracked per registration and teardown is identity-guarded — a queued death for an old binder is ignored.
  • disconnect() cleared the listener before ChromeCast.disconnect(), which synchronously emits connectionEvent(false) and is what delivers onDisconnected(SUCCESS) — so the callback was being suppressed. It now disconnects first, then clears the listener.

One production file, CastDeviceControllerImpl.java; Cast core + framework compile clean on debug and release.

The on* callbacks read this.listener twice (null-check then invoke); a
concurrent clearListener between the two reads would NPE on the ChromeCast
event thread. Read the field once into a local and make it volatile so the
snapshot reliably observes cross-thread writes.
@peterhel

Copy link
Copy Markdown
Author

Hey @mar-v-in, no rush at all, just a friendly ping on this one. CI is green on both debug and release, it's still mergeable against current master, and the connectionless listener lifecycle fix from @paulcakeface is folded in. Happy to rebase or split it into smaller pieces if that makes review easier. Whenever you have a slot 🙏🏼

@bonmoja-p-coder

This comment was marked as duplicate.

@mar-v-in mar-v-in added this to the 0.3.18 milestone Oct 7, 2026
@peterhel

peterhel commented Oct 7, 2026

Copy link
Copy Markdown
Author

Here's the proof you asked for. (Using my correct account, sorry for the hassle)

Short clip: Netflix casting a DRM title to the Chromecast through the connectionless controller from this PR, playing on the TV. Pixel 2 running microG with the patch.

The logcat (attached) shows it end to end: Netflix binds the cast device controller with connectionless_client_record_id and EXTRA_USE_ROUTE_CONNECTION=false, then setListener() and connect(), then the receiver launches and ApplicationMetadata comes back. Same code path Prime and Disney+ use, Netflix is just the clearest one to show.

Happy to grab another app on video if you want it.

Closes #580

cast-pr.mp4

drm-netflix-20261006.logcat.txt

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.

4 participants