feat(android): UPnP SSDP discovery + device description (UPnP slice 4/6)
android / Build + lint + test (push) Has been cancelled

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 <device> 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.
This commit is contained in:
2026-06-03 12:01:00 -04:00
parent 5f3905f2c7
commit dc5b8252bb
3 changed files with 368 additions and 0 deletions
@@ -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 = ""
}
}
@@ -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<String>(extraBufferCapacity = DISCOVERY_BUFFER_CAPACITY)
val discoveries: SharedFlow<String> = 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<WifiManager>() ?: 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:"
}
}
@@ -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 = """
<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
<friendlyName>Living Room</friendlyName>
<manufacturer>Sonos, Inc.</manufacturer>
<modelName>Sonos One</modelName>
<UDN>uuid:RINCON_ABC</UDN>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
<controlURL>/MediaRenderer/AVTransport/Control</controlURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType>
<controlURL>/MediaRenderer/RenderingControl/Control</controlURL>
</service>
</serviceList>
</device>
</root>
""".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 = """
<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<friendlyName>Stub TV</friendlyName>
<UDN>uuid:STUB</UDN>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
<controlURL>/cm</controlURL>
</service>
</serviceList>
</device>
</root>
""".trimIndent()
assertNull(DeviceDescription.parse(xml, base))
}
@Test
fun `handles missing optional fields`() {
val xml = """
<?xml version="1.0"?>
<root>
<device>
<UDN>uuid:MIN</UDN>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
<controlURL>/avt</controlURL>
</service>
</serviceList>
</device>
</root>
""".trimIndent()
val desc = DeviceDescription.parse(xml, base)
assertNotNull(desc)
assertEquals("", desc.friendlyName)
assertEquals("", desc.manufacturer)
assertEquals("", desc.modelName)
assertNull(desc.renderingControlUrl)
}
}