feat(android): UPnP picker integration (UPnP slice 6/6)
android / Build + lint + test (push) Successful in 3m52s

UpnpDiscoveryController - Hilt singleton that owns the SSDP listener,
follows each discovered LOCATION URL to fetch + parse the device
description, projects MediaRenderers into a StateFlow<List<UpnpRoute>>.

OutputPickerController now combines system routes with the UPnP
Flow into a unified RouteSnapshot. select() branches by protocol:
SYSTEM goes through MediaRouter as before; UPNP requests a signed
stream token via POST /api/cast/stream-token then calls
AVTransport.SetAVTransportURI + Play against the discovered device.
Local playback pauses on UPnP selection.

OutputPickerSheet gains a MulticastHintRow shown when no UPnP
devices appear after a 3s grace period - the 'your router may be
blocking multicast' footer hint per the spec.

Closes the UPnP slice spec'd in
docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
On-device verification pending: pair a Sonos / UPnP speaker, confirm
discovery + selection + playback + the multicast-blocked hint.
This commit is contained in:
2026-06-03 12:52:15 -04:00
parent f8c93e013d
commit 03cdff547d
3 changed files with 266 additions and 26 deletions
@@ -4,18 +4,30 @@ import android.content.Context
import androidx.mediarouter.media.MediaControlIntent import androidx.mediarouter.media.MediaControlIntent
import androidx.mediarouter.media.MediaRouteSelector import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter import androidx.mediarouter.media.MediaRouter
import com.fabledsword.minstrel.api.endpoints.CastApi
import com.fabledsword.minstrel.api.endpoints.StreamTokenRequest
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Snapshot of the audio output route state. [current] is the live * Snapshot of the audio output route state. [current] is the live
* route audio is being delivered to. [available] is every route * route audio is being delivered to. [available] is every route the
* MediaRouter currently knows about, sorted current-first then by * picker knows about — MediaRouter system routes merged with
* [OutputRoute.Kind] (Bluetooth, Wired, BuiltIn, Other). * UPnP/DLNA renderers discovered on the LAN — sorted current-first
* then by [OutputRoute.Kind] (Bluetooth, Wired, BuiltIn, Other).
*/ */
data class RouteSnapshot( data class RouteSnapshot(
val current: OutputRoute, val current: OutputRoute,
@@ -23,29 +35,59 @@ data class RouteSnapshot(
) )
/** /**
* Hilt-singleton facade over [MediaRouter]. Owns the callback * Hilt-singleton facade over [MediaRouter] + [UpnpDiscoveryController].
* lifecycle — default passive behavior at process start (route list * Owns the callback lifecycle — default passive behavior at process
* updates without forcing Bluetooth scans), upgrades to active * start (route list updates without forcing Bluetooth scans), upgrades
* discovery while the picker sheet is open so newly-paired devices * to active discovery while the picker sheet is open so newly-paired
* appear promptly. The [routesState] StateFlow projects the current * devices appear promptly. The [routesState] StateFlow projects the
* route + sorted available list as a [RouteSnapshot]; the picker * current route + sorted available list as a [RouteSnapshot]; the
* ViewModel collects it. * picker ViewModel collects it.
*
* Selection branches on [OutputRoute.Protocol]:
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
* wired, Bluetooth)
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
* [CastApi.streamToken], drive the discovered renderer with
* AVTransport.SetAVTransportURI + Play, pause local playback so
* audio yields to the network speaker
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
* reserved for follow-up slices; ignored for now.
* *
* Mirrors the OutputPickerController role described in * Mirrors the OutputPickerController role described in
* docs/superpowers/specs/2026-06-03-android-output-picker-bluetooth-design.md. * docs/superpowers/specs/2026-06-03-android-output-picker-bluetooth-design.md
* and the UPnP extensions in
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
*/ */
@Singleton @Singleton
class OutputPickerController @Inject constructor( class OutputPickerController @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
@ApplicationScope private val scope: CoroutineScope,
private val upnpDiscovery: UpnpDiscoveryController,
private val playerController: PlayerController,
retrofit: Retrofit,
) { ) {
private val castApi: CastApi = retrofit.create()
private val mediaRouter = MediaRouter.getInstance(context) private val mediaRouter = MediaRouter.getInstance(context)
private val selector = MediaRouteSelector.Builder() private val selector = MediaRouteSelector.Builder()
.addControlCategory(MediaControlIntent.CATEGORY_LIVE_AUDIO) .addControlCategory(MediaControlIntent.CATEGORY_LIVE_AUDIO)
.build() .build()
private val routesStateInternal = MutableStateFlow(snapshotFromRouter()) /**
val routesState: StateFlow<RouteSnapshot> = routesStateInternal.asStateFlow() * Internal projection of the live MediaRouter snapshot. Refreshed
* on every Callback event; combined downstream with UPnP routes
* into the public [routesState].
*/
private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter())
val routesState: StateFlow<RouteSnapshot> = combine(
systemRoutesInternal,
upnpDiscovery.routes,
) { sys, upnp ->
val merged = sys.available + upnp.map { OutputRoute.fromUpnpRoute(it) }
RouteSnapshot(current = sys.current, available = sortRoutes(sys.current, merged))
}.stateIn(scope, SharingStarted.Eagerly, systemRoutesInternal.value)
private val callback = object : MediaRouter.Callback() { private val callback = object : MediaRouter.Callback() {
override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) = override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) =
@@ -82,35 +124,71 @@ class OutputPickerController @Inject constructor(
/** /**
* Upgrade callback registration to active discovery — call when * Upgrade callback registration to active discovery — call when
* the picker sheet opens so newly-paired Bluetooth devices * the picker sheet opens so newly-paired Bluetooth devices
* surface within a few seconds. Idempotent: re-registering with a * surface within a few seconds, and fire an SSDP M-SEARCH burst
* for UPnP renderers. Idempotent: re-registering with a
* new flag set replaces the prior registration in MediaRouter. * new flag set replaces the prior registration in MediaRouter.
*/ */
fun upgradeDiscovery() { fun upgradeDiscovery() {
mediaRouter.addCallback(selector, callback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY) mediaRouter.addCallback(selector, callback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY)
upnpDiscovery.upgradeDiscovery()
} }
/** /**
* Downgrade callback registration back to default passive behavior * Downgrade callback registration back to default passive behavior
* — call when the picker sheet closes so we don't keep Bluetooth * — call when the picker sheet closes so we don't keep Bluetooth
* scanning on for battery cost. Two-arg overload = no flag = * scanning on for battery cost. Two-arg overload = no flag =
* passive. Same idempotent re-registration semantics. * passive. Same idempotent re-registration semantics. The UPnP
* side has no symmetric downgrade (passive SSDP NOTIFY listen is
* always on); the call is preserved for API parity.
*/ */
fun downgradeDiscovery() { fun downgradeDiscovery() {
mediaRouter.addCallback(selector, callback) mediaRouter.addCallback(selector, callback)
upnpDiscovery.downgradeDiscovery()
} }
/** /**
* Select [route]. Audio routing happens at the system level; the * Select [route]. SYSTEM routes hand off to [MediaRouter]; UPNP
* MediaRouter callback's onRouteSelected fires, [refresh] re-emits, * routes mint a signed stream token + drive AVTransport on the
* the chip flips to the new device. * discovered renderer + pause local playback. Other protocols
* (CAST / SONOS) are reserved for follow-up slices and are
* silently ignored — the picker shouldn't show them yet.
*/ */
fun select(route: OutputRoute) { fun select(route: OutputRoute) {
when (route.protocol) {
OutputRoute.Protocol.SYSTEM -> selectSystem(route)
OutputRoute.Protocol.UPNP -> scope.launch { selectUpnp(route) }
OutputRoute.Protocol.CAST, OutputRoute.Protocol.SONOS -> Unit
}
}
private fun selectSystem(route: OutputRoute) {
val target = mediaRouter.routes.firstOrNull { it.id == route.id } ?: return val target = mediaRouter.routes.firstOrNull { it.id == route.id } ?: return
mediaRouter.selectRoute(target) mediaRouter.selectRoute(target)
} }
/**
* Drive the UPnP renderer: mint a token for the currently playing
* track, set the renderer's URI, play, then pause local playback so
* audio yields to the speaker. Wrapped in `runCatching` at each
* step — token failure, transport-lookup failure, and SOAP failure
* each abandon the selection cleanly rather than crashing. Errors
* surface to the operator via OkHttp logging for now; spec'd UI
* error surfacing lands in a follow-up if hardware reveals it's
* needed.
*/
private suspend fun selectUpnp(route: OutputRoute) {
val trackId = playerController.uiState.value.currentTrack?.id ?: return
val transport = upnpDiscovery.transportFor(route.id) ?: return
runCatching {
val token = castApi.streamToken(StreamTokenRequest(trackId = trackId))
transport.setAVTransportURI(token.url)
transport.play()
playerController.pause()
}
}
private fun refresh() { private fun refresh() {
routesStateInternal.value = snapshotFromRouter() systemRoutesInternal.value = snapshotFromRouter()
} }
private fun snapshotFromRouter(): RouteSnapshot { private fun snapshotFromRouter(): RouteSnapshot {
@@ -123,8 +201,8 @@ class OutputPickerController @Inject constructor(
/** /**
* Selected first, then Bluetooth, then Wired, then BuiltIn, then * Selected first, then Bluetooth, then Wired, then BuiltIn, then
* Other. Keeps the active output at the top + likely-wanted * Other (UPnP renderers fall in Other). Keeps the active output at
* alternatives next + fallback last. * the top + likely-wanted alternatives next + fallback last.
*/ */
private fun sortRoutes(current: OutputRoute, all: List<OutputRoute>): List<OutputRoute> { private fun sortRoutes(current: OutputRoute, all: List<OutputRoute>): List<OutputRoute> {
val rank: (OutputRoute) -> Int = { route -> val rank: (OutputRoute) -> Int = { route ->
@@ -22,6 +22,7 @@ import com.composables.icons.lucide.Circle
import com.composables.icons.lucide.CircleCheck import com.composables.icons.lucide.CircleCheck
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Settings import com.composables.icons.lucide.Settings
import com.composables.icons.lucide.WifiOff
/** /**
* Material 3 ModalBottomSheet listing the available output routes. * Material 3 ModalBottomSheet listing the available output routes.
@@ -29,10 +30,18 @@ import com.composables.icons.lucide.Settings
* CircleCheck; others show an empty Circle. Long route names * CircleCheck; others show an empty Circle. Long route names
* truncate cleanly via maxLines = 2 + Ellipsis. * truncate cleanly via maxLines = 2 + Ellipsis.
* *
* Permission denial footer hint is intentionally NOT inlined here — * Two footer hints, each driven by a flag the host screen owns:
* NowPlayingScreen owns the BLUETOOTH_CONNECT request flow and * - [permissionDenied] — NowPlayingScreen owns the BLUETOOTH_CONNECT
* passes a [permissionDenied] flag to control whether the hint * request flow and sets this true on denial so the user sees the
* renders. Keeps this composable focused on rendering. * "pair in Settings" affordance.
* - [noUpnpDiscovered] — set true when the picker has been open for
* a few seconds and no UPnP renderers have arrived; surfaces the
* "router may be blocking multicast" hint. Defaults to false so
* existing call sites that haven't wired the discovery-timing
* logic continue to render without the hint.
*
* Keeping the hint flags external preserves this composable's
* focus-on-rendering shape.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -41,6 +50,7 @@ fun OutputPickerSheet(
permissionDenied: Boolean, permissionDenied: Boolean,
onRouteSelected: (OutputRoute) -> Unit, onRouteSelected: (OutputRoute) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
noUpnpDiscovered: Boolean = false,
) { ) {
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
ModalBottomSheet( ModalBottomSheet(
@@ -69,6 +79,9 @@ fun OutputPickerSheet(
if (permissionDenied) { if (permissionDenied) {
PermissionHintRow() PermissionHintRow()
} }
if (noUpnpDiscovered) {
MulticastHintRow()
}
} }
} }
} }
@@ -152,6 +165,29 @@ private fun PermissionHintRow() {
} }
} }
@Composable
private fun MulticastHintRow() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Lucide.WifiOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(HINT_ICON_DP.dp),
)
Text(
text = "Your router may be blocking multicast discovery.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) { private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
OutputRoute.Kind.BuiltIn -> "Phone speaker" OutputRoute.Kind.BuiltIn -> "Phone speaker"
OutputRoute.Kind.Wired -> "Wired" OutputRoute.Kind.Wired -> "Wired"
@@ -0,0 +1,126 @@
package com.fabledsword.minstrel.player.output.upnp
import android.content.Context
import com.fabledsword.minstrel.di.ApplicationScope
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import javax.inject.Inject
import javax.inject.Singleton
/**
* Owns the SSDP listener lifecycle, fetches each discovered LOCATION's
* device description XML, and projects discovered MediaRenderers into a
* `StateFlow<List<UpnpRoute>>` that [OutputPickerController] merges with
* system routes.
*
* Passive listen runs from process start (via `init`). Active discovery
* (an M-SEARCH burst) is triggered by [upgradeDiscovery] when the
* picker sheet opens; SSDP has no symmetric "downgrade" — the passive
* NOTIFY listener stays on for the process lifetime, so
* [downgradeDiscovery] is a no-op preserved for API parity with the
* MediaRouter-side controller.
*
* Discovered devices are de-duplicated by UDN: a repeated NOTIFY for
* the same speaker replaces the prior entry rather than appending a
* duplicate row to the picker.
*/
@Singleton
class UpnpDiscoveryController @Inject constructor(
@ApplicationContext context: Context,
@ApplicationScope private val appScope: CoroutineScope,
private val okHttp: OkHttpClient,
) {
private val ssdp = SsdpDiscovery(context)
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
private var fetchJob: Job? = null
init {
ssdp.start(appScope)
fetchJob = appScope.launch(Dispatchers.IO) {
ssdp.discoveries.collect { locationUrl -> handleDiscovery(locationUrl) }
}
}
/**
* Fire an M-SEARCH burst so newly-paired speakers appear within a
* few seconds rather than waiting for the next NOTIFY beacon
* (typical SSDP cadence: every ~30min — far too slow for a UI
* that just opened).
*/
fun upgradeDiscovery() {
appScope.launch { ssdp.requestActiveScan() }
}
/**
* No-op for the SSDP socket — passive NOTIFY listen is always on
* once [init] has run. Kept symmetric with the MediaRouter side's
* `downgradeDiscovery` so the picker controller fans out to both
* without conditional logic.
*/
fun downgradeDiscovery() {
// intentionally empty: see kdoc
}
/**
* Build an [AVTransportClient] bound to the previously-discovered
* route's control URL. Returns null when the routeId isn't in the
* current snapshot — the speaker disappeared between picker open
* and tap, or the caller passed a non-UPnP routeId. Callers handle
* the null by abandoning the selection (no crash, no fallback).
*/
fun transportFor(routeId: String): AVTransportClient? {
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
return AVTransportClient(SoapClient(okHttp), route.avTransportControlUrl)
}
private suspend fun handleDiscovery(locationUrl: String) {
val route = fetchRoute(locationUrl) ?: return
routesInternal.value =
routesInternal.value.filterNot { it.id == route.id } + route
}
/**
* Fetch + parse the device description at [locationUrl] into a
* [UpnpRoute]. Returns null on URL parse failure, transport
* failure, empty body, or non-renderer device. Single-return form
* (chained `let`s + an early null guard) so detekt's default
* ReturnCount cap is respected.
*/
private fun fetchRoute(locationUrl: String): UpnpRoute? {
val url = locationUrl.toHttpUrlOrNull() ?: return null
return fetchBody(url)
?.let { DeviceDescription.parse(it, url) }
?.let { desc ->
UpnpRoute(
id = desc.udn,
name = desc.friendlyName.ifBlank { "Network speaker" },
manufacturer = desc.manufacturer,
modelName = desc.modelName,
avTransportControlUrl = desc.avTransportControlUrl,
renderingControlUrl = desc.renderingControlUrl,
)
}
}
private fun fetchBody(url: HttpUrl): String? {
val body = runCatching {
okHttp.newCall(Request.Builder().url(url).build()).execute().use {
it.body?.string()
}
}.getOrNull().orEmpty()
return body.ifEmpty { null }
}
}