Skip to content

feat(android): Sonos UPnP as a second playback destination#77

Open
puckey wants to merge 14 commits into
feature/castfrom
feature/sonos
Open

feat(android): Sonos UPnP as a second playback destination#77
puckey wants to merge 14 commits into
feature/castfrom
feature/sonos

Conversation

@puckey

@puckey puckey commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Sonos UPnP as a second Android playback destination

Adds Sonos support on Android as the second "playback destination" backend — the data point ADR 0003 deferred the destination-agnostic seam for. A Sonos speaker fetches and plays the live stream itself over UPnP while the phone is a remote control, exactly like the Google Cast model.

Based on feature/cast (this PR targets it). Heads-up: feature/cast did not compile on its own — the first commit here fixes three pre-existing Kotlin errors to establish a green baseline.

How it works (no new JS API)

Sonos is a custom AndroidX MediaRouteProvider, so speakers appear in the same picker as Cast. The existing Cast* JS surface (showCastPicker, useCastState, getCastState, isCasting, getCastDeviceName, onCastStateChanged, endCastSession, retain/releaseCastDiscovery) now covers both backends via a DestinationCoordinator. Route selection drives the existing Player.startCasting swap through a SonosPlayer (Media3 SimpleBasePlayer).

  • UPnP hand-rolled (no jUPnP): SSDP discovery, device-description XML, AVTransport/RenderingControl SOAP, DIDL-Lite, and the x-rincon-mp3radio:// radio-scheme rewrite — all factored into pure, JVM-unit-tested units. Only new dep: androidx.mediarouter (no Play Services), in main, so Sonos works in Cast-opt-out builds too.
  • Live-only: one stream, no seek/queue on the device.
  • Self-contained URLs: the speaker fetches media + artwork itself, so both are resolved with target:'cast' (shared RemoteTrackResolver).

Scope / decisions

  • Android only. iOS Sonos is out of scope (no swappable player, same as Cast iOS).
  • Public API kept as Cast* rather than renamed to Destination* — the Nitro spec is shared with iOS and renaming it can't be verified here; deferred to a cross-platform pass (see ADR 0004).

Testing

  • ~58 Sonos JVM unit tests (SSDP/SOAP/DIDL/device-XML/transport-map/URL-rewrite, plus MockWebServer round-trips for SoapClient/SonosTransport/SonosDiscoverer). Full testDebugUnitTest green; both cast and noCast variants compile; JS vitest 73/73.
  • No real Sonos hardware in CI → discovery I/O, the route provider, and end-to-end playback are covered by the manual hardware checklist in website/guide/sonos.md.
  • App must declare CHANGE_WIFI_MULTICAST_STATE (documented; added to the example app).

Reviews performed

Protocol component review → fixes; a 3-lens thermonuclear review (concurrency / protocol / build-safety) → fixed a default-build crash, a player leak, connect/dueling-swap races, unfetchable artwork, paused-handoff playback, and a destructive volume bug; an adversarial verification pass → caught + fixed one residual race; a structural code-quality review → deleted the orphaned picker path and de-duplicated the resolver (−71 LOC).

Docs

website/guide/sonos.md (+ sidebar/cross-links), ADR 0004, CONTEXT glossary entry, README bullet. Design spec under docs/superpowers/specs/.

puckey added 14 commits June 25, 2026 11:53
The feature/cast branch was committed mid-review-pass and never compiled
locally (its TODO-CAST.md notes 'Do not run platform builds locally'). Three
distinct errors blocked compileDebugKotlin:

- CastReSign: MediaQueueItem.Builder has no setMedia(); rebuild the item from a
  fresh MediaInfo, carrying over the item-level fields (autoplay, preloadTime,
  customData, startTime-when-set).
- CastMediaItemConverter: a KDoc containing the text `audio/*` opened a NESTED
  Kotlin block comment (block comments nest in Kotlin), leaving the outer KDoc
  unclosed and swallowing the companion object -> contentTypeFor unresolved.
- CastReSign: MediaQueueItem.customData is nullable; guard before setCustomData.

Establishes a green baseline (compileDebugKotlin + testDebugUnitTest pass)
before building Sonos UPnP on the destination seam.
Design spec: generalize the Cast 'playback destination' seam (the second
backend ADR 0003 deferred) and add a hand-rolled UPnP/SSDP Sonos backend
alongside Google Cast. Android-only; live-only (single-item, no seek/grouping).
SonosPlayer as a Media3 SimpleBasePlayer plugs into the existing setPlayer swap;
pure protocol units (SSDP/SOAP/DIDL/device-XML parsers) are JVM-unit-testable.
No real hardware here -> protocol unit tests + MockWebServer + manual checklist.
Per review: instead of a bespoke Sonos device-list JS API, model Sonos as a
custom AndroidX MediaRouteProvider (the same mechanism the Cast SDK uses). Cast
and Sonos then share one discovery, one picker (showCastPicker -> showOutputPicker),
and one route-selection path; only the transport differs (CastPlayer vs
SonosPlayer). Drops getSonosDevices/connectSonosDevice/onSonosDevicesChanged and
src/features/sonos.ts entirely. Adds androidx.mediarouter (small) to main.
Honest caveat recorded: app MediaRouteChooserDialog is guaranteed; the system
Output Switcher (MediaRouter2 bridge) is verify-on-hardware.
First three pure, JVM-unit-tested protocol units for the Sonos UPnP backend:
- SsdpMessages: build the ZonePlayer M-SEARCH datagram; parse SSDP responses
  (case-insensitive headers, bare-LF tolerant, requires 200 + LOCATION + USN).
