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:
1 parent
3a8ccd4640
commit
e1bf456743
16 files changed
+1412
-226
No files matched your search
+180
-32
@@ -35,6 +35,7 @@ implements is `research/mobile-mockup/` (gitignored; see its README).
|
||||
| `src-tauri/src/updater_mobile.rs` | the updater's stand-in on mobile (`#[cfg(mobile)] #[path]`) |
|
||||
| `src-tauri/src/lyric_align_stub.rs`, `vocal_sep_stub.rs` | stand-ins off Windows for the two Windows ML modules |
|
||||
| `tests/mobile/smoke.mjs`, `perf.mjs`, `cdp.mjs` | the phone UI walked in headless Chrome or on the phone, the frame-time probe, and the DevTools client they share (below) |
|
||||
| `tools/auto-prober/` | a throwaway app that binds SoulStream's `MediaBrowserService` the way Android Auto does and logs the tree as JSON — the only eye on Auto without a head unit, because a force-stopped app has no WebView and so no DevTools socket for `smoke.mjs`. Its own Gradle build, outside `gen/android/` so `tauri android init` cannot delete it (below) |
|
||||
|
||||
Android-only manifest tables: `src-tauri/Cargo.toml` and
|
||||
`crates/metadata/Cargo.toml` give reqwest `rustls-tls` on Android and the
|
||||
@@ -826,14 +827,53 @@ plugin (arguments in, the `action` event out), `SoulStreamPlayer`, and
|
||||
`MediaService`. Since round forty-eight it is **androidx.media3 1.10.1**, not
|
||||
`androidx.media`, which was deprecated in 1.8.0 on 2026-05-06; Android Auto
|
||||
needs a media3 session to browse, and the rounds after this one build on the
|
||||
`Player` this one creates. `MediaService` is a `MediaSessionService` holding
|
||||
one `MediaSession`, and `SoulStreamPlayer` is a `SimpleBasePlayer` — media3's
|
||||
`Player` this one creates. `MediaService` is a `MediaLibraryService` holding
|
||||
one process-lifetime `MediaLibrarySession`, which is what lets Android Auto
|
||||
list and open the app at all, and `SoulStreamPlayer` is a
|
||||
`SimpleBasePlayer` — media3's
|
||||
supported way to put a non-ExoPlayer engine behind a session, which is exactly
|
||||
this app's shape: rodio owns playback in Rust, and Kotlin only mirrors it and
|
||||
forwards the buttons. `getState()` is a pure read of `MediaHub.state` (the
|
||||
last `NowPlayingArgs` off the bridge, queue window included) and every
|
||||
transport call routes back out through `MediaHub.act` → `Plugin.trigger` →
|
||||
`src/mobile/media.svelte.js`, which did not change.
|
||||
`src/mobile/media.svelte.js`, which did not change.
|
||||
|
||||
The session, the player, the notification provider, the audio-focus request
|
||||
and the becoming-noisy receiver all live in `MediaHub` for the life of the
|
||||
**process**, and `MediaService.onCreate` allocates and registers nothing. That
|
||||
is not tidiness, it is androidx/media
|
||||
[#3158](https://github.com/androidx/media/issues/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, and it is 100% reproducible after a force-stop with
|
||||
no existing binding, which is exactly how Android Auto and
|
||||
`tools/auto-prober/` connect. The loop is Android Auto's to fix and media3
|
||||
has said it will not act first, so it cannot be fixed from here — only made
|
||||
harmless, by making a cycle cost nothing. Round forty-eight's
|
||||
`onCreate`/`onDestroy` would have built a platform session and a legacy
|
||||
browser stub thirty times a second, registered and unregistered a broadcast
|
||||
receiver sixty times a second, and abandoned audio focus thirty times a
|
||||
second *while music was playing*.
|
||||
|
||||
Three consequences, and each is a trap for the next round. `onDestroy`
|
||||
calls `removeSession`, 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 `SoulStreamPlayer.markReleased()`
|
||||
would latch `released = true` and leave a process-lifetime player that never
|
||||
updates the card again. The session is built with
|
||||
`MediaLibrarySession.Builder(applicationContext, …)` and not 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 at all any
|
||||
more, so it and its platform `MediaSessionCompat` live until the process
|
||||
dies; the walk's `one-session` check is what keeps that honest.
|
||||
|
||||
`MediaService.onCreate` logs `auto service created n=…` and `onDestroy` logs
|
||||
`auto service destroyed`. Those two lines are the only way to count the loop
|
||||
on a real phone — see "### The Auto prober".
|
||||
|
||||
Four things in that file are load-bearing and silent when they break. The
|
||||
playlist **must never be empty while a track exists**:
|
||||
@@ -1043,11 +1083,27 @@ through that session with the app in front (`media-keys`); ten seconds in the
|
||||
background with the position still advancing (`background`); the paused
|
||||
restore waited out past 25 s with the pid intact and no
|
||||
`ForegroundServiceDidNotStartInTimeException` (`paused-restore`); and the
|
||||
queue window arriving across the bridge, field for field (`queue-window`).
|
||||
Printed but never failed, because their `dumpsys` formats have never been read
|
||||
back from this phone: whether the service is foreground, whether our audio
|
||||
focus is in the system's stack, and whether the becoming-noisy receiver is
|
||||
registered (`media-plumbing`).
|
||||
queue window arriving across the bridge, field for field (`queue-window`);
|
||||
and both of the intent filters Android Auto knocks on — the legacy
|
||||
`android.media.browse.MediaBrowserService` and media3's own
|
||||
`androidx.media3.session.MediaLibraryService` — resolving to `MediaService`
|
||||
(`auto-browse`, `auto-library`), which is also the guard for a media-session
|
||||
plugin that compiled and never reached the APK: the service, its filters and
|
||||
the Auto meta-data live in one library manifest and are lost together, which
|
||||
this project has already been through once as a start-up
|
||||
`ClassNotFoundException`. Printed but never failed, because their `dumpsys`
|
||||
formats have never been read back from this phone: whether the service is
|
||||
foreground, whether our audio focus is in the system's stack, and whether the
|
||||
becoming-noisy receiver is registered (`media-plumbing`). Printed and never
|
||||
failed for a different reason — `pm dump` prints no application meta-data at
|
||||
all, so nothing on the phone can be asked about it — the
|
||||
`com.google.android.gms.car.application` declaration as `tools/auto-prober/`
|
||||
last resolved it (`auto-desc`).
|
||||
|
||||
**The browse tree is not proven here and cannot be.** With the app
|
||||
force-stopped there is no WebView, so no DevTools socket, so `smoke.mjs` has
|
||||
nothing to attach to; `tools/auto-prober/` is the instrument for that, and
|
||||
"### The Auto prober" under Verifying is where it lives.
|
||||
|
||||
**Kyle's hands only. Nothing here can be automated, and the walk going green
|
||||
says nothing about any of it.** Unplugging headphones pauses the music —
|
||||
@@ -2137,19 +2193,25 @@ read over real IPC; a cold artist page, its ⋯, the whole-discography page and
|
||||
its A–Z; the first-run preview with `setupWrites` at 0; eleven raw backend
|
||||
strings through `__ss.say`; and a refused transfer's sentence.
|
||||
|
||||
Then, after the step loop, ten things the mock cannot do at all: a force-stop
|
||||
and the paused restore that follows it (waited out past twenty-five seconds,
|
||||
which is well past the ten-second FGS-kill window), the position restored,
|
||||
playback resumed, the system media session and its ownership, media keys,
|
||||
exactly one card in the shade and exactly one session in the system's list —
|
||||
the two checks that prove the media3 migration landed rather than doubled —
|
||||
ten seconds in the background with the position still advancing, SoulStream's
|
||||
own mixer underruns, and the queue window that rides along with every card
|
||||
update and that the lock-screen card is now built out of. One more line
|
||||
prints and never fails: whether the service is foreground, whether our audio
|
||||
focus is in the system's stack, and whether the becoming-noisy receiver is
|
||||
registered. What none of it touches is in "What the walk proves about the
|
||||
card, and what it does not", above.
|
||||
Then, after the step loop, twelve things the mock cannot do at all: a
|
||||
force-stop and the paused restore that follows it (waited out past
|
||||
twenty-five seconds, which is well past the ten-second FGS-kill window), the
|
||||
position restored, playback resumed, the system media session and its
|
||||
ownership, media keys, exactly one card in the shade and exactly one session
|
||||
in the system's list — the two checks that prove the media3 migration landed
|
||||
rather than doubled — the two intent filters Android Auto knocks on, the
|
||||
legacy browse one and media3's own, both resolving to `MediaService`
|
||||
(`auto-browse`, `auto-library`: the catcher for a plugin module that
|
||||
compiled and never reached the APK, which is how this project once got a
|
||||
start-up `ClassNotFoundException`), ten seconds in the background with the
|
||||
position still advancing, SoulStream's own mixer underruns, and the queue
|
||||
window that rides along with every card update and that the lock-screen card
|
||||
is now built out of. Two more lines print and never fail: whether the service
|
||||
is foreground, whether our audio focus is in the system's stack and whether
|
||||
the becoming-noisy receiver is registered; and the Auto declaration as
|
||||
`tools/auto-prober/` last read it, because `pm dump` prints no application
|
||||
meta-data and nothing else on the phone can be asked. What none of it touches
|
||||
is in "What the walk proves about the card, and what it does not", above.
|
||||
|
||||
One block in the phone walk pulls bytes, and only when the app is signed in:
|
||||
the trailing network block types `--query` into Search, streams a peer's copy
|
||||
@@ -2213,6 +2275,79 @@ through Android's create-document sheet, which the walk only ever dismisses
|
||||
with BACK; the phone physically turned sideways; and a genuine first run on a
|
||||
device that has never had the app.
|
||||
|
||||
### The Auto prober
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
Android Auto reaches a media app through `MediaBrowserService`, with the
|
||||
app's process **cold** — no Activity, no WebView, and therefore no DevTools
|
||||
socket. `smoke.mjs` drives the phone over CDP, so with SoulStream
|
||||
force-stopped it has nothing to attach to and is blind to every bit of this.
|
||||
`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 prints one
|
||||
JSON object per event under the logcat tag `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/` deliberately — `npx tauri android
|
||||
init` would delete it — and borrows the generated project's Gradle wrapper
|
||||
rather than shipping one, which pins Gradle 8.14.3, AGP 8.11.0, Kotlin 1.9.25
|
||||
and JDK 17: what is already in the cache, so a run downloads nothing.
|
||||
`probe.sh` builds, installs, clears logcat, force-stops **SoulStream** (only;
|
||||
the walk already does the same), starts the prober by name, waits, and prints
|
||||
the log. No permissions beyond `<queries>`, no network, no storage, no
|
||||
transport command; every exit path disconnects and `onDestroy` disconnects
|
||||
again.
|
||||
|
||||
The events are `start`, `desc`, `filter`, `root`, `children`, `fail`, `done`.
|
||||
The number it exists for is `root`: **milliseconds from `connect()` to
|
||||
`onConnected()` with the process cold**, next to the root media id and
|
||||
whether a session token came with it. Auto gives a media app a few seconds
|
||||
and then drops it, which is why `onGetLibraryRoot` answers with
|
||||
`Futures.immediateFuture` and nothing else — `MediaLibraryServiceLegacyStub`
|
||||
blocks the binder thread on that future with no timeout.
|
||||
|
||||
`desc` is the half the walk cannot do. `adb shell pm dump` is `dumpsys
|
||||
package`: resolver tables, permissions, package state, and **no application
|
||||
meta-data at all** — so `auto-browse` and `auto-library` can prove the intent
|
||||
filters and nothing else. The prober resolves
|
||||
`com.google.android.gms.car.application` through
|
||||
`PackageManager.GET_META_DATA` and then opens that resource out of the app's
|
||||
own `Resources` and reads the `<uses name="media"/>` inside it, which is the
|
||||
only way to tell a real declaration from a dangling id short of unpacking the
|
||||
APK. The walk echoes the last such line back as the advisory `auto-desc`.
|
||||
|
||||
**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 exactly
|
||||
what this prober does — so it shows up here before it ever shows up in a car.
|
||||
`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. The counter is the instrument:
|
||||
|
||||
```bash
|
||||
adb logcat -d -s SoulStream:I | grep "auto service created" | tail -1
|
||||
```
|
||||
|
||||
One or two is normal. A number climbing while a probe or a car is connected
|
||||
is the bug, and it is worth watching across a LONG bind rather than a short
|
||||
one — the failure mode this guards against is an OOM hours in, not a crash at
|
||||
connect.
|
||||
|
||||
**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.
|
||||
|
||||
## Not done yet
|
||||
|
||||
Thirty rounds in, this is the honest map: what is deliberately absent, what
|
||||
@@ -2278,14 +2413,27 @@ symptom.
|
||||
the share sheet, which no walk reaches. Work down the smoke list in "The
|
||||
release build" before trusting a release APK on a phone.
|
||||
|
||||
- **Android Auto, half-built.** Round forty-eight put the card on
|
||||
`androidx.media3` — a `MediaSessionService` over a `SimpleBasePlayer` —
|
||||
which is the base a browsable service needs and the reason that round
|
||||
happened at all. What is missing is the browse tree: `MediaService` becomes
|
||||
a `MediaLibraryService`, `onGetLibraryRoot` and `onGetChildren` answer with
|
||||
the library's own shape, an `automotive_app_desc.xml` and its
|
||||
`<meta-data>` declare the app to Auto, and a tapped queue row maps back
|
||||
through `queueBase` to `playQueueAt()` (which is why
|
||||
`COMMAND_SET_MEDIA_ITEM` and its neighbours are deliberately NOT advertised
|
||||
yet). None of it can be walked from here: Auto needs the Desktop Head Unit
|
||||
or a car.
|
||||
- **Android Auto can see the app, and there is nothing inside.** Round
|
||||
forty-nine made `MediaService` a `MediaLibraryService`, put three actions
|
||||
on one intent filter (media3's `MediaLibraryService` and
|
||||
`MediaSessionService`, plus the legacy
|
||||
`android.media.browse.MediaBrowserService` that Auto actually binds), and
|
||||
added `res/xml/automotive_app_desc.xml` with the
|
||||
`com.google.android.gms.car.application` meta-data that declares the app to
|
||||
Auto. So Auto lists SoulStream, opens it, and gets a root — immediately,
|
||||
from `Futures.immediateFuture`, with nothing on the main thread — and the
|
||||
root is empty. `onGetChildren` is not overridden, so the default answers
|
||||
`ERROR_NOT_SUPPORTED` and the tree is one browsable folder with no rows in
|
||||
it. What is still missing is everything under it: the library read out of
|
||||
the database **read-only** from a process with no Activity and no Tauri
|
||||
(`crates/library` is plain rusqlite and `JNI_OnLoad` publishes the Context
|
||||
at `System.loadLibrary` time, so browse can work cold), playing what it
|
||||
lists — which needs `COMMAND_SET_MEDIA_ITEM` and its neighbours, still
|
||||
deliberately unadvertised, plus the `queueBase` → `playQueueAt()` mapping —
|
||||
and search. Catalog and Soulseek rows will never play from cold: the
|
||||
resolver is 872 lines of shared JS and Soulseek needs a live session, so
|
||||
they get `STATE_ERROR` with a resolution intent instead, which is also what
|
||||
Google's quality bar asks for. The walk proves the two front doors resolve
|
||||
(`auto-browse`, `auto-library`) and `tools/auto-prober/` proves the root
|
||||
comes back cold; the car screen itself still needs the Desktop Head Unit or
|
||||
a car.
|
||||
Reference in new issue
Block a user