android: the lock-screen card, moved to media3 before anything is built on it
The card Kyle sees every day was a hand-built MediaStyle notification over a MediaSessionCompat, on `androidx.media`, deprecated since May. Android Auto needs a media3 session to browse, so the choice was to write the new thing against a deprecated library or to move the old thing first; this moves the old thing and adds no Auto. `MediaService` is a `MediaSessionService` and a new `SoulStreamPlayer` is the `SimpleBasePlayer` media3 asks for when the engine lives somewhere else — rodio, in Rust, behind a WebView round trip — so `getState()` is a pure read of the last `NowPlayingArgs` off the bridge, queue window and all, and every button routes back out through `MediaHub.act`. Three corrections to the plan, each the kind that only shows up on a phone. media3 **1.10.1**, not 1.11.0, whose POM drags kotlin-stdlib 2.2.10 into a project that compiles with Kotlin 1.9.25. The audio-focus and becoming-noisy blocks STAY hand-rolled: `setAudioAttributes(…, handleAudioFocus)` is an ExoPlayer convenience that lands in a `@ForOverride` method which throws, and deleting them would have lost "another app takes the output" and "headphones out" with nothing failing anywhere. And the service is BOUND, never started — `MediaSessionService.onStartCommand` returns `START_STICKY` without promoting for any intent that is not media3's own, so a `startForegroundService` would arm the ten-second kill timer and satisfy nothing, which is the 2026-09-13 bug in a new coat. Four more things are load-bearing and silent when they break: a playlist that must never be empty while a track exists, `COMMAND_GET_TIMELINE` with `COMMAND_GET_CURRENT_MEDIA_ITEM`, `STATE_READY` while paused, and both spellings of next and previous. Each ends in a card that is simply never posted, with nothing in any log. The window carries `queueBase` now, for a stable media-item uid as it slides, and `repeat`, whose numbering is swapped between the app and media3, so that what a controller is told about repeat is the truth and media3's own view of the end of the queue agrees with the `hasNext` the next button is advertised on. The 2026-09-13 bug has a second door, and `onStartCommand` is what shuts it. media3 hands the framework a media-button `PendingIntent` built with `PendingIntent.getForegroundService`, unconditionally, because the receiver the legacy stub resolves is now this service — there being no `MediaButtonReceiver` left for it to find. So the SYSTEM starts this service in the foreground on every media KEY, while media3 promotes only for a session it calls user-engaged. A key into a process built for that key, a key on a card paused past the ten-minute tail that does not start playback, any key with the bar cleared: none of them qualify, and each would take the app down ten seconds later. (The card's own buttons are milder — only play/pause, and only while paused, is a foreground start.) So `onStartCommand` promotes first, the way this service always did — through media3's own `onUpdateNotification` when there is a card to post, and otherwise with a plain notification of its own, posted and withdrawn in the same breath. `Service`'s `startForeground` is final, so the split is by what media3 can be asked to do, not by watching it. The walk gains `one-card` and `one-session` — the only checks that tell a landed migration from a doubled one — plus an advisory `media-plumbing` line; the logged window grew `base` and `rep`, which are the only readback either field has anywhere on the device and which `queue-window` now asserts; and the paused-restore wait goes to 25 s, which ANDROID.md had already claimed in one place and contradicted in another. ANDROID.md also says plainly what the walk cannot prove and Kyle has to try himself: headphones out, another app taking focus, the card's actual look, and a Bluetooth headset's own buttons. The Kotlin is unbuilt — no Kotlin compiler here — and was read against javap of media3 1.10.1 and the real sources of `SimpleBasePlayer`, `MediaSessionService`, `MediaNotificationManager` and `DefaultMediaNotificationProvider`; the manifest and resources compile and link under aapt2, and the Rust type-checks.
This commit is contained in:
1 parent
a44b4422fd
commit
3a8ccd4640
11 files changed
+1139
-283
No files matched your search
+122
-18
@@ -2470,36 +2470,88 @@ function shadeNotice() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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
|
||||
* fail nothing. None of these dump formats has ever been read back from
|
||||
* this phone, and a format surprise must not turn into a false red on a
|
||||
* walk that is otherwise green.
|
||||
*
|
||||
* What they are for is the morning after a change to how the service is
|
||||
* made. `fgs` says whether the service exists at all and whether media3
|
||||
* promoted it; `focus` whether the audio-focus request this plugin still
|
||||
* makes BY HAND is in the system's stack, which is the direct regression
|
||||
* guard for "another app takes the output and we pause"; `noisy` whether
|
||||
* the becoming-noisy receiver is REGISTERED, which is the half of
|
||||
* "headphones out pauses" that a creation-path change actually breaks.
|
||||
*
|
||||
* Neither of the last two proves the pause itself, and nothing here can:
|
||||
* ACTION_AUDIO_BECOMING_NOISY is a protected broadcast, so `am broadcast`
|
||||
* from the shell uid is refused outright, and the receiver is registered
|
||||
* RECEIVER_NOT_EXPORTED besides. Those two are Kyle's hands. */
|
||||
function mediaPlumbing() {
|
||||
const out = { fgs: "", focus: "", noisy: "" };
|
||||
try {
|
||||
const svc = adb("shell", "dumpsys", "activity", "services", "com.soulstream.app").toString();
|
||||
const at = svc.indexOf("com.soulstream.mediasession.MediaService");
|
||||
out.fgs = at < 0 ? "absent" : (/isForeground=true/.test(svc.slice(at, at + 6000)) ? "foreground" : "created");
|
||||
} catch (e) { out.fgs = "ERR " + e.message; }
|
||||
try {
|
||||
const audio = adb("shell", "dumpsys", "audio").toString();
|
||||
out.focus = /pack: com\.soulstream\.app/.test(audio) ? "held" : "not in the stack";
|
||||
} catch (e) { out.focus = "ERR " + e.message; }
|
||||
try {
|
||||
const bc = adb("shell", "dumpsys", "activity", "broadcasts").toString();
|
||||
/* each registered receiver is one `ReceiverList{...}` block with its
|
||||
package on the first line and its filters under it; the windows
|
||||
keep a match from wandering into the next block */
|
||||
const ours = bc.split("ReceiverList{").filter((b) =>
|
||||
/com\.soulstream\.app/.test(b.slice(0, 400)) && /AUDIO_BECOMING_NOISY/.test(b.slice(0, 2000)));
|
||||
out.noisy = ours.length ? "registered" : "not found";
|
||||
} catch (e) { out.noisy = "ERR " + e.message; }
|
||||
return out;
|
||||
}
|
||||
|
||||
/* The system's view of the app's media session: who owns it and what the
|
||||
* playback state says. From `dumpsys media_session`. */
|
||||
function mediaSessionState() {
|
||||
try {
|
||||
const dump = adb("shell", "dumpsys", "media_session").toString();
|
||||
/* how many sessions the system holds for US. Two would mean the
|
||||
old MediaSessionCompat stack and media3's are both live, which is
|
||||
the one way the migration in round forty-eight could have gone
|
||||
wrong without anything crashing. Counted loosely and REPORTED
|
||||
rather than trusted: a zero here means the dump did not say
|
||||
`package=<us>` where this looked, not that there is no session —
|
||||
`media-session` is what asserts that there is one. */
|
||||
const sessions = (dump.match(/package=com\.soulstream\.app\b/g) || []).length;
|
||||
/* sessions are listed as blocks; find ours by package, then the first
|
||||
playback state after it */
|
||||
const at = dump.search(/com\.soulstream\.app/);
|
||||
if (at < 0) return { owner: "", state: "" };
|
||||
if (at < 0) return { owner: "", state: "", sessions };
|
||||
const block = dump.slice(Math.max(0, at - 400), at + 3000);
|
||||
const state = (block.match(/state=PlaybackState \{state=([A-Z_]+)\(\d+\), position=(\d+)/) || []);
|
||||
return { owner: "com.soulstream.app", state: state[1] || "", position: state[2] ? +state[2] / 1000 : null };
|
||||
} catch { return { owner: "", state: "" }; }
|
||||
return { owner: "com.soulstream.app", state: state[1] || "", position: state[2] ? +state[2] / 1000 : null, sessions };
|
||||
} catch { return { owner: "", state: "", sessions: -1 }; }
|
||||
}
|
||||
|
||||
/* The queue window the phone was last handed, as MediaSessionPlugin logged
|
||||
* it. Round forty-six put a bounded slice of the queue into every card
|
||||
* update — the current item and ten either side — and left MediaService
|
||||
* ignoring all of it, so there is nothing on any screen to read it back
|
||||
* from: logcat is the only place on the device it exists, one line per
|
||||
* update, and the LAST line is the state now. `at=` is last in the line
|
||||
* update — the current item and ten either side. Since round forty-eight
|
||||
* SoulStreamPlayer builds the media3 playlist out of it, but the card
|
||||
* still shows ONE track, so logcat remains the only place on the device
|
||||
* the whole window can be read back: one line per update, and the LAST
|
||||
* line is the state now. `at=` is last in the line
|
||||
* because a title may hold anything, spaces and `=` included. */
|
||||
function mediaWindow() {
|
||||
try {
|
||||
const out = adb("logcat", "-d", "-s", "SoulStream:D").toString();
|
||||
const lines = out.split(/\r?\n/).filter((l) => l.includes("media window "));
|
||||
const m = (lines[lines.length - 1] || "").match(/media window n=(\d+) i=(-?\d+) art=(\d+) at=(.*)$/);
|
||||
if (!m) return { n: -1, i: -1, art: -1, at: "", lines: lines.length };
|
||||
return { n: +m[1], i: +m[2], art: +m[3], at: m[4].trim(), lines: lines.length };
|
||||
} catch (e) { return { n: -1, i: -1, art: -1, at: "ERR " + e.message, lines: 0 }; }
|
||||
const m = (lines[lines.length - 1] || "").match(
|
||||
/media window n=(\d+) i=(-?\d+) art=(\d+) base=(-?\d+) rep=(\d+) at=(.*)$/);
|
||||
if (!m) return { n: -1, i: -1, art: -1, base: -2, rep: -1, at: "", lines: lines.length };
|
||||
return { n: +m[1], i: +m[2], art: +m[3], base: +m[4], rep: +m[5], at: m[6].trim(), lines: lines.length };
|
||||
} catch (e) { return { n: -1, i: -1, art: -1, base: -2, rep: -1, at: "ERR " + e.message, lines: 0 }; }
|
||||
}
|
||||
|
||||
/* ── the phone, turned over: the two settings that do it ───────────────
|
||||
@@ -2933,7 +2985,14 @@ async function main() {
|
||||
later (a Save died with it). Wait past that window BEFORE
|
||||
resuming, and the pid must be the same one afterwards. */
|
||||
adb("logcat", "-c");
|
||||
await sleep(13000);
|
||||
/* TWENTY-FIVE seconds. Android kills an app about ten seconds
|
||||
after a foreground start that never called startForeground(),
|
||||
and since round forty-eight nothing here starts the service at
|
||||
all — it is bound, and media3 promotes it on its own schedule —
|
||||
so the margin is over the window rather than beside it. The
|
||||
doc claimed 25 s in one place and thirteen in another; both
|
||||
say 25 now. */
|
||||
await sleep(25000);
|
||||
underrunsBefore = audioUnderruns();
|
||||
const pid2 = adb("shell", "pidof", "com.soulstream.app").toString().trim();
|
||||
const fatal = (adb("logcat", "-d", "-s", "AndroidRuntime:E").toString().match(/FATAL EXCEPTION|DidNotStartInTime/g) || []).length;
|
||||
@@ -3010,6 +3069,42 @@ async function main() {
|
||||
if (!cardOk) problems.push("EXPECT media-card " + JSON.stringify(cardSeen));
|
||||
console.log((cardOk ? " ok " : " FAIL ") + "media-card".padEnd(16) + " " + JSON.stringify(cardSeen));
|
||||
|
||||
/* ── ONE card, and one session ────────────────────────────
|
||||
The check that proves round forty-eight landed rather than
|
||||
doubled. The card moved from a hand-built MediaStyle
|
||||
notification over a MediaSessionCompat to media3, which posts
|
||||
and owns the notification itself — and the way THAT goes wrong
|
||||
is not a crash and not a missing card but two of them: two
|
||||
notifications on the `playback` channel, two records in
|
||||
`dumpsys media_session`, a stale duplicate in the shade and
|
||||
media buttons arriving at whichever component the system
|
||||
picked. Nothing above this line can tell one card from two:
|
||||
`media-card` passes on the first `playback` record it finds.
|
||||
|
||||
`shadeNotice()` already returns every channel our package has
|
||||
live, joined with `|`, so counting the `playback` entries is
|
||||
the whole test — and it reads the dump `media-card` just
|
||||
read, so it costs nothing and cannot disagree with the line
|
||||
above it. */
|
||||
const cards = cardNote.shadeChans.split("|").filter((c) => c === "playback").length;
|
||||
const oneCard = cards === 1;
|
||||
if (!oneCard) problems.push("EXPECT one-card " + JSON.stringify({ cards, chans: cardNote.shadeChans }));
|
||||
console.log((oneCard ? " ok " : " FAIL ") + "one-card".padEnd(16) + " " + JSON.stringify({ cards }));
|
||||
|
||||
/* The same question of the system's own session list, and it
|
||||
FAILS ONLY ON MORE THAN ONE. `dumpsys media_session`'s shape
|
||||
is the one thing in this block that has never been held
|
||||
against a real dump, so a count of zero means "I could not
|
||||
read it", not "there is no session" — `media-session`, two
|
||||
checks up, is what asserts that there IS one. */
|
||||
const oneSession = !(sess.sessions > 1);
|
||||
if (!oneSession) problems.push("EXPECT one-session " + JSON.stringify(sess));
|
||||
console.log((oneSession ? " ok " : " FAIL ") + "one-session".padEnd(16) + " " + JSON.stringify({ sessions: sess.sessions }));
|
||||
|
||||
/* and the plumbing underneath it, read while something is
|
||||
playing, printed and never failed — see mediaPlumbing() */
|
||||
console.log(" -- " + "media-plumbing".padEnd(16) + " " + JSON.stringify(mediaPlumbing()) + " (advisory: prints, never fails)");
|
||||
|
||||
adb("shell", "input", "keyevent", "KEYCODE_MEDIA_PAUSE");
|
||||
await sleep(1500);
|
||||
const keyPaused = await cdp.evaluate(PLAYER);
|
||||
@@ -3024,11 +3119,10 @@ async function main() {
|
||||
Round forty-six put a bounded window of the queue into every
|
||||
card update — the current item and ten either side, each with
|
||||
its id, title, artist, album, duration and art key, plus where
|
||||
the current one sits among them — and left MediaService
|
||||
ignoring every bit of it, because media3's
|
||||
SimpleBasePlayer.getState() will want a playlist and an index,
|
||||
and that data should be a round old before anything depends on
|
||||
it. Which means every check above this one is blind to it: a
|
||||
the current one sits among them; round forty-eight made it the
|
||||
media3 playlist the card is built from. Every check above this
|
||||
one is still blind to the window itself, because the card shows
|
||||
one track and `dumpsys` can only be asked about that one: a
|
||||
window that arrived empty, an index off by one, or a field the
|
||||
bridge dropped because a key was spelled the wrong way would
|
||||
each look exactly like success.
|
||||
@@ -3099,7 +3193,17 @@ async function main() {
|
||||
&& wins[0][1].qi === base && wins[1][1].qi === base + 1 && wins[2][1].qi === base
|
||||
&& wins[0][2].i === base && wins[1][2].i === base + 1 && wins[2][2].i === base
|
||||
&& wins[1][1].u > wins[0][1].u && wins[2][1].u > wins[1][1].u
|
||||
&& wins[1][2].lines > wins[0][2].lines && wins[2][2].lines > wins[1][2].lines;
|
||||
&& wins[1][2].lines > wins[0][2].lines && wins[2][2].lines > wins[1][2].lines
|
||||
/* and the two fields round forty-eight added, which nothing
|
||||
else on the device can be asked about. The block arms at
|
||||
index one, so the window starts at zero and `base` must BE
|
||||
zero at all three readings; `rep` must be the repeat mode
|
||||
the page reports, unchanged across the block. Either one
|
||||
spelled snake_case on either hop arrives as nothing and
|
||||
lands here as -1: `repeat` dropped has the card report
|
||||
repeat-off forever, and `queueBase` dropped makes every
|
||||
track change look like twenty-one new rows to media3. */
|
||||
&& wins.every(([, , got]) => got.base === 0 && got.rep === armed.rep);
|
||||
if (!winOk) problems.push("EXPECT queue-window " + JSON.stringify({ armed, base, wins }));
|
||||
console.log((winOk ? " ok " : " FAIL ") + "queue-window".padEnd(16) + " "
|
||||
+ JSON.stringify({ armed, seen: wins.map(([k, sent, got]) => [k, sent && sent.qi, got.n, got.i, got.art, got.at]) }));
|
||||
|
||||
Reference in new issue
Block a user