feat(android): UPnP SSDP discovery + device description (UPnP slice 4/6)
android / Build + lint + test (push) Has been cancelled
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:
+122
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
+126
@@ -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:"
|
||||
}
|
||||
}
|
||||
+120
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user