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")