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 index 5d60c444..421e1fc0 100644 --- 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 @@ -58,6 +58,38 @@ class CompanionAudioTest { } finally { CompanionAudioBridge.release(web); lifecycle.destroy(); web.destroy() } } + @Test + @Config(shadows = [RecordingAudioMediaSession::class]) + fun jpegArtworkReachesNotificationAndMediaDescription() { + // Real 16x16 JPEG generated by Chromium canvas, independent of Android bitmap shadows. + val artwork = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAAQABADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAn/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AJVAA//Z" + assertNotNull(decodeArtwork(artwork)) + val app = RuntimeEnvironment.getApplication() + val web = android.webkit.WebView(app); web.loadUrl("https://node.test/cloud") + val payload = state().put("session", "32345678-1234-1234-1234-123456789abc").put("artwork", artwork) + 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.getLargeIcon()) + // Robolectric's MediaController does not read MediaSession metadata; + // capture the actual service's setMetadata call instead. + val metadata = RecordingAudioMediaSession.metadata!! + assertNotNull(metadata.getBitmap(android.media.MediaMetadata.METADATA_KEY_ALBUM_ART)) + assertNotNull(metadata.description.iconBitmap) + // Position-only refresh must retain the same thumbnail. + CompanionAudioBridge.receive(web, state().put("session", payload.getString("session")).put("sequence", 2).toString(), + "https://node.test", true, setOf("https://node.test")) {} + assertNotNull(Shadows.shadowOf(lifecycle.get()).lastForegroundNotification.getLargeIcon()) + // A following song without art must not keep the previous cover. + CompanionAudioBridge.receive(web, state().put("session", payload.getString("session")).put("sequence", 3).put("artwork", "").toString(), + "https://node.test", true, setOf("https://node.test")) {} + assertNull(Shadows.shadowOf(lifecycle.get()).lastForegroundNotification.getLargeIcon()) + assertNull(RecordingAudioMediaSession.metadata!!.description.iconBitmap) + } finally { CompanionAudioBridge.release(web); lifecycle.destroy(); web.destroy() } + } + @Test fun diagnosticReportExcludesPrivateMessagesAndRecordsRejectionStage() { val app = RuntimeEnvironment.getApplication() val web = android.webkit.WebView(app) @@ -116,3 +148,10 @@ class CompanionAudioTest { } finally { lifecycle.destroy() } } } + +@org.robolectric.annotation.Implements(android.media.session.MediaSession::class) +class RecordingAudioMediaSession : org.robolectric.shadows.ShadowMediaSession() { + companion object { var metadata: android.media.MediaMetadata? = null } + @org.robolectric.annotation.Implementation + fun setMetadata(value: android.media.MediaMetadata) { metadata = value } +} diff --git a/docs/companion-audio-investigation-20261007.md b/docs/companion-audio-investigation-20261007.md index 48ade996..426f2f20 100644 --- a/docs/companion-audio-investigation-20261007.md +++ b/docs/companion-audio-investigation-20261007.md @@ -124,10 +124,27 @@ Evidence: never produced a thumbnail; corrected composable produced a real JPEG. Browser checks also verified no cross-origin cookie, no followed redirect, and playback remained active after the optional image error. -- No native runtime or APK version change. A positive native JPEG/notification/ - metadata regression is prepared separately and awaits its coordinated test slot. +- No native runtime or APK version change. The focused positive native JPEG + regression passed on SDK 28 and 35 (2 tests, zero failures/errors/skips). It + verifies the actual service places a real JPEG in the notification large icon + and media-description bitmap, retains it on position-only updates, and clears + it for an explicitly artless following song. Initial test compilation used + unavailable Java desktop ImageIO APIs; replacing that test-only fixture with a + fixed Chromium-generated JPEG resolved the harness issue. Production Kotlin + remained unchanged/up-to-date. All owned test JVMs exited after success. - This identifies and fixes a real artwork boundary defect, not the operator's exact cover URL. Physical notification-artwork acceptance remains open until the qualified UI reaches the node and the operator verifies it. Evidence folder: `~/.local/state/archipelago/release-qualification/companion-artwork-20261007/`. + +## Actual app artwork access limitation + +A separate read-only Chromium probe retained the managed node-demo-v4v iframe, +but the actual app required Nostr login before initializing its media catalog. +With signing and payments blocked, the probe timed out before obtaining a cover +URL. No image access/CORS policy was bypassed, no credentials were forwarded, +and no real app artwork was decoded in that probe. The successful browser A/B +fixture and native JPEG test establish the corrected pipeline, not that the +operator's particular cover is anonymously accessible. Physical artwork acceptance +remains pending after the qualified UI deployment; APK 0.5.37 stays unchanged.