// Round forty-nine: Android Auto can see the app. // // Auto reaches a media app through MediaBrowserService, so `MediaService` // becomes a `MediaLibraryService`, its filter grows the two actions Auto and // media3 actually look for, an `automotive_app_desc.xml` and its // `` declare the app to the car, and `onGetLibraryRoot` answers // with a browsable root — immediately, unconditionally, out of // `Futures.immediateFuture`, because `MediaLibraryServiceLegacyStub.onGetRoot` // blocks the binder thread serving the host on that future with NO timeout // and runs the callback on the main handler. Nothing is under the root yet: // `onGetChildren` is left un-overridden, its default answers // ERROR_NOT_SUPPORTED at once, and round fifty fills the tree. // // THE ROUND ALSO 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 binding a MediaLibraryService with nothing // playing drive the service round onCreate → onBind → onGetSession → // onConnect → onDestroy at ~30 Hz, deterministically after a force-stop with // no existing binding. That is exactly how Android Auto connects and exactly // what tools/auto-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. So the session, the player, the notification provider, the audio // focus and the becoming-noisy receiver move into `MediaHub` for the life of // the PROCESS, `onCreate` allocates nothing, and `onDestroy` calls // `removeSession` rather than `release()` — round forty-eight's version would // otherwise have abandoned audio focus thirty times a second while music was // playing and latched `SoulStreamPlayer.released`, which is a card that never // updates again. // // And the instrument: tools/auto-prober/, a standalone Kotlin/Gradle app // OUTSIDE src-tauri/gen/android (which `tauri android init` regenerates) that // binds the same way Auto does, with Auto's own root hints, and logs the tree // as JSON. With the app force-stopped there is no WebView, so no CDP, so the // phone walk is blind to all of this; the prober is the only eye the project // will have on Auto without a head unit. // // 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; 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. Kotlin and Gradle cannot be compiled on // this machine: the Kotlin is read against the media3 1.10.1 sources and the // first `bash src-tauri/android/dev.sh` is where a typo shows. const fs = require('fs'); const root = process.env.SS_ROOT || 'F:/Coding/SoulStream/'; const scratch = 'C:/Users/Kyle/AppData/Local/Temp/claude/F--Coding-SoulStream/5488514a-11e8-482a-b0a0-20cb511ee110/scratchpad/'; /* LF, always. ANDROID.md is mixed — two of round forty-eight's ten inserts were written CRLF and its `rep`s carried that into the file — and the rest of the repo is LF, so everything this round writes is normalised to LF and the two anchors that land in those CRLF paragraphs are converted instead (see `crlf` below). A normalisation that ever changed an anchor read back out of an ins-r48 file would fail in the pre-flight, before a byte moves, which is the safe direction for it to fail in. */ const ins = (name) => fs.readFileSync(scratch + name, 'utf8').replace(/\r\n/g, String.fromCharCode(10)).replace(/\s+$/, ''); let p, s; const load = (file) => { p = file; s = fs.readFileSync(root + p, 'utf8'); }; const save = () => fs.writeFileSync(root + p, s); const once = (a) => { const i = s.indexOf(a); if (i < 0) throw new Error('missing in ' + p + ': ' + a.slice(0, 90)); if (s.indexOf(a, i + 1) >= 0) throw new Error('ambiguous in ' + p + ': ' + a.slice(0, 90)); return i; }; const rep = (a, b) => { once(a); s = s.replace(a, () => b); }; const NL = String.fromCharCode(10); /* An anchor as it sits in one of ANDROID.md's two CRLF paragraphs — the ones ins-r48-doc-bg.txt and ins-r48-doc-card-proves.txt wrote. Everything else in the file, and every byte this round writes, is LF. */ const crlf = (t) => t.split(NL).join(String.fromCharCode(13) + NL); const PLUGIN = 'crates/tauri-plugin-media-session/'; const KOTLIN = PLUGIN + 'android/src/main/java/com/soulstream/mediasession/'; const XML = PLUGIN + 'android/src/main/res/xml/'; const TOOLS = 'tools/auto-prober/'; const PROBER = TOOLS + 'prober/src/main/'; /* ── the re-run guard, before a byte moves ──────────────────────────── This round `rep`s across four files, rewrites a fifth and creates ten. A second pass would throw on the first anchor it had already consumed and leave a tree with a MediaLibraryService whose manifest still declares one action, or a doc half rewritten. `res/xml/automotive_app_desc.xml` is what this round creates and nothing else in the repo makes. */ if (fs.existsSync(root + XML + 'automotive_app_desc.xml')) { throw new Error(XML + 'automotive_app_desc.xml already exists — round 49 has already run against ' + root); } /* ── every anchor, and every insert, before the first write ─────────── Round forty-four's lesson, which is now the house rule: check it ALL here, so the round is all or nothing. A patch that wrote first left a half-applied tree its own re-run guard then refused to finish. */ // AndroidManifest.xml — the comment and the filter, as ONE span. Replaced // whole on purpose: round forty-eight's reason ("media3 finds its own // service through this") survives a careless edit and would then be a wrong // reason on a load-bearing comment, which is how the next round breaks it. const A_FILTER = ' ' + NL + ' ' + NL + ' ' + NL + ' '; const A_APPEND = ' ' + NL + ' '; // consumer-rules.pro const A_RULES = "# MediaService is declared in this module's AndroidManifest.xml, so AGP's" + NL + '# own merged-manifest keep rules hold it by name, and SoulStreamPlayer needs' + NL + '# no rule of its own — the only thing that reaches it is a direct reference' + NL + "# from MediaService. androidx's MediaButtonReceiver went with the move to" + NL + "# media3: the service's own SERVICE_INTERFACE filter routes media buttons" + NL + '# now, and two receivers would have fought over them.'; // tests/mobile/smoke.mjs const A_PLUMB_FN = '/* ── the service, the focus stack and the noisy receiver ──────────────'; const A_PLUMB_LOG = ' console.log(" -- " + "media-plumbing".padEnd(16) + " " + JSON.stringify(mediaPlumbing()) + " (advisory: prints, never fails)");'; // ANDROID.md const A_DOC_TABLE = '| `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) |'; /* CRLF: this sentence came from ins-r48-doc-bg.txt */ const A_DOC_PLUGIN = crlf('`MediaService` is a `MediaSessionService` holding' + NL + 'one `MediaSession`, and `SoulStreamPlayer` is a `SimpleBasePlayer`'); const A_DOC_UNCHANGED = '`src/mobile/media.svelte.js`, which did not change.'; /* CRLF: this paragraph came from ins-r48-doc-card-proves.txt */ const A_DOC_CARDPROVES = crlf('queue window arriving across the bridge, field for field (`queue-window`).' + NL + 'Printed but never failed, because their `dumpsys` formats have never been read' + NL + 'back from this phone: whether the service is foreground, whether our audio' + NL + 'focus is in the system\'s stack, and whether the becoming-noisy receiver is' + NL + 'registered (`media-plumbing`).'); const A_DOC_PROVES = 'Then, after the step loop, ten things the mock cannot do at all: a force-stop' + NL + 'and the paused restore that follows it (waited out past twenty-five seconds,' + NL + 'which is well past the ten-second FGS-kill window), the position restored,' + NL + 'playback resumed, the system media session and its ownership, media keys,' + NL + 'exactly one card in the shade and exactly one session in the system\'s list —' + NL + 'the two checks that prove the media3 migration landed rather than doubled —' + NL + 'ten seconds in the background with the position still advancing, SoulStream\'s' + NL + 'own mixer underruns, and the queue window that rides along with every card' + NL + 'update and that the lock-screen card is now built out of. One more line' + NL + 'prints and never fails: whether the service is foreground, whether our audio' + NL + 'focus is in the system\'s stack, and whether the becoming-noisy receiver is' + NL + 'registered. What none of it touches is in "What the walk proves about the' + NL + 'card, and what it does not", above.'; /* the heading the prober section is inserted BEFORE — the last subsection of "## Verifying" ends where this begins. Anchored on the heading that FOLLOWS rather than on "## Verifying" itself, so a sibling round inserting earlier in that section cannot collide with this one. */ const A_DOC_NOTDONE = NL + '## Not done yet' + NL; /* round forty-eight's whole "Android Auto, half-built" bullet, byte for byte from the insert that wrote it, replaced whole */ const A_DOC_AUTO = ins('ins-r48-doc-auto.txt'); for (const [file, anchors] of [ [PLUGIN + 'android/src/main/AndroidManifest.xml', [A_FILTER, A_APPEND]], [PLUGIN + 'android/consumer-rules.pro', [A_RULES]], ['tests/mobile/smoke.mjs', [A_PLUMB_FN, A_PLUMB_LOG]], ['ANDROID.md', [A_DOC_TABLE, A_DOC_PLUGIN, A_DOC_UNCHANGED, A_DOC_CARDPROVES, A_DOC_PROVES, A_DOC_NOTDONE, A_DOC_AUTO]], ]) { load(file); for (const a of anchors) once(a); } /* the file this round rewrites whole, and two the new Kotlin leans on: named here so a tree pointed at the wrong place fails now rather than after four writes. MediaService.kt has no text anchor at all — it is replaced from ins-r49-service.txt, round forty-eight's own idiom for this file — so its existence is the only check there can be. */ for (const f of [ KOTLIN + 'MediaService.kt', KOTLIN + 'SoulStreamPlayer.kt', PLUGIN + 'android/src/main/res/values/strings.xml', PLUGIN + 'android/src/main/res/drawable/ic_soulstream_note.xml', ]) { if (!fs.existsSync(root + f)) throw new Error('not the tree this round expects: missing ' + f); } /* the generated project is where probe.sh borrows its Gradle wrapper from, and it is gitignored — so its absence is a note, not a failure */ if (!fs.existsSync(root + 'src-tauri/gen/android/gradlew.bat')) { console.log('note: no src-tauri/gen/android/gradlew.bat — tools/auto-prober/probe.sh borrows that wrapper, so it wants a normal Android build first'); } /* and every insert, because a typo in a filename should not land half an Auto surface either */ for (const f of ['ins-r49-service.txt', 'ins-r49-walk-helper.txt', 'ins-r49-walk-checks.txt', 'ins-r49-probe-activity.txt', 'ins-r49-probe-manifest.txt', 'ins-r49-probe-sh.txt', 'ins-r49-probe-readme.txt', 'ins-r49-doc-prober.txt', 'ins-r49-doc-auto.txt', 'ins-r49-doc-proves.txt', 'ins-r49-doc-cardproves.txt', 'ins-r49-doc-singleton.txt']) { if (!fs.existsSync(scratch + f)) throw new Error('missing insert: ' + scratch + f); } // ── the resource that declares the app to a car ────────────────────────── /* Compiled with this project's own aapt2 (build-tools 36.0.0) before it was written here. It is referenced from the manifest as `@xml/automotive_app_desc`, which is a COMPILE-TIME reference: a missing resource fails the link rather than shipping a dangling meta-data id — and that is the only hard proof of this file there is, because `pm dump` prints no application meta-data at all. tools/auto-prober/ reads it back off the device through PackageManager.GET_META_DATA. */ fs.mkdirSync(root + XML, { recursive: true }); fs.writeFileSync(root + XML + 'automotive_app_desc.xml', [ '', '', '', ' ', '', '', ].join(NL)); // ── the manifest: three actions on one filter, and the Auto declaration ── load(PLUGIN + 'android/src/main/AndroidManifest.xml'); rep(A_FILTER, ' ' + NL + ' ' + NL + ' ' + NL + ' ' + NL + ' ' + NL + ' '); /* beside android:appCategory="audio", which is already this manifest's — a library attribute merges up when the app does not set one, and the app's own manifest lives in gitignored gen/ */ rep(A_APPEND, ' ' + NL + NL + ' ' + NL + ' ' + NL + ' '); save(); // ── the service, rewritten whole ───────────────────────────────────────── fs.writeFileSync(root + KOTLIN + 'MediaService.kt', ins('ins-r49-service.txt') + NL); // ── the keep rules: three objects now, and a different filter story ────── load(PLUGIN + 'android/consumer-rules.pro'); rep(A_RULES, "# MediaService is declared in this module's AndroidManifest.xml, so AGP's" + NL + '# own merged-manifest keep rules hold it by name, and SoulStreamPlayer,' + NL + '# MediaHub and LibraryTree need no rule of their own — the only thing that' + NL + '# reaches any of them is a direct reference from MediaService.' + NL + "# androidx's MediaButtonReceiver went with the move to media3: the" + NL + "# service's own filter routes media buttons now — through the" + NL + '# MediaLibraryService action, which MediaSessionLegacyStub queries first —' + NL + '# and two receivers would have fought over them.'); save(); // ── the prober: its own Gradle build, outside gen/ ─────────────────────── /* gen/android is regenerated by `npx tauri android init` and gitignored, so anything put in there is deleted the next time someone runs it. This is a separate build that borrows that project's WRAPPER — which pins Gradle 8.14.3, AGP 8.11.0, Kotlin 1.9.25 and JDK 17, all already in the cache, so a probe run downloads nothing. There is deliberately no gradle-wrapper.jar here: a Node patch cannot write a binary, and a second copy of one would be a second thing to keep in step. */ fs.mkdirSync(root + PROBER + 'java/com/soulstream/autoprober', { recursive: true }); fs.writeFileSync(root + TOOLS + 'settings.gradle.kts', [ '/* 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")', '', ].join(NL)); fs.writeFileSync(root + TOOLS + 'build.gradle.kts', [ '/* 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()', ' }', '}', '', ].join(NL)); fs.writeFileSync(root + TOOLS + 'gradle.properties', [ 'org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8', 'android.useAndroidX=true', 'android.nonTransitiveRClass=true', '', ].join(NL)); fs.writeFileSync(root + TOOLS + '.gitignore', [ '# the prober is source; its build output and its Gradle state are not', 'build/', '.gradle/', 'local.properties', '', ].join(NL)); fs.writeFileSync(root + TOOLS + 'prober/build.gradle.kts', [ '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.', '', ].join(NL)); fs.writeFileSync(root + PROBER + 'AndroidManifest.xml', ins('ins-r49-probe-manifest.txt') + NL); fs.writeFileSync(root + PROBER + 'java/com/soulstream/autoprober/ProbeActivity.kt', ins('ins-r49-probe-activity.txt') + NL); fs.writeFileSync(root + TOOLS + 'probe.sh', ins('ins-r49-probe-sh.txt') + NL); fs.writeFileSync(root + TOOLS + 'README.md', ins('ins-r49-probe-readme.txt') + NL); // ── the walk: one reader, two hard checks and an advisory ──────────────── load('tests/mobile/smoke.mjs'); /* the helper goes beside the other dumpsys readers, before mediaPlumbing() */ rep(A_PLUMB_FN, ins('ins-r49-walk-helper.txt') + NL + NL + A_PLUMB_FN); /* and the checks right after the plumbing line, while the app is still in front and something is playing — the same block one-card and one-session live in */ rep(A_PLUMB_LOG, A_PLUMB_LOG + NL + ins('ins-r49-walk-checks.txt')); save(); // ── the doc ────────────────────────────────────────────────────────────── load('ANDROID.md'); rep(A_DOC_TABLE, A_DOC_TABLE + NL + '| `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) |'); rep(A_DOC_PLUGIN, '`MediaService` is a `MediaLibraryService` holding' + NL + 'one process-lifetime `MediaLibrarySession`, which is what lets Android Auto' + NL + 'list and open the app at all, and `SoulStreamPlayer` is a' + NL + '`SimpleBasePlayer`'); rep(A_DOC_UNCHANGED, A_DOC_UNCHANGED + NL + NL + ins('ins-r49-doc-singleton.txt')); rep(A_DOC_CARDPROVES, ins('ins-r49-doc-cardproves.txt')); rep(A_DOC_PROVES, ins('ins-r49-doc-proves.txt')); /* a section of its own, as the last thing under "## Verifying" */ rep(A_DOC_NOTDONE, NL + ins('ins-r49-doc-prober.txt') + NL + NL + '## Not done yet' + NL); /* and what is now true, and what is still not */ rep(A_DOC_AUTO, ins('ins-r49-doc-auto.txt')); save(); console.log('round 49 wired');