aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java/invalid/lena
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-23 00:00:00 +0000
committerLena <lena@omega>2026-08-23 00:00:00 +0000
commit0c653418a67fe960bb3126559fe77833faaa385c (patch)
tree6db0f168c62ec29d3f5ccb64845d781b2fa80e79 /app/src/main/java/invalid/lena
parentdc0338f8c0c9e74b277169d812c432f7ea4888e3 (diff)
downloadrsend-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.kt147
-rw-r--r--app/src/main/java/invalid/lena/rsend/Files.kt39
-rw-r--r--app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt7
-rw-r--r--app/src/main/java/invalid/lena/rsend/FolderPickerActivity.kt3
-rw-r--r--app/src/main/java/invalid/lena/rsend/Keys.kt32
-rw-r--r--app/src/main/java/invalid/lena/rsend/LastSync.kt12
-rw-r--r--app/src/main/java/invalid/lena/rsend/MainActivity.kt110
-rw-r--r--app/src/main/java/invalid/lena/rsend/RemoteActivity.kt8
-rw-r--r--app/src/main/java/invalid/lena/rsend/Rsync.kt2
-rw-r--r--app/src/main/java/invalid/lena/rsend/SyncLog.kt13
-rw-r--r--app/src/main/java/invalid/lena/rsend/SyncWorker.kt44
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)
}
}