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. --- README | 18 ++-- 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 +- app/src/test/java/invalid/lena/rsend/ConfigTest.kt | 6 ++ .../metadata/android/en-US/full_description.txt | 12 +-- 17 files changed, 212 insertions(+), 171 deletions(-) delete mode 100644 app/src/main/java/invalid/lena/rsend/BootReceiver.kt diff --git a/README b/README index 7067cac..a77e3df 100644 --- a/README +++ b/README @@ -3,10 +3,10 @@ rsend Push configured phone folders to a home SSH host using real rsync. rsend backs up folders such as DCIM, WhatsApp media, and call recordings to a -remote host over SSH. One-way push only, on WiFi by default, periodic or -manual. Small, boring, self-contained: real rsync and a pure-Go SSH transport, -both built from source and shipped inside the APK. No foreign prebuilt -binaries. +remote host over SSH. One-way push only, on an unmetered network by default, +periodic or manual. Small, boring, self-contained: real rsync and a pure-Go SSH +transport, both built from source and shipped inside the APK. No foreign +prebuilt binaries. What it does @@ -14,7 +14,7 @@ What it does - Pushes one or more local folders to remote paths over rsync-over-SSH. - Incremental: only changed files move; large videos resume (rsync --partial). - Per-folder deletion policy: additive backup (default) or mirror (--delete). -- Runs on unmetered WiFi, periodically (WorkManager) or on demand. +- Runs on an unmetered network, periodically (WorkManager) or on demand. - ed25519 key auth with strict, pinned host-key verification. @@ -55,7 +55,8 @@ Run 3. Set the remote host, user, and port; run Test connection and accept the pinned host-key fingerprint. 4. Add folders, choose each folder's deletion policy, set the schedule. -5. Tap Sync now, or wait for the periodic WiFi sync. +5. Tap Sync now, or wait for the periodic sync. Scheduled intervals have a + 15-minute minimum imposed by WorkManager. For scheduled backup to run reliably, tap Battery and allow rsend to ignore battery optimization. Phones with aggressive power management (Samsung, Xiaomi, @@ -114,9 +115,8 @@ ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on the server. Debug ----- -- The in-app log viewer tails the plain-text rsync log. -- Reproduce a sync from a shell using the same rsync invocation rsend logs at - the top of each run. +- The in-app log viewer displays the plain-text rsync log; tap Refresh to reload. +- The log records the rsync arguments and complete process output for each run. - rsh transport: RSH_KEY, RSH_KNOWN_HOSTS, and RSH_PORT select the key, known_hosts file, and port. Run rsh by hand to isolate SSH from rsync. 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