Files
SoulStream/tools/auto-prober
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
..

The Android Auto prober

A throwaway app that binds SoulStream's MediaBrowserService the way Android Auto does, walks whatever tree comes back, and prints one JSON object per event under the logcat tag SoulAutoProbe.

bash tools/auto-prober/probe.sh            # depth 2, the ordinary root
bash tools/auto-prober/probe.sh 3 true     # depth 3, the RECENT root
adb uninstall com.soulstream.autoprober    # and it is gone

Why it exists

Android Auto reaches a media app through MediaBrowserService, with the app's process cold — no Activity, no WebView, and therefore no DevTools socket. tests/mobile/smoke.mjs drives the phone over CDP, so it cannot see any of this: with SoulStream force-stopped there is nothing for it to attach to. Without a head unit or the Desktop Head Unit, this is the only instrument the project has.

It is framework-only on purpose — android.media.browse.MediaBrowser, not androidx, not media3. That is the path Auto itself takes into a media3 app (media3 serves the legacy android.media.browse.MediaBrowserService action through MediaSessionService.onBind), and a prober built out of the same library as the thing it probes would not prove that path works.

Where it lives, and why not in the generated project

src-tauri/gen/android/ is regenerated by npx tauri android init and is gitignored; anything put in there is deleted the next time someone runs it. So the prober is its own Gradle build, tracked, outside it — and it borrows the generated project's wrapper rather than shipping one, which pins Gradle 8.14.3, AGP 8.11.0, Kotlin 1.9.25 and JDK 17: exactly what is already in the Gradle cache, so a probe run downloads nothing.

There is deliberately no gradle-wrapper.jar in this directory.

What it prints

One line per event, each a JSON object, all under SoulAutoProbe:I:

ev what it says
start the package, component, depth and whether the RECENT root was asked for
desc the com.google.android.gms.car.application meta-data resolved to a resource id, that resource's name, and the <uses name=…> inside it
filter what queryIntentServices resolves for the legacy browse action and for media3's own, scoped to SoulStream
root the number this exists for: milliseconds from connect() to onConnected() with the process cold, the root media id, and whether a session token came with it
children one per subscription: the parent, how many rows, and each row's id, title, browsable and playable
fail the bind failed, was suspended, or the 8 s watchdog fired
done the walk finished: elapsed, nodes, leaves

desc is the important one for the manifest, because adb shell pm dump prints resolver tables and package state and no application meta-data at all — the phone walk's auto-browse and auto-library checks can prove the intent filters and nothing else. This resolves the meta-data int through PackageManager.GET_META_DATA and then opens the resource out of the app's own Resources, which is the only way to tell a real declaration from a dangling id without unpacking the APK.

probe.sh also prints two lines of SoulStream's own log: auto root …, the service's view of the same connection (including which root hints survived the trip), and auto service created n=…, which is the androidx/media #3158 counter — see below.

androidx/media #3158

Open since 2026-04-03, still open, reproduced on media3 1.11.0: a host that binds a MediaLibraryService while nothing is playing can put the service into an onCreate → onBind → onGetSession → onConnect → onDestroy loop at about thirty cycles a second. It is 100% reproducible after a force-stop with no existing binding — which is precisely what this prober does, so expect to see it here first.

MediaHub holds the session, the player, the notification provider, the audio-focus request and the becoming-noisy receiver for the life of the process, so a cycle allocates and registers nothing and the loop is survivable. The loop itself is Android Auto's to fix and media3 has said it will not act first. auto service created n=… is how you find out: one or two is normal, a number climbing while a probe or a car is connected is the bug.

What it is not

It does not prove the car screen. Nothing here can: the Desktop Head Unit or a real head unit is the only thing that shows what Auto actually draws, how it groups a tree, or whether a tap plays. This proves the app answers.

Safety

No permissions beyond <queries>, no network, no storage, no transport command. Every exit path disconnects the browser and onDestroy disconnects again, so nothing is left bound to SoulStream's service. The only force-stop probe.sh issues is of com.soulstream.app, which tests/mobile/smoke.mjs already does on every phone run.