diff options
| author | Lena <lena@omega> | 2026-08-23 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-08-23 00:00:00 +0000 |
| commit | 0c653418a67fe960bb3126559fe77833faaa385c (patch) | |
| tree | 6db0f168c62ec29d3f5ccb64845d781b2fa80e79 /app/src/main/java/invalid/lena | |
| parent | dc0338f8c0c9e74b277169d812c432f7ea4888e3 (diff) | |
| download | rsend-0c653418a67fe960bb3126559fe77833faaa385c.tar.gz | |
app: remove duplicated and dead code
upgradeLegacy assembled every remote, folder and schedule twice: once as
Remote and Folder objects to validate, and again as a parallel JSON tree
to return. Build the objects and serialise them, and drop the dead
endpointAllowed branch, the redundant pin emptiness test, and the
excludes length pre-check that excludesAllowed already makes.
atomicWrite existed verbatim in Config and Keys and was open-coded a
third time in LastSync, which then reached into Config's companion for
the bounded reader. The same bounded read was open-coded twice more, for
the encrypted key and for an imported one. Both are plain file
operations on the same private directory and belong in one place, with
readText a thin wrapper over readBytes.
Generating and importing an identity key ran the same twenty lines of
worker thread, error capture, refresh, toast and dialog; only the action
and two strings differed. rotateIfBig rotated at length >= MAX_BYTES,
which line() already subsumes, and its only caller invoked it
immediately before a line(). notifyError and notifyDeleted were the same
builder twice. openSettings nested a try/catch and repeated one toast;
take the candidates as a vararg. RemoteActivity.save defaulted its
argument to current(), which validates and writes the status field, so
the default hid that side effect from one of two callers.
Dead on arrival: Outcome.deleted's default, which no caller omits; the
rsa-sha2-* arms of hostKeyFile, since rsh prints PublicKey.Type() and an
RSA host key is always ssh-rsa; and the limit <= 0 branch in
SyncLog.text, which tail already handles and handled more accurately.
The config recovery note is now written with an explicit charset like
every other write here.
Diffstat (limited to 'app/src/main/java/invalid/lena')
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/Config.kt | 147 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/Files.kt | 39 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt | 7 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/FolderPickerActivity.kt | 3 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/Keys.kt | 32 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/LastSync.kt | 12 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/MainActivity.kt | 110 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/RemoteActivity.kt | 8 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/Rsync.kt | 2 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/SyncLog.kt | 13 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/SyncWorker.kt | 44 |
11 files changed, 153 insertions, 264 deletions
diff --git a/app/src/main/java/invalid/lena/rsend/Config.kt b/app/src/main/java/invalid/lena/rsend/Config.kt index a38fbde..701ce66 100644 --- a/app/src/main/java/invalid/lena/rsend/Config.kt +++ b/app/src/main/java/invalid/lena/rsend/Config.kt @@ -4,10 +4,8 @@ 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 @@ -141,7 +139,7 @@ data class Config( 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 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") @@ -172,22 +170,13 @@ data class Config( } } - // 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(bytes) - file.finishWrite(out) - } catch (e: Exception) { - file.failWrite(out) - throw e - } + atomicWrite(file(ctx), bytes) } // overLimit reports why c cannot be persisted, or null. These are the @@ -213,9 +202,9 @@ data class Config( // 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. + // 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 @@ -226,81 +215,51 @@ data class Config( 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 remote = 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 remotes = when { + !endpointAllowed -> emptyList() + RemoteRules.hostKeyAllowed(remote) -> listOf(remote) + else -> listOf(remote.copy(hostKey = "")) } - val folders = JSONArray() - val accepted = ArrayList<Folder>() + val folders = 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"), + if (remotes.isEmpty() || folders.size >= MAX_FOLDERS) break + val fo = fa.getJSONObject(i) + val ex = fo.optJSONArray("excludes") ?: JSONArray() + val f = Folder( + name = fo.optString("name"), + local = fo.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, + remotePath = fo.optString("remote"), + delete = fo.optBoolean("delete", false), + excludes = (0 until ex.length()).map(ex::getString), ) - if (FolderRules.nameAllowed(folder.name) && - FolderRules.localPathAllowed(folder.local) && - FolderRules.remotePathAllowed(folder.remotePath) && - FolderRules.excludesAllowed(folder.excludes) && - FolderRules.destinationConflict(trial, folder, -1) == null + if (FolderRules.nameAllowed(f.name) && + FolderRules.localPathAllowed(f.local) && + FolderRules.remotePathAllowed(f.remotePath) && + FolderRules.excludesAllowed(f.excludes) && + FolderRules.destinationConflict(Config(remotes = remotes, folders = folders), f, -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), - ) + folders.add(f) } } - val oldSchedule = o.optJSONObject("schedule") ?: JSONObject() - val schedule = JSONObject() - .put("enabled", oldSchedule.optBoolean("enabled", false)) - .put( - "intervalMinutes", - oldSchedule.optInt("intervalMinutes", 120) + + val s = o.optJSONObject("schedule") ?: JSONObject() + return Config( + remotes = remotes, + schedule = Schedule( + enabled = s.optBoolean("enabled", false), + intervalMinutes = s.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) + wifiOnly = s.optBoolean("wifiOnly", true), + requireCharging = s.optBoolean("requireCharging", false), + ), + folders = folders, + ).toJson() } private fun preserveLegacy(ctx: Context, text: String, old: JSONObject, migrated: Config) { @@ -445,7 +404,7 @@ data class Config( val message = "Configuration $what and was reset. The original file was preserved as ${broken.name}. " + "Reason: $detail" - atomicWrite(recoveryFile(ctx), (message + "\n").toByteArray()) + atomicWrite(recoveryFile(ctx), (message + "\n").toByteArray(Charsets.UTF_8)) return Config() } @@ -462,29 +421,5 @@ data class Config( } 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/Files.kt b/app/src/main/java/invalid/lena/rsend/Files.kt new file mode 100644 index 0000000..60cfdf5 --- /dev/null +++ b/app/src/main/java/invalid/lena/rsend/Files.kt @@ -0,0 +1,39 @@ +package invalid.lena.rsend + +import android.util.AtomicFile +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.InputStream + +// Whole-file reads and writes of rsend's private state: the config, the +// encrypted identity key, the derived known_hosts, and the last sync status. + +// AtomicFile keeps the previous complete file if a write is interrupted. +internal 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 + } +} + +// Nothing guarantees a file on disk is still the one rsend wrote, so stop at +// limit rather than buffer whatever is there. +internal fun readBytes(input: InputStream, limit: Int): ByteArray { + 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 out.toByteArray() +} + +internal fun readText(input: InputStream, limit: Int): String = + String(readBytes(input, limit), Charsets.UTF_8) diff --git a/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt b/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt index 3024a52..7571508 100644 --- a/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt @@ -64,11 +64,10 @@ class FolderEditActivity : AppCompatActivity() { excludes.setText(f.excludes.joinToString(", ")) delete.isChecked = f.delete - val names = cfg.remotes.map { it.name }.toMutableList() - remoteNames = names + remoteNames = cfg.remotes.map { it.name } remoteName.adapter = - ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, names) - val sel = names.indexOf(f.remoteName) + ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, remoteNames) + val sel = remoteNames.indexOf(f.remoteName) if (sel >= 0) remoteName.setSelection(sel) findViewById<Button>(R.id.browse).setOnClickListener { diff --git a/app/src/main/java/invalid/lena/rsend/FolderPickerActivity.kt b/app/src/main/java/invalid/lena/rsend/FolderPickerActivity.kt index b81ce22..08da423 100644 --- a/app/src/main/java/invalid/lena/rsend/FolderPickerActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/FolderPickerActivity.kt @@ -5,6 +5,7 @@ import android.os.Bundle import android.os.Environment import android.os.storage.StorageManager import android.widget.Button +import android.widget.LinearLayout import android.widget.TextView import androidx.activity.addCallback import androidx.appcompat.app.AppCompatActivity @@ -23,7 +24,7 @@ class FolderPickerActivity : AppCompatActivity() { } private lateinit var pathView: TextView - private lateinit var list: android.widget.LinearLayout + private lateinit var list: LinearLayout private lateinit var use: Button private lateinit var root: File private lateinit var current: File diff --git a/app/src/main/java/invalid/lena/rsend/Keys.kt b/app/src/main/java/invalid/lena/rsend/Keys.kt index bd18080..23a3b97 100644 --- a/app/src/main/java/invalid/lena/rsend/Keys.kt +++ b/app/src/main/java/invalid/lena/rsend/Keys.kt @@ -2,7 +2,6 @@ package invalid.lena.rsend import android.content.Context import android.util.AtomicFile -import java.io.ByteArrayOutputStream import java.io.File import java.io.FileNotFoundException @@ -127,33 +126,8 @@ object Keys { Scan(true, lines[0], lines[1], "") } - 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. - 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 - } + private fun keyEnvironment(ctx: Context): Map<String, String> { + val blob = AtomicFile(keyEnc(ctx)).openRead().use { readBytes(it, MAX_ENCRYPTED_KEY_BYTES) } + return mapOf("RSH_KEY_DATA" to String(KeyVault.decrypt(blob), Charsets.UTF_8)) } } diff --git a/app/src/main/java/invalid/lena/rsend/LastSync.kt b/app/src/main/java/invalid/lena/rsend/LastSync.kt index 9179b19..6b939b9 100644 --- a/app/src/main/java/invalid/lena/rsend/LastSync.kt +++ b/app/src/main/java/invalid/lena/rsend/LastSync.kt @@ -26,20 +26,12 @@ object LastSync { } } 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 - } + atomicWrite(file(ctx), "$time $clean".toByteArray(Charsets.UTF_8)) } @Synchronized fun get(ctx: Context): String = try { - AtomicFile(file(ctx)).openRead().use { Config.readText(it, MAX_FILE_BYTES) } + AtomicFile(file(ctx)).openRead().use { readText(it, MAX_FILE_BYTES) } } catch (_: FileNotFoundException) { "never" } catch (e: Exception) { diff --git a/app/src/main/java/invalid/lena/rsend/MainActivity.kt b/app/src/main/java/invalid/lena/rsend/MainActivity.kt index 06270ca..f523a04 100644 --- a/app/src/main/java/invalid/lena/rsend/MainActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/MainActivity.kt @@ -6,8 +6,8 @@ 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.ConnectivityManager import android.net.Uri import android.os.Build import android.os.Bundle @@ -28,7 +28,6 @@ import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager -import java.io.ByteArrayOutputStream import kotlin.concurrent.thread // MainActivity is the dashboard: a status hero, a primary Sync action, grouped @@ -247,21 +246,17 @@ class MainActivity : AppCompatActivity() { private fun batteryUnrestricted(): Boolean = getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(packageName) - 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() - } + // Settings screens vary by OEM and Android version, so try each candidate in + // turn and say so plainly when none of them exists. + private fun openSettings(vararg intents: Intent) { + for (i in intents) { + try { + startActivity(i) + return + } catch (_: ActivityNotFoundException) { } } + Toast.makeText(this, "This settings screen is not available on this device.", Toast.LENGTH_LONG).show() } // renderSchedule draws the schedule row from the saved settings plus the @@ -402,13 +397,7 @@ class MainActivity : AppCompatActivity() { 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() - }, + onFailure = { alert("Key unavailable", it.message ?: "could not read public key") }, ) } } @@ -460,7 +449,7 @@ class MainActivity : AppCompatActivity() { } // 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. + // new one and shows it so the user can copy it. private fun confirmGenerate() { val msg = if (Keys.exists(this)) { "Replace the current key with a new one? You must add the new public key to the server's authorized_keys." @@ -471,28 +460,7 @@ class MainActivity : AppCompatActivity() { .setTitle("Generate new key") .setMessage(msg) .setPositiveButton("Generate") { _, _ -> - thread { - val error = try { - Keys.generate(this) - null - } catch (e: Exception) { - e.message ?: "keygen failed" - } - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - if (error == null) { - refresh() - Toast.makeText(this, "New key generated.", Toast.LENGTH_SHORT).show() - showKey() - } else { - AlertDialog.Builder(this) - .setTitle("Keygen failed") - .setMessage(error) - .setPositiveButton("OK", null) - .show() - } - } - } + replaceKey("New key generated.", "Keygen failed") { Keys.generate(this) } } .setNegativeButton("Cancel", null) .show() @@ -502,41 +470,41 @@ class MainActivity : AppCompatActivity() { // 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) { + replaceKey("Key imported.", "Import failed") { + val data = contentResolver.openInputStream(uri)?.use { readBytes(it, Keys.MAX_KEY_BYTES) } + ?: throw IllegalArgumentException("could not read the file") + Keys.importKey(this, data) + } + } + + // replaceKey installs a new identity key and shows it. Off the main thread: + // generating and importing both run rsh to derive and validate the key. + private fun replaceKey(done: String, failure: String, install: () -> Unit) { thread { val error = try { - val data = contentResolver.openInputStream(uri)?.use { input -> - val out = ByteArrayOutputStream() - val buf = ByteArray(4096) - while (true) { - val n = input.read(buf) - if (n < 0) break - if (out.size() + n > Keys.MAX_KEY_BYTES) { - throw IllegalArgumentException("private key exceeds ${Keys.MAX_KEY_BYTES} bytes") - } - out.write(buf, 0, n) - } - out.toByteArray() - } - ?: throw IllegalArgumentException("could not read the file") - Keys.importKey(this, data) + install() null } catch (e: Exception) { - e.message ?: "import failed" + e.message ?: e.javaClass.simpleName } runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread - if (error == null) { - refresh() - Toast.makeText(this, "Key imported.", Toast.LENGTH_SHORT).show() - showKey() - } else { - AlertDialog.Builder(this) - .setTitle("Import failed") - .setMessage(error) - .setPositiveButton("OK", null) - .show() + if (error != null) { + alert(failure, error) + return@runOnUiThread } + refresh() + Toast.makeText(this, done, Toast.LENGTH_SHORT).show() + showKey() } } } + + private fun alert(title: String, message: String) { + AlertDialog.Builder(this) + .setTitle(title) + .setMessage(message) + .setPositiveButton("OK", null) + .show() + } } diff --git a/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt index b0a94a0..f9d772a 100644 --- a/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt +++ b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt @@ -47,7 +47,7 @@ class RemoteActivity : AppCompatActivity() { port.setText("22") } - findViewById<Button>(R.id.save).setOnClickListener { if (save() >= 0) finish() } + findViewById<Button>(R.id.save).setOnClickListener { if (save(current()) >= 0) finish() } findViewById<Button>(R.id.test).setOnClickListener { test() } val del = findViewById<Button>(R.id.removeRemote) del.visibility = if (index < 0) View.GONE else View.VISIBLE @@ -93,7 +93,7 @@ class RemoteActivity : AppCompatActivity() { // 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 { + private fun save(remote: Remote?): Int { if (remote == null) return -1 val cfg = Config.load(this) if (cfg.remotes.withIndex().any { (i, r) -> i != index && r.name == remote.name }) { @@ -242,7 +242,7 @@ class RemoteActivity : AppCompatActivity() { "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" + "ssh-rsa" -> "/etc/ssh/ssh_host_rsa_key.pub" else -> "/etc/ssh/ssh_host_key.pub" } @@ -255,7 +255,7 @@ class RemoteActivity : AppCompatActivity() { 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) { + } catch (_: Exception) { "(unreadable pin)" } } diff --git a/app/src/main/java/invalid/lena/rsend/Rsync.kt b/app/src/main/java/invalid/lena/rsend/Rsync.kt index 5fc73c8..93ce623 100644 --- a/app/src/main/java/invalid/lena/rsend/Rsync.kt +++ b/app/src/main/java/invalid/lena/rsend/Rsync.kt @@ -25,7 +25,7 @@ object Rsync { // 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) + data class Outcome(val code: Int, val unreachable: Boolean, val deleted: Long) // 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, diff --git a/app/src/main/java/invalid/lena/rsend/SyncLog.kt b/app/src/main/java/invalid/lena/rsend/SyncLog.kt index f0db905..64de544 100644 --- a/app/src/main/java/invalid/lena/rsend/SyncLog.kt +++ b/app/src/main/java/invalid/lena/rsend/SyncLog.kt @@ -12,7 +12,7 @@ import java.util.Locale // what the user reads to see what rsend did and to debug failures. class SyncLog(ctx: Context) { - val file: File = File(ctx.filesDir, "sync.log") + private 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) @@ -25,14 +25,6 @@ class SyncLog(ctx: Context) { } } - // 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() { - synchronized(lock) { - if (file.exists() && file.length() >= MAX_BYTES) rotate() - } - } - // 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. @@ -51,8 +43,7 @@ class SyncLog(ctx: Context) { 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 old = tail(previous, DISPLAY_BYTES - current.bytes.size) val omitted = current.omitted || old.omitted val bytes = old.bytes + current.bytes val text = String(skipUtf8Continuation(bytes), Charsets.UTF_8) diff --git a/app/src/main/java/invalid/lena/rsend/SyncWorker.kt b/app/src/main/java/invalid/lena/rsend/SyncWorker.kt index 4f619dc..953c042 100644 --- a/app/src/main/java/invalid/lena/rsend/SyncWorker.kt +++ b/app/src/main/java/invalid/lena/rsend/SyncWorker.kt @@ -27,6 +27,12 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, const val KEY_INDEX = "i" const val KEY_TOTAL = "n" + // Distinct ids, so an outcome notification never replaces the ongoing + // foreground one. + private const val ONGOING_ID = 1 + private const val FAILED_ID = 2 + private const val DELETED_ID = 3 + private val syncMutex = Mutex() } @@ -50,7 +56,6 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, } 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 @@ -131,9 +136,12 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, val summary = if (deleted > 0) " ($deleted deleted)" else "" LastSync.set(ctx, if (ok) "ok$summary" else "FAILED: ${failed.joinToString(", ")}$summary") if (!ok) { - notifyError(ctx) + notify(ctx, FAILED_ID, "Sync failed. Open the app and check the log.") } else if (deleted > 0) { - notifyDeleted(ctx, deleted) + // Mirror semantics make deleting 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. + notify(ctx, DELETED_ID, "Sync ok. Mirror deleted $deleted item(s) on the server.") } if (ok) Result.success() else Result.failure() } @@ -151,35 +159,17 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, } } - private fun notifyError(ctx: Context) { - val n = NotificationCompat.Builder(ctx, App.CHANNEL) - .setContentTitle("rsend") - .setContentText("Sync failed. Open the app and check the log.") - .setSmallIcon(R.drawable.ic_notification) - .setAutoCancel(true) - .build() - try { - NotificationManagerCompat.from(ctx).notify(2, n) - } catch (_: SecurityException) { - // POST_NOTIFICATIONS not granted; the log still records the failure. - } - } - - // 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) { + private fun notify(ctx: Context, id: Int, text: String) { val n = NotificationCompat.Builder(ctx, App.CHANNEL) .setContentTitle("rsend") - .setContentText("Sync ok. Mirror deleted $deleted item(s) on the server.") + .setContentText(text) .setSmallIcon(R.drawable.ic_notification) .setAutoCancel(true) .build() try { - NotificationManagerCompat.from(ctx).notify(3, n) + NotificationManagerCompat.from(ctx).notify(id, n) } catch (_: SecurityException) { - // POST_NOTIFICATIONS not granted; the log still records the count. + // POST_NOTIFICATIONS not granted; the log still records the outcome. } } @@ -188,7 +178,7 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, private fun refuse(ctx: Context, why: String): Result { SyncLog(ctx).line("sync skipped: $why") LastSync.set(ctx, "SKIPPED: $why") - notifyError(ctx) + notify(ctx, FAILED_ID, "Sync failed. Open the app and check the log.") return Result.failure() } @@ -206,6 +196,6 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, .setSmallIcon(R.drawable.ic_notification) .setOngoing(true) .build() - return ForegroundInfo(1, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + return ForegroundInfo(ONGOING_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) } } |