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