Commit Graph
4 Commits
Author SHA1 Message Date
KeelrLeek 8f46f9c663 android auto: a catalog row is handed to the app, not failed at
Every entry in a streamed playlist is `kind='stream'` with no file on this
phone — only a catalog id. A service-only process cannot turn that into audio:
the resolver is hundreds of lines of shared JS that exist while a WebView does
and not otherwise. So the car tapped those rows and nothing happened, which is
the worst of the three possible answers.

Now the process asks whether the APP can do it instead. Rust, finding nothing
local, reports WHICH playlist and entry were meant rather than just failing —
a catalog row is not a broken row, it is one this process cannot do alone, and
that difference is what decides between an error and a hand-off. Kotlin carries
it over `MediaHub.onRemote`, which is non-null only while the plugin is loaded,
which is only while a WebView exists: the same fact is both the channel and the
test for "is the app warm". And the JS plays it with `playFrom(playlist, items,
item)` — the exact call the half-sheet makes when the same row is tapped in the
app, so the queue, the provenance and the failure message are the app's own.
Nothing about the car is special once it arrives.

Cold, it says so instead of pretending: the focus is handed back rather than
held for silence, and the log names the row. A phone in a pocket genuinely
cannot play a Deezer id, and a tap that quietly does nothing is worse than one
that stops.

Two things the same change fixed on the way past. A streamed entry has no cover
FILE, only a remote URL, so `COALESCE(art_path, art_url)` gives those rows their
sleeves and a remote uri now goes to the host untouched instead of through a
FileProvider whose root check it could never pass. And Recent, playlists and an
artist's albums join Albums as GRIDS — all four are things known by the picture,
which is what the grid is for.

`probe.sh` gained `SS_KEEP=1`, without which the warm path cannot be tested at
all: the script force-stops SoulStream, which is precisely the app the hand-off
needs alive.

**Proven:** cold answers `auto cold, cannot resolve` and releases focus; warm
answers `auto hand-off to the app` and the event reaches the JS with no console
error. **Not proven:** that the audio then starts. Those Deezer ids have to go
through the resolver exactly as they do when tapped in the app, and if the app
cannot play them the car will not either — which is correct behaviour and
indistinguishable, from here, from a bridge that silently failed. It wants
checking with the app in front of you.
2026-09-15 17:01:43 -05:00
KeelrLeek 54245a33b5 android auto: Recent means albums and playlists, and is never empty over a full library
"Recent" answered with TRACKS out of the `plays` table, and on a phone whose
history had just been wiped that was "No items" sitting over a full library —
the first thing a driver sees, saying the app has nothing.

Three changes, each about what the word means in a car.

**Albums, not tracks.** "Recent" in a car is "put that back on", and what
anyone means by that is the record. A list of single songs plays one and stops.
`plays` records what was played by NAME rather than by track id, so getting
back to an album means matching the pair again; anything played that has since
left the library drops out of the join, which is right for a row whose promise
is that tapping it works.

**Playlists, which were absent entirely.** They are in the database, they are
local, and they play from cold exactly as albums do — they were missing only
because the four root slots were spent, and Recent is somewhere they can live
without taking one. Counted out of `playlist_items` rather than
`playlist_tracks`: it is the richer table and the one that knows whether an
entry is a local file or something that needs the resolver. A playlist that is
part catalog says so on the row — "Playlist · 4 of 9 here" — so the gap is
declared before the tap rather than discovered after it, and tapping plays the
local entries in order. Entries that need the phone are shown rather than
hidden, so the car's list matches what is on the phone, and marked unplayable
rather than pretending.

**An unplayed library is not an empty shelf.** With no history at all, Recent
falls back to the newest albums, which is a defensible reading of the word and
a great deal better than an empty tab on a fresh install.

`ss:playlist:<id>` and `ss:pitem:<id>` join the tree and `resolve_playable`, so
a playlist or one of its tracks plays with the rest behind it.

Two fixes to the instrument that found this: `probe.sh` was passing
`--es play ""` when no query was given, which `am start` rejects outright
("Argument expected after play") — it is conditional now. And a splice left
`fn track_node(fn track_node(` in the source, which rustc reports as an
unclosed delimiter at the END of the file, pointing 400 lines away from the
mistake.
2026-09-15 15:08:21 -05:00
KeelrLeek f50cf2018a android auto: a tap in the car plays, with no Activity anywhere in the process
Android Auto starts this app by binding a service. There is no Activity, no
WebView, no `tauri::AppHandle` — and until now no way to make a sound, because
the engine lived in Tauri's managed state. `src-tauri/src/headless.rs` moves it
into a `OnceLock` owned by the PROCESS: whoever needs audio first builds it,
`playback::init` on a normal launch or a car tapping a row into a process with
no UI, and everyone after that gets the same one.

