feat(android): UPnP SOAP envelope + AVTransport client (UPnP slice 5/6)
android / Build + lint + test (push) Successful in 3m46s

SoapClient - minimal SOAP 1.1 envelope builder + POST via the shared
app OkHttpClient. Sets the SOAPACTION + Content-Type headers UPnP
expects, parses the action's Response element as a Map<String,
String>, raises SoapFaultException on a <s:Fault> response with the
UPnP errorCode + errorDescription extracted.

AVTransportClient - thin wrapper over SoapClient pinned to the
AVTransport:1 service. Three actions for v1: SetAVTransportURI /
Play / Stop. Pause + Seek deferred until we have hardware in the
loop to verify per-device quirks.

Three MockWebServer-driven unit tests cover the SOAPACTION header
shape, XML escaping of special chars in arg values, and the fault
response -> SoapFaultException path. kxml2 on the test classpath
(Task 4) makes XmlPullParserFactory resolve on the JVM.
This commit is contained in:
2026-06-03 12:43:54 -04:00
parent 1f02813cc6
commit f8c93e013d
3 changed files with 336 additions and 0 deletions
@@ -0,0 +1,50 @@
package com.fabledsword.minstrel.player.output.upnp
import okhttp3.HttpUrl
/**
* High-level wrapper for the UPnP AVTransport service. Three calls
* for v1: SetAVTransportURI / Play / Stop. Pause + Seek deferred
* until we have hardware in the loop to verify each device's quirks
* (Sonos and BubbleUPnP accept the standard shape; some smart TVs
* reject Pause without DIDL).
*/
class AVTransportClient(
private val soap: SoapClient,
private val controlUrl: HttpUrl,
) {
suspend fun setAVTransportURI(uri: String, metadata: String = "") {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "SetAVTransportURI",
args = mapOf(
"InstanceID" to "0",
"CurrentURI" to uri,
"CurrentURIMetaData" to metadata,
),
)
}
suspend fun play() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Play",
args = mapOf("InstanceID" to "0", "Speed" to "1"),
)
}
suspend fun stop() {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "Stop",
args = mapOf("InstanceID" to "0"),
)
}
private companion object {
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
}
}
@@ -0,0 +1,140 @@
package com.fabledsword.minstrel.player.output.upnp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
/**
* Minimal SOAP/UPnP envelope builder + POST. Hand-rolled rather than
* pulled in via jupnp; we control every line and integrate cleanly
* with the app's OkHttpClient (shared connection pool, timeouts).
*
* Builds the standard SOAP 1.1 envelope, POSTs with the required
* SOAPACTION + Content-Type headers, returns the parsed
* `<action>Response` element as a Map<String, String> (UPnP responses
* are flat string maps).
*
* Throws [SoapFaultException] on a `<s:Fault>` response (the UPnP
* device's way of saying "I rejected your request"). Other transport
* errors propagate as IOException.
*/
class SoapClient(
private val okHttp: OkHttpClient,
) {
suspend fun call(
controlUrl: HttpUrl,
serviceType: String,
action: String,
args: Map<String, String> = emptyMap(),
): Map<String, String> = withContext(Dispatchers.IO) {
val envelope = buildEnvelope(serviceType, action, args)
val request = Request.Builder()
.url(controlUrl)
.post(envelope.toRequestBody(null))
.header("Content-Type", SOAP_CONTENT_TYPE)
.header("SOAPACTION", "\"$serviceType#$action\"")
.build()
okHttp.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
if (!response.isSuccessful) {
throw SoapFaultException(faultCodeOf(body), faultDescriptionOf(body))
}
parseResponseArgs(body, action)
}
}
private fun buildEnvelope(
serviceType: String,
action: String,
args: Map<String, String>,
): String = buildString {
append("<?xml version=\"1.0\"?>")
append("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"")
append(" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">")
append("<s:Body>")
append("<u:").append(action)
append(" xmlns:u=\"").append(serviceType).append("\">")
args.forEach { (k, v) ->
append("<").append(k).append(">")
append(xmlEscape(v))
append("</").append(k).append(">")
}
append("</u:").append(action).append(">")
append("</s:Body>")
append("</s:Envelope>")
}
private fun xmlEscape(v: String): String = v
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
private fun parseResponseArgs(body: String, action: String): Map<String, String> {
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
setInput(body.reader())
}
val responseTag = "${action}Response"
val args = mutableMapOf<String, String>()
var inResponse = false
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
inResponse = handleParserEvent(parser, responseTag, inResponse, args)
parser.next()
}
return args
}
private fun handleParserEvent(
parser: XmlPullParser,
responseTag: String,
inResponse: Boolean,
args: MutableMap<String, String>,
): Boolean = when (parser.eventType) {
XmlPullParser.START_TAG -> {
if (parser.name == responseTag) {
true
} else {
if (inResponse) {
val name = parser.name
val text = runCatching { parser.nextText() }.getOrDefault("")
args[name] = text
}
inResponse
}
}
XmlPullParser.END_TAG -> if (parser.name == responseTag) false else inResponse
else -> inResponse
}
private fun faultCodeOf(body: String): String =
extractBetween(body, "<errorCode>", "</errorCode>") ?: "unknown"
private fun faultDescriptionOf(body: String): String =
extractBetween(body, "<errorDescription>", "</errorDescription>").orEmpty()
private fun extractBetween(body: String, open: String, close: String): String? {
val start = body.indexOf(open)
if (start < 0) return null
val contentStart = start + open.length
val end = body.indexOf(close, contentStart)
return if (end < 0) null else body.substring(contentStart, end)
}
private companion object {
const val SOAP_CONTENT_TYPE = "text/xml; charset=\"utf-8\""
}
}
/**
* Thrown when a UPnP device responds with `<s:Fault>` — typically wraps
* a UPnPError with `errorCode` + `errorDescription`. Code is preserved
* as a string (UPnP codes are numeric in spec but we don't constrain).
*/
class SoapFaultException(val code: String, val description: String) :
Exception("SOAP fault $code: $description")
@@ -0,0 +1,146 @@
package com.fabledsword.minstrel.player.output.upnp
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* Unit tests for SoapClient. Uses MockWebServer to capture the
* outgoing SOAP envelope + verify the SOAPACTION header shape, and
* to drive the `<s:Fault>` response path.
*
* kxml2 is on the test classpath (Task 4), so
* `XmlPullParserFactory.newInstance()` resolves on the JVM.
*/
class SoapClientTest {
private lateinit var server: MockWebServer
private lateinit var client: SoapClient
@BeforeEach
fun setup() {
server = MockWebServer().apply { start() }
client = SoapClient(OkHttpClient())
}
@AfterEach
fun teardown() {
server.shutdown()
}
@Test
fun `SetAVTransportURI envelope sent with correct SOAPACTION header`() = runTest {
server.enqueue(
MockResponse()
.setBody(setUriResponseBody())
.setHeader("Content-Type", "text/xml"),
)
client.call(
controlUrl = server.url("/avt/Control"),
serviceType = "urn:schemas-upnp-org:service:AVTransport:1",
action = "SetAVTransportURI",
args = mapOf(
"InstanceID" to "0",
"CurrentURI" to "http://server/api/tracks/x/stream?token=t&exp=1",
"CurrentURIMetaData" to "",
),
)
val recorded = server.takeRequest()
assertEquals(
"\"urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI\"",
recorded.getHeader("SOAPACTION"),
)
val body = recorded.body.readUtf8()
assertTrue(
body.contains("<u:SetAVTransportURI"),
"envelope missing <u:SetAVTransportURI: $body",
)
assertTrue(
body.contains(
"<CurrentURI>http://server/api/tracks/x/stream?token=t&amp;exp=1</CurrentURI>",
),
"envelope missing escaped CurrentURI: $body",
)
}
@Test
fun `XML special chars in args are escaped`() = runTest {
server.enqueue(
MockResponse()
.setBody(setUriResponseBody())
.setHeader("Content-Type", "text/xml"),
)
client.call(
controlUrl = server.url("/avt/Control"),
serviceType = "urn:schemas-upnp-org:service:AVTransport:1",
action = "SetAVTransportURI",
args = mapOf("CurrentURI" to "a&b<c>\"d'e"),
)
val recorded = server.takeRequest()
val body = recorded.body.readUtf8()
assertTrue(
body.contains("a&amp;b&lt;c&gt;&quot;d&apos;e"),
"expected all five XML escapes in body, got: $body",
)
}
@Test
fun `SOAP fault becomes SoapFaultException`() = runTest {
server.enqueue(
MockResponse()
.setResponseCode(500)
.setBody(faultResponseBody())
.setHeader("Content-Type", "text/xml"),
)
val ex = assertFailsWith<SoapFaultException> {
client.call(
controlUrl = server.url("/avt/Control"),
serviceType = "urn:schemas-upnp-org:service:AVTransport:1",
action = "Play",
args = mapOf("InstanceID" to "0", "Speed" to "1"),
)
}
assertEquals("402", ex.code)
}
private fun setUriResponseBody(): String = """
<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:SetAVTransportURIResponse
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
</s:Body>
</s:Envelope>
""".trimIndent()
private fun faultResponseBody(): String = """
<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<s:Fault>
<faultcode>s:Client</faultcode>
<faultstring>UPnPError</faultstring>
<detail>
<UPnPError xmlns="urn:schemas-upnp-org:control-1-0">
<errorCode>402</errorCode>
<errorDescription>Invalid Args</errorDescription>
</UPnPError>
</detail>
</s:Fault>
</s:Body>
</s:Envelope>
""".trimIndent()
}