diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts
index 4f101d7f..a4dee660 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 = 55
- versionName = "0.5.35"
+ versionCode = 56
+ versionName = "0.5.36"
vectorDrawables {
useSupportLibrary = true
diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml
index 14b4895f..e5be0acb 100644
--- a/Android/app/src/main/AndroidManifest.xml
+++ b/Android/app/src/main/AndroidManifest.xml
@@ -10,6 +10,7 @@
+
+
+
= 0 && sequence <= 9007199254740991L)
+ val position = value.getDouble("position"); val duration = value.getDouble("duration")
+ require(position.isFinite() && duration.isFinite() && duration in 0.0..604800.0 && position in 0.0..duration)
+ val title = value.getString("title"); require(title.length <= 512)
+ val artwork = value.optString("artwork", "")
+ require(artwork.length <= 90000 && (artwork.isEmpty() || artwork.startsWith("data:image/jpeg;base64,")))
+ return CompanionAudioState(session, sequence, title, value.getBoolean("playing"), position, duration,
+ value.optBoolean("previous"), value.optBoolean("next"), value.optBoolean("shuffle"),
+ value.optBoolean("shuffled"), artwork)
+ }
+ }
+}
+
+internal object CompanionAudioBridge {
+ private val bindings = java.util.WeakHashMap>()
+ private val retired = linkedSetOf()
+ private var view: WebView? = null
+ private var origin: String? = null
+ private var reply: ((String) -> Unit)? = null
+ var state: CompanionAudioState? = null
+ private set
+ var updatedAt: Long = 0
+ 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
+ val origins = urls.mapNotNull(::cloudVideoOrigin).toSet()
+ if (origins.isEmpty()) return
+ val existing = bindings[web]
+ if (existing != null) {
+ if (existing != origins) { bindings[web] = emptySet(); release(web) }
+ return
+ }
+ bindings[web] = origins
+ WebViewCompat.addWebMessageListener(web, "ArchipelagoAudio", origins,
+ object : WebViewCompat.WebMessageListener {
+ override fun onPostMessage(web: WebView, message: WebMessageCompat, source: Uri, main: Boolean, proxy: JavaScriptReplyProxy) {
+ if (bindings[web] != origins) return
+ val raw = runCatching { message.data }.getOrNull() ?: return
+ receive(web, raw, source.toString(), main, origins) { proxy.postMessage(it) }
+ }
+ })
+ }
+ 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
+ val session = data.optString("session")
+ if (data.optString("action") == "release") {
+ if (web === view && session == state?.session) terminate()
+ return
+ }
+ val incoming = runCatching { CompanionAudioState.parse(data) }.getOrNull() ?: return
+ if (session in retired) return
+ val old = state
+ if (old != null && old.session == session) {
+ if (view !== web || incoming.sequence <= old.sequence) return
+ } else {
+ if (!incoming.playing) return // Do not start a service for idle metadata.
+ if (old != null) { command("pause"); retire(old.session) }
+ }
+ 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))
+ }.onFailure {
+ event("error", "Background playback could not start. Reopen the companion and press Play.")
+ command("pause"); terminate()
+ }
+ }
+ private fun retire(session: String) {
+ retired.add(session)
+ while (retired.size > 64) retired.remove(retired.first())
+ }
+ private fun event(type: String, value: String? = null, position: Double? = null) {
+ val current = state ?: return
+ if (cloudVideoOrigin(view?.url) != origin) { terminate(); return }
+ val message = JSONObject().put("version", 1).put("session", current.session).put("type", type)
+ if (value != null) message.put(if (type == "error") "error" else "command", value)
+ if (position != null) message.put("position", position)
+ 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 terminate() {
+ state?.session?.let(::retire)
+ state = null; view = null; origin = null; reply = null
+ CompanionAudioService.instance?.finishPlayback()
+ releaseDetachedKioskWebView()
+ }
+ fun stop() { command("stop"); terminate() }
+}
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
new file mode 100644
index 00000000..71644053
--- /dev/null
+++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/CompanionAudioService.kt
@@ -0,0 +1,171 @@
+package com.archipelago.app.ui.screens
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.media.AudioManager
+import android.media.MediaMetadata
+import android.media.session.MediaSession
+import android.media.session.PlaybackState
+import android.os.Bundle
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import android.os.SystemClock
+import android.util.Base64
+import androidx.core.content.ContextCompat
+import com.archipelago.app.MainActivity
+
+/** Foreground ownership of the existing authenticated WebView player. No stream
+ * URL, auth token or cookie is copied into native playback or notifications. */
+class CompanionAudioService : Service() {
+ companion object {
+ internal var instance: CompanionAudioService? = null
+ private const val CHANNEL = "companion-audio"
+ private const val NOTIFICATION = 4056
+ }
+ private lateinit var media: MediaSession
+ private val handler = Handler(Looper.getMainLooper())
+ private var lastArtwork = ""
+ private var bitmap: Bitmap? = null
+ private var finishing = false
+ private val noisy = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) CompanionAudioBridge.command("pause")
+ }
+ }
+ private val watchdog = object : Runnable {
+ override fun run() {
+ val state = CompanionAudioBridge.state ?: return
+ val age = SystemClock.elapsedRealtime() - CompanionAudioBridge.updatedAt
+ if (age > 90000) CompanionAudioBridge.stop()
+ else {
+ if (age > 15000) CompanionAudioBridge.command("sync")
+ handler.postDelayed(this, 5000)
+ }
+ }
+ }
+ override fun onCreate() {
+ super.onCreate(); instance = this
+ getSystemService(NotificationManager::class.java).createNotificationChannel(
+ NotificationChannel(CHANNEL, "Audio playback", NotificationManager.IMPORTANCE_LOW))
+ media = MediaSession(this, "Archipelago audio")
+ media.setCallback(object : MediaSession.Callback() {
+ override fun onPlay() = CompanionAudioBridge.command("play")
+ override fun onPause() = CompanionAudioBridge.command("pause")
+ override fun onStop() = CompanionAudioBridge.stop()
+ override fun onSkipToNext() { if (CompanionAudioBridge.state?.next == true) CompanionAudioBridge.command("next") }
+ override fun onSkipToPrevious() { if (CompanionAudioBridge.state?.previous == true) CompanionAudioBridge.command("previous") }
+ override fun onSeekTo(pos: Long) {
+ val duration = CompanionAudioBridge.state?.duration ?: return
+ CompanionAudioBridge.command("seek", (pos / 1000.0).coerceIn(0.0, duration))
+ }
+ override fun onCustomAction(action: String, extras: Bundle?) {
+ if (action == "shuffle" && CompanionAudioBridge.state?.shuffle == true) CompanionAudioBridge.command("shuffle")
+ }
+ }, handler)
+ media.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS or MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS)
+ media.setSessionActivity(openPlayer())
+ media.isActive = true
+ ContextCompat.registerReceiver(this, noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY), ContextCompat.RECEIVER_NOT_EXPORTED)
+ handler.postDelayed(watchdog, 5000)
+ }
+ override fun onBind(intent: Intent?): IBinder? = null
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val state = CompanionAudioBridge.state
+ if (state == null) { finishPlayback(); return START_NOT_STICKY }
+ finishing = false; instance = this
+ if (intent?.action != null && intent.getStringExtra("session") == state.session) {
+ when (intent.action) {
+ "stop" -> CompanionAudioBridge.stop()
+ "play", "pause" -> CompanionAudioBridge.command(intent.action!!)
+ "next" -> if (state.next) CompanionAudioBridge.command("next")
+ "previous" -> if (state.previous) CompanionAudioBridge.command("previous")
+ "shuffle" -> if (state.shuffle) CompanionAudioBridge.command("shuffle")
+ }
+ }
+ if (!finishing) refresh()
+ return START_NOT_STICKY // Never reconstruct an authorized stream after process death.
+ }
+ private fun openPlayer() = PendingIntent.getActivity(this, 0,
+ Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ private fun action(name: String, label: String, icon: Int, session: String): Notification.Action {
+ val intent = Intent(this, CompanionAudioService::class.java).setAction(name).putExtra("session", session)
+ val pending = PendingIntent.getService(this, name.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ return Notification.Action.Builder(icon, label, pending).build()
+ }
+ internal fun refresh() {
+ if (finishing) return
+ val state = CompanionAudioBridge.state ?: return
+ if (state.artwork != lastArtwork) {
+ lastArtwork = state.artwork
+ bitmap = decodeArtwork(state.artwork)
+ }
+ val metadata = MediaMetadata.Builder().putString(MediaMetadata.METADATA_KEY_TITLE, state.title)
+ .putString(MediaMetadata.METADATA_KEY_ARTIST, "Archipelago")
+ .putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1000).toLong())
+ bitmap?.let { metadata.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it) }
+ media.setMetadata(metadata.build())
+ var actions = PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_STOP
+ if (state.duration > 0) actions = actions or PlaybackState.ACTION_SEEK_TO
+ if (state.previous) actions = actions or PlaybackState.ACTION_SKIP_TO_PREVIOUS
+ if (state.next) actions = actions or PlaybackState.ACTION_SKIP_TO_NEXT
+ val playback = PlaybackState.Builder().setActions(actions)
+ .setState(if (state.playing) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED,
+ (state.position * 1000).toLong(), if (state.playing) 1f else 0f, SystemClock.elapsedRealtime())
+ if (state.shuffle) playback.addCustomAction("shuffle", if (state.shuffled) "Shuffle on" else "Shuffle off", android.R.drawable.ic_menu_rotate)
+ media.setPlaybackState(playback.build())
+ val controls = mutableListOf()
+ if (state.previous) controls.add(action("previous", "Previous", android.R.drawable.ic_media_previous, state.session))
+ controls.add(action(if (state.playing) "pause" else "play", if (state.playing) "Pause" else "Play",
+ if (state.playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play, state.session))
+ if (state.next) controls.add(action("next", "Next", android.R.drawable.ic_media_next, state.session))
+ val compact = controls.indices.toList().toIntArray()
+ if (state.shuffle) controls.add(action("shuffle", if (state.shuffled) "Shuffle on" else "Shuffle off", android.R.drawable.ic_menu_rotate, state.session))
+ controls.add(action("stop", "Stop", android.R.drawable.ic_menu_close_clear_cancel, state.session))
+ val notification = Notification.Builder(this, CHANNEL)
+ .setSmallIcon(android.R.drawable.ic_media_play).setContentTitle(state.title).setContentText("Archipelago")
+ .setContentIntent(openPlayer()).setOnlyAlertOnce(true).setOngoing(state.playing)
+ .setVisibility(Notification.VISIBILITY_PUBLIC).setCategory(Notification.CATEGORY_TRANSPORT)
+ .setStyle(Notification.MediaStyle().setMediaSession(media.sessionToken).setShowActionsInCompactView(*compact))
+ .setActions(*controls.toTypedArray())
+ bitmap?.let { notification.setLargeIcon(it) }
+ startForeground(NOTIFICATION, notification.build())
+ }
+ override fun onTaskRemoved(rootIntent: Intent?) {
+ if (CompanionAudioBridge.state?.playing != true) CompanionAudioBridge.stop()
+ super.onTaskRemoved(rootIntent)
+ }
+ internal fun finishPlayback() {
+ if (finishing) return
+ finishing = true
+ if (instance === this) instance = null
+ stopForeground(STOP_FOREGROUND_REMOVE); stopSelf()
+ }
+ override fun onDestroy() {
+ handler.removeCallbacksAndMessages(null)
+ runCatching { unregisterReceiver(noisy) }
+ media.isActive = false; media.release(); bitmap = null
+ if (instance === this) { instance = null; CompanionAudioBridge.stop() }
+ super.onDestroy()
+ }
+}
+
+internal fun decodeArtwork(data: String): Bitmap? = runCatching {
+ if (!data.startsWith("data:image/jpeg;base64,") || data.length > 90000) return null
+ val bytes = Base64.decode(data.substringAfter(','), Base64.NO_WRAP)
+ if (bytes.size < 4 || bytes[0] != 0xff.toByte() || bytes[1] != 0xd8.toByte() || bytes[2] != 0xff.toByte()) return null
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
+ if (bounds.outWidth !in 1..512 || bounds.outHeight !in 1..512) return null
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
+}.getOrNull()
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt
index ec6dcb13..a273763c 100644
--- a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt
+++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt
@@ -144,6 +144,29 @@ private fun openExternalUrl(context: android.content.Context, url: String) {
* this when the task is genuinely finishing. */
fun releaseKioskWebView() = KioskWebView.drop()
+/** A playing session is owned by the foreground media service after task close. */
+fun finishKioskActivity() {
+ val view = KioskWebView.instance ?: return
+ if (!CompanionAudioBridge.retains(view)) { KioskWebView.drop(); return }
+ (view.parent as? ViewGroup)?.removeView(view)
+ KioskWebView.backgroundOwned = true
+ KioskWebView.clearDelegates()
+ view.setOnTouchListener(null)
+ view.setOnApplyWindowInsetsListener(null)
+ view.setDownloadListener(null)
+ view.webChromeClient = null
+ view.webViewClient = object : WebViewClient() {
+ override fun onPageStarted(web: WebView?, url: String?, favicon: Bitmap?) {
+ web?.let { CompanionAudioBridge.release(it) }
+ }
+ }
+ (view.context as? android.content.MutableContextWrapper)?.baseContext = view.context.applicationContext
+}
+
+internal fun releaseDetachedKioskWebView() {
+ if (KioskWebView.backgroundOwned && !CompanionAudioBridge.retains(KioskWebView.instance)) KioskWebView.drop()
+}
+
/** Restart the app in place: throw away the retained page and relaunch the
* task from scratch. The mesh/VPN service is deliberately left running — this
* is the "give me a clean app" button (hub menu), not a process kill. */
@@ -294,6 +317,7 @@ private fun isSameHost(url: String, base: String): Boolean {
data class InAppLaunch(val url: String, val icon: String? = null, val name: String? = null)
private object KioskWebView {
+ var backgroundOwned = false
var instance: WebView? = null
var url: String? = null
@@ -306,13 +330,19 @@ private object KioskWebView {
var onQrStatus: (String, Boolean) -> Unit = { _, _ -> }
var onQrClose: () -> Unit = {}
+ fun clearDelegates() {
+ onRouteOutbound = {}; onOpenInApp = {}; onQrOpen = {}
+ onQrStatus = { _, _ -> }; onQrClose = {}
+ }
fun drop() {
- instance?.let {
+ val old = instance
+ instance = null; url = null; backgroundOwned = false
+ clearDelegates()
+ old?.let {
+ CompanionAudioBridge.release(it)
(it.parent as? ViewGroup)?.removeView(it)
it.destroy()
}
- instance = null
- url = null
}
}
@@ -901,7 +931,9 @@ fun WebViewScreen(
// stale closures from the previous visit are replaced.
if (KioskWebView.url != serverUrl) KioskWebView.drop()
val reused = KioskWebView.instance
- (reused ?: WebView(context)).apply {
+ (reused ?: WebView(android.content.MutableContextWrapper(context))).apply {
+ (this.context as? android.content.MutableContextWrapper)?.baseContext = context
+ KioskWebView.backgroundOwned = false
(parent as? ViewGroup)?.removeView(this)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
@@ -917,6 +949,7 @@ fun WebViewScreen(
applyArchipelagoSettings()
cloudPip.attach(this, listOfNotNull(serverUrl, meshFallbackUrl))
+ CompanionAudioBridge.attach(this, listOfNotNull(serverUrl, meshFallbackUrl))
setDownloadListener(downloads)
settings.apply {
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
@@ -1091,6 +1124,7 @@ fun WebViewScreen(
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
+ CompanionAudioBridge.release(view ?: return)
cloudPip.reset()
isLoading = true
hasError = false
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
new file mode 100644
index 00000000..1ef6d8e7
--- /dev/null
+++ b/Android/app/src/test/java/com/archipelago/app/ui/screens/CompanionAudioTest.kt
@@ -0,0 +1,90 @@
+package com.archipelago.app.ui.screens
+
+import org.json.JSONObject
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.Before
+import org.robolectric.Shadows
+import org.robolectric.RuntimeEnvironment
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE, sdk = [28, 35])
+class CompanionAudioTest {
+ @Before fun compatReceiverPermission() {
+ val app = RuntimeEnvironment.getApplication()
+ // The real merged manifest contributes this AndroidX permission.
+ Shadows.shadowOf(app).grantPermissions(app.packageName + ".DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION")
+ }
+ @Test fun actualBridgeBindsOriginSessionAndSequenceAndReleasesStoppedPlayback() {
+ val app = RuntimeEnvironment.getApplication()
+ val web = android.webkit.WebView(app)
+ web.loadUrl("https://node.test/cloud")
+ val events = mutableListOf()
+ fun send(message: JSONObject, origin: String = "https://node.test", main: Boolean = true) {
+ CompanionAudioBridge.receive(web, message.toString(), origin, main, setOf("https://node.test")) { events.add(JSONObject(it)) }
+ }
+ try {
+ send(state(), main = false); assertNull(CompanionAudioBridge.state)
+ send(state(), origin = "https://foreign.test"); assertNull(CompanionAudioBridge.state)
+ send(state()); assertTrue(CompanionAudioBridge.retains(web))
+ send(state().put("sequence", 0).put("playing", false)); assertTrue(CompanionAudioBridge.state!!.playing)
+ CompanionAudioBridge.command("seek", 32.0)
+ assertEquals("seek", events.last().getString("command")); assertEquals(32.0, events.last().getDouble("position"), 0.0)
+ send(state().put("sequence", 2).put("playing", false)); assertFalse(CompanionAudioBridge.state!!.playing)
+ CompanionAudioBridge.stop(); assertNull(CompanionAudioBridge.state)
+ assertEquals("stop", events.last().getString("command"))
+ send(state().put("sequence", 3)); assertNull(CompanionAudioBridge.state) // delayed state cannot revive a stopped session
+ } finally { CompanionAudioBridge.release(web); web.destroy() }
+ }
+ @Test fun liveBridgeBuildsForegroundMediaNotificationForSameSession() {
+ val app = RuntimeEnvironment.getApplication()
+ val web = android.webkit.WebView(app); web.loadUrl("https://node.test/cloud")
+ val payload = state().put("session", "22345678-1234-1234-1234-123456789abc")
+ CompanionAudioBridge.receive(web, payload.toString(), "https://node.test", true, setOf("https://node.test")) {}
+ val lifecycle = Robolectric.buildService(CompanionAudioService::class.java).create()
+ try {
+ lifecycle.get().onStartCommand(null, 0, 1)
+ val notification = Shadows.shadowOf(lifecycle.get()).lastForegroundNotification
+ assertNotNull(notification)
+ assertEquals("Current song", notification.extras.getString(android.app.Notification.EXTRA_TITLE))
+ assertEquals(5, notification.actions.size)
+ assertNotNull(notification.extras.getParcelable(android.app.Notification.EXTRA_MEDIA_SESSION))
+ lifecycle.get().onTaskRemoved(null)
+ assertTrue(CompanionAudioBridge.retains(web))
+ } finally { CompanionAudioBridge.release(web); lifecycle.destroy(); web.destroy() }
+ }
+
+ 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,
+ "position" to -1, "position" to 121, "duration" to 604801, "title" to "x".repeat(513),
+ "artwork" to "https://node.test/protected?token=secret")) {
+ assertTrue("Must reject $key", runCatching { CompanionAudioState.parse(state().put(key, value)) }.isFailure)
+ }
+ }
+ @Test fun currentMetadataPreservesPauseSeekAndQueueCapabilities() {
+ val parsed = CompanionAudioState.parse(state().put("playing", false).put("shuffled", true))
+ assertFalse(parsed.playing); assertTrue(parsed.shuffled)
+ assertTrue(parsed.previous && parsed.next && parsed.shuffle)
+ assertEquals(10.0, parsed.position, 0.0)
+ assertEquals(120.0, parsed.duration, 0.0)
+ assertEquals("", parsed.artwork)
+ }
+ @Test fun artworkNeverFetchesProtectedUrlsAndRejectsInvalidBytes() {
+ assertNull(decodeArtwork("https://node.test/protected"))
+ assertNull(decodeArtwork("data:image/jpeg;base64,AAAA"))
+ assertNull(decodeArtwork("data:image/jpeg;base64," + "A".repeat(90000)))
+ }
+ @Test fun serviceRestartWithoutLiveAuthorizedSessionDoesNotResumePlayback() {
+ val lifecycle = Robolectric.buildService(CompanionAudioService::class.java).create()
+ try {
+ assertEquals(android.app.Service.START_NOT_STICKY, lifecycle.get().onStartCommand(null, 0, 1))
+ assertNull(CompanionAudioBridge.state)
+ assertNull(CompanionAudioService.instance)
+ } finally { lifecycle.destroy() }
+ }
+}
diff --git a/docs/SESSION-HANDOVER-20261007.md b/docs/SESSION-HANDOVER-20261007.md
index 7d9a21bb..f6bdebab 100644
--- a/docs/SESSION-HANDOVER-20261007.md
+++ b/docs/SESSION-HANDOVER-20261007.md
@@ -5,6 +5,8 @@ candidate worktree is `/home/archipelago/Projects/archy-session-key` on branch
`work/post-190-session-key`. Do not use credentials from chat or print private
node, wallet, session or registry data.
+Latest active checkpoint: see **Companion56 native-audio qualification** at the end. Earlier receipts below are historical and apply only to their named source/artifacts.
+
## Current candidate
Recent commits, newest first:
@@ -289,7 +291,7 @@ No percentage is inferred from test counts.
| 17 HTTPS apps | In progress | Exact hostname/trust and companion acceptance |
| 18 Firewall/tunnel | In progress | Read-only UI done; settings persistence/reboot/rollback qualification |
| 19 Media guide/Cloud PiP | Nearly finished | PiP operator-accepted; finish reusable contract and remaining edge cases |
-| 20 Native background media | In progress | Cloud PiP done; native audio service/controls and device checks remain |
+| 20 Native background media | In progress | Native service/controls implemented and APK56 delivered; matching LAN HTTP UI qualification and physical device checks remain |
| 21 Public files/folders | Queued, before22 | Shared pricing interface with blue tints, unrelated-node discovery and tiny marker |
| 22 Mesh file sharing | Queued, final task | Node/computer choice, tree/search picker, retained multi-selection, delivery design and usual paid-file flow |
@@ -327,3 +329,50 @@ Task22 now follows task21 at the end. Its detailed scope is in
`post-1.9.0-work-backlog.md`. This is a recorded requirement/design task, not a
claim that the picker or transport/payment flow has been implemented. Existing
public-sharing blue pricing and marker requirements are retained unchanged.
+
+
+## Companion56 native-audio qualification — 2026-10-07
+
+- Operator withdrew the accidental screensaver/Play/Skip prompts. No changes
+ were made for them, so nothing required reverting.
+- Companion0.5.36/build56 is delivered at
+ `http://192.168.63.240/packages/archipelago-companion-0.5.36.apk`.
+ SHA256 `6bee2a2a13231525997946926bb47c473e01b81c03737d22d1bce18af9982c6e`.
+ Canonical signing, clean packaging and HTTP byte verification passed. The
+ standard fleet APK and live backend/catalogs were preserved.
+- 29 Android tests pass (including native bridge, foreground notification and
+ task-removal retention on SDK28/35). These are automated tests, not phone
+ acceptance. Durable receipt:
+ `~/.local/state/archipelago/release-qualification/companion-056/`.
+- The native MediaSession/foreground service controls the same retained
+ authenticated WebView player, with queue controls, bounded thumbnail data,
+ session/origin checks and logout/stop cleanup. No protected stream URLs or
+ credentials are sent to Android and no second decoder/payment is started.
+- Initial complete dashboard qualification passed1440 tests/175files and its
+ production build. That UI was deployed on dev with backup and unchanged
+ backend, catalog/session/intent hashes and container IDs/start times.
+- Live LAN HTTP browser inspection then found `crypto.randomUUID` absent. The
+ new bridge now generates UUIDv4 using `crypto.getRandomValues`, which that
+ browser supports. A regression covers this exact condition; final full UI
+ suite/build and replacement deployment are underway. Do not ask the operator
+ to accept native audio until the matching corrected Yaya UI is deployed.
+- Yaya remains at its previous UI until qualification completes. Physical
+ Android background, lock-screen, swipe-away, reconnect and stop acceptance
+ remains required. CloudPiP55 remains operator accepted.
+- Dedicated IndeeHub agent owns backend changes and the full isolated seven-app
+ fixture. Its first latest suite had2002pass/1failure/5ignored: the new fixture
+ incorrectly expected no directory, although an empty directory is valid.
+ Assertion corrected to require no journal files. Final isolated recheck:
+ **2,003 passed, zero failed, five ignored**, committed as `b9c75b21`. This
+ is not a live IndeeHub delivery; optimized binary and full VM cutover still
+ remain before Yaya activation.
+
+- First HTTP-fix full UI run:1440 passed, one60-second Home wallet-display
+ timeout and a worker `snapshotSaved` timeout under severe memory/I/O pressure.
+ Focused rerun:all12 Home wallet-cache tests and all6 companion bridge tests
+ passed (18/18). Clean full rerun is underway; retain both failed and passing
+ logs. The initial1440-green full receipt predates the UUID fix.
+- Extended real V4V browser qualification supports optional simulated Android
+ transport while using the actual authenticated iframe and bundled free demo
+ media. It checks native command routing and JPEG thumbnail delivery; no
+ physical Android result is implied. The new end-to-end run remains pending.
diff --git a/docs/app-media-integration.md b/docs/app-media-integration.md
index 256685f6..6971d00c 100644
--- a/docs/app-media-integration.md
+++ b/docs/app-media-integration.md
@@ -7,10 +7,12 @@ reference app; its updated real-node acceptance is tracked in
[v4v-native-player-20261006.md](v4v-native-player-20261006.md). Local tests are not
proof that a particular deployed app or companion version supports every feature.
-Cloud video picture-in-picture (PiP) in the Android companion is a separate open
-implementation task. Do not advertise the audio bridge as a video/PiP API. Native
-fullscreen support alone does not provide Android PiP. A versioned video contract
-and examples must be added here when implementation and device tests pass.
+Cloud video PiP is implemented in companion0.5.35/build55; the operator accepted
+the delivered Cloud PiP flow on2026-10-07. Audio and video use separate channels.
+Native background audio is a companion0.5.36/build56 candidate:29 Android tests
+and six companion-bridge dashboard tests pass; physical background/lock-screen/task
+removal acceptance remains required. Do not infer those phone results from
+browser or Robolectric tests.
## Declare the audio integration
@@ -152,7 +154,7 @@ those guards merely to make a test pass.
viewport test cannot prove OS background playback or PiP support. Do not promise
playback after the operating system kills the process.
-## Cloud video PiP acceptance scope (not yet implemented)
+## Cloud video PiP contract and remaining acceptance
Start with Cloud's existing authorized video viewer and companion fullscreen host.
Provide an explicit PiP control when supported, with clear unavailable behavior.
@@ -165,3 +167,67 @@ logout/session expiry, FIPS disconnect/reconnect, and process recreation. Valida
on a physical companion with ordinary and FIPS-accessed Cloud videos before APK
publication. Document the proven video integration contract for other app authors
only after that implementation is qualified.
+
+
+The dashboard uses the main-frame-only `ArchipelagoCloudVideo` channel, admitted
+only for the paired node's exact HTTP(S) origins. Its version1 capability check
+is separate from `archipelago-v1` app audio integration. The Cloud host arms a
+random request/session ID with video dimensions and playing state, then enters
+fullscreen on the existing video in the same user gesture before requesting PiP.
+Native commands and replies carry that session; a different session is ignored.
+`state` updates playback controls and `release` retires the session. `restored`
+returns to the same video; closing requests pause/cleanup. No video URL, cookie,
+bearer token or second player crosses the channel. The entire dashboard must
+never be used as the PiP surface. This is currently a Cloud-host contract, not
+permission for arbitrary embedded apps to call native PiP directly.
+
+## Companion native audio: build56 candidate
+
+Apps continue using the existing version1 app audio protocol above. They do not
+need a second Android stream or a separate queue implementation. The dashboard
+owns a main-frame `ArchipelagoAudio` channel restricted to paired node origins;
+embedded app frames cannot invoke it directly. The foreground `mediaPlayback`
+service owns the retained WebView session after the Activity/task closes and
+exposes an Android MediaSession with playback metadata, play/pause, previous/next,
+seek, shuffle and Stop. Playback state is confirmed by the existing app player,
+not optimistically advanced by native controls.
+
+Dashboard→native messages contain version1, a random session, monotonically
+increasing sequence, bounded title/position/duration, playback/control flags and
+optional JPEG thumbnail bytes. Artwork is fetched by the already authenticated
+page with same-origin credentials only, capped at512KiB, resized to192px and sent
+as a bounded data URL. Native code does not fetch artwork URLs or receive auth
+credentials. Cross-origin images without CORS may have no notification thumbnail;
+missing artwork never blocks playback. Native image decoding bounds dimensions.
+
+Native→dashboard controls carry the same session. Retired sessions, stale sequence
+numbers, foreign origins and subframes cannot revive/control playback. A short
+heartbeat resynchronizes state; if the dashboard stops responding for90seconds,
+the native owner stops instead of advertising a live session indefinitely.
+Playback stays in the same authenticated WebView/iframe; app authorization and
+entitlement checks remain with that player. App removal, frame replacement,
+logout, navigation/disconnect or explicit Stop release the corresponding session.
+A non-playing task removed from Recents is released. A playing one is retained;
+reopening reattaches the existing document, queue and position. A killed process
+is not automatically restarted into an authenticated stream.
+
+The implementation uses the platform MediaSession with the existing WebView
+player, rather than adding another decoder. No boot receiver or new storage
+permission is involved. Pair this APK with the matching dashboard build: an older
+dashboard does not send the native audio protocol merely because the APK changed.
+
+Qualification commands:
+
+```sh
+cd neode-ui
+./node_modules/.bin/vitest run src/composables/__tests__/useCompanionAudio.test.ts src/composables/__tests__/useAppMediaBridge.test.ts src/components/__tests__/GlobalAudioPlayerExternal.test.ts
+cd ../Android
+./gradlew :app:testDebugUnitTest
+```
+
+Before marking physical acceptance, use V4V on the matching server: play a track,
+close its panel, press Home, lock the phone, pause/resume/seek/skip/shuffle from
+native controls, remove the companion task while playing, reopen into the same
+queue/position, then Stop. Repeat logout, headset disconnect and network loss.
+Confirm download, fullscreen and Cloud PiP still work. App force-stop/process
+kill is a stop condition, not a promise of uninterrupted playback.
diff --git a/neode-ui/public/packages/archipelago-companion.apk b/neode-ui/public/packages/archipelago-companion.apk
index 4ccb3bb5..0f758c5a 100644
Binary files a/neode-ui/public/packages/archipelago-companion.apk and b/neode-ui/public/packages/archipelago-companion.apk differ
diff --git a/neode-ui/public/packages/archipelago-companion.json b/neode-ui/public/packages/archipelago-companion.json
index 07cd50a8..23a281f1 100644
--- a/neode-ui/public/packages/archipelago-companion.json
+++ b/neode-ui/public/packages/archipelago-companion.json
@@ -1,4 +1,4 @@
{
- "versionName": "0.5.35",
- "versionCode": 55
+ "versionName": "0.5.36",
+ "versionCode": 56
}
diff --git a/neode-ui/src/components/GlobalAudioPlayer.vue b/neode-ui/src/components/GlobalAudioPlayer.vue
index fe26e6f3..668a1cd8 100644
--- a/neode-ui/src/components/GlobalAudioPlayer.vue
+++ b/neode-ui/src/components/GlobalAudioPlayer.vue
@@ -69,8 +69,10 @@