package dev.stave.upnp
import dev.stave.domain.model.MediaServer
import dev.stave.domain.model.RemoteRenderer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.jupnp.android.AndroidUpnpService
import org.jupnp.controlpoint.ControlPoint
import org.jupnp.model.message.header.STAllHeader
import org.jupnp.model.meta.LocalDevice
import org.jupnp.model.meta.RemoteDevice
import org.jupnp.model.types.UDADeviceType
import org.jupnp.registry.DefaultRegistryListener
import org.jupnp.registry.Registry
import javax.inject.Inject
import javax.inject.Singleton
/**
* Holds the bound [AndroidUpnpService] across the app's lifetime, exposes live server +
* renderer lists, owns the registry listener, and lets callers register/unregister
* LocalDevices (used by the mesh-broadcast feature to advertise this device as a UPnP
* MediaRenderer). Injected as a singleton and attached by MainActivity once Android
* has bound the service.
*/
@Singleton
class UpnpServiceHolder @Inject constructor() {
val scope = CoroutineScope(SupervisorJob())
private val _servers = MutableStateFlow<List<MediaServer>>(emptyList())
val servers: StateFlow<List<MediaServer>> = _servers.asStateFlow()
/**
* MediaRenderer devices discovered on the LAN — peers in the Earhorn mesh plus any
* generic UPnP renderers (BubbleUPnP, Kazoo, JRiver, etc.). Same lifecycle as
* [servers]: populated by the registry listener on remoteDeviceAdded /
* remoteDeviceRemoved, cleared on [detach].
*/
private val _renderers = MutableStateFlow<List<RemoteRenderer>>(emptyList())
val renderers: StateFlow<List<RemoteRenderer>> = _renderers.asStateFlow()
private val _service = MutableStateFlow<AndroidUpnpService?>(null)
val service: StateFlow<AndroidUpnpService?> = _service.asStateFlow()
val controlPoint: ControlPoint? get() = _service.value?.controlPoint
/**
* Local devices whose registration was requested before the AndroidUpnpService was
* bound. The service binds asynchronously, so callers (notably EarhornRendererService
* starting up under collectLatest from a settings flow) may legitimately register
* before [attach] has run. We queue here and drain on attach. Guarded by its own
* monitor — register/unregister are callable from any thread.
*/
private val pendingLocalDevices = mutableListOf<LocalDevice>()
private val listener = object : DefaultRegistryListener() {
override fun remoteDeviceAdded(registry: Registry, device: RemoteDevice) {
if (device.type.namespace != UDADeviceType.DEFAULT_NAMESPACE) return
// Discriminate by device type. MediaServer feeds the existing browse path,
// MediaRenderer feeds the new mesh picker. Versions (MediaServer:1 vs :2,
// MediaRenderer:1 vs :2 vs :3) all resolve to the same .type string so the
// when-arms catch every version.
when (device.type.type) {
"MediaServer" -> {
val udn = device.identity.udn.identifierString
_servers.value = _servers.value
.filterNot { it.udn == udn }
.plus(device.toMediaServer())
}
"MediaRenderer" -> {
val udn = device.identity.udn.identifierString
_renderers.value = _renderers.value
.filterNot { it.udn == udn }
.plus(device.toRemoteRenderer())
}
}
}
override fun remoteDeviceRemoved(registry: Registry, device: RemoteDevice) {
val udn = device.identity.udn.identifierString
_servers.value = _servers.value.filterNot { it.udn == udn }
_renderers.value = _renderers.value.filterNot { it.udn == udn }
}
}
fun attach(service: AndroidUpnpService) {
try {
_service.value = service
// jUPnP 3.x — unlike Cling — does NOT auto-start the UPnP stack when the Android
// service binds. You must explicitly call startup() on the underlying UpnpService;
// only then does the Jetty-backed stream server init, the multicast receiver join
// the SSDP group, and the datagram IO open its sockets. If we skip this, the
// ControlPoint exists but .search() is a no-op and no NOTIFYs arrive either.
service.get()?.startup()
val registry = service.registry
// Two binders (MainActivity, PlaybackService) both attach; add
// the listener once (I.44).
registry.removeListener(listener)
registry.addListener(listener)
// Seed with anything already in the registry (possible if the stack was started
// by a previous bind and we're reattaching). Listener handles both MediaServer
// and MediaRenderer types so this seeds both lists.
registry.remoteDevices.forEach { listener.remoteDeviceAdded(registry, it) }
// Drain any local devices that callers asked us to register before the service
// was bound. EarhornRendererService is the typical case — it starts observing
// rendererBroadcastEnabled before MainActivity finishes binding, and can call
// registerLocalDevice() on the very first emission. Add them to the live
// registry now so ssdp:alive goes out and the LAN can see us.
val queued = synchronized(pendingLocalDevices) {
pendingLocalDevices.toList().also { pendingLocalDevices.clear() }
}
queued.forEach { dev ->
runCatching { registry.addDevice(dev) }
.onFailure { android.util.Log.e("EarhornUpnp",
"deferred registerLocalDevice failed for ${dev.identity.udn}", it) }
}
// Proactively announce interest so MinimServer replies without us waiting for NOTIFY.
// Search by device type, not ssdp:all (design decision 2026-09-02,
// I.43): "everyone answer" made the router, SABnzbd and every other
// UPnP gadget reply, and jUPnP then fetched and warned about each
// description. Only music servers and speakers answer these.
searchForMediaDevices(service.controlPoint)
} catch (t: Throwable) {
android.util.Log.e("EarhornUpnp", "attach() failed; discovery disabled this session", t)
}
}
fun detach() {
_service.value?.registry?.removeListener(listener)
// Shut the stack down cleanly so sockets are released and BYEBYE messages are sent.
runCatching { _service.value?.get()?.shutdown() }
_service.value = null
_servers.value = emptyList()
_renderers.value = emptyList()
}
/** Force an SSDP M-SEARCH. Called from pull-to-refresh / "Rescan" action. */
fun rescan() {
// Belt-and-braces: if the stack somehow isn't up (e.g. attach() threw midway), starting
// it is idempotent — jUPnP guards against double-startup internally.
runCatching { _service.value?.get()?.startup() }
_service.value?.controlPoint?.let { searchForMediaDevices(it) }
}
/** M-SEARCH for MediaServer:1 and MediaRenderer:1. Per the UPnP Device
* Architecture a device answers a search for its own type and every
* earlier version of it, so MinimServer (MediaServer:1) and newer
* servers alike reply; non-media devices stay silent. */
private fun searchForMediaDevices(cp: ControlPoint) {
cp.search(org.jupnp.model.message.header.DeviceTypeHeader(UDADeviceType("MediaServer", 1)))
cp.search(org.jupnp.model.message.header.DeviceTypeHeader(UDADeviceType("MediaRenderer", 1)))
}
fun findDevice(udn: String): RemoteDevice? =
_service.value?.registry?.remoteDevices?.firstOrNull {
it.identity.udn.identifierString == udn
}
/**
* Register a local UPnP device with this device's registry so the LAN sees us as
* a renderer (or server, etc. — type is encoded in the [device]). Idempotent on
* UDN: registering the same UDN twice is a no-op rather than an error. Safe to
* call before [attach] — the device is queued and registered when the service
* binds.
*
* @return true if the device is now registered OR queued for registration. false
* only on an actual failure (registry threw despite being bound). Caller can
* treat the boolean as "your device will be discoverable" — distinguish a true-
* queued from a true-registered by checking [service].value if you need to.
*/
fun registerLocalDevice(device: LocalDevice): Boolean {
val svc = _service.value?.get()
if (svc == null) {
synchronized(pendingLocalDevices) {
val udn = device.identity.udn
if (pendingLocalDevices.none { it.identity.udn == udn }) {
pendingLocalDevices.add(device)
}
}
return true
}
return runCatching {
svc.registry.addDevice(device)
true
}.onFailure {
android.util.Log.e("EarhornUpnp", "registerLocalDevice failed", it)
}.getOrElse { false }
}
/**
* Remove a previously-registered local device. Sends ssdp:byebye via the registry.
* Also drops it from the pending queue if it was waiting to be registered, so a
* register-then-unregister-before-attach sequence cleans up correctly.
*/
fun unregisterLocalDevice(device: LocalDevice) {
val udn = device.identity.udn
synchronized(pendingLocalDevices) {
pendingLocalDevices.removeAll { it.identity.udn == udn }
}
runCatching { _service.value?.get()?.registry?.removeDevice(device) }
.onFailure { android.util.Log.e("EarhornUpnp", "unregisterLocalDevice failed", it) }
}
private fun RemoteDevice.toMediaServer(): MediaServer {
val details = details
return MediaServer(
udn = identity.udn.identifierString,
friendlyName = details?.friendlyName ?: displayString,
manufacturer = details?.manufacturerDetails?.manufacturer,
modelName = details?.modelDetails?.modelName,
modelDescription = details?.modelDetails?.modelDescription,
baseUrl = identity.descriptorURL.toExternalForm(),
iconUrl = icons?.firstOrNull()?.uri?.toString()
)
}
private fun RemoteDevice.toRemoteRenderer(): RemoteRenderer {
val details = details
return RemoteRenderer(
udn = identity.udn.identifierString,
friendlyName = details?.friendlyName ?: displayString,
manufacturer = details?.manufacturerDetails?.manufacturer,
modelName = details?.modelDetails?.modelName,
modelDescription = details?.modelDetails?.modelDescription,
baseUrl = identity.descriptorURL.toExternalForm(),
iconUrl = icons?.firstOrNull()?.uri?.toString()
)
}
}