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.
This commit is contained in:
KeelrLeek committed 2026-09-15 09:16:53 -05:00
1 parent 3a8ccd4640
commit e1bf456743
16 files changed
+1412 -226

No files matched your search

+99
View File
@@ -2470,6 +2470,71 @@ function shadeNotice() {
}
}
/* ── Android Auto's front door, as the PackageManager resolved it ─────
* Two of the three fields FAIL the walk, and they are the only automated
* proof this project has that Auto can see the app at all.
*
* What they catch is the `TAURI_ANDROID_PROJECT_PATH` trap. The service,
* its intent filter and the `com.google.android.gms.car.application`
* meta-data all live in the media-session plugin's own library manifest;
* tauri-build only rewrites `gen/android/tauri.settings.gradle` when the
* CLI's variable names the generated project, and without it the whole
* module compiles fine and never reaches the APK. That has already cost
* this project a start-up `ClassNotFoundException` once. Read out of
* `pm dump` the filters are simply absent, which is a red line rather than
* a crash in a car.
*
* `pm dump <pkg>` is `dumpsys package <pkg>`: resolver tables, permissions
* and package state. It prints NO application meta-data, so the
* `automotive_app_desc` half cannot be asserted from here — `desc` is the
* prober's own `{"ev":"desc"}` line echoed back, advisory, and the
* meta-data's real proofs are aapt2 at link time (`@xml/automotive_app_desc`
* is a compile-time reference; a missing resource fails the build) and
* `tools/auto-prober/`, which resolves the int through
* `PackageManager.GET_META_DATA` and parses the XML out of the app's own
* Resources.
*
* `table` is in the JSON on purpose: a zero there says the Service Resolver
* Table was not where this looked — a format surprise, not a missing
* filter — which is the one thing that could turn these two into a false
* red. */
function autoManifest() {
const out = { browse: "", library: "", table: 0, desc: "no prober line" };
try {
const dump = adb("shell", "pm", "dump", "com.soulstream.app").toString();
const at = dump.indexOf("Service Resolver Table:");
/* the section, and no further: the tables are printed one after
another and an action name must not be found in the wrong one */
let table = "";
if (at >= 0) {
const rest = dump.slice(at, at + 200000);
const next = rest.indexOf("Resolver Table:", 23);
table = next < 0 ? rest : rest.slice(0, next);
}
out.table = table.length;
/* each action heads a block of the components that declare it; the
window keeps a match from wandering into the next action's */
const resolves = (action) => {
const i = table.indexOf(action + ":");
if (i < 0) return "no filter";
return /com\.soulstream\.mediasession\.MediaService/.test(table.slice(i, i + 600))
? "MediaService" : "elsewhere";
};
out.browse = resolves("android.media.browse.MediaBrowserService");
out.library = resolves("androidx.media3.session.MediaLibraryService");
} catch (e) {
out.browse = "ERR " + e.message;
out.library = "ERR " + e.message;
}
try {
const log = adb("logcat", "-d", "-s", "SoulAutoProbe:I").toString();
const lines = log.split(/\r?\n/).filter((l) => l.includes('"ev":"desc"'));
const m = (lines[lines.length - 1] || "").match(/\{.*\}$/);
if (m) out.desc = m[0];
} catch (e) { out.desc = "ERR " + e.message; }
return out;
}
/* ── the service, the focus stack and the noisy receiver ──────────────
* Three things `dumpsys` can say about the media session that no page and
* no notification can. ALL THREE ARE ADVISORY: they print on every run and
@@ -3105,6 +3170,40 @@ async function main() {
playing, printed and never failed — see mediaPlumbing() */
console.log(" -- " + "media-plumbing".padEnd(16) + " " + JSON.stringify(mediaPlumbing()) + " (advisory: prints, never fails)");
/* ── Android Auto can see the app ─────────────────────────
Round forty-nine made `MediaService` a `MediaLibraryService`
and put three actions on one filter. Nothing on this phone
can prove the car screen — that needs a head unit or the
Desktop Head Unit — but the two doors Auto knocks on are
exactly what `pm dump` prints, and their absence is how this
fails in practice: a plugin module that never reached the APK
takes the service, the filter and the Auto meta-data with it
in one go, silently.
`auto-browse` is the legacy action Android Auto actually
binds (`android.media.browse.MediaBrowserService`), which
media3 still serves through `MediaSessionService.onBind`.
`auto-library` is media3's own, which is also what
`MediaSessionLegacyStub` resolves the media-button
PendingIntent against — it queries the LibraryService action
FIRST — so it is the regression guard for `media-keys` above
as much as for Auto. See autoManifest(). */
const auto = autoManifest();
const browseOk = auto.browse === "MediaService";
if (!browseOk) problems.push("EXPECT auto-browse " + JSON.stringify(auto));
console.log((browseOk ? " ok " : " FAIL ") + "auto-browse".padEnd(16) + " " + JSON.stringify({ browse: auto.browse, table: auto.table }));
const libraryOk = auto.library === "MediaService";
if (!libraryOk) problems.push("EXPECT auto-library " + JSON.stringify(auto));
console.log((libraryOk ? " ok " : " FAIL ") + "auto-library".padEnd(16) + " " + JSON.stringify({ library: auto.library, table: auto.table }));
/* and the Auto declaration itself, which `pm dump` does not
print at all — this is the last `{"ev":"desc"}` line
tools/auto-prober/ left in logcat, or a note that it has not
run. Advisory, always: the prober is a separate app and is
not installed on a plain walk. */
console.log(" -- " + "auto-desc".padEnd(16) + " " + auto.desc + " (advisory: prints, never fails)");
adb("shell", "input", "keyevent", "KEYCODE_MEDIA_PAUSE");
await sleep(1500);
const keyPaused = await cdp.evaluate(PLAYER);