android: the downloads say so in the shade

A Save still running with the app in the background now posts a progress
notice — how many files, the one it is on, a bar, Stop all and Open — and
takes it down when the queue empties. It sits on its own low-importance
channel so it can be silenced without silencing the player's card, and
rides its own dataSync foreground service rather than the media one: a
mediaPlayback service stops itself when the bar empties, a download is not
playback, and nothing else was keeping the process alive for a Save with
nothing playing — which is how the first real Save died, four tracks in.
Stop all goes through the same cancel(id) a row's tap takes, and Transfers
grew the matching button beside Clear finished. The walk pushes two rows
straight into the transfer store and reads the real notification back out
of `dumpsys notification`, without fetching anything.
This commit is contained in:
KeelrLeek committed 2026-09-14 00:44:11 -05:00
1 parent 4fc6e9c630
commit 5738c2d7bf
31 files changed
+1358 -6

No files matched your search

+81
View File
@@ -154,6 +154,15 @@ const PROBE = `(function () {
bars: document.querySelectorAll('.trk .lbar').length,
save: (document.querySelector('.rec-acts .save') || {}).textContent || '',
xfers: document.querySelectorAll('.row.xfer').length,
/* the shade's notice, as the app's own record of having posted it —
a notification is not a thing the page can be asked about */
xfnOn: (function () { try { return !!__ss.xfn.posted; } catch (e) { return false; } })(),
xfnClr: (function () { try { return __ss.xfn.clears; } catch (e) { return -1; } })(),
xfnMax: (function () { try { return __ss.xfn.max; } catch (e) { return -1; } })(),
xfnBad: (function () { try { return __ss.xfn.failed; } catch (e) { return -1; } })(),
xfnTitle: (function () { try { return __ss.xfn.title; } catch (e) { return ''; } })(),
xfnLine: (function () { try { return __ss.xfn.line; } catch (e) { return ''; } })(),
xfnBtn: !![].slice.call(document.querySelectorAll('.phead .all')).find(function (x) { return /Cancel all/.test(x.textContent); }),
feat: (document.querySelector('.feat .kick') || {}).textContent || '',
gaplines: document.querySelectorAll('.gapline').length,
folderRow: !![].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Library folder/.test(x.textContent); }),
@@ -524,6 +533,15 @@ const MOCK_STEPS = [
{ do: `__settle()`, wait: 700 },
{ do: `document.querySelectorAll('.nav button')[4].click()`, wait: 600 },
{ do: `__click('.screen .row', 'Transfers')`, name: "transfers", wait: 1200 },
/* the shade's copy of this screen, driven from the store itself: two
rows written straight in — nothing fetched — so the notice is posted
with a count and the file it is on, and Cancel all is on the head;
then the rows are taken away again and the notice must go with them */
{ do: `(function () { var t = __ss.transfers.items; t[990001] = { id: 990001, username: 'walk_peer', path: 'Walk\\\\walk-a.flac', name: 'walk-a.flac', state: 'transferring', queue_place: null, local_path: null, error: null, received: 3000000, total: 9000000 }; t[990002] = { id: 990002, username: 'walk_peer', path: 'Walk\\\\walk-b.flac', name: 'walk-b.flac', state: 'queued', queue_place: 2, local_path: null, error: null, received: 0, total: 9000000 }; return Object.keys(t).length; })()`, name: "shade-note", wait: 1300 },
/* 2600 ms on purpose: the album burst drains 3.4-5.3 s after it starts
and this lands ten seconds after the Save, so "nothing in flight" is
settled state rather than a race against a mock still finishing */
{ do: `(function () { var t = __ss.transfers.items; delete t[990001]; delete t[990002]; return Object.keys(t).length; })()`, name: "shade-gone", wait: 2600 },
{ do: `history.back()`, wait: 600 },
/* Gaps: the followed shelf, its missing records dimmed */
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
@@ -853,6 +871,13 @@ const MOCK_EXPECT = {
"cat-track-save": (i) => /Saving from /.test(i.halfText) || !i.half.includes("up"),
"cat-album-save": (i) => /^Saving \d+ from /.test(i.save) && /^\d+$/.test(i.navBadges) && /\|You, \d+ transfers?$/.test(i.navTabs),
transfers: (i) => i.title.startsWith("Transfers") && i.xfers >= 11,
/* xfnMax >= 5 can only have come from the real album burst, and it is
monotonic, so it is not a race; the title and the line come from the
two injected rows (990001 is the newest transferring one, so it is
deterministically the current file); xfnBad === 0 proves the call
reached a handler instead of being swallowed */
"shade-note": (i) => i.xfnOn && i.xfnBad === 0 && i.xfnMax >= 5 && /^Saving \d+ files$/.test(i.xfnTitle) && /walk-a\.flac/.test(i.xfnLine) && i.xfnBtn && i.xfers >= 13,
"shade-gone": (i) => !i.xfnOn && i.xfnClr >= 1 && i.xfnBad === 0 && !i.xfnBtn,
gaps: (i) => i.title.startsWith("Your library") && i.gaplines >= 1,
"gaps-open": (i) => i.gapCards >= 3,
playlists: (i) => i.title.startsWith("Your library") && i.plcards === 3 && i.plpinned,
@@ -1031,6 +1056,15 @@ const PHONE_STEPS = [
{ do: `__click('.screen .row', 'Listening port')`, wait: 800 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '50300'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port-restored", wait: 1200 },
/* the shade, for real. The walk starts no download — two rows go
straight into the transfer store, which is the notice's only input —
and the phone's own notification is read back out of dumpsys, then
the rows are taken away and it has to be gone. Nothing is fetched
into Kyle's library, and the ids are far above the backend's counter */
{ do: `__click('.screen .row', 'Transfers')`, name: "shade-open", wait: 1200 },
{ do: `(function () { var t = __ss.transfers.items; t[990001] = { id: 990001, username: 'walk_peer', path: 'Walk\\\\walk-a.flac', name: 'walk-a.flac', state: 'transferring', queue_place: null, local_path: null, error: null, received: 3000000, total: 9000000 }; t[990002] = { id: 990002, username: 'walk_peer', path: 'Walk\\\\walk-b.flac', name: 'walk-b.flac', state: 'queued', queue_place: 2, local_path: null, error: null, received: 0, total: 9000000 }; return Object.keys(t).length; })()`, name: "shade-note", wait: 2400, probe: shadeNotice },
{ do: `(function () { var t = __ss.transfers.items; delete t[990001]; delete t[990002]; return Object.keys(t).length; })()`, name: "shade-gone", wait: 2400, probe: shadeNotice },
{ do: `history.back()`, wait: 700 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
{ do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 900 },
/* the real config's contact list, read back over the real IPC */
@@ -1088,6 +1122,12 @@ const PHONE_EXPECT = {
"you-port-prompt": (i) => i.half.includes("up") && /Listening port/.test(i.sheetTitle) && !i.toast,
"you-port": (i) => !i.half.includes("up") && /^50310 /.test(i.port) && !/the default/.test(i.port) && !i.toast,
"you-port-restored": (i) => !i.half.includes("up") && /^50300 /.test(i.port) && !/the default/.test(i.port) && !i.toast,
"shade-open": (i) => i.title.startsWith("Transfers") && !i.toast,
"shade-note": (i) => i.xfnOn && i.xfnBad === 0 && i.xfnBtn && /downloads/.test(i.shadeChans) && /^Saving \d+ files?$/.test(i.shadeTitle) && /walk-a\.flac/.test(i.shadeText),
/* the dump has to have been read for the negative to mean anything: one
that came back empty would pass "no downloads channel" on nothing at
all. A format change fails the step above, loudly, first */
"shade-gone": (i) => !i.xfnOn && i.xfnClr >= 1 && i.shadeSeen > 0 && !/downloads/.test(i.shadeChans),
playlists: (i) => i.title.startsWith("Your library"),
"playlist-new": (i) => i.screen.includes("tr-push") && i.title.startsWith("Walk test") && i.plrows === 0 && !i.toast,
"playlist-pick": (i) => i.half.includes("up") && /Walk test/.test(i.halfText),
@@ -1118,6 +1158,44 @@ const PHONE_EXPECT = {
end: (i) => /^(Good |Still)/.test(i.title),
};
/* What OUR package has in the shade right now: the channels its live
* notifications sit on, and the downloads one's own title and text.
* `--noredact` is what makes the extras print their values rather than a
* placeholder. Nobody else's notifications are read — `seen` counts the
* dump's records only so that "no downloads channel" cannot pass by the
* dump having come back empty. */
function shadeNotice() {
try {
const dump = adb("shell", "dumpsys", "notification", "--noredact").toString();
const blocks = dump.split("NotificationRecord(").slice(1);
const chans = [];
let title = "", text = "";
for (const block of blocks) {
const head = block.slice(0, 6000);
if (!/pkg=com\.soulstream\.app\b/.test(head)) continue;
/* Notification.toString() carries `channel=<id>` on the record's
own first line; older dumps spell it as the channel object */
let ch = (head.match(/channel=([\w.\-]+)/) || [])[1] || "";
if (!ch || ch === "NotificationChannel") ch = (head.match(/mId='([^']*)'/) || [])[1] || ch;
if (ch) chans.push(ch);
if (ch !== "downloads") continue;
/* the extras print as `<key>=<class> (<value>)`, and the class is
whatever CharSequence the builder kept — String on this one,
but a Spannable on a device that styles it, so the class is
matched loosely and stray quotes are trimmed */
const extra = (key) => {
const m = head.match(new RegExp(key.replace(/\./g, "\\.") + "=[A-Za-z]+ \\(([^\\n]*?)\\)\\s*\\n"));
return m ? m[1].replace(/^"|"$/g, "") : "";
};
title = extra("android.title");
text = extra("android.text");
}
return { shadeSeen: blocks.length, shadeChans: chans.join("|"), shadeTitle: title, shadeText: text };
} catch (e) {
return { shadeSeen: 0, shadeChans: "ERR " + e.message, shadeTitle: "", shadeText: "" };
}
}
/* 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() {
@@ -1294,6 +1372,9 @@ async function main() {
if (!s.name) continue;
await still(path.join(OUT, `${prefix}${s.name}.png`));
const info = await cdp.evaluate(PROBE);
/* a step may want the system's own view as well; dumpsys is slow,
so only the steps that ask for it pay for it */
if (PHONE && s.probe) { try { Object.assign(info, s.probe()); } catch (e) { problems.push("PROBE " + s.name + ": " + e.message); } }
const ok = expect[s.name] ? expect[s.name](info) : true;
if (!ok) problems.push("EXPECT " + s.name + " " + JSON.stringify(info));
console.log((ok ? " ok " : " FAIL ") + s.name.padEnd(16) + " " + JSON.stringify(info));