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:
@@ -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 -->
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user