Skip to content

fix(android): resolve metadata and artwork on Android Auto cold start (#61/#80) - #84

Merged
sebsto merged 10 commits into
mainfrom
fix/android-auto-cold-start-metadata-artwork
Aug 12, 2026
Merged

sebsto merged 10 commits into
mainfrom
fix/android-auto-cold-start-metadata-artwork

Conversation

@sebsto

@sebsto sebsto commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Root cause: On an Android Auto cold start (process started for the media service alone, no Activity, app never opened), the native Swift pipeline (MetadataParser → ArtworkService → NowPlayingController) was never reached — its only composition root was Maxi80RootView, which needs a UI that never appears in that scenario. The service's display-only Kotlin ICY listener was the only writer, so the card always showed the raw unsplit ARTIST - TITLE string with Maxi 80 as the artist, and drawable/media_placeholder (the station logo) for artwork.
  • Fix: Three small Swift edits, zero Kotlin changes. Maxi80AppDelegate.onInit() (called from Application.onCreate() on every process start) now triggers SharedPlayer.handleProcessStart(), which builds the composition root before any UI exists. androidSyncWithExternalPlayback() uses SharedAudioPlayer.shared() (creating) instead of .current (nil before the service starts), fixing an ordering race that left the ICY listener unattached.
  • Second bug fixed: The Kotlin icyListener/applyIcyTitle became a redundant second writer. On pause→play reconnect ICY re-delivers the same title; the native side short-circuits at "metadata unchanged"; the raw Kotlin write (unsplit line, Maxi 80 artist) became last writer, degrading the card back to the Android Auto: live song metadata missing on car display until the app is opened once #61 symptom after every pause. Removed the listener entirely — one writer, the one that splits correctly.

Changes

File Change
Sources/Maxi80/App/Maxi80App.swift onInit() calls SharedPlayer.handleProcessStart()
Sources/Maxi80/Player/Support/SharedPlayer.swift adds handleProcessStart() static method
Sources/Maxi80Services/Platform/Android/ExoPlayerStreamPlayer.swift androidSyncWithExternalPlayback() uses creating shared() not nil-returning current
Sources/Maxi80Services/Skip/Maxi80MediaService.kt removes icyListener, applyIcyTitle, dead imports, teardown calls
Tests/Maxi80Tests/ServiceColdStartMetadataTests.swift 7 new host tests covering the cold-start pipeline contract
docs/testing/android-auto-dhu-procedure.md Test C/D rewritten; Test B adds cycle-text check; pre-flight updated to check libMaxi80.so

Test plan

  • make test → 251/251 tests pass
  • make build-android → BUILD SUCCESSFUL
  • DHU A–E: all pass on Galaxy A07 (service-only cold start, app never opened):
    • Audio audible on cold connect
    • Transport (pause/play ×3) works, card text stays split throughout
    • Split song text after song change (no Activity in process)
    • Real album art on notification card and DHU
    • Phone-path regression: none

🤖 Generated with Claude Code

sebsto and others added 9 commits August 11, 2026 15:36
…pp play

media3's ExoPlayer.Builder never assigns handleAudioFocus, so it defaults to
false. Audio attributes + focus were configured only in androidPlay(), which
does not run on an Android Auto cold start — the car drove the shared player
directly and the stream decoded with no focus request. media3 reported
READY + playWhenReady so Auto rendered a playing button, but the car's audio
policy routed no stream: playback was "playing" and silent, and only became
briefly audible when another app (voice dictation) forced a real focus
transaction.

Configuring focus at construction makes every entry path audible by
construction. Also adds WAKE_MODE_NETWORK (+ the WAKE_LOCK permission it
requires) so a backgrounded car session isn't stalled by doze.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing the per-play setAudioAttributes block left both AudioAttributes and C
unused in ExoPlayerStreamPlayer. Also corrects the plan's APK path (the real
output is under .build/, not Android/app/build/).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ICY metadata listener was attached only by androidPlay() /
syncWithExternalPlayback(), both app-side Swift, so on an Android Auto cold
start ICY events were dropped and the car card stayed on the static
"Maxi 80 / Live" placeholder until the app was opened once.

Gives the service its own Player.Listener on the shared player. onMetadata is
delivered to every in-process listener, so it coexists with the native one,
which still overwrites the item with properly-parsed artist/title once the app
opens. Deliberately display-only — it does not re-split "ARTIST - TITLE",
because a second copy of MetadataParser's contract in Kotlin would drift from
the backend's algorithm and silently break artwork matching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ogo (#80)

