android: the first run — a folder to keep music in, and an account to reach the network

Nobody had seen this app on a phone that has just installed it. On Android
the library root is never missing — config.rs falls back to the app's own
sandbox, a real folder nothing else can see — so "no folder yet" cannot be
a null check, and setup_done has never been written by the phone, so every
phone in existence reads as unstamped, Kyle's included. The tour therefore
opens on three clauses, not one: no flag, no saved account, and no records,
read only after the library and the credentials have both answered. Two
cards, skippable, and no password anywhere in it — the Soulseek step goes
to the card on Community and stops.

Every screen that is empty because of those two things now says so with the
row that fixes it: Home (whose copy predated the folder picker), Library →
Albums, Library → Artists, which had no empty state at all, and Search,
where a flat sentence about signing in became the row that does it. A row
goes away because the config moved, so the two doors onto the same folder
tell each other — You re-reads when the tour moves it, the tour's store is
told when You's own picker does — and the all-files row re-reads the grant
when the system screen hands the app back, the way You already did.

You → This phone opens the tour again as a preview that reads the config
and writes nothing, which is the door the phone walk drives on Kyle's own
device. The mock can answer as a fresh install now — an empty database, no
account, no permission, the sandbox root — so the whole thing is walked end
to end, and two commands that had no mock at all (player_control,
open_external) got one on the way past. It is the mock walk's first reload,
which turned up two things a reload does not clear by itself: the player
writes the session back on pagehide, so the fresh block clears it from a
pagehide listener of its own, and the viewport emulation lives on the
DevTools session, so the harness hands it back after the re-attach.
This commit is contained in:
KeelrLeek committed 2026-09-14 02:21:37 -05:00
1 parent 46cf6a98fe
commit 95c2bfeeba
13 files changed
+633 -14

No files matched your search

+57 -2
View File
@@ -352,6 +352,23 @@ function mockImport(cmd, args) {
return undefined;
}
/* The walk's other device: a phone that has just installed the app. The
mock is the whole backend on that leg, so it can answer as one — and
the flag has to survive `location.reload()`, which is why it rides in
the URL fragment as well as in localStorage: a file:// document's
storage is not something the harness can lean on, and a fragment
always comes back. */
const fresh = (() => {
try {
if (location.hash.indexOf("fresh") >= 0) return true;
} catch { /* no document */ }
try {
return localStorage.getItem("mockFresh") === "1";
} catch {
return false;
}
})();
const enabled = (() => {
try {
return localStorage.getItem("mockLibrary") === "1";
@@ -367,6 +384,14 @@ export function mockInvoke(cmd, args) {
command arm exactly the same way (see the doors below) */
const forecast = weather(cmd);
if (forecast) return forecast;
/* a device that has just been installed: an empty database. Not an
error and not a refusal — `pipeline::init` scans the sandbox root
at startup and honestly finds nothing in it, so every one of these
answers with an empty list. Everything else below answers exactly
as it always did: this is the same mock with nothing in it yet. */
if (fresh && (cmd === "library_albums" || cmd === "library_album_stats" || cmd === "recent_albums"
|| cmd === "followed_artists" || cmd === "list_playlists" || cmd === "library_track_index"
|| cmd === "library_track_search" || cmd === "contacts_meta_list" || cmd === "buddy_list")) return Promise.resolve([]);
if (cmd === "app_status") return Promise.resolve({ version: "mock", connected_as: null });
if (cmd === "library_albums") return Promise.resolve(albums);
if (cmd === "library_album_stats") return Promise.resolve(albums.map(statsFor));
@@ -740,7 +765,7 @@ function mockCatalog(cmd, args) {
return Promise.resolve();
}
if (cmd === "plugin:storage|pick_folder") return Promise.resolve({ uri: "content://mock/tree/primary%3AMusic", path: "C:\\mock\\Music" });
if (cmd === "plugin:storage|all_files_access") return Promise.resolve({ granted: true });
if (cmd === "plugin:storage|all_files_access") return Promise.resolve({ granted: !fresh });
if (cmd === "plugin:storage|request_all_files_access") return Promise.resolve();
if (cmd === "plugin:storage|pick_image") return Promise.resolve({ path: "C:\\mock\\photo.jpg" });
/* the downloads notice: the mock walk cannot see the shade, so the calls
@@ -762,6 +787,7 @@ let downloadSeq = 0;
const mockSettings = {
download_dir: "C:\\mock\\Library",
volume_normalization: true,
setup_done: true,
library_layout: "{artist}\\{album}",
shared_folders: [],
upload_slots: 2,
@@ -777,6 +803,18 @@ const mockSettings = {
stream_source_prefs: { max: "any", min: "any", priority: "quality" },
download_source_prefs: { max: "any", min: "any", priority: "quality" },
};
/* the fresh device's own config: the library root Android actually hands
a fresh install (the app's private sandbox — real, writable, and
invisible to every other app), nothing shared, and the flag the tour
stamps still unwritten. Mutated rather than replaced, so `set_settings`
keeps working and a folder picked during the walk genuinely changes
what the notes read. */
if (fresh) Object.assign(mockSettings, {
download_dir: "/data/user/0/com.soulstream.app/files/Library",
setup_done: false,
share_files: 0,
shared_folders: [],
});
const cacheReady = new Set(); // paths a preload or a finished stream has fully fetched
const cacheSizes = new Map(); // path → the file's size, for stream_cache_len
const preloading = new Set();
@@ -786,7 +824,7 @@ let posTimer = null; // the fake engine's position ticks
them — a declaration moves both, the way the real server does */
const MY_INTERESTS = { likes: ["Daft Punk", "french house", "Warp Records"], hates: ["loudness war"] };
function mockNet(cmd, args) {
if (cmd === "get_credentials") return Promise.resolve({ username: MOCK_USER, has_password: true });
if (cmd === "get_credentials") return Promise.resolve(fresh ? { username: "", has_password: false } : { username: MOCK_USER, has_password: true });
if (cmd === "connect_stored" || cmd === "connect_and_login") {
later(400, () => mockEmit("soulseek:state", { kind: "connected", username: MOCK_USER }));
return Promise.resolve();
@@ -908,6 +946,23 @@ function mockNet(cmd, args) {
return new Promise((r) => setTimeout(() => r(pool.slice(0, Math.min(pool.length, n + 1)).map((username, i) => ({ username, rating: Math.max(1, n - i) }))), 300));
}
if (cmd === "player_state") return Promise.resolve({ status: "idle", position: 0 });
/* Two the phone can reach that the mock never knew, found while
mapping every api.js wrapper against it for this round: unmocked
they fall through to the real IPC, which on a file:// page rejects —
a console warning AND a toast, in a step that has nothing to do with
either. The transport is answered the way the real core answers it,
with an event and nothing else; `resume` cannot restart the position
ticks (they live in the closure the play_* command opened), so the
state is right and the position simply stands still. */
if (cmd === "player_control") {
const act = String(args?.action || "");
if (act === "pause" || act === "stop") { clearInterval(posTimer); posTimer = null; }
if (act === "pause") later(60, () => mockEmit("soulseek:player", { kind: "paused" }));
if (act === "stop") later(60, () => mockEmit("soulseek:player", { kind: "stopped" }));
if (act === "resume") later(60, () => mockEmit("soulseek:player", { kind: "playing" }));
return Promise.resolve();
}
if (cmd === "open_external") return Promise.resolve();
if (cmd === "preload_local") return Promise.resolve();
if (cmd === "waveform") {
const n = args?.buckets || 900;