From f8c93e013d8d5607a6bd06079219b4bee1cdb69d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 12:43:54 -0400 Subject: [PATCH] feat(android): UPnP SOAP envelope + AVTransport client (UPnP slice 5/6) 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, raises SoapFaultException on a 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. --- .../player/output/upnp/AVTransportClient.kt | 50 ++++++ .../minstrel/player/output/upnp/SoapClient.kt | 140 +++++++++++++++++ .../player/output/upnp/SoapClientTest.kt | 146 ++++++++++++++++++ 3 files changed, 336 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/SoapClientTest.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt new file mode 100644 index 00000000..a7897087 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt @@ -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" + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt new file mode 100644 index 00000000..4bf6ab51 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt @@ -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 + * `Response` element as a Map (UPnP responses + * are flat string maps). + * + * Throws [SoapFaultException] on a `` 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 = emptyMap(), + ): Map = 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 = buildString { + append("") + append("") + append("") + append("") + args.forEach { (k, v) -> + append("<").append(k).append(">") + append(xmlEscape(v)) + append("") + } + append("") + append("") + append("") + } + + private fun xmlEscape(v: String): String = v + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") + + private fun parseResponseArgs(body: String, action: String): Map { + val parser = XmlPullParserFactory.newInstance().newPullParser().apply { + setInput(body.reader()) + } + val responseTag = "${action}Response" + val args = mutableMapOf() + 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, + ): 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, "", "") ?: "unknown" + + private fun faultDescriptionOf(body: String): String = + extractBetween(body, "", "").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 `` — 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") diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/SoapClientTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/SoapClientTest.kt new file mode 100644 index 00000000..ad052e72 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/upnp/SoapClientTest.kt @@ -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 `` 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("http://server/api/tracks/x/stream?token=t&exp=1", + ), + "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\"d'e"), + ) + + val recorded = server.takeRequest() + val body = recorded.body.readUtf8() + assertTrue( + body.contains("a&b<c>"d'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 { + 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 = """ + + + + + + + """.trimIndent() + + private fun faultResponseBody(): String = """ + + + + + s:Client + UPnPError + + + 402 + Invalid Args + + + + + + """.trimIndent() +}