From 75132a2afe21a3fc154c219b966b00610755a372 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 20:12:15 -0400 Subject: [PATCH] diag(android): log raw SOAP response body for first 3 UPnP polls 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 --- .../minstrel/player/output/upnp/SoapClient.kt | 26 +++++++++++++++++++ .../output/upnp/UpnpDiscoveryController.kt | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt index 7418f9eb..d58e33e5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/SoapClient.kt @@ -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("&", "&") diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt index 12f02b2c..d8e688c7 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt @@ -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) {