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