An earlier pass in this repo mocked a car UI from imagination: a tab strip, a header with search and queue icons, a bottom mini-player, a big-art playback view with a linear progress bar. None of those exist. Google's line is flat — "You don't draw your user interface directly on Android Auto; the framework has two well-defined UIs that are created automatically" — and the reference frames confirm it. The bottom bar is the SYSTEM's (home, notifications, Assistant). Progress is a RING around play/pause. The artwork sits small at the left behind a blur of itself. About THREE rows fit on screen, not eight. So the artifact that was needed was never a layout. It is the short list of slots the app actually fills, and this commit decides them: tabs Recent · Albums · Artists · Saved style albums grid, artists and tracks list actions Shuffle · Save, 2 of the 6 allowed reach amber dot on rows that need the app; Save shows a tick when down accent brand teal #31C0CB Playlists loses its tab because the phone holds exactly one, and an empty tab is a dead quarter of the only four we get. Saved takes it because it is the set that plays with the app shut, which is the one thing genuinely different about this app in a car. Grid-vs-list is not density — the car fits three either way — it is what identifies the row: a record by its sleeve, an artist by name, a track only by text. Save is the highest-value button on the screen: hear it, save it, it plays cold on the way home. The dot marks the RISKY row rather than the safe one, and thins out on its own as more music is saved. Auto allows one accent pair, set at build time, and it cannot follow the artwork the way the app does everywhere else — so it has to be the constant, the falcon's teal. None of this can ship before round 48. The card is MediaSessionCompat today, Auto needs a MediaLibraryService, and two live sessions is not a safe intermediate. Order is unchanged: 44 → 45 → 46 → 47 → 48, soak, then 49–52 build the table above. The mockup that settles it is research/auto-mockup (gitignored): both real libraries — the handset's three albums and the desktop's 418 — inside the real template, at the three DHU resolutions, with an overlay that outlines which pixels are the car's and which are ours.
38 KiB
Android Auto — the design
Produced 2026-09-14 by a research pass (current API research, testing research, a scout of this repo's media stack, a scout of every icon surface) and a synthesis.
Verdict
Build it on media3 MediaLibraryService, and migrate the existing card onto it rather than standing a second service beside it. Do the icon first, in two small rounds, before touching any of it.
Three decisions, stated plainly:
1. media3, not MediaBrowserServiceCompat. androidx.media — the whole library the plugin currently depends on (crates/tauri-plugin-media-session/android/build.gradle.kts:26, androidx.media:media:1.7.0) — was deprecated in 1.8.0 (2026-05-06). MediaSessionCompat, MediaButtonReceiver and the MediaStyle notification in MediaService.kt are all on it. Building Auto on MediaBrowserServiceCompat would be adding new code to a deprecated library, which is exactly the obsolete pattern Kyle asked to avoid. media3 1.11.0 is stable and gives the legacy android.media.browse.MediaBrowserService binder Auto actually binds, for free — you just declare both actions in the intent-filter.
2. Migrate, do not wrap or double up. Two live MediaSessions in one app means two cards in the shade and undefined media-button targeting; media3 posts and owns the notification itself. There is no safe "add Auto alongside" intermediate — the cut is atomic within one deploy. That means one round deliberately puts the daily-use lock-screen card at risk. This is the real cost of Auto and it should be said out loud before starting. The mitigations are: land the hardest sub-piece (the queue mirror) a round early, where it cannot break anything; make the migration its own round that ships and soaks before any car-facing code follows; and re-run by hand the one check the walk provably cannot catch — the 25-second paused-restore FGS survival test, which memory/android-fgs-promote-first.md records as having killed a real Save while the walks passed.
3. The cold-start problem has a real answer, and it is smaller than it looks. The framing "playback lives in Rust driven by a WebView, so cold start is hard" is half right. What is genuinely WebView-bound is the queue (src/mobile/player.svelte.js) and the resolver (src/lib/sources.js, 872 lines of JS). What is not WebView-bound — and this is the load-bearing finding — is everything else: crates/library is plain rusqlite with no Tauri dependency, crates/player is plain rodio/symphonia with no Tauri dependency, and JNI_OnLoad already publishes the JavaVM and Context to ndk-context at System.loadLibrary time, before any Activity exists. object Rust's class initializer does nothing but load that .so.
So a service-started process with no Activity, no Tauri and no WebView can already: load the native library, open library.db, and drive rodio. The honest staged answer is:
- Browse works cold from day one, served by a new JNI entry in
libapp_lib.sothat opens the library read-only (Connection::open_with_flags, neverLibrary::open— that one writes) and answers from its own small queries. - Playback from cold lands one stage later, as a headless fallback queue of local file paths that exists only while no WebView is alive, over a process-singleton
Player. - Catalog and Soulseek content never plays from cold, in this plan or any realistic successor, because the resolver is JS and Soulseek needs a live session. Those items get the documented
STATE_ERROR+ERROR_RESOLUTION_ACTION_LABEL+ resolution intent — "open SoulStream on your phone" — which is also exactly what satisfies Google's "must work when the user isn't signed in" test requirement.
What the user experiences in between (after browse lands, before cold playback lands): the car shows the full library tree whether or not the app is running. If the app is running — which it is whenever the phone has been used recently — taps play normally. If the process is cold, a tap surfaces a one-line card telling them to open the app, and the next tap works. That is a real, defensible intermediate, not a broken one, and it is worth shipping on the way.
Two mechanism notes that save real work. First, the JNI symbol lives in libapp_lib.so (the app crate, which has the library and player deps) while the Kotlin external fun is declared in the plugin module's own package and calls System.loadLibrary("app_lib") itself. Names link at runtime, so the plugin crate needs no Rust dependency on library — the whole "plugin can't depend upward on LibraryState, so it needs a registration hook" problem simply evaporates. Second, resources: a library module cannot override a resource the app module already defines, so the density mipmaps must come from a tracked res overlay copied in by build.sh/dev.sh; but the app module defines no mipmap-anydpi-v26 and sets no android:roundIcon/appCategory, so the adaptive icon and the manifest attributes can come from a library module. Use both, each for what it can actually do. The overlay also becomes the only sane home for res/xml/file_paths.xml, which Auto needs later for content:// cover art and which currently exposes only external-path and cache-path.
Scope: Android Automotive OS, Android TV, Wear and a Play release are separate jobs, none of which come along for free. The template already declares LEANBACK_LAUNCHER with no banner — leave it dormant rather than adding a banner and making a never-tested TV entry look shippable. And note that a sideloaded SoulStream is invisible to Android Auto until developer mode + "Unknown sources" are enabled on the phone; real-world visibility needs a Play release and the car-app-quality review. That is a product decision, not an engineering one.
Rounds
Nine rounds. Two are icon, six are Auto, one is Kyle's. Rounds 4 and 7 are honestly bigger than a sitting and are flagged as such.
R1 — The launcher icon, everywhere it is static.
Delivers: SoulStream's falcon replaces Tauri's logo on the launcher, in recents, on the splash and in other apps' share sheets; a real adaptive icon with a monochrome layer so Android 13+ themed icons work.
Touches: a new tracked src-tauri/android/res/ overlay carrying mipmap-anydpi-v26/{ic_launcher,ic_launcher_round}.xml, mipmap-*/ic_launcher.png (kit legacy/), and the foreground/background/monochrome layers; a copy step added to src-tauri/android/build.sh (before the cd src-tauri/gen/android && gradlew at line ~21) and to dev.sh. The kit's own mipmap-anydpi-v26/*.xml references @drawable/ic_launcher_foreground while the PNGs ship under mipmap-* with no drawable-* directory anywhere — verified by reading both — so it will not compile as delivered and must be reconciled during the copy (point the XML at @mipmap/, or land the layers under drawable-*).
Verified by: the walk cannot see a launcher. Three checks instead — (a) unzip the built APK and assert res/mipmap-anydpi-v26/ic_launcher.xml exists and the ic_launcher.png hashes now match the kit, which is a pure build assertion and can be a permanent one-liner; (b) deploy.sh already screenshots after launch, so the recents/splash change shows up there; (c) one adb exec-out screencap of the launcher with themed icons off, then on — 60 seconds of Kyle's phone, and the only way adaptive masking and the monochrome layer are ever really proven.
Depends on: nothing. Ship first.
R2 — The icon's moving parts.
Delivers: a falcon notification glyph replacing the generic eighth-note; an app-icon fallback bitmap so the lock screen and (later) Auto never show a grey hole when a cover hasn't decoded; a proper Android 12+ splash instead of the platform's white flash; android:appCategory="audio".
Touches: ic_soulstream_note.xml (media-session) and ic_soulstream_down.xml (transfer-notice) — both, or the two notifications stop matching; MediaService.kt:261 .setLargeIcon(MediaHub.art) and :205-208's METADATA_KEY_ALBUM_ART gain a decoded-once fallback; values/themes.xml in the overlay gains Theme.App.Starting; a small library manifest in the media-session module adds appCategory (the app manifest sets none, so the merger takes it).
Verified by: the walk already reads dumpsys notification --noredact (smoke.mjs:2180) — assert the card is still posted and the transport still works. The glyph itself is a screenshot: Android's shade takes the alpha channel only, so a falcon exported with colour renders as a white blob and only a pixel will show it. Splash is a screencap during the first 400 ms of a cold start.
Depends on: R1's overlay mechanism.
R3 — The mirror grows a queue window. (No behaviour change.)
Delivers: the single hardest sub-piece of the media3 migration, landed a round early where it cannot break anything.
Touches: NowPlaying in crates/tauri-plugin-media-session/src/lib.rs:29-44 and NowPlayingArgs in MediaSessionPlugin.kt:18-31 gain a bounded queue window (current ±N items: id, title, artist, album, duration, artKey) plus the current index; src/mobile/media.svelte.js:15-43 fills them from player.queue/player.qi. MediaService.kt ignores the new fields entirely.
Why it matters: media3's SimpleBasePlayer.getState() wants a playlist and an index. Today Kotlin knows exactly one track. Doing this inside R4 would mean debugging a new mirror contract and a new session library at the same time. Doing it here means that by the time media3 arrives, the data it needs has already been running on the phone for a round.
Verified by: the existing card must be completely unchanged — the walk's media-session steps pass as-is. Add one logcat assertion that the window arrives populated and the index is right after a next/prev. Genuinely half a sitting.
Depends on: nothing (can even run in parallel with R1/R2).
R4 — media3 migration. No Auto yet. THE BIG ONE.
Delivers: the same lock-screen card, shade transport, headset buttons, audio focus and becoming-noisy pause, on a current API — and the Player abstraction that everything after this depends on.
Touches: build.gradle.kts swaps androidx.media:media:1.7.0 for androidx.media3:media3-session + media3-common. A new SoulStreamPlayer : SimpleBasePlayer maps getState() from MediaHub.state (with R3's window) and routes handlePlay/handlePause/handleSeek/handleSetMediaItem/handleSeekToNext|Previous into the existing MediaHub.act(...) — the round trip out to the WebView via Plugin.trigger("action", …) is unchanged, so media.svelte.js:57-68 keeps working untouched. MediaService becomes a MediaSessionService returning a MediaSession. Deleted: buildNotification (:247-282), the MediaButtonReceiver declaration and the MEDIA_BUTTON intent-filter in the plugin manifest (:11-27), and the whole audio-focus block (:107-127, :295-308) — media3 handles focus via setAudioAttributes(…, handleAudioFocus = true). Leaving the old receiver in place alongside media3's will fight for media buttons.
Verified by: the walk's existing media-session steps re-pointed at the new card (mediaSessionState() at smoke.mjs:2211 reads dumpsys media_session, which media3 still populates). Then by hand, every time: lock screen shows title/artist/art; shade transport play/pause/next/prev; a headset button with the app backgrounded; unplugging headphones pauses; another app taking focus pauses and returning resumes; and the 25-second paused-restore check — force-stop, relaunch with a paused track, wait 25 s, confirm the pid survives and logcat has no ForegroundServiceDidNotStartInTimeException. That last one is not optional and the walk cannot do it: its restart step resumes inside 3.5 s, well under the ~10 s kill window, which is exactly why this class of bug shipped once already.
Honest sizing: 3–4 sittings, and it cannot be split — a half-migrated app has two sessions. Recommend it ships on its own and soaks for a few days of ordinary use before R5.
Depends on: R3.
R5 — Discovery + the prober. Auto can see the app and gets a root.
Delivers: Android Auto lists SoulStream and opens it to a root that answers instantly, with nothing under it yet. Plus the only instrument that can observe any of this.
Touches: MediaSessionService becomes MediaLibraryService; onGetSession returns a MediaLibrarySession; onGetLibraryRoot returns immediately and unconditionally (no DB, no I/O — the host times out otherwise). Plugin manifest: service exported="true" with both androidx.media3.session.MediaLibraryService and android.media.browse.MediaBrowserService actions, plus <meta-data android:name="com.google.android.gms.car.application" android:resource="@xml/automotive_app_desc"/> and a new res/xml/automotive_app_desc.xml in the plugin module. New tools/auto-prober/ — its own ~200-line Kotlin Gradle project, deliberately outside src-tauri/gen/android so tauri android init cannot delete it — that binds a MediaBrowser with Auto-shaped root hints, walks the tree, and logs JSON under one logcat tag.
Verified by: adb shell pm dump com.soulstream.app asserts the android.media.browse.MediaBrowserService filter resolves and the gms.car.application meta-data points at a real resource — two permanent one-line assertions in the walk that catch the TAURI_ANDROID_PROJECT_PATH trap documented in env.sh:18-22, a failure this project has already lived through as a start-up ClassNotFoundException. Then the prober: am force-stop, bind, assert a root comes back within DR-3's 10 s with the app process cold.
Before committing to this round: run the plain bindService/unbindService check for androidx/media issue #3158 — a create/bind/destroy loop at ~30 cycles/second when Auto binds with nothing playing, reported open against 1.6.1 and 1.7.1 and unconfirmed on 1.11.0. MediaService registers a BroadcastReceiver and an AudioFocusRequest in onCreate, which is precisely the shape that bug punishes. Mitigation if it still bites: process-lifetime singleton session/player, nothing registered in onCreate.
Depends on: R4.
R6 — The tree.
Delivers: Recent / Albums / Artists / Playlists in the car, three levels deep, with covers.
Touches: a new Rust module in the app crate exporting Java_com_soulstream_mediasession_..._nativeBrowse, opening the DB read-only via Connection::open_with_flags(SQLITE_OPEN_READ_ONLY) with its own queries — never Library::open, which runs a CREATE TABLE IF NOT EXISTS + PRAGMA journal_mode = WAL write batch (crates/library/src/lib.rs:259-262). The path is derived from the filesDir Kotlin passes down plus a read-only, never-saving parse of config.json for download_dir (default <filesDir>/Library, per config.rs:326-341). Artists are not a Rust concept — they are derived in JS at lib.svelte.js:55-65 — so the Rust side needs the same grouping. Cover art goes out as content:// through the existing FileProvider, which needs a files-path entry added to res/xml/file_paths.xml via R1's overlay. Root children: read BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT rather than hardcoding 4; all browsable, none playable; ignore page/pageSize, since Auto does not paginate.
Verified by: the prober, extended into real assertions in the walk — root ≤ limit and all browsable; each tab's children load with unique stable ids; every item has a title and an art URI (MC-1); per-node onGetChildren latency under DR-3's 10 s, as numbers, the same way the walk already asserts preload latency. All of it with the app force-stopped, which is the whole point: with no WebView there is no CDP, so the existing walk is blind here and the prober is the only eye.
Watch for: a read-only connection on a WAL database needs the -shm; same UID so it should create one, but if the WAL holds uncheckpointed content with no writer around it can fail to open. Prove it, and make the failure return an empty-but-valid tree rather than hanging the head unit.
Depends on: R5.
R7 — Cold-start playback. The second big one.
Delivers: a tap in the car plays a local library track with the phone in a pocket and no UI running, and the album keeps going.
Touches: the Player moves to a process-wide OnceLock<Arc<Player>> in the app crate, taken by whoever needs it first — playback::init (src-tauri/src/playback.rs:187-191, today Player::spawn_with_device owned inside PlayerState) or the headless service. Two Players in one process means two AAudio output streams, so this is the load-bearing piece. The event pump that feeds PlaybackSnapshot becomes the same singleton, with a subscription slot. A new nativePlay(mediaId) resolves local paths read-only and drives a small headless fallback queue — a Vec<PathBuf> plus an index that advances on track-ended. It is not "move the queue into Rust": the JS queue keeps shuffle, repeat, alts failover, provenance and 3-ahead preload. The fallback exists only when no WebView is alive, and the WebView takes over when it comes up — which the session-restore path already half-does, since it asks player_state() and follows a live audio thread.
Verified by: the prober — force-stop, bind, playFromMediaId, assert dumpsys media_session reaches STATE_PLAYING and dumpsys media.audio_flinger shows a track on the app's uid (both parsers already exist in the walk, smoke.mjs:2211 and :2242). The real proof is the second track: assert the fallback queue advances on its own. Then the handover: launch the app while headless playback is running and assert exactly one audio stream and one queue driver survive.
Honest sizing: 3 sittings. The handover rule is where the bugs will be.
Depends on: R6.
R8 — Voice, errors, and the quality pass.
Delivers: "play X" from Assistant/Gemini; an honest error instead of silence for anything the resolver would be needed for; the checklist items that block Play acceptance later.
Touches: onSearch/onGetSearchResult and the mediaItem.requestMetadata.searchQuery path in onAddMediaItems — Google's own samples disagree on which Auto uses, so implement both. Empty query ("play some music") → Recent. Catalog/Soulseek items → STATE_ERROR with a localized message routed through src/mobile/trouble.js's existing say(), plus ERROR_RESOLUTION_ACTION_LABEL and a resolution intent. Content-style extras (grid for albums/artists, list for tracks), title + thumbnail on every item (MC-1), no autoplay on bind (MA-1 — already half-satisfied, since restore comes up paused), tintable white icons from the kit's monochrome SVG (VD-2).
Verified by: the prober for search (assert rows come back for a known query, and that a catalog item's tap lands in STATE_ERROR with a non-empty message rather than silence). The visual half is DHU-only.
Depends on: R7.
R9 — DHU pass. Kyle's round, not an agent's.
Delivers: the answers only pixels can give.
How: sdkmanager "extras;google;auto" → %LOCALAPPDATA%\Android\Sdk\extras\google\auto\desktop-head-unit.exe. On the phone: Settings → Connected devices → Connection preferences → Android Auto → About → tap Version ×10 → ⋮ → Developer settings → Unknown sources on, then ⋮ → Start head unit server. Then adb forward tcp:5277 tcp:5277 and launch the DHU. Prefer the adb tunnel over --usb on Windows — --usb needs a WinUSB driver and fights adb.
Checks: contrast (VD-1/2/3), no animation (SA-1 — the app's animated eq-bars glyph and waveform lane must never reach a browse row or the playback card), no auto-scrolling text (ST-1), ≤2 s button response (DR-1), day/night (daynight), driving restrictions (U / Shift+U), rotary input (-i rotary), all three resolutions, and — critically — what Auto actually presents itself as in onConnect (log packageName/uid/signature rather than copying a years-old allowed_media_browser_callers.xml).
Cannot be answered by DHU or by any agent: real car audio routing, OEM launcher skins, wireless projection handover, and genuine Gemini/Assistant round trips. Those need an actual car, and are worth deferring to "Kyle happens to be in one."
One caution: smoke.mjs calls adb("forward", "--remove-all") around line 2430, which will drop the DHU tunnel mid-session. Do not run the walk and a DHU session at the same time, or fence that call.
Add to ANDROID.md: the exact invocation and the phone's setting path. Those taps are forgettable and the docs for them move.
Decisions that were Kyle's
Fork 1 — The phone icon's background: the kit's flat #05080B, or concept-04's navy gradient rebuilt at 108dp.
The desktop ships concept 04 "Deep navy glow" (src-tauri/icons/icon.svg is byte-identical to the kit's soulstream-04-deep-navy-glow.svg). The kit's adaptive background is flat #05080B — the older, darker soulstream-app-dark ground. Taking the kit as-is gives a phone icon visibly flatter than the desktop's, side by side.
- Flat: ship it as
<color name="ic_launcher_background">#FF05080B</color>rather than five identical flat PNGs. Zero extra work, and honestly it reads fine under a launcher mask. - Gradient: one 108-unit SVG reproducing concept 04's
#0A2330 → #07141F → #040A10axis plus the#31C0CB/0.18 radial glow, scaled 1024→108.
Recommendation: build the gradient, but mock it first. It is maybe 15 minutes and it is the difference between "the phone has the app's icon" and "the phone has a version of the app's icon". Kyle's own standing rule is that a look gets mocked before it gets implemented, and this qualifies — a side-by-side of both backgrounds under a circle mask, themed on and off, is cheap and is the only way to actually judge it.
One note either way: the kit's foreground sits 2 px above the geometric safe-zone line at xxxhdpi, recorded in brand-tokens.json as opticalSourceOffset. That is a deliberate optical correction for the light wing and tail tips. Do not "fix" it — it will look low, and it will diverge from every other kit export.
Fork 2 — Where Auto stops.
Three defensible stopping points, and they differ by several sittings:
- (a) R1–R6. Browse works always; playback works when the app process is alive, which is most of the time in practice. Cold tap → "open SoulStream on your phone", then it works. Stops after the tree.
- (b) R1–R8. Adds cold-start playback for the local library and the search/voice/error pass. The car works from a genuinely cold phone for music Kyle owns.
- (c) Catalog and Soulseek in the car. Would require the resolver (
src/lib/sources.js, 872 lines of shared JS) and a Soulseek session to exist headlessly. Not in this plan and not in a realistic successor.
Recommendation: commit to (b), ship (a) on the way, never promise (c). (a) is a real intermediate rather than a broken one, and shipping it means R7's risk is taken against something already working in a car rather than against a blank. But (a) alone is not what "Android Auto support" means to a user — the car binds the service when the phone is in a pocket, which is exactly when the process is cold. Stopping at (a) permanently would mean the feature works least often precisely when it is most wanted.
Folded into this recommendation: let R4 soak. The media3 migration should ship on its own and get a few days of ordinary daily use before R5 lands on top of it. It is the round that risks the card Kyle uses every day, and a regression in it is much easier to see and attribute alone than underneath three rounds of car code.
Fork 3 — Build the prober APK, or drive everything by hand from the DHU.
The prober is ~200 lines of Kotlin plus its own small Gradle project under tools/, installed alongside the app, driven from smoke.mjs with the adb helper it already has (tests/mobile/cdp.mjs:25) and gated to dev builds.
- Build it: every structural thing Auto checks becomes an assertion — root width and flags, unique stable ids, title and art on every item, per-node latency as numbers against DR-3, and the cold-start case.
- Skip it: rely on the DHU and Google's Media Controller Test app, both of which are human-driven and neither of which asserts anything about the browse tree.
Recommendation: build it — but note the decision is coupled to Fork 2. If Kyle stops at (a), the prober's value drops a lot: the tree can be eyeballed in the DHU and the cold-start case barely matters. If Kyle goes to (b), the prober is not optional. With no WebView there is no CDP, so the walk is structurally blind to every single thing R6 and R7 deliver — the prober is the only instrument that can see the state Auto actually operates in. It is also the cheapest insurance against R7's handover bugs, which will otherwise only show up as "the car went quiet sometimes".
Deliberately not offered as forks: media3 vs MediaBrowserServiceCompat (the latter means writing new code into a library deprecated five months ago — settled), migrate vs second service (two sessions means two shade cards and undefined button routing — settled), and Android TV (the template's dormant LEANBACK_LAUNCHER costs nothing; adding the kit's banner would make a never-tested TV entry look shippable, and removing the category means editing a generated file — leave it alone).
Risks
What could break that works today
The lock-screen card, in R4. This is the headline risk and there is no way to avoid it while getting Auto right. Media3 takes over notification posting, media-button routing and audio focus, which means the promote-first invariant gets re-litigated inside MediaSessionService's own lifecycle. memory/android-fgs-promote-first.md records that this exact class of bug — a foreground start that does not promote in time — killed the process mid-Save on 2026-09-13, four of fourteen tracks in, and that the walks did not catch it: the restart step resumed in 3.5 s, well inside the ~10 s kill window. The 25-second paused-restore check is manual, every time, no exceptions.
Two sessions is not a safe intermediate. No incremental "add media3 alongside" exists. R4 is atomic within one deploy, or the shade shows two cards and media buttons land somewhere undefined.
The mediaPlayback service's lifetime changes shape. Today MediaService.refresh() calls stopSelf() the moment the bar empties (:191-198) — deliberately, which is why downloads needed their own dataSync service. Auto binds with BIND_AUTO_CREATE and never calls onStartCommand, so the current promote-first path is simply not on Auto's route into the process. Bound is not the same as foregrounded, so this is probably fine — but "probably" is not good enough for the code path that already killed the app once. Decide it deliberately in R5, do not inherit it.
androidx/media #3158 — the bind loop. When Auto binds a MediaLibraryService with no playback ongoing, the service can enter a onCreate → onBind → onGetSession → onConnect → onUnbind → onDestroy loop at ~30 cycles/second, building a fresh player and session each time; reporters hit crashes past 1000 registered receivers. Open, reported on 1.6.1 and 1.7.1, unconfirmed on 1.11.0. MediaService.onCreate registers a BroadcastReceiver and requests audio focus, which is the exact shape that gets punished. Reproduce it with a plain bindService/unbindService instrumentation test before committing to R5 — no DHU needed — and if it still bites, the mitigation is a process-lifetime singleton session/player with nothing registered in onCreate.
Two Players in one process, in R7. playback::init creates its own Player via Player::spawn_with_device (playback.rs:189). A headless one alongside it means two rodio output streams and two AAudio clients. The OnceLock<Arc<Player>> singleton is the fix; getting it wrong is either doubled audio or a silently dead output, and the event pump has a single receiver so it must become part of the same singleton rather than a second consumer.
The config-write hazard — the sharpest one in the plan. config::load (config.rs:306-311) is serde_json::from_str(&s).ok() → unwrap_or_default(), with no BOM tolerance, and save writes unconditionally. A headless path that used the normal load/save shape and failed to parse would hand back all-defaults and, on the next save, wipe Kyle's library folder setting — the exact failure memory/config-json-bom-silently-defaults.md already documents, arriving from a new direction. The headless browse path must be read-only end to end: parse config.json tolerantly, take download_dir if present, default to <filesDir>/Library, and never call anything that writes.
A second SQLite connection. Library::open runs a CREATE TABLE IF NOT EXISTS + PRAGMA journal_mode = WAL batch (crates/library/src/lib.rs:259-262) — it is a writer, not an opener, and must not be used headlessly. Connection::open_with_flags(SQLITE_OPEN_READ_ONLY) is the right shape, but reading a WAL database needs the -shm file; same UID so it should create one, and if the WAL holds uncheckpointed content with no writer around it can still fail to open. Prove it in R6, and make failure yield an empty-but-valid tree rather than a head unit stuck on a spinner.
The res overlay must be copied in two places. build.sh and dev.sh. Miss dev.sh and dev builds silently revert to the Tauri logo — a failure with no error message that will be blamed on something else.
npx tauri icon is a trap, and src-tauri/icon-source.svg is a loaded gun. The CLI regenerates every platform from default in one pass — icon.ico, .icns, the Square*Logo.png set — and when gen/ exists it writes into the gitignored tree rather than src-tauri/icons/android/, which is exactly why that tracked directory looks correct while the APK does not. Meanwhile src-tauri/icon-source.svg is still the pre-falcon green #1ed760 waveform, tracked, with a name that invites the one command that would re-brand the app backwards. Worth deleting in passing.
src-tauri/icons/android/ is an attractive nuisance. It is tracked and on-brand, so it looks like the obvious source — but its ic_launcher_foreground.png is full-bleed (alpha bbox = the whole 432×432 canvas) over a #fff background. Copied into gen/ it produces a cropped rounded box on white: a different wrong icon that will look plausibly done in a file listing. Confirmed independently — the three candidate mipmap-xxxhdpi/ic_launcher.png files (gen/, src-tauri/icons/android/, kit) have three different md5s, and there is no mipmap-anydpi-v26 in gen/ at all.
Library resources cannot override app resources. Putting mipmap-*/ic_launcher.png in a plugin crate does nothing — the app module wins for the same name at the same qualifier. Only mipmap-anydpi-v26 (which the app module does not define) survives, and only with layer names that do not collide with the app's existing ic_launcher_foreground. Get this wrong and you get a green build with an unchanged icon.
Patch scripts. memory/heredoc-regex-patch-trap.md: a regex inside a heredoc→template-literal patch became a // comment and shipped, losing every artist photo. These rounds are patch-script-shaped. Write them with the Write tool, and look at the resulting still.
What is unverifiable without a car
DHU covers more than expected: browse tree rendering, content style, contrast, day/night, driving-restriction states, rotary input, all three resolutions, the ≤2 s button response, error and sign-in cards, and a canned voice query via mic play. Everything structural is covered better by the prober.
Genuinely car-only: real car audio routing (DHU keeps audio on the phone — there is no car speaker path, no Bluetooth/USB audio, no car-side focus contention), OEM launcher skins and quirks, wireless projection handover, and actual Gemini/Assistant round trips. Also unverifiable anywhere before a Play release: whether the app is visible to a non-developer at all.
Which leads to the thing worth saying before the work starts rather than after: a sideloaded SoulStream will not appear in Android Auto until developer mode and "Unknown sources" are turned on in the phone's Android Auto settings. Until that tap happens, a completely correct implementation looks identical to a broken one. Real-world visibility needs a Play Store release and the car-app-quality review — a product decision, not an engineering one, and tauri-plugin-storage's MANAGE_EXTERNAL_STORAGE is a Play-policy-restricted permission that would need its own answer if that ever comes up.
Two smaller unknowns worth five minutes each on the phone before R9: whether Android 16's Android Auto overflow still carries "Start head unit server" (every write-up describes Android 9–15 menus), and whether the head unit server can be started from adb at all — if it can, R9 becomes scriptable end to end; if not, it stays a manual tap before every DHU session.
Effort
Sized in sittings of roughly twenty minutes, the unit this project actually ships in.
| Round | Sittings | Note |
|---|---|---|
| R1 launcher icon overlay | 1 | + ~1 min of Kyle's phone for the launcher screencap |
| R2 glyph, art fallback, splash | 1 | |
| R3 mirror grows a queue window | 0.5 | pure additive, zero risk |
| R4 media3 migration | 3–4 | cannot be split; ships alone and soaks |
| R5 discovery + prober | 2 | prober is its own Gradle project |
| R6 the tree (JNI + read-only DB) | 2 | |
| R7 cold-start playback | 3 | singleton player + fallback queue + handover |
| R8 search, voice, errors, quality | 2 | |
| R9 DHU pass | — | Kyle, 30–45 min, once, not an agent's round |
Total: 14–15 sittings of agent work, plus one session of Kyle's with the DHU.
The proportions matter more than the total:
- The icon is about 15% of this — two and a half sittings out of fifteen. It is low risk, the assets already exist, it is the most visible change in the whole plan, and it fixes a live shipped defect: the phone is currently wearing Tauri's logo with no adaptive icon at all. It should go first, and it should go first even if Auto is deferred indefinitely.
- Two rounds are half the work. R4 and R7 together are 6–7 of the 15. Everything else is genuinely sitting-sized. Any estimate that makes those two look like ordinary rounds is wrong, and splitting either one produces a worse outcome — R4 split leaves two sessions fighting, R7 split leaves a headless player racing the app's.
- R3 is the cheapest insurance in the plan. Half a sitting, no risk, and it removes the single hardest sub-problem from R4 by landing it a round early where the existing card simply ignores it.
- The two open questions that could move these numbers are both cheap to settle and both should be settled before their round rather than during it: reproduce androidx/media #3158 against 1.11.0 with a plain
bindService/unbindServicetest before R5 (if it still bites, R5 grows by a sitting for the singleton restructure), and prove a read-only WAL open oflibrary.dbbefore R6 (if it fails, R6 needs a different data path and grows by one to two). - Stopping at Fork 2(a) — browse only, no cold-start playback — lands at about 9 sittings instead of 15, and makes the prober optional, which would trim another 1–2.
Nothing here is blocked on anything outside the repo except the DHU install and two settings taps on the phone. The critical path is strictly sequential from R3 onward; R1, R2 and R3 can run in any order or in parallel.
Decided — 2026-09-15
The earlier document left the car UI as forks. Kyle's direction was to use the
app's own design language and pick. These are the picks, and the mockup at
research/auto-mockup/ is built to them (CHOSEN in auto.js).
First, the correction that reframes all of it. An earlier pass in this repo mocked a car UI from imagination — a tab strip, header icons, a bottom mini-player, a big-art playback view. None of that exists. Google's line is flat: "You don't draw your user interface directly on Android Auto; the framework has two well-defined UIs that are created automatically." The reference frames are saved with this note. What the app actually controls is a short list, and everything below is one of those slots.
| slot | decision | why |
|---|---|---|
| Root tabs | Recent · Albums · Artists · Saved | Playlists loses its tab: the phone holds exactly one ("Imported playlist", 3 tracks), and an empty tab is a dead quarter of the only four we get. Saved is the set that plays with the app shut, which is the one thing genuinely different about SoulStream in a car — and the only tab that always works. |
| Content style | albums grid, artists and tracks list | Not a density choice: the car fits ~3 either way. It is about what identifies the row. A record is known by its sleeve — that is the app's whole thesis — so albums get art. An artist is known by name, a track only by text, so those get lists where the type is bigger. |
| Playback actions | Shuffle · Save (2 of the 6 allowed) | Shuffle is what a driver reaches for. Save is SoulStream's core verb and the highest-value button on the screen: hear it, save it, it plays cold on the way home. Repeat is rarely touched while driving; Follow is a library action, so it belongs on a browse row, not the transport. |
| Reach signal | an amber dot on rows that need the app, plus the Save button showing a tick when already saved | The dot marks the risky row, not the safe one — a warning has to appear where the tap can fail. It also thins out on its own as more music is saved, which is the right direction. Group titles were rejected: on today's library they make one block of 3 and one of 415. |
| Accent | brand teal #31C0CB |
The app derives an accent per cover; Auto allows exactly one pair set at build time and it cannot follow the artwork, so it has to be the constant — the falcon's teal from the desktop icon. It tints the FAB and the ring around play/pause, and nothing else. |
Deliberately not decided here: the queue's contents, search ranking and the error copy, all of which want the prober in front of them rather than a guess.
What blocks building it
Nothing in this list can ship before round 48 (media3). The current card is
MediaSessionCompat; Auto needs a MediaLibraryService, and two live sessions
is not a safe intermediate. Order is unchanged: 44 → 45 → 46 → 47 → 48, soak,
then 49–52 build the table above.