aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java')
-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)
}
}