Add local companion playback diagnostics for failed background audio
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 56
|
versionCode = 57
|
||||||
versionName = "0.5.36"
|
versionName = "0.5.37"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import com.archipelago.app.R
|
import com.archipelago.app.R
|
||||||
import com.archipelago.app.data.ServerEntry
|
import com.archipelago.app.data.ServerEntry
|
||||||
import com.archipelago.app.ui.screens.restartCompanionApp
|
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.BitcoinOrange
|
||||||
import com.archipelago.app.ui.theme.SurfaceDark
|
import com.archipelago.app.ui.theme.SurfaceDark
|
||||||
import com.archipelago.app.ui.theme.TextMuted
|
import com.archipelago.app.ui.theme.TextMuted
|
||||||
@@ -252,6 +253,7 @@ private fun MenuPanel(
|
|||||||
HubPage.FIPS -> "FIPS Mesh"
|
HubPage.FIPS -> "FIPS Mesh"
|
||||||
HubPage.BACKUP -> "Backup & Restore"
|
HubPage.BACKUP -> "Backup & Restore"
|
||||||
HubPage.SIGNER -> "Remote Signer"
|
HubPage.SIGNER -> "Remote Signer"
|
||||||
|
HubPage.AUDIO -> "Playback diagnostics"
|
||||||
HubPage.HUB -> "Menu"
|
HubPage.HUB -> "Menu"
|
||||||
},
|
},
|
||||||
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
|
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
|
||||||
@@ -307,6 +309,7 @@ private fun MenuPanel(
|
|||||||
onDismiss()
|
onDismiss()
|
||||||
restartCompanionApp(hubContext)
|
restartCompanionApp(hubContext)
|
||||||
}
|
}
|
||||||
|
HubCard(Icons.Default.Dashboard, "Playback diagnostics", "Local background audio status") { page = HubPage.AUDIO }
|
||||||
val versionLabel = remember {
|
val versionLabel = remember {
|
||||||
runCatching {
|
runCatching {
|
||||||
hubContext.packageManager
|
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 -> {
|
HubPage.FIPS -> {
|
||||||
FipsSection(embedded = true)
|
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. */
|
/** Big tappable destination card for the hub page: icon + title + subtitle. */
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.webkit.WebMessageCompat
|
|||||||
import androidx.webkit.WebViewCompat
|
import androidx.webkit.WebViewCompat
|
||||||
import androidx.webkit.WebViewFeature
|
import androidx.webkit.WebViewFeature
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import com.archipelago.app.ui.screens.CompanionAudioDiagnostics.Event
|
||||||
|
|
||||||
/** Metadata/control only: the authorized WebView owns the stream and queue. */
|
/** Metadata/control only: the authorized WebView owns the stream and queue. */
|
||||||
internal data class CompanionAudioState(
|
internal data class CompanionAudioState(
|
||||||
@@ -50,12 +51,12 @@ internal object CompanionAudioBridge {
|
|||||||
private set
|
private set
|
||||||
fun retains(web: WebView?) = web != null && view === web && state != null
|
fun retains(web: WebView?) = web != null && view === web && state != null
|
||||||
fun attach(web: WebView, urls: List<String>) {
|
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()
|
val origins = urls.mapNotNull(::cloudVideoOrigin).toSet()
|
||||||
if (origins.isEmpty()) return
|
if (origins.isEmpty()) { CompanionAudioDiagnostics.record(Event.NO_APPROVED_ORIGIN); return }
|
||||||
val existing = bindings[web]
|
val existing = bindings[web]
|
||||||
if (existing != null) {
|
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
|
return
|
||||||
}
|
}
|
||||||
bindings[web] = origins
|
bindings[web] = origins
|
||||||
@@ -67,34 +68,45 @@ internal object CompanionAudioBridge {
|
|||||||
receive(web, raw, source.toString(), main, origins) { proxy.postMessage(it) }
|
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,
|
internal fun receive(web: WebView, raw: String, source: String, main: Boolean,
|
||||||
origins: Set<String>, proxy: (String) -> Unit) {
|
origins: Set<String>, proxy: (String) -> Unit) {
|
||||||
if (!cloudVideoSenderAllowed(web.url, source, origins, main)) return
|
CompanionAudioDiagnostics.record(Event.MESSAGE_RECEIVED)
|
||||||
if (raw.length > 96000) return
|
if (!cloudVideoSenderAllowed(web.url, source, origins, main)) { CompanionAudioDiagnostics.record(Event.SENDER_REJECTED); return }
|
||||||
val data = runCatching { JSONObject(raw) }.getOrNull() ?: 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")
|
val session = data.optString("session")
|
||||||
if (data.optString("action") == "release") {
|
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
|
return
|
||||||
}
|
}
|
||||||
val incoming = runCatching { CompanionAudioState.parse(data) }.getOrNull() ?: return
|
val incoming = runCatching { CompanionAudioState.parse(data) }.getOrNull() ?: run { CompanionAudioDiagnostics.record(Event.INVALID_STATE); return }
|
||||||
if (session in retired) return
|
if (session in retired) { CompanionAudioDiagnostics.record(Event.RETIRED_SESSION); return }
|
||||||
val old = state
|
val old = state
|
||||||
if (old != null && old.session == session) {
|
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 {
|
} 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) }
|
if (old != null) { command("pause"); retire(old.session) }
|
||||||
}
|
}
|
||||||
|
CompanionAudioDiagnostics.record(Event.STATE_ACCEPTED)
|
||||||
view = web; origin = cloudVideoOrigin(source); reply = proxy
|
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()
|
state = if (old != null && old.session == session && !data.has("artwork")) incoming.copy(artwork = old.artwork) else incoming; updatedAt = SystemClock.elapsedRealtime()
|
||||||
runCatching {
|
runCatching {
|
||||||
val service = CompanionAudioService.instance
|
val service = CompanionAudioService.instance
|
||||||
if (service != null) service.refresh()
|
if (service != null) service.refresh()
|
||||||
else ContextCompat.startForegroundService(web.context.applicationContext,
|
else {
|
||||||
Intent(web.context.applicationContext, CompanionAudioService::class.java))
|
CompanionAudioDiagnostics.record(Event.SERVICE_REQUESTED)
|
||||||
|
ContextCompat.startForegroundService(web.context.applicationContext,
|
||||||
|
Intent(web.context.applicationContext, CompanionAudioService::class.java))
|
||||||
|
}
|
||||||
}.onFailure {
|
}.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.")
|
event("error", "Background playback could not start. Reopen the companion and press Play.")
|
||||||
command("pause"); terminate()
|
command("pause"); terminate()
|
||||||
}
|
}
|
||||||
@@ -112,7 +124,7 @@ internal object CompanionAudioBridge {
|
|||||||
runCatching { reply?.invoke(message.toString()) }
|
runCatching { reply?.invoke(message.toString()) }
|
||||||
}
|
}
|
||||||
fun command(name: String, position: Double? = null) = event("command", name, position)
|
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() {
|
fun terminate() {
|
||||||
state?.session?.let(::retire)
|
state?.session?.let(::retire)
|
||||||
state = null; view = null; origin = null; reply = null
|
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 android.util.Base64
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.archipelago.app.MainActivity
|
import com.archipelago.app.MainActivity
|
||||||
|
import com.archipelago.app.ui.screens.CompanionAudioDiagnostics.Event
|
||||||
|
|
||||||
/** Foreground ownership of the existing authenticated WebView player. No stream
|
/** Foreground ownership of the existing authenticated WebView player. No stream
|
||||||
* URL, auth token or cookie is copied into native playback or notifications. */
|
* URL, auth token or cookie is copied into native playback or notifications. */
|
||||||
@@ -46,7 +47,7 @@ class CompanionAudioService : Service() {
|
|||||||
override fun run() {
|
override fun run() {
|
||||||
val state = CompanionAudioBridge.state ?: return
|
val state = CompanionAudioBridge.state ?: return
|
||||||
val age = SystemClock.elapsedRealtime() - CompanionAudioBridge.updatedAt
|
val age = SystemClock.elapsedRealtime() - CompanionAudioBridge.updatedAt
|
||||||
if (age > 90000) CompanionAudioBridge.stop()
|
if (age > 90000) { CompanionAudioDiagnostics.record(Event.HEARTBEAT_EXPIRED); CompanionAudioBridge.stop() }
|
||||||
else {
|
else {
|
||||||
if (age > 15000) CompanionAudioBridge.command("sync")
|
if (age > 15000) CompanionAudioBridge.command("sync")
|
||||||
handler.postDelayed(this, 5000)
|
handler.postDelayed(this, 5000)
|
||||||
@@ -55,6 +56,7 @@ class CompanionAudioService : Service() {
|
|||||||
}
|
}
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate(); instance = this
|
super.onCreate(); instance = this
|
||||||
|
CompanionAudioDiagnostics.record(Event.SERVICE_CREATED)
|
||||||
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||||
NotificationChannel(CHANNEL, "Audio playback", NotificationManager.IMPORTANCE_LOW))
|
NotificationChannel(CHANNEL, "Audio playback", NotificationManager.IMPORTANCE_LOW))
|
||||||
media = MediaSession(this, "Archipelago audio")
|
media = MediaSession(this, "Archipelago audio")
|
||||||
@@ -80,6 +82,7 @@ class CompanionAudioService : Service() {
|
|||||||
}
|
}
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
CompanionAudioDiagnostics.record(Event.SERVICE_STARTED)
|
||||||
val state = CompanionAudioBridge.state
|
val state = CompanionAudioBridge.state
|
||||||
if (state == null) { finishPlayback(); return START_NOT_STICKY }
|
if (state == null) { finishPlayback(); return START_NOT_STICKY }
|
||||||
finishing = false; instance = this
|
finishing = false; instance = this
|
||||||
@@ -140,6 +143,7 @@ class CompanionAudioService : Service() {
|
|||||||
.setActions(*controls.toTypedArray())
|
.setActions(*controls.toTypedArray())
|
||||||
bitmap?.let { notification.setLargeIcon(it) }
|
bitmap?.let { notification.setLargeIcon(it) }
|
||||||
startForeground(NOTIFICATION, notification.build())
|
startForeground(NOTIFICATION, notification.build())
|
||||||
|
CompanionAudioDiagnostics.record(Event.FOREGROUND_ACTIVE)
|
||||||
}
|
}
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
if (CompanionAudioBridge.state?.playing != true) CompanionAudioBridge.stop()
|
if (CompanionAudioBridge.state?.playing != true) CompanionAudioBridge.stop()
|
||||||
@@ -148,10 +152,12 @@ class CompanionAudioService : Service() {
|
|||||||
internal fun finishPlayback() {
|
internal fun finishPlayback() {
|
||||||
if (finishing) return
|
if (finishing) return
|
||||||
finishing = true
|
finishing = true
|
||||||
|
CompanionAudioDiagnostics.record(Event.SERVICE_FINISHED)
|
||||||
if (instance === this) instance = null
|
if (instance === this) instance = null
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE); stopSelf()
|
stopForeground(STOP_FOREGROUND_REMOVE); stopSelf()
|
||||||
}
|
}
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
CompanionAudioDiagnostics.record(Event.SERVICE_DESTROYED)
|
||||||
handler.removeCallbacksAndMessages(null)
|
handler.removeCallbacksAndMessages(null)
|
||||||
runCatching { unregisterReceiver(noisy) }
|
runCatching { unregisterReceiver(noisy) }
|
||||||
media.isActive = false; media.release(); bitmap = null
|
media.isActive = false; media.release(); bitmap = null
|
||||||
|
|||||||
@@ -58,6 +58,34 @@ class CompanionAudioTest {
|
|||||||
} finally { CompanionAudioBridge.release(web); lifecycle.destroy(); web.destroy() }
|
} 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}""")
|
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() {
|
@Test fun malformedOrUnboundedMetadataCannotBecomeNativePlayback() {
|
||||||
for ((key, value) in listOf("version" to 2, "session" to "foreign", "sequence" to -1,
|
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.
|
||||||
Reference in New Issue
Block a user