From 0890d139e5fa501561e469aca7854791a936fcb6 Mon Sep 17 00:00:00 2001 From: Lena Date: Sun, 16 Aug 2026 00:00:00 +0000 Subject: app: support multiple named remotes Let each folder select a named SSH endpoint and migrate the published 0.1.x configuration without discarding its folders or host pin. --- app/src/main/java/invalid/lena/rsend/Config.kt | 125 +++++++++++++++++---- .../java/invalid/lena/rsend/FolderEditActivity.kt | 49 ++++++-- app/src/main/java/invalid/lena/rsend/Keys.kt | 13 +-- .../main/java/invalid/lena/rsend/MainActivity.kt | 94 +++++++++++----- .../main/java/invalid/lena/rsend/RemoteActivity.kt | 98 ++++++++++++++-- .../main/java/invalid/lena/rsend/RsyncRunner.kt | 2 +- app/src/main/java/invalid/lena/rsend/Scheduler.kt | 4 +- app/src/main/java/invalid/lena/rsend/SyncWorker.kt | 25 ++++- app/src/main/res/layout/activity_folder.xml | 15 ++- app/src/main/res/layout/activity_main.xml | 98 ++++++---------- app/src/main/res/layout/activity_remote.xml | 24 ++++ app/src/main/res/layout/item_folder.xml | 48 -------- app/src/main/res/layout/item_row.xml | 49 ++++++++ app/src/test/java/invalid/lena/rsend/ConfigTest.kt | 111 +++++++++++++++++- .../java/invalid/lena/rsend/RsyncRunnerTest.kt | 12 +- 15 files changed, 552 insertions(+), 215 deletions(-) delete mode 100644 app/src/main/res/layout/item_folder.xml create mode 100644 app/src/main/res/layout/item_row.xml (limited to 'app') diff --git a/app/src/main/java/invalid/lena/rsend/Config.kt b/app/src/main/java/invalid/lena/rsend/Config.kt index 2662ea3..69f3869 100644 --- a/app/src/main/java/invalid/lena/rsend/Config.kt +++ b/app/src/main/java/invalid/lena/rsend/Config.kt @@ -7,10 +7,20 @@ import org.json.JSONObject import java.io.File import java.io.FileNotFoundException -// Config is rsend's whole state: the remote target, the schedule, and the -// folders to push. It is stored as plain JSON in app-private storage. +// Config is rsend's whole state: the named remote targets, the schedule, and +// the folders to push. It is stored as plain JSON in app-private storage. +// config.json owns the host-key pins (Remote.hostKey); the known_hosts file +// rsh reads is regenerated from them before every sync. -data class Remote(val host: String = "", val port: Int = 22, val user: String = "") +data class Remote( + val name: String = "", + val host: String = "", + val port: Int = 22, + val user: String = "", + val hostKey: String = "", +) { + fun pinned(): Boolean = hostKey.isNotEmpty() +} data class Schedule( val enabled: Boolean = false, @@ -26,21 +36,37 @@ data class Schedule( data class Folder( val name: String = "", val local: String = "", - val remote: String = "", + val remoteName: String = "", + val remotePath: String = "", val delete: Boolean = false, val excludes: List = emptyList(), ) data class Config( - val remote: Remote = Remote(), + val remotes: List = emptyList(), val schedule: Schedule = Schedule(), val folders: List = emptyList(), ) { + fun remote(name: String): Remote? = remotes.firstOrNull { it.name == name } + + // syncReady reports whether a sync can do useful work: an identity key, + // at least one folder, and at least one complete, pinned remote. Folders + // pointing at a broken remote fail individually during the run. + fun syncReady(keyExists: Boolean): Boolean = + keyExists && folders.isNotEmpty() && + remotes.any { it.host.isNotEmpty() && it.user.isNotEmpty() && it.pinned() } + fun toJson(): JSONObject = JSONObject().apply { - put("remote", JSONObject().apply { - put("host", remote.host) - put("port", remote.port) - put("user", remote.user) + put("remotes", JSONArray().apply { + remotes.forEach { r -> + put(JSONObject().apply { + put("name", r.name) + put("host", r.host) + put("port", r.port) + put("user", r.user) + put("hostKey", r.hostKey) + }) + } }) put("schedule", JSONObject().apply { put("enabled", schedule.enabled) @@ -53,7 +79,8 @@ data class Config( put(JSONObject().apply { put("name", f.name) put("local", f.local) - put("remote", f.remote) + put("remoteName", f.remoteName) + put("remotePath", f.remotePath) put("delete", f.delete) put("excludes", JSONArray(f.excludes)) }) @@ -64,16 +91,38 @@ data class Config( companion object { fun file(ctx: Context): File = File(ctx.filesDir, "config.json") + // legacyShape reports whether o is a pre-0.2 config: no "remotes" array, + // but the single-remote "remote" object 0.1.x always wrote. + fun legacyShape(o: JSONObject): Boolean = + o.optJSONArray("remotes") == null && o.optJSONObject("remote") != null + fun load(ctx: Context): Config { val f = file(ctx) - return try { + val migrated: Boolean + val c = try { val text = AtomicFile(f).openRead().bufferedReader().use { it.readText() } - fromJson(JSONObject(text)) + val o = JSONObject(text) + migrated = legacyShape(o) + // Only the legacy single-remote shape needs the old pin file. + var pin = "" + if (migrated) { + val kh = Keys.knownHosts(ctx) + if (kh.exists()) pin = kh.readText().trim() + } + fromJson(o, pin) } catch (_: FileNotFoundException) { - Config() + return Config() } catch (e: Exception) { throw IllegalStateException("could not read ${f.name}: ${e.message}", e) } + // Retire the old shape on the first read rather than waiting for the + // user to edit something. Without this a working 0.1.x setup keeps + // its pre-0.2 config.json indefinitely, and deleting the fallback in + // a later release would silently strand it. Writing the new shape + // also makes the fallback unreachable from here on: toJson always + // emits "remotes", so legacyShape can never match again. + if (migrated) save(ctx, c) + return c } // AtomicFile keeps the previous complete config if a write is interrupted. @@ -89,8 +138,42 @@ data class Config( } } - fun fromJson(o: JSONObject): Config { - val r = o.optJSONObject("remote") ?: JSONObject() + // fromJson also reads the pre-0.2 single-remote shape once: the old + // "remote" object becomes the sole entry, named after its host, every + // folder points at it (the folder's old "remote" field was the + // destination path), and legacyPin carries the known_hosts line over. + // Remove this fallback after a release or two. + fun fromJson(o: JSONObject, legacyPin: String = ""): Config { + val ra = o.optJSONArray("remotes") + val legacy = if (ra == null) o.optJSONObject("remote") else null + val legacyName = legacy?.optString("host") ?: "" + + val remotes = ArrayList() + if (ra != null) { + for (i in 0 until ra.length()) { + val ro = ra.getJSONObject(i) + remotes.add( + Remote( + name = ro.optString("name"), + host = ro.optString("host"), + port = ro.optInt("port", 22), + user = ro.optString("user"), + hostKey = ro.optString("hostKey"), + ) + ) + } + } else if (legacyName.isNotEmpty()) { + remotes.add( + Remote( + name = legacyName, + host = legacyName, + port = legacy!!.optInt("port", 22), + user = legacy.optString("user"), + hostKey = legacyPin, + ) + ) + } + val s = o.optJSONObject("schedule") ?: JSONObject() val fa = o.optJSONArray("folders") ?: JSONArray() val folders = ArrayList(fa.length()) @@ -103,18 +186,18 @@ data class Config( Folder( name = fo.optString("name"), local = fo.optString("local"), - remote = fo.optString("remote"), + // Gate on the legacy shape, not on the legacy host: a + // 0.1.x config with folders but no host yet still has + // real paths in the folders' old "remote" field. + remoteName = if (legacy != null) legacyName else fo.optString("remoteName"), + remotePath = if (legacy != null) fo.optString("remote") else fo.optString("remotePath"), delete = fo.optBoolean("delete", false), excludes = excludes, ) ) } return Config( - remote = Remote( - host = r.optString("host"), - port = r.optInt("port", 22), - user = r.optString("user"), - ), + remotes = remotes, schedule = Schedule( enabled = s.optBoolean("enabled", false), intervalMinutes = s.optInt("intervalMinutes", 120) diff --git a/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt b/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt index 466a859..14e04c9 100644 --- a/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt @@ -4,8 +4,10 @@ import android.app.Activity import android.content.Intent import android.os.Bundle import android.view.View +import android.widget.ArrayAdapter import android.widget.Button import android.widget.EditText +import android.widget.Spinner import android.widget.Switch import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts @@ -19,10 +21,15 @@ class FolderEditActivity : AppCompatActivity() { private var index: Int = -1 private lateinit var name: EditText private lateinit var local: EditText - private lateinit var remote: EditText + private lateinit var remoteName: Spinner + private lateinit var remotePath: EditText private lateinit var excludes: EditText private lateinit var delete: Switch + // remoteNames[i] is the real name behind spinner position i; the label + // differs only for a reference to a remote that no longer exists. + private var remoteNames: List = emptyList() + // The picker returns a real filesystem path; drop it into the local field and // name the folder after its basename if the name is still blank. private val pickFolder = @@ -42,20 +49,34 @@ class FolderEditActivity : AppCompatActivity() { fitSystemBars() name = findViewById(R.id.name) local = findViewById(R.id.local) - remote = findViewById(R.id.remote) + remoteName = findViewById(R.id.remoteName) + remotePath = findViewById(R.id.remotePath) excludes = findViewById(R.id.excludes) delete = findViewById(R.id.delete) index = intent.getIntExtra("index", -1) val cfg = Config.load(this) - if (index in cfg.folders.indices) { - val f = cfg.folders[index] - name.setText(f.name) - local.setText(f.local) - remote.setText(f.remote) - excludes.setText(f.excludes.joinToString(", ")) - delete.isChecked = f.delete + val f = if (index in cfg.folders.indices) cfg.folders[index] else Folder() + name.setText(f.name) + local.setText(f.local) + remotePath.setText(f.remotePath) + excludes.setText(f.excludes.joinToString(", ")) + delete.isChecked = f.delete + + // A reference to a missing remote (hand-edited config) stays selectable + // as "name (missing)" so Save cannot silently repoint the folder, which + // with mirror on would --delete into the wrong host. + val names = cfg.remotes.map { it.name }.toMutableList() + val labels = names.toMutableList() + if (f.remoteName.isNotEmpty() && f.remoteName !in names) { + names.add(0, f.remoteName) + labels.add(0, "${f.remoteName} (missing)") } + remoteNames = names + remoteName.adapter = + ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, labels) + val sel = names.indexOf(f.remoteName) + if (sel >= 0) remoteName.setSelection(sel) findViewById