android auto: the car can see the app, and the root it opens is empty

Android Auto reaches a media app through `MediaBrowserService`, so `MediaService`
becomes a `MediaLibraryService`, one intent filter carries three actions — media3's
`MediaLibraryService` and `MediaSessionService`, plus the legacy
`android.media.browse.MediaBrowserService` that Auto actually binds — and
`res/xml/automotive_app_desc.xml` with the `com.google.android.gms.car.application`
meta-data declares the app to the car. Auto lists SoulStream, opens it, and gets a
browsable root. There is nothing under it: `onGetChildren` is left un-overridden so
the default answers `ERROR_NOT_SUPPORTED` at once, and round fifty fills the tree.

`onGetLibraryRoot` answers out of `Futures.immediateFuture` — no database, no I/O,
nothing deferred — because `MediaLibraryServiceLegacyStub.onGetRoot` blocks the
binder thread serving the host on that future with NO timeout and runs the callback
on the main handler. A root that waits on anything is a head unit that gives up.

The round carries a mitigation it cannot be split from. androidx/media #3158 — open
since 2026-04-03, confirmed again 2026-09-04, reproduced on media3 1.11.0 — has a
host that binds a `MediaLibraryService` with nothing playing drive the service round
`onCreate` → `onBind` → `onGetSession` → `onConnect` → `onDestroy` at about thirty
cycles a second, deterministically after a force-stop with no existing binding. That
is exactly how Auto connects and exactly what the prober does. media3 will not act
before an Auto-side fix lands, so the loop cannot be stopped from here; it can only
be made to cost nothing. The session, the player, the notification provider, the
audio-focus request and the becoming-noisy receiver move into `MediaHub` for the life
of the PROCESS and `onCreate` allocates and registers nothing — round forty-eight's
version would have built a platform session and a legacy browser stub thirty times a
second, registered and unregistered a receiver sixty times a second, and abandoned
audio focus thirty times a second while music was playing.

Three consequences, each a trap for the next round. `onDestroy` calls `removeSession`
and never `release()`: `removeSession` drops the session from that instance's map and
clears its listener, which is what stops a destroyed service being called back, while
`markReleased()` would latch `released` and leave a process-lifetime player that never
updates the card again. The session is built with
`MediaLibrarySession.Builder(applicationContext, …)` rather than the
`(MediaLibraryService, …)` overload, because `MediaSessionServiceLegacyStub.initialize()`
does `attachToBaseContext(sessionImpl.getContext())` — a session built against the
Service pins a destroyed Service as the base context of a live
`MediaBrowserServiceCompat`. And nothing releases the session any more, so it and its
platform `MediaSessionCompat` live until the process dies; the walk's `one-session`
check is what keeps that honest. `onCreate` logs `auto service created n=…` and
`onDestroy` logs `auto service destroyed`, which are the only way to count the loop on
a real phone.

And the instrument. `tools/auto-prober/` is a throwaway app that binds the same way
Auto does, with Auto's own root hints, walks whatever tree comes back, and logs one
JSON object per event under `SoulAutoProbe`. It is framework-only
(`android.media.browse.MediaBrowser`, not androidx, not media3) because that is the
path Auto itself takes into a media3 app, and a prober built out of the library it is
probing would not prove that path works. It lives outside `src-tauri/gen/android/`
because `tauri android init` would delete it, and borrows the generated project's
Gradle wrapper rather than shipping one, so a run downloads nothing. With SoulStream
force-stopped there is no WebView, so no CDP, so `smoke.mjs` is blind to every bit of
this — the prober is the only eye the project has on Auto without a head unit. The
number it exists for is `root`: milliseconds from `connect()` to `onConnected()` with
the process cold.

Two corrections to the plan, argued in the files. `adb shell pm dump` is
`dumpsys package` and prints NO application meta-data, so the Auto declaration cannot
be a hard walk assertion — the two that ARE hard are the intent filters
(`auto-browse`, `auto-library`), which catch the `TAURI_ANDROID_PROJECT_PATH` trap
completely because the filters and the meta-data live in one library manifest and are
lost together; the declaration is read instead by the prober, through
`PackageManager.GET_META_DATA` and the app's own `Resources`, and echoed back by the
walk as the advisory `auto-desc`. And the prober module compiles at jvmTarget 1.8 like
every other module here, not 17 — JDK 17 is what RUNS Gradle.

