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:
@@ -76,6 +76,44 @@ pub fn open_reference_for_build(path: &Path) -> Result<Connection> {
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Sanity-check that `path` is a usable reference db and report how many oracle
|
||||
/// cards it holds.
|
||||
///
|
||||
/// Used before adopting an externally-supplied file — e.g. a `reference.db`
|
||||
/// copied over from a desktop install, which is how mobile avoids a ~740MB
|
||||
/// on-device ingest. Without this check, handing over the wrong file (or a
|
||||
/// truncated copy) would be accepted silently and only surface later as every
|
||||
/// query failing. Opened read-only so inspecting a file can't alter it.
|
||||
pub fn inspect_reference_db(path: &Path) -> Result<i64> {
|
||||
let conn = Connection::open_with_flags(
|
||||
path,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
|
||||
)?;
|
||||
// The tables every card query depends on. `oracle_names_fts` is included
|
||||
// because it's an FTS5 virtual table: if the SQLite that opens this file
|
||||
// lacks FTS5, it won't be listed and we want to fail here with a clear
|
||||
// message rather than at first search.
|
||||
for table in ["oracle_cards", "printings", "oracle_names_fts"] {
|
||||
let found: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE name = ?1",
|
||||
[table],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if found == 0 {
|
||||
return Err(crate::error::CoreError::Other(format!(
|
||||
"not a valid mtgc card database (missing '{table}')"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let count: i64 = conn.query_row("SELECT COUNT(*) FROM oracle_cards", [], |r| r.get(0))?;
|
||||
if count == 0 {
|
||||
return Err(crate::error::CoreError::Other(
|
||||
"card database is empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Open the library db at `library_path`, run migrations, and ATTACH the
|
||||
/// reference db at `reference_path` under the [`REF_DB`] alias.
|
||||
pub fn open(library_path: &Path, reference_path: &Path) -> Result<Connection> {
|
||||
@@ -166,4 +204,39 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(hit, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_reference_db_accepts_a_real_db_and_rejects_bad_ones() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
// A schema-correct db with cards in it: reports the card count.
|
||||
let good = dir.path().join("good.db");
|
||||
{
|
||||
let conn = open_reference_for_build(&good).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO oracle_cards (oracle_id,name,name_lower) VALUES ('a','Sol Ring','sol ring')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(inspect_reference_db(&good).unwrap(), 1);
|
||||
|
||||
// Right schema, no cards — a truncated/aborted build, not usable.
|
||||
let empty = dir.path().join("empty.db");
|
||||
open_reference_for_build(&empty).unwrap();
|
||||
assert!(inspect_reference_db(&empty).is_err());
|
||||
|
||||
// A valid SQLite file that isn't ours (e.g. the user picked library.db).
|
||||
let wrong = dir.path().join("wrong.db");
|
||||
{
|
||||
let conn = Connection::open(&wrong).unwrap();
|
||||
migrate_library(&conn).unwrap();
|
||||
}
|
||||
assert!(inspect_reference_db(&wrong).is_err());
|
||||
|
||||
// Not a database at all.
|
||||
let junk = dir.path().join("junk.db");
|
||||
std::fs::write(&junk, b"definitely not sqlite").unwrap();
|
||||
assert!(inspect_reference_db(&junk).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,11 @@ pub fn build_reference_db(
|
||||
let result = build_into(&tmp, cancel, &mut progress);
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(dest);
|
||||
// Rename straight over `dest`: POSIX rename replaces the
|
||||
// destination atomically, so there is never a moment with no
|
||||
// reference.db. (Deleting `dest` first would open a window where a
|
||||
// process kill — far likelier on Android, which reaps backgrounded
|
||||
// apps — leaves the user with no card data at all.)
|
||||
std::fs::rename(&tmp, dest)?;
|
||||
}
|
||||
Err(_) => {
|
||||
@@ -331,8 +335,23 @@ struct BulkUris {
|
||||
default_size: u64,
|
||||
}
|
||||
|
||||
/// HTTP agent with timeouts. Without them a stalled connection hangs forever:
|
||||
/// cancellation is only polled per *parsed record*, so if no bytes arrive, no
|
||||
/// records parse and `CancelToken` is never consulted — the ingest wedges with
|
||||
/// no way out. Mobile networks stall like this routinely.
|
||||
///
|
||||
/// `timeout_read` is per read call, not for the whole transfer, so a
|
||||
/// multi-hundred-megabyte download is fine as long as it keeps making progress.
|
||||
fn agent() -> ureq::Agent {
|
||||
ureq::AgentBuilder::new()
|
||||
.timeout_connect(std::time::Duration::from_secs(30))
|
||||
.timeout_read(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
}
|
||||
|
||||
fn fetch_bulk_uris() -> Result<BulkUris> {
|
||||
let index: BulkIndex = ureq::get(BULK_INDEX_URL)
|
||||
let index: BulkIndex = agent()
|
||||
.get(BULK_INDEX_URL)
|
||||
.set("User-Agent", USER_AGENT)
|
||||
.set("Accept", "application/json")
|
||||
.call()?
|
||||
@@ -356,7 +375,7 @@ fn fetch_bulk_uris() -> Result<BulkUris> {
|
||||
}
|
||||
|
||||
fn http_reader(url: &str) -> Result<impl Read> {
|
||||
let resp = ureq::get(url).set("User-Agent", USER_AGENT).call()?;
|
||||
let resp = agent().get(url).set("User-Agent", USER_AGENT).call()?;
|
||||
Ok(BufReader::with_capacity(1 << 20, resp.into_reader()))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user