**That singleton is the whole safety story.** `Player::spawn` opens an output
stream, so two of them in one process is two AAudio streams playing over each
other — the failure this module exists to make impossible. The event receiver
is single-consumer, so the pump lives there too and offers a subscription slot
for the WebView to claim when it finally appears, rather than a second engine
being stood up beside the first.

The queue here is deliberately not the app's queue. The JS one keeps shuffle,
repeat, `alts` failover, provenance and the three-ahead preload; this is a
vector and an index whose only job is that the album keeps playing when there
is no JS alive to advance it. Its metadata is read ONCE, when the queue is
built, so advancing never touches the database again — which matters when the
thing advancing is a background thread in a process with no UI.

Resolution happens in Rust, against the library, read-only. A tapped
`ss:track:<id>` brings its whole album at the right index, because selecting
one song and getting silence after it is not what a record does. Nothing
trusts a URI from the controller: the tree only ever publishes `ss:` ids, so
there is no file path for anyone to hand back, and an id that resolves to
nothing returns a count of zero rather than a guess.

Kotlin's half is three things that each fail invisibly on their own.
`COMMAND_SET_MEDIA_ITEM` is advertised now — without it a browse row is drawn
as playable and does nothing when tapped. `onAddMediaItems` passes ids through
instead of media3's default `UnsupportedOperationException`, which kills the
tap before it reaches the player. And `handleSetMediaItems` does the work on
its own thread, because the first tap in a cold process opens a database AND
starts an audio engine, and it is called on the application thread where ten
seconds is an ANR.

**Two things the device taught, both of which looked like silence.** The
headless path has to request audio focus itself: the first run showed a live
AAudio track on our uid, decoding perfectly, with `PortMuted=true` and the gain
at `-inf` — audible to nobody. On the WebView path the JS asks for focus; a car
tapping a cold process has no JS to ask. And it has to write the card itself:
`getState()` mirrors `MediaHub.state`, which only the WebView ever fills, so
the second run played audio while the session still reported `STATE_NONE` with
no title — a head unit showing a blank player over music. The engine knows what
it started, and that now goes into `MediaHub.update` in the shape the card
already reads.

Proven by the prober, which gained a `play` mode for it: bind cold, issue
`playFromMediaId` through the session token the browser handed back — Auto's
own path, not an interface of ours — and read the state back. `ss:track:1`
answers `queued=13` (the album behind it), and the controller reports
`state=3` with the title and a position moving 3.8 s → 11.8 s → 19.8 s across
the run. `probe.sh <depth> <recent> <mediaId>` is the invocation.

**Not done, and it is the display rather than the audio.** Nothing tells Kotlin
when the fallback queue advances, so the card will keep showing track one while
track two plays. The position moves on its own — `SimpleBasePlayer`
extrapolates it while playing — but a track change and a seek both need the
pump to push, and the pump currently only speaks to a subscriber that exists
when there is a WebView. The handover the design asks for (a UI arriving over
live headless playback, and exactly one queue driver surviving) is untested for
the same reason.
2026-09-15 11:49:40 -05:00
KeelrLeek e1bf456743 android auto: the car can see the app, and the root it opens is empty
Android Auto reaches a media app through `MediaBrowserService`, so `MediaService`
becomes a `MediaLibraryService`, one intent filter carries three actions — media3's
`MediaLibraryService` and `MediaSessionService`, plus the legacy
`android.media.browse.MediaBrowserService` that Auto actually binds — and
`res/xml/automotive_app_desc.xml` with the `com.google.android.gms.car.application`
meta-data declares the app to the car. Auto lists SoulStream, opens it, and gets a
browsable root. There is nothing under it: `onGetChildren` is left un-overridden so
the default answers `ERROR_NOT_SUPPORTED` at once, and round fifty fills the tree.

`onGetLibraryRoot` answers out of `Futures.immediateFuture` — no database, no I/O,
nothing deferred — because `MediaLibraryServiceLegacyStub.onGetRoot` blocks the
binder thread serving the host on that future with NO timeout and runs the callback
on the main handler. A root that waits on anything is a head unit that gives up.

