aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/src/main/AndroidManifest.xml9
-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
-rw-r--r--app/src/main/res/layout/activity_folder.xml2
-rw-r--r--app/src/main/res/layout/activity_schedule.xml4
-rw-r--r--app/src/test/java/invalid/lena/rsend/ConfigTest.kt6
15 files changed, 197 insertions, 156 deletions
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 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
- <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
@@ -49,14 +48,6 @@
android:name=".LogActivity"
android:exported="false" />
- <receiver
- android:name=".BootReceiver"
- android:exported="true">
- <intent-filter>
- <action android:name="android.intent.action.BOOT_COMPLETED" />
- </intent-filter>
- </receiver>
-
<!-- WorkManager runs the sync as a dataSync foreground service. -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
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) {
diff --git a/app/src/main/res/layout/activity_folder.xml b/app/src/main/res/layout/activity_folder.xml
index 1381529..9709312 100644
--- a/app/src/main/res/layout/activity_folder.xml
+++ b/app/src/main/res/layout/activity_folder.xml
@@ -85,7 +85,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:inputType="textUri"
- android:hint="/backup/phone/DCIM" />
+ android:hint="DCIM" />
<TextView
android:layout_width="match_parent"
diff --git a/app/src/main/res/layout/activity_schedule.xml b/app/src/main/res/layout/activity_schedule.xml
index 981d6a9..d06ef1b 100644
--- a/app/src/main/res/layout/activity_schedule.xml
+++ b/app/src/main/res/layout/activity_schedule.xml
@@ -36,7 +36,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/space_m"
android:textAppearance="@style/TextAppearance.Rsend.Caption"
- android:text="Interval (minutes, minimum 1)" />
+ android:text="Interval (minutes, minimum 15)" />
<EditText
android:id="@+id/interval"
@@ -53,7 +53,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/space_m"
android:textAppearance="@style/TextAppearance.Rsend.Body"
- android:text="WiFi only (unmetered networks)" />
+ android:text="Unmetered network only" />
<Switch
android:id="@+id/charging"
diff --git a/app/src/test/java/invalid/lena/rsend/ConfigTest.kt b/app/src/test/java/invalid/lena/rsend/ConfigTest.kt
index 51796ba..a63582d 100644
--- a/app/src/test/java/invalid/lena/rsend/ConfigTest.kt
+++ b/app/src/test/java/invalid/lena/rsend/ConfigTest.kt
@@ -23,4 +23,10 @@ class ConfigTest {
fun defaultsForEmptyObject() {
assertEquals(Config(), Config.fromJson(JSONObject("{}")))
}
+
+ @Test
+ fun scheduleUsesWorkManagerMinimum() {
+ val json = JSONObject("""{"schedule":{"intervalMinutes":1}}""")
+ assertEquals(Schedule.MIN_INTERVAL_MINUTES, Config.fromJson(json).schedule.intervalMinutes)
+ }
}