fix(android): Sonos ZGT robust extraction -- escaped + nested element fallback + diag
android / Build + lint + test (push) Failing after 1m29s

This commit is contained in:
2026-06-03 20:40:52 -04:00
parent 2c61d7a333
commit 96594ba52b
5 changed files with 121 additions and 10 deletions
@@ -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("&", "&amp;")
@@ -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
@@ -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("&lt;ZoneGroup")) {
unescapeXmlEntities(xml)
} else {
xml
}
return runCatching { parseStrict(effective) }.getOrDefault(emptyList())
}
private fun unescapeXmlEntities(s: String): String = s
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&amp;", "&") // must be last to avoid double-decoding
private fun parseStrict(xml: String): List<SonosZoneGroup> {
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
setInput(xml.reader())
@@ -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
}
}
@@ -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 = """
&lt;ZoneGroupState&gt;
&lt;ZoneGroups&gt;
&lt;ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1"&gt;
&lt;ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen"
Location="http://192.168.1.10:1400/xml/device_description.xml"/&gt;
&lt;/ZoneGroup&gt;
&lt;/ZoneGroups&gt;
&lt;/ZoneGroupState&gt;
""".trimIndent()
val groups = SonosTopology.parse(xml)
assertEquals(1, groups.size)
}
}