No Rust, no frontend, no cargo. The round was authored where none of it could be
compiled and it has now been built and run: the Kotlin compiles, the APK installs, and
the prober binds the app cold. `desc` resolves the meta-data to a real
`xml/automotive_app_desc`; `filter` shows BOTH the legacy
`android.media.browse.MediaBrowserService` and media3's own resolving to
`MediaService`; `root` comes back at ~2.2 s with `ss:root` and a session token, of
which the callback itself is about nine milliseconds and the rest is the cold process
starting; `children` answers `n=-1` with nothing in it, which is the un-overridden
default doing its job; and `auto service created n=1 reused=false` — #3158 does not
loop here.

One fix the first run earned. The prober read the descriptor's `name` out of the
android namespace, but Google's `<uses name="media"/>` is a BARE attribute with no
namespace, so it came back `[""]` — a real declaration and a dangling resource id
reported identically, which is the single thing the event exists to tell apart. It
reads the null namespace now and keeps ANDROID_NS as a fallback; the second run says
`uses:["media"]`.

None of this proves the car screen: the Desktop Head Unit or a real head unit is the
only thing that shows what Auto draws, how it groups a tree, or whether a tap plays.
This proves the app answers.
This commit is contained in:
KeelrLeek committed 2026-09-15 09:16:53 -05:00
1 parent 3a8ccd4640
commit e1bf456743
16 files changed
+1412 -226

No files matched your search