A coverless song's notification / lock screen / Android Auto artwork was the
station logo while the carousel showed a per-song generic cover. Android has no
platform image APIs, so materializePlaceholderArtwork returns nil there, no URL
was published, and the Android controller took its station-logo fallback.

Ships the seven generic covers as 1024x1024 drawables and threads the displayed
cover's asset NAME through the Now Playing seam, so Android resolves an
android.resource:// drawable URI. The name is read off nowPlaceholderCover, never
re-rolled, so the card and the carousel can never disagree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three of the seven source covers (NoCover-25ans-{2,3,4}) are 8-bit palette
PNGs. The sips -Z downscale used to generate the drawables decodes them to
truecolor, so the 1024 output came out LARGER than the 1440 source (872KB ->
1.9MB) and the seven drawables cost 8.0MB instead of 3.9MB.

Regenerates them preserving each source's color depth, plus optipng. Re-
quantizing a palette source to 256 colors loses nothing the shipped iOS asset
has -- brand/make-anniversary-cover.py already documented this for the same
three images, which is what the sips step contradicted.

Keeps the generator as brand/make-android-drawables.py so adding an eighth
cover cannot silently reintroduce the bloat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cold-start audio-focus fix has no host-testable seam -- audio focus, the
media session and the car transport are Android-only code that make test
(macOS) never compiles. This procedure is its test, written as a document so
it is repeatable across releases rather than a one-off.

Tests A-E cover cold-start audibility (with a focus dumpsys check that
distinguishes "playing" from "audible"), car transport, #61 live song text,
#80 per-song cover, and phone-path regression. Leads with the harness
artifacts that have each previously cost hours of chasing a non-bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CURRENT_PROJECT_VERSION is bumped per release, not per build, so a fixed and an
unfixed debug APK carry the same versionCode/versionName -- the pre-fix Aug 9
build on the A07 and the freshly built one are both 5.2.0/2026080901. Checking
versionName proves nothing about which code is installed.

