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.
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
- Library/Card Search/Deck tables stack into dense card-style rows below
720px instead of scrolling horizontally (floated thumbnail, wrapping
chips for MV/Owned/color pips).
- Deck view: fix desktop min-widths and a non-wrapping button row that
forced horizontal scroll on phones; split the deck list and the
deck-building pane into separate drill-down screens with a back button
so a long deck list doesn't bury the builder.
- Suggested Cards modal: fix action buttons crushing the card name down to
one word per line by giving them their own full-width row.
- Add a close (X) button to the shared dialog title bar — mobile has no
Escape key and wide dialogs leave little backdrop to tap.
- Info tab, bottom-nav loading/safe-area fixes, and Gradle NDK_HOME
inheritance fix carried over from the Android bring-up work.
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.
- Remember which library.db was last chosen (via a small config.json in
the app-data dir) so relaunching the app reopens the same file instead
of always defaulting to library.db.
- Fix set_library_path returning a hard error (and thus never updating
the topbar) whenever the reference catalog fails to reopen against the
new path — e.g. a stale reference.db schema. Picking a library file
and successfully reattaching the reference db are separate concerns;
the chosen path now always persists and is reported back, with the
catalog reopened best-effort.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Exclude Scryfall Art Series cards (layout "art_series") from ingest —
they aren't playable cards and were polluting search results.
- Replace the coarse per-stage progress text with a byte-accurate,
weighted multi-segment progress bar (Index / Card data / Printings /
Indexing) that fills as one continuous bar instead of swapping per
stage; segment weights are computed from the real download sizes
reported by Scryfall's bulk-data index (the download responses
themselves are chunked with no Content-Length to rely on).
- Add a Cancel button, wired through a new CancelToken checked once per
parsed record so cancellation lands almost instantly.
- Fix a related correctness bug this surfaced: ingest used to delete the
existing reference.db before writing its replacement, so a cancelled
or crashed update left the app with no card database at all. It now
builds into a temp file and only atomically replaces reference.db on
full success.
- Split the topbar's single "Choose library…" action into separate
"Open Library…" (existing file) and "New Library…" (save-dialog for a
new file) buttons.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>