diff options
Diffstat (limited to 'app/src/main')
| -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) } } |