android: the handoff — the pending rounds, the pipeline, and what is unverified
Everything written this session that has not been through the device: rounds 43 to 48 (each patch adversarially verified, 43's transient-notice expectation fixed), a partial round 49, the pipeline scripts, the Android Auto design and the icon comparison mock. Also the two harness changes that were never A/B'd — adaptive waits, which may only shorten a declared wait and never lengthen it, and a ship that runs the mock walk and the deploy at the same time. The A/B and its 255-step baseline are in handoff/tools; main stays at what the phone has actually walked.
This commit is contained in:
1 parent
867fd42972
commit
9f0601027c
400 files changed
+26401
-3
No files matched your search
@@ -0,0 +1,169 @@
|
||||
/* The first run.
|
||||
|
||||
What a phone that has just installed the app actually holds: a config
|
||||
file that does not exist yet, an empty database, no Soulseek account —
|
||||
and a library root that is NOT missing. `config.rs`'s `download_dir`
|
||||
falls back on Android to `app_data_dir()/Library`, a real, writable
|
||||
folder inside the app's own sandbox that nothing else on the phone can
|
||||
see, and it returns before the desktop's audio_dir() fallback is even
|
||||
reached. So the root is never null and "no folder yet" can never be a
|
||||
null check anywhere: the test is the PATH (isPrivateFolder), which
|
||||
You.svelte carried first and now imports from here, so there is one of
|
||||
it rather than two drifting apart.
|
||||
|
||||
Two things the app genuinely cannot work without, and both are already
|
||||
fixable on a screen that exists: somewhere to keep music (You's folder
|
||||
picker and the all-files permission beside it) and, for anything that
|
||||
touches the network, a Soulseek sign-in (Community's card). This store
|
||||
knows which of the two are still missing, opens the sheet that says so,
|
||||
and stamps `setup_done` once the tour has run. It never handles a
|
||||
password: the Soulseek step navigates to the card and stops there. */
|
||||
import * as api from "../lib/api.js";
|
||||
import { openHalf, closeHalf } from "./nav.svelte.js";
|
||||
import { slsk } from "./slsk.svelte.js";
|
||||
import { lib, loadLibrary } from "./lib.svelte.js";
|
||||
import { player } from "./player.svelte.js";
|
||||
|
||||
/** The sandbox root Android hands a fresh install —
|
||||
* `/data/user/0/com.soulstream.app/files/Library`, or `/data/data/…` on an
|
||||
* older device. A real folder, and an invisible one, which is why it reads
|
||||
* as "no folder picked yet". A string match is all there is: change the
|
||||
* package id and every phone silently reads as set up. */
|
||||
export function isPrivateFolder(dir) {
|
||||
return !dir || /\/data\/(user\/\d+\/|data\/)com\.soulstream/.test(dir);
|
||||
}
|
||||
|
||||
export const setup = $state({
|
||||
/** the config has been READ — not that it said anything in particular */
|
||||
loaded: false,
|
||||
/** "boot" = the tour opened itself · "preview" = You opened it ·
|
||||
* "shown" = it has already stamped and must not stamp again */
|
||||
reason: "boot",
|
||||
folder: "",
|
||||
private: true,
|
||||
access: null, // all-files access: true | false | null (never asked)
|
||||
done: false, // the config's setup_done
|
||||
picking: false,
|
||||
/** stamps this store has written. A preview must leave it at zero — it
|
||||
* is the whole proof that the door in You is not a door onto the
|
||||
* config, and the phone walk asserts it on Kyle's own phone. */
|
||||
writes: 0,
|
||||
});
|
||||
|
||||
/** Still the app's own sandbox folder. */
|
||||
export function needsFolder() {
|
||||
return setup.loaded && setup.private;
|
||||
}
|
||||
/** Signed out, with no saved account to sign in with. Gated on the
|
||||
* credentials having been read: before that every phone looks signed out,
|
||||
* and a note that flashes on a set-up phone is a lie with good timing. */
|
||||
export function needsAccount() {
|
||||
return slsk.credsRead && slsk.connection !== "online" && !slsk.savedUser;
|
||||
}
|
||||
/** What the walk reads back: "<folder>:<account>:<flag>". */
|
||||
export function setupState() {
|
||||
return (setup.private ? "private" : "chosen")
|
||||
+ ":" + (needsAccount() ? "out" : "in")
|
||||
+ ":" + (setup.done ? "done" : "todo");
|
||||
}
|
||||
|
||||
export async function loadSetup() {
|
||||
let cfg = null;
|
||||
try { cfg = await api.getSettings(); } catch { return; }
|
||||
setup.folder = cfg?.download_dir || "";
|
||||
setup.private = isPrivateFolder(cfg?.download_dir);
|
||||
setup.done = !!cfg?.setup_done;
|
||||
setup.loaded = true;
|
||||
await refreshAccess();
|
||||
}
|
||||
|
||||
/** All-files access: true, false, or null where the phone never asked
|
||||
* (an old device, or the desktop). A failed read leaves the last answer
|
||||
* standing rather than turning the row back on. */
|
||||
async function refreshAccess() {
|
||||
try { setup.access = (await api.allFilesAccess())?.granted ?? null; } catch { /* keep what we had */ }
|
||||
}
|
||||
|
||||
/** Ask for it. The system answers by taking the app away and giving it
|
||||
* back, so the grant is re-read on the way back — You.svelte watches
|
||||
* visibility for exactly this reason, and without it the row stays on
|
||||
* screen offering a permission that has already been given. */
|
||||
export async function requestAccess() {
|
||||
try { await api.requestAllFilesAccess(); } catch (e) { player.error = String(e); return; }
|
||||
const back = () => {
|
||||
if (document.hidden) return;
|
||||
document.removeEventListener("visibilitychange", back);
|
||||
void refreshAccess();
|
||||
};
|
||||
document.addEventListener("visibilitychange", back);
|
||||
}
|
||||
|
||||
/** The tour opened by hand — You's "Set up this phone again". A preview:
|
||||
* it reads the real state and writes nothing, which is what makes it
|
||||
* something the phone walk can drive on Kyle's own device. */
|
||||
export function openSetup(reason = "preview") {
|
||||
setup.reason = reason;
|
||||
void loadSetup();
|
||||
openHalf("setup");
|
||||
}
|
||||
|
||||
/** The boot gate: three clauses, ALL of them required.
|
||||
*
|
||||
* The desktop gates on `setup_done` alone (App.svelte), and that alone
|
||||
* would greet Kyle with a setup sheet on a phone full of his own music —
|
||||
* the phone has never written the flag, and `config::setup_done` unwraps a
|
||||
* missing field to false, so every phone in existence reads as unstamped.
|
||||
* A saved account, or one record in the library, says this is not a fresh
|
||||
* install whatever the flag says.
|
||||
*
|
||||
* Both of those are read from the backend, so the caller has to let
|
||||
* `loadLibrary()` and `connectStored()` settle first: asked early they are
|
||||
* `[]` and `""` on every phone there is, and the gate would open on all of
|
||||
* them. */
|
||||
export async function maybeOpenFirstRun() {
|
||||
if (!setup.loaded) await loadSetup();
|
||||
if (!setup.loaded) return false;
|
||||
if (setup.done || slsk.savedUser || lib.albums.length) return false;
|
||||
setup.reason = "boot";
|
||||
openHalf("setup");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The library folder, picked from the tour — You's own three calls: pick,
|
||||
* save, rescan. Keeping the private folder is a legitimate answer, so
|
||||
* nothing here is required and a cancelled picker is not an error. */
|
||||
export async function chooseFolder() {
|
||||
if (setup.picking) return;
|
||||
setup.picking = true;
|
||||
try {
|
||||
const r = await api.pickLibraryFolder();
|
||||
if (!r?.path) { player.error = "That folder has no path the phone can scan — pick one on this phone's storage."; return; }
|
||||
await api.setSettings({ downloadDir: r.path });
|
||||
await loadSetup();
|
||||
await api.rescanLibrary();
|
||||
await loadLibrary();
|
||||
} catch (e) {
|
||||
if (!/cancelled/.test(String(e))) player.error = String(e);
|
||||
} finally {
|
||||
setup.picking = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the tour and remember that it ran — whichever door closed it: the
|
||||
* foot's button, the scrim, the back gesture, or a row that navigated away
|
||||
* (a tour that ran is a tour that ran). `reason` moves to "shown" BEFORE
|
||||
* the await, so two doors closing in the same tick still stamp once.
|
||||
*
|
||||
* A preview writes nothing at all. The write is best effort otherwise: a
|
||||
* failed save leaves the flag false and the tour comes back on the next
|
||||
* launch, which is the honest outcome — nothing else depends on it. */
|
||||
export async function finishSetup() {
|
||||
closeHalf();
|
||||
if (setup.reason !== "boot") return;
|
||||
setup.reason = "shown";
|
||||
try {
|
||||
await api.setSettings({ setupDone: true });
|
||||
setup.done = true;
|
||||
setup.writes++;
|
||||
} catch { /* see above */ }
|
||||
}
|
||||
Reference in new issue
Block a user