From 03cdff547d7aa6ca6015e0a79c1c25946772942b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 12:52:15 -0400 Subject: [PATCH] feat(android): UPnP picker integration (UPnP slice 6/6) UpnpDiscoveryController - Hilt singleton that owns the SSDP listener, follows each discovered LOCATION URL to fetch + parse the device description, projects MediaRenderers into a StateFlow>. 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. --- .../player/output/OutputPickerController.kt | 122 ++++++++++++++--- .../player/output/OutputPickerSheet.kt | 44 +++++- .../output/upnp/UpnpDiscoveryController.kt | 126 ++++++++++++++++++ 3 files changed, 266 insertions(+), 26 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt index 8af4ce1f..206b0c97 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt @@ -4,18 +4,30 @@ import android.content.Context import androidx.mediarouter.media.MediaControlIntent import androidx.mediarouter.media.MediaRouteSelector 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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted 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.Singleton /** * Snapshot of the audio output route state. [current] is the live - * route audio is being delivered to. [available] is every route - * MediaRouter currently knows about, sorted current-first then by - * [OutputRoute.Kind] (Bluetooth, Wired, BuiltIn, Other). + * route audio is being delivered to. [available] is every route the + * picker knows about — MediaRouter system routes merged with + * UPnP/DLNA renderers discovered on the LAN — sorted current-first + * then by [OutputRoute.Kind] (Bluetooth, Wired, BuiltIn, Other). */ data class RouteSnapshot( val current: OutputRoute, @@ -23,29 +35,59 @@ data class RouteSnapshot( ) /** - * Hilt-singleton facade over [MediaRouter]. Owns the callback - * lifecycle — default passive behavior at process start (route list - * updates without forcing Bluetooth scans), upgrades to active - * discovery while the picker sheet is open so newly-paired devices - * appear promptly. The [routesState] StateFlow projects the current - * route + sorted available list as a [RouteSnapshot]; the picker - * ViewModel collects it. + * Hilt-singleton facade over [MediaRouter] + [UpnpDiscoveryController]. + * Owns the callback lifecycle — default passive behavior at process + * start (route list updates without forcing Bluetooth scans), upgrades + * to active discovery while the picker sheet is open so newly-paired + * devices appear promptly. The [routesState] StateFlow projects the + * current route + sorted available list as a [RouteSnapshot]; the + * 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 - * 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 class OutputPickerController @Inject constructor( @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 selector = MediaRouteSelector.Builder() .addControlCategory(MediaControlIntent.CATEGORY_LIVE_AUDIO) .build() - private val routesStateInternal = MutableStateFlow(snapshotFromRouter()) - val routesState: StateFlow = 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 = 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() { override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) = @@ -82,35 +124,71 @@ class OutputPickerController @Inject constructor( /** * Upgrade callback registration to active discovery — call when * 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. */ fun upgradeDiscovery() { mediaRouter.addCallback(selector, callback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY) + upnpDiscovery.upgradeDiscovery() } /** * Downgrade callback registration back to default passive behavior * — call when the picker sheet closes so we don't keep Bluetooth * 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() { mediaRouter.addCallback(selector, callback) + upnpDiscovery.downgradeDiscovery() } /** - * Select [route]. Audio routing happens at the system level; the - * MediaRouter callback's onRouteSelected fires, [refresh] re-emits, - * the chip flips to the new device. + * Select [route]. SYSTEM routes hand off to [MediaRouter]; UPNP + * routes mint a signed stream token + drive AVTransport on the + * 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) { + 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 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() { - routesStateInternal.value = snapshotFromRouter() + systemRoutesInternal.value = snapshotFromRouter() } private fun snapshotFromRouter(): RouteSnapshot { @@ -123,8 +201,8 @@ class OutputPickerController @Inject constructor( /** * Selected first, then Bluetooth, then Wired, then BuiltIn, then - * Other. Keeps the active output at the top + likely-wanted - * alternatives next + fallback last. + * Other (UPnP renderers fall in Other). Keeps the active output at + * the top + likely-wanted alternatives next + fallback last. */ private fun sortRoutes(current: OutputRoute, all: List): List { val rank: (OutputRoute) -> Int = { route -> diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerSheet.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerSheet.kt index 0ae7d6a0..173fcb46 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerSheet.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerSheet.kt @@ -22,6 +22,7 @@ import com.composables.icons.lucide.Circle import com.composables.icons.lucide.CircleCheck import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Settings +import com.composables.icons.lucide.WifiOff /** * 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 * truncate cleanly via maxLines = 2 + Ellipsis. * - * Permission denial footer hint is intentionally NOT inlined here — - * NowPlayingScreen owns the BLUETOOTH_CONNECT request flow and - * passes a [permissionDenied] flag to control whether the hint - * renders. Keeps this composable focused on rendering. + * Two footer hints, each driven by a flag the host screen owns: + * - [permissionDenied] — NowPlayingScreen owns the BLUETOOTH_CONNECT + * request flow and sets this true on denial so the user sees the + * "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) @Composable @@ -41,6 +50,7 @@ fun OutputPickerSheet( permissionDenied: Boolean, onRouteSelected: (OutputRoute) -> Unit, onDismiss: () -> Unit, + noUpnpDiscovered: Boolean = false, ) { val sheetState = rememberModalBottomSheetState() ModalBottomSheet( @@ -69,6 +79,9 @@ fun OutputPickerSheet( if (permissionDenied) { 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) { OutputRoute.Kind.BuiltIn -> "Phone speaker" OutputRoute.Kind.Wired -> "Wired" diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt new file mode 100644 index 00000000..485a9301 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt @@ -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>` 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>(emptyList()) + val routes: StateFlow> = 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 } + } +}