Files
SoulStream/handoff/rounds/patch-round48.cjs
T
KeelrLeek 07dad65ca5 android: the keep rules, and the release build that proves them
Every APK this port has produced is `assembleArm64Debug`, and the generated
project turns R8 on for `release` and only for `release` — while tauri finds a
plugin class by string, files every `@Command` under `commands[method.name]`,
and lets Jackson fill every `@InvokeArg` field by field. Minification breaks
exactly that, and it breaks it without a word: the app compiles, installs,
launches, shows Home, and then a tap on Library folder answers "No command
pickFolder found for plugin ..." in logcat and nothing on screen. So each
plugin module now carries its own `consumer-rules.pro` naming its own
reflected surface — the shape tauri-android and tauri-plugin-dialog already
use in this build, and one that travels with the plugin into a regenerated
`gen/android`, which a rule written into that gitignored tree would not.

What belongs to no single plugin is in `src-tauri/android/proguard-soulstream.pro`,
copied into the generated project by the build script because the app's own
Gradle file is regenerated: the runtime annotation attributes, a `-dontwarn`
pair for the two `java.beans` classes jackson-databind references and Android
does not have (R8 turns a missing class into a build error, so without them
there may be no APK at all), and a keep for the four `@Command` methods
declared on `app.tauri.plugin.Plugin` itself. Tauri's own rule matches classes
annotated `@TauriPlugin` and the members they declare; the base class carries
no such annotation, so nothing names `registerListener` — which is what the
lock-screen card's buttons and the download notice's Cancel and Open ride on.

`build.sh` grows `--release`: the release profile, `--features tauri/devtools`
so the walk can still attach at all, `assembleArm64Release`, and a zipalign and
sign with the same debug key the phone already trusts, so the APK installs over
the app rather than forcing an uninstall that would take Kyle's settings with
it. With no argument the script is what it has always been, cargo line for
cargo line, so deploy.sh and ship.sh keep the path they run every twenty
minutes. And it has been built. Not on the phone and not as arm64 — as **x86_64**, on an
emulator, because that is what this machine can run — but the flavour is the
only difference: the same Gradle, the same R8, the same rules, the same
zipalign and apksigner. `minifyX86_64ReleaseWithR8` succeeded and wrote **no**
`missing_rules.txt` at all, which is the `-dontwarn` pair doing its job;
`lintVitalX86_64Release` passed, so the `-x lintVital` advice is for a failure
that has not happened. The signed APK went over the debug app with
`adb install -r` and left its data alone, exactly as the same-key argument
above says it must.

Then the minified app was walked: Home, the record, playback, the queue, the
sheets, the lyrics, the drags, the export — **131 of 131, no exceptions, no
console errors, every step as expected**, and nothing in logcat about a missing
command or class. That is the failure this round exists to prevent, looked for
and not found.

What it does not cover is the hands-only list in ANDROID.md — the pickers, the
notification buttons, the share sheet — which no walk reaches, and the four
`@Command` methods on `Plugin` itself remain a precaution rather than a
diagnosed bug. The document says so, command by command.
2026-09-14 11:09:33 -05:00

528 lines
35 KiB
JavaScript

