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.
This commit is contained in:
KeelrLeek committed 2026-09-14 07:57:41 -05:00
1 parent 867fd42972
commit 9f0601027c
400 files changed
+26401 -3

No files matched your search

+264
View File
@@ -0,0 +1,264 @@
# 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.
+159
View File
@@ -0,0 +1,159 @@
// Generates icon-mock.html from the REAL SoulStream brand-kit sources.
// Reads only; never writes into F:/Coding/SoulStream.
import fs from 'node:fs';
import path from 'node:path';
const KIT = 'F:/Coding/SoulStream/branding/soulstream-logo-kit';
const ADP = KIT + '/android/adaptive';
const OUT = path.dirname(new URL(import.meta.url).pathname.replace(/^\//, ''));
const read = p => fs.readFileSync(p, 'utf8');
/* ---------- helpers ---------------------------------------------------- */
function attrs(tag) {
const o = {};
for (const m of tag.matchAll(/([\w:-]+)="([^"]*)"/g)) o[m[1]] = m[2];
return o;
}
function gradBlock(svg, id) {
const i = svg.indexOf('id="' + id + '"');
if (i < 0) throw new Error('gradient not found: ' + id);
const start = svg.lastIndexOf('<', i);
const isRadial = svg.slice(start, i).includes('radialGradient');
const close = isRadial ? '</radialGradient>' : '</linearGradient>';
const end = svg.indexOf(close, i) + close.length;
return { block: svg.slice(start, end), radial: isRadial };
}
function stopsOf(block) {
return [...block.matchAll(/<stop\b[^>]*\/?>/g)].map(m => {
const a = attrs(m[0]);
const s = { o: Number(a.offset), c: a['stop-color'] };
if (a['stop-opacity'] != null) s.a = Number(a['stop-opacity']);
return s;
});
}
function linOf(svg, id) {
const { block } = gradBlock(svg, id);
const a = attrs(block.slice(0, block.indexOf('>')));
return { x1: +a.x1, y1: +a.y1, x2: +a.x2, y2: +a.y2, stops: stopsOf(block) };
}
function radOf(svg, id) {
const { block } = gradBlock(svg, id);
const a = attrs(block.slice(0, block.indexOf('>')));
const gt = a.gradientTransform || '';
const tr = /translate\(\s*([-\d.]+)[ ,]+([-\d.]+)\s*\)/.exec(gt);
const ro = /rotate\(\s*([-\d.]+)\s*\)/.exec(gt);
const sc = /scale\(\s*([-\d.]+)\s*\)/.exec(gt);
if (!tr || !sc) throw new Error('unexpected gradientTransform: ' + gt);
return { cx: +tr[1], cy: +tr[2], rot: ro ? +ro[1] : 0, r: +sc[1], stops: stopsOf(block) };
}
const r6 = n => Math.round(n * 1e6) / 1e6;
function scaleLin(g, s, off) {
return { x1: r6(g.x1 * s + off), y1: r6(g.y1 * s + off),
x2: r6(g.x2 * s + off), y2: r6(g.y2 * s + off), stops: g.stops };
}
function scaleRad(g, s, off) {
return { cx: r6(g.cx * s + off), cy: r6(g.cy * s + off), rot: g.rot, r: r6(g.r * s), stops: g.stops };
}
/* ---------- read the real layers --------------------------------------- */
const fgSvg = read(ADP + '/ic_launcher_foreground.svg');
const bgSvg = read(ADP + '/ic_launcher_background.svg');
const monoSvg = read(ADP + '/ic_launcher_monochrome.svg');
const conSvg = read('F:/Coding/SoulStream/src-tauri/icons/icon.svg');
const fgTag = /<path\b[^>]*>/.exec(fgSvg)[0];
const monoTag = /<path\b[^>]*>/.exec(monoSvg)[0];
const fgA = attrs(fgTag), monoA = attrs(monoTag);
if (fgA.d !== monoA.d) console.warn('WARN: monochrome path d differs from foreground');
else console.log('ok monochrome path d is byte-identical to the foreground path');
if (fgA.transform !== monoA.transform) console.warn('WARN: transforms differ');
else console.log('ok both layers share transform:', fgA.transform);
const flat = attrs(/<rect\b[^>]*>/.exec(bgSvg)[0]).fill;
console.log('ok kit adaptive background =', flat);
// transform: translate(a b) scale(s) translate(c d)
const tm = /translate\(([-\d.]+)\s+([-\d.]+)\)\s*scale\(([-\d.]+)\)\s*translate\(([-\d.]+)\s+([-\d.]+)\)/
.exec(fgA.transform);
if (!tm) throw new Error('unexpected foreground transform');
const tParsed = [ +tm[1], +tm[2], +tm[3], +tm[4], +tm[5] ];
console.log('ok optical offset preserved verbatim: translate(' + tm[4] + ' ' + tm[5] + ')');
const fgGrad = linOf(fgSvg, 'adaptive-gradient');
const surf1024 = linOf(conSvg, 'rounded-4-surface-gradient');
const glow1024 = radOf(conSvg, 'rounded-4-glow-gradient');
console.log('ok concept 04 surface axis :', JSON.stringify(surf1024).slice(0, 120));
console.log('ok concept 04 glow : centre', glow1024.cx + ',' + glow1024.cy,
'r', glow1024.r, 'peak alpha', glow1024.stops[0].a, glow1024.stops[0].c);
const S_LAYER = 108 / 1024; // B: 1024 artwork spans the whole 108 layer
const S_MASK = 72 / 1024; // C: 1024 artwork spans only the visible 72 window
const gradL = { surf: scaleLin(surf1024, S_LAYER, 0), glow: scaleRad(glow1024, S_LAYER, 0) };
const gradM = { surf: scaleLin(surf1024, S_MASK, 18), glow: scaleRad(glow1024, S_MASK, 18) };
/* ---------- composite sampling ----------------------------------------- */
const hex2rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
const rgb2hex = c => '#' + c.map(v => Math.round(Math.max(0, Math.min(255, v)))
.toString(16).padStart(2, '0')).join('').toUpperCase();
function sampleStops(stops, t) {
t = Math.max(0, Math.min(1, t));
let a = stops[0], b = stops[stops.length - 1];
for (let i = 0; i < stops.length - 1; i++)
if (t >= stops[i].o && t <= stops[i + 1].o) { a = stops[i]; b = stops[i + 1]; break; }
const f = b.o === a.o ? 0 : (t - a.o) / (b.o - a.o);
const ca = hex2rgb(a.c), cb = hex2rgb(b.c);
const al = (a.a == null ? 1 : a.a), bl = (b.a == null ? 1 : b.a);
return { rgb: ca.map((v, i) => v + f * (cb[i] - v)), a: al + f * (bl - al) };
}
function linT(g, p) {
const vx = g.x2 - g.x1, vy = g.y2 - g.y1;
return ((p[0] - g.x1) * vx + (p[1] - g.y1) * vy) / (vx * vx + vy * vy);
}
function composite(v, p) {
if (v === 'flat') return rgb2hex(hex2rgb(flat));
const g = v === 'gradL' ? gradL : gradM;
const s = sampleStops(g.surf.stops, linT(g.surf, p)).rgb;
const d = Math.hypot(p[0] - g.glow.cx, p[1] - g.glow.cy);
const gl = sampleStops(g.glow.stops, d / g.glow.r);
return rgb2hex(s.map((v2, i) => gl.rgb[i] * gl.a + v2 * (1 - gl.a)));
}
const R = 36 / Math.SQRT2;
const POINTS = [
['visible NW corner (18, 18)', [18, 18]],
['circle NW rim (' + r6(54 - R).toFixed(1) + ', ' + r6(54 - R).toFixed(1) + ')', [54 - R, 54 - R]],
['mask centre (54, 54)', [54, 54]],
['circle SE rim (' + r6(54 + R).toFixed(1) + ', ' + r6(54 + R).toFixed(1) + ')', [54 + R, 54 + R]],
['visible SE corner (90, 90)', [90, 90]]
];
const samples = POINTS.map(([where, p]) => ({
where, v: ['flat', 'gradL', 'gradM'].map(v => composite(v, p))
}));
console.log('\n point A flat B layer C mask');
for (const s of samples) console.log(' ' + s.where.padEnd(34) + s.v.join(' '));
/* ---------- shipping raster -------------------------------------------- */
const pngPath = 'F:/Coding/SoulStream/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png';
const todayPng = 'data:image/png;base64,' + fs.readFileSync(pngPath).toString('base64');
console.log('\nok shipping raster embedded:', pngPath);
/* ---------- emit -------------------------------------------------------- */
const DATA = {
flat,
fgPath: { d: fgA.d, t: fgA.transform, tParsed },
monoPath: { d: monoA.d, t: monoA.transform },
fgGrad, gradL, gradM, samples, todayPng,
themed: {
light: { ground: '#DCE3E9', ink: '#44515E' },
dark: { ground: '#2A3138', ink: '#C6D3DF' }
}
};
const tpl = read(path.join(OUT, 'template.html'));
if (!tpl.includes('/*__DATA__*/null')) throw new Error('template marker missing');
const html = tpl.replace('/*__DATA__*/null', JSON.stringify(DATA));
const dest = path.join(OUT, 'icon-mock.html');
fs.writeFileSync(dest, html, 'utf8');
console.log('ok wrote', dest, (html.length / 1024).toFixed(1) + ' KB');
+376
View File
@@ -0,0 +1,376 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SoulStream Android icon — flat vs concept 04 gradient</title>
<style>
:root{
--ui-bg:#0e1013; --ui-panel:#161a1f; --ui-line:#252b33;
--ui-fg:#d2d9e1; --ui-dim:#828e9b; --ui-hi:#6fd3e0;
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--ui-bg);color:var(--ui-fg);
font:13px/1.45 ui-sans-serif,system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
a{color:var(--ui-hi)}
.wrap{max-width:1180px;margin:0 auto;padding:18px 20px 60px}
header.top{display:flex;flex-wrap:wrap;align-items:baseline;gap:10px 16px;margin-bottom:14px}
header.top h1{font-size:15px;font-weight:650;margin:0;letter-spacing:.2px}
header.top .sub{font-size:12px;color:var(--ui-dim)}
.bar{position:sticky;top:0;z-index:20;display:flex;flex-wrap:wrap;gap:8px 18px;
align-items:center;padding:10px 12px;margin:0 0 18px;border:1px solid var(--ui-line);
border-radius:10px;background:rgba(16,19,23,.94);backdrop-filter:blur(6px)}
.grp{display:flex;align-items:center;gap:7px}
.grp>label{font-size:10.5px;text-transform:uppercase;letter-spacing:.9px;color:var(--ui-dim)}
.seg{display:flex;border:1px solid var(--ui-line);border-radius:7px;overflow:hidden}
.seg button{appearance:none;border:0;background:transparent;color:var(--ui-dim);
font:inherit;font-size:12px;padding:5px 9px;cursor:pointer;white-space:nowrap}
.seg button+button{border-left:1px solid var(--ui-line)}
.seg button:hover{color:var(--ui-fg)}
.seg button[aria-pressed="true"]{background:#232c35;color:#eaf3fa}
.chk{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-dim);cursor:pointer}
h2.sec{font-size:11px;text-transform:uppercase;letter-spacing:1.2px;color:var(--ui-dim);
margin:26px 0 9px;font-weight:600}
.band{border-radius:12px;padding:22px 18px 16px;margin-bottom:12px;position:relative;overflow:hidden}
.band .bandname{position:absolute;top:8px;left:12px;font-size:10px;letter-spacing:1.1px;
text-transform:uppercase;opacity:.55;font-weight:600}
.band.dark{background:radial-gradient(125% 105% at 18% -5%,#2e3339 0%,#191c21 52%,#0a0b0d 100%)}
.band.dark .bandname{color:#fff}
.band.light{background:radial-gradient(125% 105% at 18% -5%,#fdfaf5 0%,#ece5db 52%,#d5cdc1 100%)}
.band.light .bandname{color:#2b2722}
.band.neutral{background:#12151a;border:1px solid var(--ui-line)}
.band.neutral .bandname{color:var(--ui-dim)}
.cols{display:flex;flex-wrap:wrap;gap:26px;align-items:flex-start;justify-content:center}
.col{display:flex;flex-direction:column;align-items:center;gap:9px;min-width:104px}
.col.today{opacity:.92}
.tilebox{display:flex;align-items:center;justify-content:center;min-height:0}
.tag{font-size:12px;font-weight:700;letter-spacing:.4px;text-align:center}
.tagsub{font-size:10.5px;line-height:1.35;text-align:center;max-width:150px}
.band.dark .tag{color:#eef4fa}.band.dark .tagsub{color:#9aa6b3}
.band.light .tag{color:#221f1b}.band.light .tagsub{color:#6a635a}
.band.neutral .tag{color:#eef4fa}.band.neutral .tagsub{color:var(--ui-dim)}
.divider{width:1px;align-self:stretch;background:currentColor;opacity:.16;margin:0 2px}
.ladder{display:flex;flex-direction:column;gap:14px}
.lrow{display:flex;align-items:flex-end;gap:26px;justify-content:center}
.lcell{display:flex;flex-direction:column;align-items:center;gap:6px}
.lcell .sz{font-size:10px;color:var(--ui-dim)}
.lhead{display:flex;gap:26px;justify-content:center;margin-bottom:2px}
.lhead span{width:118px;text-align:center;font-size:11px;font-weight:700;letter-spacing:.4px}
.note{border:1px solid var(--ui-line);border-radius:10px;padding:14px 16px;background:var(--ui-panel);
font-size:12.5px;color:#b6c0cb;margin-top:14px}
.note b{color:#e6eef6;font-weight:650}
.note p{margin:0 0 9px}.note p:last-child{margin:0}
table.m{border-collapse:collapse;font:11.5px/1.3 ui-monospace,SFMono-Regular,Consolas,monospace;margin-top:4px}
table.m th,table.m td{border:1px solid var(--ui-line);padding:4px 8px;text-align:left}
table.m th{color:var(--ui-dim);font-weight:600}
.sw{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:-1px;margin-right:6px;
border:1px solid rgba(255,255,255,.18)}
.hidden{display:none}
.banner{border-left:2px solid var(--ui-hi);padding:8px 0 8px 12px;margin:0 0 14px;
font-size:12.5px;color:#b6c0cb}
.banner b{color:#e6eef6}
</style>
</head>
<body>
<div class="wrap">
<header class="top">
<h1>SoulStream Android launcher icon — background layer decision</h1>
<span class="sub">real kit layers · true 108/72 adaptive geometry · nothing here is shipped</span>
</header>
<div class="bar">
<div class="grp"><label>Mask</label><div class="seg" id="segMask"></div></div>
<div class="grp"><label>Size</label><div class="seg" id="segSize"></div></div>
<div class="grp"><label>Mode</label><div class="seg" id="segMode"></div></div>
<label class="chk"><input type="checkbox" id="cbShadow" checked> launcher shadow</label>
<label class="chk"><input type="checkbox" id="cbSafe"> safe-zone guides</label>
</div>
<div id="stdBanner" class="banner">
<b>A</b> is the kit as delivered. <b>B</b> and <b>C</b> are the same concept-04 gradient, differing only in
how the 1024 artwork is mapped onto the 108-unit layer. Reply with a letter.
</div>
<div id="thmBanner" class="banner hidden">
<b>Themed icons discard the background layer entirely.</b> Android 13+ draws only the
<code>&lt;monochrome&gt;</code> drawable, tinted, on a wallpaper-derived system ground — so A, B and C are
pixel-identical here. The decision below is not a background decision; it is only whether the monochrome
layer exists at all.
</div>
<div id="stage"></div>
<h2 class="sec">Size ladder — same mask, same wallpaper</h2>
<div class="band neutral"><span class="bandname">Neutral ground</span><div id="ladder" class="ladder"></div></div>
<div class="note" id="notes"></div>
</div>
<script>
const D = {"flat":"#05080B","fgPath":{"d":"M 471.889 863.531 C 470.033 861.073 467.485 856.812 466.226 854.062 C 464.398 850.070 463.857 847.418 463.544 840.920 C 463.249 834.800 463.512 831.540 464.601 827.793 C 466.399 821.609 469.407 815.555 473.409 810.066 C 476.304 806.095 476.482 805.474 476.562 799.067 C 476.630 793.639 477.162 790.964 479.217 785.732 C 483.406 775.062 489.001 766.958 499.269 756.689 C 504.437 751.520 515.049 742.428 522.967 736.383 C 530.854 730.363 540.573 722.201 544.565 718.246 C 548.557 714.290 553.986 707.799 556.630 703.821 C 566.285 689.294 570.061 677.926 570.109 663.251 C 570.167 645.247 564.288 631.978 550.406 618.778 C 543.711 612.413 537.141 607.939 527.046 602.871 C 511.185 594.907 499.224 590.830 464.632 581.594 C 429.650 572.254 407.140 565.108 384.291 556.087 C 369.696 550.324 349.615 540.740 338.462 534.213 C 320.047 523.435 302.208 509.772 290.291 497.319 C 282.487 489.163 272.391 476.452 268.043 469.309 C 260.079 456.225 252.284 438.708 251.281 431.640 C 250.907 429.006 251.062 428.044 251.860 428.044 C 252.460 428.044 257.063 430.702 262.090 433.950 C 279.955 445.495 298.689 456.033 313.781 463.025 L 321.990 466.828 L 315.175 459.916 C 306.482 451.099 297.522 440.054 290.373 429.342 C 283.846 419.562 275.156 402.180 271.326 391.240 C 268.303 382.605 266.682 375.526 264.934 363.320 C 263.614 354.107 263.278 340.053 264.377 340.053 C 264.755 340.053 271.343 346.050 279.018 353.379 C 305.154 378.337 323.767 394.790 344.545 411.305 C 357.025 421.223 379.468 437.524 391.447 445.369 C 421.469 465.031 452.755 481.034 483.387 492.398 C 493.465 496.137 514.890 502.670 542.833 510.526 C 553.736 513.591 567.823 518.023 574.138 520.375 C 589.578 526.124 605.702 534.093 616.621 541.371 C 626.525 547.973 639.041 559.257 644.889 566.856 C 650.348 573.951 654.995 583.053 657.601 591.759 C 659.560 598.303 659.892 600.978 659.957 610.795 C 660.037 622.827 659.257 627.864 655.632 638.715 C 653.007 646.577 649.606 653.442 644.525 661.136 C 642.375 664.394 640.265 667.630 639.838 668.328 C 639.410 669.026 642.266 666.701 646.183 663.161 C 658.976 651.602 669.377 635.863 674.360 620.525 C 680.887 600.431 681.418 582.436 676.073 562.442 C 672.127 547.683 662.390 530.337 650.734 517.305 C 640.568 505.936 625.125 494.022 607.956 484.301 C 589.833 474.040 573.381 467.503 530.216 453.412 C 512.773 447.718 493.356 440.996 487.066 438.473 C 474.757 433.536 464.611 428.344 455.790 422.469 C 449.119 418.025 438.217 408.277 432.454 401.601 C 422.380 389.933 413.615 372.247 410.336 356.975 C 409.168 351.535 408.804 346.280 408.840 335.400 C 408.881 323.303 409.202 319.739 410.863 312.979 C 414.453 298.361 419.429 287.287 428.148 274.505 C 434.444 265.277 446.706 252.323 456.519 244.535 C 483.937 222.774 516.119 207.844 564.045 194.653 C 579.471 190.408 592.384 187.409 620.816 181.469 C 654.191 174.497 674.652 168.373 699.416 157.943 C 728.416 145.729 764.550 125.102 787.619 107.593 C 791.507 104.642 792.482 104.208 792.476 105.431 C 792.471 106.273 791.534 108.965 790.394 111.414 C 785.297 122.364 772.485 139.362 759.953 151.803 C 750.130 161.555 738.729 170.675 725.744 179.167 C 715.779 185.684 694.819 196.809 683.162 201.768 C 680.040 203.097 677.586 204.285 677.710 204.409 C 678.135 204.834 706.608 197.159 714.223 194.567 C 728.618 189.666 742.169 183.962 756.021 176.972 C 763.653 173.121 770.077 170.148 770.294 170.366 C 771.203 171.274 761.797 182.974 752.317 192.729 C 741.008 204.364 729.933 213.624 716.943 222.304 C 698.650 234.528 680.678 243.412 657.959 251.461 C 642.229 257.034 624.511 261.607 598.311 266.858 C 587.608 269.003 574.363 271.878 568.878 273.248 C 555.300 276.639 537.612 282.530 526.395 287.397 C 507.859 295.440 490.988 307.165 482.040 318.224 C 479.729 321.080 476.651 325.830 475.199 328.779 C 473.747 331.728 471.958 337.085 471.225 340.682 C 470.121 346.096 470.058 348.298 470.860 353.458 C 472.774 365.768 478.100 375.203 489.472 386.432 C 502.963 399.753 527.921 414.250 558.969 426.799 C 566.647 429.902 581.496 435.667 591.966 439.609 C 638.284 457.049 660.959 467.018 682.072 479.225 C 693.287 485.709 708.151 495.840 717.893 503.640 C 729.475 512.914 743.275 526.023 750.422 534.542 C 762.716 549.194 773.237 567.193 778.922 583.298 C 782.840 594.397 785.607 607.564 786.657 620.110 C 787.335 628.206 787.324 633.770 786.618 641.059 C 783.472 673.507 771.319 700.824 748.857 725.937 C 745.773 729.385 740.336 734.824 736.774 738.023 C 710.587 761.547 675.649 780.088 633.589 792.780 C 625.820 795.125 606.899 800.277 591.543 804.231 C 558.928 812.627 544.532 816.705 530.869 821.417 C 510.985 828.275 497.176 835.191 487.628 843.074 C 480.759 848.745 477.646 854.394 477.088 862.205 C 476.860 865.392 476.356 868.000 475.968 868.000 C 475.580 868.000 473.745 865.989 471.889 863.531 Z M 507.752 803.633 C 514.224 802.436 520.624 799.550 524.691 795.993 C 526.496 794.414 530.427 789.316 533.427 784.662 C 536.427 780.009 539.690 775.298 540.676 774.193 L 542.471 772.184 L 540.355 772.Line truncated
/* ---------- Android adaptive geometry ----------------------------------
Layers are 108x108 dp. The launcher mask shows only the central 72x72 dp,
i.e. the layer is drawn at 1.5x the on-screen icon size and centre-cropped.
Every tile below is an <svg viewBox="18 18 72 72"> over 108-unit artwork,
which reproduces exactly that. The 66 dp circle is the content safe zone.
------------------------------------------------------------------------*/
const VIEWBOX = '18 18 72 72';
/* Mask outlines in a 0..100 box, mapped into the visible 72 window. */
const MASKS = {
circle: {label:'Circle', d:'M50,0 A50,50 0 1,1 50,100 A50,50 0 1,1 50,0 Z'},
squircle: {label:'Squircle', d:'M50,0 C10.05,0 0,10.05 0,50 C0,89.95 10.05,100 50,100 C89.95,100 100,89.95 100,50 C100,10.05 89.95,0 50,0 Z'},
rsquare: {label:'Rounded square',d:'M20,0 L80,0 A20,20 0 0,1 100,20 L100,80 A20,20 0 0,1 80,100 L20,100 A20,20 0 0,1 0,80 L0,20 A20,20 0 0,1 20,0 Z'},
teardrop: {label:'Teardrop (aggressive OEM)',d:'M0,20 A20,20 0 0,1 20,0 L50,0 A50,50 0 1,1 0,50 Z'}
};
const SIZES = [
{px:24, cap:'24 dp · notification'},
{px:32, cap:'32 dp · recents chip'},
{px:48, cap:'48 dp · launcher grid'},
{px:64, cap:'64 dp · folder / tablet'},
{px:112, cap:'112 dp · inspect'}
];
const VARIANTS = [
{id:'today', tag:'TODAY', sub:'shipping raster — Tauri placeholder on a white plate', today:true},
{id:'flat', tag:'A — FLAT', sub:'kit as delivered · solid ' + D.flat},
{id:'gradL', tag:'B — GRADIENT', sub:'concept 04 mapped 1024 → the whole 108 layer'},
{id:'gradM', tag:'C — GRADIENT', sub:'concept 04 mapped 1024 → the visible 72 window'}
];
/* ---------- defs ------------------------------------------------------ */
function stopsSVG(stops){
return stops.map(s =>
'<stop offset="'+s.o+'" stop-color="'+s.c+'"'+(s.a!=null?' stop-opacity="'+s.a+'"':'')+'/>'
).join('');
}
function linSVG(id,g){
return '<linearGradient id="'+id+'" gradientUnits="userSpaceOnUse" color-interpolation="sRGB" '+
'x1="'+g.x1+'" y1="'+g.y1+'" x2="'+g.x2+'" y2="'+g.y2+'">'+stopsSVG(g.stops)+'</linearGradient>';
}
function radSVG(id,g){
return '<radialGradient id="'+id+'" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" '+
'gradientTransform="translate('+g.cx+' '+g.cy+') rotate('+g.rot+') scale('+g.r+')">'+
stopsSVG(g.stops)+'</radialGradient>';
}
function buildDefs(){
let s = '<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>';
s += linSVG('fgGrad', D.fgGrad);
s += linSVG('surfL', D.gradL.surf) + radSVG('glowL', D.gradL.glow);
s += linSVG('surfM', D.gradM.surf) + radSVG('glowM', D.gradM.glow);
for (const k in MASKS){
s += '<clipPath id="clip-'+k+'" clipPathUnits="userSpaceOnUse">'+
'<path transform="translate(18,18) scale(0.72)" d="'+MASKS[k].d+'"/></clipPath>';
}
s += '</defs></svg>';
const el = document.createElement('div'); el.innerHTML = s; document.body.appendChild(el.firstChild);
}
/* ---------- layers ---------------------------------------------------- */
const FG = '<path d="'+D.fgPath.d+'" fill="url(#fgGrad)" fill-rule="evenodd" transform="'+D.fgPath.t+'"/>';
function MONO(fillVar){
return '<path d="'+D.monoPath.d+'" fill="'+fillVar+'" fill-rule="evenodd" transform="'+D.monoPath.t+'"/>';
}
const RECT = '<rect x="0" y="0" width="108" height="108" ';
function layers(id){
switch(id){
case 'flat': return RECT+'fill="'+D.flat+'"/>' + FG;
case 'gradL': return RECT+'fill="url(#surfL)"/>' + RECT+'fill="url(#glowL)"/>' + FG;
case 'gradM': return RECT+'fill="url(#surfM)"/>' + RECT+'fill="url(#glowM)"/>' + FG;
case 'today': /* Launcher3 wraps a legacy raster: corner-extracted plate + icon at ~0.7 */
return RECT+'fill="#ffffff"/>' +
'<image href="'+D.todayPng+'" x="28.8" y="28.8" width="50.4" height="50.4" '+
'preserveAspectRatio="xMidYMid meet"/>';
case 'monoLight': return RECT+'fill="'+D.themed.light.ground+'"/>' + MONO(D.themed.light.ink);
case 'monoDark': return RECT+'fill="'+D.themed.dark.ground +'"/>' + MONO(D.themed.dark.ink);
case 'todayThemed': return layers('today');
}
return '';
}
const SAFE = '<g fill="none" stroke="#ff4d6d" stroke-opacity=".85">'+
'<circle cx="54" cy="54" r="33" stroke-width=".6" stroke-dasharray="2 2"/>'+
'<rect x="18" y="18" width="72" height="72" stroke-width=".6" stroke-opacity=".45"/></g>';
function tile(variantId, px, mask){
const sh = state.shadow
? 'filter:drop-shadow(0 '+(px/48).toFixed(2)+'px '+(px/26).toFixed(2)+'px rgba(0,0,0,.30)) '+
'drop-shadow(0 0 '+(px/40).toFixed(2)+'px rgba(0,0,0,.14));' : '';
return '<svg class="tile" width="'+px+'" height="'+px+'" viewBox="'+VIEWBOX+'" style="'+sh+'display:block">'+
'<g clip-path="url(#clip-'+mask+')">'+layers(variantId)+(state.safe?SAFE:'')+'</g></svg>';
}
/* ---------- state + render -------------------------------------------- */
const state = {mask:'circle', size:48, mode:'standard', shadow:true, safe:false};
/* the hash makes any view reproducible: #mask=teardrop&size=112&mode=themed */
(function readHash(){
for (const kv of location.hash.replace(/^#/,'').split('&')){
const [k,v] = kv.split('=');
if (k === 'mask' && MASKS[v]) state.mask = v;
if (k === 'size' && SIZES.some(s => s.px === +v)) state.size = +v;
if (k === 'mode' && (v === 'themed' || v === 'standard')) state.mode = v;
if (k === 'shadow') state.shadow = v !== '0';
if (k === 'safe') state.safe = v === '1';
}
})();
function writeHash(){
history.replaceState(null,'','#mask='+state.mask+'&size='+state.size+'&mode='+state.mode+
'&shadow='+(state.shadow?1:0)+'&safe='+(state.safe?1:0));
}
function seg(host, items, key, onpick){
host.innerHTML = items.map(i =>
'<button data-v="'+i.v+'" aria-pressed="'+(state[key]===i.v)+'">'+i.t+'</button>').join('');
host.onclick = e => {
const b = e.target.closest('button'); if(!b) return;
state[key] = items.find(i => String(i.v) === b.dataset.v).v;
[...host.children].forEach(c => c.setAttribute('aria-pressed', c === b));
onpick && onpick(); render(); writeHash();
};
}
function colHTML(v, px){
return '<div class="col'+(v.today?' today':'')+'">'+
'<div class="tilebox">'+tile(v.id, px, state.mask)+'</div>'+
'<div class="tag">'+v.tag+'</div><div class="tagsub">'+v.sub+'</div></div>';
}
function bandStandard(cls, name){
let h = '<div class="band '+cls+'"><span class="bandname">'+name+'</span><div class="cols">';
h += colHTML(VARIANTS[0], state.size);
h += '<div class="divider"></div>';
for (let i=1;i<VARIANTS.length;i++) h += colHTML(VARIANTS[i], state.size);
return h + '</div></div>';
}
function bandThemed(cls, name, which){
const t = which === 'light' ? D.themed.light : D.themed.dark;
const cols = [
{id:'todayThemed', tag:'TODAY', sub:'no monochrome layer — the launcher leaves it full-colour among themed neighbours', today:true},
{id: which==='light'?'monoLight':'monoDark', tag:'A / B / C', sub:'kit monochrome, tinted '+t.ink+' on system ground '+t.ground}
];
let h = '<div class="band '+cls+'"><span class="bandname">'+name+'</span><div class="cols">';
h += colHTML(cols[0], state.size) + '<div class="divider"></div>' + colHTML(cols[1], state.size);
return h + '</div></div>';
}
function render(){
const stage = document.getElementById('stage');
const themed = state.mode === 'themed';
document.getElementById('stdBanner').classList.toggle('hidden', themed);
document.getElementById('thmBanner').classList.toggle('hidden', !themed);
if (!themed){
stage.innerHTML = bandStandard('dark','Dark wallpaper') + bandStandard('light','Light wallpaper');
} else {
stage.innerHTML = bandThemed('light','Light theme · wallpaper-derived ground','light') +
bandThemed('dark','Dark theme · wallpaper-derived ground','dark');
}
const list = themed
? [{id:'todayThemed',tag:'TODAY'},{id:'monoLight',tag:'THEMED · LIGHT'},{id:'monoDark',tag:'THEMED · DARK'}]
: VARIANTS;
let lh = '<div class="lhead">' + list.map(v=>'<span>'+v.tag+'</span>').join('') + '</div>';
for (const s of SIZES){
lh += '<div class="lrow">' + list.map(v =>
'<div class="lcell" style="width:118px"><div class="tilebox">'+tile(v.id, s.px, state.mask)+
'</div><div class="sz">'+s.cap+'</div></div>').join('') + '</div>';
}
document.getElementById('ladder').innerHTML = lh;
}
/* ---------- measurement: how much background does the falcon leave? ---- */
function coverage(maskKey){
const N = 512, c = document.createElement('canvas');
c.width = c.height = N; const x = c.getContext('2d');
const k = N/72; // 72 visible units -> N px
// mask
x.save(); x.scale(k,k); x.translate(-18,-18);
x.translate(18,18); x.scale(0.72,0.72);
x.fill(new Path2D(MASKS[maskKey].d)); x.restore();
const maskData = x.getImageData(0,0,N,N).data;
let maskPx = 0; for (let i=3;i<maskData.length;i+=4) if (maskData[i]>127) maskPx++;
// falcon inside the mask
x.globalCompositeOperation = 'source-in';
x.save(); x.scale(k,k); x.translate(-18,-18);
const t = D.fgPath.tParsed; // [tx,ty,s,tx2,ty2]
x.translate(t[0],t[1]); x.scale(t[2],t[2]); x.translate(t[3],t[4]);
x.fill(new Path2D(D.fgPath.d),'evenodd'); x.restore();
const fgData = x.getImageData(0,0,N,N).data;
let fgPx = 0; for (let i=3;i<fgData.length;i+=4) if (fgData[i]>127) fgPx++;
// the falcon on its own, to see how much the mask shaves off
const c2 = document.createElement('canvas'); c2.width = c2.height = N;
const y = c2.getContext('2d');
y.save(); y.scale(k,k); y.translate(-18,-18);
y.translate(t[0],t[1]); y.scale(t[2],t[2]); y.translate(t[3],t[4]);
y.fill(new Path2D(D.fgPath.d),'evenodd'); y.restore();
const allData = y.getImageData(0,0,N,N).data;
let allPx = 0; for (let i=3;i<allData.length;i+=4) if (allData[i]>127) allPx++;
return {mask:maskPx, fg:fgPx, pct: 100*fgPx/maskPx, kept: 100*fgPx/allPx,
maskOfSquare: 100*maskPx/(N*N)};
}
/* ---------- notes ----------------------------------------------------- */
function notes(){
const cov = coverage(state.mask);
const rows = D.samples.map(r =>
'<tr><td>'+r.where+'</td>'+
r.v.map(v=>'<td><span class="sw" style="background:'+v+'"></span>'+v+'</td>').join('')+'</tr>').join('');
document.getElementById('notes').innerHTML =
'<p><b>Geometry.</b> Layers are 108 dp; the mask shows the central 72 dp, so every layer is drawn at '+
'1.5&times; the icon size and centre-cropped — that is what the <code>viewBox="18 18 72 72"</code> on each '+
'tile reproduces. The foreground and monochrome paths, including the kit\'s v1.1 optical offset '+
'<code>translate(-40.740741 20.370370)</code>, are copied verbatim from the kit SVGs and are '+
'<b>identical in A, B and C</b>. Only the background layer changes.</p>'+
'<p><b>Composited background colour</b> (surface + glow, under the falcon, sampled in layer units):</p>'+
'<table class="m"><tr><th>point in the visible 72 window</th><th>A flat</th><th>B layer-fit</th>'+
'<th>C mask-fit</th></tr>'+rows+'</table>'+
'<p style="margin-top:10px"><b>How much background is even visible?</b> Measured live on this page: '+
'under the '+MASKS[state.mask].label.toLowerCase()+' mask the falcon covers only <b>'+
cov.pct.toFixed(1)+'%</b> of the masked area, so the background owns the other '+
(100-cov.pct).toFixed(1)+'%. That mask keeps '+cov.maskOfSquare.toFixed(1)+'% of the 72 dp square and '+
cov.kept.toFixed(1)+'% of the falcon (the falcon\'s painted box is 46.6 &times; 65.8 of the 72 window, '+
'so even a circle shaves only the extreme tips).</p>'+
'<p><b>B vs C.</b> B maps the 1024 artwork onto the whole 108 layer, so the visible 72 window sees only '+
'the middle two-thirds of the gradient. C maps it onto the visible 72 window, so the masked result matches '+
'the desktop tile corner for corner — but C\'s outer 18 dp of bleed is flat padding, which is what a '+
'launcher reveals when it parallaxes the layers on a swipe. Under a circle the two are almost the same '+
'(#0F3643 vs #0E3442 at the NW rim); they separate only where a mask keeps the corners.</p>'+
'<p><b>Honesty notes.</b> 1 dp = 1 CSS px here; on a Pixel-class screen a 48 dp icon is ~132 physical '+
'pixels but only ~7.6 mm across. Mask outlines are the standard circle / squircle / rounded-square / '+
'teardrop families — OEM paths vary slightly. The launcher shadow is a Launcher3-style ambient+key pair, '+
'approximated in CSS, applied identically to every tile. Themed-icon ground and ink are representative '+
'Material You tones; the real values are derived from the user\'s wallpaper. The TODAY column is the '+
'actual shipping <code>mipmap-xxxhdpi/ic_launcher.png</code> with Launcher3\'s legacy plating '+
'(corner-extracted plate, icon at 0.7) applied.</p>';
}
/* ---------- boot ------------------------------------------------------ */
buildDefs();
seg(document.getElementById('segMask'),
Object.keys(MASKS).map(k=>({v:k,t:MASKS[k].label})), 'mask', notes);
seg(document.getElementById('segSize'),
SIZES.map(s=>({v:s.px,t:s.px+' dp'})), 'size');
seg(document.getElementById('segMode'),
[{v:'standard',t:'Standard'},{v:'themed',t:'Themed (13+)'}], 'mode');
document.getElementById('cbShadow').checked = state.shadow;
document.getElementById('cbSafe').checked = state.safe;
document.getElementById('cbShadow').onchange = e => { state.shadow = e.target.checked; render(); writeHash(); };
document.getElementById('cbSafe').onchange = e => { state.safe = e.target.checked; render(); writeHash(); };
render(); notes(); writeHash();
</script>
</body>
</html>