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 @@