Add Android support via Tauri
The Rust core was already portable (caller-provided db paths, rustls rather than openssl) and the shell already had the mobile entry point, so this is mostly toolchain, provisioning, and platform-gating. Toolchain: - scripts/android-setup.sh installs what Android Studio doesn't: SDK cmdline-tools, a pinned stable NDK, and the Rust Android targets. The NDK is pinned rather than "latest" because sdkmanager's newest is often a beta (30.0.15729638 is r30-beta2) -- not something to compile SQLite with. Prefers Android Studio's bundled JDK 21, since Gradle rejects newer JDKs. - Track src-tauri/gen/android (excluding build output): AndroidManifest.xml is hand-edited and Android permissions have no tauri.conf.json equivalent. - vite: serve on TAURI_DEV_HOST so a device/emulator can reach the dev server. Card data: downloading streams ~740MB to build a ~73MB db, which is rough on a phone. Add import_reference_db to adopt a desktop-built file instead, validated by db::inspect_reference_db first so picking the wrong file is a clean error rather than a broken install. Installs via copy-then-rename so an interrupted copy can't leave a partial db. Ingest hardening (helps desktop too): - Give ureq connect/read timeouts. Cancellation is only polled per parsed record, so a stalled socket previously hung forever with no way to cancel. - Drop the remove_file before rename; POSIX rename already replaces atomically, and the delete-first window could leave no reference.db at all if the process was killed -- much likelier on Android. UI: hide Open/New Library on mobile (scoped storage has no arbitrary-path equivalent, and SQLite can't open a content:// URI), reported via a cfg!(mobile) flag on Status rather than user-agent sniffing. Use 100dvh so the soft keyboard doesn't break the shell. Verified: 70/70 core tests, desktop build clean, and the aarch64 APK builds (bundled SQLite C and ring both cross-compile under NDK 28). Not yet run on a device.
This commit is contained in:
@@ -27,6 +27,12 @@ pub struct Status {
|
||||
/// Unix timestamp (seconds) of reference.db's last modification — i.e.
|
||||
/// when "Update card data" last completed. `None` if unavailable.
|
||||
pub reference_updated_at: Option<i64>,
|
||||
/// Whether this is a mobile build. The UI uses it to hide affordances that
|
||||
/// only make sense with an arbitrary-path filesystem (choosing where
|
||||
/// library.db lives) — see `set_library_path`. Reported from the build
|
||||
/// target rather than sniffed from the user agent so it can't disagree with
|
||||
/// what the backend will actually allow.
|
||||
pub is_mobile: bool,
|
||||
}
|
||||
|
||||
fn status_of(s: &AppState) -> Status {
|
||||
@@ -41,6 +47,7 @@ fn status_of(s: &AppState) -> Status {
|
||||
reference_ready: s.catalog.is_some(),
|
||||
card_count: s.index.as_ref().map(|i| i.len()).unwrap_or(0),
|
||||
reference_updated_at,
|
||||
is_mobile: cfg!(mobile),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +96,50 @@ pub async fn update_card_data(app: AppHandle, db: Db<'_>) -> Res<Status> {
|
||||
Ok(status_of(&s))
|
||||
}
|
||||
|
||||
/// Adopt an already-built `reference.db` from elsewhere on the device instead of
|
||||
/// downloading and rebuilding it.
|
||||
///
|
||||
/// This is the practical way to provision card data on mobile: the built db is
|
||||
/// ~73MB, whereas `update_card_data` streams ~740MB of JSON and can take many
|
||||
/// minutes — and Android may kill the app partway through. Copy the file from a
|
||||
/// desktop install and point this at it.
|
||||
#[tauri::command]
|
||||
pub fn import_reference_db(db: Db, path: String) -> Res<Status> {
|
||||
let src = PathBuf::from(&path);
|
||||
let mut s = db.lock().unwrap();
|
||||
let dest = s.reference_path.clone();
|
||||
|
||||
if src == dest {
|
||||
return Err("That file is already the active card database.".into());
|
||||
}
|
||||
|
||||
// Validate before disturbing the working db, so picking the wrong file is a
|
||||
// harmless error rather than a broken install.
|
||||
mtgc_core::db::inspect_reference_db(&src).map_err(|e| e.to_string())?;
|
||||
|
||||
// Release the handles on the file we're about to replace.
|
||||
s.close();
|
||||
|
||||
// Copy beside the destination, then rename: a same-directory rename is
|
||||
// atomic, so an interrupted copy can't leave a half-written reference.db.
|
||||
let tmp = dest.with_extension("db.import");
|
||||
let install = || -> std::io::Result<()> {
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(&src, &tmp)?;
|
||||
std::fs::rename(&tmp, &dest)
|
||||
};
|
||||
if let Err(e) = install() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let _ = s.reopen(); // put the previous db back into service
|
||||
return Err(format!("Couldn't install that card database: {e}"));
|
||||
}
|
||||
|
||||
s.reopen()?;
|
||||
Ok(status_of(&s))
|
||||
}
|
||||
|
||||
/// Cancel an in-progress `update_card_data`, if one is running. No-op otherwise.
|
||||
#[tauri::command]
|
||||
pub fn cancel_update_card_data(db: Db) -> Res<()> {
|
||||
|
||||
@@ -25,6 +25,7 @@ pub fn run() {
|
||||
commands::set_library_path,
|
||||
commands::update_card_data,
|
||||
commands::cancel_update_card_data,
|
||||
commands::import_reference_db,
|
||||
commands::search_cards,
|
||||
commands::add_owned,
|
||||
commands::set_owned,
|
||||
|
||||
@@ -75,6 +75,14 @@ impl AppState {
|
||||
);
|
||||
}
|
||||
|
||||
/// Drop the open catalog (and its name index), releasing the handles on both
|
||||
/// db files. Call before replacing `reference.db` on disk so nothing is
|
||||
/// still reading the file being swapped out.
|
||||
pub fn close(&mut self) {
|
||||
self.catalog = None;
|
||||
self.index = None;
|
||||
}
|
||||
|
||||
/// (Re)open the catalog + rebuild the name index. Only opens when a reference
|
||||
/// db exists — attaching a non-existent file would create an empty (tableless)
|
||||
/// db and make every query fail.
|
||||
|
||||
Reference in New Issue
Block a user