Verify native notification artwork with real JPEG regression

This commit is contained in:
archipelago
2026-10-07 19:00:20 -04:00
parent b0b95810e0
commit 60bd728a04
2 changed files with 58 additions and 2 deletions
@@ -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 }
}
+19 -2
View File
@@ -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.