diff options
Diffstat (limited to 'app/src/main/java')
17 files changed, 1478 insertions, 337 deletions
diff --git a/app/src/main/java/invalid/lena/rsend/App.kt b/app/src/main/java/invalid/lena/rsend/App.kt index 55ed66e..0251d7d 100644 --- a/app/src/main/java/invalid/lena/rsend/App.kt +++ b/app/src/main/java/invalid/lena/rsend/App.kt @@ -3,17 +3,18 @@ package invalid.lena.rsend import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager -import android.os.Build // App creates the notification channel used by the foreground sync service. class App : Application() { override fun onCreate() { super.onCreate() - if (Build.VERSION.SDK_INT >= 26) { - val ch = NotificationChannel(CHANNEL, "Sync", NotificationManager.IMPORTANCE_LOW) - getSystemService(NotificationManager::class.java).createNotificationChannel(ch) - } + val ch = NotificationChannel(CHANNEL, "Sync", NotificationManager.IMPORTANCE_LOW) + getSystemService(NotificationManager::class.java).createNotificationChannel(ch) + // Apply on every process start, including one launched by an old + // WorkManager request after an app upgrade. This retires stale unique + // names without waiting for the dashboard to be opened. + Scheduler.apply(this) } companion object { diff --git a/app/src/main/java/invalid/lena/rsend/Config.kt b/app/src/main/java/invalid/lena/rsend/Config.kt index 69f3869..25bf338 100644 --- a/app/src/main/java/invalid/lena/rsend/Config.kt +++ b/app/src/main/java/invalid/lena/rsend/Config.kt @@ -4,13 +4,16 @@ import android.content.Context import android.util.AtomicFile import org.json.JSONArray import org.json.JSONObject +import java.io.ByteArrayOutputStream import java.io.File import java.io.FileNotFoundException +import java.io.InputStream +import java.util.Base64 // Config is rsend's whole state: the named remote targets, the schedule, and // the folders to push. It is stored as plain JSON in app-private storage. -// config.json owns the host-key pins (Remote.hostKey); the known_hosts file -// rsh reads is regenerated from them before every sync. +// config.json owns the host-key pins (Remote.hostKey). The known_hosts file rsh +// reads is derived from the one remote used by the current connection. data class Remote( val name: String = "", @@ -22,6 +25,48 @@ data class Remote( fun pinned(): Boolean = hostKey.isNotEmpty() } +object RemoteRules { + private const val MAX_NAME_CHARS = 128 + private const val MAX_HOST_CHARS = 255 + private const val MAX_USER_CHARS = 128 + private const val MAX_HOST_KEY_CHARS = 16 * 1024 + private val user = Regex("[A-Za-z0-9._-]+") + private val hostKeyTypes = setOf( + "ssh-ed25519", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", + "ssh-rsa", + ) + + fun nameAllowed(value: String): Boolean = + value.isNotEmpty() && value.length <= MAX_NAME_CHARS && value.none { it.isISOControl() } + + fun userAllowed(value: String): Boolean = + value.length <= MAX_USER_CHARS && !value.startsWith('-') && user.matches(value) + + fun hostAllowed(value: String): Boolean = + value.isNotEmpty() && value.length <= MAX_HOST_CHARS && !value.startsWith('-') && value.all { + it in 'A'..'Z' || it in 'a'..'z' || it in '0'..'9' || + it == '.' || it == '-' || it == '_' || + it == ':' || it == '%' + } + + fun hostKeyAllowed(remote: Remote): Boolean { + if (remote.hostKey.isEmpty()) return true + if (remote.hostKey.length > MAX_HOST_KEY_CHARS) return false + val parts = remote.hostKey.split(' ') + if (parts.size != 3 || parts.any(String::isEmpty)) return false + val address = if (remote.port == 22) remote.host else "[${remote.host}]:${remote.port}" + if (parts[0] != address || parts[1] !in hostKeyTypes) return false + return try { + Base64.getDecoder().decode(parts[2]).isNotEmpty() + } catch (_: IllegalArgumentException) { + false + } + } +} + data class Schedule( val enabled: Boolean = false, val intervalMinutes: Int = 120, @@ -49,12 +94,13 @@ data class Config( ) { fun remote(name: String): Remote? = remotes.firstOrNull { it.name == name } - // syncReady reports whether a sync can do useful work: an identity key, - // at least one folder, and at least one complete, pinned remote. Folders - // pointing at a broken remote fail individually during the run. + // syncReady reports whether a run can do useful work: an identity key and + // at least one folder with a pinned target. Requiring every folder to be + // ready would let one half-configured folder, a remote added but not yet + // pinned, stop the backups that were already working. A folder whose + // remote is not pinned fails on its own and is named in the log. fun syncReady(keyExists: Boolean): Boolean = - keyExists && folders.isNotEmpty() && - remotes.any { it.host.isNotEmpty() && it.user.isNotEmpty() && it.pinned() } + keyExists && folders.any { remote(it.remoteName)?.pinned() == true } fun toJson(): JSONObject = JSONObject().apply { put("remotes", JSONArray().apply { @@ -89,48 +135,51 @@ data class Config( } companion object { + internal const val MAX_CONFIG_BYTES = 1024 * 1024 + private const val MAX_REMOTES = 64 + private const val MAX_FOLDERS = 256 fun file(ctx: Context): File = File(ctx.filesDir, "config.json") + private fun recoveryFile(ctx: Context): File = File(ctx.filesDir, "config-recovery.txt") + private fun legacyFile(ctx: Context): File = File(ctx.filesDir, "config.json.0.1") - // legacyShape reports whether o is a pre-0.2 config: no "remotes" array, - // but the single-remote "remote" object 0.1.x always wrote. - fun legacyShape(o: JSONObject): Boolean = - o.optJSONArray("remotes") == null && o.optJSONObject("remote") != null - + @Synchronized fun load(ctx: Context): Config { val f = file(ctx) - val migrated: Boolean - val c = try { - val text = AtomicFile(f).openRead().bufferedReader().use { it.readText() } - val o = JSONObject(text) - migrated = legacyShape(o) - // Only the legacy single-remote shape needs the old pin file. - var pin = "" - if (migrated) { - val kh = Keys.knownHosts(ctx) - if (kh.exists()) pin = kh.readText().trim() - } - fromJson(o, pin) + val text = try { + AtomicFile(f).openRead().use { readText(it, MAX_CONFIG_BYTES) } } catch (_: FileNotFoundException) { return Config() } catch (e: Exception) { - throw IllegalStateException("could not read ${f.name}: ${e.message}", e) + return recover(ctx, f, "read failed", e) + } + return try { + val o = JSONObject(text) + if (!legacyShape(o)) return fromJson(o) + // Retire the old shape on the first read rather than waiting + // for the user to edit something, and write it back so this + // path is never taken twice: toJson always emits "remotes", so + // legacyShape cannot match again. + val upgraded = upgradeLegacy(o, Keys.legacyPin(ctx)) + val migrated = fromJson(upgraded) + preserveLegacy(ctx, text, o, migrated) + save(ctx, migrated) + migrated + } catch (e: Exception) { + recover(ctx, f, "parse failed", e) } - // Retire the old shape on the first read rather than waiting for the - // user to edit something. Without this a working 0.1.x setup keeps - // its pre-0.2 config.json indefinitely, and deleting the fallback in - // a later release would silently strand it. Writing the new shape - // also makes the fallback unreachable from here on: toJson always - // emits "remotes", so legacyShape can never match again. - if (migrated) save(ctx, c) - return c } // AtomicFile keeps the previous complete config if a write is interrupted. + @Synchronized fun save(ctx: Context, c: Config) { + val json = c.toJson() + fromJson(json) + val bytes = json.toString(2).toByteArray(Charsets.UTF_8) + require(bytes.size <= MAX_CONFIG_BYTES) { "config exceeds $MAX_CONFIG_BYTES bytes" } val file = AtomicFile(file(ctx)) val out = file.startWrite() try { - out.write(c.toJson().toString(2).toByteArray()) + out.write(bytes) file.finishWrite(out) } catch (e: Exception) { file.failWrite(out) @@ -138,75 +187,288 @@ data class Config( } } - // fromJson also reads the pre-0.2 single-remote shape once: the old - // "remote" object becomes the sole entry, named after its host, every - // folder points at it (the folder's old "remote" field was the - // destination path), and legacyPin carries the known_hosts line over. - // Remove this fallback after a release or two. - fun fromJson(o: JSONObject, legacyPin: String = ""): Config { - val ra = o.optJSONArray("remotes") - val legacy = if (ra == null) o.optJSONObject("remote") else null - val legacyName = legacy?.optString("host") ?: "" + // legacyShape reports whether o is the pre-0.2 config 0.1.x wrote: one + // "remote" object instead of a "remotes" array. + fun legacyShape(o: JSONObject): Boolean = + !o.has("remotes") && o.optJSONObject("remote") != null - val remotes = ArrayList<Remote>() - if (ra != null) { - for (i in 0 until ra.length()) { - val ro = ra.getJSONObject(i) - remotes.add( - Remote( - name = ro.optString("name"), - host = ro.optString("host"), - port = ro.optInt("port", 22), - user = ro.optString("user"), - hostKey = ro.optString("hostKey"), - ) + // upgradeLegacy rewrites a 0.1.x config into the current shape so the + // strict parser validates it exactly like any other config. The single + // remote becomes the sole entry named after its host, every retained + // safe folder points at it, and the folder's old "remote" field was the + // destination path. pin is the known_hosts line 0.1.x kept in a separate + // file; it is carried over only if it still describes this host and port, so a + // pin that no longer matches costs one Test connection rather than the + // whole config. + // + // This is a deliberate, documented exception to the no-compatibility + // rule: 0.1.3 is the last published release, so it is what every + // upgrading user has, and the alternative is that all of them silently + // lose their remotes, pins and folders. Delete it in 0.4.0. + fun upgradeLegacy(o: JSONObject, pin: String): JSONObject { + val old = o.getJSONObject("remote") + val host = old.optString("host").trim().removeSurrounding("[", "]").trim() + val port = old.optInt("port", 22) + val user = old.optString("user") + val candidate = Remote( + name = host, + host = host, + port = port, + user = user, + hostKey = pin, + ) + val endpointAllowed = RemoteRules.nameAllowed(host) && + RemoteRules.hostAllowed(host) && RemoteRules.userAllowed(user) && port in 1..65535 + val remote = JSONObject() + .put("name", host) + .put("host", host) + .put("port", port) + .put("user", user) + .put("hostKey", "") + if (endpointAllowed && RemoteRules.hostKeyAllowed(candidate) && pin.isNotEmpty()) { + remote.put("hostKey", pin) + } + + val folders = JSONArray() + val accepted = ArrayList<Folder>() + val fa = o.optJSONArray("folders") ?: JSONArray() + for (i in 0 until fa.length()) { + if (!endpointAllowed || accepted.size >= MAX_FOLDERS) break + val f = fa.getJSONObject(i) + val oldExcludes = f.optJSONArray("excludes") ?: JSONArray() + if (oldExcludes.length() > FolderRules.MAX_EXCLUDES) continue + val excludes = JSONArray() + for (j in 0 until oldExcludes.length()) { + excludes.put(oldExcludes.getString(j)) + } + val folder = Folder( + name = f.optString("name"), + local = f.optString("local"), + remoteName = host, + remotePath = f.optString("remote"), + delete = f.optBoolean("delete", false), + excludes = (0 until excludes.length()).map(excludes::getString), + ) + val trial = Config( + remotes = if (endpointAllowed) listOf(candidate.copy(hostKey = "")) else emptyList(), + folders = accepted, + ) + if (FolderRules.nameAllowed(folder.name) && + FolderRules.localPathAllowed(folder.local) && + FolderRules.remotePathAllowed(folder.remotePath) && + FolderRules.excludesAllowed(folder.excludes) && + FolderRules.destinationConflict(trial, folder, -1) == null + ) { + accepted.add(folder) + folders.put( + JSONObject() + .put("name", folder.name) + .put("local", folder.local) + .put("remoteName", host) + .put("remotePath", folder.remotePath) + .put("delete", folder.delete) + .put("excludes", excludes), ) } - } else if (legacyName.isNotEmpty()) { + } + val oldSchedule = o.optJSONObject("schedule") ?: JSONObject() + val schedule = JSONObject() + .put("enabled", oldSchedule.optBoolean("enabled", false)) + .put( + "intervalMinutes", + oldSchedule.optInt("intervalMinutes", 120) + .coerceAtLeast(Schedule.MIN_INTERVAL_MINUTES), + ) + .put("wifiOnly", oldSchedule.optBoolean("wifiOnly", true)) + .put("requireCharging", oldSchedule.optBoolean("requireCharging", false)) + return JSONObject() + .put("remotes", if (endpointAllowed) JSONArray().put(remote) else JSONArray()) + .put("schedule", schedule) + .put("folders", folders) + } + + private fun preserveLegacy(ctx: Context, text: String, old: JSONObject, migrated: Config) { + atomicWrite(legacyFile(ctx), text.toByteArray(Charsets.UTF_8)) + val oldFolders = old.optJSONArray("folders")?.length() ?: 0 + val endpointOmitted = old.optJSONObject("remote")?.optString("host").orEmpty().isNotEmpty() && + migrated.remotes.isEmpty() + val omitted = oldFolders - migrated.folders.size + if (!endpointOmitted && omitted == 0) return + val what = buildList { + if (endpointOmitted) add("the invalid remote") + if (omitted > 0) add("$omitted unsafe folder mapping(s)") + }.joinToString(" and ") + val message = + "The 0.1.x configuration was upgraded with omissions: $what. " + + "The exact original was preserved as ${legacyFile(ctx).name}." + atomicWrite(recoveryFile(ctx), (message + "\n").toByteArray(Charsets.UTF_8)) + } + + fun fromJson(o: JSONObject): Config { + requireFields(o, setOf("remotes", "schedule", "folders")) + val ra = o.getJSONArray("remotes") + require(ra.length() <= MAX_REMOTES) { "too many remotes" } + val remotes = ArrayList<Remote>() + for (i in 0 until ra.length()) { + val ro = ra.getJSONObject(i) + requireFields(ro, setOf("name", "host", "port", "user", "hostKey")) remotes.add( Remote( - name = legacyName, - host = legacyName, - port = legacy!!.optInt("port", 22), - user = legacy.optString("user"), - hostKey = legacyPin, + name = string(ro, "name"), + host = string(ro, "host"), + port = integer(ro, "port"), + user = string(ro, "user"), + hostKey = string(ro, "hostKey"), ) ) } + require(remotes.map { it.name }.distinct().size == remotes.size) { "remote names must be unique" } + require(remotes.all { RemoteRules.nameAllowed(it.name) }) { "remote name is invalid" } + require(remotes.all { RemoteRules.hostAllowed(it.host) }) { "remote host is invalid" } + require(remotes.all { RemoteRules.userAllowed(it.user) }) { "remote user is invalid" } + require(remotes.all { it.port in 1..65535 }) { "remote port must be between 1 and 65535" } + require(remotes.all(RemoteRules::hostKeyAllowed)) { "remote host key is invalid" } - val s = o.optJSONObject("schedule") ?: JSONObject() - val fa = o.optJSONArray("folders") ?: JSONArray() + val s = o.getJSONObject("schedule") + requireFields(s, setOf("enabled", "intervalMinutes", "wifiOnly", "requireCharging")) + val interval = integer(s, "intervalMinutes") + require(interval >= Schedule.MIN_INTERVAL_MINUTES) { + "schedule interval must be at least ${Schedule.MIN_INTERVAL_MINUTES} minutes" + } + val fa = o.getJSONArray("folders") + require(fa.length() <= MAX_FOLDERS) { "too many folders" } val folders = ArrayList<Folder>(fa.length()) for (i in 0 until fa.length()) { val fo = fa.getJSONObject(i) - val ex = fo.optJSONArray("excludes") ?: JSONArray() + requireFields(fo, setOf("name", "local", "remoteName", "remotePath", "delete", "excludes")) + val ex = fo.getJSONArray("excludes") + require(ex.length() <= FolderRules.MAX_EXCLUDES) { "too many folder excludes" } val excludes = ArrayList<String>(ex.length()) - for (j in 0 until ex.length()) excludes.add(ex.getString(j)) + for (j in 0 until ex.length()) { + val value = ex.get(j) + require(value is String) { "folder exclude must be a string" } + excludes.add(value) + } folders.add( Folder( - name = fo.optString("name"), - local = fo.optString("local"), - // Gate on the legacy shape, not on the legacy host: a - // 0.1.x config with folders but no host yet still has - // real paths in the folders' old "remote" field. - remoteName = if (legacy != null) legacyName else fo.optString("remoteName"), - remotePath = if (legacy != null) fo.optString("remote") else fo.optString("remotePath"), - delete = fo.optBoolean("delete", false), + name = string(fo, "name"), + local = string(fo, "local"), + remoteName = string(fo, "remoteName"), + remotePath = string(fo, "remotePath"), + delete = bool(fo, "delete"), excludes = excludes, ) ) } - return Config( + require(folders.all { FolderRules.nameAllowed(it.name) }) { "folder name is invalid" } + require(folders.all { FolderRules.localPathAllowed(it.local) }) { "folder local path is invalid" } + require(folders.all { FolderRules.remotePathAllowed(it.remotePath) }) { "folder remote path is invalid" } + val remoteNames = remotes.map(Remote::name).toSet() + require(folders.all { it.remoteName in remoteNames }) { "folder refers to a missing remote" } + require(folders.all { f -> FolderRules.excludesAllowed(f.excludes) }) { "folder exclude is invalid" } + val config = Config( remotes = remotes, schedule = Schedule( - enabled = s.optBoolean("enabled", false), - intervalMinutes = s.optInt("intervalMinutes", 120) - .coerceAtLeast(Schedule.MIN_INTERVAL_MINUTES), - wifiOnly = s.optBoolean("wifiOnly", true), - requireCharging = s.optBoolean("requireCharging", false), + enabled = bool(s, "enabled"), + intervalMinutes = interval, + wifiOnly = bool(s, "wifiOnly"), + requireCharging = bool(s, "requireCharging"), ), folders = folders, ) + require(FolderRules.firstDestinationConflict(config) == null) { + "folder remote destinations conflict" + } + return config + } + + private fun requireFields(o: JSONObject, expected: Set<String>) { + val missing = expected.firstOrNull { !o.has(it) } + require(missing == null) { "missing config field: $missing" } + val unexpected = o.keys().asSequence().firstOrNull { it !in expected } + require(unexpected == null) { "unexpected config field: $unexpected" } + } + + private fun string(o: JSONObject, name: String): String { + val value = o.get(name) + require(value is String) { "$name must be a string" } + return value + } + + private fun integer(o: JSONObject, name: String): Int { + val value = o.get(name) + require(value is Int) { "$name must be an integer" } + return value + } + + private fun bool(o: JSONObject, name: String): Boolean { + val value = o.get(name) + require(value is Boolean) { "$name must be a boolean" } + return value + } + + fun recoveryMessage(ctx: Context): String { + val f = recoveryFile(ctx) + return try { + AtomicFile(f).openRead().use { readText(it, 4096) } + } catch (_: FileNotFoundException) { + "" + } + } + + fun clearRecoveryMessage(ctx: Context) { + AtomicFile(recoveryFile(ctx)).delete() + } + + private fun recover(ctx: Context, source: File, what: String, error: Exception): Config { + val broken = quarantine(source) + val detail = (error.message ?: error.javaClass.simpleName) + .replace('\n', ' ') + .replace('\r', ' ') + .take(300) + val message = + "Configuration $what and was reset. The original file was preserved as ${broken.name}. " + + "Reason: $detail" + atomicWrite(recoveryFile(ctx), (message + "\n").toByteArray()) + return Config() + } + + internal fun quarantine(source: File): File { + var n = 0 + var target: File + do { + val suffix = if (n == 0) ".broken" else ".broken.$n" + target = File(source.parentFile, source.name + suffix) + n++ + } while (target.exists()) + if (!source.renameTo(target)) { + throw IllegalStateException("could not preserve unreadable ${source.name}") + } + return target + } + + internal fun readText(input: InputStream, limit: Int): String { + val out = ByteArrayOutputStream(minOf(limit, 8192)) + val buf = ByteArray(4096) + while (true) { + val n = input.read(buf) + if (n < 0) break + if (out.size() + n > limit) throw IllegalArgumentException("file exceeds $limit bytes") + out.write(buf, 0, n) + } + return String(out.toByteArray(), Charsets.UTF_8) + } + + 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 + } } } } diff --git a/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt b/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt index 14e04c9..5565bff 100644 --- a/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt @@ -8,10 +8,11 @@ import android.widget.ArrayAdapter import android.widget.Button import android.widget.EditText import android.widget.Spinner -import android.widget.Switch import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SwitchCompat import java.io.File // FolderEditActivity adds or edits one folder mapping. index < 0 means a new @@ -24,10 +25,10 @@ class FolderEditActivity : AppCompatActivity() { private lateinit var remoteName: Spinner private lateinit var remotePath: EditText private lateinit var excludes: EditText - private lateinit var delete: Switch + private lateinit var delete: SwitchCompat + private var mirrorConfirmed = false - // remoteNames[i] is the real name behind spinner position i; the label - // differs only for a reference to a remote that no longer exists. + // remoteNames[i] is the real name behind spinner position i. private var remoteNames: List<String> = emptyList() // The picker returns a real filesystem path; drop it into the local field and @@ -63,18 +64,10 @@ class FolderEditActivity : AppCompatActivity() { excludes.setText(f.excludes.joinToString(", ")) delete.isChecked = f.delete - // A reference to a missing remote (hand-edited config) stays selectable - // as "name (missing)" so Save cannot silently repoint the folder, which - // with mirror on would --delete into the wrong host. val names = cfg.remotes.map { it.name }.toMutableList() - val labels = names.toMutableList() - if (f.remoteName.isNotEmpty() && f.remoteName !in names) { - names.add(0, f.remoteName) - labels.add(0, "${f.remoteName} (missing)") - } remoteNames = names remoteName.adapter = - ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, labels) + ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, names) val sel = names.indexOf(f.remoteName) if (sel >= 0) remoteName.setSelection(sel) @@ -105,16 +98,67 @@ class FolderEditActivity : AppCompatActivity() { delete = delete.isChecked, excludes = excludes.text.toString().split(",").map { it.trim() }.filter { it.isNotEmpty() }, ) - // An empty path would rsync / (local) or the rrsync root (remote): - // refuse rather than sync the world. + // Neither filesystem root is a reasonable backup boundary. Require a + // local path below root and a named remote destination tree. if (f.local.isEmpty() || f.remotePath.isEmpty()) { Toast.makeText(this, "Set both local and remote paths first.", Toast.LENGTH_LONG).show() return false } - if (!File(f.local).exists()) { + if (!FolderRules.nameAllowed(f.name) || !FolderRules.remotePathAllowed(f.remotePath) || + !FolderRules.excludesAllowed(f.excludes) + ) { + Toast.makeText( + this, + "Names, remote paths, and excludes cannot contain control characters; " + + "use a named remote directory without :, ., .., or a bare ~.", + Toast.LENGTH_LONG, + ).show() + return false + } + if (!FolderRules.localPathAllowed(f.local)) { + Toast.makeText(this, "Local path must be an absolute path below the filesystem root.", Toast.LENGTH_LONG) + .show() + return false + } + val localFile = File(f.local) + if (localFile.exists() && !localFile.isDirectory) { + Toast.makeText(this, "Local path is not a directory.", Toast.LENGTH_LONG).show() + return false + } + if (localFile.exists() && !FolderRules.sourceReadable(f.local)) { + Toast.makeText(this, "Local directory cannot be read.", Toast.LENGTH_LONG).show() + return false + } + if (!localFile.exists()) { Toast.makeText(this, "Warning: local path does not exist yet.", Toast.LENGTH_LONG).show() } val cfg = Config.load(this) + val conflict = FolderRules.destinationConflict(cfg, f, index) + if (conflict != null) { + val label = conflict.name.ifEmpty { conflict.remotePath } + Toast.makeText( + this, + "Remote destination conflicts with folder \"$label\". Use separate trees and one path style per remote.", + Toast.LENGTH_LONG, + ).show() + return false + } + val oldMirror = cfg.folders.getOrNull(index)?.delete == true + if (f.delete && !oldMirror && !mirrorConfirmed) { + AlertDialog.Builder(this) + .setTitle("Enable exact mirror?") + .setMessage( + "Mirror follows rsync exactly: files absent locally are deleted remotely. " + + "An empty local folder empties the remote destination." + ) + .setPositiveButton("Enable mirror") { _, _ -> + mirrorConfirmed = true + if (saveFolder()) finish() + } + .setNegativeButton("Cancel", null) + .show() + return false + } val list = cfg.folders.toMutableList() if (index in list.indices) list[index] = f else list.add(f) Config.save(this, cfg.copy(folders = list)) diff --git a/app/src/main/java/invalid/lena/rsend/FolderPickerActivity.kt b/app/src/main/java/invalid/lena/rsend/FolderPickerActivity.kt index 097be3b..0e403e0 100644 --- a/app/src/main/java/invalid/lena/rsend/FolderPickerActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/FolderPickerActivity.kt @@ -9,6 +9,7 @@ import android.widget.TextView import androidx.activity.addCallback import androidx.appcompat.app.AppCompatActivity import java.io.File +import java.nio.file.Files // FolderPickerActivity browses the real filesystem and returns one directory's // absolute path in the "path" result extra. The app holds all-files access, so @@ -17,8 +18,13 @@ import java.io.File // above it; SD cards and other volumes are still reachable by typing the path. class FolderPickerActivity : AppCompatActivity() { + companion object { + private const val MAX_VISIBLE_DIRECTORIES = 500 + } + private lateinit var pathView: TextView private lateinit var list: android.widget.LinearLayout + private lateinit var use: Button private lateinit var root: File private lateinit var current: File @@ -31,15 +37,16 @@ class FolderPickerActivity : AppCompatActivity() { pathView = findViewById(R.id.currentPath) list = findViewById(R.id.list) - root = getSystemService(StorageManager::class.java).primaryStorageVolume.directory - ?: Environment.getExternalStorageDirectory() + root = (getSystemService(StorageManager::class.java).primaryStorageVolume.directory + ?: Environment.getExternalStorageDirectory()).canonicalFile // Start where the field already points, if that is a directory under the // root; otherwise at the root itself. - val start = intent.getStringExtra("start")?.let { File(it) } - current = if (start != null && start.isDirectory && underRoot(start)) start else root + val start = intent.getStringExtra("start")?.let { safeDirectory(File(it)) } + current = start ?: root - findViewById<Button>(R.id.use).setOnClickListener { + use = findViewById(R.id.use) + use.setOnClickListener { setResult(RESULT_OK, Intent().putExtra("path", current.absolutePath)) finish() } @@ -67,23 +74,58 @@ class FolderPickerActivity : AppCompatActivity() { addRow("..", current.parentFile!!) } - val dirs = current.listFiles { f -> f.isDirectory } - ?.sortedBy { it.name.lowercase() } ?: emptyList() + // null means the directory could not be read at all, which is a + // different thing from having no subfolders and must not read as an + // invitation to pick it. + val listed = try { + Files.newDirectoryStream(current.toPath()) { Files.isDirectory(it) }.use { stream -> + val found = stream.asSequence().map { it.toFile() }.mapNotNull(::safeDirectory) + .distinctBy { it.absolutePath }.take(MAX_VISIBLE_DIRECTORIES + 1).toList() + Listing( + found.take(MAX_VISIBLE_DIRECTORIES).sortedBy { it.name.lowercase() }, + found.size > MAX_VISIBLE_DIRECTORIES, + ) + } + } catch (_: Exception) { + null + } + use.isEnabled = listed != null + val dirs = listed?.directories ?: emptyList() for (d in dirs) addRow(d.name, d) - if (dirs.isEmpty()) { - list.addView(TextView(this).apply { - text = "No subfolders here. Tap \"Use this folder\" to pick it." - setTextAppearance(R.style.TextAppearance_Rsend_Caption) - }) + if (listed?.truncated == true) { + list.addView(message("More folders exist. Type a deeper path to reach one not shown.")) + } else if (dirs.isEmpty()) { + list.addView( + message( + if (listed == null) { + "Cannot read this folder. Check all-files access on the main screen." + } else { + "No subfolders here. Tap \"Use this folder\" to pick it." + } + ) + ) } } + private data class Listing(val directories: List<File>, val truncated: Boolean) + + private fun message(value: String): TextView = TextView(this).apply { + text = value + setTextAppearance(R.style.TextAppearance_Rsend_Caption) + } + // Anchored at a path separator so /storage/emulated/0-evil does not pass // for a root of /storage/emulated/0. private fun underRoot(f: File): Boolean = f.absolutePath == root.absolutePath || f.absolutePath.startsWith(root.absolutePath + "/") + private fun safeDirectory(file: File): File? = try { + file.canonicalFile.takeIf { it.isDirectory && underRoot(it) } + } catch (_: Exception) { + null + } + private fun addRow(label: String, dir: File) { val row = layoutInflater.inflate(R.layout.item_dir, list, false) row.findViewById<TextView>(R.id.dirName).text = label diff --git a/app/src/main/java/invalid/lena/rsend/FolderRules.kt b/app/src/main/java/invalid/lena/rsend/FolderRules.kt new file mode 100644 index 0000000..1aa07c9 --- /dev/null +++ b/app/src/main/java/invalid/lena/rsend/FolderRules.kt @@ -0,0 +1,108 @@ +package invalid.lena.rsend + +import java.io.File +import java.nio.file.Files + +// FolderRules holds the small set of path checks shared by the editor and the +// worker. Remote paths are compared lexically because their filesystem lives +// on another machine and cannot be resolved safely on the phone. +object FolderRules { + private const val MAX_NAME_CHARS = 256 + private const val MAX_PATH_CHARS = 4096 + private const val MAX_EXCLUDE_CHARS = 512 + internal const val MAX_EXCLUDES = 64 + + fun nameAllowed(value: String): Boolean = + value.length <= MAX_NAME_CHARS && value.none(Char::isISOControl) + + fun localPathAllowed(path: String): Boolean { + if (path.isEmpty() || path.length > MAX_PATH_CHARS || path.any(Char::isISOControl)) return false + val file = File(path) + if (!file.isAbsolute) return false + return try { + file.toPath().normalize() != File(File.separator).toPath() + } catch (_: Exception) { + false + } + } + + // Empty directories are readable sources. Opening a DirectoryStream checks + // access without allocating one String for every entry in a large media + // directory. The source itself must not be a symlink: rsync follows a + // command-line symlink when the source has a trailing slash. + fun sourceReadable(path: String): Boolean { + if (!localPathAllowed(path)) return false + val file = File(path) + return try { + if (!file.isDirectory || Files.isSymbolicLink(file.toPath())) return false + Files.newDirectoryStream(file.toPath()).use { true } + } catch (_: Exception) { + false + } + } + + fun remotePathAllowed(path: String): Boolean { + if (path.isEmpty() || path.length > MAX_PATH_CHARS || path.startsWith(':') || + path.any(Char::isISOControl) + ) return false + if (path.startsWith('~') && !path.startsWith("~/")) return false + if (path.split('/').any { it == "." || it == ".." }) return false + return remotePath(path).parts.isNotEmpty() + } + + fun excludeAllowed(pattern: String): Boolean = + pattern.isNotEmpty() && pattern.length <= MAX_EXCLUDE_CHARS && pattern.none(Char::isISOControl) + + fun excludesAllowed(patterns: List<String>): Boolean = + patterns.size <= MAX_EXCLUDES && patterns.all(::excludeAllowed) + + fun destinationConflict(cfg: Config, candidate: Folder, exceptIndex: Int): Folder? { + val candidateRemote = cfg.remote(candidate.remoteName) + return cfg.folders.withIndex().firstOrNull { (i, existing) -> + i != exceptIndex && + sameEndpoint(candidate.remoteName, candidateRemote, existing.remoteName, cfg.remote(existing.remoteName)) && + remotePathsConflict(candidate.remotePath, existing.remotePath) + }?.value + } + + fun firstDestinationConflict(cfg: Config): Pair<Folder, Folder>? { + for ((i, folder) in cfg.folders.withIndex()) { + val other = destinationConflict(cfg, folder, i) + if (other != null) return folder to other + } + return null + } + + // Absolute and home-relative destinations can resolve to the same tree, + // but the phone cannot prove how a remote account's home is laid out. Keep + // one style per endpoint and compare conservatively on case too. + internal fun remotePathsConflict(a: String, b: String): Boolean { + val left = remotePath(a) + val right = remotePath(b) + if (left.absolute != right.absolute) return true + val shorter = minOf(left.parts.size, right.parts.size) + return (0 until shorter).all { left.parts[it].equals(right.parts[it], ignoreCase = true) } + } + + private fun sameEndpoint(aName: String, a: Remote?, bName: String, b: Remote?): Boolean { + if (aName == bName) return true + if (a == null || b == null) return false + return a.user == b.user && a.port == b.port && a.host.equals(b.host, ignoreCase = true) + } + + private data class RemotePath(val absolute: Boolean, val parts: List<String>) + + private fun remotePath(path: String): RemotePath { + val absolute = path.startsWith("/") + var value = path + if (!absolute) { + value = when { + value == "~" -> "" + value.startsWith("~/") -> value.substring(2) + else -> value + } + } + val parts = value.split('/').filter(String::isNotEmpty) + return RemotePath(absolute, parts) + } +} diff --git a/app/src/main/java/invalid/lena/rsend/KeyVault.kt b/app/src/main/java/invalid/lena/rsend/KeyVault.kt index ecbc9cf..e0fe66f 100644 --- a/app/src/main/java/invalid/lena/rsend/KeyVault.kt +++ b/app/src/main/java/invalid/lena/rsend/KeyVault.kt @@ -18,9 +18,12 @@ object KeyVault { private const val IV_LEN = 12 private const val TAG_BITS = 128 - private fun secret(): SecretKey { + private fun existing(): SecretKey? { val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } + return (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey + } + + private fun create(): SecretKey { val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") gen.init( KeyGenParameterSpec.Builder( @@ -36,13 +39,21 @@ object KeyVault { fun encrypt(plain: ByteArray): ByteArray { val c = Cipher.getInstance(TRANSFORM) - c.init(Cipher.ENCRYPT_MODE, secret()) + c.init(Cipher.ENCRYPT_MODE, existing() ?: create()) return c.iv + c.doFinal(plain) } + // Decrypting must never mint a key: doing so would replace the one that + // wrapped the stored blob, making it undecryptable forever while looking + // like an ordinary cipher failure. Say what happened instead, so the user + // is told to import or generate the identity key again. fun decrypt(blob: ByteArray): ByteArray { + require(blob.size >= IV_LEN + TAG_BITS / 8) { "encrypted private key is truncated" } + val key = existing() ?: throw IllegalStateException( + "the device keystore entry for rsend is gone; generate or import the key again" + ) val c = Cipher.getInstance(TRANSFORM) - c.init(Cipher.DECRYPT_MODE, secret(), GCMParameterSpec(TAG_BITS, blob, 0, IV_LEN)) + c.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, blob, 0, IV_LEN)) return c.doFinal(blob, IV_LEN, blob.size - IV_LEN) } } diff --git a/app/src/main/java/invalid/lena/rsend/Keys.kt b/app/src/main/java/invalid/lena/rsend/Keys.kt index 5ba6d67..bd18080 100644 --- a/app/src/main/java/invalid/lena/rsend/Keys.kt +++ b/app/src/main/java/invalid/lena/rsend/Keys.kt @@ -2,7 +2,9 @@ package invalid.lena.rsend import android.content.Context import android.util.AtomicFile +import java.io.ByteArrayOutputStream import java.io.File +import java.io.FileNotFoundException // Keys manages the on-device ed25519 identity and the pinned host keys. The // private key is stored Keystore-encrypted (KeyVault) and only ever exists in @@ -10,21 +12,29 @@ import java.io.File 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") + private fun knownHosts(ctx: Context): File = File(ctx.filesDir, "known_hosts") const val MAX_KEY_BYTES = 64 * 1024 + private const val MAX_ENCRYPTED_KEY_BYTES = MAX_KEY_BYTES + 64 + private val keyLock = Any() - fun knownHosts(ctx: Context): File = File(ctx.filesDir, "known_hosts") - - fun exists(ctx: Context): Boolean = keyEnc(ctx).exists() + fun exists(ctx: Context): Boolean = synchronized(keyLock) { + try { + AtomicFile(keyEnc(ctx)).openRead().use { } + true + } catch (_: FileNotFoundException) { + false + } + } // generate creates a key pair via rsh -keygen (the plaintext key never // touches disk), stores it encrypted, and returns the public key in // authorized_keys format. Storage and validation reuse importKey. - fun generate(ctx: Context): String { - val r = Native.run(Native.rsh(ctx), listOf("-keygen")) - if (r.code != 0) throw IllegalStateException(r.output.trim().ifEmpty { "keygen failed" }) - importKey(ctx, r.output.toByteArray()) - return publicKeyText(ctx) + fun generate(ctx: Context): String = synchronized(keyLock) { + val result = Native.run(Native.rsh(ctx), listOf("-keygen")) + if (result.code != 0) { + throw IllegalStateException(result.output.trim().ifEmpty { "keygen failed" }) + } + validateAndStore(ctx, result.output.toByteArray(Charsets.UTF_8)) } // importKey stores a user-supplied private key, replacing any current one. @@ -32,47 +42,106 @@ object Keys { // same parser as the transport), so a key that imports here will also sync. // 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) { + fun importKey(ctx: Context, pem: ByteArray): String = synchronized(keyLock) { + validateAndStore(ctx, pem) + } + + private fun validateAndStore(ctx: Context, pem: ByteArray): String { 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))) + val r = Native.run( + Native.rsh(ctx), + listOf("-pubkey"), + mapOf("RSH_KEY_DATA" to String(pem, Charsets.UTF_8)), + ) if (r.code != 0) throw IllegalArgumentException(r.output.trim().ifEmpty { "invalid private key" }) atomicWrite(keyEnc(ctx), KeyVault.encrypt(pem)) - atomicWrite(publicKey(ctx), (r.output.trim() + "\n").toByteArray()) + return r.output.trim() } - fun publicKeyText(ctx: Context): String = - if (publicKey(ctx).exists()) publicKey(ctx).readText().trim() else "" + fun publicKeyText(ctx: Context): String = synchronized(keyLock) { + if (!exists(ctx)) return "" + val r = Native.run(Native.rsh(ctx), listOf("-pubkey"), keyEnvironment(ctx)) + if (r.code != 0) throw IllegalStateException(r.output.trim().ifEmpty { "could not read public key" }) + r.output.trim() + } - // env returns the RSH_* environment rsh needs. The decrypted key is passed - // by value so it never touches the filesystem. - fun env(ctx: Context, port: Int): Map<String, String> = mapOf( - "RSH_KEY_DATA" to String(KeyVault.decrypt(keyEnc(ctx).readBytes())), - "RSH_KNOWN_HOSTS" to knownHosts(ctx).absolutePath, - "RSH_PORT" to port.toString(), - ) + // env derives a one-pin known_hosts file for this connection. A shared file + // containing every remote's key would let one configured alias authorize a + // different alias of the same endpoint. + fun env(ctx: Context, remote: Remote): Map<String, String> = synchronized(keyLock) { + require(RemoteRules.hostKeyAllowed(remote)) { "remote pin is invalid" } + require(remote.pinned()) { "remote is not pinned" } + val hosts = knownHosts(ctx) + atomicWrite(hosts, (remote.hostKey.trim() + "\n").toByteArray(Charsets.UTF_8)) + keyEnvironment(ctx) + mapOf( + "RSH_KNOWN_HOSTS" to hosts.absolutePath, + "RSH_PORT" to remote.port.toString(), + ) + } + + // legacyPin returns the known_hosts line 0.1.x kept beside the config, back + // when one pin was all there was. Config reads it once while migrating a + // 0.1.x config; nothing else should, because this path is now a scratch + // file rewritten per connection. Delete with that migration in 0.4.0. + fun legacyPin(ctx: Context): String { + val f = knownHosts(ctx) + if (!f.exists() || f.length() > MAX_LEGACY_PIN_BYTES) return "" + return try { + f.readText(Charsets.UTF_8).lineSequence().firstOrNull { it.isNotBlank() }?.trim().orEmpty() + } catch (_: Exception) { + "" + } + } + + private const val MAX_LEGACY_PIN_BYTES = 64L * 1024 data class Scan(val ok: Boolean, val fingerprint: String, val line: String, val error: String) // scan connects (proving the key is installed), captures the host key, and // returns its fingerprint and the known_hosts line to pin. pin() writes it // once the user accepts. - fun scan(ctx: Context, remote: Remote): Scan { + fun scan(ctx: Context, remote: Remote): Scan = synchronized(keyLock) { + // Point the scan at this remote's own pin, so rsh offers the key type + // already pinned and an unchanged server reproduces its stored line. A + // config pinned by an older rsend holds whichever key that version + // negotiated, and re-scanning without this would return a different key + // type and look like a host-key change on an untouched server. + val scanHosts = File(ctx.cacheDir, "scan_known_hosts") + atomicWrite( + scanHosts, + if (remote.pinned()) (remote.hostKey + "\n").toByteArray(Charsets.UTF_8) else ByteArray(0), + ) val r = Native.run( Native.rsh(ctx), listOf("-scan", "${remote.user}@${remote.host}"), - env(ctx, remote.port), + keyEnvironment(ctx) + mapOf( + "RSH_KNOWN_HOSTS" to scanHosts.absolutePath, + "RSH_PORT" to remote.port.toString(), + ), ) - if (r.code != 0) return Scan(false, "", "", r.output.trim()) + if (r.code != 0) return@synchronized Scan(false, "", "", r.output.trim()) val lines = r.output.trim().lines() - if (lines.size < 2) return Scan(false, "", "", "unexpected scan output:\n${r.output}") - return Scan(true, lines[0], lines[1], "") + if (lines.size < 2) { + return@synchronized Scan(false, "", "", "unexpected scan output:\n${r.output}") + } + Scan(true, lines[0], lines[1], "") } - // writeKnownHosts regenerates the known_hosts file rsh verifies against. - // The pins live in config.json (Remote.hostKey); this file is derived - // state, rewritten from all pinned remotes before every sync. - fun writeKnownHosts(ctx: Context, lines: List<String>) { - atomicWrite(knownHosts(ctx), lines.joinToString("") { it + "\n" }.toByteArray()) + private fun keyEnvironment(ctx: Context): Map<String, String> = + mapOf("RSH_KEY_DATA" to String(KeyVault.decrypt(encryptedKey(ctx)), Charsets.UTF_8)) + + private fun encryptedKey(ctx: Context): ByteArray { + val out = ByteArrayOutputStream() + AtomicFile(keyEnc(ctx)).openRead().use { input -> + val buf = ByteArray(4096) + while (true) { + val n = input.read(buf) + if (n < 0) break + require(out.size() + n <= MAX_ENCRYPTED_KEY_BYTES) { "encrypted private key is too large" } + out.write(buf, 0, n) + } + } + return out.toByteArray() } // AtomicFile keeps the previous complete file if a write is interrupted. diff --git a/app/src/main/java/invalid/lena/rsend/LastSync.kt b/app/src/main/java/invalid/lena/rsend/LastSync.kt index 3f1d518..9179b19 100644 --- a/app/src/main/java/invalid/lena/rsend/LastSync.kt +++ b/app/src/main/java/invalid/lena/rsend/LastSync.kt @@ -1,21 +1,48 @@ package invalid.lena.rsend import android.content.Context +import android.util.AtomicFile import java.io.File +import java.io.FileNotFoundException import java.text.SimpleDateFormat import java.util.Date import java.util.Locale // LastSync records the outcome of the most recent run so the dashboard can show -// it without opening the log. +// it without opening the log. This small status is independent of the full log. object LastSync { + private const val MAX_STATUS_CHARS = 4_000 + private const val MAX_FILE_BYTES = 20 * 1024 + private fun file(ctx: Context): File = File(ctx.filesDir, "last-sync") - private val fmt = SimpleDateFormat("MM-dd HH:mm", Locale.US) - fun set(ctx: Context, ok: Boolean) { - file(ctx).writeText("${fmt.format(Date())} ${if (ok) "ok" else "FAILED"}") + @Synchronized + fun set(ctx: Context, status: String) { + val clean = buildString(minOf(status.length, MAX_STATUS_CHARS)) { + for (c in status) { + append(if (c.isISOControl()) ' ' else c) + if (length == MAX_STATUS_CHARS) break + } + } + val time = SimpleDateFormat("MM-dd HH:mm", Locale.US).format(Date()) + val file = AtomicFile(file(ctx)) + val out = file.startWrite() + try { + out.write("$time $clean".toByteArray(Charsets.UTF_8)) + file.finishWrite(out) + } catch (e: Exception) { + file.failWrite(out) + throw e + } } - fun get(ctx: Context): String = if (file(ctx).exists()) file(ctx).readText() else "never" + @Synchronized + fun get(ctx: Context): String = try { + AtomicFile(file(ctx)).openRead().use { Config.readText(it, MAX_FILE_BYTES) } + } catch (_: FileNotFoundException) { + "never" + } catch (e: Exception) { + "unavailable (${e.javaClass.simpleName})" + } } diff --git a/app/src/main/java/invalid/lena/rsend/LogActivity.kt b/app/src/main/java/invalid/lena/rsend/LogActivity.kt index c9cce94..13ad766 100644 --- a/app/src/main/java/invalid/lena/rsend/LogActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/LogActivity.kt @@ -4,11 +4,13 @@ import android.os.Bundle import android.widget.Button import android.widget.TextView import androidx.appcompat.app.AppCompatActivity +import kotlin.concurrent.thread // LogActivity shows the plain-text sync log with refresh and clear. class LogActivity : AppCompatActivity() { private lateinit var log: TextView + private var generation = 0 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -18,10 +20,7 @@ class LogActivity : AppCompatActivity() { fitSystemBars() log = findViewById(R.id.log) findViewById<Button>(R.id.refresh).setOnClickListener { load() } - findViewById<Button>(R.id.clear).setOnClickListener { - SyncLog(this).file.writeText("") - load() - } + findViewById<Button>(R.id.clear).setOnClickListener { load(clear = true) } } override fun onResume() { @@ -29,7 +28,22 @@ class LogActivity : AppCompatActivity() { load() } - private fun load() { - log.text = SyncLog(this).text().ifEmpty { "No log yet." } + // File I/O stays off the main thread. SyncLog caps displayed text at 64 KB + // so the unavoidable TextView assignment and layout remain bounded. + private fun load(clear: Boolean = false) { + val current = ++generation + thread { + val result = try { + val syncLog = SyncLog(this) + if (clear) syncLog.clear() + Result.success(syncLog.text().ifEmpty { "No log yet." }) + } catch (e: Exception) { + Result.failure(e) + } + runOnUiThread { + if (isFinishing || isDestroyed || current != generation) return@runOnUiThread + log.text = result.getOrElse { "Could not read log: ${it.message}" } + } + } } } diff --git a/app/src/main/java/invalid/lena/rsend/MainActivity.kt b/app/src/main/java/invalid/lena/rsend/MainActivity.kt index 2f300c9..875afdf 100644 --- a/app/src/main/java/invalid/lena/rsend/MainActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/MainActivity.kt @@ -1,10 +1,12 @@ package invalid.lena.rsend import android.Manifest +import android.content.ActivityNotFoundException import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent +import android.net.ConnectivityManager import android.content.pm.PackageManager import android.net.Uri import android.os.Build @@ -27,7 +29,6 @@ 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 // MainActivity is the dashboard: a status hero, a primary Sync action, grouped @@ -35,6 +36,10 @@ import kotlin.concurrent.thread // happens in RemoteActivity, ScheduleActivity, and FolderEditActivity. class MainActivity : AppCompatActivity() { + companion object { + private const val MANUAL_WORK_NAME = "sync-now" + } + private lateinit var lastSync: TextView private lateinit var keyValue: TextView private lateinit var scheduleValue: TextView @@ -50,6 +55,13 @@ class MainActivity : AppCompatActivity() { private lateinit var syncBar: ProgressBar private lateinit var syncStatus: TextView + // Last drawn schedule and the observed state of the periodic job. Observing + // it keeps the dashboard off the 500ms blocking WorkManager query that used + // to run on the main thread for every refresh. + private var schedule: Schedule = Schedule() + private var jobState: String = "none" + private var recoveryShown = false + private val notifPerm = registerForActivityResult(ActivityResultContracts.RequestPermission()) { refresh() } @@ -82,16 +94,23 @@ class MainActivity : AppCompatActivity() { btnSync.setOnClickListener { syncNow() } observeSync() + WorkManager.getInstance(this) + .getWorkInfosForUniqueWorkLiveData(Scheduler.NAME) + .observe(this) { infos -> + jobState = activeWork(infos)?.state?.name ?: "none" + renderSchedule(schedule) + } findViewById<LinearLayout>(R.id.rowKey).setOnClickListener { showKey() } findViewById<LinearLayout>(R.id.rowSchedule).setOnClickListener { startActivity(Intent(this, ScheduleActivity::class.java)) } findViewById<LinearLayout>(R.id.rowAllFiles).setOnClickListener { - startActivity( + openSettings( Intent( Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:$packageName"), - ) + ), + Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION), ) } findViewById<LinearLayout>(R.id.rowNotif).setOnClickListener { @@ -100,21 +119,23 @@ class MainActivity : AppCompatActivity() { } else { // No runtime permission to request (pre-13) or already granted: // open the app's notification settings so the row still acts. - startActivity( + openSettings( Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) .putExtra(Settings.EXTRA_APP_PACKAGE, packageName), + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")), ) } } findViewById<LinearLayout>(R.id.rowBattery).setOnClickListener { if (batteryUnrestricted()) { - startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + openSettings(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) } else { - startActivity( + openSettings( Intent( Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:$packageName"), ), + Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS), ) } } @@ -140,8 +161,26 @@ class MainActivity : AppCompatActivity() { .show() return } + if (!allFiles()) { + Toast.makeText(this, "Grant all-files access first, or rsend cannot read your folders.", Toast.LENGTH_LONG) + .show() + return + } + // A manual sync honours the "unmetered only" setting, but as a check + // here rather than as a WorkManager constraint. A constrained request + // would sit ENQUEUED until the phone found wifi, and observeSync counts + // ENQUEUED as running, so the button would lock at "Syncing..." with no + // way to cancel it. Refuse now, with a reason the user can act on. + if (cfg.schedule.wifiOnly && getSystemService(ConnectivityManager::class.java).isActiveNetworkMetered) { + Toast.makeText( + this, + "This network is metered and the schedule is set to unmetered only. Connect to Wi-Fi, or turn that off under Schedule.", + Toast.LENGTH_LONG, + ).show() + return + } val req = OneTimeWorkRequestBuilder<SyncWorker>().build() - WorkManager.getInstance(this).enqueueUniqueWork("sync-now", ExistingWorkPolicy.KEEP, req) + WorkManager.getInstance(this).enqueueUniqueWork(MANUAL_WORK_NAME, ExistingWorkPolicy.KEEP, req) Toast.makeText(this, "Sync started.", Toast.LENGTH_SHORT).show() } @@ -150,17 +189,15 @@ class MainActivity : AppCompatActivity() { // fresh status (last sync) the moment it ends, without leaving the screen. private fun observeSync() { WorkManager.getInstance(this) - .getWorkInfosForUniqueWorkLiveData("sync-now") + .getWorkInfosForUniqueWorkLiveData(MANUAL_WORK_NAME) .observe(this) { infos -> - val info = infos.lastOrNull() - val running = info != null && - (info.state == WorkInfo.State.RUNNING || info.state == WorkInfo.State.ENQUEUED) - if (running) { + val active = activeWork(infos) + if (active != null) { syncBar.visibility = View.VISIBLE syncStatus.visibility = View.VISIBLE btnSync.isEnabled = false btnSync.text = "Syncing..." - val p = info!!.progress + val p = active.progress val folder = p.getString(SyncWorker.KEY_FOLDER) val i = p.getInt(SyncWorker.KEY_INDEX, 0) val n = p.getInt(SyncWorker.KEY_TOTAL, 0) @@ -176,10 +213,30 @@ class MainActivity : AppCompatActivity() { } } + // WorkManager does not promise query result order. Unique work should have + // one unfinished record; generation is a deterministic tie-breaker if an + // old database contains more than one. + private fun activeWork(infos: List<WorkInfo>): WorkInfo? = + infos.filterNot { it.state.isFinished }.maxByOrNull { it.generation } + override fun onResume() { super.onResume() Scheduler.apply(this) refresh() + showRecoveryNotice() + } + + private fun showRecoveryNotice() { + if (recoveryShown) return + val message = Config.recoveryMessage(this).trim() + if (message.isEmpty()) return + recoveryShown = true + AlertDialog.Builder(this) + .setTitle("Configuration recovered") + .setMessage(message) + .setPositiveButton("OK") { _, _ -> Config.clearRecoveryMessage(this) } + .setCancelable(false) + .show() } private fun allFiles() = Environment.isExternalStorageManager() @@ -190,25 +247,26 @@ class MainActivity : AppCompatActivity() { private fun batteryUnrestricted(): Boolean = getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(packageName) - // jobState reports the WorkManager state of the scheduled sync (ENQUEUED, - // RUNNING, none, ...) so the dashboard shows whether the scheduler is armed. - private fun jobState(): String = try { - WorkManager.getInstance(this) - .getWorkInfosForUniqueWork(Scheduler.NAME) - .get(500, TimeUnit.MILLISECONDS) - .firstOrNull()?.state?.name ?: "none" - } catch (e: Exception) { - "?" + private fun openSettings(intent: Intent, fallback: Intent? = null) { + try { + startActivity(intent) + } catch (_: ActivityNotFoundException) { + if (fallback == null) { + Toast.makeText(this, "This settings screen is not available on this device.", Toast.LENGTH_LONG).show() + } else { + try { + startActivity(fallback) + } catch (_: ActivityNotFoundException) { + Toast.makeText(this, "This settings screen is not available on this device.", Toast.LENGTH_LONG) + .show() + } + } + } } - private fun refresh() { - val cfg = Config.load(this) - - lastSync.text = "Last sync: ${LastSync.get(this)}" - - keyValue.text = if (Keys.exists(this)) "Generated, tap to view" else "Not created, tap to create" - - val s = cfg.schedule + // renderSchedule draws the schedule row from the saved settings plus the + // observed WorkManager state, so neither has to block to fetch the other. + private fun renderSchedule(s: Schedule) { val sched = if (s.enabled) { "Every ${s.intervalMinutes}m" + (if (s.wifiOnly) ", unmetered" else "") + @@ -216,7 +274,18 @@ class MainActivity : AppCompatActivity() { } else { "Off" } - scheduleValue.text = "$sched, job ${jobState()}" + scheduleValue.text = "$sched, job $jobState" + } + + private fun refresh() { + val cfg = Config.load(this) + + lastSync.text = "Last sync: ${LastSync.get(this)}" + + keyValue.text = if (Keys.exists(this)) "Generated, tap to view" else "Not created, tap to create" + + schedule = cfg.schedule + renderSchedule(cfg.schedule) setPerm(dotAllFiles, valAllFiles, allFiles(), "Granted", "Tap to grant") setPerm(dotNotif, valNotif, notif(), "Granted", "Tap to grant") @@ -289,9 +358,35 @@ class MainActivity : AppCompatActivity() { // showKey displays the public key (only the key) and offers to copy it, // generate a fresh key, or import the user's own private key. private fun showKey() { + if (!Keys.exists(this)) { + showKeyDialog("") + return + } + thread { + val result = try { + Result.success(Keys.publicKeyText(this)) + } catch (e: Exception) { + Result.failure(e) + } + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + result.fold( + onSuccess = { showKeyDialog(it) }, + onFailure = { + AlertDialog.Builder(this) + .setTitle("Key unavailable") + .setMessage(it.message ?: "could not read public key") + .setPositiveButton("OK", null) + .show() + }, + ) + } + } + } + + private fun showKeyDialog(pub: String) { val view = layoutInflater.inflate(R.layout.dialog_key, null) val keyText = view.findViewById<TextView>(R.id.keyText) - val pub = Keys.publicKeyText(this) keyText.text = pub.ifEmpty { "No key yet. Generate or import one below." } val dialog = AlertDialog.Builder(this) @@ -313,11 +408,27 @@ class MainActivity : AppCompatActivity() { } view.findViewById<Button>(R.id.btnImportKey).setOnClickListener { dialog.dismiss() - openKeyDoc.launch(arrayOf("*/*")) + chooseKeyToImport() } dialog.show() } + private fun chooseKeyToImport() { + if (!Keys.exists(this)) { + openKeyDoc.launch(arrayOf("*/*")) + return + } + AlertDialog.Builder(this) + .setTitle("Replace identity key?") + .setMessage( + "Importing a key replaces the current one. The imported public key must be present in every " + + "server's authorized_keys file." + ) + .setPositiveButton("Choose key") { _, _ -> openKeyDoc.launch(arrayOf("*/*")) } + .setNegativeButton("Cancel", null) + .show() + } + // confirmGenerate warns before replacing an existing key, then generates a // new one off the main thread and shows it so the user can copy it. private fun confirmGenerate() { @@ -359,8 +470,8 @@ class MainActivity : AppCompatActivity() { } // importKeyFrom reads the chosen private-key file and stores it, replacing - // the current key. rsh validates the key first; an unusable key (wrong type - // or passphrase-protected) is reported rather than saved. + // the current key. rsh accepts only unencrypted Ed25519 keys, using the + // same parser as the transport, and reports any other file without saving. private fun importKeyFrom(uri: Uri) { thread { val error = try { diff --git a/app/src/main/java/invalid/lena/rsend/Native.kt b/app/src/main/java/invalid/lena/rsend/Native.kt index 7f7b5ba..fcc42d7 100644 --- a/app/src/main/java/invalid/lena/rsend/Native.kt +++ b/app/src/main/java/invalid/lena/rsend/Native.kt @@ -1,7 +1,9 @@ package invalid.lena.rsend import android.content.Context +import java.io.ByteArrayOutputStream import java.io.File +import java.io.InputStream import java.util.concurrent.TimeUnit // Native locates and runs the executables shipped inside the APK as lib*.so. @@ -16,17 +18,53 @@ object Native { data class Result(val code: Int, val output: String) // Blocking; call off the main thread. stderr is merged into stdout. + // + // The output is drained on its own thread while the process runs. Waiting + // first would deadlock as soon as the child filled the pipe buffer, and the + // only symptom would be the 45s timeout. fun run(bin: File, args: List<String>, env: Map<String, String> = emptyMap()): Result { val pb = ProcessBuilder(listOf(bin.absolutePath) + args).redirectErrorStream(true) pb.environment().putAll(env) val p = pb.start() + var output = "" + val drain = Thread { + try { + p.inputStream.use { output = boundedOutput(it) } + } catch (_: Exception) { + // The pipe closes when the process is killed; whatever was read + // before that is still worth returning. + } + } + drain.start() if (!p.waitFor(45, TimeUnit.SECONDS)) { p.destroyForcibly() p.waitFor() + p.inputStream.close() + drain.join(5_000) throw IllegalStateException("${bin.name} timed out") } - val out = p.inputStream.bufferedReader().use { it.readText() } - val code = p.exitValue() - return Result(code, out) + drain.join(5_000) + if (drain.isAlive) { + p.inputStream.close() + drain.join(5_000) + } + if (drain.isAlive) throw IllegalStateException("${bin.name} output reader did not stop") + return Result(p.exitValue(), output) + } + + internal fun boundedOutput(input: InputStream): String { + val out = ByteArrayOutputStream() + val buf = ByteArray(4096) + var truncated = false + while (true) { + val n = input.read(buf) + if (n < 0) break + val room = MAX_OUTPUT_BYTES - out.size() + if (room > 0) out.write(buf, 0, minOf(room, n)) + if (n > room) truncated = true + } + return String(out.toByteArray(), Charsets.UTF_8) + if (truncated) "\n[output truncated]\n" else "" } + + private const val MAX_OUTPUT_BYTES = 64 * 1024 } diff --git a/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt index 0d77e6c..994e233 100644 --- a/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt @@ -1,12 +1,14 @@ package invalid.lena.rsend import android.os.Bundle +import android.util.Base64 import android.view.View import android.widget.Button import android.widget.EditText import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import java.security.MessageDigest import kotlin.concurrent.thread // RemoteActivity adds or edits one named remote and runs Test connection, @@ -33,7 +35,7 @@ class RemoteActivity : AppCompatActivity() { user = findViewById(R.id.user) status = findViewById(R.id.status) - index = intent.getIntExtra("index", -1) + index = savedInstanceState?.getInt("index") ?: intent.getIntExtra("index", -1) val cfg = Config.load(this) if (index in cfg.remotes.indices) { val r = cfg.remotes[index] @@ -52,9 +54,14 @@ class RemoteActivity : AppCompatActivity() { del.setOnClickListener { if (remove()) finish() } } + override fun onSaveInstanceState(outState: Bundle) { + outState.putInt("index", index) + super.onSaveInstanceState(outState) + } + private fun current(): Remote? { val n = name.text.toString().trim() - if (n.isEmpty()) { + if (!RemoteRules.nameAllowed(n)) { status.text = "Set a name; folders pick their remote by it." return null } @@ -63,18 +70,29 @@ class RemoteActivity : AppCompatActivity() { status.text = "Port must be between 1 and 65535." return null } - return Remote( - name = n, - host = host.text.toString().trim(), - port = p, - user = user.text.toString().trim(), - ) + // Unbracket before validating, or a host of "[]" passes the emptiness + // check and is then stored empty. An IPv6 literal is kept bare: rsh and + // rsync each add the brackets their own syntax needs, and a stored + // bracketed form would be bracketed twice. + val h = host.text.toString().trim().removeSurrounding("[", "]").trim() + val u = user.text.toString().trim() + // Checked here rather than only in test(), so Save and Test reject the + // same incomplete form. + if (!RemoteRules.hostAllowed(h)) { + status.text = "Host must be a hostname or IP address without brackets." + return null + } + if (!RemoteRules.userAllowed(u)) { + status.text = "User may contain letters, digits, dot, underscore, and hyphen." + return null + } + return Remote(name = n, host = h, port = p, user = u) } - // save validates, keeps the pin only while host and port are unchanged, - // renames folder references along with the remote, and persists. Returns - // the saved entry's index (a new remote is appended), or -1 on a - // validation failure. + // save validates, takes the pin the caller just accepted or carries the + // stored one over while host and port are unchanged, renames folder + // references along with the remote, and persists. Returns the saved entry's + // index (a new remote is appended), or -1 on a validation failure. private fun save(remote: Remote? = current()): Int { if (remote == null) return -1 val cfg = Config.load(this) @@ -83,8 +101,11 @@ class RemoteActivity : AppCompatActivity() { return -1 } val old = cfg.remotes.getOrNull(index) - val hostKey = - if (old != null && old.host == remote.host && old.port == remote.port) old.hostKey else "" + val hostKey = when { + remote.hostKey.isNotEmpty() -> remote.hostKey + old != null && old.host == remote.host && old.port == remote.port -> old.hostKey + else -> "" + } val entry = remote.copy(hostKey = hostKey) val remotes = cfg.remotes.toMutableList() val at = if (index in remotes.indices) { @@ -100,7 +121,17 @@ class RemoteActivity : AppCompatActivity() { if (it.remoteName == old.name) it.copy(remoteName = entry.name) else it } } - Config.save(this, cfg.copy(remotes = remotes, folders = folders)) + val next = cfg.copy(remotes = remotes, folders = folders) + val conflict = FolderRules.firstDestinationConflict(next) + if (conflict != null) { + val (a, b) = conflict + status.text = + "This endpoint would make folder \"${a.name.ifEmpty { a.remotePath }}\" conflict with " + + "folder \"${b.name.ifEmpty { b.remotePath }}\"." + return -1 + } + Config.save(this, next) + Scheduler.apply(this) return at } @@ -115,57 +146,113 @@ class RemoteActivity : AppCompatActivity() { return false } Config.save(this, cfg.copy(remotes = cfg.remotes.filterIndexed { i, _ -> i != index })) + Scheduler.apply(this) return true } + // test scans the form's remote without writing anything. Persisting first + // would let a failed or cancelled test clear the pin of a working remote + // the user never saved, which also silently disarms the periodic job. private fun test() { val r = current() ?: return - val saved = save(r) - if (saved < 0) return - // Testing a new remote adopts the appended entry, so Pin and a second - // Save update it instead of appending again. - index = saved - when { - !Keys.exists(this) -> - status.text = "No key yet. Generate one on the main screen and add it to the server first." - r.host.isEmpty() || r.user.isEmpty() -> - status.text = "Set host and user first." - else -> { - status.text = "Connecting to ${r.user}@${r.host}:${r.port} ..." - thread { - 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 { - AlertDialog.Builder(this) - .setTitle("Verify host key") - .setMessage("Fingerprint:\n${scan.fingerprint}\n\nPin this host?") - .setPositiveButton("Pin") { _, _ -> - pin(scan.line) - status.text = "Host key pinned. Connection OK." - } - .setNegativeButton("Cancel", null) - .show() - } - } + val cfg = Config.load(this) + if (cfg.remotes.withIndex().any { (i, existing) -> i != index && existing.name == r.name }) { + status.text = "A remote named \"${r.name}\" already exists." + return + } + if (!Keys.exists(this)) { + status.text = "No key yet. Generate one on the main screen and add it to the server first." + return + } + // Hand the scan the pin this remote already has, so rsh offers the + // pinned key type and an untouched server reproduces its stored line. + // Scanning the bare form instead would negotiate whatever the server + // prefers and report a key change that never happened. + val pinned = storedPin(r) + status.text = "Connecting to ${r.user}@${r.host}:${r.port} ..." + thread { + val scan = try { + Keys.scan(this, r.copy(hostKey = pinned)) + } 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 { + confirmPin(r, pinned, scan) } } } } - // pin re-loads the config before writing: the scan ran in the background - // and the dialog may have sat open for a while. - private fun pin(line: String) { - val cfg = Config.load(this) - val remotes = cfg.remotes.toMutableList() - if (index !in remotes.indices) return - remotes[index] = remotes[index].copy(hostKey = line) - Config.save(this, cfg.copy(remotes = remotes)) + // storedPin is the pin already saved for this remote, and only while host + // and port still match the form: a pin belongs to one host and port. + private fun storedPin(r: Remote): String { + val old = Config.load(this).remotes.getOrNull(index) ?: return "" + return if (old.host == r.host && old.port == r.port) old.hostKey else "" + } + + // confirmPin is the one security decision the user makes, so a key that + // differs from the stored pin must not look like a first-time pin. + private fun confirmPin(r: Remote, pinned: String, scan: Keys.Scan) { + if (pinned == scan.line) { + status.text = "Connection OK. Host key unchanged." + return + } + val b = AlertDialog.Builder(this).setNegativeButton("Cancel", null) + if (pinned.isEmpty()) { + b.setTitle("Verify host key") + .setMessage( + "Fingerprint:\n${scan.fingerprint}\n\n" + + "Check it on the server with:\n" + + "ssh-keygen -lf ${hostKeyFile(scan.fingerprint)}\n\nPin this host?" + ) + .setPositiveButton("Pin") { _, _ -> savePinned(r, scan.line) } + } else { + b.setTitle("HOST KEY CHANGED") + .setMessage( + "This host presents a different key from the one rsend pinned.\n\n" + + "Pinned:\n${fingerprintOf(pinned)}\n\n" + + "Presented:\n${scan.fingerprint}\n\n" + + "This is what an intercepted connection looks like. Only replace " + + "the pin if you rebuilt the server or rotated its host key." + ) + .setPositiveButton("Replace pin") { _, _ -> savePinned(r, scan.line) } + } + b.show() + } + + // savePinned writes the form and the accepted key together: until the user + // accepts a key, nothing about this remote is persisted. + private fun savePinned(r: Remote, line: String) { + val at = save(r.copy(hostKey = line)) + if (at < 0) return + index = at + intent.putExtra("index", at) + status.text = "Host key pinned. Connection OK." + } + + private fun hostKeyFile(fingerprint: String): String = when (fingerprint.substringBefore(' ')) { + "ssh-ed25519" -> "/etc/ssh/ssh_host_ed25519_key.pub" + "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521" -> + "/etc/ssh/ssh_host_ecdsa_key.pub" + "ssh-rsa", "rsa-sha2-256", "rsa-sha2-512" -> "/etc/ssh/ssh_host_rsa_key.pub" + else -> "/etc/ssh/ssh_host_key.pub" + } + + // fingerprintOf renders a stored known_hosts line the way rsh -scan and + // ssh-keygen -lf do, so the two fingerprints in the warning are comparable. + private fun fingerprintOf(line: String): String { + val parts = line.trim().split(" ") + if (parts.size < 3) return "(unreadable pin)" + return try { + val raw = Base64.decode(parts[2], Base64.DEFAULT) + val sha = MessageDigest.getInstance("SHA-256").digest(raw) + parts[1] + " SHA256:" + Base64.encodeToString(sha, Base64.NO_PADDING or Base64.NO_WRAP) + } catch (e: Exception) { + "(unreadable pin)" + } } } diff --git a/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt b/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt index c4c0536..f807288 100644 --- a/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt +++ b/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt @@ -6,6 +6,8 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.io.ByteArrayOutputStream +import java.io.InputStream import java.util.concurrent.TimeUnit // RsyncRunner builds and runs the rsync invocation for one folder, streaming @@ -13,6 +15,44 @@ import java.util.concurrent.TimeUnit // passes key, known_hosts, and port to rsh through the environment. object RsyncRunner { + // rsh prints this when it cannot reach the host at all. rsync only ever + // reports its own exit 12 for a remote shell that died, which does not say + // why, so the reason is read off rsh's merged stderr instead. + private const val UNREACHABLE = "rsh: unreachable:" + + // Outcome carries rsync's exit code plus whether the failure was the host + // being unreachable, which the caller uses to skip the rest of that remote. + // deleted is what a mirror removed on the server. A mirror is allowed to + // empty its destination, so the count is not a brake; it is how the run + // says what it did, loudly enough to be noticed the same day. + data class Outcome(val code: Int, val unreachable: Boolean, val deleted: Long = 0) + + // rsync exit 24 is "some files vanished before they could be transferred". + // For an additive push that is routine on a live phone: the camera, + // WhatsApp and the media scanner all move files while a sync runs. + // + // For a mirror it is not routine. rsync reports the same 24 when a whole + // source *directory* vanishes mid-run, and the receiver still deletes that + // directory's contents on the server, so calling 24 a success would record + // a run that destroyed remote data as "ok". + private const val EXIT_VANISHED = 24 + + fun succeeded(code: Int, mirror: Boolean): Boolean = + code == 0 || (code == EXIT_VANISHED && !mirror) + + // partialDir keeps a half-transferred file out of the destination name. + // Relative, so rsync excludes it automatically and --delete leaves it alone. + const val PARTIAL_DIR = ".rsend-partial" + + // "<operation> <path>": %o is send, recv or del. + const val OUT_FORMAT = "%o %n" + + // Transfers are counted rather than logged one line each. Deletions and + // everything else go to the log verbatim. + private const val SENT = "send " + private const val DELETED = "del. " + internal const val MAX_OUTPUT_LINE_BYTES = 16 * 1024 + // args builds the rsync argument vector for one folder. Flags are tuned for // media backup: recursive, preserve mtimes, resume partial files, and skip // the ownership and permission bits that mean nothing across Android and a @@ -22,10 +62,34 @@ object RsyncRunner { // older than 3.2.3 (notably stock macOS) reject it. rsync creates the // final component of the destination path on its own; deeper missing // parents need a one-time mkdir on the server (see README). + // + // --out-format labels every line with the operation: "del. <path>" for a + // deletion, "send <path>" for a transfer. That is what makes the log an + // audit trail for mirrors, and it costs nothing in compatibility: rsync + // forwards it to the remote as --log-format, which every rsync back to the + // 2.6 series understands. --info=del would be tidier still, but rsync + // forwards --info verbatim and remotes older than 3.1.0 reject it, the same + // trap --mkpath fell into. + // + // Labelling also keeps filenames out of column 0, where an attacker-chosen + // name could otherwise forge rsh's "unreachable" marker. runFolder counts + // the "send" lines rather than logging each one, so a first sync of a large + // library cannot flood the capped log and rotate the deletions out of it. fun args(rsh: String, remote: Remote, f: Folder): List<String> { + require(RemoteRules.hostAllowed(remote.host) && RemoteRules.userAllowed(remote.user)) { + "invalid remote endpoint" + } + require(FolderRules.localPathAllowed(f.local)) { "invalid local path" } + require(FolderRules.remotePathAllowed(f.remotePath)) { "invalid remote path" } + require(FolderRules.excludesAllowed(f.excludes)) { "invalid exclude" } val a = ArrayList<String>() a.add("-rt") - a.add("--partial") + a.add("--out-format=$OUT_FORMAT") + // Without a partial dir an interrupted transfer renames the truncated + // temp file over the destination name, destroying a complete remote + // copy. Interruption is routine here: the worker is killed when a + // constraint is lost or the execution limit is hit. + a.add("--partial-dir=$PARTIAL_DIR") // Abort rather than hang if the network stalls for 5 minutes. a.add("--timeout=300") a.add("--no-perms") @@ -35,35 +99,99 @@ object RsyncRunner { a.add("-e") a.add(rsh) for (e in f.excludes) a.add("--exclude=$e") - if (f.delete) a.add("--delete") + // --delete-after, not --delete. Deleting during the transfer removes the + // server's copy of a file before its replacement has arrived, and this + // worker is routinely killed mid-run, so a rename on the phone plus one + // interruption leaves the server with neither copy. Deleting only after + // everything is transferred keeps the old copy until the new one is + // safely there. It is as old as rsync, so no remote is excluded. + if (f.delete) a.add("--delete-after") a.add(withSlash(f.local)) - a.add("${remote.user}@${remote.host}:${withSlash(f.remotePath)}") + a.add("${remote.user}@${hostArg(remote.host)}:${withSlash(f.remotePath)}") return a } private fun withSlash(p: String): String = if (p.endsWith("/")) p else "$p/" + // An IPv6 literal must be bracketed in rsync's USER@HOST:PATH, or rsync + // splits the destination on the first colon of the address. + private fun hostArg(h: String): String = if (h.contains(":")) "[$h]" else h + // runFolder execs rsync for one folder, appending every output line to the // log, and returns rsync's exit code. Blocks the calling thread (run it on // an IO dispatcher). If the caller is cancelled (worker stopped, schedule // replaced, constraints lost) the watchdog kills rsync rather than leave it // running detached; killing it also closes its pipes, which unblocks the // log reader below. destroy() on an already-exited process is a no-op. - suspend fun runFolder(ctx: Context, remote: Remote, f: Folder, log: SyncLog): Int = coroutineScope { + suspend fun runFolder(ctx: Context, remote: Remote, f: Folder, log: SyncLog): Outcome = coroutineScope { val cmd = listOf(Native.rsync(ctx).absolutePath) + args(Native.rsh(ctx).absolutePath, remote, f) log.line("rsync ${cmd.drop(1).joinToString(" ")}") val pb = ProcessBuilder(cmd).redirectErrorStream(true) - pb.environment().putAll(Keys.env(ctx, remote.port)) + pb.environment().putAll(Keys.env(ctx, remote)) val p = pb.start() val watchdog = launch(Dispatchers.IO) { try { awaitCancellation() } finally { terminate(p) } } try { - p.inputStream.bufferedReader().forEachLine { log.line(it) } - p.waitFor() + val t = tally(p.inputStream) { log.line(it) } + if (t.sent > 0) log.line("transferred ${t.sent} item(s)") + if (t.deleted > 0) log.line("deleted ${t.deleted} item(s) on the server") + Outcome(p.waitFor(), t.unreachable, t.deleted) } finally { watchdog.cancelAndJoin() } } + // A hostile or broken remote can print an arbitrarily long line. Keep + // draining its pipe so rsync cannot deadlock, but retain only a bounded + // prefix for the log. + internal data class Tally(val sent: Long, val deleted: Long, val unreachable: Boolean) + + // tally reads rsync's merged output, counts what it did, and passes + // everything worth keeping to log. Separated from the process so it can be + // tested against real rsync output without spawning one. It streams: a + // first sync of a large library must not be buffered to be counted. + internal fun tally(input: InputStream, log: (String) -> Unit): Tally { + var sent = 0L + var deleted = 0L + var unreachable = false + boundedLines(input) { + if (it.startsWith(UNREACHABLE)) unreachable = true + if (it.startsWith(DELETED)) deleted++ + // One line per transferred file would bury the deletions and the + // errors, and on a first sync would outgrow the whole log. + if (it.startsWith(SENT)) sent++ else log(it) + } + return Tally(sent, deleted, unreachable) + } + + internal fun boundedLines(input: InputStream, emit: (String) -> Unit) { + val line = ByteArrayOutputStream() + val buf = ByteArray(4096) + var truncated = false + while (true) { + val n = input.read(buf) + if (n < 0) break + for (i in 0 until n) { + val b = buf[i].toInt() and 0xff + if (b == '\n'.code) { + emitLine(line, truncated, emit) + line.reset() + truncated = false + } else if (line.size() < MAX_OUTPUT_LINE_BYTES) { + line.write(b) + } else { + truncated = true + } + } + } + if (line.size() > 0 || truncated) emitLine(line, truncated, emit) + } + + private fun emitLine(line: ByteArrayOutputStream, truncated: Boolean, emit: (String) -> Unit) { + val value = String(line.toByteArray(), Charsets.UTF_8).removeSuffix("\r") + + if (truncated) " [truncated]" else "" + emit(value) + } + private fun terminate(p: Process) { p.destroy() if (!p.waitFor(5, TimeUnit.SECONDS)) { diff --git a/app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt b/app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt index 31ec6b4..b3c3060 100644 --- a/app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt @@ -3,17 +3,18 @@ package invalid.lena.rsend import android.os.Bundle import android.widget.Button import android.widget.EditText -import android.widget.Switch +import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SwitchCompat // ScheduleActivity edits the periodic sync settings and (re)applies them to // WorkManager on save. class ScheduleActivity : AppCompatActivity() { - private lateinit var enabled: Switch + private lateinit var enabled: SwitchCompat private lateinit var interval: EditText - private lateinit var wifiOnly: Switch - private lateinit var charging: Switch + private lateinit var wifiOnly: SwitchCompat + private lateinit var charging: SwitchCompat override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -32,19 +33,28 @@ class ScheduleActivity : AppCompatActivity() { wifiOnly.isChecked = s.wifiOnly charging.isChecked = s.requireCharging - findViewById<Button>(R.id.save).setOnClickListener { save(); finish() } + findViewById<Button>(R.id.save).setOnClickListener { if (save()) finish() } } - private fun save() { + private fun save(): Boolean { + val minutes = interval.text.toString().toIntOrNull() + if (minutes == null || minutes < Schedule.MIN_INTERVAL_MINUTES) { + Toast.makeText( + this, + "Interval must be at least ${Schedule.MIN_INTERVAL_MINUTES} minutes.", + Toast.LENGTH_LONG, + ).show() + return false + } val cfg = Config.load(this) val s = Schedule( enabled = enabled.isChecked, - intervalMinutes = interval.text.toString().toIntOrNull() - ?.coerceAtLeast(Schedule.MIN_INTERVAL_MINUTES) ?: 120, + intervalMinutes = minutes, wifiOnly = wifiOnly.isChecked, requireCharging = charging.isChecked, ) Config.save(this, cfg.copy(schedule = s)) Scheduler.apply(this) + return true } } diff --git a/app/src/main/java/invalid/lena/rsend/Scheduler.kt b/app/src/main/java/invalid/lena/rsend/Scheduler.kt index 4b0e687..c35fb42 100644 --- a/app/src/main/java/invalid/lena/rsend/Scheduler.kt +++ b/app/src/main/java/invalid/lena/rsend/Scheduler.kt @@ -11,15 +11,21 @@ import java.util.concurrent.TimeUnit // Scheduler keeps one periodic sync request aligned with the saved config. object Scheduler { - // Versioned so it can never collide with the retired one-time chain's - // unique work name. + // Keep this distinct from the retired pre-0.1.3 one-time chain. WorkManager + // persists unique names across app upgrades, so changing it creates a + // second schedule and leaves the first one invisible to the UI. const val NAME = "periodic-sync-v2" + private const val RETIRED_NAME = "periodic-sync" fun apply(ctx: Context) { val cfg = Config.load(ctx) val s = cfg.schedule val wm = WorkManager.getInstance(ctx) + // Idempotently retire both the old one-time chain and any periodic job + // created by the unreleased name regression. + wm.cancelUniqueWork(RETIRED_NAME) + if (!s.enabled || !cfg.syncReady(Keys.exists(ctx))) { wm.cancelUniqueWork(NAME) return diff --git a/app/src/main/java/invalid/lena/rsend/SyncLog.kt b/app/src/main/java/invalid/lena/rsend/SyncLog.kt index 44c4d46..f0db905 100644 --- a/app/src/main/java/invalid/lena/rsend/SyncLog.kt +++ b/app/src/main/java/invalid/lena/rsend/SyncLog.kt @@ -2,6 +2,8 @@ package invalid.lena.rsend import android.content.Context import java.io.File +import java.io.FileOutputStream +import java.io.RandomAccessFile import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -11,16 +13,112 @@ import java.util.Locale class SyncLog(ctx: Context) { val file: File = File(ctx.filesDir, "sync.log") + private val previous: File = File(ctx.filesDir, "sync.log.1") private val fmt = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US) fun line(s: String) { - file.appendText("${fmt.format(Date())} $s\n") + synchronized(lock) { + val clean = cleanLine(s) + val entry = "${fmt.format(Date())} $clean\n".toByteArray(Charsets.UTF_8) + if (file.exists() && file.length() + entry.size > MAX_BYTES) rotate() + FileOutputStream(file, true).use { it.write(entry) } + } } - // Truncate once the log grows past the cap so it cannot fill storage. + // Called once at the start of a run; line() also enforces the cap as the + // run goes, so a single long run cannot grow without bound. fun rotateIfBig() { - if (file.exists() && file.length() > 512 * 1024) file.writeText("") + synchronized(lock) { + if (file.exists() && file.length() >= MAX_BYTES) rotate() + } } - fun text(): String = if (file.exists()) file.readText() else "" + // Roll over rather than truncate. Truncating destroyed every earlier run, + // and a run that outgrew the cap destroyed its own output, including the + // record of what it had just deleted on the server. + private fun rotate() { + if (previous.exists() && !previous.delete()) { + throw IllegalStateException("could not remove ${previous.name}") + } + if (file.exists() && !file.renameTo(previous)) { + throw IllegalStateException("could not rotate ${file.name}") + } + } + + // Both halves, oldest first. Showing only the current file would hide + // everything a rotation moved aside, which is where a long run's earliest + // and most interesting lines end up. + fun text(): String { + synchronized(lock) { + val current = tail(file, DISPLAY_BYTES) + val left = DISPLAY_BYTES - current.bytes.size + val old = if (left > 0) tail(previous, left) else Tail(ByteArray(0), previous.exists()) + val omitted = current.omitted || old.omitted + val bytes = old.bytes + current.bytes + val text = String(skipUtf8Continuation(bytes), Charsets.UTF_8) + return if (omitted) "[older log omitted]\n$text" else text + } + } + + // Clear both halves, or Clear would leave the rotated one on screen. + fun clear() { + synchronized(lock) { + if (previous.exists() && !previous.delete()) { + throw IllegalStateException("could not remove ${previous.name}") + } + FileOutputStream(file, false).use { } + } + } + + companion object { + private const val MAX_BYTES = 512L * 1024 + private const val DISPLAY_BYTES = 64 * 1024 + private const val MAX_LINE_CHARS = 4_000 + private val lock = Any() + + internal fun cleanLine(value: String): String = buildString(minOf(value.length, MAX_LINE_CHARS + 20)) { + for (c in value) { + when (c) { + '\n' -> append("\\n") + '\r' -> append("\\r") + else -> if (unsafeDisplayCharacter(c)) { + append("\\u") + append(c.code.toString(16).padStart(4, '0')) + } else { + append(c) + } + } + if (length >= MAX_LINE_CHARS) { + append(" [truncated]") + break + } + } + } + + private fun unsafeDisplayCharacter(c: Char): Boolean { + val type = Character.getType(c) + return c.isISOControl() || type == Character.FORMAT.toInt() || + type == Character.LINE_SEPARATOR.toInt() || type == Character.PARAGRAPH_SEPARATOR.toInt() + } + } + + private data class Tail(val bytes: ByteArray, val omitted: Boolean) + + private fun tail(source: File, limit: Int): Tail { + if (!source.exists() || limit <= 0) return Tail(ByteArray(0), source.exists() && source.length() > 0) + val length = source.length() + val count = minOf(length, limit.toLong()).toInt() + val bytes = ByteArray(count) + RandomAccessFile(source, "r").use { + it.seek(length - count) + it.readFully(bytes) + } + return Tail(bytes, length > count) + } + + private fun skipUtf8Continuation(bytes: ByteArray): ByteArray { + var first = 0 + while (first < bytes.size && bytes[first].toInt() and 0xc0 == 0x80) first++ + return if (first == 0) bytes else bytes.copyOfRange(first, bytes.size) + } } diff --git a/app/src/main/java/invalid/lena/rsend/SyncWorker.kt b/app/src/main/java/invalid/lena/rsend/SyncWorker.kt index b8c9a9d..6bc2b2c 100644 --- a/app/src/main/java/invalid/lena/rsend/SyncWorker.kt +++ b/app/src/main/java/invalid/lena/rsend/SyncWorker.kt @@ -3,7 +3,8 @@ package invalid.lena.rsend import android.app.Notification import android.content.Context import android.content.pm.ServiceInfo -import android.os.Build +import android.net.ConnectivityManager +import android.os.Environment import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.work.CoroutineWorker @@ -30,69 +31,123 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, } override suspend fun doWork(): Result = syncMutex.withLock { - withContext(Dispatchers.IO) { - val ctx = applicationContext - val cfg = Config.load(ctx) - if (!cfg.syncReady(Keys.exists(ctx))) { - SyncLog(ctx).line("sync skipped: key, folders, or a pinned remote missing") - return@withContext Result.failure() - } - - val log = SyncLog(ctx) - log.rotateIfBig() - log.line("sync started (${cfg.folders.size} folders)") - // known_hosts is derived from the config's pins; regenerate it here, - // under the sync mutex, before the first rsync reads it. - Keys.writeKnownHosts(ctx, cfg.remotes.map { it.hostKey }.filter { it.isNotEmpty() }) - // 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: ${e.javaClass.simpleName}: ${e.message}") - } + val ctx = applicationContext + try { + withContext(Dispatchers.IO) { + val cfg = Config.load(ctx) + // A refused run must be as visible as a failed one. These return + // before the loop that would otherwise record the outcome, so + // without this the dashboard keeps showing the last success while + // nothing has been backed up since. + if (!cfg.syncReady(Keys.exists(ctx))) { + return@withContext refuse(ctx, "key, folders, or a pinned remote missing") + } + // All-files access is the only storage permission rsend declares. + // Without it configured storage can be unreadable. Refuse the run + // before rsync sees a partial view of the source tree. + if (!Environment.isExternalStorageManager()) { + return@withContext refuse(ctx, "all-files access not granted") + } - 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 -> ${f.remoteName} ===") - val remote = cfg.remote(f.remoteName) - // A broken folder fails alone; the others still sync. - // Guard against a hand-edited config: an empty path would rsync /. - val code = if (f.local.isEmpty() || f.remotePath.isEmpty()) { - log.line("error: local or remote path not set") - 1 - } else if (remote == null) { - log.line("error: remote \"${f.remoteName}\" not found") - 1 - } else if (remote.host.isEmpty() || remote.user.isEmpty()) { - log.line("error: remote \"${f.remoteName}\" has no host or user") - 1 - } else if (!remote.pinned()) { - log.line("error: remote \"${f.remoteName}\" not pinned, run Test connection") - 1 - } else try { - RsyncRunner.runFolder(ctx, 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.javaClass.simpleName}: ${e.message}") - 1 + // Worth shouting about: a background job is subject to the + // execution limit, so a large first sync can be cut short every + // period. rsync resumes, but the user should know why progress + // is slow and that the battery exemption fixes it. + log.line("WARNING: foreground service refused (${e.javaClass.simpleName}: ${e.message})") + log.line("WARNING: running as a background job, which the system may stop early;") + log.line("WARNING: grant Battery > unrestricted so long syncs can finish") } - log.line("exit=$code") - if (code != 0) ok = false + + var ok = true + val total = cfg.folders.size + // Folder labels that failed, so the dashboard identifies the work + // that did not complete instead of only saying the run failed. + val failed = LinkedHashSet<String>() + // Remotes that turned out to be unreachable during this run. A + // dropped connect costs the full dial timeout, so pay it once per + // remote instead of once per folder pointing at it. + val unreachable = HashSet<String>() + // Total removed on the server across the run. + var deleted = 0L + for ((i, f) in cfg.folders.withIndex()) { + val label = f.name.ifEmpty { f.local } + // Re-check per folder: a run that began on wifi can lose it and + // would otherwise push the rest of a photo library over mobile + // data. Stop rather than carry on against the user's setting. + if (cfg.schedule.wifiOnly && metered(ctx)) { + log.line("stopping: network became metered and the schedule is unmetered only") + ok = false + failed.add("metered network") + break + } + // Publish progress the dashboard observes (current folder, i of n). + setProgress(workDataOf(KEY_FOLDER to label.take(256), KEY_INDEX to i + 1, KEY_TOTAL to total)) + log.line("=== $label -> ${f.remoteName} ===") + // Config parsing guarantees the remote exists; being pinned is + // a separate thing the user can still be part-way through. + val remote = checkNotNull(cfg.remote(f.remoteName)) + val code = if (!remote.pinned()) { + log.line("error: remote \"${f.remoteName}\" not pinned, run Test connection") + 1 + } else if (unreachable.contains(f.remoteName)) { + log.line("error: remote \"${f.remoteName}\" was unreachable earlier this run, skipped") + 1 + } else if (!FolderRules.sourceReadable(f.local)) { + log.line("error: local source \"${f.local}\" is not a readable directory") + 1 + } else try { + val r = RsyncRunner.runFolder(ctx, remote, f, log) + if (r.unreachable) unreachable.add(f.remoteName) + deleted += r.deleted + r.code + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.line("error: ${e.javaClass.simpleName}: ${e.message}") + 1 + } + log.line("exit=$code") + if (!RsyncRunner.succeeded(code, f.delete)) { + ok = false + failed.add(label) + } + } + log.line("sync finished ok=$ok") + // A mirror may legitimately empty its destination, so deletions are + // reported rather than prevented. Saying so on the dashboard, and + // notifying when a run deleted more than it sent, is what turns a + // surprising mass deletion into something noticed the same day. + val summary = if (deleted > 0) " ($deleted deleted)" else "" + LastSync.set(ctx, if (ok) "ok$summary" else "FAILED: ${failed.joinToString(", ")}$summary") + if (!ok) { + notifyError(ctx) + } else if (deleted > 0) { + notifyDeleted(ctx, deleted) + } + if (ok) Result.success() else Result.failure() } - log.line("sync finished ok=$ok") - LastSync.set(ctx, ok) - if (!ok) notifyError(ctx) - if (ok) Result.success() else Result.failure() + } catch (e: CancellationException) { + // Cancellation can arrive at any suspension point, including + // between folders or while publishing progress. Record it once at + // the worker boundary instead of only around the rsync process. + try { + SyncLog(ctx).line("cancelled") + LastSync.set(ctx, "interrupted") + } catch (recordError: Exception) { + e.addSuppressed(recordError) + } + throw e } } @@ -110,6 +165,40 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, } } + // notifyDeleted reports a successful mirror that removed files on the + // server. Mirror semantics make that correct, but a user who did not mean + // to empty a folder should hear about it while the local copy may still + // exist, not months later. + private fun notifyDeleted(ctx: Context, deleted: Long) { + val n = NotificationCompat.Builder(ctx, App.CHANNEL) + .setContentTitle("rsend") + .setContentText("Sync ok. Mirror deleted $deleted item(s) on the server.") + .setSmallIcon(R.drawable.ic_notification) + .setAutoCancel(true) + .build() + try { + NotificationManagerCompat.from(ctx).notify(3, n) + } catch (_: SecurityException) { + // POST_NOTIFICATIONS not granted; the log still records the count. + } + } + + // refuse records a run that never started, so the dashboard and the + // notification say so instead of leaving the last success on screen. + private fun refuse(ctx: Context, why: String): Result { + SyncLog(ctx).line("sync skipped: $why") + LastSync.set(ctx, "SKIPPED: $why") + notifyError(ctx) + return Result.failure() + } + + // metered reports whether the current network costs money, so a run can + // stop when the user asked for unmetered only. The periodic job has a + // WorkManager constraint, but a manual run has none by design, and either + // can lose wifi part way through a long sync. + private fun metered(ctx: Context): Boolean = + ctx.getSystemService(ConnectivityManager::class.java).isActiveNetworkMetered + private fun foregroundInfo(): ForegroundInfo { val n: Notification = NotificationCompat.Builder(applicationContext, App.CHANNEL) .setContentTitle("rsend") @@ -117,10 +206,6 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, .setSmallIcon(R.drawable.ic_notification) .setOngoing(true) .build() - return if (Build.VERSION.SDK_INT >= 29) { - ForegroundInfo(1, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) - } else { - ForegroundInfo(1, n) - } + return ForegroundInfo(1, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) } } |