f50cf2018a050b66502fcf1578b440a6c3d582c1
3
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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.
|
||
|
|
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. |