fix(android): resolve metadata and artwork on Android Auto cold start (#61/#80) - #84
Merged
Merged
Conversation
…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>
There was a problem hiding this comment.
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 intoSharedAudioPlayer.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.
- 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>
This was referenced Aug 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
MetadataParser→ArtworkService→NowPlayingController) was never reached — its only composition root wasMaxi80RootView, 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 unsplitARTIST - TITLEstring withMaxi 80as the artist, anddrawable/media_placeholder(the station logo) for artwork.Maxi80AppDelegate.onInit()(called fromApplication.onCreate()on every process start) now triggersSharedPlayer.handleProcessStart(), which builds the composition root before any UI exists.androidSyncWithExternalPlayback()usesSharedAudioPlayer.shared()(creating) instead of.current(nil before the service starts), fixing an ordering race that left the ICY listener unattached.icyListener/applyIcyTitlebecame 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 80artist) 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
Sources/Maxi80/App/Maxi80App.swiftonInit()callsSharedPlayer.handleProcessStart()Sources/Maxi80/Player/Support/SharedPlayer.swifthandleProcessStart()static methodSources/Maxi80Services/Platform/Android/ExoPlayerStreamPlayer.swiftandroidSyncWithExternalPlayback()uses creatingshared()not nil-returningcurrentSources/Maxi80Services/Skip/Maxi80MediaService.kticyListener,applyIcyTitle, dead imports, teardown callsTests/Maxi80Tests/ServiceColdStartMetadataTests.swiftdocs/testing/android-auto-dhu-procedure.mdlibMaxi80.soTest plan
make test→ 251/251 tests passmake build-android→ BUILD SUCCESSFUL🤖 Generated with Claude Code