aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java/invalid
diff options
context:
space:
mode:
authorLena <lena@omega>2026-07-01 00:00:00 +0000
committerLena <lena@omega>2026-07-13 22:00:29 +0000
commit2593151ca2c67256442ad25b074504e3ecd3371f (patch)
tree15ff1b56d265be51a6dcc26505705ab9cc4587f4 /app/src/main/java/invalid
parenta6640c603b7a15d3531980116fdce15d1743c865 (diff)
downloadrsend-2593151ca2c67256442ad25b074504e3ecd3371f.tar.gz
app: harden sync execution and persisted state
Persist config, key blob, and host pin via AtomicFile so an interrupted write cannot corrupt state. Validate the port, clear the host pin when the remote changes, cap imported key size, time out native helpers, and escalate rsync termination to destroyForcibly. Serialize manual and scheduled syncs behind a mutex. Replace the chained one-time jobs with plain periodic work: it survives reboots without a boot receiver and cannot silently die like a broken chain. Costs the sub-15-minute interval, which photo backup does not need. Document the floor and that syncs run on any unmetered network, not only WiFi.
Diffstat (limited to 'app/src/main/java/invalid')
-rw-r--r--app/src/main/java/invalid/lena/rsend/BootReceiver.kt15
-rw-r--r--app/src/main/java/invalid/lena/rsend/Config.kt38
-rw-r--r--app/src/main/java/invalid/lena/rsend/KeyVault.kt4
-rw-r--r--app/src/main/java/invalid/lena/rsend/Keys.kt27
-rw-r--r--app/src/main/java/invalid/lena/rsend/MainActivity.kt28
-rw-r--r--app/src/main/java/invalid/lena/rsend/Native.kt8
-rw-r--r--app/src/main/java/invalid/lena/rsend/RemoteActivity.kt37
-rw-r--r--app/src/main/java/invalid/lena/rsend/RsyncRunner.kt15
-rw-r--r--app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt7
-rw-r--r--app/src/main/java/invalid/lena/rsend/Scheduler.kt55
-rw-r--r--app/src/main/java/invalid/lena/rsend/SyncWorker.kt98
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) {