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() }
}
}