Replace folder sync with an HTTP sync server, keeping the app offline-first

Folder sync left too much to the user's sync tool: platform file access
(the Android SAF plugin), choosing the document, merging conflict copies —
and its merge base could fall out of step with the document (absorbing
conflict copies, "forget", picking a new Android folder), reading every
difference as a deletion.

The new mtgc-server crate stores one versioned library per user and accepts
a write only when it names the version it replaces. It never merges: devices
still do that with merge3, through a new sync cycle over a `Remote` trait in
mtgc-core. The base advances only on knowledge — to what was read, or to a
write the server confirmed — and is stored with the server and account it
belongs to, so a different account starts from an empty ancestor instead of
deleting. The working copy is only rewritten under the app-state lock, and
sync commands now run off the main thread.

Sign-in happens in the browser on the server's pages, via the owner's OIDC
provider and/or local username/password accounts (Argon2id, managed with
`mtgc-server user ...`), and returns a per-device revocable token to the app
by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on
Android.

The Info tab gains sign-in, the first-sync combine/replace choice, the
account's devices, and JSON export/import; the top bar shows sync state.
Includes a Dockerfile, Compose/Caddy example, and scripts to build and push
${DOCKER_REGISTRY}/mtgc/sync-server.
This commit is contained in:
2026-10-08 22:32:08 -05:00
parent df2cbb372a
commit de55fc634a
41 changed files with 6365 additions and 1662 deletions

View File

@@ -2,9 +2,10 @@
<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. -->
<!-- Library sync talks to the user's sync server over the network, so it
needs no storage permission. Sign-in returns to the app through the
mtgc:// scheme; the deep-link plugin adds that intent filter to the
activity below when the app is built. -->
<!-- AndroidTV support -->

View File

@@ -1,295 +0,0 @@
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}")
}
}
}