@@ -17,11 +17,13 @@
# rule of tauri's own names. See src-tauri/android/proguard-soulstream.pro.
#
# MediaService is declared in this module's AndroidManifest.xml, so AGP's
# own merged-manifest keep rules hold it by name, and SoulStreamPlayer needs
# no rule of its own — the only thing that reaches it is a direct reference
# from MediaService. androidx's MediaButtonReceiver went with the move to
# media3: the service's own SERVICE_INTERFACE filter routes media buttons
# now, and two receivers would have fought over them.
# own merged-manifest keep rules hold it by name, and SoulStreamPlayer,
# MediaHub and LibraryTree need no rule of their own — the only thing that
# reaches any of them is a direct reference from MediaService.
# androidx's MediaButtonReceiver went with the move to media3: the
# service's own filter routes media buttons now — through the
# MediaLibraryService action, which MediaSessionLegacyStub queries first —
# and two receivers would have fought over them.
-keep class com.soulstream.mediasession.MediaSessionPlugin { *; }
-keep class com.soulstream.mediasession.NowPlayingArgs { *; }
-keep class com.soulstream.mediasession.QueueItemArgs { *; }
@@ -16,18 +16,45 @@
android:name="com.soulstream.mediasession.MediaService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<!-- media3 finds its own service through this, and while the
process is alive it is what routes a headset or a card
button here. There is no MediaButtonReceiver any more, and
media3's own is deliberately NOT put in its place: its
docs make that receiver conditional on
MediaSession.Callback.onPlaybackResumption(), which this
app cannot implement — playback lives in Rust behind a
WebView round trip. Two receivers would only have fought
over the buttons. -->
<!-- Three actions, one component, and all three are safe.
Android Auto binds the LEGACY one
(android.media.browse.MediaBrowserService), which media3
still serves: MediaSessionService.onBind answers it with
the session's legacy browser binder. media3's own two are
how a MediaController and a SessionToken find this
service; SessionToken(Context, ComponentName) tests
MediaLibraryService FIRST, so the component is one
TYPE_LIBRARY_SERVICE token however many actions it wears,
and getAllServiceTokens collects into a Set.
The MediaSessionService action is KEPT rather than
replaced because the media-button path already verified on
this phone hangs off it: MediaSessionLegacyStub looks for
an ACTION_MEDIA_BUTTON receiver, finds none (there is no
MediaButtonReceiver any more, and media3's own is
deliberately not in its place — its docs make it
conditional on MediaSession.Callback.onPlaybackResumption(),
which this app cannot implement because playback lives in
Rust behind a WebView round trip), and falls to a service
declared under MediaLibraryService and then
MediaSessionService. Either way it lands here.
android:exported is true above for the same reason the
filters are here: a browse service Auto cannot bind is a
browse service that does not exist. -->
<intent-filter>
<action android:name="androidx.media3.session.MediaLibraryService" />
<action android:name="androidx.media3.session.MediaSessionService" />
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<!-- What puts SoulStream in Android Auto's app list at all.
The resource is res/xml/automotive_app_desc.xml and the
reference is resolved at LINK time, so a missing file fails
the build rather than shipping a car-invisible app. -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
</application>
</manifest>
@@ -25,16 +25,23 @@ import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaLibraryService.LibraryParams
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import java.io.ByteArrayOutputStream
/**
* What is playing, shared between the plugin (which hears the WebView) and
* the service (which owns the session). The service is BOUND on the first
* update and refreshed on every one after.
* the service (which owns the session) — and, since round forty-nine, the
* owner of everything that must outlive the service.
*
* Bound, not started, and that is half the safety story of this file.
* `MediaSessionService.onStartCommand` promotes the service for a media
@@ -53,7 +60,50 @@ import java.io.ByteArrayOutputStream
* The other half is [MediaService.onStartCommand], because the SYSTEM starts
* this service in the foreground on a button press whether media3 then wants
* it there or not.
*
* ── WHY THE SESSION, THE PLAYER, THE FOCUS AND THE RECEIVER LIVE HERE ────
*
* androidx/media issue #3158, open since 2026-04-03 and still open when this
* round was written (last confirmed 2026-09-04, reproduced on media3 1.11.0):
* when a host binds a `MediaLibraryService` with nothing playing, the service
* runs `onCreate → onBind → onGetSession → onConnect → onDestroy` at about
* thirty cycles a second and keeps doing it. It is 100% reproducible after a
* force-stop with no existing binding, which is exactly what Android Auto
* does on a fresh connection — and exactly what `tools/auto-prober/` does.
* marcbaechinger's answer on the issue is that this is the Service contract
* (the last client unbinds, nothing called `onStartCommand`, so the system
* destroys it) and that the fix is being prepared on the Android Auto side;
* media3 will not act first. So it cannot be fixed from here — only made
* harmless.
*
* Made harmless by allocating NOTHING per cycle. Everything below the line
* is built once per PROCESS and never released: one `MediaLibrarySession`,
* one [SoulStreamPlayer], one notification provider, one audio-focus
* request, one becoming-noisy receiver, one notification channel. What round
* forty-eight did in `onCreate`/`onDestroy` would, under that loop, have
* built a platform session and a legacy browser stub thirty times a second
* (the exact retention the issue names), registered and unregistered a
* broadcast receiver sixty times a second ("Too many receivers registered"),
* and — worst, because it is audible — abandoned audio focus thirty times a
* second WHILE MUSIC WAS PLAYING.
*
* The session is built against `applicationContext` and not the Service, and
* that is not tidiness: `MediaSessionImpl.getLegacyBrowserServiceBinder()`
* hands the session's own context to
* `MediaSessionServiceLegacyStub.initialize()`, which calls
* `attachToBaseContext(sessionImpl.getContext())`. A session built against
* the Service therefore pins a destroyed Service as the base context of a
* live `MediaBrowserServiceCompat` — which is the leak comment 9 of #3158
* describes from the other end.
*
* The counter is the instrument. `MediaService.onCreate` logs
* `auto service created n=…`; the last such line in `adb logcat -d -s
* SoulStream:I` is how many times this process has built the service, and a
* number that climbs during a bind is #3158 firing. Without it an
* unmitigated version of this bug reaches Kyle as an unexplained OOM in a
* car, hours later.
*/
@OptIn(markerClass = [UnstableApi::class])
object MediaHub {
@Volatile var onAction: ((String, Double) -> Unit)? = null
@Volatile var state: NowPlayingArgs? = null
@@ -65,6 +115,18 @@ object MediaHub {
/** the app's own icon, encoded once — see appIcon() */
@Volatile private var icon: ByteArray? = null
/* ── the process-lifetime half: built once, never released ────────── */
@Volatile private var libSession: MediaLibrarySession? = null
@Volatile private var noteProvider: DefaultMediaNotificationProvider? = null
@Volatile private var focus: AudioFocusRequest? = null
@Volatile private var pausedByFocus = false
@Volatile private var noisyOn = false
@Volatile private var channelDone = false
/** how many `MediaService` instances this process has built. See the
* class comment: this is the #3158 instrument. */
@Volatile var creations = 0
private set
private val mainHandler = Handler(Looper.getMainLooper())
@Volatile private var bound = false
private val conn = object : ServiceConnection {
@@ -73,7 +135,36 @@ object MediaHub {
override fun onServiceDisconnected(name: ComponentName?) {}
}
private val noisy = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) act("pause")
}
}
private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
when (change) {
AudioManager.AUDIOFOCUS_LOSS -> {
pausedByFocus = false
act("pause")
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
if (state?.playing == true) {
pausedByFocus = true
act("pause")
}
}
AudioManager.AUDIOFOCUS_GAIN -> {
if (pausedByFocus) {
pausedByFocus = false
act("play")
}
}
/* CAN_DUCK: the framework ducks us on its own */
}
}
fun update(ctx: Context, np: NowPlayingArgs) {
val app = ctx.applicationContext
if (np.artKey != artKey) {
artKey = np.artKey
artBytes = null
@@ -86,14 +177,18 @@ object MediaHub {
}
}
state = np
bind(ctx)
bind(app)
/* ALWAYS on the main looper. Every SimpleBasePlayer entry point calls
verifyApplicationThread(), a Tauri @Command is not promised to run
on the main thread, and the IllegalStateException that follows
would be swallowed by media.svelte.js's own `.catch(() => {})` —
the card would just quietly stop updating, with nothing in any log. */
mainHandler.post {
service?.syncFocus(np.playing)
/* focus is no longer the service's, so it no longer waits for
one. Round forty-eight had to catch this up from `onCreate`,
because the update that MADE the service found `service` still
null; there is nothing to catch up now. */
if (np.playing) requestFocus(app)
player?.let {
it.settle()
it.refresh()
@@ -106,14 +201,15 @@ object MediaHub {
* timeline) and to step out of the foreground. The service is left
* alive and the session left registered ON PURPOSE: a headset button
* should still reach us with nothing on the bar, and re-creating the
* service is the expensive half. Audio focus IS given back, which the
* old service did by dying. */
* service is the expensive half. Audio focus IS given back — by this
* object rather than by a service that may not exist. */
fun clear(ctx: Context) {
val app = ctx.applicationContext
state = null
artBytes = null
artKey = null
mainHandler.post {
service?.dropFocus()
dropFocus(app)
player?.let {
it.settle()
it.refresh()
@@ -126,11 +222,14 @@ object MediaHub {
private fun bind(ctx: Context) {
if (bound) return
try {
/* the action matters: MediaSessionService.onBind answers only for
SERVICE_INTERFACE and returns null for a bare intent, and
BIND_AUTO_CREATE is what creates the service at all */
/* The action matters: `onBind` answers only for an action it
knows and returns null for a bare intent. Three are declared
in the manifest now and any of them would do — the component
is explicit, so no filter is consulted — and the LibraryService
one is named here because that is what this service is.
BIND_AUTO_CREATE is what creates the service at all. */
val intent = Intent(ctx, MediaService::class.java)
.setAction(MediaSessionService.SERVICE_INTERFACE)
.setAction(MediaLibraryService.SERVICE_INTERFACE)
bound = ctx.bindService(intent, conn, Context.BIND_AUTO_CREATE)
if (!bound) {
android.util.Log.w("SoulStream", "media service would not bind")
@@ -141,6 +240,153 @@ object MediaHub {
}
}
/* ── the singletons ──────────────────────────────────────────────── */
/** The one session this process will ever have, and the one player under
* it. Built on the first `MediaService.onCreate` and handed back to
* every one after it — which is the whole #3158 mitigation.
*
* `MediaLibrarySession.Builder(Context, Player, Callback)` rather than
* the `(MediaLibraryService, …)` overload, which is a one-line delegate
* to it (`this((Context) service, player, callback)`) that exists only
* to keep construction inside a service. The Context overload is
* @UnstableApi; this file is already opted in, and see the class
* comment for why a Service context is the wrong thing to pin here.
*
* Main thread only in practice: `onCreate` runs there and always before
* any bind, so by the time `onGetSession` asks, this is a pure read. */
@Synchronized
fun session(app: Context): MediaLibrarySession {
libSession?.let { return it }
val p = SoulStreamPlayer(app)
player = p
val builder = MediaLibrarySession.Builder(app, p, LibraryTree).setId("SoulStream")
val launch = app.packageManager.getLaunchIntentForPackage(app.packageName)
if (launch != null) {
builder.setSessionActivity(
PendingIntent.getActivity(
app, 0, launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
}
val s = builder.build()
libSession = s
return s
}
/** The session if one was ever built, without building one. `onDestroy`
* and `holdForeground` both want exactly this. */
fun sessionOrNull(): MediaLibrarySession? = libSession
/** The notification provider, also once. It is configuration and a
* Context and nothing else, so one instance serves every service
* instance; the `MediaNotificationManager` that READS it is media3's
* own and is per-service, which is why `setMediaNotificationProvider`
* still runs in every `onCreate`. */
@Synchronized
fun provider(app: Context): DefaultMediaNotificationProvider {
noteProvider?.let { return it }
val pr = DefaultMediaNotificationProvider.Builder(app)
.setChannelId(MediaService.CHANNEL)
.setChannelName(R.string.ss_playback_channel)
.setNotificationId(MediaService.NOTIFICATION_ID)
.build()
pr.setSmallIcon(R.drawable.ic_soulstream_note)
noteProvider = pr
return pr
}
/** Ours first. media3 would create the channel itself, at IMPORTANCE_LOW
* and nothing else — no lockscreenVisibility, and setShowBadge only
* below API 28 — so on a fresh install the card would be hidden behind
* "sensitive notifications". `ensureNotificationChannel` leaves an
* existing channel alone, so getting in first is the whole fix.
*
* Latched, and the latch is set BEFORE the work: this used to run in
* every `onCreate`, which under #3158 is thirty `NotificationManager`
* round trips a second for a channel that already exists. */
@Synchronized
fun ensureChannel(app: Context) {
if (channelDone) return
channelDone = true
if (Build.VERSION.SDK_INT < 26) return
try {
val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(MediaService.CHANNEL) != null) return
val channel =
NotificationChannel(MediaService.CHANNEL, "Playback", NotificationManager.IMPORTANCE_LOW)
channel.description = "What is playing"
channel.setShowBadge(false)
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
manager.createNotificationChannel(channel)
} catch (e: Exception) {
android.util.Log.w("SoulStream", "playback channel could not be made: $e")
}
}
/** Headphones out pauses. Registered ONCE, on the application context,
* and never unregistered — the process dying is what takes it down.
* A system broadcast reaches a non-exported receiver; Android 14 wants
* the flag said out loud. */
@Synchronized
fun ensureNoisy(app: Context) {
if (noisyOn) return
noisyOn = true
try {
ContextCompat.registerReceiver(
app, noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
} catch (e: Exception) {
noisyOn = false
android.util.Log.w("SoulStream", "noisy receiver would not register: $e")
}
}
/** Focus is asked for when something starts playing and kept until the
* bar empties. It is NOT given back when the service dies, which is the
* point: under #3158 the service dies thirty times a second and the
* music is still playing. */
@Synchronized
fun requestFocus(app: Context) {
if (focus != null) return
val manager = app.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build(),
)
.setOnAudioFocusChangeListener(focusListener)
.build()
if (manager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) focus = request
}
/** The bar emptied. The pre-media3 service gave focus back by dying;
* nothing dies on purpose any more, so it is given back by hand. */
@Synchronized
fun dropFocus(app: Context) {
pausedByFocus = false
val request = focus ?: return
try {
(app.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
.abandonAudioFocusRequest(request)
} catch (e: Exception) {
android.util.Log.w("SoulStream", "focus would not go back: $e")
}
focus = null
}
/** One more `MediaService` exists. Returns the running total, which is
* what the log line carries. */
@Synchronized
fun noteCreation(): Int {
creations += 1
return creations
}
/** The app's own icon, encoded ONCE and kept, as the card's artwork when
* there is no cover yet. `artBytes` is cleared the moment `artKey`
* changes and only refilled when the frontend has finished decoding a
@@ -196,28 +442,128 @@ object MediaHub {
}
/**
* The media3 session service: a `MediaSession` over [SoulStreamPlayer], the
* card on the lock screen and in the shade, audio focus (pause when another
* app takes the output, or the headphones come out), and the reason the
* process is not frozen in the background. The audio never passes through
* here.
* Everything Android Auto (or any other `MediaBrowser`) is allowed to ask
* this app for. Round forty-nine answers exactly one question — where the
* tree starts — and nothing under it.
*
* media3 posts and owns the notification now, and promotes and demotes the
* service around it — there is no `buildNotification` and no
* `MediaButtonReceiver` in this file any more, and the manifest's
* `androidx.media3.session.MediaSessionService` filter is what routes a
* headset button here while the process is alive.
* A named object rather than an anonymous one because the rounds after this
* fill it in: `onGetChildren` is the tree, `onGetItem` a single row,
* `onSearch`/`onGetSearchResult` the voice query. Each of those has a
* default on `MediaLibrarySession.Callback` that answers
* `LibraryResult.ofError(ERROR_NOT_SUPPORTED)` immediately, so leaving them
* un-overridden is a root with nothing under it and no hang anywhere.
*
* Focus and becoming-noisy are still hand-rolled, and that is not an
* oversight. `setAudioAttributes(attrs, handleAudioFocus = true)` and
* `setHandleAudioBecomingNoisy` are ExoPlayer conveniences; on a custom
* `Player` the first lands in `handleSetAudioAttributes`, whose default body
* throws, and the second does not exist. Deleting these blocks would lose
* "another app takes the output → we pause" and "headphones out → we pause"
* with nothing failing anywhere and no walk able to catch it.
* `onConnect` is deliberately NOT overridden either.
* `ConnectionResult.AcceptedResultBuilder` defaults a `MediaLibrarySession`
* to `DEFAULT_SESSION_AND_LIBRARY_COMMANDS`, and that is what carries
* `COMMAND_CODE_LIBRARY_GET_LIBRARY_ROOT`. Without it
* `MediaLibraryServiceLegacyStub.onGetRoot` returns null before it ever
* reaches [onGetLibraryRoot] and Auto retries six times with backoff and
* gives up. If a package validator is ever added here it has to be additive,
* and it has to keep accepting `tools/auto-prober/`.
*/
@OptIn(markerClass = [UnstableApi::class])
class MediaService : MediaSessionService() {
object LibraryTree : MediaLibrarySession.Callback {
/** The id of the root node. Everything the later rounds hang under the
* tree is named relative to this; `ss:` keeps our ids from colliding
* with a media id that happens to be a file path. */
const val ROOT = "ss:root"
/**
* IMMEDIATE, UNCONDITIONALLY, AND THAT IS NOT A STYLE CHOICE.
*
* Android Auto reaches this through the legacy browser path, and
* `MediaLibraryServiceLegacyStub.onGetRoot` (media3 1.10.1, lines
* 100-115) posts this callback to the session's application handler and
* then does `haveFuture.block()` followed by `future.get()` with NO
* timeout — on the binder thread serving the host. So a future that
* waits on anything is main-thread I/O and a blocked binder thread at
* the same time: an ANR, and a host that drops us.
* `Futures.immediateFuture` is the only correct answer, and there must
* be no branch in here that can reach a file, a database or a lock.
*
* The log line is the other half of `tools/auto-prober/`: the prober
* puts Auto's own root hints into the bind,
* `LegacyConversions.convertToLibraryParams` turns them into the
* [LibraryParams] read here, and these three booleans are what came out
* the far end. `isRecent` is the
* separate connection a host makes to ask for playback resumption — the
* documented pattern is to REFUSE that root when resumption is not
* supported, which this app cannot support (playback lives in Rust
* behind a WebView round trip). Round forty-nine does not take that
* decision; it records what Auto actually asks for, so the round that
* fills the tree decides it from evidence.
*/
override fun onGetLibraryRoot(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
params: LibraryParams?,
): ListenableFuture<LibraryResult<MediaItem>> {
android.util.Log.i(
"SoulStream",
"auto root from=" + browser.packageName + " uid=" + browser.uid +
" recent=" + (params?.isRecent ?: false) +
" offline=" + (params?.isOffline ?: false) +
" suggested=" + (params?.isSuggested ?: false),
)
return Futures.immediateFuture(LibraryResult.ofItem(rootItem(), params))
}
/** A browsable, non-playable folder with nothing in it yet. Browsable
* and not playable is what makes a host descend into it rather than try
* to play it; MEDIA_TYPE_FOLDER_MIXED says the children will not all be
* one kind, which is what the library, the playlists and the queue will
* be once they are there. */
private fun rootItem(): MediaItem =
MediaItem.Builder()
.setMediaId(ROOT)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("SoulStream")
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
.build(),
)
.build()
}
/**
* The media3 library service: a browsable `MediaLibrarySession` over
* [SoulStreamPlayer], the card on the lock screen and in the shade, and the
* reason the process is not frozen in the background. The audio never passes
* through here.
*
* A `MediaLibraryService` since round forty-nine, which is what lets Android
* Auto list and open the app at all: `MediaLibraryService` extends
* `MediaSessionService`, and its `onBind` serves the legacy
* `android.media.browse.MediaBrowserService` action that Auto actually binds
* as well as media3's own two. The manifest declares all three on one
* filter.
*
* THIS CLASS ALLOCATES NOTHING AND REGISTERS NOTHING OF ITS OWN. Every
* durable thing it needs lives in [MediaHub] for the life of the process —
* see the comment there, and androidx/media #3158. Re-introducing a `new`
* anywhere in `onCreate`, or a `release()` anywhere in `onDestroy`, silently
* restores the bug; `removeSession` is the correct way to detach a session
* that outlives its service, and it is what keeps a destroyed instance's
* `MediaSessionListener` from being called by a `refresh()` that lands in
* the gap between one destroy and the next create.
*
* media3 posts and owns the notification, and promotes and demotes the
* service around it — there is no `buildNotification` and no
* `MediaButtonReceiver` in this file.
*
* Focus and becoming-noisy are still hand-rolled (in [MediaHub] now), and
* that is not an oversight. `setAudioAttributes(attrs, handleAudioFocus =
* true)` and `setHandleAudioBecomingNoisy` are ExoPlayer conveniences; on a
* custom `Player` the first lands in `handleSetAudioAttributes`, whose
* default body throws, and the second does not exist. Deleting those blocks
* would lose "another app takes the output → we pause" and "headphones out →
* we pause" with nothing failing anywhere and no walk able to catch it.
*/
@OptIn(markerClass = [UnstableApi::class])
class MediaService : MediaLibraryService() {
companion object {
/** Literally "playback", and load-bearing twice over: the phone
* walk's `media-card` check finds the card by this channel id, and
@@ -233,97 +579,38 @@ class MediaService : MediaSessionService() {
const val HOLD_NOTIFICATION_ID = 3
}
private var session: MediaSession? = null
private var player: SoulStreamPlayer? = null
private var focus: AudioFocusRequest? = null
private var pausedByFocus = false
private var noisyRegistered = false
private val noisy = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) MediaHub.act("pause")
}
}
private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
when (change) {
AudioManager.AUDIOFOCUS_LOSS -> {
pausedByFocus = false
MediaHub.act("pause")
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
if (MediaHub.state?.playing == true) {
pausedByFocus = true
MediaHub.act("pause")
}
}
AudioManager.AUDIOFOCUS_GAIN -> {
if (pausedByFocus) {
pausedByFocus = false
MediaHub.act("play")
}
}
/* CAN_DUCK: the framework ducks us on its own */
}
}
override fun onCreate() {
super.onCreate()
/* ours first. media3 would create the channel itself, at
IMPORTANCE_LOW and nothing else — no lockscreenVisibility, and
setShowBadge only below API 28 — so on a fresh install the card
would be hidden behind "sensitive notifications". ensureNotification
Channel leaves an existing channel alone, so getting in first is
the whole fix. */
createChannel()
val p = SoulStreamPlayer(applicationContext)
player = p
MediaHub.player = p
val app = applicationContext
/* all four of these are no-ops after the first time in this process */
MediaHub.ensureChannel(app)
val s = MediaHub.session(app)
MediaHub.ensureNoisy(app)
/* BEFORE addSession, and the order is real: addSession spins up the
internal notification controller that reads this provider, and both
calls post to the same main handler (inline, from here) in order. */
val provider = DefaultMediaNotificationProvider.Builder(this)
.setChannelId(CHANNEL)
.setChannelName(R.string.ss_playback_channel)
.setNotificationId(NOTIFICATION_ID)
.build()
provider.setSmallIcon(R.drawable.ic_soulstream_note)
setMediaNotificationProvider(provider)
val builder = MediaSession.Builder(this, p).setId("SoulStream")
val launch = packageManager.getLaunchIntentForPackage(packageName)
if (launch != null) {
builder.setSessionActivity(
PendingIntent.getActivity(
this, 0, launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
}
val s = builder.build()
session = s
calls post to the same main handler (inline, from here) in order.
Both DO run per instance: the provider is shared, the
MediaNotificationManager that reads it is media3's own and belongs
to this service. */
setMediaNotificationProvider(MediaHub.provider(app))
/* Re-adding the same session to a fresh service is exactly what the
API is for: addSession keys on the session id, finds nothing in
THIS instance's map, and registers a new MediaSessionListener and
notification controller for it. */
addSession(s)
/* a system broadcast reaches a non-exported receiver; Android 14 wants
the flag said out loud */
ContextCompat.registerReceiver(
this, noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
noisyRegistered = true
MediaHub.service = this
/* The update that MADE this service is already sitting in MediaHub,
and its own post to the main looper found `service` still null —
bindService creates the service a round trip later. Without this
line the focus request waits for whatever update happens to come
next (a status change, or the thirty-second top-up), and the first
track of a cold start plays with no focus held. */
if (MediaHub.state?.playing == true) requestFocus()
if (MediaHub.state?.playing == true) MediaHub.requestFocus(app)
/* The #3158 counter. `n=` climbing during a bind is the bug firing;
see MediaHub's comment for how to read it. */
val n = MediaHub.noteCreation()
android.util.Log.i("SoulStream", "auto service created n=" + n + " reused=" + (n > 1))
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session
/** Never null, on purpose: `MediaSessionService.onBind` returns null to
* a legacy browser when this answers null, and a null there is Auto
* failing to connect with nothing in any log. */
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
MediaHub.session(applicationContext)
/* ── the foreground contract media3 does not keep for us ──────────────
*
@@ -334,11 +621,14 @@ class MediaService : MediaSessionService() {
* `PendingIntent.getForegroundService` (SDK 26+), unconditionally, for
* every key. It resolves a service because there is no BroadcastReceiver
* any more: it asks the PackageManager for an ACTION_MEDIA_BUTTON
* receiver first, finds none, and falls to the manifest's
* `androidx.media3.session.MediaSessionService` filter. So a headset
* key, a lock-screen button and anything else the framework routes to
* the session arms Android's ten-second `startForeground()` timer — the
* same timer that took a Save down with the process on 2026-09-13.
* receiver first, finds none, and falls to a service declared under
* `androidx.media3.session.MediaLibraryService` — which it queries
* BEFORE `MediaSessionService`, and which this service now declares.
* (Both are in the manifest; whichever it picks is this component.) So a
* headset key, a lock-screen button and anything else the framework
* routes to the session arms Android's ten-second `startForeground()`
* timer — the same timer that took a Save down with the process on
* 2026-09-13.
*
* The CARD's own buttons are milder: `DefaultActionFactory` sets
* `setStartAsForegroundService(!player.playWhenReady)`, but
@@ -394,7 +684,7 @@ class MediaService : MediaSessionService() {
}
private fun holdForeground() {
val s = session
val s = MediaHub.sessionOrNull()
if (s != null && MediaHub.state != null) {
try {
/* the boolean is startInForegroundRequired */
@@ -441,70 +731,39 @@ class MediaService : MediaSessionService() {
.build()
}
/**
* DETACH, NEVER RELEASE. Four lines round forty-eight had here are gone
* and none of them may come back:
*
* · `unregisterReceiver(noisy)` — under #3158 that is sixty binder
* calls a second and the "Too many receivers registered" crash the
* issue reports. The receiver is MediaHub's now and lives as long as
* the process.
* · `abandonFocus()` — thirty focus drops a second while music plays.
* Audible, and the most user-visible half of the bug.
* · `player.release()` + `markReleased()` — `markReleased` latches
* `released = true` on SoulStreamPlayer, after which `refresh()` and
* `awaitUpdate()` are permanent no-ops. On a per-service player that
* was correct; on a process-lifetime one it is a card that never
* updates again after the first destroy.
* · `session.release()` — the session outlives the service on purpose.
*
* `removeSession` is what replaces all of it: it drops the session from
* THIS instance's map and posts `getMediaNotificationManager()
* .removeSession(session); session.clearListener()`, which is precisely
* the "stop calling a dead service back" that `release()` was doing by
* accident. It throws `checkArgument(sessions.containsKey(id))` when the
* session was never added, hence the guard.
*/
override fun onDestroy() {
if (noisyRegistered) {
try { unregisterReceiver(noisy) } catch (_: Exception) {}
noisyRegistered = false
val s = MediaHub.sessionOrNull()
if (s != null && isSessionAdded(s)) {
try { removeSession(s) } catch (e: Exception) {
android.util.Log.w("SoulStream", "session would not detach: $e")
}
}
abandonFocus()
/* the player first, then the session — media3's own order. `release()`
is SimpleBasePlayer's, needs COMMAND_RELEASE (advertised) and the
main thread (onDestroy runs there); markReleased() is our own latch
so a refresh() that slipped through MediaHub does nothing. */
player?.release()
player?.markReleased()
session?.release()
session = null
player = null
MediaHub.player = null
MediaHub.service = null
android.util.Log.i("SoulStream", "auto service destroyed")
super.onDestroy()
}
/** Focus is asked for when something starts playing and kept until the
* bar empties or the service dies — called from MediaHub's own post to
* the main looper, so it rides the same thread everything else here
* does. */
fun syncFocus(playing: Boolean) {
if (playing) requestFocus()
}
/** The bar emptied. The old service gave focus back by dying; this one
* outlives the music, so it gives it back by hand. */
fun dropFocus() {
pausedByFocus = false
abandonFocus()
}
private fun createChannel() {
if (Build.VERSION.SDK_INT < 26) return
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL) != null) return
val channel = NotificationChannel(CHANNEL, "Playback", NotificationManager.IMPORTANCE_LOW)
channel.description = "What is playing"
channel.setShowBadge(false)
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
manager.createNotificationChannel(channel)
}
private fun requestFocus() {
if (focus != null) return
val manager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build(),
)
.setOnAudioFocusChangeListener(focusListener)
.build()
if (manager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) focus = request
}
private fun abandonFocus() {
val request = focus ?: return
(getSystemService(Context.AUDIO_SERVICE) as AudioManager).abandonAudioFocusRequest(request)
focus = null
}
}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- What Android Auto reads to decide this is a media app it can host.
Named by the <meta-data android:name="com.google.android.gms.car.
application"> in this module's AndroidManifest.xml; "media" is the
whole declaration (the other value, "template", is for a car app
built on the Car App Library, which this is not). -->
<automotiveApp>
<uses name="media" />
</automotiveApp>