UpnpServiceHolder.kt
235 lines · 11.4 KB
Changed in: 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 | } |


