EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

UpnpServiceHolder.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.upnp
2
3import dev.stave.domain.model.MediaServer
4import dev.stave.domain.model.RemoteRenderer
5import kotlinx.coroutines.CoroutineScope
6import kotlinx.coroutines.SupervisorJob
7import kotlinx.coroutines.flow.MutableStateFlow
8import kotlinx.coroutines.flow.StateFlow
9import kotlinx.coroutines.flow.asStateFlow
10import org.jupnp.android.AndroidUpnpService
11import org.jupnp.controlpoint.ControlPoint
12import org.jupnp.model.message.header.STAllHeader
13import org.jupnp.model.meta.LocalDevice
14import org.jupnp.model.meta.RemoteDevice
15import org.jupnp.model.types.UDADeviceType
16import org.jupnp.registry.DefaultRegistryListener
17import org.jupnp.registry.Registry
18import javax.inject.Inject
19import 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
29class 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}