UpnpServiceHolder.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.upnp |
| 2 | ||
| 3 | import dev.stave.domain.model.MediaServer | |
| 4 | import dev.stave.domain.model.RemoteRenderer | |
| 5 | import kotlinx.coroutines.CoroutineScope | |
| 6 | import kotlinx.coroutines.SupervisorJob | |
| 7 | import kotlinx.coroutines.flow.MutableStateFlow | |
| 8 | import kotlinx.coroutines.flow.StateFlow | |
| 9 | import kotlinx.coroutines.flow.asStateFlow | |
| 10 | import org.jupnp.android.AndroidUpnpService | |
| 11 | import org.jupnp.controlpoint.ControlPoint | |
| 12 | import org.jupnp.model.message.header.STAllHeader | |
| 13 | import org.jupnp.model.meta.LocalDevice | |
| 14 | import org.jupnp.model.meta.RemoteDevice | |
| 15 | import org.jupnp.model.types.UDADeviceType | |
| 16 | import org.jupnp.registry.DefaultRegistryListener | |
| 17 | import org.jupnp.registry.Registry | |
| 18 | import javax.inject.Inject | |
| 19 | import javax.inject.Singleton | |
| 20 | ||
| 21 | /** | |
| 22 | * Holds the bound [AndroidUpnpService] across the app's lifetime, exposes live server + | |
| 23 | * renderer lists, owns the registry listener, and lets callers register/unregister | |
| 24 | * LocalDevices (used by the mesh-broadcast feature to advertise this device as a UPnP | |
| 25 | * MediaRenderer). Injected as a singleton and attached by MainActivity once Android | |
| 26 | * has bound the service. | |
| 27 | */ | |
| 28 | @Singleton | |
| 29 | class UpnpServiceHolder @Inject constructor() { | |
| 30 | ||
| 31 | val scope = CoroutineScope(SupervisorJob()) | |
| 32 | ||
| 33 | private val _servers = MutableStateFlow<List<MediaServer>>(emptyList()) | |
| 34 | val servers: StateFlow<List<MediaServer>> = _servers.asStateFlow() | |
| 35 | ||
| 36 | /** | |
| 37 | * MediaRenderer devices discovered on the LAN — peers in the Earhorn mesh plus any | |
| 38 | * generic UPnP renderers (BubbleUPnP, Kazoo, JRiver, etc.). Same lifecycle as | |
| 39 | * [servers]: populated by the registry listener on remoteDeviceAdded / | |
| 40 | * remoteDeviceRemoved, cleared on [detach]. | |
| 41 | */ | |
| 42 | private val _renderers = MutableStateFlow<List<RemoteRenderer>>(emptyList()) | |
| 43 | val renderers: StateFlow<List<RemoteRenderer>> = _renderers.asStateFlow() | |
| 44 | ||
| 45 | private val _service = MutableStateFlow<AndroidUpnpService?>(null) | |
| 46 | val service: StateFlow<AndroidUpnpService?> = _service.asStateFlow() | |
| 47 | ||
| 48 | val controlPoint: ControlPoint? get() = _service.value?.controlPoint | |
| 49 | ||
| 50 | /** | |
| 51 | * Local devices whose registration was requested before the AndroidUpnpService was | |
| 52 | * bound. The service binds asynchronously, so callers (notably EarhornRendererService | |
| 53 | * starting up under collectLatest from a settings flow) may legitimately register | |
| 54 | * before [attach] has run. We queue here and drain on attach. Guarded by its own | |
| 55 | * monitor — register/unregister are callable from any thread. | |
| 56 | */ | |
| 57 | private val pendingLocalDevices = mutableListOf<LocalDevice>() | |
| 58 | ||
| 59 | private val listener = object : DefaultRegistryListener() { | |
| 60 | override fun remoteDeviceAdded(registry: Registry, device: RemoteDevice) { | |
| 61 | if (device.type.namespace != UDADeviceType.DEFAULT_NAMESPACE) return | |
| 62 | // Discriminate by device type. MediaServer feeds the existing browse path, | |
| 63 | // MediaRenderer feeds the new mesh picker. Versions (MediaServer:1 vs :2, | |
| 64 | // MediaRenderer:1 vs :2 vs :3) all resolve to the same .type string so the | |
| 65 | // when-arms catch every version. | |
| 66 | when (device.type.type) { | |
| 67 | "MediaServer" -> { | |
| 68 | val udn = device.identity.udn.identifierString | |
| 69 | _servers.value = _servers.value | |
| 70 | .filterNot { it.udn == udn } | |
| 71 | .plus(device.toMediaServer()) | |
| 72 | } | |
| 73 | "MediaRenderer" -> { | |
| 74 | val udn = device.identity.udn.identifierString | |
| 75 | _renderers.value = _renderers.value | |
| 76 | .filterNot { it.udn == udn } | |
| 77 | .plus(device.toRemoteRenderer()) | |
| 78 | } | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | override fun remoteDeviceRemoved(registry: Registry, device: RemoteDevice) { | |
| 83 | val udn = device.identity.udn.identifierString | |
| 84 | _servers.value = _servers.value.filterNot { it.udn == udn } | |
| 85 | _renderers.value = _renderers.value.filterNot { it.udn == udn } | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | fun attach(service: AndroidUpnpService) { | |
| 90 | try { | |
| 91 | _service.value = service | |
| 92 | // jUPnP 3.x — unlike Cling — does NOT auto-start the UPnP stack when the Android | |
| 93 | // service binds. You must explicitly call startup() on the underlying UpnpService; | |
| 94 | // only then does the Jetty-backed stream server init, the multicast receiver join | |
| 95 | // the SSDP group, and the datagram IO open its sockets. If we skip this, the | |
| 96 | // ControlPoint exists but .search() is a no-op and no NOTIFYs arrive either. | |
| 97 | service.get()?.startup() | |
| 98 | ||
| 99 | val registry = service.registry | |
| 100 | // Two binders (MainActivity, PlaybackService) both attach; add | |
| 101 | // the listener once (I.44). | |
| 102 | registry.removeListener(listener) | |
| 103 | registry.addListener(listener) | |
| 104 | // Seed with anything already in the registry (possible if the stack was started | |
| 105 | // by a previous bind and we're reattaching). Listener handles both MediaServer | |
| 106 | // and MediaRenderer types so this seeds both lists. | |
| 107 | registry.remoteDevices.forEach { listener.remoteDeviceAdded(registry, it) } | |
| 108 | ||
| 109 | // Drain any local devices that callers asked us to register before the service | |
| 110 | // was bound. EarhornRendererService is the typical case — it starts observing | |
| 111 | // rendererBroadcastEnabled before MainActivity finishes binding, and can call | |
| 112 | // registerLocalDevice() on the very first emission. Add them to the live | |
| 113 | // registry now so ssdp:alive goes out and the LAN can see us. | |
| 114 | val queued = synchronized(pendingLocalDevices) { | |
| 115 | pendingLocalDevices.toList().also { pendingLocalDevices.clear() } | |
| 116 | } | |
| 117 | queued.forEach { dev -> | |
| 118 | runCatching { registry.addDevice(dev) } | |
| 119 | .onFailure { android.util.Log.e("EarhornUpnp", | |
| 120 | "deferred registerLocalDevice failed for ${dev.identity.udn}", it) } | |
| 121 | } | |
| 122 | ||
| 123 | // Proactively announce interest so MinimServer replies without us waiting for NOTIFY. | |
| 124 | // Search by device type, not ssdp:all (design decision 2026-09-02, | |
| 125 | // I.43): "everyone answer" made the router, SABnzbd and every other | |
| 126 | // UPnP gadget reply, and jUPnP then fetched and warned about each | |
| 127 | // description. Only music servers and speakers answer these. | |
| 128 | searchForMediaDevices(service.controlPoint) | |
| 129 | } catch (t: Throwable) { | |
| 130 | android.util.Log.e("EarhornUpnp", "attach() failed; discovery disabled this session", t) | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 134 | fun detach() { | |
| 135 | _service.value?.registry?.removeListener(listener) | |
| 136 | // Shut the stack down cleanly so sockets are released and BYEBYE messages are sent. | |
| 137 | runCatching { _service.value?.get()?.shutdown() } | |
| 138 | _service.value = null | |
| 139 | _servers.value = emptyList() | |
| 140 | _renderers.value = emptyList() | |
| 141 | } | |
| 142 | ||
| 143 | /** Force an SSDP M-SEARCH. Called from pull-to-refresh / "Rescan" action. */ | |
| 144 | fun rescan() { | |
| 145 | // Belt-and-braces: if the stack somehow isn't up (e.g. attach() threw midway), starting | |
| 146 | // it is idempotent — jUPnP guards against double-startup internally. | |
| 147 | runCatching { _service.value?.get()?.startup() } | |
| 148 | _service.value?.controlPoint?.let { searchForMediaDevices(it) } | |
| 149 | } | |
| 150 | ||
| 151 | /** M-SEARCH for MediaServer:1 and MediaRenderer:1. Per the UPnP Device | |
| 152 | * Architecture a device answers a search for its own type and every | |
| 153 | * earlier version of it, so MinimServer (MediaServer:1) and newer | |
| 154 | * servers alike reply; non-media devices stay silent. */ | |
| 155 | private fun searchForMediaDevices(cp: ControlPoint) { | |
| 156 | cp.search(org.jupnp.model.message.header.DeviceTypeHeader(UDADeviceType("MediaServer", 1))) | |
| 157 | cp.search(org.jupnp.model.message.header.DeviceTypeHeader(UDADeviceType("MediaRenderer", 1))) | |
| 158 | } | |
| 159 | ||
| 160 | fun findDevice(udn: String): RemoteDevice? = | |
| 161 | _service.value?.registry?.remoteDevices?.firstOrNull { | |
| 162 | it.identity.udn.identifierString == udn | |
| 163 | } | |
| 164 | ||
| 165 | /** | |
| 166 | * Register a local UPnP device with this device's registry so the LAN sees us as | |
| 167 | * a renderer (or server, etc. — type is encoded in the [device]). Idempotent on | |
| 168 | * UDN: registering the same UDN twice is a no-op rather than an error. Safe to | |
| 169 | * call before [attach] — the device is queued and registered when the service | |
| 170 | * binds. | |
| 171 | * | |
| 172 | * @return true if the device is now registered OR queued for registration. false | |
| 173 | * only on an actual failure (registry threw despite being bound). Caller can | |
| 174 | * treat the boolean as "your device will be discoverable" — distinguish a true- | |
| 175 | * queued from a true-registered by checking [service].value if you need to. | |
| 176 | */ | |
| 177 | fun registerLocalDevice(device: LocalDevice): Boolean { | |
| 178 | val svc = _service.value?.get() | |
| 179 | if (svc == null) { | |
| 180 | synchronized(pendingLocalDevices) { | |
| 181 | val udn = device.identity.udn | |
| 182 | if (pendingLocalDevices.none { it.identity.udn == udn }) { | |
| 183 | pendingLocalDevices.add(device) | |
| 184 | } | |
| 185 | } | |
| 186 | return true | |
| 187 | } | |
| 188 | return runCatching { | |
| 189 | svc.registry.addDevice(device) | |
| 190 | true | |
| 191 | }.onFailure { | |
| 192 | android.util.Log.e("EarhornUpnp", "registerLocalDevice failed", it) | |
| 193 | }.getOrElse { false } | |
| 194 | } | |
| 195 | ||
| 196 | /** | |
| 197 | * Remove a previously-registered local device. Sends ssdp:byebye via the registry. | |
| 198 | * Also drops it from the pending queue if it was waiting to be registered, so a | |
| 199 | * register-then-unregister-before-attach sequence cleans up correctly. | |
| 200 | */ | |
| 201 | fun unregisterLocalDevice(device: LocalDevice) { | |
| 202 | val udn = device.identity.udn | |
| 203 | synchronized(pendingLocalDevices) { | |
| 204 | pendingLocalDevices.removeAll { it.identity.udn == udn } | |
| 205 | } | |
| 206 | runCatching { _service.value?.get()?.registry?.removeDevice(device) } | |
| 207 | .onFailure { android.util.Log.e("EarhornUpnp", "unregisterLocalDevice failed", it) } | |
| 208 | } | |
| 209 | ||
| 210 | private fun RemoteDevice.toMediaServer(): MediaServer { | |
| 211 | val details = details | |
| 212 | return MediaServer( | |
| 213 | udn = identity.udn.identifierString, | |
| 214 | friendlyName = details?.friendlyName ?: displayString, | |
| 215 | manufacturer = details?.manufacturerDetails?.manufacturer, | |
| 216 | modelName = details?.modelDetails?.modelName, | |
| 217 | modelDescription = details?.modelDetails?.modelDescription, | |
| 218 | baseUrl = identity.descriptorURL.toExternalForm(), | |
| 219 | iconUrl = icons?.firstOrNull()?.uri?.toString() | |
| 220 | ) | |
| 221 | } | |
| 222 | ||
| 223 | private fun RemoteDevice.toRemoteRenderer(): RemoteRenderer { | |
| 224 | val details = details | |
| 225 | return RemoteRenderer( | |
| 226 | udn = identity.udn.identifierString, | |
| 227 | friendlyName = details?.friendlyName ?: displayString, | |
| 228 | manufacturer = details?.manufacturerDetails?.manufacturer, | |
| 229 | modelName = details?.modelDetails?.modelName, | |
| 230 | modelDescription = details?.modelDetails?.modelDescription, | |
| 231 | baseUrl = identity.descriptorURL.toExternalForm(), | |
| 232 | iconUrl = icons?.firstOrNull()?.uri?.toString() | |
| 233 | ) | |
| 234 | } | |
| 235 | } |


