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:
2026-08-09 12:17:57 -05:00
parent ce2e556c52
commit dff52a9d83
5 changed files with 308 additions and 30 deletions

View File

@@ -73,6 +73,8 @@ pub fn run() {
sync::sync_status,
sync::sync_pick_folder,
sync::sync_set_document,
sync::sync_list_documents,
sync::sync_set_document_name,
sync::sync_forget_document,
sync::sync_now,
sync::convert_legacy_library,

View File

@@ -23,6 +23,14 @@ struct PersistedConfig {
/// it has been converted so the app can offer to do that once.
#[serde(default)]
legacy_library_path: Option<PathBuf>,
/// Which file inside the granted folder holds the library. Android only:
/// there the user grants a *folder* (SAF has no path to hand back), so the
/// file within it has to be chosen separately. Defaults to "library.json"
/// for a fresh setup, but must be settable — a library named anything else
/// would otherwise never be found, and the phone would quietly create an
/// empty second one beside it.
#[serde(default)]
document_name: Option<String>,
}
fn config_path(data_dir: &Path) -> PathBuf {
@@ -30,32 +38,31 @@ fn config_path(data_dir: &Path) -> PathBuf {
}
fn load_config(data_dir: &Path) -> PersistedConfig {
let text = match std::fs::read_to_string(config_path(data_dir)) {
Ok(t) => t,
Err(_) => return PersistedConfig::default(),
let Ok(text) = std::fs::read_to_string(config_path(data_dir)) else {
return PersistedConfig::default();
};
if let Ok(cfg) = serde_json::from_str::<PersistedConfig>(&text) {
// Nothing to carry over if it already parses as the current shape,
// unless it's an old config that only had `library_path`.
if cfg.document_path.is_some() || cfg.legacy_library_path.is_some() {
return cfg;
// Every field defaults, so an older or partial file parses fine and simply
// leaves the unknown settings unset. Deliberately *not* keyed on which
// fields happen to be populated: an Android install has neither a
// `document_path` (its folder is a SAF grant) nor a legacy library, so a
// "does this look empty?" test threw away its `document_name` on every
// launch and silently reverted the chosen library file.
let mut cfg: PersistedConfig = serde_json::from_str(&text).unwrap_or_default();
// Builds before the document format stored `library_path` pointing at a
// SQLite file the user managed themselves. Pick that up so the UI can offer
// to convert it once — but never open it as the working copy.
if cfg.legacy_library_path.is_none() {
#[derive(Deserialize)]
struct OldConfig {
library_path: Option<PathBuf>,
}
cfg.legacy_library_path = serde_json::from_str::<OldConfig>(&text)
.ok()
.and_then(|c| c.library_path)
.filter(|p| p.extension().is_some_and(|e| e != "json"));
}
// Older builds stored `library_path` pointing at a SQLite file the user
// managed themselves. Remember it so the UI can offer to convert it, but
// never open it as the working copy.
#[derive(Deserialize)]
struct OldConfig {
library_path: Option<PathBuf>,
}
let legacy = serde_json::from_str::<OldConfig>(&text)
.ok()
.and_then(|c| c.library_path)
.filter(|p| p.extension().is_some_and(|e| e != "json"));
PersistedConfig {
document_path: None,
legacy_library_path: legacy,
}
cfg
}
fn save_config(data_dir: &Path, cfg: &PersistedConfig) {
@@ -145,6 +152,21 @@ impl AppState {
save_config(&self.data_dir, &self.config);
}
/// The library file within the granted folder.
///
/// Android-only: desktop names the whole path at once, so these are dead
/// there rather than genuinely unused.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn document_name(&self) -> Option<&str> {
self.config.document_name.as_deref()
}
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn set_document_name(&mut self, name: Option<String>) {
self.config.document_name = name;
save_config(&self.data_dir, &self.config);
}
/// A pre-document `library.db` still waiting to be converted.
pub fn legacy_library_path(&self) -> Option<&Path> {
self.config.legacy_library_path.as_deref()
@@ -193,3 +215,87 @@ impl AppState {
.ok_or_else(|| "Card data not loaded yet — click 'Download card data'.".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("mtgc-cfg-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_document_name_alone_survives_a_reload() {
// An Android install has no document_path (its folder is a SAF grant)
// and no legacy library, so this config's *only* meaningful field is
// the chosen library file. A loader that decided emptiness meant "must
// be an old config" discarded it on every launch, silently reverting
// the user's choice back to library.json.
let dir = scratch("android-shape");
std::fs::write(
config_path(&dir),
r#"{"document_path":null,"legacy_library_path":null,"document_name":"decks-v1.json"}"#,
)
.unwrap();
let cfg = load_config(&dir);
assert_eq!(cfg.document_name.as_deref(), Some("decks-v1.json"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn every_setting_round_trips() {
let dir = scratch("round-trip");
let cfg = PersistedConfig {
document_path: Some(PathBuf::from("/tmp/lib.json")),
legacy_library_path: Some(PathBuf::from("/tmp/old.db")),
document_name: Some("mine.json".into()),
};
save_config(&dir, &cfg);
let back = load_config(&dir);
assert_eq!(back.document_path, cfg.document_path);
assert_eq!(back.legacy_library_path, cfg.legacy_library_path);
assert_eq!(back.document_name, cfg.document_name);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_pre_document_config_offers_its_library_for_conversion() {
let dir = scratch("legacy");
std::fs::write(
config_path(&dir),
r#"{"library_path":"/home/u/Seafile/Decks-v1.db"}"#,
)
.unwrap();
let cfg = load_config(&dir);
assert_eq!(
cfg.legacy_library_path,
Some(PathBuf::from("/home/u/Seafile/Decks-v1.db"))
);
assert!(cfg.document_path.is_none(), "a .db is never the document");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_library_path_that_is_already_json_is_not_treated_as_legacy() {
let dir = scratch("json-path");
std::fs::write(config_path(&dir), r#"{"library_path":"/home/u/lib.json"}"#).unwrap();
assert!(load_config(&dir).legacy_library_path.is_none());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_unreadable_or_corrupt_config_falls_back_to_defaults() {
let dir = scratch("corrupt");
assert!(load_config(&dir).document_name.is_none(), "missing file");
std::fs::write(config_path(&dir), "{not json").unwrap();
assert!(load_config(&dir).document_name.is_none(), "truncated file");
std::fs::remove_dir_all(&dir).unwrap();
}
}

View File

@@ -24,10 +24,10 @@ use crate::state::AppState;
type Db<'a> = tauri::State<'a, std::sync::Mutex<AppState>>;
/// Document name used on Android, where the user grants a folder rather than
/// choosing a file. Desktop uses whatever they named their file.
/// Fallback name when the user has granted a folder but not yet said which file
/// in it is their library — i.e. a fresh setup with nothing to adopt.
#[cfg(target_os = "android")]
const ANDROID_DOC_NAME: &str = "library.json";
const DEFAULT_DOC_NAME: &str = "library.json";
/// Where transfers are staged across the SAF boundary. Inside the app data dir,
/// so it inherits the same private-storage guarantees as the working copy.
@@ -56,6 +56,9 @@ pub struct SyncStatus {
pub picker_based: bool,
/// A pre-document `library.db` waiting to be converted, if any.
pub legacy_library: Option<String>,
/// Which file in the granted folder is the library (Android). `None` on
/// desktop, where the whole path is chosen at once.
pub document_name: Option<String>,
}
#[cfg(target_os = "android")]
@@ -324,7 +327,12 @@ fn locate(#[allow(unused_variables)] app: &AppHandle, db: &Db) -> Result<Locatio
if status.granted {
return Ok(Location {
folder: Folder::Picker,
doc_name: ANDROID_DOC_NAME.into(),
doc_name: db
.lock()
.unwrap()
.document_name()
.unwrap_or(DEFAULT_DOC_NAME)
.to_string(),
});
}
}
@@ -356,12 +364,21 @@ pub fn sync_status(
{
let s = bridge::status(&app)?;
if s.chosen {
let name = db
.lock()
.unwrap()
.document_name()
.unwrap_or(DEFAULT_DOC_NAME)
.to_string();
return Ok(SyncStatus {
chosen: true,
granted: s.granted,
label: s.label,
// Show the folder *and* the file, since on Android they're
// chosen in two separate steps.
label: s.label.map(|l| format!("{l}/{name}")),
picker_based: true,
legacy_library,
document_name: Some(name),
});
}
// No folder picked: the library is in private storage and works, it
@@ -378,6 +395,7 @@ pub fn sync_status(
),
picker_based: true,
legacy_library,
document_name: None,
});
}
#[cfg(not(target_os = "android"))]
@@ -392,6 +410,7 @@ pub fn sync_status(
label: Some(path.display().to_string()),
picker_based: false,
legacy_library,
document_name: None,
})
}
}
@@ -466,6 +485,78 @@ pub fn sync_set_document(
}
}
/// The library files already sitting in the granted folder.
///
/// Android grants a folder, not a file, so the user has to say which of its
/// documents is theirs. Without this the app assumes "library.json" and would
/// silently create an empty second library beside one named anything else.
#[tauri::command]
pub fn sync_list_documents(
#[allow(unused_variables)] app: AppHandle,
db: Db,
) -> Result<Vec<String>, String> {
let location = locate(&app, &db)?;
let mut names: Vec<String> = location
.list(&app)?
.into_iter()
.filter(|n| n.ends_with(".json") && !n.ends_with(".json.tmp"))
.collect();
names.sort();
Ok(names)
}
/// Android only: choose which file in the granted folder is the library.
///
/// Switching to a different file means adopting a different library, so the
/// working copy is dropped and rebuilt from it — the same rule desktop applies
/// when opening an existing document.
#[tauri::command]
pub fn sync_set_document_name(
app: AppHandle,
db: Db,
#[allow(unused_variables)] name: String,
) -> Result<SyncStatus, String> {
#[cfg(not(target_os = "android"))]
{
let _ = (&app, &db, name);
return Err("This platform chooses the library file by full path.".into());
}
#[cfg(target_os = "android")]
{
let mut name = name.trim().to_string();
if name.is_empty() {
return Err("Give the library file a name.".into());
}
if !name.ends_with(".json") {
name.push_str(".json");
}
// Keep it a plain file name: a path would escape the granted folder,
// which is the whole boundary the SAF grant establishes.
if name.contains('/') || name.contains('\\') {
return Err("The library file must sit directly in the chosen folder.".into());
}
let existed = {
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let location = locate(&app, &db)?;
location.read(&app, &data_dir, &name)?.is_some()
};
db.lock().unwrap().set_document_name(Some(name));
if existed {
// Adopting an existing library: start from it, not from whatever
// this device happened to be holding.
reset_working_copy(&app, &db)?;
} else {
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let _ = std::fs::remove_file(base_path(&data_dir));
}
if let Err(e) = sync_now(app.clone(), db.clone()) {
crate::mlog!("sync_set_document_name: initial sync failed: {e}");
}
sync_status(app, db)
}
}
#[tauri::command]
pub fn sync_forget_document(app: AppHandle, db: Db) -> Result<SyncStatus, String> {
#[cfg(target_os = "android")]

View File

@@ -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 && (

View File

@@ -145,6 +145,9 @@ export interface SyncStatus {
picker_based: boolean;
/** A pre-document library.db from an older build, waiting to be converted. */
legacy_library: string | null;
/** Which file in the granted folder is the library (Android). Null on
* desktop, where the whole path is chosen at once. */
document_name: string | null;
}
/** What one sync did. */
@@ -288,6 +291,11 @@ export const api = {
/** Desktop: adopt a library document chosen with the file dialog. */
syncSetDocument: (path: string) => invoke<SyncStatus>("sync_set_document", { path }),
syncForgetDocument: () => invoke<SyncStatus>("sync_forget_document"),
/** Library files already in the chosen folder, for Android's second step. */
syncListDocuments: () => invoke<string[]>("sync_list_documents"),
/** Android: choose which file in the granted folder is the library. */
syncSetDocumentName: (name: string) =>
invoke<SyncStatus>("sync_set_document_name", { name }),
/** Convert a pre-document library.db into the current format. */
convertLegacyLibrary: (path: string) => invoke<SyncReport>("convert_legacy_library", { path }),