aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java/invalid
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-16 00:00:00 +0000
committerLena <lena@omega>2026-08-16 00:00:00 +0000
commit0890d139e5fa501561e469aca7854791a936fcb6 (patch)
tree58c6002cfb9f9b198a5013941ba01147aed1bc9a /app/src/main/java/invalid
parent6c0e672432dc466214c0838db378e8269d090ab4 (diff)
downloadrsend-0890d139e5fa501561e469aca7854791a936fcb6.tar.gz
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.
Diffstat (limited to 'app/src/main/java/invalid')
-rw-r--r--app/src/main/java/invalid/lena/rsend/Config.kt125
-rw-r--r--app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt49
-rw-r--r--app/src/main/java/invalid/lena/rsend/Keys.kt13
-rw-r--r--app/src/main/java/invalid/lena/rsend/MainActivity.kt94
-rw-r--r--app/src/main/java/invalid/lena/rsend/RemoteActivity.kt98
-rw-r--r--app/src/main/java/invalid/lena/rsend/RsyncRunner.kt2
-rw-r--r--app/src/main/java/invalid/lena/rsend/Scheduler.kt4
-rw-r--r--app/src/main/java/invalid/lena/rsend/SyncWorker.kt25
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