Rework library sync around a JSON document; add card filters

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.
This commit is contained in:
2026-08-07 22:03:49 -05:00
parent 663af32562
commit 04e9c3cbbb
21 changed files with 3370 additions and 100 deletions

View File

@@ -2,6 +2,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Note: library sync needs no storage permission. The user grants access
to one folder through the system folder picker (SAF), and that grant is
persisted across restarts — see SyncPlugin.kt. -->
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />

View File

@@ -0,0 +1,295 @@
package com.garrettmills.mtgc
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.database.Cursor
import android.net.Uri
import android.provider.DocumentsContract
import androidx.activity.result.ActivityResult
import app.tauri.annotation.ActivityCallback
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import java.io.File
@InvokeArg
class FileArgs {
/** File name inside the chosen folder, e.g. "library.json". */
lateinit var name: String
/** App-private staging path the bytes are copied to/from. */
lateinit var path: String
}
@InvokeArg
class NameArgs {
lateinit var name: String
}
/**
* Bridges the Storage Access Framework to Rust for library sync.
*
* The user picks one folder through the system picker; we take a *persistable*
* URI permission on it so the grant survives restarts. That folder is a real
* directory in shared storage (e.g. /storage/emulated/0/mtgc), so a sync tool
* like Syncthing can see it, but this app reaches it only through SAF and
* therefore needs no storage permission in the manifest.
*
* SAF hands back `content://` URIs, which SQLite cannot open — so nothing here
* exposes a URI to Rust. Instead every operation copies bytes between the
* chosen folder and an app-private staging path that Rust picks, and Rust only
* ever does plain file I/O on its own private storage. That is sufficient
* because the synced copy is never opened as a database; it is only ever
* written whole or read whole.
*/
@TauriPlugin
class SyncPlugin(private val activity: Activity) : Plugin(activity) {
companion object {
private const val PREFS = "mtgc-sync"
private const val KEY_TREE_URI = "tree-uri"
/** Generic type: a SQLite file has no useful registered MIME type. */
private const val MIME = "application/octet-stream"
private const val NO_FOLDER =
"No synced folder is set up yet — choose one first."
}
private fun prefs() = activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/**
* The chosen folder, but only when the persisted grant is still held.
*
* A saved URI isn't proof of access: the user can revoke it in system
* settings, and clearing app data drops it. Checking the live grant list
* keeps us from failing later with an opaque SecurityException.
*/
private fun grantedTree(): Uri? {
val saved = prefs().getString(KEY_TREE_URI, null) ?: return null
val uri = Uri.parse(saved)
val held = activity.contentResolver.persistedUriPermissions.any {
it.uri == uri && it.isReadPermission && it.isWritePermission
}
return if (held) uri else null
}
/** Best-effort human-readable name for the folder, for display in the UI. */
private fun label(uri: Uri): String = try {
val docId = DocumentsContract.getTreeDocumentId(uri)
val parts = docId.split(":", limit = 2)
if (parts.size == 2 && parts[0].equals("primary", ignoreCase = true)) {
"Internal storage/${parts[1]}"
} else {
docId
}
} catch (_: Exception) {
uri.toString()
}
private fun statusObject(): JSObject {
val saved = prefs().getString(KEY_TREE_URI, null)
val granted = grantedTree()
return JSObject().apply {
// "chosen but not granted" is a distinct, recoverable state worth
// telling the user about — it means re-pick, not first-time setup.
put("chosen", saved != null)
put("granted", granted != null)
put("label", saved?.let { label(Uri.parse(it)) })
}
}
@Command
fun status(invoke: Invoke) {
invoke.resolve(statusObject())
}
@Command
fun pickFolder(invoke: Invoke) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
)
}
startActivityForResult(invoke, intent, "folderPicked")
}
@ActivityCallback
fun folderPicked(invoke: Invoke, result: ActivityResult) {
val uri = result.data?.data
if (result.resultCode != Activity.RESULT_OK || uri == null) {
// Backing out of the picker is a normal choice, not a failure.
invoke.resolve(statusObject().apply { put("cancelled", true) })
return
}
try {
// Without this the grant dies with the activity.
activity.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
} catch (e: Exception) {
invoke.reject("Couldn't keep access to that folder: ${e.message}")
return
}
prefs().edit().putString(KEY_TREE_URI, uri.toString()).apply()
invoke.resolve(statusObject().apply { put("cancelled", false) })
}
@Command
fun forgetFolder(invoke: Invoke) {
prefs().getString(KEY_TREE_URI, null)?.let {
try {
activity.contentResolver.releasePersistableUriPermission(
Uri.parse(it),
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
} catch (_: Exception) {
// Already gone; clearing our own record below is what matters.
}
}
prefs().edit().remove(KEY_TREE_URI).apply()
invoke.resolve(statusObject())
}
/** Locate a direct child of the chosen folder by display name. */
private fun findChild(tree: Uri, name: String): Uri? {
val children = DocumentsContract.buildChildDocumentsUriUsingTree(
tree,
DocumentsContract.getTreeDocumentId(tree)
)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME
)
activity.contentResolver.query(children, projection, null, null, null)
?.use { c: Cursor ->
while (c.moveToNext()) {
if (c.getString(1) == name) {
return DocumentsContract.buildDocumentUriUsingTree(tree, c.getString(0))
}
}
}
return null
}
/**
* Names of the files directly inside the chosen folder. Rust uses this to
* discover the other devices' snapshots; which names matter is entirely its
* business, so everything is returned unfiltered.
*/
@Command
fun list(invoke: Invoke) {
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
try {
val names = JSArray()
val children = DocumentsContract.buildChildDocumentsUriUsingTree(
tree,
DocumentsContract.getTreeDocumentId(tree)
)
val projection = arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
activity.contentResolver.query(children, projection, null, null, null)
?.use { c: Cursor ->
while (c.moveToNext()) {
c.getString(0)?.let { names.put(it) }
}
}
invoke.resolve(JSObject().apply { put("names", names) })
} catch (e: Exception) {
invoke.reject("Couldn't list the synced folder: ${e.message}")
}
}
/**
* Remove a file from the chosen folder.
*
* Used only to clear away conflict copies once their contents have been
* merged and republished — never to delete the library document itself.
*/
@Command
fun deleteFile(invoke: Invoke) {
val args = invoke.parseArgs(NameArgs::class.java)
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
val doc = findChild(tree, args.name)
if (doc == null) {
invoke.resolve(JSObject().apply { put("removed", false) })
return
}
try {
val removed = DocumentsContract.deleteDocument(activity.contentResolver, doc)
invoke.resolve(JSObject().apply { put("removed", removed) })
} catch (e: Exception) {
invoke.reject("Couldn't remove ${args.name}: ${e.message}")
}
}
/** Copy `name` out of the chosen folder into the app-private `path`. */
@Command
fun readInto(invoke: Invoke) {
val args = invoke.parseArgs(FileArgs::class.java)
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
val doc = findChild(tree, args.name)
if (doc == null) {
// A missing file is an expected state (nothing synced here yet), not an
// error — the caller decides what to do about it.
invoke.resolve(JSObject().apply {
put("found", false)
put("bytes", 0L)
})
return
}
try {
val dest = File(args.path)
dest.parentFile?.mkdirs()
val copied = activity.contentResolver.openInputStream(doc)!!.use { input ->
dest.outputStream().use { out -> input.copyTo(out) }
}
invoke.resolve(JSObject().apply {
put("found", true)
put("bytes", copied)
})
} catch (e: Exception) {
invoke.reject("Couldn't read ${args.name} from the synced folder: ${e.message}")
}
}
/** Copy the app-private `path` into the chosen folder as `name`. */
@Command
fun writeFrom(invoke: Invoke) {
val args = invoke.parseArgs(FileArgs::class.java)
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
val src = File(args.path)
if (!src.isFile) return invoke.reject("Nothing to upload at ${args.path}")
try {
// Overwrite the existing document when there is one, rather than
// delete-and-recreate: a sync tool then sees one modified file instead
// of a removal followed by an unrelated addition.
val doc = findChild(tree, args.name)
?: DocumentsContract.createDocument(
activity.contentResolver,
DocumentsContract.buildDocumentUriUsingTree(
tree,
DocumentsContract.getTreeDocumentId(tree)
),
MIME,
args.name
)
?: return invoke.reject("Couldn't create ${args.name} in the synced folder")
// "wt" truncates first. Plain "w" leaves any trailing bytes of a longer
// previous file in place, which would silently corrupt a database that
// got smaller.
val copied = activity.contentResolver.openOutputStream(doc, "wt")!!.use { out ->
src.inputStream().use { input -> input.copyTo(out) }
}
invoke.resolve(JSObject().apply { put("bytes", copied) })
} catch (e: Exception) {
invoke.reject("Couldn't write ${args.name} to the synced folder: ${e.message}")
}
}
}