Files
SoulStream/tools/auto-prober
KeelrLeek 3f56f274c5 android auto: the covers, and the grid a record is owed
Round fifty put the tree in the car with every row a bare line of text. This
gives the rows their sleeves and asks for albums to be drawn as a grid — the
two things the design picked that a tree alone cannot express, and the only
parts of a car screen the app actually controls.

**Artwork goes out as a `content://`, not as bytes.** media3 will carry a cover
as data in the metadata and the legacy stub Android Auto binds turns that into
a bitmap per row — inside a single Binder reply with a one-megabyte ceiling for
the whole list. Two hundred rows of even thumbnailed JPEG is a
`TransactionTooLargeException` on a list that worked yesterday. A URI is a few
dozen bytes and the host fetches only what it draws. The provider stays
`exported="false"`; each browse grants read on its own URIs to the package that
asked, which `onGetChildren` already knows.

Reaching the library needed a new `file_paths.xml` in the res overlay, and it
needs BOTH spellings of one directory. Covers live under the app's DATA dir,
which `files-path` cannot reach — that tag means `getFilesDir()`, the `files/`
subdirectory. `root-path` can, but FileProvider resolves a file through
`getCanonicalPath()` before matching, and for the primary user
`/data/user/0/<pkg>` canonicalises to `/data/data/<pkg>`, so a root scoped to
`/data/user/` never matches on a phone. `/data/user/` still earns its place:
a car's driver profile is user 10 and does not canonicalise away.

The grid is `CONTENT_STYLE_BROWSABLE_HINT` on the parent's `LibraryParams` —
grid for `ss:albums` and for an artist's albums, list for artists and tracks.
Those keys belong to the host rather than to media3, which is why the same
constants serve a phone-projected car and a head unit alike.

**Three failures, one cause: a catch with nothing in it.** `artUri` returned
null on any throw and said nothing, and every row came back coverless with the
database, the res overlay and the APK all checking out — the binary XML was
dumped out of the APK to prove the root was in there. It cost an afternoon and
two wrong diagnoses. One `Log.w` in the catch named both real bugs in a single
run: `JSONObject.optString` returns the four-character string `"null"` for a
JSON null rather than `""`, so the tabs were handing `File("null")` to the
provider (`isNull` is checked first now); and the canonical-path substitution
above, which is visible ONLY in that exception's text — `readlink -f` on the
device does not show it, and believing it cost the first wrong answer. The
warning latches to one line per process, because two hundred identical traces
would bury the tree logging it sits beside.

The prober reports each row's `iconUri` now. A tree walk could say a row
arrived but never whether it would be drawn with anything, which is the one
thing this round changes, and `art:""` against a real `content://` is the whole
difference.

Verified on the Pixel 6a through Android Auto's own binder with the app cold:
both albums come back carrying
`content://com.soulstream.app.fileprovider/ss_library_canonical/...cover.jpg`.
The car screen itself is still the Desktop Head Unit's to show.
2026-09-15 11:11:35 -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.