- DeviceDescriptionParser: DOM-parse device_description.xml -> SonosDevice;
  walks the embedded MediaRenderer for AVTransport/RenderingControl control URLs;
  resolves relative vs absolute; rejects non-Sonos / missing-service / malformed.
- DidlLite: deterministic, XML-escaped DIDL-Lite metadata (audioBroadcast for
  live, musicTrack otherwise); omits blank optionals.

All test-first (watched RED then GREEN). 19 tests; no hardware needed.
…pper (TDD)

- SoapEnvelopes: byte-exact SOAP bodies + SOAPAction for SetAVTransportURI
  (URL-escaped, DIDL double-escaped), Play/Pause/Stop/GetTransportInfo,
  Set/GetVolume (clamped 0..100, Master channel), SetMute.
- SoapResponseParser: DOM parse of CurrentTransportState, CurrentVolume, and
  prefix-agnostic SOAP <Fault> -> UPnPError(code, description).
- TransportStateMapper: UPnP state -> Media3 state (+isPlaying); STOPPED/
  NO_MEDIA_PRESENT -> IDLE (live has no end), unknown -> IDLE, case-insensitive.

Test-first (RED then GREEN). The full pure protocol layer is now complete and
JVM-unit-tested with zero hardware: SSDP, device-description, DIDL-Lite, SOAP
build+parse, transport mapping.
OkHttp-based SoapClient.execute(controlUrl, action): posts text/xml with a
quoted SOAPACTION, returns the 2xx body, and throws SoapException(httpCode,
fault) parsed from the body on non-2xx. Verified end-to-end against a local
MockWebServer (asserts method/path/headers/body wire format + fault path).
Adds testImplementation okhttp3:mockwebserver:5.3.0.
…sing)

Adversarial review of the protocol layer, fixes applied test-first:
- C2: SonosStreamUri normalizer -> x-rincon-mp3radio:// for MP3/ICY radio (the
  scheme Sonos needs to start raw radio); HLS/DASH/AAC/FLAC left as http(s).
  RG is live radio, so this is the highest-value real-hardware fix.
- C1: M-SEARCH gains USER-AGENT and MX default 2 (UPnP DA 1.1; better discovery).
- M1: DeviceDescriptionParser honours <URLBase> over the retrieval location.
- M2: root device selected by structure (direct <device> child), not item(0).
- M3: SoapResponseParser parses each response once.
- M4: volume() is fault-safe like transportState().
- m1: escape ' as &#39; (legacy-parser-safe); m3: SSDP status code checked as a
  discrete token. Added URLBase, IPv6-location, and volume-fault tests.

Full module testDebugUnitTest green; 50 Sonos unit tests.
SonosTransport composes the tested protocol units into blocking device control:
setUriAndPlay (SonosStreamUri rewrite + DIDL -> SetAVTransportURI then Play),
play/pause/stop, getTransportState, set/getVolume, setMute. Verified end-to-end
against MockWebServer (asserts endpoints, SOAPACTIONs, bodies, fault path).
- SonosPlayer: a Media3 SimpleBasePlayer for a single live stream on a speaker.
  Delegates all control to the tested SonosTransport; maps Media3 commands
  (prepare/setMediaItems/playWhenReady/stop/setDeviceVolume) to SOAP and polls
  GetTransportInfo -> Media3 state. Field writes confined to the looper thread;
  SOAP on IO. Live-only (no seek/duration). Compiles against media3 1.10.1.
- SonosDiscoverer (+SsdpScanner seam): SSDP scan -> fetch+parse descriptions ->
  dedup by UDN. Fetch+parse+dedup verified via MockWebServer + a fake scanner.
- SsdpDiscovery: real UDP M-SEARCH implementation (repeat sends, bounded receive
  window, multicast lock); network path on the manual checklist.

58 Sonos unit tests; full module testDebugUnitTest green.
Wire Sonos into the existing destination path so speakers appear in the same
picker as Google Cast, with no new JS API:
- SonosMediaRouteProvider: AndroidX MediaRouteProvider publishing a route per
  discovered speaker; periodic SSDP rescan while MediaRouter actively scans;
  RouteController onSelect/onUnselect/onSetVolume.
