feat(android): UPnP picker integration (UPnP slice 6/6)
android / Build + lint + test (push) Successful in 3m52s
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:
+100
-22
@@ -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<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() {
|
||||
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<OutputRoute>): List<OutputRoute> {
|
||||
val rank: (OutputRoute) -> Int = { route ->
|
||||
|
||||
+40
-4
@@ -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"
|
||||
|
||||
+126
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user