From 2593151ca2c67256442ad25b074504e3ecd3371f Mon Sep 17 00:00:00 2001 From: Lena Date: Wed, 1 Jul 2026 00:00:00 +0000 Subject: 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. --- app/src/main/AndroidManifest.xml | 9 -- .../main/java/invalid/lena/rsend/BootReceiver.kt | 15 ---- app/src/main/java/invalid/lena/rsend/Config.kt | 38 ++++++--- app/src/main/java/invalid/lena/rsend/KeyVault.kt | 4 +- app/src/main/java/invalid/lena/rsend/Keys.kt | 27 +++++- .../main/java/invalid/lena/rsend/MainActivity.kt | 28 ++++++- app/src/main/java/invalid/lena/rsend/Native.kt | 8 +- .../main/java/invalid/lena/rsend/RemoteActivity.kt | 37 +++++--- .../main/java/invalid/lena/rsend/RsyncRunner.kt | 15 +++- .../java/invalid/lena/rsend/ScheduleActivity.kt | 7 +- app/src/main/java/invalid/lena/rsend/Scheduler.kt | 55 +++--------- app/src/main/java/invalid/lena/rsend/SyncWorker.kt | 98 +++++++++++----------- app/src/main/res/layout/activity_folder.xml | 2 +- app/src/main/res/layout/activity_schedule.xml | 4 +- 14 files changed, 191 insertions(+), 156 deletions(-) delete mode 100644 app/src/main/java/invalid/lena/rsend/BootReceiver.kt (limited to 'app/src/main') diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e146f29..2062a2a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,7 +9,6 @@ - - - - - - - 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().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