Add local companion playback diagnostics for failed background audio

This commit is contained in:
archipelago
2026-10-07 18:10:54 -04:00
parent 4ead8376e7
commit 7383d47bad
7 changed files with 215 additions and 18 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 56
versionName = "0.5.36"
versionCode = 57
versionName = "0.5.37"
vectorDrawables {
useSupportLibrary = true
@@ -74,6 +74,7 @@ import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.ui.screens.restartCompanionApp
import com.archipelago.app.ui.screens.CompanionAudioDiagnostics
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted
@@ -252,6 +253,7 @@ private fun MenuPanel(
HubPage.FIPS -> "FIPS Mesh"
HubPage.BACKUP -> "Backup & Restore"
HubPage.SIGNER -> "Remote Signer"
HubPage.AUDIO -> "Playback diagnostics"
HubPage.HUB -> "Menu"
},
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
@@ -307,6 +309,7 @@ private fun MenuPanel(
onDismiss()
restartCompanionApp(hubContext)
}
HubCard(Icons.Default.Dashboard, "Playback diagnostics", "Local background audio status") { page = HubPage.AUDIO }
val versionLabel = remember {
runCatching {
hubContext.packageManager
@@ -451,6 +454,17 @@ private fun MenuPanel(
}
}
HubPage.AUDIO -> {
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
var report by remember { mutableStateOf(CompanionAudioDiagnostics.report(context)) }
var copied by remember { mutableStateOf(false) }
Text("Local status only. No track names, addresses, credentials, or automatic uploads.", color = TextMuted, fontSize = 12.sp)
Text(report, color = TextPrimary, fontSize = 12.sp)
MenuItem(label = "Refresh", onClick = { report = CompanionAudioDiagnostics.report(context); copied = false })
MenuItem(label = if (copied) "Copied" else "Copy report", onClick = { clipboard.setText(AnnotatedString(report)); copied = true })
}
HubPage.FIPS -> {
FipsSection(embedded = true)
}
@@ -470,7 +484,7 @@ private fun MenuPanel(
}
}
private enum class HubPage { HUB, NODES, FIPS, BACKUP, SIGNER }
private enum class HubPage { HUB, NODES, FIPS, BACKUP, SIGNER, AUDIO }
/** Big tappable destination card for the hub page: icon + title + subtitle. */
@Composable
@@ -11,6 +11,7 @@ import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import org.json.JSONObject
import com.archipelago.app.ui.screens.CompanionAudioDiagnostics.Event
/** Metadata/control only: the authorized WebView owns the stream and queue. */
internal data class CompanionAudioState(
@@ -50,12 +51,12 @@ internal object CompanionAudioBridge {
private set
fun retains(web: WebView?) = web != null && view === web && state != null
fun attach(web: WebView, urls: List<String>) {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) return
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { CompanionAudioDiagnostics.record(Event.BRIDGE_UNSUPPORTED); return }
val origins = urls.mapNotNull(::cloudVideoOrigin).toSet()
if (origins.isEmpty()) return
if (origins.isEmpty()) { CompanionAudioDiagnostics.record(Event.NO_APPROVED_ORIGIN); return }
val existing = bindings[web]
if (existing != null) {
if (existing != origins) { bindings[web] = emptySet(); release(web) }
if (existing != origins) { CompanionAudioDiagnostics.record(Event.ORIGIN_CHANGED); bindings[web] = emptySet(); release(web) }
return
}
bindings[web] = origins
@@ -67,34 +68,45 @@ internal object CompanionAudioBridge {
receive(web, raw, source.toString(), main, origins) { proxy.postMessage(it) }
}
})
CompanionAudioDiagnostics.record(Event.BRIDGE_ATTACHED)
}
internal fun receive(web: WebView, raw: String, source: String, main: Boolean,
origins: Set<String>, proxy: (String) -> Unit) {
if (!cloudVideoSenderAllowed(web.url, source, origins, main)) return
if (raw.length > 96000) return
val data = runCatching { JSONObject(raw) }.getOrNull() ?: return
CompanionAudioDiagnostics.record(Event.MESSAGE_RECEIVED)
if (!cloudVideoSenderAllowed(web.url, source, origins, main)) { CompanionAudioDiagnostics.record(Event.SENDER_REJECTED); return }
if (raw.length > 96000) { CompanionAudioDiagnostics.record(Event.MESSAGE_TOO_LARGE); return }
val data = runCatching { JSONObject(raw) }.getOrNull() ?: run { CompanionAudioDiagnostics.record(Event.INVALID_JSON); return }
val session = data.optString("session")
if (data.optString("action") == "release") {
if (web === view && session == state?.session) terminate()
if (web === view && session == state?.session) { CompanionAudioDiagnostics.record(Event.SESSION_RELEASED); terminate() }
return
}
val incoming = runCatching { CompanionAudioState.parse(data) }.getOrNull() ?: return
if (session in retired) return
val incoming = runCatching { CompanionAudioState.parse(data) }.getOrNull() ?: run { CompanionAudioDiagnostics.record(Event.INVALID_STATE); return }
if (session in retired) { CompanionAudioDiagnostics.record(Event.RETIRED_SESSION); return }
val old = state
if (old != null && old.session == session) {
if (view !== web || incoming.sequence <= old.sequence) return
if (view !== web || incoming.sequence <= old.sequence) { CompanionAudioDiagnostics.record(Event.STALE_STATE); return }
} else {
if (!incoming.playing) return // Do not start a service for idle metadata.
if (!incoming.playing) { CompanionAudioDiagnostics.record(Event.IDLE_STATE); return } // Do not start a service for idle metadata.
if (old != null) { command("pause"); retire(old.session) }
}
CompanionAudioDiagnostics.record(Event.STATE_ACCEPTED)
view = web; origin = cloudVideoOrigin(source); reply = proxy
state = if (old != null && old.session == session && !data.has("artwork")) incoming.copy(artwork = old.artwork) else incoming; updatedAt = SystemClock.elapsedRealtime()
runCatching {
val service = CompanionAudioService.instance
if (service != null) service.refresh()
else ContextCompat.startForegroundService(web.context.applicationContext,
else {
CompanionAudioDiagnostics.record(Event.SERVICE_REQUESTED)
ContextCompat.startForegroundService(web.context.applicationContext,
Intent(web.context.applicationContext, CompanionAudioService::class.java))
}
}.onFailure {
CompanionAudioDiagnostics.record(when {
it is SecurityException -> Event.SERVICE_PERMISSION_DENIED
it.javaClass.simpleName == "ForegroundServiceStartNotAllowedException" -> Event.SERVICE_BACKGROUND_START_DENIED
else -> Event.SERVICE_REQUEST_FAILED
})
event("error", "Background playback could not start. Reopen the companion and press Play.")
command("pause"); terminate()
}
@@ -112,7 +124,7 @@ internal object CompanionAudioBridge {
runCatching { reply?.invoke(message.toString()) }
}
fun command(name: String, position: Double? = null) = event("command", name, position)
fun release(web: WebView) { if (view === web) { command("stop"); terminate() } }
fun release(web: WebView) { if (view === web) { CompanionAudioDiagnostics.record(Event.PAGE_RELEASED); command("stop"); terminate() } }
fun terminate() {
state?.session?.let(::retire)
state = null; view = null; origin = null; reply = null
@@ -0,0 +1,47 @@
package com.archipelago.app.ui.screens
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.os.SystemClock
import android.webkit.WebView
/** Local, memory-only allowlisted status. Never accepts URLs, titles, IDs, or exception text. */
internal object CompanionAudioDiagnostics {
enum class Event {
BRIDGE_ATTACHED, BRIDGE_UNSUPPORTED, NO_APPROVED_ORIGIN, ORIGIN_CHANGED,
MESSAGE_RECEIVED, SENDER_REJECTED, MESSAGE_TOO_LARGE, INVALID_JSON, INVALID_STATE,
RETIRED_SESSION, STALE_STATE, IDLE_STATE, STATE_ACCEPTED, SERVICE_REQUESTED,
SERVICE_REQUEST_FAILED, SERVICE_PERMISSION_DENIED, SERVICE_BACKGROUND_START_DENIED, SERVICE_CREATED, SERVICE_STARTED, FOREGROUND_ACTIVE,
SERVICE_FINISHED, SERVICE_DESTROYED, PAGE_RELEASED, SESSION_RELEASED, HEARTBEAT_EXPIRED,
}
private val counts = linkedMapOf<Event, Long>()
private val recent = ArrayDeque<Pair<Long, Event>>()
@Synchronized fun record(event: Event) {
counts[event] = (counts[event] ?: 0) + 1
// Position updates must not displace the useful startup/failure sequence.
if (recent.lastOrNull()?.second != event && event !in setOf(Event.MESSAGE_RECEIVED, Event.STATE_ACCEPTED, Event.FOREGROUND_ACTIVE)) {
recent.addLast(SystemClock.elapsedRealtime() to event)
while (recent.size > 12) recent.removeFirst()
}
}
@Synchronized internal fun events(): String = buildString {
counts.forEach { (event, count) -> append("${event.name}: $count\n") }
append("Recent transitions (seconds since boot):\n")
recent.forEach { (at, event) -> append("${at / 1000}: ${event.name}\n") }
}
fun report(context: Context): String = buildString {
val manager = context.getSystemService(NotificationManager::class.java)
append("Companion playback diagnostics v1\n")
val app = context.packageManager.getPackageInfo(context.packageName, 0)
append("App: ${app.versionName}\n")
append("Android API: ${Build.VERSION.SDK_INT}\n")
append("WebView: ${WebView.getCurrentWebViewPackage()?.versionName ?: "unavailable"}\n")
append("Notifications enabled: ${manager.areNotificationsEnabled()}\n")
append("Audio channel importance: ${manager.getNotificationChannel("companion-audio")?.importance ?: "not created"}\n")
append("Native session: ${CompanionAudioBridge.state != null}\n")
append("Native playing: ${CompanionAudioBridge.state?.playing ?: false}\n")
append("Service present: ${CompanionAudioService.instance != null}\n")
append(events())
}
}
@@ -23,6 +23,7 @@ import android.os.SystemClock
import android.util.Base64
import androidx.core.content.ContextCompat
import com.archipelago.app.MainActivity
import com.archipelago.app.ui.screens.CompanionAudioDiagnostics.Event
/** Foreground ownership of the existing authenticated WebView player. No stream
* URL, auth token or cookie is copied into native playback or notifications. */
@@ -46,7 +47,7 @@ class CompanionAudioService : Service() {
override fun run() {
val state = CompanionAudioBridge.state ?: return
val age = SystemClock.elapsedRealtime() - CompanionAudioBridge.updatedAt
if (age > 90000) CompanionAudioBridge.stop()
if (age > 90000) { CompanionAudioDiagnostics.record(Event.HEARTBEAT_EXPIRED); CompanionAudioBridge.stop() }
else {
if (age > 15000) CompanionAudioBridge.command("sync")
handler.postDelayed(this, 5000)
@@ -55,6 +56,7 @@ class CompanionAudioService : Service() {
}
override fun onCreate() {
super.onCreate(); instance = this
CompanionAudioDiagnostics.record(Event.SERVICE_CREATED)
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL, "Audio playback", NotificationManager.IMPORTANCE_LOW))
media = MediaSession(this, "Archipelago audio")
@@ -80,6 +82,7 @@ class CompanionAudioService : Service() {
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
CompanionAudioDiagnostics.record(Event.SERVICE_STARTED)
val state = CompanionAudioBridge.state
if (state == null) { finishPlayback(); return START_NOT_STICKY }
finishing = false; instance = this
@@ -140,6 +143,7 @@ class CompanionAudioService : Service() {
.setActions(*controls.toTypedArray())
bitmap?.let { notification.setLargeIcon(it) }
startForeground(NOTIFICATION, notification.build())
CompanionAudioDiagnostics.record(Event.FOREGROUND_ACTIVE)
}
override fun onTaskRemoved(rootIntent: Intent?) {
if (CompanionAudioBridge.state?.playing != true) CompanionAudioBridge.stop()
@@ -148,10 +152,12 @@ class CompanionAudioService : Service() {
internal fun finishPlayback() {
if (finishing) return
finishing = true
CompanionAudioDiagnostics.record(Event.SERVICE_FINISHED)
if (instance === this) instance = null
stopForeground(STOP_FOREGROUND_REMOVE); stopSelf()
}
override fun onDestroy() {
CompanionAudioDiagnostics.record(Event.SERVICE_DESTROYED)
handler.removeCallbacksAndMessages(null)
runCatching { unregisterReceiver(noisy) }
media.isActive = false; media.release(); bitmap = null
@@ -58,6 +58,34 @@ class CompanionAudioTest {
} finally { CompanionAudioBridge.release(web); lifecycle.destroy(); web.destroy() }
}
@Test fun diagnosticReportExcludesPrivateMessagesAndRecordsRejectionStage() {
val app = RuntimeEnvironment.getApplication()
val web = android.webkit.WebView(app)
web.loadUrl("https://private-node.test/cloud?token=private-token")
try {
CompanionAudioBridge.receive(web, "private-invalid-payload", "https://private-node.test", true,
setOf("https://private-node.test")) {}
CompanionAudioBridge.receive(web, state().put("title", "PRIVATE SONG").put("duration", -1).toString(),
"https://private-node.test", true, setOf("https://private-node.test")) {}
val report = CompanionAudioDiagnostics.report(app)
assertTrue(report.contains("INVALID_JSON:"))
assertTrue(report.contains("INVALID_STATE:"))
for (privateValue in listOf("private-node", "private-token", "private-invalid-payload", "PRIVATE SONG", "12345678")) {
assertFalse(report.contains(privateValue))
}
} finally { web.destroy() }
}
@Test fun diagnosticHistoryIsBoundedWithoutDroppingStageCounts() {
repeat(100) {
CompanionAudioDiagnostics.record(CompanionAudioDiagnostics.Event.SERVICE_CREATED)
CompanionAudioDiagnostics.record(CompanionAudioDiagnostics.Event.SERVICE_DESTROYED)
}
val report = CompanionAudioDiagnostics.events()
val history = report.substringAfter("Recent transitions (seconds since boot):\n")
assertEquals(12, history.lines().count { it.isNotBlank() })
assertTrue(report.contains("SERVICE_CREATED:"))
}
private fun state() = JSONObject("""{"version":1,"action":"state","session":"12345678-1234-1234-1234-123456789abc","sequence":1,"title":"Current song","playing":true,"position":10,"duration":120,"previous":true,"next":true,"shuffle":true,"shuffled":false}""")
@Test fun malformedOrUnboundedMetadataCannotBecomeNativePlayback() {
for ((key, value) in listOf("version" to 2, "session" to "foreign", "sequence" to -1,
@@ -0,0 +1,90 @@
# Companion background audio investigation — 2026-10-07
Status: **OPEN — physical acceptance failed.**
APK 0.5.36 (build 56) plays V4V while foregrounded, but the operator reports audio
stopping about five seconds after locking the phone or switching applications,
with no playback notification. Restarting the companion did not fix it. USB is
unavailable. Do not ask for USB again or repeat the restart/demo-track instructions.
The V4V track the operator already used is valid; no separate demo is required.
The 29 Android tests and dashboard/browser native-bridge simulation passed for
build 56. Those checks did not establish real WebView-to-Android messaging or
OS background playback. Physical evidence overrides the earlier nearly-finished
assessment. No confirmed root cause or correction has yet been established.
## Source investigation
- The packaged manifest contains the nonexported mediaPlayback foreground service
and required permissions. The bridge accepts only the paired dashboard main
frame and sends metadata/control, never the authenticated stream URL.
- Existing Robolectric tests call the bridge handler directly. Browser tests use
a mocked native bridge. Neither tests injection on the affected phone.
- Unsupported WebView messaging, origin rejection, malformed state, and absent
native messages previously had no user-readable diagnosis. Startup exceptions
are handled but only emit a generic page error.
- The separate native browser overlay for apps opened outside the dashboard does
not attach this dashboard media bridge. Whether the operator used that route
is unknown; do not assert this is the cause or broaden trusted origins.
- Missing notification does not prove notification permission is the cause. The
diagnostic separates whether a native state/service was ever established.
## Diagnostic build 0.5.37 / 57
Android-only, with no speculative playback behavior change. Adds Menu → Playback
diagnostics, Refresh, and Copy report. The report includes app/Android/WebView
versions, notification/channel status, native session/service presence, allowlisted
bridge acceptance/rejection and service stage counts, and at most 12 recent stage
transitions. No URLs, titles, session identifiers, credentials, raw exceptions,
automatic upload, or persistent logging. Counters are in memory and reset on app
process restart. The operator chooses whether to copy/share the report.
Interpretation:
- BRIDGE_UNSUPPORTED / NO_APPROVED_ORIGIN: bridge did not attach.
- BRIDGE_ATTACHED with no MESSAGE_RECEIVED: investigate dashboard client/loading
and whether playback opened outside its retained iframe.
- SENDER_REJECTED / INVALID_STATE: investigate the corresponding protocol boundary;
do not weaken origin or input checks.
- SERVICE_REQUESTED without FOREGROUND_ACTIVE: investigate Android service startup.
- FOREGROUND_ACTIVE followed by stopping: investigate lifecycle, service ownership,
notification settings and WebView background policy with those actual stages.
The build is diagnostic, **not a fixed/accepted background-audio release**. After
installation, play the same V4V track, reproduce once, reopen Menu → Playback
diagnostics and copy the report. No need to use a different track or USB.
## Validation
- All **33 Android unit/Robolectric tests passed**, zero failures/errors/skips,
including SDK 28/35 privacy and bounded-history regressions. XML was preserved
before the canonical clean package build.
- Canonical clean assemble/sign verification passed v1/v2/v3 and the original
signer. APK SHA256:
`9b63bd928ab396278791ab3b514dbb53daaf4b7de9311e8e4f505acc02594a2c`.
- Delivered and HTTP bytes verified at
<http://192.168.63.240/packages/archipelago-companion-0.5.37.apk>.
The live dashboard, generic APK/JSON and versioned APK 0.5.36 remained byte-for-byte
unchanged. Candidate generic APK/JSON were restored after the canonical script.
- 85 captured Android/package source input hashes remained unchanged through
packaging. No keystore was staged, copied into evidence, or replaced.
- Durable evidence: `~/.local/state/archipelago/release-qualification/companion-057/`.
No wallet, payment, catalog, OTA, ISO, or native node-service deployment changed.
- Physical background playback remains **unaccepted**; the diagnostic report is
the next evidence required from the affected phone.
## Companion shell routing check
A read-only browser check on Yaya at 390 px injected `ArchipelagoNative`
(openInApp, openInAppEx, openExternal) alongside the mocked audio bridge, then
used the existing qualification entry `openSession('node-demo-v4v')` from Discover.
It retained one :7475 dashboard iframe, `panelApp` and `mediaApp` were both
`node-demo-v4v`, and native navigation calls were empty. Zero signing/payment
operations were attempted. Thus the managed entry also retains its host frame
when the companion shell is detected. This still does not prove actual Android
WebView injection or the affected phone's navigation path.
An initial Apps-button probe found no V4V card/package on `/dashboard/apps`; it
therefore did not exercise an actual Apps launch button. Keep that failed probe
distinct from the subsequent successful Discover/launcher check. No routing fix
is justified by these results. Scripts/logs are in the durable diagnostic evidence.