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:
1 parent
4fc6e9c630
commit
5738c2d7bf
31 files changed
+1358
-6
No files matched your search
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user