Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was hardcoded to "library.json". A library named anything else was therefore never found: the phone created an empty second library beside it and the two devices silently never synced, while appearing to work. The Info tab now lists the .json files in the granted folder and lets you choose one. Picking an existing file adopts it — the working copy is dropped and rebuilt from it, matching what desktop does when opening a document — while a name that isn't there yet keeps the current library and publishes it, matching New Library. The name is validated as a plain filename, since a path would escape the granted folder that the SAF grant exists to bound. Two bugs found while testing this on-device: load_config decided whether a config was the pre-document format by testing whether document_path or legacy_library_path were set. An Android install has neither — its folder is a grant, not a path, and there is no legacy library — so a valid config's document_name was discarded on every launch and the chosen library file silently reverted to the default. It no longer infers the format from which fields happen to be populated; every field defaults, and the legacy library_path is picked up as a separate additive step. Covered by tests, including one built from the exact Android config shape, because a setting that quietly resets itself is not something a careful read catches. The Info tab's sync refresh fired at mount when the app relaunched onto that tab — the active tab is persisted — which is before setup() calls manage(), producing "state not managed for field db". Gated on statusLoaded like the startup refresh already was; get_status retries until the backend is ready, so waiting on it is what guarantees the state exists.
This commit is contained in:
75
src/App.tsx
75
src/App.tsx
@@ -460,9 +460,15 @@ export default function App() {
|
||||
|
||||
// And whenever the Info tab is opened, which is also when the user gets back
|
||||
// from the folder picker, and when a revoked grant would first matter.
|
||||
//
|
||||
// Gated on `statusLoaded` as well as the tab: the active tab is persisted, so
|
||||
// relaunching while on Info makes this fire at mount — before setup() has
|
||||
// called manage(), which fails with "state not managed for field db".
|
||||
// `getStatus` retries until the backend is ready, so waiting on it is what
|
||||
// guarantees the state exists.
|
||||
useEffect(() => {
|
||||
if (tab === "info") refreshSync();
|
||||
}, [tab, refreshSync]);
|
||||
if (tab === "info" && statusLoaded) refreshSync();
|
||||
}, [tab, statusLoaded, refreshSync]);
|
||||
|
||||
|
||||
/**
|
||||
@@ -544,6 +550,30 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Android's second step: which file in the granted folder is the library.
|
||||
* Loaded lazily, since it costs a SAF round trip. */
|
||||
const [docChoices, setDocChoices] = useState<string[] | null>(null);
|
||||
useEffect(() => {
|
||||
if (tab !== "info" || !sync?.picker_based || !sync.granted) return;
|
||||
api.syncListDocuments().then(setDocChoices).catch(() => setDocChoices([]));
|
||||
}, [tab, sync?.picker_based, sync?.granted, sync?.label]);
|
||||
|
||||
async function chooseDocumentName(name: string) {
|
||||
setSyncBusy(true);
|
||||
setSyncTest(null);
|
||||
try {
|
||||
setSync(await api.syncSetDocumentName(name));
|
||||
setStatus(await api.getStatus());
|
||||
setDeckRefreshSignal((v) => v + 1);
|
||||
if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {});
|
||||
setSyncTest({ ok: true, message: `Using ${name}.` });
|
||||
} catch (err) {
|
||||
setSyncTest({ ok: false, message: String(err) });
|
||||
} finally {
|
||||
setSyncBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function forgetLibrary() {
|
||||
setSyncBusy(true);
|
||||
setSyncTest(null);
|
||||
@@ -927,6 +957,47 @@ export default function App() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Android grants a *folder*, so which file in it is the
|
||||
library is a separate choice. Without this the app
|
||||
assumes "library.json" and would create an empty second
|
||||
library beside one named anything else. */}
|
||||
{sync.picker_based && sync.chosen && sync.granted && (
|
||||
<div className="field">
|
||||
<label>Library file in that folder</label>
|
||||
{docChoices === null ? (
|
||||
<div className="hint">Looking…</div>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
value={sync.document_name ?? ""}
|
||||
onChange={(e) => chooseDocumentName(e.target.value)}
|
||||
disabled={syncBusy}
|
||||
>
|
||||
{/* The current name may not exist in the folder
|
||||
yet (a library this device hasn't published),
|
||||
so it's always offered. */}
|
||||
{[
|
||||
...new Set(
|
||||
[sync.document_name ?? "library.json", ...docChoices].filter(
|
||||
Boolean,
|
||||
),
|
||||
),
|
||||
].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{docChoices.length === 0 && (
|
||||
<span className="hint">
|
||||
No library files here yet — a new one will be created.
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Only shown while an old library.db is still waiting; the
|
||||
offer disappears for good once it's been converted. */}
|
||||
{sync.legacy_library && (
|
||||
|
||||
Reference in New Issue
Block a user