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

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.