Files
SoulStream/handoff/notes/android-auto-design.md
T
KeelrLeek 9f0601027c android: the handoff — the pending rounds, the pipeline, and what is unverified
Everything written this session that has not been through the device: rounds 43
to 48 (each patch adversarially verified, 43's transient-notice expectation
fixed), a partial round 49, the pipeline scripts, the Android Auto design and
the icon comparison mock. Also the two harness changes that were never A/B'd —
adaptive waits, which may only shorten a declared wait and never lengthen it,
and a ship that runs the mock walk and the deploy at the same time. The A/B and
its 255-step baseline are in handoff/tools; main stays at what the phone has
actually walked.
2026-09-14 07:57:41 -05:00

265 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `MediaSession`s 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.so` that opens the library **read-only** (`Connection::open_with_flags`, never `Library::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 `Player`s 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 → #040A10` axis 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 `Player`s 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`/`unbindService` test before R5 (if it still bites, R5 grows by a sitting for the singleton restructure), and prove a read-only WAL open of `library.db` before 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.