- SonosBackend: turns route selection into the existing Player.startCasting swap
  (resolves the Active Track URL with target:'cast' -> self-contained; builds a
  SonosPlayer); discovery ref-counted via the shared CastDiscoveryLeases; emits
  the existing CastStateChangedEvent so JS destination state reflects Sonos.
- DestinationCoordinator: fronts CastBridge + SonosBackend behind one API —
  multiplexed state (CONNECTED>CONNECTING>NOT_CONNECTED>NO_DEVICES), discovery
  on both, and ONE chooser whose selector unions both backends' categories.
- CastBridge gains routeSelector() (exposed from CastSessionController) for the
  union; DestinationActivityTracker (main) provides the chooser's Activity.
- AudioBrowser routes configureCast/getCastState/isCasting/showCastPicker/
  endCastSession/retain+releaseCastDiscovery through the coordinator.
- adds androidx.mediarouter:mediarouter:1.7.0 to main.

Both cast and noCast variants compile; full testDebugUnitTest green. Runtime
behavior is on the manual hardware checklist (no Sonos device in CI).
- website/guide/sonos.md: full Sonos guide (destination-not-route framing, one
  picker, CHANGE_WIFI_MULTICAST_STATE setup, reactive state via the existing
  destination hooks, self-contained URLs, x-rincon-mp3radio radio handling,
  limitations, and a manual hardware verification checklist). Sidebar entry +
  cross-links from audio-output.md and cast.md.
- docs/adr/0004: Sonos as a second playback destination via hand-rolled UPnP
  behind a MediaRouteProvider; records the no-bespoke-JS-API decision, the
  reused player swap, UPnP-not-jUPnP, the radio-scheme rewrite, polling over
  GENA, and the deferred public Cast->Destination rename (iOS-contract risk).
- CONTEXT.md: Sonos glossary entry under Playback destinations.
- README: Sonos feature bullet.
…volume)

Three parallel adversarial reviews (concurrency, protocol, build-safety) found
real defects; fixes applied:

CRITICAL
- Default-build crash: getCastState()/isCasting() reached MediaRouter off the JS
  thread (regressed the Cast-disabled build). SonosBackend now caches hasDevices
  from the router callback; getState() reads only fields.
- SonosPlayer leak: never released on unselect -> immortal poll loop per
  reconnect. SonosBackend now retains + release()s the SonosPlayer; SonosPlayer
  owns its scope and cancels it (poll + in-flight commands) in handleRelease.
- Dueling swaps: both backends could call Player.startCasting concurrently.
  startCasting now rejects a second swap while a destination player is active.
- Artwork unfetchable by the speaker: buildRemoteMediaItem now resolves artwork
  with target:'cast' (self-contained), mirroring Cast.
- Paused handoff played anyway + reorderable SOAP: SonosPlayer loads on prepare
  (after playWhenReady is known), issues Play only when playWhenReady, and
  serializes all transport commands through a Mutex (split SonosTransport.setUri).

MAJOR
- Volume key sent delta as absolute (slammed to ~0): onUpdateVolume now applies
  current+delta via a new onAdjustRouteVolume path.
- Connect/disconnect race: added the post-suspension re-check and set
  currentDevice only on success (no premature CONNECTED); onSetRouteVolume reads
  player inside runOnMain.
- Stale queued state after stop/release: looper posts guarded by released /
  pollJob-active. Volume now polled alongside transport state (slider sync).
- Lazy provider registration (first discovery lease) so non-Sonos apps never
  touch MediaRouter -> the guide's 'inert until a hook mounts' is now true.
- Example app declares CHANGE_WIFI_MULTICAST_STATE so the manual checklist runs.

Both cast and noCast variants compile; full testDebugUnitTest green.
…ted connected

Verification of the thermonuclear fixes found a residual gap: the single-active
guard in Player.startCasting returned silently, so if the OTHER backend won the
swap concurrently, the loser still committed CONNECTED + a phantom polling
player. startCasting now returns Boolean; both SonosBackend and
CastSessionController release the player they built and stay not-connected when
it returns false. Both variants compile; tests green.
…resolution

Code-quality review cleanups (behavior unchanged):
- Delete the orphaned Cast picker path: the DestinationCoordinator now owns the
  unified chooser, so CastBridge.showPicker() (interface + both impls +
  CastSessionController.showPicker) and the Cast-only CastActivityTracker were
  dead. Removed them; the coordinator + main DestinationActivityTracker are the
  single picker path.
- Extract RemoteTrackResolver: CastSessionController.buildCastMediaItem and
  SonosBackend.buildRemoteMediaItem both resolved media + artwork with
  target:'cast' (and the Sonos copy had shipped without the artwork half). Now
  one shared resolver; each backend only builds its own MediaItem.

Both variants compile; full testDebugUnitTest green.
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.

1 participant