feat(companion): retain audio with native media controls

This commit is contained in:
archipelago
2026-10-07 16:10:10 -04:00
parent b9c75b2141
commit 235f6d04d5
17 changed files with 830 additions and 27 deletions
+2 -2
View File
@@ -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
+7
View File
@@ -10,6 +10,7 @@
<!-- Embedded FIPS mesh tunnel (ArchyVpnService) runs as a foreground service. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
@@ -67,6 +68,12 @@
</intent-filter>
</activity>
<service
android:name=".ui.screens.CompanionAudioService"
android:exported="false"
android:stopWithTask="false"
android:foregroundServiceType="mediaPlayback" />
<!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only),
configured entirely by scanning the node's pairing QR. -->
<service
@@ -9,7 +9,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.archipelago.app.ui.navigation.AppNavHost
import com.archipelago.app.ui.screens.releaseKioskWebView
import com.archipelago.app.ui.screens.finishKioskActivity
import com.archipelago.app.ui.theme.ArchipelagoTheme
import kotlinx.coroutines.flow.MutableStateFlow
@@ -56,11 +56,8 @@ class MainActivity : ComponentActivity() {
override fun onDestroy() {
super.onDestroy()
// Swiped out of recents (or otherwise finished) — let go of the
// retained kiosk WebView so the next launch starts clean. Without
// this the FIPS service keeps the process (and the static WebView)
// alive, and "close the app" no longer restarted it. isFinishing
// keeps config changes (rotation) on the fast reattach path.
if (isFinishing) releaseKioskWebView()
// Keep an authorized playing WebView owned by the media service;
// discard ordinary dashboard state when the task is finished.
if (isFinishing) finishKioskActivity()
}
}
@@ -0,0 +1,123 @@
package com.archipelago.app.ui.screens
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.SystemClock
import android.webkit.WebView
import androidx.core.content.ContextCompat
import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import org.json.JSONObject
/** Metadata/control only: the authorized WebView owns the stream and queue. */
internal data class CompanionAudioState(
val session: String, val sequence: Long, val title: String,
val playing: Boolean, val position: Double, val duration: Double,
val previous: Boolean, val next: Boolean, val shuffle: Boolean,
val shuffled: Boolean, val artwork: String,
) {
companion object {
fun parse(value: JSONObject): CompanionAudioState {
require(value.optInt("version") == 1 && value.getString("action") == "state")
val session = value.getString("session")
require(session.matches(Regex("[0-9a-f-]{36}")))
val sequence = value.getLong("sequence")
require(sequence >= 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<WebView, Set<String>>()
private val retired = linkedSetOf<String>()
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<String>) {
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<String>, 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() }
}
@@ -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<Notification.Action>()
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()
@@ -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
@@ -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<JSONObject>()
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.media.session.MediaSession.Token>(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() }
}
}
+50 -1
View File
@@ -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.
+71 -5
View File
@@ -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.
Binary file not shown.
@@ -1,4 +1,4 @@
{
"versionName": "0.5.35",
"versionCode": 55
"versionName": "0.5.36",
"versionCode": 56
}
@@ -69,8 +69,10 @@
<script setup lang="ts">
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
import { useAudioPlayer } from '@/composables/useAudioPlayer'
import { useCompanionAudio } from '@/composables/useCompanionAudio'
const audioPlayer = useAudioPlayer()
useCompanionAudio(audioPlayer)
const barEl = ref<HTMLElement | null>(null)
// Publish the player's height as a CSS variable so page scroll containers can
@@ -0,0 +1,93 @@
import { mount } from '@vue/test-utils'
import { defineComponent, nextTick, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useCompanionAudio } from '../useCompanionAudio'
import type { useAudioPlayer } from '../useAudioPlayer'
beforeEach(() => vi.useFakeTimers())
afterEach(() => { vi.useRealTimers(); delete (window as any).ArchipelagoAudio })
function fixture(install = true) {
const messages: any[] = []
const bridge: any = { onmessage: null, postMessage: (raw: string) => messages.push(JSON.parse(raw)) }
if (install) (window as any).ArchipelagoAudio = bridge
const player = {
currentSrc: ref<string | null>('https://node.test/private?token=secret'), currentName: ref('A song'),
playing: ref(true), currentTime: ref(12), duration: ref(120), externalArtwork: ref(''),
externalApp: ref(true), externalShuffle: ref(false), error: ref<string | null>(null),
pause: vi.fn(), play: vi.fn(), seek: vi.fn(), nextExternal: vi.fn(), previousExternal: vi.fn(), shuffleExternal: vi.fn(),
stop: vi.fn(() => { player.currentSrc.value = null; player.playing.value = false }),
}
let audio!: ReturnType<typeof useCompanionAudio>
const wrapper = mount(defineComponent({ setup() { audio = useCompanionAudio(player as unknown as ReturnType<typeof useAudioPlayer>); return () => null } }))
return { messages, bridge, player, wrapper, audio,
command: (command: string, extra = {}) => bridge.onmessage({ data: JSON.stringify({ version: 1, type: 'command', session: messages[0]?.session, command, ...extra }) }) }
}
describe('companion native audio control of the existing player', () => {
it('sends bounded metadata and queue controls without stream URLs or credentials', async () => {
const f = fixture(); await nextTick()
expect(f.messages[0]).toMatchObject({ action: 'state', title: 'A song', playing: true, position: 12, duration: 120, next: true, previous: true, shuffle: true })
expect(JSON.stringify(f.messages)).not.toContain('secret')
expect(JSON.stringify(f.messages)).not.toContain('https:')
f.player.currentTime.value = 13; await nextTick(); f.player.currentTime.value = 14; await nextTick()
expect(f.messages).toHaveLength(1)
await vi.advanceTimersByTimeAsync(1000)
expect(f.messages).toHaveLength(2); expect(f.messages[1].position).toBe(14)
f.wrapper.unmount(); expect(f.messages[f.messages.length - 1].action).toBe('release')
})
it('starts unique valid sessions on LAN HTTP without crypto.randomUUID', async () => {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'randomUUID')
Object.defineProperty(crypto, 'randomUUID', { configurable: true, value: undefined })
const f = fixture()
try {
await nextTick()
const first = f.messages[0].session
expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
f.player.currentSrc.value = 'app:second'; await nextTick()
expect(f.messages[2].session).not.toBe(first)
} finally {
f.wrapper.unmount()
if (descriptor) Object.defineProperty(crypto, 'randomUUID', descriptor)
else Reflect.deleteProperty(crypto, 'randomUUID')
}
})
it('operates the same queue and ignores controls from a stale session', async () => {
const f = fixture(); await nextTick()
f.command('pause', { session: 'foreign' }); expect(f.player.pause).not.toHaveBeenCalled()
f.command('pause'); f.command('next'); f.command('previous'); f.command('shuffle'); f.command('seek', { position: 900 })
expect(f.player.pause).toHaveBeenCalledOnce(); expect(f.player.nextExternal).toHaveBeenCalledOnce()
expect(f.player.previousExternal).toHaveBeenCalledOnce(); expect(f.player.shuffleExternal).toHaveBeenCalledOnce()
expect(f.player.seek).toHaveBeenCalledWith(120)
f.command('seek', { position: '5' }); expect(f.player.seek).toHaveBeenCalledTimes(1)
f.command('stop'); await nextTick(); expect(f.player.stop).toHaveBeenCalledOnce()
expect(f.messages[f.messages.length - 1].action).toBe('release'); f.wrapper.unmount()
})
it('replaces native session when changing source and releases on logout/player stop', async () => {
const f = fixture(); await nextTick(); const first = f.messages[0].session
f.player.currentSrc.value = 'app:new'; await nextTick()
expect(f.messages[1]).toMatchObject({ action: 'release', session: first })
expect(f.messages[2].session).not.toBe(first)
f.command('pause', { session: first }); expect(f.player.pause).not.toHaveBeenCalled()
f.player.currentSrc.value = null; await nextTick()
expect(f.messages[f.messages.length - 1].action).toBe('release'); f.wrapper.unmount()
})
it('connects a late native bridge and answers synchronization while backgrounded', async () => {
const f = fixture(false); await nextTick(); expect(f.messages).toHaveLength(0)
;(window as any).ArchipelagoAudio = f.bridge
document.dispatchEvent(new Event('visibilitychange'))
expect(f.messages).toHaveLength(1)
f.command('sync'); expect(f.messages).toHaveLength(2)
f.wrapper.unmount(); const count = f.messages.length
await vi.advanceTimersByTimeAsync(30000); expect(f.messages).toHaveLength(count)
})
it('does not revive paused sessions and reports native start failures without leaking details', async () => {
const f = fixture(); await nextTick()
f.bridge.onmessage({ data: JSON.stringify({ version: 1, session: f.messages[0].session, type: 'error', error: 'sensitive details' }) })
expect(f.player.pause).toHaveBeenCalledOnce(); expect(f.player.error.value).toContain('Background playback')
expect(f.player.error.value).not.toContain('sensitive')
f.player.playing.value = false; await nextTick(); const count = f.messages.length
await vi.advanceTimersByTimeAsync(10000); expect(f.messages).toHaveLength(count)
f.wrapper.unmount()
})
})
@@ -0,0 +1,136 @@
import { onBeforeUnmount, watch } from 'vue'
import { useAudioPlayer } from './useAudioPlayer'
type AudioBridge = { postMessage: (message: string) => void; onmessage: ((event: { data: string }) => void) | null }
type NativeWindow = Window & { ArchipelagoAudio?: AudioBridge }
/** LAN HTTP WebViews expose getRandomValues, but not secure-context randomUUID. */
function audioSessionId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6]! & 0x0f) | 0x40
bytes[8] = (bytes[8]! & 0x3f) | 0x80
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
/** Send a small decoded thumbnail, never a protected URL or authorization data. */
async function thumbnail(url: string, signal: AbortSignal): Promise<string> {
if (!url) return ''
const source = new URL(url, window.location.href)
if (source.username || source.password || (source.protocol !== 'https:' && source.origin !== window.location.origin)) return ''
const response = await fetch(source.href, { signal, credentials: 'same-origin', referrerPolicy: 'no-referrer' })
if (!response.ok || !response.headers.get('content-type')?.startsWith('image/')) return ''
const reader = response.body?.getReader()
if (!reader) return ''
const chunks: Uint8Array[] = []
let size = 0
try {
while (true) {
const chunk = await reader.read()
if (chunk.done) break
size += chunk.value.byteLength
if (size > 512 * 1024) { await reader.cancel(); return '' }
chunks.push(chunk.value)
}
} finally { reader.releaseLock() }
const bytes = new Uint8Array(size)
let offset = 0
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length }
const picture = await createImageBitmap(new Blob([bytes]), { resizeWidth: 192, resizeHeight: 192, resizeQuality: 'medium' })
try {
const canvas = document.createElement('canvas'); canvas.width = 192; canvas.height = 192
const context = canvas.getContext('2d'); if (!context) return ''
context.drawImage(picture, 0, 0, 192, 192)
const result = canvas.toDataURL('image/jpeg', 0.75)
return result.length <= 90000 ? result : ''
} finally { picture.close() }
}
export function useCompanionAudio(player = useAudioPlayer()) {
let bridge: AudioBridge | undefined
let session = '', source = '', sequence = 0
let artwork = '', sentArtwork = ''
let imageRequest: AbortController | null = null
let timer: ReturnType<typeof setTimeout> | undefined
let disposed = false
let previousHandler: AudioBridge['onmessage'] = null
function post(value: object) { try { bridge?.postMessage(JSON.stringify(value)) } catch { /* Lifecycle cleanup can close the document channel. */ } }
function release() {
if (session) post({ version: 1, action: 'release', session })
session = ''; source = ''; artwork = ''; sentArtwork = ''
imageRequest?.abort(); imageRequest = null
if (timer) clearTimeout(timer)
timer = undefined
}
function receive(event: { data: string }) {
let value: any
try { value = JSON.parse(event.data) } catch { return }
if (value.version !== 1 || !session || value.session !== session) return
if (value.type === 'error') {
player.pause(); player.error.value = 'Background playback could not start. Reopen the companion and press Play.'
release(); return
}
if (value.type !== 'command') return
switch (value.command) {
case 'play': if (player.currentSrc.value && !player.playing.value) player.play(player.currentSrc.value, player.currentName.value); break
case 'pause': player.pause(); break
case 'stop': player.stop(); release(); break
case 'seek': if (Number.isFinite(value.position)) player.seek(Math.max(0, Math.min(value.position, player.duration.value))); break
case 'next': player.nextExternal(); break
case 'previous': player.previousExternal(); break
case 'shuffle': player.shuffleExternal(); break
case 'sync': publish(); break
}
}
function connect() {
const candidate = (window as NativeWindow).ArchipelagoAudio
if (!candidate) return false
if (bridge !== candidate) {
release()
if (bridge?.onmessage === receive) bridge.onmessage = previousHandler
bridge = candidate; previousHandler = candidate.onmessage; candidate.onmessage = receive
}
return true
}
function publish() {
if (disposed || !connect()) return
if (!player.currentSrc.value || !player.currentName.value) { release(); return }
if (!session || source !== player.currentSrc.value) {
if (!player.playing.value) { release(); return }
release(); session = audioSessionId(); source = player.currentSrc.value; sequence = 0
loadArtwork()
}
const duration = Number.isFinite(player.duration.value) ? Math.max(0, Math.min(player.duration.value, 604800)) : 0
const position = Number.isFinite(player.currentTime.value) ? Math.max(0, Math.min(player.currentTime.value, duration)) : 0
const message: Record<string, unknown> = { version: 1, action: 'state', session, sequence: ++sequence,
title: player.currentName.value.slice(0, 512), playing: player.playing.value, position, duration,
previous: player.externalApp.value, next: player.externalApp.value,
shuffle: player.externalApp.value, shuffled: player.externalShuffle.value }
if (artwork !== sentArtwork || sequence === 1) { message.artwork = artwork; sentArtwork = artwork }
post(message)
}
function loadArtwork() {
imageRequest?.abort(); artwork = ''
if (!session || !player.externalArtwork.value) return
const target = session; const request = new AbortController(); imageRequest = request
void thumbnail(player.externalArtwork.value, request.signal).then(value => {
if (!disposed && !request.signal.aborted && target === session) { artwork = value; publish() }
}).catch(() => { /* Optional image failure never interrupts music. */ })
}
// Playback transitions/track changes are immediate; position updates are bounded.
watch([player.currentSrc, player.currentName, player.playing, player.externalShuffle], publish, { immediate: true, flush: 'post' })
watch([player.currentTime, player.duration], () => {
if (!timer) timer = setTimeout(() => { timer = undefined; publish() }, 1000)
})
watch(player.externalArtwork, () => { loadArtwork(); publish() })
const sync = () => publish()
window.addEventListener('pageshow', sync)
document.addEventListener('visibilitychange', sync)
const heartbeat = setInterval(publish, 10000)
onBeforeUnmount(() => {
disposed = true; release(); clearInterval(heartbeat)
window.removeEventListener('pageshow', sync); document.removeEventListener('visibilitychange', sync)
if (bridge?.onmessage === receive) bridge.onmessage = previousHandler
})
return { sync }
}
+4 -1
View File
@@ -1,7 +1,8 @@
// Authentication store — login, logout, session management
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { useAudioPlayer } from '@/composables/useAudioPlayer'
import { rpcClient } from '../api/rpc-client'
import { useSyncStore } from './sync'
import { useResourcesStore } from './resources'
@@ -9,6 +10,7 @@ import { useResourcesStore } from './resources'
export const useAuthStore = defineStore('auth', () => {
// State
const isAuthenticated = ref(localStorage.getItem('neode-auth') === 'true')
watch(isAuthenticated, authenticated => { if (!authenticated) useAudioPlayer().stop() }, { flush: 'sync' })
const isLoading = ref(false)
const error = ref<string | null>(null)
let sessionValidated = false
@@ -75,6 +77,7 @@ export const useAuthStore = defineStore('auth', () => {
}
async function logout(): Promise<void> {
useAudioPlayer().stop()
const sync = useSyncStore()
try {
await rpcClient.logout()
+14
View File
@@ -63,6 +63,20 @@ const { chromium, expect } = require(process.env.PLAYWRIGHT_MODULE || '../../neo
return route.fallback();
});
}
if (process.env.QUALIFY_COMPANION_BRIDGE === '1') {
if (process.env.QUALIFY_PLAYBACK !== '1') throw Error('Companion bridge qualification requires real playback');
await context.addInitScript(() => {
if (window !== window.top) return;
window.__qualificationAudioBridge = [];
window.ArchipelagoAudio = {
onmessage: null,
postMessage(raw) {
window.__qualificationAudioBridge.push(JSON.parse(raw));
if (window.__qualificationAudioBridge.length > 300) window.__qualificationAudioBridge.shift();
},
};
});
}
page = await context.newPage();
page.on('response', response => { const u = new URL(response.url()); if (u.origin === appOrigin && u.pathname === '/api/auth/nostr/login') proof.login.push(response.status()); });
await page.goto(origin + '/dashboard/discover', { waitUntil: 'domcontentloaded', timeout: 60000 });
+23 -5
View File
@@ -2,6 +2,15 @@
// No fixture media, intercepted success, payment or application state fabrication.
const { expect } = require(process.env.PLAYWRIGHT_MODULE || '../../neode-ui/node_modules/@playwright/test');
module.exports = async function qualifyPlayback(page, appOrigin, proof) {
const nativeBridge = process.env.QUALIFY_COMPANION_BRIDGE === '1';
async function control(command, button) {
if (!nativeBridge) return page.getByRole('button', { name: button, exact: true }).click();
await page.evaluate(command => {
const state = window.__qualificationAudioBridge?.filter(value => value.action === 'state').at(-1);
if (!state?.session || !window.ArchipelagoAudio?.onmessage) throw Error('Companion audio session missing');
window.ArchipelagoAudio.onmessage({ data: JSON.stringify({ version: 1, type: 'command', session: state.session, command }) });
}, command);
}
const app = page.frames().find(frame => frame.url().startsWith(appOrigin + '/'));
if (!app) throw Error('Managed V4V iframe missing after login');
proof.initialBrowse = new URL(app.url()).pathname === '/browse';
@@ -46,18 +55,18 @@ module.exports = async function qualifyPlayback(page, appOrigin, proof) {
await expect(bar).toBeVisible();
await expect(bar).toContainText(tracks[0].title);
await app.waitForFunction(time => window.__qualificationMedia.some(audio => !audio.paused && audio.currentTime > time + 1), initial, { timeout: 15000, polling: 100 });
await page.getByRole('button', { name: 'Pause audio', exact: true }).click();
await control('pause', 'Pause audio');
await app.waitForFunction(() => window.__qualificationMedia.every(audio => audio.paused), undefined, { polling: 100 });
await page.getByRole('button', { name: 'Play audio', exact: true }).click();
await control('play', 'Play audio');
await app.waitForFunction(() => window.__qualificationMedia.some(audio => !audio.paused), undefined, { polling: 100 });
await page.getByRole('button', { name: 'Next track', exact: true }).click();
await control('next', 'Next track');
await app.waitForFunction(id => window.PulsewireLegacy.getReactBridge().helpers.activePlayerRelease()?.id === id, tracks[1].id, { polling: 100 });
await expect(bar).toContainText(tracks[1].title);
// Previous after >3 seconds intentionally restarts the current song in V4V.
await app.waitForFunction(() => window.__qualificationMedia.some(audio => !audio.paused && audio.currentTime > 4), undefined, { polling: 100 });
await page.getByRole('button', { name: 'Previous track', exact: true }).click();
await control('previous', 'Previous track');
await app.waitForFunction(() => window.__qualificationMedia.some(audio => !audio.paused && audio.currentTime < 3), undefined, { polling: 100 });
await page.getByRole('button', { name: 'Shuffle', exact: true }).click();
await control('shuffle', 'Shuffle');
await expect(page.getByRole('button', { name: 'Shuffle', exact: true })).toHaveAttribute('aria-pressed', 'true');
expect(await app.evaluate(() => window.PulsewireLegacy.getReactBridge().getState().ui.shuffleEnabled)).toBe(true);
const artwork = await app.evaluate(() => window.PulsewireLegacy.getReactBridge().helpers.activePlayerRelease()?.cover || '');
@@ -74,6 +83,15 @@ module.exports = async function qualifyPlayback(page, appOrigin, proof) {
for (const axis of ['x', 'y', 'width', 'height']) expect(Math.abs(geometry.image[axis] - geometry.bar[axis])).toBeLessThanOrEqual(2);
proof.renderedArtwork = true;
}
if (nativeBridge) {
await expect.poll(() => page.evaluate(() => window.__qualificationAudioBridge?.filter(value => value.action === 'state').at(-1)?.title)).toContain(tracks[1].title);
const nativeState = await page.evaluate(() => window.__qualificationAudioBridge.filter(value => value.action === 'state').at(-1));
expect(nativeState.playing).toBe(true);
expect(nativeState.session).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(nativeState.shuffled).toBe(true);
if (artwork) await expect.poll(() => page.evaluate(() => window.__qualificationAudioBridge.some(value => value.action === 'state' && value.artwork?.startsWith('data:image/jpeg;base64,'))), { timeout: 15000 }).toBe(true);
proof.companionBridge = { realBrowserPlayer: true, nativeTransportSimulated: true, controls: ['pause', 'play', 'next', 'previous', 'shuffle'], thumbnail: Boolean(artwork), physicalAndroid: false };
}
const barBounds = await bar.boundingBox();
for (const name of ['Previous track', 'Next track', 'Open playing app']) {
const control = bar.getByRole('button', { name, exact: true });