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

+180 -32
View File
@@ -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.
@@ -17,11 +17,13 @@
# rule of tauri's own names. See src-tauri/android/proguard-soulstream.pro.
#
# MediaService is declared in this module's AndroidManifest.xml, so AGP's
# own merged-manifest keep rules hold it by name, and SoulStreamPlayer needs
# no rule of its own — the only thing that reaches it is a direct reference
# from MediaService. androidx's MediaButtonReceiver went with the move to
# media3: the service's own SERVICE_INTERFACE filter routes media buttons
# now, and two receivers would have fought over them.
# own merged-manifest keep rules hold it by name, and SoulStreamPlayer,
# MediaHub and LibraryTree need no rule of their own — the only thing that
# reaches any of them is a direct reference from MediaService.
# androidx's MediaButtonReceiver went with the move to media3: the
# service's own filter routes media buttons now — through the
# MediaLibraryService action, which MediaSessionLegacyStub queries first —
# and two receivers would have fought over them.
-keep class com.soulstream.mediasession.MediaSessionPlugin { *; }
-keep class com.soulstream.mediasession.NowPlayingArgs { *; }
-keep class com.soulstream.mediasession.QueueItemArgs { *; }
@@ -16,18 +16,45 @@
android:name="com.soulstream.mediasession.MediaService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<!-- media3 finds its own service through this, and while the
process is alive it is what routes a headset or a card
button here. There is no MediaButtonReceiver any more, and
media3's own is deliberately NOT put in its place: its
docs make that receiver conditional on
MediaSession.Callback.onPlaybackResumption(), which this
app cannot implement — playback lives in Rust behind a
WebView round trip. Two receivers would only have fought
over the buttons. -->
<!-- Three actions, one component, and all three are safe.
Android Auto binds the LEGACY one
(android.media.browse.MediaBrowserService), which media3
still serves: MediaSessionService.onBind answers it with
the session's legacy browser binder. media3's own two are
how a MediaController and a SessionToken find this
service; SessionToken(Context, ComponentName) tests
MediaLibraryService FIRST, so the component is one
TYPE_LIBRARY_SERVICE token however many actions it wears,
and getAllServiceTokens collects into a Set.
The MediaSessionService action is KEPT rather than
replaced because the media-button path already verified on
this phone hangs off it: MediaSessionLegacyStub looks for
an ACTION_MEDIA_BUTTON receiver, finds none (there is no
MediaButtonReceiver any more, and media3's own is
deliberately not in its place — its docs make it
conditional on MediaSession.Callback.onPlaybackResumption(),
which this app cannot implement because playback lives in
Rust behind a WebView round trip), and falls to a service
declared under MediaLibraryService and then
MediaSessionService. Either way it lands here.
android:exported is true above for the same reason the
filters are here: a browse service Auto cannot bind is a
browse service that does not exist. -->
<intent-filter>
<action android:name="androidx.media3.session.MediaLibraryService" />
<action android:name="androidx.media3.session.MediaSessionService" />
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<!-- What puts SoulStream in Android Auto's app list at all.
The resource is res/xml/automotive_app_desc.xml and the
reference is resolved at LINK time, so a missing file fails
the build rather than shipping a car-invisible app. -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
</application>
</manifest>
@@ -25,16 +25,23 @@ import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaLibraryService.LibraryParams
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import java.io.ByteArrayOutputStream
/**
* What is playing, shared between the plugin (which hears the WebView) and
* the service (which owns the session). The service is BOUND on the first
* update and refreshed on every one after.
* the service (which owns the session) — and, since round forty-nine, the
* owner of everything that must outlive the service.
*
* Bound, not started, and that is half the safety story of this file.
* `MediaSessionService.onStartCommand` promotes the service for a media
@@ -53,7 +60,50 @@ import java.io.ByteArrayOutputStream
* The other half is [MediaService.onStartCommand], because the SYSTEM starts
* this service in the foreground on a button press whether media3 then wants
* it there or not.
*
* ── WHY THE SESSION, THE PLAYER, THE FOCUS AND THE RECEIVER LIVE HERE ────
*
* androidx/media issue #3158, open since 2026-04-03 and still open when this
* round was written (last confirmed 2026-09-04, reproduced on media3 1.11.0):
* when a host binds a `MediaLibraryService` with nothing playing, the service
* runs `onCreate → onBind → onGetSession → onConnect → onDestroy` at about
* thirty cycles a second and keeps doing it. It is 100% reproducible after a
* force-stop with no existing binding, which is exactly what Android Auto
* does on a fresh connection — and exactly what `tools/auto-prober/` does.
* marcbaechinger's answer on the issue is that this is the Service contract
* (the last client unbinds, nothing called `onStartCommand`, so the system
* destroys it) and that the fix is being prepared on the Android Auto side;
* media3 will not act first. So it cannot be fixed from here — only made
* harmless.
*
* Made harmless by allocating NOTHING per cycle. Everything below the line
* is built once per PROCESS and never released: one `MediaLibrarySession`,
* one [SoulStreamPlayer], one notification provider, one audio-focus
* request, one becoming-noisy receiver, one notification channel. What round
* forty-eight did in `onCreate`/`onDestroy` would, under that loop, have
* built a platform session and a legacy browser stub thirty times a second
* (the exact retention the issue names), registered and unregistered a
* broadcast receiver sixty times a second ("Too many receivers registered"),
* and — worst, because it is audible — abandoned audio focus thirty times a
* second WHILE MUSIC WAS PLAYING.
*
* The session is built against `applicationContext` and not the Service, and
* that is not tidiness: `MediaSessionImpl.getLegacyBrowserServiceBinder()`
* hands the session's own context to
* `MediaSessionServiceLegacyStub.initialize()`, which calls
* `attachToBaseContext(sessionImpl.getContext())`. A session built against
* the Service therefore pins a destroyed Service as the base context of a
* live `MediaBrowserServiceCompat` — which is the leak comment 9 of #3158
* describes from the other end.
*
* The counter is the instrument. `MediaService.onCreate` logs
* `auto service created n=…`; the last such line in `adb logcat -d -s
* SoulStream:I` is how many times this process has built the service, and a
* number that climbs during a bind is #3158 firing. Without it an
* unmitigated version of this bug reaches Kyle as an unexplained OOM in a
* car, hours later.
*/
@OptIn(markerClass = [UnstableApi::class])
object MediaHub {
@Volatile var onAction: ((String, Double) -> Unit)? = null
@Volatile var state: NowPlayingArgs? = null
@@ -65,6 +115,18 @@ object MediaHub {
/** the app's own icon, encoded once — see appIcon() */
@Volatile private var icon: ByteArray? = null
/* ── the process-lifetime half: built once, never released ────────── */
@Volatile private var libSession: MediaLibrarySession? = null
@Volatile private var noteProvider: DefaultMediaNotificationProvider? = null
@Volatile private var focus: AudioFocusRequest? = null
@Volatile private var pausedByFocus = false
@Volatile private var noisyOn = false
@Volatile private var channelDone = false
/** how many `MediaService` instances this process has built. See the
* class comment: this is the #3158 instrument. */
@Volatile var creations = 0
private set
private val mainHandler = Handler(Looper.getMainLooper())
@Volatile private var bound = false
private val conn = object : ServiceConnection {
@@ -73,7 +135,36 @@ object MediaHub {
override fun onServiceDisconnected(name: ComponentName?) {}
}
private val noisy = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) act("pause")
}
}
private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
when (change) {
AudioManager.AUDIOFOCUS_LOSS -> {
pausedByFocus = false
act("pause")
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
if (state?.playing == true) {
pausedByFocus = true
act("pause")
}
}
AudioManager.AUDIOFOCUS_GAIN -> {
if (pausedByFocus) {
pausedByFocus = false
act("play")
}
}
/* CAN_DUCK: the framework ducks us on its own */
}
}
fun update(ctx: Context, np: NowPlayingArgs) {
val app = ctx.applicationContext
if (np.artKey != artKey) {
artKey = np.artKey
artBytes = null
@@ -86,14 +177,18 @@ object MediaHub {
}
}
state = np
bind(ctx)
bind(app)
/* ALWAYS on the main looper. Every SimpleBasePlayer entry point calls
verifyApplicationThread(), a Tauri @Command is not promised to run
on the main thread, and the IllegalStateException that follows
would be swallowed by media.svelte.js's own `.catch(() => {})` —
the card would just quietly stop updating, with nothing in any log. */
mainHandler.post {
service?.syncFocus(np.playing)
/* focus is no longer the service's, so it no longer waits for
one. Round forty-eight had to catch this up from `onCreate`,
because the update that MADE the service found `service` still
null; there is nothing to catch up now. */
if (np.playing) requestFocus(app)
player?.let {
it.settle()
it.refresh()
@@ -106,14 +201,15 @@ object MediaHub {
* timeline) and to step out of the foreground. The service is left
* alive and the session left registered ON PURPOSE: a headset button
* should still reach us with nothing on the bar, and re-creating the
* service is the expensive half. Audio focus IS given back, which the
* old service did by dying. */
* service is the expensive half. Audio focus IS given back — by this
* object rather than by a service that may not exist. */
fun clear(ctx: Context) {
val app = ctx.applicationContext
state = null
artBytes = null
artKey = null
mainHandler.post {
service?.dropFocus()
dropFocus(app)
player?.let {
it.settle()
it.refresh()
@@ -126,11 +222,14 @@ object MediaHub {
private fun bind(ctx: Context) {
if (bound) return
try {
/* the action matters: MediaSessionService.onBind answers only for
SERVICE_INTERFACE and returns null for a bare intent, and
BIND_AUTO_CREATE is what creates the service at all */
/* The action matters: `onBind` answers only for an action it
knows and returns null for a bare intent. Three are declared
in the manifest now and any of them would do — the component
is explicit, so no filter is consulted — and the LibraryService
one is named here because that is what this service is.
BIND_AUTO_CREATE is what creates the service at all. */
val intent = Intent(ctx, MediaService::class.java)
.setAction(MediaSessionService.SERVICE_INTERFACE)
.setAction(MediaLibraryService.SERVICE_INTERFACE)
bound = ctx.bindService(intent, conn, Context.BIND_AUTO_CREATE)
if (!bound) {
android.util.Log.w("SoulStream", "media service would not bind")
@@ -141,6 +240,153 @@ object MediaHub {
}
}
/* ── the singletons ──────────────────────────────────────────────── */
/** The one session this process will ever have, and the one player under
* it. Built on the first `MediaService.onCreate` and handed back to
* every one after it — which is the whole #3158 mitigation.
*
* `MediaLibrarySession.Builder(Context, Player, Callback)` rather than
* the `(MediaLibraryService, …)` overload, which is a one-line delegate
* to it (`this((Context) service, player, callback)`) that exists only
* to keep construction inside a service. The Context overload is
* @UnstableApi; this file is already opted in, and see the class
* comment for why a Service context is the wrong thing to pin here.
*
* Main thread only in practice: `onCreate` runs there and always before
* any bind, so by the time `onGetSession` asks, this is a pure read. */
@Synchronized
fun session(app: Context): MediaLibrarySession {
libSession?.let { return it }
val p = SoulStreamPlayer(app)
player = p
val builder = MediaLibrarySession.Builder(app, p, LibraryTree).setId("SoulStream")
val launch = app.packageManager.getLaunchIntentForPackage(app.packageName)
if (launch != null) {
builder.setSessionActivity(
PendingIntent.getActivity(
app, 0, launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
}
val s = builder.build()
libSession = s
return s
}
/** The session if one was ever built, without building one. `onDestroy`
* and `holdForeground` both want exactly this. */
fun sessionOrNull(): MediaLibrarySession? = libSession
/** The notification provider, also once. It is configuration and a
* Context and nothing else, so one instance serves every service
* instance; the `MediaNotificationManager` that READS it is media3's
* own and is per-service, which is why `setMediaNotificationProvider`
* still runs in every `onCreate`. */
@Synchronized
fun provider(app: Context): DefaultMediaNotificationProvider {
noteProvider?.let { return it }
val pr = DefaultMediaNotificationProvider.Builder(app)
.setChannelId(MediaService.CHANNEL)
.setChannelName(R.string.ss_playback_channel)
.setNotificationId(MediaService.NOTIFICATION_ID)
.build()
pr.setSmallIcon(R.drawable.ic_soulstream_note)
noteProvider = pr
return pr
}
/** Ours first. media3 would create the channel itself, at IMPORTANCE_LOW
* and nothing else — no lockscreenVisibility, and setShowBadge only
* below API 28 — so on a fresh install the card would be hidden behind
* "sensitive notifications". `ensureNotificationChannel` leaves an
* existing channel alone, so getting in first is the whole fix.
*
* Latched, and the latch is set BEFORE the work: this used to run in
* every `onCreate`, which under #3158 is thirty `NotificationManager`
* round trips a second for a channel that already exists. */
@Synchronized
fun ensureChannel(app: Context) {
if (channelDone) return
channelDone = true
if (Build.VERSION.SDK_INT < 26) return
try {
val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(MediaService.CHANNEL) != null) return
val channel =
NotificationChannel(MediaService.CHANNEL, "Playback", NotificationManager.IMPORTANCE_LOW)
channel.description = "What is playing"
channel.setShowBadge(false)
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
manager.createNotificationChannel(channel)
} catch (e: Exception) {
android.util.Log.w("SoulStream", "playback channel could not be made: $e")
}
}
/** Headphones out pauses. Registered ONCE, on the application context,
* and never unregistered — the process dying is what takes it down.
* A system broadcast reaches a non-exported receiver; Android 14 wants
* the flag said out loud. */
@Synchronized
fun ensureNoisy(app: Context) {
if (noisyOn) return
noisyOn = true
try {
ContextCompat.registerReceiver(
app, noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
} catch (e: Exception) {
noisyOn = false
android.util.Log.w("SoulStream", "noisy receiver would not register: $e")
}
}
/** Focus is asked for when something starts playing and kept until the
* bar empties. It is NOT given back when the service dies, which is the
* point: under #3158 the service dies thirty times a second and the
* music is still playing. */
@Synchronized
fun requestFocus(app: Context) {
if (focus != null) return
val manager = app.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build(),
)
.setOnAudioFocusChangeListener(focusListener)
.build()
if (manager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) focus = request
}
/** The bar emptied. The pre-media3 service gave focus back by dying;
* nothing dies on purpose any more, so it is given back by hand. */
@Synchronized
fun dropFocus(app: Context) {
pausedByFocus = false
val request = focus ?: return
try {
(app.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
.abandonAudioFocusRequest(request)
} catch (e: Exception) {
android.util.Log.w("SoulStream", "focus would not go back: $e")
}
focus = null
}
/** One more `MediaService` exists. Returns the running total, which is
* what the log line carries. */
@Synchronized
fun noteCreation(): Int {
creations += 1
return creations
}
/** The app's own icon, encoded ONCE and kept, as the card's artwork when
* there is no cover yet. `artBytes` is cleared the moment `artKey`
* changes and only refilled when the frontend has finished decoding a
@@ -196,28 +442,128 @@ object MediaHub {
}
/**
* The media3 session service: a `MediaSession` over [SoulStreamPlayer], the
* card on the lock screen and in the shade, audio focus (pause when another
* app takes the output, or the headphones come out), and the reason the
* process is not frozen in the background. The audio never passes through
* here.
* Everything Android Auto (or any other `MediaBrowser`) is allowed to ask
* this app for. Round forty-nine answers exactly one question — where the
* tree starts — and nothing under it.
*
* media3 posts and owns the notification now, and promotes and demotes the
* service around it — there is no `buildNotification` and no
* `MediaButtonReceiver` in this file any more, and the manifest's
* `androidx.media3.session.MediaSessionService` filter is what routes a
* headset button here while the process is alive.
* A named object rather than an anonymous one because the rounds after this
* fill it in: `onGetChildren` is the tree, `onGetItem` a single row,
* `onSearch`/`onGetSearchResult` the voice query. Each of those has a
* default on `MediaLibrarySession.Callback` that answers
* `LibraryResult.ofError(ERROR_NOT_SUPPORTED)` immediately, so leaving them
* un-overridden is a root with nothing under it and no hang anywhere.
*
* Focus and becoming-noisy are still hand-rolled, and that is not an
* oversight. `setAudioAttributes(attrs, handleAudioFocus = true)` and
* `setHandleAudioBecomingNoisy` are ExoPlayer conveniences; on a custom
* `Player` the first lands in `handleSetAudioAttributes`, whose default body
* throws, and the second does not exist. Deleting these blocks would lose
* "another app takes the output → we pause" and "headphones out → we pause"
* with nothing failing anywhere and no walk able to catch it.
* `onConnect` is deliberately NOT overridden either.
* `ConnectionResult.AcceptedResultBuilder` defaults a `MediaLibrarySession`
* to `DEFAULT_SESSION_AND_LIBRARY_COMMANDS`, and that is what carries
* `COMMAND_CODE_LIBRARY_GET_LIBRARY_ROOT`. Without it
* `MediaLibraryServiceLegacyStub.onGetRoot` returns null before it ever
* reaches [onGetLibraryRoot] and Auto retries six times with backoff and
* gives up. If a package validator is ever added here it has to be additive,
* and it has to keep accepting `tools/auto-prober/`.
*/
@OptIn(markerClass = [UnstableApi::class])
class MediaService : MediaSessionService() {
object LibraryTree : MediaLibrarySession.Callback {
/** The id of the root node. Everything the later rounds hang under the
* tree is named relative to this; `ss:` keeps our ids from colliding
* with a media id that happens to be a file path. */
const val ROOT = "ss:root"
/**
* IMMEDIATE, UNCONDITIONALLY, AND THAT IS NOT A STYLE CHOICE.
*
* Android Auto reaches this through the legacy browser path, and
* `MediaLibraryServiceLegacyStub.onGetRoot` (media3 1.10.1, lines
* 100-115) posts this callback to the session's application handler and
* then does `haveFuture.block()` followed by `future.get()` with NO
* timeout — on the binder thread serving the host. So a future that
* waits on anything is main-thread I/O and a blocked binder thread at
* the same time: an ANR, and a host that drops us.
* `Futures.immediateFuture` is the only correct answer, and there must
* be no branch in here that can reach a file, a database or a lock.
*
* The log line is the other half of `tools/auto-prober/`: the prober
* puts Auto's own root hints into the bind,
* `LegacyConversions.convertToLibraryParams` turns them into the
* [LibraryParams] read here, and these three booleans are what came out
* the far end. `isRecent` is the
* separate connection a host makes to ask for playback resumption — the
* documented pattern is to REFUSE that root when resumption is not
* supported, which this app cannot support (playback lives in Rust
* behind a WebView round trip). Round forty-nine does not take that
* decision; it records what Auto actually asks for, so the round that
* fills the tree decides it from evidence.
*/
override fun onGetLibraryRoot(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
params: LibraryParams?,
): ListenableFuture<LibraryResult<MediaItem>> {
android.util.Log.i(
"SoulStream",
"auto root from=" + browser.packageName + " uid=" + browser.uid +
" recent=" + (params?.isRecent ?: false) +
" offline=" + (params?.isOffline ?: false) +
" suggested=" + (params?.isSuggested ?: false),
)
return Futures.immediateFuture(LibraryResult.ofItem(rootItem(), params))
}
/** A browsable, non-playable folder with nothing in it yet. Browsable
* and not playable is what makes a host descend into it rather than try
* to play it; MEDIA_TYPE_FOLDER_MIXED says the children will not all be
* one kind, which is what the library, the playlists and the queue will
* be once they are there. */
private fun rootItem(): MediaItem =
MediaItem.Builder()
.setMediaId(ROOT)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("SoulStream")
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
.build(),
)
.build()
}
/**
* The media3 library service: a browsable `MediaLibrarySession` over
* [SoulStreamPlayer], the card on the lock screen and in the shade, and the
* reason the process is not frozen in the background. The audio never passes
* through here.
*
* A `MediaLibraryService` since round forty-nine, which is what lets Android
* Auto list and open the app at all: `MediaLibraryService` extends
* `MediaSessionService`, and its `onBind` serves the legacy
* `android.media.browse.MediaBrowserService` action that Auto actually binds
* as well as media3's own two. The manifest declares all three on one
* filter.
*
* THIS CLASS ALLOCATES NOTHING AND REGISTERS NOTHING OF ITS OWN. Every
* durable thing it needs lives in [MediaHub] for the life of the process —
* see the comment there, and androidx/media #3158. Re-introducing a `new`
* anywhere in `onCreate`, or a `release()` anywhere in `onDestroy`, silently
* restores the bug; `removeSession` is the correct way to detach a session
* that outlives its service, and it is what keeps a destroyed instance's
* `MediaSessionListener` from being called by a `refresh()` that lands in
* the gap between one destroy and the next create.
*
* media3 posts and owns the notification, and promotes and demotes the
* service around it — there is no `buildNotification` and no
* `MediaButtonReceiver` in this file.
*
* Focus and becoming-noisy are still hand-rolled (in [MediaHub] now), and
* that is not an oversight. `setAudioAttributes(attrs, handleAudioFocus =
* true)` and `setHandleAudioBecomingNoisy` are ExoPlayer conveniences; on a
* custom `Player` the first lands in `handleSetAudioAttributes`, whose
* default body throws, and the second does not exist. Deleting those blocks
* would lose "another app takes the output → we pause" and "headphones out →
* we pause" with nothing failing anywhere and no walk able to catch it.
*/
@OptIn(markerClass = [UnstableApi::class])
class MediaService : MediaLibraryService() {
companion object {
/** Literally "playback", and load-bearing twice over: the phone
* walk's `media-card` check finds the card by this channel id, and
@@ -233,97 +579,38 @@ class MediaService : MediaSessionService() {
const val HOLD_NOTIFICATION_ID = 3
}
private var session: MediaSession? = null
private var player: SoulStreamPlayer? = null
private var focus: AudioFocusRequest? = null
private var pausedByFocus = false
private var noisyRegistered = false
private val noisy = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) MediaHub.act("pause")
}
}
private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
when (change) {
AudioManager.AUDIOFOCUS_LOSS -> {
pausedByFocus = false
MediaHub.act("pause")
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
if (MediaHub.state?.playing == true) {
pausedByFocus = true
MediaHub.act("pause")
}
}
AudioManager.AUDIOFOCUS_GAIN -> {
if (pausedByFocus) {
pausedByFocus = false
MediaHub.act("play")
}
}
/* CAN_DUCK: the framework ducks us on its own */
}
}
override fun onCreate() {
super.onCreate()
/* ours first. media3 would create the channel itself, at
IMPORTANCE_LOW and nothing else — no lockscreenVisibility, and
setShowBadge only below API 28 — so on a fresh install the card
would be hidden behind "sensitive notifications". ensureNotification
Channel leaves an existing channel alone, so getting in first is
the whole fix. */
createChannel()
val p = SoulStreamPlayer(applicationContext)
player = p
MediaHub.player = p
val app = applicationContext
/* all four of these are no-ops after the first time in this process */
MediaHub.ensureChannel(app)
val s = MediaHub.session(app)
MediaHub.ensureNoisy(app)
/* BEFORE addSession, and the order is real: addSession spins up the
internal notification controller that reads this provider, and both
calls post to the same main handler (inline, from here) in order. */
val provider = DefaultMediaNotificationProvider.Builder(this)
.setChannelId(CHANNEL)
.setChannelName(R.string.ss_playback_channel)
.setNotificationId(NOTIFICATION_ID)
.build()
provider.setSmallIcon(R.drawable.ic_soulstream_note)
setMediaNotificationProvider(provider)
val builder = MediaSession.Builder(this, p).setId("SoulStream")
val launch = packageManager.getLaunchIntentForPackage(packageName)
if (launch != null) {
builder.setSessionActivity(
PendingIntent.getActivity(
this, 0, launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
}
val s = builder.build()
session = s
calls post to the same main handler (inline, from here) in order.
Both DO run per instance: the provider is shared, the
MediaNotificationManager that reads it is media3's own and belongs
to this service. */
setMediaNotificationProvider(MediaHub.provider(app))
/* Re-adding the same session to a fresh service is exactly what the
API is for: addSession keys on the session id, finds nothing in
THIS instance's map, and registers a new MediaSessionListener and
notification controller for it. */
addSession(s)
/* a system broadcast reaches a non-exported receiver; Android 14 wants
the flag said out loud */
ContextCompat.registerReceiver(
this, noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
noisyRegistered = true
MediaHub.service = this
/* The update that MADE this service is already sitting in MediaHub,
and its own post to the main looper found `service` still null —
bindService creates the service a round trip later. Without this
line the focus request waits for whatever update happens to come
next (a status change, or the thirty-second top-up), and the first
track of a cold start plays with no focus held. */
if (MediaHub.state?.playing == true) requestFocus()
if (MediaHub.state?.playing == true) MediaHub.requestFocus(app)
/* The #3158 counter. `n=` climbing during a bind is the bug firing;
see MediaHub's comment for how to read it. */
val n = MediaHub.noteCreation()
android.util.Log.i("SoulStream", "auto service created n=" + n + " reused=" + (n > 1))
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session
/** Never null, on purpose: `MediaSessionService.onBind` returns null to
* a legacy browser when this answers null, and a null there is Auto
* failing to connect with nothing in any log. */
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
MediaHub.session(applicationContext)
/* ── the foreground contract media3 does not keep for us ──────────────
*
@@ -334,11 +621,14 @@ class MediaService : MediaSessionService() {
* `PendingIntent.getForegroundService` (SDK 26+), unconditionally, for
* every key. It resolves a service because there is no BroadcastReceiver
* any more: it asks the PackageManager for an ACTION_MEDIA_BUTTON
* receiver first, finds none, and falls to the manifest's
* `androidx.media3.session.MediaSessionService` filter. So a headset
* key, a lock-screen button and anything else the framework routes to
* the session arms Android's ten-second `startForeground()` timer — the
* same timer that took a Save down with the process on 2026-09-13.
* receiver first, finds none, and falls to a service declared under
* `androidx.media3.session.MediaLibraryService` — which it queries
* BEFORE `MediaSessionService`, and which this service now declares.
* (Both are in the manifest; whichever it picks is this component.) So a
* headset key, a lock-screen button and anything else the framework
* routes to the session arms Android's ten-second `startForeground()`
* timer — the same timer that took a Save down with the process on
* 2026-09-13.
*
* The CARD's own buttons are milder: `DefaultActionFactory` sets
* `setStartAsForegroundService(!player.playWhenReady)`, but
@@ -394,7 +684,7 @@ class MediaService : MediaSessionService() {
}
private fun holdForeground() {
val s = session
val s = MediaHub.sessionOrNull()
if (s != null && MediaHub.state != null) {
try {
/* the boolean is startInForegroundRequired */
@@ -441,70 +731,39 @@ class MediaService : MediaSessionService() {
.build()
}
/**
* DETACH, NEVER RELEASE. Four lines round forty-eight had here are gone
* and none of them may come back:
*
* · `unregisterReceiver(noisy)` — under #3158 that is sixty binder
* calls a second and the "Too many receivers registered" crash the
* issue reports. The receiver is MediaHub's now and lives as long as
* the process.
* · `abandonFocus()` — thirty focus drops a second while music plays.
* Audible, and the most user-visible half of the bug.
* · `player.release()` + `markReleased()` — `markReleased` latches
* `released = true` on SoulStreamPlayer, after which `refresh()` and
* `awaitUpdate()` are permanent no-ops. On a per-service player that
* was correct; on a process-lifetime one it is a card that never
* updates again after the first destroy.
* · `session.release()` — the session outlives the service on purpose.
*
* `removeSession` is what replaces all of it: it drops the session from
* THIS instance's map and posts `getMediaNotificationManager()
* .removeSession(session); session.clearListener()`, which is precisely
* the "stop calling a dead service back" that `release()` was doing by
* accident. It throws `checkArgument(sessions.containsKey(id))` when the
* session was never added, hence the guard.
*/
override fun onDestroy() {
if (noisyRegistered) {
try { unregisterReceiver(noisy) } catch (_: Exception) {}
noisyRegistered = false
val s = MediaHub.sessionOrNull()
if (s != null && isSessionAdded(s)) {
try { removeSession(s) } catch (e: Exception) {
android.util.Log.w("SoulStream", "session would not detach: $e")
}
}
abandonFocus()
/* the player first, then the session — media3's own order. `release()`
is SimpleBasePlayer's, needs COMMAND_RELEASE (advertised) and the
main thread (onDestroy runs there); markReleased() is our own latch
so a refresh() that slipped through MediaHub does nothing. */
player?.release()
player?.markReleased()
session?.release()
session = null
player = null
MediaHub.player = null
MediaHub.service = null
android.util.Log.i("SoulStream", "auto service destroyed")
super.onDestroy()
}
/** Focus is asked for when something starts playing and kept until the
* bar empties or the service dies — called from MediaHub's own post to
* the main looper, so it rides the same thread everything else here
* does. */
fun syncFocus(playing: Boolean) {
if (playing) requestFocus()
}
/** The bar emptied. The old service gave focus back by dying; this one
* outlives the music, so it gives it back by hand. */
fun dropFocus() {
pausedByFocus = false
abandonFocus()
}
private fun createChannel() {
if (Build.VERSION.SDK_INT < 26) return
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL) != null) return
val channel = NotificationChannel(CHANNEL, "Playback", NotificationManager.IMPORTANCE_LOW)
channel.description = "What is playing"
channel.setShowBadge(false)
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
manager.createNotificationChannel(channel)
}
private fun requestFocus() {
if (focus != null) return
val manager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build(),
)
.setOnAudioFocusChangeListener(focusListener)
.build()
if (manager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) focus = request
}
private fun abandonFocus() {
val request = focus ?: return
(getSystemService(Context.AUDIO_SERVICE) as AudioManager).abandonAudioFocusRequest(request)
focus = null
}
}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- What Android Auto reads to decide this is a media app it can host.
Named by the <meta-data android:name="com.google.android.gms.car.
application"> in this module's AndroidManifest.xml; "media" is the
whole declaration (the other value, "template", is for a car app
built on the Car App Library, which this is not). -->
<automotiveApp>
<uses name="media" />
</automotiveApp>
+89
View File
@@ -0,0 +1,89 @@
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.
+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);
+4
View File
@@ -0,0 +1,4 @@
# the prober is source; its build output and its Gradle state are not
build/
.gradle/
local.properties
+94
View File
@@ -0,0 +1,94 @@
# 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
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.
+21
View File
@@ -0,0 +1,21 @@
/* Copied in shape from src-tauri/gen/android/build.gradle.kts, versions
included: AGP 8.11.0 and Kotlin 1.9.25 are what the app is built with
and what is already in Kyle's Gradle cache. buildscript{} rather than
plugins{} for the same reason it is that way over there. */
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.11.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Build, install and run the Android Auto prober, then print what it saw.
#
# bash tools/auto-prober/probe.sh # depth 2, the ordinary root
# bash tools/auto-prober/probe.sh 3 true # depth 3, the RECENT root
#
# This installs a SECOND app on the phone — com.soulstream.autoprober, about
# 50 KB, no permissions, no network, no storage, no UI. `adb uninstall
# com.soulstream.autoprober` removes it completely. It never signs in,
# downloads, joins a room, writes the config or touches the library; the only
# thing it force-stops is SoulStream, which the phone walk already does.
#
# Gradle comes from the GENERATED project's wrapper on purpose: that pins
# Gradle 8.14.3, AGP 8.11.0, Kotlin 1.9.25 and JDK 17, all of which are
# already in Kyle's Gradle cache, so this downloads nothing new. There is no
# gradle-wrapper.jar in this directory and there should not be one.
set -euo pipefail
ROOT=/f/Coding/SoulStream
cd "$ROOT"
source src-tauri/android/env.sh
DEPTH="${1:-2}"
RECENT="${2:-false}"
APK=tools/auto-prober/prober/build/outputs/apk/debug/prober-debug.apk
GRADLEW="$ROOT/src-tauri/gen/android/gradlew.bat"
test -f "$GRADLEW" || { echo "no generated Android project — run a normal build first"; exit 1; }
echo "== this installs com.soulstream.autoprober on the phone (adb uninstall com.soulstream.autoprober removes it)"
echo "== gradle $(date +%T)"
(
cd tools/auto-prober
MSYS_NO_PATHCONV=1 "$GRADLEW" :prober:assembleDebug -q 2>&1 | grep -v "^$" | tail -20 || true
)
test -f "$APK" || { echo "no prober APK — gradle's tail is above"; exit 1; }
ls -la "$APK"
echo "== install $(date +%T)"
"$ADB" install -r "$APK" > /dev/null
echo "== cold: force-stopping SoulStream $(date +%T)"
"$ADB" logcat -c
"$ADB" shell am force-stop com.soulstream.app
"$ADB" shell am start -W -n com.soulstream.autoprober/.ProbeActivity \
--ei depth "$DEPTH" --ez recent "$RECENT" > /dev/null
sleep 7
echo
echo "== the probe (one JSON object per event)"
"$ADB" logcat -d -s SoulAutoProbe:I
echo
echo "== the root, as the service saw it"
"$ADB" logcat -d -s SoulStream:I | grep "auto root" | tail -3 || echo " (no root line — the callback was never reached)"
# androidx/media #3158: a host that binds with nothing playing can put the
# service in a create/destroy loop at ~30 Hz. The singleton in MediaHub means
# nothing is allocated per cycle, but the loop itself is Android Auto's bug
# to fix — so this number is the thing to watch, and it is why those two
# lifecycle log lines exist at all. One or two is normal.
echo
echo "== androidx/media #3158: how many times the service was built"
"$ADB" logcat -d -s SoulStream:I | grep "auto service created" | tail -1 \
|| echo " (no creation line — the service was never built)"
+34
View File
@@ -0,0 +1,34 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.soulstream.autoprober"
compileSdk = 36
defaultConfig {
applicationId = "com.soulstream.autoprober"
// 26 and 36 to match the app: package visibility (the <queries>
// block in the manifest) only behaves like the real thing at a
// targetSdk that has it, and a prober that can see more than
// Android Auto can would prove the wrong thing.
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
// NO dependencies, deliberately. android.media.browse.MediaBrowser is the
// framework class Android Auto itself uses to reach a media3 app, and a
// prober built out of media3 would not prove that path works. The Kotlin
// plugin adds its own stdlib and nothing else comes along.
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- WITHOUT THIS THE PROBER MEASURES NOTHING AND LOOKS LIKE IT DID.
targetSdk 30+ hides every other package from this one, so
queryIntentServices() comes back empty and MediaBrowser.connect()
fails with nothing useful in the log — which is indistinguishable
from "SoulStream has no browse service", i.e. a false report about
the one thing this app exists to measure. media3's own
SessionToken.java carries the note "We ask the app to declare the
<queries> tags, so it's expected that they are missing".
Both forms: the package by name for the bind, the action for the
PackageManager questions in ProbeActivity.filters(). -->
<queries>
<package android:name="com.soulstream.app" />
<intent>
<action android:name="android.media.browse.MediaBrowserService" />
</intent>
</queries>
<application
android:allowBackup="false"
android:label="SoulStream Auto prober">
<!-- No launcher filter and no UI: it is started by name
(`am start -n com.soulstream.autoprober/.ProbeActivity`), which
is why it must be exported. noHistory and excludeFromRecents
keep it out of the recents list Kyle actually uses. -->
<activity
android:name=".ProbeActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
</application>
</manifest>
@@ -0,0 +1,292 @@
package com.soulstream.autoprober
import android.app.Activity
import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
import android.media.browse.MediaBrowser
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import org.xmlpull.v1.XmlPullParser
/**
* The only eye this project has on Android Auto without a car.
*
* Auto talks to a media app through `MediaBrowserService`. With SoulStream
* force-stopped there is no Activity, no WebView and therefore no DevTools
* socket, so `tests/mobile/smoke.mjs` — which drives the app over CDP — is
* blind to every bit of it. This app 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 a single logcat tag, so the whole thing can be read
* with `adb logcat -d -s SoulAutoProbe:I`.
*
* Framework classes only — `android.media.browse.MediaBrowser`, not
* androidx, not media3. That is deliberate: it is the path Android 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 is probing would not prove that path works.
*
* It changes nothing on the phone. No permissions, no network, no storage,
* no playback command. It force-stops nothing itself; `probe.sh` does that,
* to SoulStream only, exactly as the phone walk already does. Every exit
* path disconnects, and `onDestroy` disconnects again, so no binding is left
* hanging on Kyle's phone if a step throws.
*
* Run it through `tools/auto-prober/probe.sh`, which builds, installs,
* clears logcat, force-stops SoulStream and starts this with the right
* extras.
*/
class ProbeActivity : Activity() {
companion object {
private const val TAG = "SoulAutoProbe"
private const val PKG = "com.soulstream.app"
private const val SVC = "com.soulstream.mediasession.MediaService"
private const val BROWSE = "android.media.browse.MediaBrowserService"
private const val LIBRARY = "androidx.media3.session.MediaLibraryService"
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
/** Longer than Auto's own patience and shorter than a person's. A
* root that has not come back by now is the failure this whole app
* exists to catch. */
private const val WATCHDOG_MS = 8000L
}
private val main = Handler(Looper.getMainLooper())
private var browser: MediaBrowser? = null
private var t0 = 0L
private var depth = 2
/** subscriptions asked for and not yet answered; zero means the walk is
* over. Main thread only — every callback lands there. */
private var pending = 0
private var nodes = 0
private var leaves = 0
private var finished = false
private val subscribed = HashSet<String>()
private val watchdog = Runnable { fail("watchdog after " + WATCHDOG_MS + "ms") }
private fun ms() = System.currentTimeMillis() - t0
private fun say(o: JSONObject) { Log.i(TAG, o.toString()) }
override fun onCreate(saved: Bundle?) {
super.onCreate(saved)
t0 = System.currentTimeMillis()
depth = intent?.getIntExtra("depth", 2) ?: 2
val recent = intent?.getBooleanExtra("recent", false) ?: false
say(
JSONObject().put("ev", "start").put("pkg", PKG).put("svc", SVC)
.put("depth", depth).put("recent", recent),
)
describe()
filters()
/* Auto's own root hints, spelled the way the framework spells them.
media3's LegacyConversions.convertToLibraryParams reads exactly
these four and turns them into the LibraryParams that
LibraryTree.onGetLibraryRoot logs on the far side — so what goes in
here and what comes out of `adb logcat -s SoulStream:I` are two
ends of one wire. */
val hints = Bundle()
hints.putBoolean("android.service.media.extra.RECENT", recent)
hints.putInt("androidx.media.MediaBrowserCompat.Extras.KEY_ROOT_CHILDREN_LIMIT", 4)
hints.putInt(
"androidx.media.MediaBrowserCompat.Extras.KEY_ROOT_CHILDREN_SUPPORTED_FLAGS",
MediaBrowser.MediaItem.FLAG_BROWSABLE or MediaBrowser.MediaItem.FLAG_PLAYABLE,
)
hints.putInt("android.media.extras.MEDIA_ART_SIZE_HINT_PIXELS", 320)
main.postDelayed(watchdog, WATCHDOG_MS)
try {
val b = MediaBrowser(this, ComponentName(PKG, SVC), connection, hints)
browser = b
b.connect()
} catch (e: Exception) {
fail("connect threw: " + e)
}
}
private val connection = object : MediaBrowser.ConnectionCallback() {
/** The number the whole exercise is for: milliseconds from
* `connect()` to here, with the app's process cold. Auto gives a
* media app a few seconds and then drops it. */
override fun onConnected() {
val b = browser ?: return
val root = try { b.root } catch (e: Exception) { "" }
val token = try { b.sessionToken != null } catch (e: Exception) { false }
say(
JSONObject().put("ev", "root").put("ms", ms())
.put("id", root).put("tokenOk", token),
)
if (root.isEmpty()) { fail("no root id"); return }
walk(root, 0)
}
/* the two ways a bind ends badly. "connection failed" with nothing
else in the log is usually package visibility — see <queries> in
the manifest — or a service that answered null from onGetSession. */
override fun onConnectionFailed() { fail("connection failed") }
override fun onConnectionSuspended() { fail("connection suspended") }
}
/** Breadth-ish, depth-limited, and every node subscribed at most once.
* A `MediaBrowserService` may answer a subscription more than once
* (that is how it pushes a changed folder), so both callbacks latch. */
private fun walk(parent: String, level: Int) {
val b = browser ?: return
if (parent.isEmpty() || level > depth) { maybeDone(); return }
if (!subscribed.add(parent)) { maybeDone(); return }
pending += 1
b.subscribe(
parent,
object : MediaBrowser.SubscriptionCallback() {
private var answered = false
override fun onChildrenLoaded(
parentId: String,
children: MutableList<MediaBrowser.MediaItem>,
) {
if (answered) return
answered = true
val arr = JSONArray()
for (c in children) {
arr.put(
JSONObject()
.put("id", c.mediaId ?: "")
.put("title", (c.description.title ?: "").toString())
.put("browsable", c.isBrowsable)
.put("playable", c.isPlayable),
)
}
say(
JSONObject().put("ev", "children").put("parent", parentId)
.put("n", children.size).put("items", arr),
)
nodes += 1
/* descend BEFORE this one is retired, so `pending` never
touches zero while there is more tree to read */
for (c in children) {
if (c.isBrowsable) walk(c.mediaId ?: "", level + 1) else leaves += 1
}
pending -= 1
maybeDone()
}
override fun onError(parentId: String) {
if (answered) return
answered = true
say(
JSONObject().put("ev", "children").put("parent", parentId)
.put("n", -1).put("items", JSONArray()),
)
pending -= 1
maybeDone()
}
},
)
}
/** The app's Auto declaration, read out of the app's OWN resources.
* This is the half `adb shell pm dump` cannot do: `dumpsys package`
* prints resolver tables and package state and no `<meta-data>` at all,
* so the only way to prove `com.google.android.gms.car.application`
* points at a real resource — rather than at a dangling id — is to
* resolve the int and then open it. */
private fun describe() {
val o = JSONObject().put("ev", "desc")
try {
val ai = packageManager.getApplicationInfo(PKG, PackageManager.GET_META_DATA)
val id = ai.metaData?.getInt("com.google.android.gms.car.application", 0) ?: 0
o.put("metaResId", id)
if (id != 0) {
val res = packageManager.getResourcesForApplication(ai)
o.put("resName", res.getResourceName(id))
val uses = JSONArray()
val xml = res.getXml(id)
var ev = xml.eventType
while (ev != XmlPullParser.END_DOCUMENT) {
if (ev == XmlPullParser.START_TAG && xml.name == "uses") {
// `name` here carries NO namespace. Google's own
// descriptor is `<uses name="media"/>` — a bare
// attribute, not `android:name` — so reading it out of
// ANDROID_NS returns null and the probe reports [""],
// which is indistinguishable from a descriptor that
// declares nothing. That is the one thing this event
// exists to tell apart from a dangling resource id, so
// the null namespace is the real read and ANDROID_NS is
// only a fallback for a descriptor written the other way.
uses.put(
xml.getAttributeValue(null, "name")
?: xml.getAttributeValue(ANDROID_NS, "name")
?: ""
)
}
ev = xml.next()
}
xml.close()
o.put("uses", uses)
}
} catch (e: Exception) {
o.put("err", e.toString())
}
say(o)
}
/** What the PackageManager thinks each of the two actions resolves to,
* scoped to SoulStream. An empty list for `browse` and a present one
* for `media3` is a manifest that landed half; two empty lists with a
* working app is package visibility eating this prober. */
private fun filters() {
val o = JSONObject().put("ev", "filter")
for (pair in listOf(Pair("browse", BROWSE), Pair("media3", LIBRARY))) {
val arr = JSONArray()
try {
for (ri in packageManager.queryIntentServices(Intent(pair.second).setPackage(PKG), 0)) {
arr.put(ri.serviceInfo.packageName + "/" + ri.serviceInfo.name)
}
} catch (e: Exception) {
arr.put("ERR " + e)
}
o.put(pair.first, arr)
}
say(o)
}
private fun maybeDone() {
if (finished || pending > 0) return
say(
JSONObject().put("ev", "done").put("ms", ms())
.put("nodes", nodes).put("leaves", leaves),
)
stop()
}
private fun fail(why: String) {
if (finished) return
say(JSONObject().put("ev", "fail").put("why", why).put("ms", ms()))
stop()
}
/** One way out, and `onDestroy` repeats it. A prober that left a binding
* behind would hold SoulStream's service up for as long as this process
* lived, which is the opposite of what it is for. */
private fun stop() {
if (finished) return
finished = true
main.removeCallbacks(watchdog)
try { browser?.disconnect() } catch (_: Exception) {}
browser = null
finish()
}
override fun onDestroy() {
main.removeCallbacks(watchdog)
try { browser?.disconnect() } catch (_: Exception) {}
browser = null
super.onDestroy()
}
}
+6
View File
@@ -0,0 +1,6 @@
/* The prober is its own build. No pluginManagement block: the root
build.gradle.kts puts AGP and the Kotlin plugin on the buildscript
classpath the way src-tauri/gen/android does, so nothing here is
resolved from the Gradle Plugin Portal. */
rootProject.name = "auto-prober"
include(":prober")