diff options
Diffstat (limited to 'app/src/main/java/invalid/lena')
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/Config.kt | 125 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt | 49 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/Keys.kt | 13 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/MainActivity.kt | 94 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/RemoteActivity.kt | 98 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/RsyncRunner.kt | 2 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/Scheduler.kt | 4 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/SyncWorker.kt | 25 |
8 files changed, 317 insertions, 93 deletions
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<String> = emptyList(), ) data class Config( - val remote: Remote = Remote(), + val remotes: List<Remote> = emptyList(), val schedule: Schedule = Schedule(), val folders: List<Folder> = 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<Remote>() + 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<Folder>(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<String> = 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<Button>(R.id.browse).setOnClickListener { pickFolder.launch( @@ -71,16 +92,22 @@ class FolderEditActivity : AppCompatActivity() { } private fun saveFolder(): Boolean { + val pos = remoteName.selectedItemPosition + if (pos !in remoteNames.indices) { + Toast.makeText(this, "Add a remote first.", Toast.LENGTH_LONG).show() + return false + } val f = Folder( name = name.text.toString().trim(), local = local.text.toString().trim(), - remote = remote.text.toString().trim(), + remoteName = remoteNames[pos], + remotePath = remotePath.text.toString().trim(), delete = delete.isChecked, excludes = excludes.text.toString().split(",").map { it.trim() }.filter { it.isNotEmpty() }, ) // An empty path would rsync / (local) or the rrsync root (remote): // refuse rather than sync the world. - if (f.local.isEmpty() || f.remote.isEmpty()) { + if (f.local.isEmpty() || f.remotePath.isEmpty()) { Toast.makeText(this, "Set both local and remote paths first.", Toast.LENGTH_LONG).show() return false } diff --git a/app/src/main/java/invalid/lena/rsend/Keys.kt b/app/src/main/java/invalid/lena/rsend/Keys.kt index 501a124..5ba6d67 100644 --- a/app/src/main/java/invalid/lena/rsend/Keys.kt +++ b/app/src/main/java/invalid/lena/rsend/Keys.kt @@ -68,12 +68,11 @@ object Keys { return Scan(true, lines[0], lines[1], "") } - fun pin(ctx: Context, line: String) { - atomicWrite(knownHosts(ctx), (line + "\n").toByteArray()) - } - - fun clearPin(ctx: Context) { - knownHosts(ctx).delete() + // writeKnownHosts regenerates the known_hosts file rsh verifies against. + // The pins live in config.json (Remote.hostKey); this file is derived + // state, rewritten from all pinned remotes before every sync. + fun writeKnownHosts(ctx: Context, lines: List<String>) { + atomicWrite(knownHosts(ctx), lines.joinToString("") { it + "\n" }.toByteArray()) } // AtomicFile keeps the previous complete file if a write is interrupted. @@ -88,6 +87,4 @@ object Keys { throw e } } - - fun pinned(ctx: Context): Boolean = knownHosts(ctx).let { it.exists() && it.length() > 0 } } diff --git a/app/src/main/java/invalid/lena/rsend/MainActivity.kt b/app/src/main/java/invalid/lena/rsend/MainActivity.kt index 1154449..2f300c9 100644 --- a/app/src/main/java/invalid/lena/rsend/MainActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/MainActivity.kt @@ -14,6 +14,7 @@ import android.os.PowerManager import android.provider.Settings import android.view.View import android.widget.Button +import android.widget.ImageView import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.TextView @@ -30,12 +31,11 @@ import java.util.concurrent.TimeUnit import kotlin.concurrent.thread // MainActivity is the dashboard: a status hero, a primary Sync action, grouped -// Setup and Permissions rows, and the list of folders. Editing happens in -// RemoteActivity, ScheduleActivity, and FolderEditActivity. +// Setup and Permissions rows, and the lists of remotes and folders. Editing +// happens in RemoteActivity, ScheduleActivity, and FolderEditActivity. class MainActivity : AppCompatActivity() { private lateinit var lastSync: TextView - private lateinit var remoteValue: TextView private lateinit var keyValue: TextView private lateinit var scheduleValue: TextView private lateinit var dotAllFiles: View @@ -44,6 +44,7 @@ class MainActivity : AppCompatActivity() { private lateinit var valNotif: TextView private lateinit var dotBattery: View private lateinit var valBattery: TextView + private lateinit var remotes: LinearLayout private lateinit var folders: LinearLayout private lateinit var btnSync: Button private lateinit var syncBar: ProgressBar @@ -63,7 +64,6 @@ class MainActivity : AppCompatActivity() { setSupportActionBar(findViewById(R.id.toolbar)) fitSystemBars() lastSync = findViewById(R.id.lastSync) - remoteValue = findViewById(R.id.remoteValue) keyValue = findViewById(R.id.keyValue) scheduleValue = findViewById(R.id.scheduleValue) dotAllFiles = findViewById(R.id.dotAllFiles) @@ -72,6 +72,7 @@ class MainActivity : AppCompatActivity() { valNotif = findViewById(R.id.valNotif) dotBattery = findViewById(R.id.dotBattery) valBattery = findViewById(R.id.valBattery) + remotes = findViewById(R.id.remotes) folders = findViewById(R.id.folders) btnSync = findViewById(R.id.btnSync) syncBar = findViewById(R.id.syncBar) @@ -81,9 +82,6 @@ class MainActivity : AppCompatActivity() { btnSync.setOnClickListener { syncNow() } observeSync() - findViewById<LinearLayout>(R.id.rowRemote).setOnClickListener { - startActivity(Intent(this, RemoteActivity::class.java)) - } findViewById<LinearLayout>(R.id.rowKey).setOnClickListener { showKey() } findViewById<LinearLayout>(R.id.rowSchedule).setOnClickListener { startActivity(Intent(this, ScheduleActivity::class.java)) @@ -120,8 +118,15 @@ class MainActivity : AppCompatActivity() { ) } } + findViewById<Button>(R.id.btnAddRemote).setOnClickListener { + startActivity(Intent(this, RemoteActivity::class.java).putExtra("index", -1)) + } findViewById<Button>(R.id.btnAddFolder).setOnClickListener { - startActivity(Intent(this, FolderEditActivity::class.java).putExtra("index", -1)) + if (Config.load(this).remotes.isEmpty()) { + Toast.makeText(this, "Add a remote first.", Toast.LENGTH_LONG).show() + } else { + startActivity(Intent(this, FolderEditActivity::class.java).putExtra("index", -1)) + } } findViewById<Button>(R.id.btnLog).setOnClickListener { startActivity(Intent(this, LogActivity::class.java)) @@ -130,9 +135,8 @@ class MainActivity : AppCompatActivity() { private fun syncNow() { val cfg = Config.load(this) - if (cfg.remote.host.isEmpty() || cfg.remote.user.isEmpty() || cfg.folders.isEmpty() || - !Keys.exists(this) || !Keys.pinned(this)) { - Toast.makeText(this, "Set the remote, key, host pin, and at least one folder first.", Toast.LENGTH_LONG) + if (!cfg.syncReady(Keys.exists(this))) { + Toast.makeText(this, "Set up a key, a pinned remote, and at least one folder first.", Toast.LENGTH_LONG) .show() return } @@ -202,13 +206,6 @@ class MainActivity : AppCompatActivity() { lastSync.text = "Last sync: ${LastSync.get(this)}" - val r = cfg.remote - remoteValue.text = if (r.host.isEmpty()) { - "Not set" - } else { - "${r.user}@${r.host}:${r.port}, ${if (Keys.pinned(this)) "pinned" else "not pinned"}" - } - keyValue.text = if (Keys.exists(this)) "Generated, tap to view" else "Not created, tap to create" val s = cfg.schedule @@ -225,30 +222,65 @@ class MainActivity : AppCompatActivity() { setPerm(dotNotif, valNotif, notif(), "Granted", "Tap to grant") setPerm(dotBattery, valBattery, batteryUnrestricted(), "Unrestricted", "Optimized, tap to fix") + remotes.removeAllViews() + if (cfg.remotes.isEmpty()) { + remotes.addView(emptyHint("No remotes yet. Tap Add remote below.")) + } else { + cfg.remotes.forEachIndexed { i, r -> + addRow( + remotes, R.drawable.ic_remote, r.name, + "${r.user}@${r.host}:${r.port}", + if (r.pinned()) "pinned" else "no pin", + ) { + startActivity( + Intent(this, RemoteActivity::class.java).putExtra("index", i) + ) + } + } + } + folders.removeAllViews() if (cfg.folders.isEmpty()) { - folders.addView(TextView(this).apply { - text = "No folders yet. Tap Add folder below." - setTextAppearance(R.style.TextAppearance_Rsend_Caption) - setPadding(0, 0, 0, resources.getDimensionPixelSize(R.dimen.space_m)) - }) + folders.addView(emptyHint("No folders yet. Tap Add folder below.")) } else { cfg.folders.forEachIndexed { i, f -> - val row = layoutInflater.inflate(R.layout.item_folder, folders, false) - row.findViewById<TextView>(R.id.folderName).text = f.name.ifEmpty { f.local } - row.findViewById<TextView>(R.id.folderPath).text = "${f.local} -> ${f.remote}" - row.findViewById<TextView>(R.id.folderChip).text = if (f.delete) "mirror" else "add" - row.setOnClickListener { + addRow( + folders, R.drawable.ic_folder, f.name.ifEmpty { f.local }, + "${f.local} -> ${f.remoteName}:${f.remotePath}", + if (f.delete) "mirror" else "add", + ) { startActivity( - Intent(this@MainActivity, FolderEditActivity::class.java) - .putExtra("index", i) + Intent(this, FolderEditActivity::class.java).putExtra("index", i) ) } - folders.addView(row) } } } + // addRow inflates one tappable list row: icon, title, subtitle, chip. + private fun addRow( + list: LinearLayout, + icon: Int, + title: String, + sub: String, + chip: String, + onClick: () -> Unit, + ) { + val row = layoutInflater.inflate(R.layout.item_row, list, false) + row.findViewById<ImageView>(R.id.icon).setImageResource(icon) + row.findViewById<TextView>(R.id.rowTitle).text = title + row.findViewById<TextView>(R.id.rowSub).text = sub + row.findViewById<TextView>(R.id.rowChip).text = chip + row.setOnClickListener { onClick() } + list.addView(row) + } + + private fun emptyHint(msg: String): TextView = TextView(this).apply { + text = msg + setTextAppearance(R.style.TextAppearance_Rsend_Caption) + setPadding(0, 0, 0, resources.getDimensionPixelSize(R.dimen.space_m)) + } + private fun setPerm(dot: View, value: TextView, ok: Boolean, okText: String, badText: String) { dot.setBackgroundResource(if (ok) R.drawable.dot_on else R.drawable.dot_off) value.text = if (ok) okText else badText diff --git a/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt index 5ff53f9..0d77e6c 100644 --- a/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt @@ -1,6 +1,7 @@ package invalid.lena.rsend import android.os.Bundle +import android.view.View import android.widget.Button import android.widget.EditText import android.widget.TextView @@ -8,10 +9,13 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import kotlin.concurrent.thread -// RemoteActivity edits the SSH target and runs Test connection, which scans the -// host key and pins it on the user's confirmation. +// RemoteActivity adds or edits one named remote and runs Test connection, +// which scans the host key and stores the pin on this remote when the user +// accepts it. index < 0 means a new remote. class RemoteActivity : AppCompatActivity() { + private var index: Int = -1 + private lateinit var name: EditText private lateinit var host: EditText private lateinit var port: EditText private lateinit var user: EditText @@ -23,44 +27,104 @@ class RemoteActivity : AppCompatActivity() { setSupportActionBar(findViewById(R.id.toolbar)) title = "Remote" fitSystemBars() + name = findViewById(R.id.name) host = findViewById(R.id.host) port = findViewById(R.id.port) user = findViewById(R.id.user) status = findViewById(R.id.status) + index = intent.getIntExtra("index", -1) val cfg = Config.load(this) - host.setText(cfg.remote.host) - port.setText(cfg.remote.port.toString()) - user.setText(cfg.remote.user) + if (index in cfg.remotes.indices) { + val r = cfg.remotes[index] + name.setText(r.name) + host.setText(r.host) + port.setText(r.port.toString()) + user.setText(r.user) + } else { + port.setText("22") + } - findViewById<Button>(R.id.save).setOnClickListener { if (save()) finish() } + findViewById<Button>(R.id.save).setOnClickListener { if (save() >= 0) finish() } findViewById<Button>(R.id.test).setOnClickListener { test() } + val del = findViewById<Button>(R.id.removeRemote) + del.visibility = if (index < 0) View.GONE else View.VISIBLE + del.setOnClickListener { if (remove()) finish() } } private fun current(): Remote? { + val n = name.text.toString().trim() + if (n.isEmpty()) { + status.text = "Set a name; folders pick their remote by it." + return null + } val p = port.text.toString().toIntOrNull() if (p == null || p !in 1..65535) { status.text = "Port must be between 1 and 65535." return null } return Remote( + name = n, host = host.text.toString().trim(), port = p, user = user.text.toString().trim(), ) } - private fun save(remote: Remote? = current()): Boolean { - if (remote == null) return false + // save validates, keeps the pin only while host and port are unchanged, + // renames folder references along with the remote, and persists. Returns + // the saved entry's index (a new remote is appended), or -1 on a + // validation failure. + private fun save(remote: Remote? = current()): Int { + if (remote == null) return -1 + val cfg = Config.load(this) + if (cfg.remotes.withIndex().any { (i, r) -> i != index && r.name == remote.name }) { + status.text = "A remote named \"${remote.name}\" already exists." + return -1 + } + val old = cfg.remotes.getOrNull(index) + val hostKey = + if (old != null && old.host == remote.host && old.port == remote.port) old.hostKey else "" + val entry = remote.copy(hostKey = hostKey) + val remotes = cfg.remotes.toMutableList() + val at = if (index in remotes.indices) { + remotes[index] = entry + index + } else { + remotes.add(entry) + remotes.size - 1 + } + var folders = cfg.folders + if (old != null && old.name != entry.name) { + folders = folders.map { + if (it.remoteName == old.name) it.copy(remoteName = entry.name) else it + } + } + Config.save(this, cfg.copy(remotes = remotes, folders = folders)) + return at + } + + // remove refuses while folders still reference this remote: a dangling + // name would need special-casing on every other screen. + private fun remove(): Boolean { val cfg = Config.load(this) - if (cfg.remote.host != remote.host || cfg.remote.port != remote.port) Keys.clearPin(this) - Config.save(this, cfg.copy(remote = remote)) + val r = cfg.remotes.getOrNull(index) ?: return true + val used = cfg.folders.count { it.remoteName == r.name } + if (used > 0) { + status.text = "Cannot remove: $used folder(s) sync to this remote." + return false + } + Config.save(this, cfg.copy(remotes = cfg.remotes.filterIndexed { i, _ -> i != index })) return true } private fun test() { val r = current() ?: return - save(r) + val saved = save(r) + if (saved < 0) return + // Testing a new remote adopts the appended entry, so Pin and a second + // Save update it instead of appending again. + index = saved when { !Keys.exists(this) -> status.text = "No key yet. Generate one on the main screen and add it to the server first." @@ -83,7 +147,7 @@ class RemoteActivity : AppCompatActivity() { .setTitle("Verify host key") .setMessage("Fingerprint:\n${scan.fingerprint}\n\nPin this host?") .setPositiveButton("Pin") { _, _ -> - Keys.pin(this, scan.line) + pin(scan.line) status.text = "Host key pinned. Connection OK." } .setNegativeButton("Cancel", null) @@ -94,4 +158,14 @@ class RemoteActivity : AppCompatActivity() { } } } + + // pin re-loads the config before writing: the scan ran in the background + // and the dialog may have sat open for a while. + private fun pin(line: String) { + val cfg = Config.load(this) + val remotes = cfg.remotes.toMutableList() + if (index !in remotes.indices) return + remotes[index] = remotes[index].copy(hostKey = line) + Config.save(this, cfg.copy(remotes = remotes)) + } } diff --git a/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt b/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt index 2868e25..c4c0536 100644 --- a/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt +++ b/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt @@ -37,7 +37,7 @@ object RsyncRunner { for (e in f.excludes) a.add("--exclude=$e") if (f.delete) a.add("--delete") a.add(withSlash(f.local)) - a.add("${remote.user}@${remote.host}:${withSlash(f.remote)}") + a.add("${remote.user}@${remote.host}:${withSlash(f.remotePath)}") return a } diff --git a/app/src/main/java/invalid/lena/rsend/Scheduler.kt b/app/src/main/java/invalid/lena/rsend/Scheduler.kt index 8f20498..4b0e687 100644 --- a/app/src/main/java/invalid/lena/rsend/Scheduler.kt +++ b/app/src/main/java/invalid/lena/rsend/Scheduler.kt @@ -20,9 +20,7 @@ object Scheduler { val s = cfg.schedule val wm = WorkManager.getInstance(ctx) - val ready = cfg.remote.host.isNotEmpty() && cfg.remote.user.isNotEmpty() && cfg.folders.isNotEmpty() && - Keys.exists(ctx) && Keys.pinned(ctx) - if (!s.enabled || !ready) { + if (!s.enabled || !cfg.syncReady(Keys.exists(ctx))) { wm.cancelUniqueWork(NAME) return } diff --git a/app/src/main/java/invalid/lena/rsend/SyncWorker.kt b/app/src/main/java/invalid/lena/rsend/SyncWorker.kt index d884a6b..b8c9a9d 100644 --- a/app/src/main/java/invalid/lena/rsend/SyncWorker.kt +++ b/app/src/main/java/invalid/lena/rsend/SyncWorker.kt @@ -33,15 +33,17 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, withContext(Dispatchers.IO) { val ctx = applicationContext val cfg = Config.load(ctx) - if (cfg.remote.host.isEmpty() || cfg.remote.user.isEmpty() || cfg.folders.isEmpty() || - !Keys.exists(ctx) || !Keys.pinned(ctx)) { - SyncLog(ctx).line("sync skipped: remote, folders, key, or pinned host missing") + if (!cfg.syncReady(Keys.exists(ctx))) { + SyncLog(ctx).line("sync skipped: key, folders, or a pinned remote missing") return@withContext Result.failure() } val log = SyncLog(ctx) log.rotateIfBig() log.line("sync started (${cfg.folders.size} folders)") + // known_hosts is derived from the config's pins; regenerate it here, + // under the sync mutex, before the first rsync reads it. + Keys.writeKnownHosts(ctx, cfg.remotes.map { it.hostKey }.filter { it.isNotEmpty() }) // A foreground service lets long syncs survive, but Android 12+ forbids // starting one when the periodic job fires in the background. Try it, and // fall back to a plain background job when it is not allowed. @@ -59,13 +61,24 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, val label = f.name.ifEmpty { f.local } // Publish progress the dashboard observes (current folder, i of n). setProgress(workDataOf(KEY_FOLDER to label, KEY_INDEX to i + 1, KEY_TOTAL to total)) - log.line("=== $label ===") + log.line("=== $label -> ${f.remoteName} ===") + val remote = cfg.remote(f.remoteName) + // A broken folder fails alone; the others still sync. // Guard against a hand-edited config: an empty path would rsync /. - val code = if (f.local.isEmpty() || f.remote.isEmpty()) { + val code = if (f.local.isEmpty() || f.remotePath.isEmpty()) { log.line("error: local or remote path not set") 1 + } else if (remote == null) { + log.line("error: remote \"${f.remoteName}\" not found") + 1 + } else if (remote.host.isEmpty() || remote.user.isEmpty()) { + log.line("error: remote \"${f.remoteName}\" has no host or user") + 1 + } else if (!remote.pinned()) { + log.line("error: remote \"${f.remoteName}\" not pinned, run Test connection") + 1 } else try { - RsyncRunner.runFolder(ctx, cfg.remote, f, log) + RsyncRunner.runFolder(ctx, remote, f, log) } catch (e: CancellationException) { log.line("cancelled") throw e |