feat(android): UPnP SOAP envelope + AVTransport client (UPnP slice 5/6)
android / Build + lint + test (push) Successful in 3m46s
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:
+50
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
|
||||
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")
|
||||
+146
@@ -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&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&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<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()
|
||||
}
|
||||
Reference in New Issue
Block a user