// Round forty-eight: the media card, on media3.
//
// The card on Kyle's lock screen has been a hand-built MediaStyle
// notification over a MediaSessionCompat, with a MediaButtonReceiver for the
// headset and audio focus managed by hand — all of it on `androidx.media`,
// which was deprecated in 1.8.0 on 2026-05-06. Android Auto needs a media3
// session to browse, so the choice was to add new code to a deprecated
// library or to move the card first. This moves the card, and adds no Auto:
// `MediaService` becomes a `MediaSessionService`, a new `SoulStreamPlayer`
// is the `SimpleBasePlayer` media3 asks for when the engine is somewhere
// else (rodio, in Rust, behind a WebView round trip), and media3 posts and
// owns the notification from here on.
//
// It cannot be split. A midpoint with both stacks live puts two cards in the
// shade and two records in `dumpsys media_session`, and two components fight
// over every headset button — which is why the one new assertion in the walk
// is `one-card`, the only check that can tell a landed migration from a
// doubled one.
//
// Three things the research design got wrong, corrected here and argued in
// the files themselves: media3 1.10.1 and not 1.11.0 (whose POM drags in
// kotlin-stdlib 2.2.10 against this project's Kotlin 1.9.25); the audio-focus
// and becoming-noisy blocks stay, because `handleSetAudioAttributes` is
// @ForOverride and throws by default and media3 does no focus work for a
// custom player; and the service is BOUND, never started, because
// `MediaSessionService.onStartCommand` returns START_STICKY without promoting
// for any intent that is not media3's own — a `startForegroundService` would
// arm the ten-second kill timer and satisfy nothing, which is the 2026-09-13
// bug with a new coat on.
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/';
const ins = (name) => fs.readFileSync(scratch + name, 'utf8').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);
const PLUGIN = 'crates/tauri-plugin-media-session/';
const KOTLIN = PLUGIN + 'android/src/main/java/com/soulstream/mediasession/';
/* ── the re-run guard, before a byte moves ────────────────────────────
This round `rep`s across six files and rewrites a seventh. A second pass
would throw on the first anchor it had already consumed and leave a tree
with the new build.gradle.kts and no MediaService, or the reverse — an app
with no media session at all. SoulStreamPlayer.kt is what this round
creates and nothing else in the repo makes. */
if (fs.existsSync(root + KOTLIN + 'SoulStreamPlayer.kt')) {
throw new Error(KOTLIN + 'SoulStreamPlayer.kt already exists — round 48 has already run against ' + root);
}
/* ── every anchor, and every insert, before the first write ───────────
The round-44 lesson: check it ALL here, so the round is all or nothing. */
// build.gradle.kts
const A_GRADLE = ' implementation("androidx.core:core-ktx:1.13.1")' + NL +
' // MediaSessionCompat, the MediaStyle notification, MediaButtonReceiver' + NL +
' implementation("androidx.media:media:1.7.0")';
// AndroidManifest.xml — the service, and the receiver that goes with it
const A_SERVICE = ' android:exported="false"' + NL +
' android:foregroundServiceType="mediaPlayback">' + NL +
' <!-- lets MediaButtonReceiver forward headset and card buttons here -->' + NL +
' <intent-filter>' + NL +
' <action android:name="android.intent.action.MEDIA_BUTTON" />' + NL +
' </intent-filter>';
const A_RECEIVER = ' </service>' + NL + NL +
' <receiver' + NL +
' android:name="androidx.media.session.MediaButtonReceiver"' + NL +
' android:exported="true">' + NL +
' <intent-filter>' + NL +
' <action android:name="android.intent.action.MEDIA_BUTTON" />' + NL +
' </intent-filter>' + NL +
' </receiver>' + NL +
' </application>';
// consumer-rules.pro
const A_RULES = "# MediaService and androidx's MediaButtonReceiver are declared in this" + NL +
"# module's AndroidManifest.xml, so AGP's own merged-manifest keep rules hold" + NL +
'# them by name. Not repeated here: one place decides each of them.';
// MediaSessionPlugin.kt
const A_KT_STALE = ' * NOTHING READS IT: MediaService ignores both of these on purpose, so' + NL +
" * the card is byte for byte the card it was. media3's" + NL +
' * SimpleBasePlayer.getState() wants a playlist and an index into it,' + NL +
' * and this is that data arriving a round before the migration does.';
const A_KT_INDEX = ' var queueIndex: Int? = null';
/* the one line on the DEVICE that says what crossed the bridge. Round
forty-eight adds two fields to that bridge and neither is on any screen,
so neither is readable at all until this line carries them. */
const A_KT_LOG = ' /* The window is on no screen anywhere — MediaService ignores it and' + NL +
' the card cannot show it — so logcat is the only place on the' + NL +
' device it can be read back, and tests/mobile/smoke.mjs reads it' + NL +
' from exactly here. One line per update, and an update is a track' + NL +
' change, a transport change or the thirty-second top-up, never a' + NL +
' position tick. `at=` goes LAST because a title may hold anything,' + NL +
' spaces and `=` included. */' + NL +
' val window = args.queue ?: emptyList()' + NL +
' android.util.Log.i(' + NL +
' "SoulStream",' + NL +
' "media window n=" + window.size + " i=" + (args.queueIndex ?: -1) +' + NL +
' " art=" + window.count { !it.artKey.isNullOrEmpty() } +' + NL +
' " at=" + (window.getOrNull(args.queueIndex ?: -1)?.title ?: ""),' + NL +
' )';
// lib.rs
const A_RS_STALE = ' /// NOTHING READS IT YET, and that is the point — `MediaService` ignores' + NL +
' /// both of these fields, so the card is exactly the card it was. media3\'s' + NL +
' /// `SimpleBasePlayer.getState()` wants a playlist and an index into it,' + NL +
' /// and when that migration comes the data it needs should have been' + NL +
' /// running on the phone for a while rather than have been written the' + NL +
' /// same day.';
const A_RS_INDEX = ' pub queue_index: Option<u32>,';
// media.svelte.js
const A_JS_INDEX = ' state.queueIndex = at >= 0 && at < win.length ? at : null;';
/* the file's own opening paragraph, which still said nothing reads it */
const A_JS_STALE = " the current item and QUEUE_WINDOW either side — because media3's" + NL +
' SimpleBasePlayer.getState() wants a playlist and an index into it, and' + NL +
' the migration to it should find this data already running rather than' + NL +
' written the same day. Nothing reads it yet: MediaService ignores both' + NL +
' fields and the card is exactly the card it was.';
// tests/mobile/smoke.mjs
const A_SLEEP = ' await sleep(13000);';
const A_SESSFN = "/* The system's view of the app's media session: who owns it and what the" + NL +
' * playback state says. From `dumpsys media_session`. */' + NL +
'function mediaSessionState() {';
const A_SESSDUMP = ' const dump = adb("shell", "dumpsys", "media_session").toString();' + NL +
' /* sessions are listed as blocks; find ours by package, then the first' + NL +
' playback state after it */';
const A_SESSEARLY = ' if (at < 0) return { owner: "", state: "" };';
const A_SESSRET = ' return { owner: "com.soulstream.app", state: state[1] || "", position: state[2] ? +state[2] / 1000 : null };';
const A_SESSCATCH = ' } catch { return { owner: "", state: "" }; }';
const A_CARDLOG = ' console.log((cardOk ? " ok " : " FAIL ") + "media-card".padEnd(16) + " " + JSON.stringify(cardSeen));';
/* the two halves of the logcat readback: the line mediaWindow() parses, and
the predicate queue-window holds it against */
const A_WINPARSE = ' const m = (lines[lines.length - 1] || "").match(/media window n=(' + '\\' + 'd+) i=(-?' + '\\' + 'd+) art=(' + '\\' + 'd+) at=(.*)$/);' + NL +
' if (!m) return { n: -1, i: -1, art: -1, at: "", lines: lines.length };' + NL +
' return { n: +m[1], i: +m[2], art: +m[3], at: m[4].trim(), lines: lines.length };';
const A_WINERR = ' } catch (e) { return { n: -1, i: -1, art: -1, at: "ERR " + e.message, lines: 0 }; }';
const A_WINOK = ' && wins[1][2].lines > wins[0][2].lines && wins[2][2].lines > wins[1][2].lines;';
/* two comments that said "nothing reads the window". Something does now, and
a wrong reason on a check is worse than no reason at all. */
const A_WINFN = ' * update — the current item and ten either side — and left MediaService' + NL +
' * ignoring all of it, so there is nothing on any screen to read it back' + NL +
' * from: logcat is the only place on the device it exists, one line per' + NL +
' * update, and the LAST line is the state now.';
const A_WINWHY = ' its id, title, artist, album, duration and art key, plus where' + NL +
' the current one sits among them — and left MediaService' + NL +
" ignoring every bit of it, because media3's" + NL +
' SimpleBasePlayer.getState() will want a playlist and an index,' + NL +
' and that data should be a round old before anything depends on' + NL +
' it. Which means every check above this one is blind to it: a';
// ANDROID.md
const A_DOC_ROW = '| `crates/tauri-plugin-media-session/` | a Tauri plugin with a Kotlin foreground service (`MediaService`) that owns the `MediaSession`, the MediaStyle notification, audio focus and the becoming-noisy pause. The audio never passes through it; it is what keeps the process alive in the background and what the lock screen shows |';
const A_DOC_BG = 'The service is started with `startForegroundService`, so it promotes itself' + NL +
'FIRST in `onStartCommand` — with the card there is, or a plain one — and' + NL +
'only then mirrors the state, stepping back (`STOP_FOREGROUND_DETACH`) when' + NL +
'the track is paused or stopping when the bar is empty. Android 14 kills the' + NL +
'whole app about ten seconds after a foreground start that never called' + NL +
'`startForeground()`, and every restart restores a PAUSED track, which is' + NL +
'exactly that case: on 2026-09-13 the first real Save died with the process' + NL +
'that way, four tracks in. The check: force-stop, relaunch with a paused' + NL +
'track, wait 25 s, the pid survives.';
const A_DOC_PLUGIN = '`crates/tauri-plugin-media-session` is a Tauri mobile plugin: `build.rs`' + NL +
'hands its `android/` Gradle module to tauri-build (which writes it into' + NL +
'`gen/android/tauri.settings.gradle`), `src/lib.rs` registers the Kotlin' + NL +
'class and exposes `update` / `clear`, and the Kotlin side is two files —' + NL +
'the plugin (arguments in, the `action` event out) and `MediaService`, a' + NL +
'`mediaPlayback` foreground service holding a `MediaSessionCompat` and a' + NL +
'MediaStyle notification. Playing → `startForeground`; paused → the service' + NL +
'steps back and the card stays, dismissable. Android 13+ asks for' + NL +
'`POST_NOTIFICATIONS` the first time something plays. Audio focus is' + NL +
'requested while playing (loss pauses, a transient loss pauses and resumes),' + NL +
'and headphones coming out pause. The app\'s capability grants' + NL +
'`media-session:default`; the plugin is registered on every platform so the' + NL +
'permission resolves everywhere, and on the desktop its commands answer' + NL +
'"not here" (the desktop has `smtc.rs`).';
const A_DOC_ICON = 'The card\'s large icon falls back to the app\'s own icon. `MediaHub.art` is' + NL +
'cleared the moment `artKey` changes and only refilled once the frontend has' + NL +
'decoded a cover into bytes, and a track with no cover never refills it at' + NL +
'all — so without a fallback every track change has a window where the lock' + NL +
'screen shows a grey hole. `MediaHub.appIcon()` decodes the icon ONCE and' + NL +
'keeps it (`refresh()` runs on every position tick, so a per-update decode' + NL +
'would be a real cost), through the PackageManager rather than a resource id' + NL +
'because a library module\'s own R has no `ic_launcher`.';
const A_DOC_ART = 'The same bitmap serves' + NL + '`METADATA_KEY_ALBUM_ART`.';
const A_DOC_PROVES = 'Then, after the step loop, eight things the mock cannot do at all: a' + NL +
'force-stop and the paused restore that follows it (waited out past the' + NL +
'thirteen-second FGS-kill window), the position restored, playback resumed, the' + NL +
'system media session and its ownership, media keys, ten seconds in the' + NL +
'background with the position still advancing, SoulStream\'s own mixer' + NL +
'underruns, and the queue window that rides along with every card update and' + NL +
'that nothing on the card reads.';
const A_DOC_QUEUE = 'Every update also carries a **queue window** that nothing reads. `NowPlaying`' + NL +
'(Rust) and `NowPlayingArgs` (Kotlin) hold a `queue` of at most twenty-one' + NL +
'entries — the current item and ten either side, each with its id, title,' + NL +
'artist, album, duration and art key — plus `queueIndex`, where the current one' + NL +
'sits *within that window* rather than within the whole queue.' + NL +
'`src/mobile/media.svelte.js` fills both from `player.queue` and `player.qi`;' + NL +
'`MediaService` ignores them completely, so the card is byte for byte the card' + NL +
'it was. The reason is media3: `SimpleBasePlayer.getState()` wants a playlist' + NL +
'and an index into it, and the data that migration needs should have been' + NL +
'running on the phone for a while before anything depends on it.';
const A_DOC_SCREENREADER = '## What a screen reader says';
const A_DOC_LOGLINE = '`MediaSessionPlugin.update` logs one `media window n=… i=… art=… at=…` line' + NL +
"per update under the tag `SoulStream`, and the phone walk's `queue-window`" + NL +
'check holds that against `__ss.card` — the mirror `media.svelte.js` keeps of' + NL +
"what it last sent, beside xfernote's — field for field, through a next and a" + NL +
'previous. Field for field is deliberate: a key spelled snake_case where the' + NL +
'bridge wants camelCase arrives as nothing at all on both hops, with no error' + NL +
'anywhere, which is why the art-key count is compared and not just the size and' + NL +
'the index.';
const A_DOC_TAIL = ' the share sheet, which no walk reaches. Work down the smoke list in "The' + NL +
' release build" before trusting a release APK on a phone.';
for (const [file, anchors] of [
[PLUGIN + 'android/build.gradle.kts', [A_GRADLE]],
[PLUGIN + 'android/src/main/AndroidManifest.xml', [A_SERVICE, A_RECEIVER]],
[PLUGIN + 'android/consumer-rules.pro', [A_RULES]],
[KOTLIN + 'MediaSessionPlugin.kt', [A_KT_STALE, A_KT_INDEX, A_KT_LOG]],
[PLUGIN + 'src/lib.rs', [A_RS_STALE, A_RS_INDEX]],
['src/mobile/media.svelte.js', [A_JS_STALE, A_JS_INDEX]],
['tests/mobile/smoke.mjs', [A_SLEEP, A_SESSFN, A_SESSDUMP, A_SESSEARLY, A_SESSRET,
A_SESSCATCH, A_CARDLOG, A_WINPARSE, A_WINERR, A_WINOK, A_WINFN, A_WINWHY]],
/* the two opening paragraphs are replaced as ONE span, so it is the span
that has to be checked — two separate hits would not prove they are
still adjacent */
['ANDROID.md', [A_DOC_ROW, A_DOC_BG + NL + NL + A_DOC_PLUGIN, A_DOC_ICON, A_DOC_ART,
A_DOC_PROVES, A_DOC_QUEUE, A_DOC_LOGLINE, A_DOC_SCREENREADER, A_DOC_TAIL]],
]) {
load(file);
for (const a of anchors) once(a);
}
/* the file this round rewrites whole, and the directory the two new Kotlin
files land in: named here so a tree pointed at the wrong place fails now
rather than after four writes */
for (const f of [KOTLIN + 'MediaService.kt', 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);
}
/* and every insert, because a typo in a filename should not land half a
migration either */
for (const f of ['ins-r48-gradle.txt', 'ins-r48-player.txt', 'ins-r48-service.txt',
'ins-r48-walk-helper.txt', 'ins-r48-walk-checks.txt', 'ins-r48-doc-bg.txt',
'ins-r48-doc-card-proves.txt', 'ins-r48-doc-proves.txt', 'ins-r48-doc-queue.txt',
'ins-r48-doc-auto.txt']) {
if (!fs.existsSync(scratch + f)) throw new Error('missing insert: ' + scratch + f);
}
// ── the Gradle module: media3 in, androidx.media out of OUR compile path ──
load(PLUGIN + 'android/build.gradle.kts');
rep(A_GRADLE, ins('ins-r48-gradle.txt'));
save();
// ── the manifest: media3's own service filter, and no receiver at all ────
load(PLUGIN + 'android/src/main/AndroidManifest.xml');
rep(A_SERVICE,
' android:exported="true"' + NL +
' android:foregroundServiceType="mediaPlayback">' + NL +
' <!-- media3 finds its own service through this, and while the' + NL +
' process is alive it is what routes a headset or a card' + NL +
' button here. There is no MediaButtonReceiver any more, and' + NL +
" media3's own is deliberately NOT put in its place: its" + NL +
' docs make that receiver conditional on' + NL +
' MediaSession.Callback.onPlaybackResumption(), which this' + NL +
' app cannot implement — playback lives in Rust behind a' + NL +
' WebView round trip. Two receivers would only have fought' + NL +
' over the buttons. -->' + NL +
' <intent-filter>' + NL +
' <action android:name="androidx.media3.session.MediaSessionService" />' + NL +
' </intent-filter>');
rep(A_RECEIVER, ' </service>' + NL + ' </application>');
save();
// ── the channel's name, as a string resource ─────────────────────────────
/* DefaultMediaNotificationProvider.Builder.setChannelName takes a @StringRes
int and there is no overload that takes a String. Without it the channel
media3 would create on a FRESH install carries media3's own
default_notification_channel_name. On Kyle's phone it changes nothing:
Util.ensureNotificationChannel is a no-op when the channel exists, and
MediaService.createChannel() still gets in first anyway — that is what
keeps the lockscreen-PUBLIC visibility media3 would not set. */
fs.mkdirSync(root + PLUGIN + 'android/src/main/res/values', { recursive: true });
fs.writeFileSync(root + PLUGIN + 'android/src/main/res/values/strings.xml', [
'<?xml version="1.0" encoding="utf-8"?>',
'<resources>',
' <!-- The channel the card sits on. Its ID stays literally "playback":',
' tests/mobile/smoke.mjs:2448 finds the card by that id, and the',
' channel already on the phone — IMPORTANCE_LOW, lockscreen',
' PUBLIC, no badge — is left exactly as it is. -->',
' <string name="ss_playback_channel">Playback</string>',
'</resources>',
'',
].join(NL));
// ── the Player media3 shows the world, and the service that holds it ─────
fs.writeFileSync(root + KOTLIN + 'SoulStreamPlayer.kt', ins('ins-r48-player.txt') + NL);
fs.writeFileSync(root + KOTLIN + 'MediaService.kt', ins('ins-r48-service.txt') + NL);
// ── the keep rules: there is no receiver to name any more ────────────────
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 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.');
save();
// ── the bridge: queueBase and repeat, and two comments that went stale ───
load(KOTLIN + 'MediaSessionPlugin.kt');
rep(A_KT_STALE,
' * Since round forty-eight SoulStreamPlayer.getState() turns this into' + NL +
' * the media3 playlist the card is built from, and the index into it.');
rep(A_KT_INDEX,
A_KT_INDEX + NL +
' /** where the window starts in the WHOLE queue. A stable media3 uid is' + NL +
' * built out of it, so an entry keeps its identity as the window slides' + NL +
' * past it; it is also half of the row -> playQueueAt() mapping Auto' + NL +
' * will want. Null when the window is empty. */' + NL +
' var queueBase: Int? = null' + NL +
" /** Repeat, in the APP's numbering: 0 off, 1 all, 2 one. media3 numbers" + NL +
' * them 0 OFF, 1 ONE, 2 ALL — SWAPPED — and SoulStreamPlayer maps across' + NL +
' * rather than casting. */' + NL +
' var repeat: Int = 0');
/* the log line, which is now the ONLY readback there is for queueBase and
repeat: neither is on a screen, neither is on the card, and a camelCase
key spelled the wrong way arrives as nothing at all on both hops of this
bridge with no error anywhere — a dropped `repeat` would simply have the
card report repeat-off forever, and a dropped `queueBase` would make every
track change look like twenty-one new rows to media3. */
rep(A_KT_LOG,
' /* The window is on no screen anywhere — the card shows ONE track —' + NL +
' so logcat is the only place on the device the whole of it can be' + NL +
' read back, and tests/mobile/smoke.mjs reads it from exactly here.' + NL +
' One line per update, and an update is a track change, a transport' + NL +
' change or the thirty-second top-up, never a position tick.' + NL +
' `base` and `rep` are here for the same reason and a sharper one:' + NL +
' since round forty-eight they steer the media3 playlist and what' + NL +
' a controller is told about repeat, they appear on no screen, and a' + NL +
' camelCase key spelled snake_case arrives as nothing on either hop' + NL +
' of this bridge without an error anywhere. `at=` goes LAST because' + NL +
' a title may hold anything, spaces and `=` included. */' + NL +
' val window = args.queue ?: emptyList()' + NL +
' android.util.Log.i(' + NL +
' "SoulStream",' + NL +
' "media window n=" + window.size + " i=" + (args.queueIndex ?: -1) +' + NL +
' " art=" + window.count { !it.artKey.isNullOrEmpty() } +' + NL +
' " base=" + (args.queueBase ?: -1) + " rep=" + args.repeat +' + NL +
' " at=" + (window.getOrNull(args.queueIndex ?: -1)?.title ?: ""),' + NL +
' )');
save();
load(PLUGIN + 'src/lib.rs');
rep(A_RS_STALE,
' /// Round forty-eight is where this BECAME the card. `SoulStreamPlayer`' + NL +
' /// is a media3 `SimpleBasePlayer`, its `getState()` wants a playlist and' + NL +
' /// an index into one, and this window and `queue_index` are exactly that' + NL +
' /// — so what the lock screen shows is built out of these fields rather' + NL +
' /// than beside them.');
rep(A_RS_INDEX,
A_RS_INDEX + NL +
' /// Where `queue[0]` sits in the WHOLE queue. A media3 `MediaItemData`' + NL +
' /// uid is built from it, so an entry keeps its identity as the window' + NL +
' /// slides past it and a track change is ONE media-item transition' + NL +
' /// rather than twenty-one re-labelled rows; it is also half of the' + NL +
' /// mapping Android Auto needs to turn a tapped queue row back into' + NL +
' /// `playQueueAt()`. `None` when the window is empty.' + NL +
' pub queue_base: Option<u32>,' + NL +
" /// Repeat, in the app's own numbering: 0 off, 1 all, 2 one. media3" + NL +
' /// numbers them 0 OFF, 1 ONE, 2 ALL — SWAPPED — and the Kotlin side' + NL +
' /// maps across rather than casting, which would turn repeat-all into' + NL +
' /// repeat-one. It crosses at all so the player\'s state is not a lie to' + NL +
' /// whatever reads it — the system\'s own media controls now, Android' + NL +
' /// Auto later — and so media3\'s view of "is there a next item" agrees' + NL +
' /// with the `has_next` the card\'s next button is advertised on.' + NL +
' #[serde(default)]' + NL +
' pub repeat: u8,');
save();
load('src/mobile/media.svelte.js');
rep(A_JS_STALE,
' the current item and QUEUE_WINDOW either side. Since round forty-eight' + NL +
' SoulStreamPlayer.getState() builds the media3 playlist and the index' + NL +
' into it out of exactly these fields, so what the lock screen shows is' + NL +
' made of them rather than beside them.');
rep(A_JS_INDEX,
A_JS_INDEX + NL +
' /* where the window STARTS in the whole queue. It is what gives an' + NL +
' entry a stable media3 uid as the window slides past it — without it' + NL +
' a track change re-labels all twenty-one rows and media3 reports a' + NL +
' phantom media-item transition — and it is half of the mapping Auto' + NL +
' needs to turn a tapped queue row back into playQueueAt(). camelCase,' + NL +
' like everything on this bridge: a snake_case key is silently ignored' + NL +
' on both hops, with no error anywhere. */' + NL +
' state.queueBase = player.qi < 0 ? null : lo;' + NL +
' /* 0 off, 1 all, 2 one — ours. media3 numbers them 0 OFF, 1 ONE, 2 ALL' + NL +
' and SoulStreamPlayer maps across rather than casting, which would' + NL +
' turn repeat-all into repeat-one. It crosses so the player\'s state is' + NL +
' not a lie to whatever reads it — the system\'s own media controls' + NL +
' now, Auto later — and so media3\'s view of "is there a next item"' + NL +
' agrees with the hasNext the next button is advertised on. */' + NL +
' state.repeat = player.repeat;');
save();
// ── the walk ─────────────────────────────────────────────────────────────
load('tests/mobile/smoke.mjs');
/* twenty-five seconds, and ANDROID.md said so in one place already */
rep(A_SLEEP,
' /* TWENTY-FIVE seconds. Android kills an app about ten seconds' + NL +
' after a foreground start that never called startForeground(),' + NL +
' and since round forty-eight nothing here starts the service at' + NL +
' all — it is bound, and media3 promotes it on its own schedule —' + NL +
' so the margin is over the window rather than beside it. The' + NL +
' doc claimed 25 s in one place and thirteen in another; both' + NL +
' say 25 now. */' + NL +
' await sleep(25000);');
/* the plumbing helper, beside the other dumpsys readers */
rep(A_SESSFN, ins('ins-r48-walk-helper.txt') + NL + NL + A_SESSFN);
/* and a count of OUR session records, for one-session below */
rep(A_SESSDUMP,
' const dump = adb("shell", "dumpsys", "media_session").toString();' + NL +
' /* how many sessions the system holds for US. Two would mean the' + NL +
' old MediaSessionCompat stack and media3\'s are both live, which is' + NL +
' the one way the migration in round forty-eight could have gone' + NL +
' wrong without anything crashing. Counted loosely and REPORTED' + NL +
' rather than trusted: a zero here means the dump did not say' + NL +
' `package=<us>` where this looked, not that there is no session —' + NL +
' `media-session` is what asserts that there is one. */' + NL +
' const sessions = (dump.match(/package=com\\.soulstream\\.app\\b/g) || []).length;' + NL +
' /* sessions are listed as blocks; find ours by package, then the first' + NL +
' playback state after it */');
rep(A_SESSEARLY, ' if (at < 0) return { owner: "", state: "", sessions };');
rep(A_SESSRET, A_SESSRET.replace('position: state[2] ? +state[2] / 1000 : null };',
'position: state[2] ? +state[2] / 1000 : null, sessions };'));
rep(A_SESSCATCH, ' } catch { return { owner: "", state: "", sessions: -1 }; }');
/* one card, one session, and the plumbing — right after media-card */
rep(A_CARDLOG, A_CARDLOG + NL + ins('ins-r48-walk-checks.txt'));
/* the logcat readback, widened to the two fields round forty-eight adds.
Nothing else on the device can be asked about `queueBase` or `repeat`:
they are on no screen and the card shows neither, so without these two
the bridge could drop either one and every check would stay green. */
rep(A_WINPARSE,
' const m = (lines[lines.length - 1] || "").match(' + NL +
' /media window n=(\\d+) i=(-?\\d+) art=(\\d+) base=(-?\\d+) rep=(\\d+) at=(.*)$/);' + NL +
' if (!m) return { n: -1, i: -1, art: -1, base: -2, rep: -1, at: "", lines: lines.length };' + NL +
' return { n: +m[1], i: +m[2], art: +m[3], base: +m[4], rep: +m[5], at: m[6].trim(), lines: lines.length };');
rep(A_WINERR, ' } catch (e) { return { n: -1, i: -1, art: -1, base: -2, rep: -1, at: "ERR " + e.message, lines: 0 }; }');
rep(A_WINOK,
' && wins[1][2].lines > wins[0][2].lines && wins[2][2].lines > wins[1][2].lines' + NL +
' /* and the two fields round forty-eight added, which nothing' + NL +
' else on the device can be asked about. The block arms at' + NL +
' index one, so the window starts at zero and `base` must BE' + NL +
' zero at all three readings; `rep` must be the repeat mode' + NL +
' the page reports, unchanged across the block. Either one' + NL +
' spelled snake_case on either hop arrives as nothing and' + NL +
' lands here as -1: `repeat` dropped has the card report' + NL +
' repeat-off forever, and `queueBase` dropped makes every' + NL +
' track change look like twenty-one new rows to media3. */' + NL +
' && wins.every(([, , got]) => got.base === 0 && got.rep === armed.rep);');
/* and the two comments that still said nothing read the window */
rep(A_WINFN,
' * update — the current item and ten either side. Since round forty-eight' + NL +
' * SoulStreamPlayer builds the media3 playlist out of it, but the card' + NL +
' * still shows ONE track, so logcat remains the only place on the device' + NL +
' * the whole window can be read back: one line per update, and the LAST' + NL +
' * line is the state now.');
rep(A_WINWHY,
' its id, title, artist, album, duration and art key, plus where' + NL +
' the current one sits among them; round forty-eight made it the' + NL +
' media3 playlist the card is built from. Every check above this' + NL +
' one is still blind to the window itself, because the card shows' + NL +
' one track and `dumpsys` can only be asked about that one: a');
save();
// ── the doc ──────────────────────────────────────────────────────────────
load('ANDROID.md');
rep(A_DOC_ROW,
'| `crates/tauri-plugin-media-session/` | a Tauri plugin with a Kotlin `MediaSessionService` (`MediaService`) and a `SimpleBasePlayer` over the Rust engine (`SoulStreamPlayer`), on androidx.media3. media3 posts and owns the card; audio focus and the becoming-noisy pause are still ours. The audio never passes through it; it is what keeps the process alive in the background and what the lock screen shows |');
/* the two opening paragraphs, replaced by what the migration actually did */
rep(A_DOC_BG + NL + NL + A_DOC_PLUGIN, ins('ins-r48-doc-bg.txt'));
rep(A_DOC_ICON,
'The card\'s artwork falls back to the app\'s own icon. `MediaHub.artBytes`' + NL +
'is cleared the moment `artKey` changes and only refilled once the frontend' + NL +
'has decoded a cover into bytes, and a track with no cover never refills it' + NL +
'at all — so without a fallback every track change has a window where the' + NL +
'lock screen shows a grey hole. `MediaHub.appIcon()` encodes the icon ONCE' + NL +
'and keeps the PNG bytes (`getState()` runs on every invalidation, so a' + NL +
'per-update encode would be a real cost), through the PackageManager rather' + NL +
'than a resource id because a library module\'s own R has no `ic_launcher`.');
rep(A_DOC_ART,
'The same bytes go across as' + NL +
'`MediaMetadata.artworkData`, which media3 decodes itself — one' + NL +
'`BitmapFactory` call this plugin no longer makes.');
rep(A_DOC_PROVES, ins('ins-r48-doc-proves.txt'));
rep(A_DOC_QUEUE, ins('ins-r48-doc-queue.txt'));
/* the log line the walk parses, named here field for field — it grew two */
rep(A_DOC_LOGLINE,
'`MediaSessionPlugin.update` logs one' + NL +
'`media window n=… i=… art=… base=… rep=… at=…` line per update under the tag' + NL +
"`SoulStream`, and the phone walk's `queue-window` check holds that against" + NL +
'`__ss.card` — the mirror `media.svelte.js` keeps of what it last sent, beside' + NL +
"xfernote's — field for field, through a next and a previous. Field for field" + NL +
'is deliberate: a key spelled snake_case where the bridge wants camelCase' + NL +
'arrives as nothing at all on both hops, with no error anywhere, which is why' + NL +
'the art-key count is compared and not just the size and the index. `base` and' + NL +
'`rep` are on that line for a sharper version of the same reason: they steer' + NL +
"the media3 playlist and the card's next button, they are on no screen and no" + NL +
'card, and this is the only place on the device either of them can be read' + NL +
'back at all.');
/* the card's own honest list, at the end of the section it belongs to */
rep(A_DOC_SCREENREADER, ins('ins-r48-doc-card-proves.txt') + NL + NL + A_DOC_SCREENREADER);
rep(A_DOC_TAIL, A_DOC_TAIL + NL + NL + ins('ins-r48-doc-auto.txt'));
save();
console.log('round 48 wired');