diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index a4dee660..1f3d4983 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -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 diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt index 0d43ea87..27309b1d 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt @@ -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 diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/CompanionAudioBridge.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/CompanionAudioBridge.kt index c76838f6..0a4a3e15 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/CompanionAudioBridge.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/CompanionAudioBridge.kt @@ -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) { - 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, 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, - Intent(web.context.applicationContext, CompanionAudioService::class.java)) + 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 diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/CompanionAudioDiagnostics.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/CompanionAudioDiagnostics.kt new file mode 100644 index 00000000..eaef0f28 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/CompanionAudioDiagnostics.kt @@ -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() + private val recent = ArrayDeque>() + @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()) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/CompanionAudioService.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/CompanionAudioService.kt index 71644053..a845e6ba 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/CompanionAudioService.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/CompanionAudioService.kt @@ -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 diff --git a/Android/app/src/test/java/com/archipelago/app/ui/screens/CompanionAudioTest.kt b/Android/app/src/test/java/com/archipelago/app/ui/screens/CompanionAudioTest.kt index 1ef6d8e7..5d60c444 100644 --- a/Android/app/src/test/java/com/archipelago/app/ui/screens/CompanionAudioTest.kt +++ b/Android/app/src/test/java/com/archipelago/app/ui/screens/CompanionAudioTest.kt @@ -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, diff --git a/docs/companion-audio-investigation-20261007.md b/docs/companion-audio-investigation-20261007.md new file mode 100644 index 00000000..15f33998 --- /dev/null +++ b/docs/companion-audio-investigation-20261007.md @@ -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 + . + 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.