From e1bf456743e2f1e51ba530f148a88023e07f0840 Mon Sep 17 00:00:00 2001
From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com>
Date: Tue, 15 Sep 2026 09:16:53 -0500
Subject: [PATCH] android auto: the car can see the app, and the root it opens
is empty
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 `` 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.
---
ANDROID.md | 212 +++++-
.../android/consumer-rules.pro | 12 +-
.../android/src/main/AndroidManifest.xml | 45 +-
.../soulstream/mediasession/MediaService.kt | 619 +++++++++++++-----
.../src/main/res/xml/automotive_app_desc.xml | 9 +
handoff/rounds/commit-msg49.txt | 89 +++
tests/mobile/smoke.mjs | 99 +++
tools/auto-prober/.gitignore | 4 +
tools/auto-prober/README.md | 94 +++
tools/auto-prober/build.gradle.kts | 21 +
tools/auto-prober/gradle.properties | 3 +
tools/auto-prober/probe.sh | 64 ++
tools/auto-prober/prober/build.gradle.kts | 34 +
.../prober/src/main/AndroidManifest.xml | 35 +
.../soulstream/autoprober/ProbeActivity.kt | 292 +++++++++
tools/auto-prober/settings.gradle.kts | 6 +
16 files changed, 1412 insertions(+), 226 deletions(-)
create mode 100644 crates/tauri-plugin-media-session/android/src/main/res/xml/automotive_app_desc.xml
create mode 100644 handoff/rounds/commit-msg49.txt
create mode 100644 tools/auto-prober/.gitignore
create mode 100644 tools/auto-prober/README.md
create mode 100644 tools/auto-prober/build.gradle.kts
create mode 100644 tools/auto-prober/gradle.properties
create mode 100644 tools/auto-prober/probe.sh
create mode 100644 tools/auto-prober/prober/build.gradle.kts
create mode 100644 tools/auto-prober/prober/src/main/AndroidManifest.xml
create mode 100644 tools/auto-prober/prober/src/main/java/com/soulstream/autoprober/ProbeActivity.kt
create mode 100644 tools/auto-prober/settings.gradle.kts
diff --git a/ANDROID.md b/ANDROID.md
index 4eab01f..0d1c12c 100644
--- a/ANDROID.md
+++ b/ANDROID.md
@@ -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 ``, 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 `` 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
- `` 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.
diff --git a/crates/tauri-plugin-media-session/android/consumer-rules.pro b/crates/tauri-plugin-media-session/android/consumer-rules.pro
index 88dcd7e..9ee9baf 100644
--- a/crates/tauri-plugin-media-session/android/consumer-rules.pro
+++ b/crates/tauri-plugin-media-session/android/consumer-rules.pro
@@ -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 { *; }
diff --git a/crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml b/crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml
index 9750fe6..f3300f1 100644
--- a/crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml
+++ b/crates/tauri-plugin-media-session/android/src/main/AndroidManifest.xml
@@ -16,18 +16,45 @@
android:name="com.soulstream.mediasession.MediaService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
-
+
+
+
+
+
+
diff --git a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt
index 7189dbb..4f474a5 100644
--- a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt
+++ b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt
@@ -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> {
+ 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
- }
}
diff --git a/crates/tauri-plugin-media-session/android/src/main/res/xml/automotive_app_desc.xml b/crates/tauri-plugin-media-session/android/src/main/res/xml/automotive_app_desc.xml
new file mode 100644
index 0000000..d6cddec
--- /dev/null
+++ b/crates/tauri-plugin-media-session/android/src/main/res/xml/automotive_app_desc.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/handoff/rounds/commit-msg49.txt b/handoff/rounds/commit-msg49.txt
new file mode 100644
index 0000000..0ac14a2
--- /dev/null
+++ b/handoff/rounds/commit-msg49.txt
@@ -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 `` 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.
diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs
index 3ec39c2..5327330 100644
--- a/tests/mobile/smoke.mjs
+++ b/tests/mobile/smoke.mjs
@@ -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 ` is `dumpsys package `: 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);
diff --git a/tools/auto-prober/.gitignore b/tools/auto-prober/.gitignore
new file mode 100644
index 0000000..293d777
--- /dev/null
+++ b/tools/auto-prober/.gitignore
@@ -0,0 +1,4 @@
+# the prober is source; its build output and its Gradle state are not
+build/
+.gradle/
+local.properties
diff --git a/tools/auto-prober/README.md b/tools/auto-prober/README.md
new file mode 100644
index 0000000..ee5bd06
--- /dev/null
+++ b/tools/auto-prober/README.md
@@ -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 `` 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 ``, 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.
diff --git a/tools/auto-prober/build.gradle.kts b/tools/auto-prober/build.gradle.kts
new file mode 100644
index 0000000..f1344fb
--- /dev/null
+++ b/tools/auto-prober/build.gradle.kts
@@ -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()
+ }
+}
diff --git a/tools/auto-prober/gradle.properties b/tools/auto-prober/gradle.properties
new file mode 100644
index 0000000..2e11322
--- /dev/null
+++ b/tools/auto-prober/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+android.nonTransitiveRClass=true
diff --git a/tools/auto-prober/probe.sh b/tools/auto-prober/probe.sh
new file mode 100644
index 0000000..33a1a91
--- /dev/null
+++ b/tools/auto-prober/probe.sh
@@ -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)"
diff --git a/tools/auto-prober/prober/build.gradle.kts b/tools/auto-prober/prober/build.gradle.kts
new file mode 100644
index 0000000..80db249
--- /dev/null
+++ b/tools/auto-prober/prober/build.gradle.kts
@@ -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
+ // 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.
diff --git a/tools/auto-prober/prober/src/main/AndroidManifest.xml b/tools/auto-prober/prober/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..5f626f6
--- /dev/null
+++ b/tools/auto-prober/prober/src/main/AndroidManifest.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/auto-prober/prober/src/main/java/com/soulstream/autoprober/ProbeActivity.kt b/tools/auto-prober/prober/src/main/java/com/soulstream/autoprober/ProbeActivity.kt
new file mode 100644
index 0000000..105f004
--- /dev/null
+++ b/tools/auto-prober/prober/src/main/java/com/soulstream/autoprober/ProbeActivity.kt
@@ -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()
+ 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 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,
+ ) {
+ 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 `` 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 `` — 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()
+ }
+}
diff --git a/tools/auto-prober/settings.gradle.kts b/tools/auto-prober/settings.gradle.kts
new file mode 100644
index 0000000..35c1dbc
--- /dev/null
+++ b/tools/auto-prober/settings.gradle.kts
@@ -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")