android: a playlist, out of the phone
The desktop has had Export since playlists v2 and the phone had no door onto it for one honest reason: `tauri-plugin-dialog` does open Android's create-document sheet, but it answers with a `content://` URI and `playlist_export` wrote with `std::fs::write`, which cannot take one. The storage plugin grew a `create_document` whose Kotlin writes the bytes through the ContentResolver — a plugin function called from Rust, not a command, so the file never passes through the WebView and nothing was added to the ACL — and `playlist_export` now builds the file above the platform fork with the same `playlist_formats` writers, so the phone rebuilds no format and the two exports cannot drift. The door is the playlist's own sheet, on the page and on the shelf card, with the desktop's five profiles in the desktop's order; a dismissed sheet says nothing, because nothing happened. The mock walk drives it three ways — a file written where the sheet pointed, a sheet dismissed, a refusal read back as a sentence — and the phone walk opens the real sheet and dismisses it with BACK, writing nothing to the phone. The Kotlin is unbuilt: nothing in this tree compiles it, though it is written against tauri-android's own `PluginHandle`, `Invoke` and `JSObject` rather than against a guess at them.
This commit is contained in:
1 parent
95c2bfeeba
commit
04c87e0db1
9 files changed
+390
-72
No files matched your search
@@ -897,6 +897,35 @@ const MOCK_STEPS = [
|
||||
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "fresh-fixed", wait: 1100 },
|
||||
{ do: `localStorage.removeItem('mockFresh'); location.hash = ''; true`, wait: 400 },
|
||||
{ do: `location.reload()`, name: "fresh-off", wait: 4500 },
|
||||
/* ── the playlist export, three ways ──────────────────────────────
|
||||
On the provisioned device the block above deliberately put back, and
|
||||
on the pristine fixture its reload restored. The file is built in
|
||||
Rust by the desktop's own writers and written by whichever door the
|
||||
platform has, so the only thing that comes back — and the only thing
|
||||
the phone can show — is where it went. */
|
||||
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 800 },
|
||||
{ do: `__click('.segtabs button', 'Playlists')`, wait: 800 },
|
||||
{ do: `__click('.plgrid .release', 'Late night drive')`, name: "export-open", wait: 1200 },
|
||||
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 700 },
|
||||
{ do: `__click('.halfsheet .row', 'Export')`, name: "export-sheet", wait: 800 },
|
||||
{ do: `__click('.halfsheet .row', 'CSV')`, name: "export-written", wait: 1000 },
|
||||
/* a sheet dismissed: the command answers null and the phone says
|
||||
nothing, because nothing happened. The notice from the step above is
|
||||
cleared by hand rather than waited out — note() lives 3.2 s, longer
|
||||
than any wait here, and a wait that had to outlast it would be the
|
||||
fragile kind. */
|
||||
{ do: `__ssMock.answer('playlist_export', null); __ss.notice.text = ''; true`, wait: 300 },
|
||||
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 700 },
|
||||
{ do: `__click('.halfsheet .row', 'Export')`, wait: 700 },
|
||||
{ do: `__click('.halfsheet .row', 'M3U8')`, name: "export-cancelled", wait: 1000 },
|
||||
/* and a refusal — what Android answers when the document is somewhere
|
||||
the app cannot write — read back as a sentence rather than as Rust */
|
||||
{ do: `__ssMock.forget('playlist_export'); __ssMock.fail('playlist_export', 'permission denied (os error 13)'); true`, wait: 300 },
|
||||
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 700 },
|
||||
{ do: `__click('.halfsheet .row', 'Export')`, wait: 700 },
|
||||
{ do: `__click('.halfsheet .row', 'Backup (JSON)')`, name: "export-refused", wait: 1200 },
|
||||
{ do: `__ssMock.clear(); __ss.player.error = ''; __ss.lib.error = ''; history.back(); true`, wait: 900 },
|
||||
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "export-clear", wait: 900 },
|
||||
];
|
||||
const MOCK_EXPECT = {
|
||||
tracks: (i) => i.title.startsWith("Your library"),
|
||||
@@ -1190,6 +1219,18 @@ const MOCK_EXPECT = {
|
||||
&& i.setupNoteText === "Sign in on Soulseek" && i.setupWrites === 1,
|
||||
/* the provisioned device is back, and it is NOT offered a first run */
|
||||
"fresh-off": (i) => !i.setupUp && !i.setupNotes && i.setupState === "chosen:in:done" && !i.toast,
|
||||
/* ── the playlist export ──────────────────────────────────────────
|
||||
Where the file went is the only thing the command answers, so the
|
||||
notice naming it IS the feature; a dismissed sheet has to leave the
|
||||
screen exactly as quiet as it found it; and a refusal has to arrive
|
||||
as a sentence, because "permission denied (os error 13)" is the one
|
||||
string Android hands back when the document is out of reach. */
|
||||
"export-open": (i) => i.title.startsWith("Late night drive") && i.plrows >= 1 && !i.toast,
|
||||
"export-sheet": (i) => i.half.includes("up") && /M3U8/.test(i.halfText) && /Backup \(JSON\)/.test(i.halfText),
|
||||
"export-written": (i) => !i.half.includes("up") && /Exported as .+\.csv/.test(i.notice) && !i.toast,
|
||||
"export-cancelled": (i) => !i.half.includes("up") && !i.notice && !i.toast,
|
||||
"export-refused": (i) => /refused access/.test(i.toast) && !/os error|permission denied/i.test(i.toast),
|
||||
"export-clear": (i) => /^(Good |Still)/.test(i.title) && !i.toast && !i.notice && !i.half.includes("up"),
|
||||
};
|
||||
|
||||
/* the phone walk: a record by name, played for real */
|
||||
@@ -1255,6 +1296,16 @@ const PHONE_STEPS = [
|
||||
{ do: `__click('.segtabs button', 'Playlists')`, wait: 800 },
|
||||
{ do: `__click('.plgrid .release', 'Walk test')`, name: "playlist-filled", wait: 1500 },
|
||||
{ do: `document.querySelectorAll('.pl-rows .trk')[0].click()`, name: "playlist-playing", wait: 3000 },
|
||||
/* export, on the real core: the file is built by the desktop's own
|
||||
writers and handed to Android's create-document sheet. That sheet is
|
||||
a system activity the walk cannot tap, so BACK dismisses it — the
|
||||
cancel path is what gets walked, and NOTHING is written to this
|
||||
phone's storage. The playlist is the "Walk test" one the walk itself
|
||||
makes, and deletes three steps below. */
|
||||
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 800 },
|
||||
{ do: `__click('.halfsheet .row', 'Export')`, name: "export-door", wait: 800 },
|
||||
{ do: `__click('.halfsheet .row', 'M3U8')`, name: "export-picker", wait: 2600 },
|
||||
{ adb: ["shell", "input", "keyevent", "4"], name: "export-dismissed", wait: 2000 },
|
||||
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "playlist-more", wait: 800 },
|
||||
{ do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 },
|
||||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "playlist-deleted", wait: 1500 },
|
||||
@@ -1459,6 +1510,16 @@ const PHONE_EXPECT = {
|
||||
"setup-preview": (i) => i.setupUp && i.setupWrites === 0
|
||||
&& i.setupCards === "Where your music lives|Soulseek" && !i.a11yAnon && !i.a11yGhost,
|
||||
"setup-closed": (i) => !i.setupUp && i.setupWrites === 0 && !i.toast,
|
||||
/* the export door on the real core. The sheet the second step opens is
|
||||
Android's own, so nothing here asserts that it is UP: the core can
|
||||
build a ten-row M3U8 and hand off before the probe lands, and a step
|
||||
that demanded the picker would be the one that failed for being too
|
||||
fast. What is asserted is the app's own half sheet closing, nothing
|
||||
going red either side of the system sheet, and — after BACK — no
|
||||
notice, because a dismissed sheet wrote nothing to name. */
|
||||
"export-door": (i) => i.half.includes("up") && /M3U8/.test(i.halfText) && /Backup \(JSON\)/.test(i.halfText),
|
||||
"export-picker": (i) => !i.half.includes("up") && !i.toast,
|
||||
"export-dismissed": (i) => i.title.startsWith("Walk test") && !i.half.includes("up") && !i.toast && !i.notice,
|
||||
end: (i) => /^(Good |Still)/.test(i.title),
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user