Greps the packaged dex for a symbol each fix introduced (applyIcyTitle,
androidDrawableName, WAKE_LOCK) so the tester can tell the builds apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…uplication (#61/#80)

On an Android Auto cold start the process is started for the media service
alone — no Activity, app never opened. The native Swift pipeline (MetadataParser
→ ArtworkService → NowPlayingController) was never reached because its only
composition root was Maxi80RootView, which needs a UI.

Three small Swift edits, zero Kotlin changes:

1. Maxi80AppDelegate.onInit() now calls SharedPlayer.handleProcessStart(),
   which is invoked from Application.onCreate() on every process start.
2. SharedPlayer.handleProcessStart() calls coordinator.reconcileWithPlayer(),
   building the pipeline before any UI exists.
3. androidSyncWithExternalPlayback() uses SharedAudioPlayer.shared() (creating)
   instead of .current (nil before the service starts), fixing an ordering
   race that left the ICY listener unattached.

Also removed the Kotlin icyListener/applyIcyTitle from Maxi80MediaService.
It was added as a fallback when the native pipeline was unreachable, but with
the fix above it became a second writer that only ever wrote worse data: on a
pause→play reconnect ICY re-delivers the same title, the native side skips it
as unchanged, and the raw Kotlin write (unsplit "ARTIST - TITLE", artist="Maxi
80") became the last writer, degrading the card back to exactly the #61 symptom.

Verified on Galaxy A07 (service-only process, no Activity):
- description=Dancing In The Dark, Bruce Springsteen
- Real cover art rendered (Born in the U.S.A.)
- 3 pause/play cycles: card stayed split throughout
- 251/251 host tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 12, 2026 07:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes Android Auto “cold start” behavior where the app process can start for the media service alone (no Activity), ensuring the native Swift metadata/artwork pipeline is initialized early enough to publish split song metadata and correct artwork to the MediaSession/notification/Auto card.

Changes:

  • Bootstraps the native composition root at Android process start (onInit() → SharedPlayer.handleProcessStart()), and removes the redundant Kotlin ICY writer to eliminate last-writer-wins regressions after pause→play reconnects.
  • Makes Android external-playback adoption reliable by creating the shared ExoPlayer in androidSyncWithExternalPlayback() and baking audio focus + wake mode into SharedAudioPlayer.shared().
  • Threads placeholder-cover asset names through the Now Playing seam so Android can publish android.resource://…/drawable/<name> when no remote artwork is available, with new host tests + DHU verification procedure/docs.

Reviewed changes

Copilot reviewed 18 out of 25 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
Tests/Maxi80Tests/ServiceColdStartMetadataTests.swift New host tests pinning the service-only cold-start pipeline contract (adoption, metadata split, backend lookup, idempotency).
Tests/Maxi80Tests/Fakes/FakeNowPlayingPublisher.swift Extends fake publisher recording to include artworkAssetName.
Tests/Maxi80Tests/AndroidPlaceholderArtworkTests.swift New tests for Android placeholder-artwork publishing behavior (asset name vs URL).
Sources/Maxi80Services/Skip/Maxi80MediaService.kt Removes Kotlin ICY listener and documents “one writer” rationale; minor refactor to name sharedPlayer.
Sources/Maxi80Services/Platform/iOS/IOSNowPlayingController.swift Updates iOS Now Playing API signature to accept artworkAssetName (intentionally unused).
Sources/Maxi80Services/Platform/Android/SharedAudioPlayer.swift Configures ExoPlayer audio attributes/focus at construction; enables network wake mode.
Sources/Maxi80Services/Platform/Android/ExoPlayerStreamPlayer.swift Removes per-play audio-attribute wiring and fixes cold-start ordering by using creating SharedAudioPlayer.shared(...) in external sync.
Sources/Maxi80Services/Platform/Android/AndroidNowPlayingController.swift Publishes placeholder artwork via Android drawable URI derived from artworkAssetName.
Sources/Maxi80Services/NowPlayingController.swift Adds artworkAssetName parameter to the bridged controller API and dispatch.
Sources/Maxi80/Services/NowPlayingSession.swift Extends NowPlayingPublishing.update(...) seam to carry artworkAssetName.
Sources/Maxi80/Services/BridgedNowPlayingPublisher.swift Forwards artworkAssetName through to the bridged NowPlaying controller.
Sources/Maxi80/Player/Support/SharedPlayer.swift Adds handleProcessStart() to reconcile/adopt external playback at process start.
Sources/Maxi80/Player/RadioPlayerCoordinator.swift Publishes placeholder artwork URL + placeholder asset name from a single gate to keep sinks consistent.
Sources/Maxi80/App/Maxi80App.swift Calls SharedPlayer.handleProcessStart() from Maxi80AppDelegate.onInit() (process start).
docs/testing/android-auto-dhu-procedure.md Adds/updates DHU validation procedure and pre-flight checks for the cold-start path.
docs/superpowers/plans/2026-08-11-android-auto-self-sufficient-service.md Adds an implementation plan document (currently partially inconsistent with final architecture).
brand/make-android-drawables.py Adds generator script for producing Android drawable versions of placeholder covers.
Android/app/src/main/AndroidManifest.xml Adds WAKE_LOCK permission (required by ExoPlayer wake mode).
Android/app/src/main/res/drawable-nodpi/nocover_a.png New Android drawable placeholder cover.
Android/app/src/main/res/drawable-nodpi/nocover_b.png New Android drawable placeholder cover.
Android/app/src/main/res/drawable-nodpi/nocover_c.png New Android drawable placeholder cover.
Android/app/src/main/res/drawable-nodpi/nocover_25ans.png New Android drawable placeholder cover.
Android/app/src/main/res/drawable-nodpi/nocover_25ans_2.png New Android drawable placeholder cover.
Android/app/src/main/res/drawable-nodpi/nocover_25ans_3.png New Android drawable placeholder cover.
Android/app/src/main/res/drawable-nodpi/nocover_25ans_4.png New Android drawable placeholder cover.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Tests/Maxi80Tests/AndroidPlaceholderArtworkTests.swift Outdated
Comment thread docs/testing/android-auto-dhu-procedure.md Outdated
- AndroidPlaceholderArtworkTests: rewrite "real artwork" test to pin the
  host-testable invariant (published asset name matches nowPlaceholderCover)
  and document why the nil-when-real-artwork branch cannot be host-tested
  without a protocol seam on ArtworkService
- DHU procedure: replace the now-removed applyIcyTitle presence check with
  an absence check (STALE line on regression); fix "all four" → "three"; add
  note that applyIcyTitle was removed to prevent the two-writer race
- Implementation plan: mark the Architecture section as superseded with a
  note explaining the shipped approach and why the Kotlin ICY listener was
  removed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@sebsto
sebsto merged commit a0bcd9c into main Aug 12, 2026
3 checks passed
@sebsto sebsto added this to the 5.2 milestone Aug 12, 2026
@sebsto
sebsto deleted the fix/android-auto-cold-start-metadata-artwork branch August 12, 2026 11:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants