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:
64
src/App.tsx
64
src/App.tsx
@@ -291,6 +291,27 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Adopt a reference.db built elsewhere (e.g. copied from a desktop install)
|
||||
* instead of streaming ~740MB to rebuild it on this device. */
|
||||
async function importReferenceDb() {
|
||||
const path = await open({
|
||||
title: "Import a card database (reference.db)",
|
||||
filters: [{ name: "Card database", extensions: ["db", "sqlite"] }],
|
||||
});
|
||||
if (typeof path !== "string") return;
|
||||
try {
|
||||
setStatus(await api.importReferenceDb(path));
|
||||
setProgress(null);
|
||||
} catch (err) {
|
||||
setProgress({
|
||||
stage: "error",
|
||||
message: `Couldn't import that card database: ${String(err)}`,
|
||||
overall_percent: 0,
|
||||
stages: lastProgress.current?.stages ?? [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function openLibrary() {
|
||||
const path = await open({
|
||||
title: "Open an existing library.db",
|
||||
@@ -378,14 +399,21 @@ export default function App() {
|
||||
<span className="path" title={status?.library_path ?? ""}>
|
||||
{status?.library_path ?? ""}
|
||||
</span>
|
||||
<button onClick={openLibrary} disabled={busy}>
|
||||
{ICON_OPEN_LIBRARY}
|
||||
Open Library
|
||||
</button>
|
||||
<button onClick={newLibrary} disabled={busy}>
|
||||
{ICON_NEW_LIBRARY}
|
||||
New Library
|
||||
</button>
|
||||
{/* Choosing where library.db lives is desktop-only: on Android the
|
||||
live db must stay in app-private storage (SQLite can't open a
|
||||
content:// URI), so these would only ever fail there. */}
|
||||
{status && !status.is_mobile && (
|
||||
<>
|
||||
<button onClick={openLibrary} disabled={busy}>
|
||||
{ICON_OPEN_LIBRARY}
|
||||
Open Library
|
||||
</button>
|
||||
<button onClick={newLibrary} disabled={busy}>
|
||||
{ICON_NEW_LIBRARY}
|
||||
New Library
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={updateCardData} disabled={busy} className="primary">
|
||||
{ICON_DOWNLOAD}
|
||||
{ready ? "Update card data" : "Download card data"}
|
||||
@@ -437,15 +465,27 @@ export default function App() {
|
||||
</div>
|
||||
<h2>Welcome to mtgc</h2>
|
||||
<p className="hint">
|
||||
No card database yet. Click <strong>Download card data</strong> to fetch
|
||||
the latest cards from Scryfall (a few hundred MB, about a minute). The
|
||||
reference data stays on this device — only your library file is meant to
|
||||
be synced.
|
||||
No card database yet. <strong>Download card data</strong> fetches the
|
||||
latest cards from Scryfall (a few hundred MB
|
||||
{status?.is_mobile ? " — slow over mobile data" : ", about a minute"}).
|
||||
The reference data stays on this device — only your library file is meant
|
||||
to be synced.
|
||||
</p>
|
||||
{status?.is_mobile && (
|
||||
<p className="hint">
|
||||
Already have it on your computer? Copying that{" "}
|
||||
<strong>reference.db</strong> over is much faster — it's about 73MB
|
||||
versus the ~740MB the download has to stream.
|
||||
</p>
|
||||
)}
|
||||
<button className="primary big" onClick={updateCardData} disabled={busy}>
|
||||
{ICON_DOWNLOAD}
|
||||
{busy ? "Downloading…" : "Download card data"}
|
||||
</button>
|
||||
<button onClick={importReferenceDb} disabled={busy}>
|
||||
{ICON_OPEN_LIBRARY}
|
||||
Import card data…
|
||||
</button>
|
||||
</div>
|
||||
) : tab === "add" ? (
|
||||
<CardEntry />
|
||||
|
||||
@@ -102,6 +102,10 @@ export interface Status {
|
||||
reference_ready: boolean;
|
||||
card_count: number;
|
||||
reference_updated_at: number | null; // unix seconds
|
||||
/** True on mobile builds. Used to hide affordances that depend on choosing an
|
||||
* arbitrary filesystem path for library.db — Android scoped storage has no
|
||||
* equivalent, and SQLite can't be backed by a content:// URI. */
|
||||
is_mobile: boolean;
|
||||
}
|
||||
|
||||
export interface LibraryStats {
|
||||
@@ -180,6 +184,8 @@ export const api = {
|
||||
setLibraryPath: (path: string) => invoke<Status>("set_library_path", { path }),
|
||||
updateCardData: () => invoke<Status>("update_card_data"),
|
||||
cancelUpdateCardData: () => invoke<void>("cancel_update_card_data"),
|
||||
/** Adopt an already-built reference.db from `path` instead of downloading it. */
|
||||
importReferenceDb: (path: string) => invoke<Status>("import_reference_db", { path }),
|
||||
|
||||
searchCards: (query: string, limit = 20) =>
|
||||
invoke<CardRef[]>("search_cards", { query, limit }),
|
||||
|
||||
@@ -317,7 +317,12 @@ input[type="checkbox"] {
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
/* `dvh` tracks the *dynamic* viewport, so an on-screen keyboard (or a mobile
|
||||
browser's collapsing chrome) shrinks the shell instead of pushing content
|
||||
out of an over-tall, unscrollable `100vh` box. Falls back for older
|
||||
engines that don't know dvh. */
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user