fix(android): Sonos x-rincon-mp3radio URI transform for SetAVTransportURI
android / Build + lint + test (push) Failing after 1m20s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 20:20:25 -04:00
parent 75132a2afe
commit 8652b86f40
3 changed files with 78 additions and 4 deletions
@@ -12,6 +12,7 @@ import okhttp3.HttpUrl
class AVTransportClient(
private val soap: SoapClient,
private val controlUrl: HttpUrl,
private val applySonosUriFix: Boolean = false,
) {
suspend fun setAVTransportURI(uri: String, metadata: String = "") {
soap.call(
@@ -43,7 +44,9 @@ class AVTransportClient(
* when the caller doesn't supply one.
*/
suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) {
setAVTransportURI(uri, buildDidlLite(uri, mime, title))
val safeTitle = title.ifBlank { "Minstrel" }
val effectiveUri = maybeTransformForSonos(uri)
setAVTransportURI(effectiveUri, buildDidlLite(effectiveUri, mime, safeTitle))
}
/**
@@ -52,14 +55,16 @@ class AVTransportClient(
* [setAVTransportURIWithMetadata].
*/
suspend fun setNextAVTransportURI(uri: String, mime: String, title: String) {
val safeTitle = title.ifBlank { "Minstrel" }
val effectiveUri = maybeTransformForSonos(uri)
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "SetNextAVTransportURI",
args = mapOf(
"InstanceID" to "0",
"NextURI" to uri,
"NextURIMetaData" to buildDidlLite(uri, mime, title),
"NextURI" to effectiveUri,
"NextURIMetaData" to buildDidlLite(effectiveUri, mime, safeTitle),
),
)
}
@@ -135,6 +140,24 @@ class AVTransportClient(
return TransportInfo(state)
}
/**
* Sonos firmware 6.4.2+ silently refuses plain http(s):// URIs in
* SetAVTransportURI -- the SOAP call returns 200 OK but the URI never
* loads. The documented fix (from SoCo et al.) is to replace the
* scheme with x-rincon-mp3radio:// for Sonos targets. Generic UPnP
* renderers (Yamaha, Bose, generic DLNA) reject this scheme, so the
* transform is conditional on the manufacturer.
*
* The transformed URI goes both in the SOAP CurrentURI / NextURI arg
* AND inside the DIDL-Lite `<res>` element so the two stay consistent.
*/
private fun maybeTransformForSonos(uri: String): String {
if (!applySonosUriFix) return uri
return uri
.replaceFirst("https://", "x-rincon-mp3radio://")
.replaceFirst("http://", "x-rincon-mp3radio://")
}
private fun buildDidlLite(uri: String, mime: String, title: String): String {
val safeTitle = title.ifBlank { "Minstrel" }
return buildString {
@@ -90,7 +90,12 @@ class UpnpDiscoveryController @Inject constructor(
*/
fun transportFor(routeId: String): AVTransportClient? {
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
return AVTransportClient(loggingSoapClient(okHttp, route.name), route.avTransportControlUrl)
val isSonos = route.manufacturer.contains("Sonos", ignoreCase = true)
return AVTransportClient(
loggingSoapClient(okHttp, route.name),
route.avTransportControlUrl,
applySonosUriFix = isSonos,
)
}
private suspend fun handleDiscovery(locationUrl: String) {