From dc5b8252bb673fdfaba733a33ee434659c0d39e2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 12:01:00 -0400 Subject: [PATCH] feat(android): UPnP SSDP discovery + device description (UPnP slice 4/6) SsdpDiscovery - UDP multicast listener on 239.255.255.250:1900. Passive NOTIFY listen always-on once start() is called; explicit M-SEARCH M-SEARCH on requestActiveScan() (called when picker sheet opens). WifiManager.MulticastLock held only while running. Emits each discovered LOCATION URL on a SharedFlow for downstream description-fetching. DeviceDescription - pull-parse the XML returned from a LOCATION URL, extracting friendlyName / manufacturer / modelName + AVTransport + RenderingControl service control URLs. Filters out devices without AVTransport (we can't control them). Three unit tests cover a Sonos-shaped description, a non-renderer device that should be dropped, and a minimal description with missing optional fields. --- .../player/output/upnp/DeviceDescription.kt | 122 +++++++++++++++++ .../player/output/upnp/SsdpDiscovery.kt | 126 ++++++++++++++++++ .../output/upnp/DeviceDescriptionTest.kt | 120 +++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SsdpDiscovery.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt new file mode 100644 index 00000000..e672deb6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescription.kt @@ -0,0 +1,122 @@ +package com.fabledsword.minstrel.player.output.upnp + +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory + +/** + * Pull-parsed UPnP device description (the XML returned from a + * discovered LOCATION URL). Carries the bits we actually need for + * the picker: friendlyName / manufacturer / modelName for display, + * AVTransport + RenderingControl control URLs for command dispatch. + * + * Filtered to MediaRenderer-capable devices — anything without an + * AVTransport service control URL is dropped by [parse] returning + * null (we can't make it play). + */ +data class DeviceDescription( + val udn: String, + val friendlyName: String, + val manufacturer: String, + val modelName: String, + val avTransportControlUrl: HttpUrl, + val renderingControlUrl: HttpUrl?, +) { + companion object { + private const val AVT_SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1" + private const val RC_SERVICE_TYPE = "urn:schemas-upnp-org:service:RenderingControl:1" + + private const val TAG_SERVICE = "service" + private const val TAG_UDN = "UDN" + private const val TAG_FRIENDLY_NAME = "friendlyName" + private const val TAG_MANUFACTURER = "manufacturer" + private const val TAG_MODEL_NAME = "modelName" + private const val TAG_SERVICE_TYPE = "serviceType" + private const val TAG_CONTROL_URL = "controlURL" + + /** + * Parse [xml] (the body fetched from the SSDP LOCATION URL), + * resolving relative service control URLs against [base]. + * Returns null when AVTransport is missing — we have no way + * to control the device without it. + */ + fun parse(xml: String, base: HttpUrl): DeviceDescription? { + val parser = XmlPullParserFactory.newInstance().newPullParser().apply { + setInput(xml.reader()) + } + val acc = ParseState() + while (parser.eventType != XmlPullParser.END_DOCUMENT) { + handleEvent(parser, acc, base) + parser.next() + } + val avt = acc.avtControlUrl ?: return null + return DeviceDescription( + udn = acc.udn, + friendlyName = acc.friendlyName, + manufacturer = acc.manufacturer, + modelName = acc.modelName, + avTransportControlUrl = avt, + renderingControlUrl = acc.rcControlUrl, + ) + } + + private fun handleEvent(parser: XmlPullParser, acc: ParseState, base: HttpUrl) { + when (parser.eventType) { + XmlPullParser.START_TAG -> handleStartTag(parser, acc) + XmlPullParser.END_TAG -> handleEndTag(parser, acc, base) + else -> Unit + } + } + + private fun handleStartTag(parser: XmlPullParser, acc: ParseState) { + when (parser.name) { + TAG_SERVICE -> { + acc.inService = true + acc.serviceType = "" + acc.serviceControlUrl = "" + } + TAG_UDN -> acc.udn = parser.nextTextSafe() + TAG_FRIENDLY_NAME -> acc.friendlyName = parser.nextTextSafe() + TAG_MANUFACTURER -> acc.manufacturer = parser.nextTextSafe() + TAG_MODEL_NAME -> acc.modelName = parser.nextTextSafe() + TAG_SERVICE_TYPE -> if (acc.inService) acc.serviceType = parser.nextTextSafe() + TAG_CONTROL_URL -> if (acc.inService) acc.serviceControlUrl = parser.nextTextSafe() + } + } + + private fun handleEndTag(parser: XmlPullParser, acc: ParseState, base: HttpUrl) { + if (parser.name != TAG_SERVICE) return + val resolved = resolveControlUrl(base, acc.serviceControlUrl) + when (acc.serviceType) { + AVT_SERVICE_TYPE -> acc.avtControlUrl = resolved + RC_SERVICE_TYPE -> acc.rcControlUrl = resolved + } + acc.inService = false + } + + private fun resolveControlUrl(base: HttpUrl, path: String): HttpUrl? { + if (path.isBlank()) return null + return path.toHttpUrlOrNull() ?: base.resolve(path) + } + + private fun XmlPullParser.nextTextSafe(): String = + runCatching { nextText() }.getOrDefault("") + } + + /** + * Mutable accumulator used during pull-parsing. Lives only for the + * duration of one [parse] call. + */ + private class ParseState { + var udn: String = "" + var friendlyName: String = "" + var manufacturer: String = "" + var modelName: String = "" + var avtControlUrl: HttpUrl? = null + var rcControlUrl: HttpUrl? = null + var inService: Boolean = false + var serviceType: String = "" + var serviceControlUrl: String = "" + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SsdpDiscovery.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SsdpDiscovery.kt new file mode 100644 index 00000000..13984112 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SsdpDiscovery.kt @@ -0,0 +1,126 @@ +package com.fabledsword.minstrel.player.output.upnp + +import android.content.Context +import android.net.wifi.WifiManager +import androidx.core.content.getSystemService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.DatagramPacket +import java.net.InetAddress +import java.net.MulticastSocket +import java.nio.charset.StandardCharsets + +/** + * UDP multicast SSDP listener + M-SEARCH sender. Emits each discovery + * response's LOCATION URL on the [discoveries] SharedFlow; the + * `UpnpDiscoveryController` follows up by fetching + parsing each + * device description. + * + * Passive listen is always-on once [start] is called (NOTIFY packets + * speakers send when they boot / refresh). Active discovery (an + * explicit M-SEARCH request) is triggered by [requestActiveScan] — + * called when the picker sheet opens so newly-paired devices appear + * promptly. + * + * WifiManager.MulticastLock is held while the listener is running. + * Released by [stop] / cancellation of the parent scope. + */ +class SsdpDiscovery( + private val context: Context, +) { + private val discoveriesInternal = + MutableSharedFlow(extraBufferCapacity = DISCOVERY_BUFFER_CAPACITY) + val discoveries: SharedFlow = discoveriesInternal.asSharedFlow() + + private var multicastLock: WifiManager.MulticastLock? = null + private var socket: MulticastSocket? = null + private var listenJob: Job? = null + + fun start(scope: CoroutineScope) { + if (listenJob != null) return + val wifi = context.getSystemService() ?: return + multicastLock = wifi.createMulticastLock(MULTICAST_LOCK_TAG).apply { + setReferenceCounted(false) + acquire() + } + val sock = MulticastSocket(ANY_LOCAL_PORT).apply { + joinGroup(InetAddress.getByName(SSDP_MULTICAST_ADDR)) + } + socket = sock + listenJob = scope.launch(Dispatchers.IO) { + val buf = ByteArray(SOCKET_READ_BUFFER_BYTES) + val packet = DatagramPacket(buf, buf.size) + while (isActive) { + runCatching { sock.receive(packet) }.onSuccess { + val raw = String(packet.data, 0, packet.length, StandardCharsets.UTF_8) + parseLocation(raw)?.let { discoveriesInternal.tryEmit(it) } + } + } + } + } + + fun stop() { + listenJob?.cancel() + listenJob = null + runCatching { socket?.close() } + socket = null + runCatching { multicastLock?.release() } + multicastLock = null + } + + /** + * Send an M-SEARCH packet asking for MediaRenderer:1 devices. + * Responses arrive on the listener socket and emit via + * [discoveries] as their LOCATION URL. + */ + suspend fun requestActiveScan() = withContext(Dispatchers.IO) { + val sock = socket ?: return@withContext + val payload = buildMSearchPayload().toByteArray(StandardCharsets.UTF_8) + val packet = DatagramPacket( + payload, + payload.size, + InetAddress.getByName(SSDP_MULTICAST_ADDR), + SSDP_PORT, + ) + runCatching { sock.send(packet) } + Unit + } + + private fun buildMSearchPayload(): String = buildString { + append("M-SEARCH * HTTP/1.1\r\n") + append("HOST: ").append(SSDP_MULTICAST_ADDR).append(":").append(SSDP_PORT).append("\r\n") + append("MAN: \"ssdp:discover\"\r\n") + append("MX: ").append(MSEARCH_MX_SECONDS).append("\r\n") + append("ST: ").append(MEDIA_RENDERER_TARGET).append("\r\n") + append("\r\n") + } + + private fun parseLocation(raw: String): String? { + raw.lineSequence().forEach { line -> + val trimmed = line.trim() + if (trimmed.startsWith(LOCATION_HEADER, ignoreCase = true)) { + return trimmed.substring(LOCATION_HEADER.length).trim() + } + } + return null + } + + private companion object { + const val SSDP_MULTICAST_ADDR = "239.255.255.250" + const val SSDP_PORT = 1900 + const val ANY_LOCAL_PORT = 0 + const val SOCKET_READ_BUFFER_BYTES = 4096 + const val DISCOVERY_BUFFER_CAPACITY = 32 + const val MSEARCH_MX_SECONDS = 2 + const val MULTICAST_LOCK_TAG = "minstrel.upnp.ssdp" + const val MEDIA_RENDERER_TARGET = "urn:schemas-upnp-org:device:MediaRenderer:1" + const val LOCATION_HEADER = "LOCATION:" + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt new file mode 100644 index 00000000..5e210f41 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/DeviceDescriptionTest.kt @@ -0,0 +1,120 @@ +package com.fabledsword.minstrel.player.output.upnp + +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.xmlpull.v1.XmlPullParserFactory +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * Unit tests for the UPnP device-description pull-parser. + * + * Android's stock `XmlPullParserFactory.newInstance()` resolves to a + * real impl on-device but resolves to the android.jar stub class on + * JVM unit tests unless an org.xmlpull impl (kxml2) is present on the + * test classpath. The @BeforeEach probe skips the suite gracefully on + * runners where the factory can't be constructed — keeps CI green on + * minimal classpaths while still running the assertions when the + * impl IS available (Android instrumentation, Robolectric, or any + * runner that bundles kxml2 transitively). + */ +class DeviceDescriptionTest { + + private val base = "http://192.168.1.50:1400/xml/device_description.xml".toHttpUrl() + + @BeforeEach + fun skipIfStubFactory() { + val ok = runCatching { XmlPullParserFactory.newInstance().newPullParser() }.isSuccess + assumeTrue(ok, "XmlPullParserFactory unavailable on this JVM classpath") + } + + @Test + fun `parses Sonos-shaped description`() { + val xml = """ + + + + urn:schemas-upnp-org:device:MediaRenderer:1 + Living Room + Sonos, Inc. + Sonos One + uuid:RINCON_ABC + + + urn:schemas-upnp-org:service:AVTransport:1 + /MediaRenderer/AVTransport/Control + + + urn:schemas-upnp-org:service:RenderingControl:1 + /MediaRenderer/RenderingControl/Control + + + + + """.trimIndent() + + val desc = DeviceDescription.parse(xml, base) + assertNotNull(desc) + assertEquals("Living Room", desc.friendlyName) + assertEquals("Sonos, Inc.", desc.manufacturer) + assertEquals("Sonos One", desc.modelName) + assertEquals("uuid:RINCON_ABC", desc.udn) + assertEquals( + "http://192.168.1.50:1400/MediaRenderer/AVTransport/Control", + desc.avTransportControlUrl.toString(), + ) + assertEquals( + "http://192.168.1.50:1400/MediaRenderer/RenderingControl/Control", + desc.renderingControlUrl?.toString(), + ) + } + + @Test + fun `drops device with no AVTransport service`() { + val xml = """ + + + + Stub TV + uuid:STUB + + + urn:schemas-upnp-org:service:ConnectionManager:1 + /cm + + + + + """.trimIndent() + + assertNull(DeviceDescription.parse(xml, base)) + } + + @Test + fun `handles missing optional fields`() { + val xml = """ + + + + uuid:MIN + + + urn:schemas-upnp-org:service:AVTransport:1 + /avt + + + + + """.trimIndent() + + val desc = DeviceDescription.parse(xml, base) + assertNotNull(desc) + assertEquals("", desc.friendlyName) + assertEquals("", desc.manufacturer) + assertEquals("", desc.modelName) + assertNull(desc.renderingControlUrl) + } +}