fix(android): Sonos ZGT robust extraction -- escaped + nested element fallback + diag
android / Build + lint + test (push) Failing after 1m29s
android / Build + lint + test (push) Failing after 1m29s
This commit is contained in:
+62
-8
@@ -99,7 +99,7 @@ class SoapClient(
|
||||
} else {
|
||||
if (inResponse) {
|
||||
val name = parser.name
|
||||
val text = runCatching { parser.nextText() }.getOrDefault("")
|
||||
val text = readElementContent(parser, name)
|
||||
args[name] = text
|
||||
}
|
||||
inResponse
|
||||
@@ -109,6 +109,51 @@ class SoapClient(
|
||||
else -> inResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the content of the currently-started element. Try nextText() first
|
||||
* (works when the content is text -- escaped XML included). If that throws
|
||||
* (because the content is nested elements), manually walk to the matching
|
||||
* END_TAG, accumulating text and re-serializing child elements.
|
||||
*
|
||||
* Some Sonos firmware sends the GetZoneGroupState payload as nested XML
|
||||
* elements without escaping; this fallback recovers that path.
|
||||
*/
|
||||
private fun readElementContent(parser: XmlPullParser, tagName: String): String {
|
||||
return runCatching { parser.nextText() }.getOrElse {
|
||||
readUntilEndTag(parser, tagName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readUntilEndTag(parser: XmlPullParser, tagName: String): String {
|
||||
val sb = StringBuilder()
|
||||
var depth = 1
|
||||
while (depth > 0) {
|
||||
when (parser.next()) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
sb.append('<').append(parser.name)
|
||||
for (i in 0 until parser.attributeCount) {
|
||||
sb.append(' ')
|
||||
.append(parser.getAttributeName(i))
|
||||
.append("=\"")
|
||||
.append(parser.getAttributeValue(i))
|
||||
.append('"')
|
||||
}
|
||||
sb.append('>')
|
||||
depth += 1
|
||||
}
|
||||
XmlPullParser.END_TAG -> {
|
||||
depth -= 1
|
||||
if (depth == 0 && parser.name == tagName) break
|
||||
sb.append("</").append(parser.name).append('>')
|
||||
}
|
||||
XmlPullParser.TEXT -> sb.append(parser.text.orEmpty())
|
||||
XmlPullParser.END_DOCUMENT -> break
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun faultCodeOf(body: String): String =
|
||||
extractBetween(body, "<errorCode>", "</errorCode>") ?: "unknown"
|
||||
|
||||
@@ -141,23 +186,32 @@ private const val MAX_BODY_LOG_CHARS = 2048
|
||||
|
||||
/**
|
||||
* Returns a [SoapClient] that logs the raw response body for the first
|
||||
* [MAX_DIAGNOSTIC_RESPONSES] GetPositionInfo / GetTransportInfo calls (i.e.
|
||||
* the first 3 poll cycles of a UPnP session). After that the callback is a
|
||||
* no-op so there is no persistent log spam. Logged at WARN so release builds
|
||||
* capture it without a separate log-level override.
|
||||
* [MAX_DIAGNOSTIC_RESPONSES] calls for actions in [DIAGNOSTIC_ACTIONS]
|
||||
* (GetPositionInfo, GetTransportInfo, GetZoneGroupState). After that the
|
||||
* callback is a no-op so there is no persistent log spam. Logged at WARN
|
||||
* so release builds capture it without a separate log-level override.
|
||||
*/
|
||||
internal fun loggingSoapClient(okHttp: OkHttpClient, label: String): SoapClient {
|
||||
val counter = AtomicInteger(0)
|
||||
return SoapClient(okHttp) { action, body ->
|
||||
if (action != "GetPositionInfo" && action != "GetTransportInfo") return@SoapClient
|
||||
if (action !in DIAGNOSTIC_ACTIONS) return@SoapClient
|
||||
val n = counter.incrementAndGet()
|
||||
if (n <= MAX_DIAGNOSTIC_RESPONSES) {
|
||||
Timber.w("UPnP %s response #%d (%s): %s",
|
||||
label, n, action, body.take(MAX_BODY_LOG_CHARS))
|
||||
Timber.w(
|
||||
"UPnP %s response #%d (%s): %s",
|
||||
label, n, action,
|
||||
body.take(MAX_BODY_LOG_CHARS),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val DIAGNOSTIC_ACTIONS = setOf(
|
||||
"GetPositionInfo",
|
||||
"GetTransportInfo",
|
||||
"GetZoneGroupState",
|
||||
)
|
||||
|
||||
/** XML-escapes a string value for embedding as text content inside a SOAP envelope. */
|
||||
internal fun xmlEscape(v: String): String = v
|
||||
.replace("&", "&")
|
||||
|
||||
+4
-1
@@ -125,7 +125,10 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
return
|
||||
}
|
||||
val groups = runCatching {
|
||||
ZoneGroupTopologyClient(SoapClient(okHttp), zgtUrl).getZoneGroupState()
|
||||
ZoneGroupTopologyClient(
|
||||
loggingSoapClient(okHttp, "ZGT-${anySonos.id}"),
|
||||
zgtUrl,
|
||||
).getZoneGroupState()
|
||||
}
|
||||
.onFailure { Timber.w(it, "refreshSonosTopology failed for %s", anySonos.id) }
|
||||
.getOrNull() ?: return
|
||||
|
||||
+13
-1
@@ -24,9 +24,21 @@ data class SonosZoneMember(
|
||||
object SonosTopology {
|
||||
|
||||
fun parse(xml: String): List<SonosZoneGroup> {
|
||||
return runCatching { parseStrict(xml) }.getOrDefault(emptyList())
|
||||
val effective = if (xml.contains("<ZoneGroup")) {
|
||||
unescapeXmlEntities(xml)
|
||||
} else {
|
||||
xml
|
||||
}
|
||||
return runCatching { parseStrict(effective) }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun unescapeXmlEntities(s: String): String = s
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("&", "&") // must be last to avoid double-decoding
|
||||
|
||||
private fun parseStrict(xml: String): List<SonosZoneGroup> {
|
||||
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
|
||||
setInput(xml.reader())
|
||||
|
||||
+7
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.player.output.upnp.sonos
|
||||
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
||||
import okhttp3.HttpUrl
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Sonos's proprietary ZoneGroupTopology service. Same SOAP shape as a
|
||||
@@ -21,10 +22,16 @@ class ZoneGroupTopologyClient(
|
||||
args = emptyMap(),
|
||||
)
|
||||
val inner = args["ZoneGroupState"].orEmpty()
|
||||
Timber.w(
|
||||
"ZGT extracted ZoneGroupState (%d chars): %s",
|
||||
inner.length,
|
||||
inner.take(ZGT_LOG_TRUNCATE_CHARS),
|
||||
)
|
||||
return SonosTopology.parse(inner)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:ZoneGroupTopology:1"
|
||||
const val ZGT_LOG_TRUNCATE_CHARS = 2048
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -71,4 +71,39 @@ class SonosTopologyTest {
|
||||
fun `malformed xml returns empty list`() {
|
||||
assertEquals(emptyList(), SonosTopology.parse("<garbage"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses inline non-escaped ZoneGroupState document`() {
|
||||
// Some Sonos firmware sends the topology as nested elements with
|
||||
// no escaping. After SoapClient.readUntilEndTag rebuilds a flat
|
||||
// string, parse should still find the groups.
|
||||
val xml = """
|
||||
<ZoneGroupState>
|
||||
<ZoneGroups>
|
||||
<ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1">
|
||||
<ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen"
|
||||
Location="http://192.168.1.10:1400/xml/device_description.xml"/>
|
||||
</ZoneGroup>
|
||||
</ZoneGroups>
|
||||
</ZoneGroupState>
|
||||
""".trimIndent()
|
||||
val groups = SonosTopology.parse(xml)
|
||||
assertEquals(1, groups.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses escaped ZoneGroupState wrapped in entities`() {
|
||||
val xml = """
|
||||
<ZoneGroupState>
|
||||
<ZoneGroups>
|
||||
<ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1">
|
||||
<ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen"
|
||||
Location="http://192.168.1.10:1400/xml/device_description.xml"/>
|
||||
</ZoneGroup>
|
||||
</ZoneGroups>
|
||||
</ZoneGroupState>
|
||||
""".trimIndent()
|
||||
val groups = SonosTopology.parse(xml)
|
||||
assertEquals(1, groups.size)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user