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

DesktopUpnpServiceHolder.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 kotlinx.coroutines.flow.update
11import org.jupnp.DefaultUpnpServiceConfiguration
12import org.jupnp.UpnpService
13import org.jupnp.controlpoint.ControlPoint
14import org.jupnp.model.message.header.DeviceTypeHeader
15import org.jupnp.model.meta.LocalDevice
16import org.jupnp.model.meta.RemoteDevice
17import org.jupnp.model.types.UDADeviceType
18import org.jupnp.registry.DefaultRegistryListener
19import org.jupnp.registry.Registry
20import java.net.NetworkInterface
21
22/**
23 * Desktop UPnP holder. Instrumented with stdout diagnostics.
24 *
25 * Critical jUPnP pitfall that bit us once already: `UpnpServiceImpl()` (no-arg) leaves
26 * `configuration` null and `startup()` NPEs at line 256 dereferencing `configuration.getClass()`.
27 * The no-arg constructor exists only so subclasses like `AndroidUpnpServiceImpl` can inject
28 * the configuration later. From plain-JVM code you MUST pass a `UpnpServiceConfiguration` —
29 * we use `DefaultUpnpServiceConfiguration`.
30 *
31 * Interface scoping: if the machine has multiple UP interfaces (VPN, docker, libvirt), jUPnP's
32 * default auto-selection can land on the wrong one. Before constructing the service we set
33 * the `org.jupnp.network.useInterfaces` system property to a comma-separated list of the
34 * interfaces we're confident are "real LAN" — UP, non-loopback, MULTICAST-capable, and
35 * whose name doesn't match known virtual/VPN patterns.
36 */
37class DesktopUpnpServiceHolder : UpnpServiceHolder {
38
39 val scope = CoroutineScope(SupervisorJob())
40
41 private val _servers = MutableStateFlow<List<MediaServer>>(emptyList())
42 override val servers: StateFlow<List<MediaServer>> = _servers.asStateFlow()
43
44 private val _renderers = MutableStateFlow<List<RemoteRenderer>>(emptyList())
45 override val renderers: StateFlow<List<RemoteRenderer>> = _renderers.asStateFlow()
46
47 private var service: UpnpService? = null
48
49 override val controlPoint: ControlPoint?
50 get() = service?.controlPoint
51
52 private val listener = object : DefaultRegistryListener() {
53 override fun remoteDeviceAdded(registry: Registry, device: RemoteDevice) {
54 val udn = device.identity.udn.identifierString
55 val kind = "${device.type.namespace}:${device.type.type}:${device.type.version}"
56 val name = device.details?.friendlyName ?: "(no friendly name)"
57 log("Remote device seen: $name [$kind] udn=$udn")
58
59 if (device.type.namespace == UDADeviceType.DEFAULT_NAMESPACE) {
60 when (device.type.type) {
61 "MediaServer" -> {
62 log(" → MediaServer; adding to server list")
63 // update {}: jUPnP calls this from several threads; a
64 // plain read-modify-write lost one of two devices that
65 // announced together (Team 3 P2-10).
66 val server = device.toMediaServer()
67 _servers.update { l -> l.filterNot { it.udn == udn } + server }
68 }
69 "MediaRenderer" -> {
70 // Skip the UDN we generated for ourselves — jUPnP doesn't
71 // typically loop back local devices via the remote listener
72 // (LocalDevice and RemoteDevice are separate registry shelves)
73 // but defensive filter in case a future jUPnP version does.
74 log(" → MediaRenderer; adding to renderer list")
75 val renderer = device.toRemoteRenderer()
76 _renderers.update { l -> l.filterNot { it.udn == udn } + renderer }
77 }
78 else -> { /* other types ignored — Earhorn doesn't drive Light, BinaryControl, etc. */ }
79 }
80 }
81 }
82
83 override fun remoteDeviceRemoved(registry: Registry, device: RemoteDevice) {
84 val udn = device.identity.udn.identifierString
85 log("Remote device removed: udn=$udn")
86 _servers.update { l -> l.filterNot { it.udn == udn } }
87 _renderers.update { l -> l.filterNot { it.udn == udn } }
88 }
89 }
90
91 override fun start() {
92 log("=== DesktopUpnpServiceHolder.start() ===")
93 logEnvironment()
94 configureInterfaces()
95
96 try {
97 if (service != null) {
98 log("Already started; firing a fresh M-SEARCH.")
99 service?.controlPoint?.let { searchByType(it) }
100 return
101 }
102 // Resolve the streaming port. We persist whatever port we pick on
103 // first run to ~/.local/share/earhorn/upnp.port (XDG_DATA_HOME-aware)
104 // and reuse it on subsequent runs. Random ephemeral ports change the
105 // SSDP LOCATION URL on every restart, and UPnP control points like
106 // BubbleUPnP cache the renderer endpoint by UDN — so a fresh port
107 // means the first play attempt after restart fails with a stale-cache
108 // 501 until the control point is force-killed. Persisting the port
109 // keeps the LOCATION URL stable across runs.
110 val port = resolveStreamingPort()
111 log("Constructing UpnpServiceImpl(DefaultUpnpServiceConfiguration(port=$port))…")
112 // Event subscriptions must call back the subscriber itself (CallStranger).
113 val svc = PeerOnlySubscriptionsUpnpService(DefaultUpnpServiceConfiguration(port))
114 log("Starting jUPnP (UpnpService.startup())…")
115 svc.startup()
116 log("jUPnP stack is up.")
117
118 svc.registry.addListener(listener)
119 val seeded = svc.registry.remoteDevices?.size ?: 0
120 log("Seeding from registry: $seeded existing device(s)")
121 svc.registry.remoteDevices?.forEach { listener.remoteDeviceAdded(svc.registry, it) }
122
123 service = svc
124 log("Firing initial SSDP M-SEARCH (ssdp:all)…")
125 searchByType(svc.controlPoint)
126 log("Discovery is now running. Devices will appear above as they respond.")
127 } catch (t: Throwable) {
128 log("!!! start() failed — discovery disabled this session")
129 t.printStackTrace(System.out)
130 }
131 }
132
133 override fun stop() {
134 log("DesktopUpnpServiceHolder.stop()")
135 service?.registry?.removeListener(listener)
136 runCatching { service?.shutdown() }
137 service = null
138 _servers.value = emptyList()
139 _renderers.value = emptyList()
140 }
141
142 override fun rescan() {
143 log("DesktopUpnpServiceHolder.rescan() — firing SSDP M-SEARCH")
144 val svc = service ?: run {
145 log(" (stack wasn't up — starting now)")
146 start()
147 return
148 }
149 runCatching { searchByType(svc.controlPoint) }
150 }
151
152 /** design decision: ask music servers and speakers, not everyone.
153 * A device answers for its own type and every older version of it. */
154 private fun searchByType(cp: ControlPoint) {
155 cp.search(DeviceTypeHeader(UDADeviceType("MediaServer", 1)))
156 cp.search(DeviceTypeHeader(UDADeviceType("MediaRenderer", 1)))
157 }
158
159 override fun findDevice(udn: String): RemoteDevice? =
160 service?.registry?.remoteDevices?.firstOrNull {
161 it.identity.udn.identifierString == udn
162 }
163
164 override fun registerLocalDevice(device: LocalDevice): Boolean {
165 val svc = service ?: run {
166 log("registerLocalDevice: stack not up; skipping")
167 return false
168 }
169 val udn = device.identity.udn.identifierString
170 // Idempotent: jUPnP's registry.addDevice() throws on duplicate, so check first.
171 val existing = svc.registry.localDevices?.any {
172 it.identity.udn.identifierString == udn
173 } ?: false
174 if (existing) {
175 log("registerLocalDevice: $udn already registered; no-op")
176 return true
177 }
178 return runCatching {
179 svc.registry.addDevice(device)
180 val name = device.details?.friendlyName ?: "(no friendly name)"
181 log("Registered LocalDevice '$name' udn=$udn — visible to LAN")
182 true
183 }.getOrElse { t ->
184 log("registerLocalDevice failed: ${t.message}")
185 t.printStackTrace(System.out)
186 false
187 }
188 }
189
190 override fun unregisterLocalDevice(device: LocalDevice) {
191 val svc = service ?: return
192 val udn = device.identity.udn.identifierString
193 runCatching {
194 svc.registry.removeDevice(device)
195 log("Unregistered LocalDevice udn=$udn (sent ssdp:byebye)")
196 }.onFailure { t ->
197 // Most likely "device not in registry" — fine, that's what we wanted.
198 log("unregisterLocalDevice: ${t.message}")
199 }
200 }
201
202 private fun RemoteDevice.toMediaServer(): MediaServer {
203 val details = details
204 return MediaServer(
205 udn = identity.udn.identifierString,
206 friendlyName = details?.friendlyName ?: displayString,
207 manufacturer = details?.manufacturerDetails?.manufacturer,
208 modelName = details?.modelDetails?.modelName,
209 modelDescription = details?.modelDetails?.modelDescription,
210 baseUrl = identity.descriptorURL.toExternalForm(),
211 iconUrl = icons?.firstOrNull()?.uri?.toString()
212 )
213 }
214
215 private fun RemoteDevice.toRemoteRenderer(): RemoteRenderer {
216 val details = details
217 return RemoteRenderer(
218 udn = identity.udn.identifierString,
219 friendlyName = details?.friendlyName ?: displayString,
220 manufacturer = details?.manufacturerDetails?.manufacturer,
221 modelName = details?.modelDetails?.modelName,
222 modelDescription = details?.modelDetails?.modelDescription,
223 baseUrl = identity.descriptorURL.toExternalForm(),
224 iconUrl = icons?.firstOrNull()?.uri?.toString()
225 )
226 }
227
228 // ---- Interface scoping ------------------------------------------------------------------
229
230 /**
231 * Restrict jUPnP to real LAN interfaces by setting `org.jupnp.network.useInterfaces`
232 * with a comma-separated list of names.
233 *
234 * We include interfaces that are: UP, not loopback, multicast-capable, not obviously
235 * virtual/VPN/container by name. Anything else is excluded so jUPnP never considers it.
236 *
237 * If the user has already set the property themselves, we respect their choice.
238 *
239 * With no such interface we do NOT let jUPnP auto-select (Team 4 #17): that
240 * picks every interface, VPN and tailnet included, for discovery, the stream
241 * server and the published renderer. We name a non-existent interface
242 * instead, so nothing is exposed and the log says why.
243 */
244 private fun configureInterfaces() {
245 val existing = System.getProperty(JUPNP_USE_INTERFACES_PROP)
246 if (!existing.isNullOrBlank() && existing != interfacesWeSet) {
247 log("Respecting existing $JUPNP_USE_INTERFACES_PROP='$existing'")
248 return
249 }
250 val chosen = runCatching {
251 NetworkInterface.getNetworkInterfaces().toList().filter { nif ->
252 nif.isUp &&
253 !nif.isLoopback &&
254 nif.supportsMulticast() &&
255 !isVirtualByName(nif.name)
256 }.map { it.name }
257 }.getOrDefault(emptyList())
258
259 val joined = if (chosen.isEmpty()) {
260 log("No LAN interface found (only loopback, VPN or virtual ones are up) — " +
261 "music servers and speakers can't be found until one is connected.")
262 NO_INTERFACE
263 } else chosen.joinToString(",")
264 System.setProperty(JUPNP_USE_INTERFACES_PROP, joined)
265 interfacesWeSet = joined
266 log("Set $JUPNP_USE_INTERFACES_PROP=$joined")
267 }
268
269 /** What [configureInterfaces] last wrote, so a later start re-evaluates our
270 * own choice (Wi-Fi may have come up since) but never the user's. */
271 private var interfacesWeSet: String? = null
272
273 // ---- Diagnostics ------------------------------------------------------------------------
274
275 private fun log(msg: String) {
276 println("[EarhornUPnP] $msg")
277 }
278
279 private fun logEnvironment() {
280 log("Java: ${System.getProperty("java.version")} " +
281 "vendor=${System.getProperty("java.vendor")}")
282 log("OS: ${System.getProperty("os.name")} ${System.getProperty("os.version")} " +
283 "arch=${System.getProperty("os.arch")}")
284 log("preferIPv4Stack=${System.getProperty("java.net.preferIPv4Stack")} " +
285 "preferIPv6Addresses=${System.getProperty("java.net.preferIPv6Addresses")} " +
286 "org.jupnp.network.useIPv6=${System.getProperty("org.jupnp.network.useIPv6")}")
287
288 log("Available network interfaces:")
289 var hasActiveVirtual = false
290 try {
291 NetworkInterface.getNetworkInterfaces()?.toList()?.forEach { nif ->
292 val flags = buildList {
293 if (nif.isUp) add("UP") else add("DOWN")
294 if (nif.isLoopback) add("LOOPBACK")
295 if (nif.isVirtual) add("VIRTUAL")
296 if (nif.supportsMulticast()) add("MULTICAST")
297 }.joinToString(",")
298 val v4 = nif.inetAddresses?.toList().orEmpty()
299 .filter { it.address.size == 4 }
300 .joinToString(",") { it.hostAddress }
301 log(" ${nif.name}${if (nif.displayName != nif.name) " (${nif.displayName})" else ""} " +
302 "[$flags] v4=[$v4]")
303 // Only flag interfaces that could actually interfere: UP, not loopback,
304 // MULTICAST-capable, and matching a virtual/VPN name pattern.
305 if (nif.isUp && !nif.isLoopback && nif.supportsMulticast() &&
306 isVirtualByName(nif.name)
307 ) hasActiveVirtual = true
308 }
309 } catch (t: Throwable) {
310 log(" (failed to enumerate: ${t.message})")
311 }
312 if (hasActiveVirtual) {
313 log("Note: a virtual/VPN interface is UP and multicast-capable. jUPnP may bind " +
314 "to it instead of the real LAN. useInterfaces filtering will exclude it.")
315 }
316 }
317
318 companion object {
319 private const val JUPNP_USE_INTERFACES_PROP = "org.jupnp.network.useInterfaces"
320
321 /** An interface name that matches nothing: jUPnP then binds nowhere. */
322 private const val NO_INTERFACE = "earhorn-no-lan-interface"
323
324 /**
325 * Resolve the streaming port. On first run, picks an ephemeral free port and
326 * persists it to the user data dir. On subsequent runs, reads the persisted
327 * port, verifies it's still bindable, and reuses it. If the persisted port is
328 * unreadable, corrupt, or no longer bindable (e.g. some other process took it),
329 * we fall back to a fresh ephemeral pick and overwrite the file.
330 *
331 * Why persist: random ephemeral ports change the SSDP LOCATION URL on every
332 * restart. UPnP control points cache renderer endpoints by UDN — so a fresh
333 * port means the first play attempt after restart fails with a stale-cache
334 * error until the user force-kills the control point. A stable port across
335 * runs eliminates that whole class of bug.
336 */
337 private fun resolveStreamingPort(): Int {
338 val portFile = userDataDir().resolve("upnp.port")
339 val cached = runCatching { portFile.readText().trim().toIntOrNull() }.getOrNull()
340 if (cached != null && cached in 1..65535 && isPortFree(cached)) {
341 // Don't take the port now — there's still a tiny TOCTOU between this
342 // and jUPnP's bind, same as the ephemeral path. In practice fine.
343 return cached
344 }
345 val fresh = pickFreeTcpPort()
346 runCatching {
347 portFile.parentFile?.mkdirs()
348 portFile.writeText(fresh.toString())
349 }.onFailure { t ->
350 // Persisting failed (read-only home, permissions, etc.) — the port
351 // still works for this run; we just won't get the cross-run stability.
352 // Non-fatal, log and continue.
353 System.err.println(
354 "[EarhornUPnP] Could not persist streaming port to $portFile: " +
355 "${t.javaClass.simpleName}: ${t.message}"
356 )
357 }
358 return fresh
359 }
360
361 /**
362 * Test whether a TCP port is currently bindable on the wildcard address.
363 * Returns true if we successfully bound (and immediately released) the port,
364 * false if anything threw (BindException for in-use, SecurityException for
365 * privileged ports under a security manager, etc.).
366 */
367 private fun isPortFree(port: Int): Boolean = runCatching {
368 java.net.ServerSocket(port).close()
369 true
370 }.getOrDefault(false)
371
372 /**
373 * User data directory for Earhorn, following the XDG Base Directory spec on
374 * Linux (where this desktop app lives). Honors `$XDG_DATA_HOME` if set, else
375 * falls back to `~/.local/share`. The directory is NOT created here —
376 * callers materialize it lazily via `parentFile.mkdirs()` only when writing.
377 */
378 private fun userDataDir(): java.io.File {
379 val xdg = System.getenv("XDG_DATA_HOME")?.takeIf { it.isNotBlank() }
380 ?: (System.getProperty("user.home") + "/.local/share")
381 return java.io.File(xdg, "earhorn")
382 }
383
384 /**
385 * Ask the OS for a free TCP port by opening a ServerSocket on port 0, reading the
386 * assigned port, and immediately closing the socket. There's a tiny TOCTOU window
387 * between close and jUPnP's bind, but in practice this is reliable for an ephemeral
388 * port grab on a desktop. Used by [resolveStreamingPort] as the fallback when no
389 * persisted port is available or the persisted one is no longer bindable.
390 */
391 private fun pickFreeTcpPort(): Int =
392 java.net.ServerSocket(0).use { it.localPort }
393
394 /** Interface name prefixes we treat as "not real LAN". */
395 private val VIRTUAL_PREFIXES = listOf(
396 "docker", // docker bridge
397 "br-", // docker custom networks
398 "virbr", // libvirt
399 "vmnet", // VMware
400 "vboxnet", // VirtualBox
401 "tailscale", // Tailscale
402 "zt", // ZeroTier
403 "wg", // WireGuard (covers wgpia0, wg0, etc.)
404 "tun", // OpenVPN / generic tun
405 "tap", // generic tap
406 "ppp", // PPP / older VPN
407 "utun", // macOS tun (if someone runs this on mac)
408 "veth", // container veth pair host side
409 )
410
411 private fun isVirtualByName(name: String): Boolean =
412 VIRTUAL_PREFIXES.any { name.startsWith(it) }
413 }
414}