diag(android): log raw SOAP response body for first 3 UPnP polls
android / Build + lint + test (push) Failing after 1m22s

Add optional onRawResponse callback to SoapClient; loggingSoapClient
factory emits the first 6 GetPositionInfo/GetTransportInfo bodies
(3 poll cycles) at WARN so release logs capture them. Wire into
transportFor so every AVTransportClient for a new UPnP session logs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 20:12:15 -04:00
parent 673f98487f
commit 75132a2afe
2 changed files with 27 additions and 1 deletions
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.player.output.upnp
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
@@ -8,6 +9,7 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import timber.log.Timber
/**
* Minimal SOAP/UPnP envelope builder + POST. Hand-rolled rather than
@@ -25,6 +27,7 @@ import org.xmlpull.v1.XmlPullParserFactory
*/
class SoapClient(
private val okHttp: OkHttpClient,
private val onRawResponse: ((action: String, body: String) -> Unit)? = null,
) {
suspend fun call(
controlUrl: HttpUrl,
@@ -41,6 +44,7 @@ class SoapClient(
.build()
okHttp.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
onRawResponse?.invoke(action, body)
if (!response.isSuccessful) {
throw SoapFaultException(faultCodeOf(body), faultDescriptionOf(body))
}
@@ -132,6 +136,28 @@ class SoapClient(
class SoapFaultException(val code: String, val description: String) :
Exception("SOAP fault $code: $description")
private const val MAX_DIAGNOSTIC_RESPONSES = 6 // 3 polls x 2 action types
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.
*/
internal fun loggingSoapClient(okHttp: OkHttpClient, label: String): SoapClient {
val counter = AtomicInteger(0)
return SoapClient(okHttp) { action, body ->
if (action != "GetPositionInfo" && action != "GetTransportInfo") 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))
}
}
}
/** XML-escapes a string value for embedding as text content inside a SOAP envelope. */
internal fun xmlEscape(v: String): String = v
.replace("&", "&amp;")
@@ -90,7 +90,7 @@ class UpnpDiscoveryController @Inject constructor(
*/
fun transportFor(routeId: String): AVTransportClient? {
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
return AVTransportClient(SoapClient(okHttp), route.avTransportControlUrl)
return AVTransportClient(loggingSoapClient(okHttp, route.name), route.avTransportControlUrl)
}
private suspend fun handleDiscovery(locationUrl: String) {