diff options
Diffstat (limited to 'app/src/main/java')
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/BootReceiver.kt | 15 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/Config.kt | 38 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/KeyVault.kt | 4 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/Keys.kt | 27 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/MainActivity.kt | 28 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/Native.kt | 8 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/RemoteActivity.kt | 37 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/RsyncRunner.kt | 15 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt | 7 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/Scheduler.kt | 55 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/SyncWorker.kt | 98 |
11 files changed, 188 insertions, 144 deletions
diff --git a/app/src/main/java/invalid/lena/rsend/BootReceiver.kt b/app/src/main/java/invalid/lena/rsend/BootReceiver.kt deleted file mode 100644 index 3e135f3..0000000 --- a/app/src/main/java/invalid/lena/rsend/BootReceiver.kt +++ /dev/null @@ -1,15 +0,0 @@ -package invalid.lena.rsend - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent - -// BootReceiver re-applies the schedule after the device restarts, so periodic -// sync survives reboots even if WorkManager's own state was cleared. -class BootReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == Intent.ACTION_BOOT_COMPLETED) { - Scheduler.apply(context) - } - } -} diff --git a/app/src/main/java/invalid/lena/rsend/Config.kt b/app/src/main/java/invalid/lena/rsend/Config.kt index d7f17eb..2662ea3 100644 --- a/app/src/main/java/invalid/lena/rsend/Config.kt +++ b/app/src/main/java/invalid/lena/rsend/Config.kt @@ -1,10 +1,11 @@ package invalid.lena.rsend import android.content.Context +import android.util.AtomicFile import org.json.JSONArray import org.json.JSONObject import java.io.File -import java.io.IOException +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. @@ -16,7 +17,11 @@ data class Schedule( val intervalMinutes: Int = 120, val wifiOnly: Boolean = true, val requireCharging: Boolean = false, -) +) { + companion object { + const val MIN_INTERVAL_MINUTES = 15 + } +} data class Folder( val name: String = "", @@ -61,17 +66,27 @@ data class Config( fun load(ctx: Context): Config { val f = file(ctx) - if (!f.exists()) return Config() - return fromJson(JSONObject(f.readText())) + return try { + val text = AtomicFile(f).openRead().bufferedReader().use { it.readText() } + fromJson(JSONObject(text)) + } catch (_: FileNotFoundException) { + Config() + } catch (e: Exception) { + throw IllegalStateException("could not read ${f.name}: ${e.message}", e) + } } - // save writes tmp-then-rename so a crash mid-write cannot corrupt the - // config and brick every later load. + // AtomicFile keeps the previous complete config if a write is interrupted. fun save(ctx: Context, c: Config) { - val f = file(ctx) - val tmp = File(f.path + ".tmp") - tmp.writeText(c.toJson().toString(2)) - if (!tmp.renameTo(f)) throw IOException("rename ${tmp.path} failed") + val file = AtomicFile(file(ctx)) + val out = file.startWrite() + try { + out.write(c.toJson().toString(2).toByteArray()) + file.finishWrite(out) + } catch (e: Exception) { + file.failWrite(out) + throw e + } } fun fromJson(o: JSONObject): Config { @@ -102,7 +117,8 @@ data class Config( ), schedule = Schedule( enabled = s.optBoolean("enabled", false), - intervalMinutes = s.optInt("intervalMinutes", 120), + intervalMinutes = s.optInt("intervalMinutes", 120) + .coerceAtLeast(Schedule.MIN_INTERVAL_MINUTES), wifiOnly = s.optBoolean("wifiOnly", true), requireCharging = s.optBoolean("requireCharging", false), ), diff --git a/app/src/main/java/invalid/lena/rsend/KeyVault.kt b/app/src/main/java/invalid/lena/rsend/KeyVault.kt index ae3e8d4..ecbc9cf 100644 --- a/app/src/main/java/invalid/lena/rsend/KeyVault.kt +++ b/app/src/main/java/invalid/lena/rsend/KeyVault.kt @@ -8,8 +8,8 @@ import javax.crypto.KeyGenerator import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec -// KeyVault encrypts the SSH private key at rest with a hardware-backed AES-GCM -// key in the Android Keystore. The Keystore key cannot be exported, so the blob +// KeyVault encrypts the SSH private key at rest with an AES-GCM key in the +// Android Keystore. The Keystore key cannot be exported, so the blob // on disk is useless off this device. Blob layout is IV || ciphertext+tag. object KeyVault { diff --git a/app/src/main/java/invalid/lena/rsend/Keys.kt b/app/src/main/java/invalid/lena/rsend/Keys.kt index fa233f7..501a124 100644 --- a/app/src/main/java/invalid/lena/rsend/Keys.kt +++ b/app/src/main/java/invalid/lena/rsend/Keys.kt @@ -1,6 +1,7 @@ package invalid.lena.rsend import android.content.Context +import android.util.AtomicFile import java.io.File // Keys manages the on-device ed25519 identity and the pinned host keys. The @@ -10,6 +11,7 @@ object Keys { private fun keyEnc(ctx: Context): File = File(ctx.filesDir, "id_ed25519.enc") private fun publicKey(ctx: Context): File = File(ctx.filesDir, "id_ed25519.pub") + const val MAX_KEY_BYTES = 64 * 1024 fun knownHosts(ctx: Context): File = File(ctx.filesDir, "known_hosts") @@ -31,11 +33,11 @@ object Keys { // Throws IllegalArgumentException with rsh's message if the key is unusable // (wrong type, or passphrase-protected: rsend needs an unencrypted key). fun importKey(ctx: Context, pem: ByteArray) { + require(pem.size <= MAX_KEY_BYTES) { "private key exceeds $MAX_KEY_BYTES bytes" } val r = Native.run(Native.rsh(ctx), listOf("-pubkey"), mapOf("RSH_KEY_DATA" to String(pem))) if (r.code != 0) throw IllegalArgumentException(r.output.trim().ifEmpty { "invalid private key" }) - val pub = r.output.trim() - keyEnc(ctx).writeBytes(KeyVault.encrypt(pem)) - publicKey(ctx).writeText(pub + "\n") + atomicWrite(keyEnc(ctx), KeyVault.encrypt(pem)) + atomicWrite(publicKey(ctx), (r.output.trim() + "\n").toByteArray()) } fun publicKeyText(ctx: Context): String = @@ -67,7 +69,24 @@ object Keys { } fun pin(ctx: Context, line: String) { - knownHosts(ctx).writeText(line + "\n") + atomicWrite(knownHosts(ctx), (line + "\n").toByteArray()) + } + + fun clearPin(ctx: Context) { + knownHosts(ctx).delete() + } + + // AtomicFile keeps the previous complete file if a write is interrupted. + private fun atomicWrite(f: File, bytes: ByteArray) { + val file = AtomicFile(f) + val out = file.startWrite() + try { + out.write(bytes) + file.finishWrite(out) + } catch (e: Exception) { + file.failWrite(out) + 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 ce65c2e..1154449 100644 --- a/app/src/main/java/invalid/lena/rsend/MainActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/MainActivity.kt @@ -25,6 +25,7 @@ import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager +import java.io.ByteArrayOutputStream import java.util.concurrent.TimeUnit import kotlin.concurrent.thread @@ -128,8 +129,11 @@ class MainActivity : AppCompatActivity() { } private fun syncNow() { - if (!Keys.pinned(this)) { - Toast.makeText(this, "Set the remote and pin its host key first.", Toast.LENGTH_LONG).show() + 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) + .show() return } val req = OneTimeWorkRequestBuilder<SyncWorker>().build() @@ -210,7 +214,7 @@ class MainActivity : AppCompatActivity() { val s = cfg.schedule val sched = if (s.enabled) { "Every ${s.intervalMinutes}m" + - (if (s.wifiOnly) ", wifi" else "") + + (if (s.wifiOnly) ", unmetered" else "") + (if (s.requireCharging) ", charging" else "") } else { "Off" @@ -302,7 +306,9 @@ class MainActivity : AppCompatActivity() { e.message ?: "keygen failed" } runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread if (error == null) { + Scheduler.apply(this) refresh() Toast.makeText(this, "New key generated.", Toast.LENGTH_SHORT).show() showKey() @@ -326,7 +332,19 @@ class MainActivity : AppCompatActivity() { private fun importKeyFrom(uri: Uri) { thread { val error = try { - val data = contentResolver.openInputStream(uri)?.use { it.readBytes() } + val data = contentResolver.openInputStream(uri)?.use { input -> + val out = ByteArrayOutputStream() + val buf = ByteArray(4096) + while (true) { + val n = input.read(buf) + if (n < 0) break + if (out.size() + n > Keys.MAX_KEY_BYTES) { + throw IllegalArgumentException("private key exceeds ${Keys.MAX_KEY_BYTES} bytes") + } + out.write(buf, 0, n) + } + out.toByteArray() + } ?: throw IllegalArgumentException("could not read the file") Keys.importKey(this, data) null @@ -334,7 +352,9 @@ class MainActivity : AppCompatActivity() { e.message ?: "import failed" } runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread if (error == null) { + Scheduler.apply(this) refresh() Toast.makeText(this, "Key imported.", Toast.LENGTH_SHORT).show() showKey() diff --git a/app/src/main/java/invalid/lena/rsend/Native.kt b/app/src/main/java/invalid/lena/rsend/Native.kt index ec02cfe..7f7b5ba 100644 --- a/app/src/main/java/invalid/lena/rsend/Native.kt +++ b/app/src/main/java/invalid/lena/rsend/Native.kt @@ -2,6 +2,7 @@ package invalid.lena.rsend import android.content.Context import java.io.File +import java.util.concurrent.TimeUnit // Native locates and runs the executables shipped inside the APK as lib*.so. // Android only allows exec of native code from the app's native library dir, @@ -19,8 +20,13 @@ object Native { val pb = ProcessBuilder(listOf(bin.absolutePath) + args).redirectErrorStream(true) pb.environment().putAll(env) val p = pb.start() + if (!p.waitFor(45, TimeUnit.SECONDS)) { + p.destroyForcibly() + p.waitFor() + throw IllegalStateException("${bin.name} timed out") + } val out = p.inputStream.bufferedReader().use { it.readText() } - val code = p.waitFor() + val code = p.exitValue() return Result(code, out) } } diff --git a/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt index a587667..5ff53f9 100644 --- a/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt @@ -33,24 +33,34 @@ class RemoteActivity : AppCompatActivity() { port.setText(cfg.remote.port.toString()) user.setText(cfg.remote.user) - findViewById<Button>(R.id.save).setOnClickListener { save(); finish() } + findViewById<Button>(R.id.save).setOnClickListener { if (save()) finish() } findViewById<Button>(R.id.test).setOnClickListener { test() } } - private fun current(): Remote = Remote( - host = host.text.toString().trim(), - port = port.text.toString().toIntOrNull() ?: 22, - user = user.text.toString().trim(), - ) + private fun current(): Remote? { + 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( + host = host.text.toString().trim(), + port = p, + user = user.text.toString().trim(), + ) + } - private fun save() { + private fun save(remote: Remote? = current()): Boolean { + if (remote == null) return false val cfg = Config.load(this) - Config.save(this, cfg.copy(remote = current())) + if (cfg.remote.host != remote.host || cfg.remote.port != remote.port) Keys.clearPin(this) + Config.save(this, cfg.copy(remote = remote)) + return true } private fun test() { - save() - val r = current() + val r = current() ?: return + save(r) when { !Keys.exists(this) -> status.text = "No key yet. Generate one on the main screen and add it to the server first." @@ -59,8 +69,13 @@ class RemoteActivity : AppCompatActivity() { else -> { status.text = "Connecting to ${r.user}@${r.host}:${r.port} ..." thread { - val scan = Keys.scan(this, r) + val scan = try { + Keys.scan(this, r) + } catch (e: Exception) { + Keys.Scan(false, "", "", e.message ?: "connection failed") + } runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread if (!scan.ok) { status.text = "Connection failed:\n${scan.error}" } else { diff --git a/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt b/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt index 4d636a6..2868e25 100644 --- a/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt +++ b/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt @@ -2,8 +2,11 @@ package invalid.lena.rsend import android.content.Context import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.util.concurrent.TimeUnit // RsyncRunner builds and runs the rsync invocation for one folder, streaming // rsync's output into the log. It uses the bundled rsync and rsh binaries and @@ -52,12 +55,20 @@ object RsyncRunner { val pb = ProcessBuilder(cmd).redirectErrorStream(true) pb.environment().putAll(Keys.env(ctx, remote.port)) val p = pb.start() - val watchdog = launch { try { awaitCancellation() } finally { p.destroy() } } + val watchdog = launch(Dispatchers.IO) { try { awaitCancellation() } finally { terminate(p) } } try { p.inputStream.bufferedReader().forEachLine { log.line(it) } p.waitFor() } finally { - watchdog.cancel() + watchdog.cancelAndJoin() + } + } + + private fun terminate(p: Process) { + p.destroy() + if (!p.waitFor(5, TimeUnit.SECONDS)) { + p.destroyForcibly() + p.waitFor() } } } diff --git a/app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt b/app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt index b917bff..31ec6b4 100644 --- a/app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt @@ -39,13 +39,12 @@ class ScheduleActivity : AppCompatActivity() { val cfg = Config.load(this) val s = Schedule( enabled = enabled.isChecked, - intervalMinutes = interval.text.toString().toIntOrNull()?.coerceAtLeast(1) ?: 120, + intervalMinutes = interval.text.toString().toIntOrNull() + ?.coerceAtLeast(Schedule.MIN_INTERVAL_MINUTES) ?: 120, wifiOnly = wifiOnly.isChecked, requireCharging = charging.isChecked, ) Config.save(this, cfg.copy(schedule = s)) - // Config changed: force a re-arm so the new interval/constraints take - // effect now instead of after the current countdown. - Scheduler.apply(this, force = true) + Scheduler.apply(this) } } diff --git a/app/src/main/java/invalid/lena/rsend/Scheduler.kt b/app/src/main/java/invalid/lena/rsend/Scheduler.kt index af713e0..8f20498 100644 --- a/app/src/main/java/invalid/lena/rsend/Scheduler.kt +++ b/app/src/main/java/invalid/lena/rsend/Scheduler.kt @@ -2,55 +2,25 @@ package invalid.lena.rsend import android.content.Context import androidx.work.Constraints -import androidx.work.ExistingWorkPolicy +import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import java.util.concurrent.TimeUnit -// Scheduler runs the sync on a self-perpetuating chain of one-time jobs: each -// run re-arms the next from SyncWorker. This sidesteps WorkManager's 15-minute -// periodic floor (so the interval can be as low as 1 minute) and tends to be -// more reliable across OEMs. +// Scheduler keeps one periodic sync request aligned with the saved config. object Scheduler { - const val NAME = "periodic-sync" + // Versioned so it can never collide with the retired one-time chain's + // unique work name. + const val NAME = "periodic-sync-v2" - // Floor on the chained delay so a sync that runs longer than the interval - // cannot turn the chain into a hot loop. - private val MIN_DELAY_MS = TimeUnit.SECONDS.toMillis(15) - - // apply (re)arms or cancels the chain to match the schedule config. force - // decides what to do when a job is already pending under NAME: - // force=false (KEEP) leave a pending countdown running; only arm when - // nothing is pending. Used on app open and boot, so - // returning to the app does not reset the timer. - // force=true (REPLACE) cancel and re-arm now. Used when the schedule - // config changes. - fun apply(ctx: Context, force: Boolean = false) { - val cfg = Config.load(ctx) - val intervalMs = TimeUnit.MINUTES.toMillis(intervalMinutes(cfg)) - arm(ctx, cfg, force, intervalMs) - } - - // armAfterRun schedules the next run so the cadence is measured from when - // this run STARTED, not when it finished: delay = interval - runtime. That - // keeps "every N minutes" close to N minutes instead of N plus the sync - // duration. Always replaces, since the finishing run holds the unique name. - fun armAfterRun(ctx: Context, runMillis: Long) { + fun apply(ctx: Context) { val cfg = Config.load(ctx) - val intervalMs = TimeUnit.MINUTES.toMillis(intervalMinutes(cfg)) - arm(ctx, cfg, force = true, delayMs = (intervalMs - runMillis).coerceAtLeast(MIN_DELAY_MS)) - } - - private fun intervalMinutes(cfg: Config): Long = - cfg.schedule.intervalMinutes.toLong().coerceAtLeast(1) - - private fun arm(ctx: Context, cfg: Config, force: Boolean, delayMs: Long) { val s = cfg.schedule val wm = WorkManager.getInstance(ctx) - val ready = cfg.remote.host.isNotEmpty() && cfg.folders.isNotEmpty() && + val ready = cfg.remote.host.isNotEmpty() && cfg.remote.user.isNotEmpty() && cfg.folders.isNotEmpty() && Keys.exists(ctx) && Keys.pinned(ctx) if (!s.enabled || !ready) { wm.cancelUniqueWork(NAME) @@ -61,11 +31,12 @@ object Scheduler { .setRequiredNetworkType(if (s.wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) .setRequiresCharging(s.requireCharging) .build() - val req = OneTimeWorkRequestBuilder<SyncWorker>() - .setInitialDelay(delayMs, TimeUnit.MILLISECONDS) + val req = PeriodicWorkRequestBuilder<SyncWorker>( + s.intervalMinutes.toLong().coerceAtLeast(Schedule.MIN_INTERVAL_MINUTES.toLong()), + TimeUnit.MINUTES, + ) .setConstraints(constraints) .build() - val policy = if (force) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP - wm.enqueueUniqueWork(NAME, policy, req) + wm.enqueueUniquePeriodicWork(NAME, ExistingPeriodicWorkPolicy.UPDATE, req) } } diff --git a/app/src/main/java/invalid/lena/rsend/SyncWorker.kt b/app/src/main/java/invalid/lena/rsend/SyncWorker.kt index 1b14673..d884a6b 100644 --- a/app/src/main/java/invalid/lena/rsend/SyncWorker.kt +++ b/app/src/main/java/invalid/lena/rsend/SyncWorker.kt @@ -12,6 +12,8 @@ import androidx.work.WorkerParameters import androidx.work.workDataOf import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext // SyncWorker runs one full sync as a foreground job: it pushes every configured @@ -23,62 +25,62 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, const val KEY_FOLDER = "folder" const val KEY_INDEX = "i" const val KEY_TOTAL = "n" - } - override suspend fun doWork(): Result = withContext(Dispatchers.IO) { - val ctx = applicationContext - val startedAt = System.currentTimeMillis() - val cfg = Config.load(ctx) - if (cfg.remote.host.isEmpty() || cfg.folders.isEmpty() || !Keys.exists(ctx) || !Keys.pinned(ctx)) { - SyncLog(ctx).line("sync skipped: remote, folders, key, or pinned host missing") - return@withContext Result.success() - } + private val syncMutex = Mutex() + } - val log = SyncLog(ctx) - log.rotateIfBig() - log.line("sync started (${cfg.folders.size} folders)") - // 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. - try { - setForeground(foregroundInfo()) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - log.line("foreground unavailable, running in background") - } + override suspend fun doWork(): Result = syncMutex.withLock { + 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") + return@withContext Result.failure() + } - var ok = true - val total = cfg.folders.size - cfg.folders.forEachIndexed { i, f -> - 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 ===") - // Guard against a hand-edited config: an empty path would rsync /. - val code = if (f.local.isEmpty() || f.remote.isEmpty()) { - log.line("error: local or remote path not set") - 1 - } else try { - RsyncRunner.runFolder(ctx, cfg.remote, f, log) + val log = SyncLog(ctx) + log.rotateIfBig() + log.line("sync started (${cfg.folders.size} folders)") + // 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. + try { + setForeground(foregroundInfo()) } catch (e: CancellationException) { - log.line("cancelled") throw e } catch (e: Exception) { - log.line("error: ${e.message}") - 1 + log.line("foreground unavailable, running in background: ${e.javaClass.simpleName}: ${e.message}") + } + + var ok = true + val total = cfg.folders.size + cfg.folders.forEachIndexed { i, f -> + 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 ===") + // Guard against a hand-edited config: an empty path would rsync /. + val code = if (f.local.isEmpty() || f.remote.isEmpty()) { + log.line("error: local or remote path not set") + 1 + } else try { + RsyncRunner.runFolder(ctx, cfg.remote, f, log) + } catch (e: CancellationException) { + log.line("cancelled") + throw e + } catch (e: Exception) { + log.line("error: ${e.javaClass.simpleName}: ${e.message}") + 1 + } + log.line("exit=$code") + if (code != 0) ok = false } - log.line("exit=$code") - if (code != 0) ok = false + log.line("sync finished ok=$ok") + LastSync.set(ctx, ok) + if (!ok) notifyError(ctx) + if (ok) Result.success() else Result.failure() } - log.line("sync finished ok=$ok") - LastSync.set(ctx, ok) - if (!ok) notifyError(ctx) - // Re-arm the next scheduled run (the chain), measuring the interval from - // when this run started so the cadence stays close to the configured - // interval. Always succeed so a failed run does not break the chain. - Scheduler.armAfterRun(ctx, System.currentTimeMillis() - startedAt) - Result.success() } private fun notifyError(ctx: Context) { |