The round carries a mitigation it cannot be split from. androidx/media #3158 — open
since 2026-04-03, confirmed again 2026-09-04, reproduced on media3 1.11.0 — has a
host that binds a `MediaLibraryService` with nothing playing drive the service round
`onCreate` → `onBind` → `onGetSession` → `onConnect` → `onDestroy` at about thirty
cycles a second, deterministically after a force-stop with no existing binding. That
is exactly how Auto connects and exactly what the prober does. media3 will not act
before an Auto-side fix lands, so the loop cannot be stopped from here; it can only
be made to cost nothing. The session, the player, the notification provider, the
audio-focus request and the becoming-noisy receiver move into `MediaHub` for the life
of the PROCESS and `onCreate` allocates and registers nothing — round forty-eight's
version would have built a platform session and a legacy browser stub thirty times a
second, registered and unregistered a receiver sixty times a second, and abandoned
audio focus thirty times a second while music was playing.

Three consequences, each a trap for the next round. `onDestroy` calls `removeSession`
and never `release()`: `removeSession` drops the session from that instance's map and
clears its listener, which is what stops a destroyed service being called back, while
`markReleased()` would latch `released` and leave a process-lifetime player that never
updates the card again. The session is built with
`MediaLibrarySession.Builder(applicationContext, …)` rather than the
`(MediaLibraryService, …)` overload, because `MediaSessionServiceLegacyStub.initialize()`
does `attachToBaseContext(sessionImpl.getContext())` — a session built against the
Service pins a destroyed Service as the base context of a live
`MediaBrowserServiceCompat`. And nothing releases the session any more, so it and its
platform `MediaSessionCompat` live until the process dies; the walk's `one-session`
check is what keeps that honest. `onCreate` logs `auto service created n=…` and
`onDestroy` logs `auto service destroyed`, which are the only way to count the loop on
a real phone.

And the instrument. `tools/auto-prober/` is a throwaway app that binds the same way
Auto does, with Auto's own root hints, walks whatever tree comes back, and logs one
JSON object per event under `SoulAutoProbe`. It is framework-only
(`android.media.browse.MediaBrowser`, not androidx, not media3) because that is the
path Auto itself takes into a media3 app, and a prober built out of the library it is
probing would not prove that path works. It lives outside `src-tauri/gen/android/`
because `tauri android init` would delete it, and borrows the generated project's
Gradle wrapper rather than shipping one, so a run downloads nothing. With SoulStream
force-stopped there is no WebView, so no CDP, so `smoke.mjs` is blind to every bit of
this — the prober is the only eye the project has on Auto without a head unit. The
number it exists for is `root`: milliseconds from `connect()` to `onConnected()` with
the process cold.

Two corrections to the plan, argued in the files. `adb shell pm dump` is
`dumpsys package` and prints NO application meta-data, so the Auto declaration cannot
be a hard walk assertion — the two that ARE hard are the intent filters
(`auto-browse`, `auto-library`), which catch the `TAURI_ANDROID_PROJECT_PATH` trap
completely because the filters and the meta-data live in one library manifest and are
lost together; the declaration is read instead by the prober, through
`PackageManager.GET_META_DATA` and the app's own `Resources`, and echoed back by the
walk as the advisory `auto-desc`. And the prober module compiles at jvmTarget 1.8 like
every other module here, not 17 — JDK 17 is what RUNS Gradle.

No Rust, no frontend, no cargo. The round was authored where none of it could be
compiled and it has now been built and run: the Kotlin compiles, the APK installs, and
the prober binds the app cold. `desc` resolves the meta-data to a real
`xml/automotive_app_desc`; `filter` shows BOTH the legacy
`android.media.browse.MediaBrowserService` and media3's own resolving to
`MediaService`; `root` comes back at ~2.2 s with `ss:root` and a session token, of
which the callback itself is about nine milliseconds and the rest is the cold process
starting; `children` answers `n=-1` with nothing in it, which is the un-overridden
default doing its job; and `auto service created n=1 reused=false` — #3158 does not
loop here.

One fix the first run earned. The prober read the descriptor's `name` out of the
android namespace, but Google's `<uses name="media"/>` is a BARE attribute with no
namespace, so it came back `[""]` — a real declaration and a dangling resource id
reported identically, which is the single thing the event exists to tell apart. It
reads the null namespace now and keeps ANDROID_NS as a fallback; the second run says
`uses:["media"]`.

None of this proves the car screen: the Desktop Head Unit or a real head unit is the
only thing that shows what Auto draws, how it groups a tree, or whether a tap plays.
This proves the app answers.
2026-09-15 09:16:53 -05:00