aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java/invalid
diff options
context:
space:
mode:
authorLena <lena@omega>2026-01-01 00:00:00 +0000
committerLena <lena@omega>2026-01-01 00:00:00 +0000
commit7e04941bccb2683f8a6e3ee38a99c50129234dd1 (patch)
tree471227fa437291e7a6b499e3de6c106c54eaf311 /app/src/main/java/invalid
downloadrsend-7e04941bccb2683f8a6e3ee38a99c50129234dd1.tar.gz
rsend: push phone folders to a home SSH host over rsync
A small Android app for one-way folder backup, a KISS alternative to Syncthing. It bundles rsync (built from pinned source via the NDK) and a pure-Go SSH transport, both shipped in the APK as lib*.so and run from the native library directory. rsend pins the host key, stores the ed25519 identity Keystore-encrypted, pushes each folder additively or as a mirror, and runs on demand or on a WiFi-only schedule. The build is self-contained and reproducible: make setup provisions the toolchain, make builds rsync, rsh, and the APK.
Diffstat (limited to 'app/src/main/java/invalid')
-rw-r--r--app/src/main/java/invalid/lena/rsend/App.kt22
-rw-r--r--app/src/main/java/invalid/lena/rsend/BootReceiver.kt15
-rw-r--r--app/src/main/java/invalid/lena/rsend/Config.kt107
-rw-r--r--app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt77
-rw-r--r--app/src/main/java/invalid/lena/rsend/Insets.kt34
-rw-r--r--app/src/main/java/invalid/lena/rsend/KeyVault.kt48
-rw-r--r--app/src/main/java/invalid/lena/rsend/Keys.kt75
-rw-r--r--app/src/main/java/invalid/lena/rsend/LastSync.kt21
-rw-r--r--app/src/main/java/invalid/lena/rsend/LogActivity.kt35
-rw-r--r--app/src/main/java/invalid/lena/rsend/MainActivity.kt338
-rw-r--r--app/src/main/java/invalid/lena/rsend/Native.kt26
-rw-r--r--app/src/main/java/invalid/lena/rsend/RemoteActivity.kt82
-rw-r--r--app/src/main/java/invalid/lena/rsend/RsyncRunner.kt47
-rw-r--r--app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt51
-rw-r--r--app/src/main/java/invalid/lena/rsend/Scheduler.kt71
-rw-r--r--app/src/main/java/invalid/lena/rsend/SyncLog.kt26
-rw-r--r--app/src/main/java/invalid/lena/rsend/SyncWorker.kt103
17 files changed, 1178 insertions, 0 deletions
diff --git a/app/src/main/java/invalid/lena/rsend/App.kt b/app/src/main/java/invalid/lena/rsend/App.kt
new file mode 100644
index 0000000..55ed66e
--- /dev/null
+++ b/app/src/main/java/invalid/lena/rsend/App.kt
@@ -0,0 +1,22 @@
+package invalid.lena.rsend
+
+import android.app.Application
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.os.Build
+
+// App creates the notification channel used by the foreground sync service.
+class App : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+ if (Build.VERSION.SDK_INT >= 26) {
+ val ch = NotificationChannel(CHANNEL, "Sync", NotificationManager.IMPORTANCE_LOW)
+ getSystemService(NotificationManager::class.java).createNotificationChannel(ch)
+ }
+ }
+
+ companion object {
+ const val CHANNEL = "sync"
+ }
+}
diff --git a/app/src/main/java/invalid/lena/rsend/BootReceiver.kt b/app/src/main/java/invalid/lena/rsend/BootReceiver.kt
new file mode 100644
index 0000000..3e135f3
--- /dev/null
+++ b/app/src/main/java/invalid/lena/rsend/BootReceiver.kt
@@ -0,0 +1,15 @@
+package invalid.lena.rsend
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+
+// BootReceiver re-applies the schedule after the device restarts, so periodic
+// sync survives reboots even if WorkManager's own state was cleared.
+class BootReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
+ Scheduler.apply(context)
+ }
+ }
+}
diff --git a/app/src/main/java/invalid/lena/rsend/Config.kt b/app/src/main/java/invalid/lena/rsend/Config.kt
new file mode 100644
index 0000000..8c9c22c
--- /dev/null
+++ b/app/src/main/java/invalid/lena/rsend/Config.kt
@@ -0,0 +1,107 @@
+package invalid.lena.rsend
+
+import android.content.Context
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+
+// Config is rsend's whole state: the remote target, the schedule, and the
+// folders to push. It is stored as plain JSON in app-private storage.
+
+data class Remote(val host: String = "", val port: Int = 22, val user: String = "")
+
+data class Schedule(
+ val enabled: Boolean = false,
+ val intervalMinutes: Int = 120,
+ val wifiOnly: Boolean = true,
+ val requireCharging: Boolean = false,
+)
+
+data class Folder(
+ val name: String = "",
+ val local: String = "",
+ val remote: String = "",
+ val delete: Boolean = false,
+ val excludes: List<String> = emptyList(),
+)
+
+data class Config(
+ val remote: Remote = Remote(),
+ val schedule: Schedule = Schedule(),
+ val folders: List<Folder> = emptyList(),
+) {
+ fun toJson(): JSONObject = JSONObject().apply {
+ put("remote", JSONObject().apply {
+ put("host", remote.host)
+ put("port", remote.port)
+ put("user", remote.user)
+ })
+ put("schedule", JSONObject().apply {
+ put("enabled", schedule.enabled)
+ put("intervalMinutes", schedule.intervalMinutes)
+ put("wifiOnly", schedule.wifiOnly)
+ put("requireCharging", schedule.requireCharging)
+ })
+ put("folders", JSONArray().apply {
+ folders.forEach { f ->
+ put(JSONObject().apply {
+ put("name", f.name)
+ put("local", f.local)
+ put("remote", f.remote)
+ put("delete", f.delete)
+ put("excludes", JSONArray(f.excludes))
+ })
+ }
+ })
+ }
+
+ companion object {
+ fun file(ctx: Context): File = File(ctx.filesDir, "config.json")
+
+ fun load(ctx: Context): Config {
+ val f = file(ctx)
+ if (!f.exists()) return Config()
+ return fromJson(JSONObject(f.readText()))
+ }
+
+ fun save(ctx: Context, c: Config) {
+ file(ctx).writeText(c.toJson().toString(2))
+ }
+
+ fun fromJson(o: JSONObject): Config {
+ val r = o.optJSONObject("remote") ?: JSONObject()
+ val s = o.optJSONObject("schedule") ?: JSONObject()
+ val fa = o.optJSONArray("folders") ?: JSONArray()
+ val folders = ArrayList<Folder>(fa.length())
+ for (i in 0 until fa.length()) {
+ val fo = fa.getJSONObject(i)
+ val ex = fo.optJSONArray("excludes") ?: JSONArray()
+ val excludes = ArrayList<String>(ex.length())
+ for (j in 0 until ex.length()) excludes.add(ex.getString(j))
+ folders.add(
+ Folder(
+ name = fo.optString("name"),
+ local = fo.optString("local"),
+ remote = fo.optString("remote"),
+ delete = fo.optBoolean("delete", false),
+ excludes = excludes,
+ )
+ )
+ }
+ return Config(
+ remote = Remote(
+ host = r.optString("host"),
+ port = r.optInt("port", 22),
+ user = r.optString("user"),
+ ),
+ schedule = Schedule(
+ enabled = s.optBoolean("enabled", false),
+ intervalMinutes = s.optInt("intervalMinutes", 120),
+ wifiOnly = s.optBoolean("wifiOnly", true),
+ requireCharging = s.optBoolean("requireCharging", false),
+ ),
+ folders = folders,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt b/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt
new file mode 100644
index 0000000..2fe2b1b
--- /dev/null
+++ b/app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt
@@ -0,0 +1,77 @@
+package invalid.lena.rsend
+
+import android.os.Bundle
+import android.view.View
+import android.widget.Button
+import android.widget.EditText
+import android.widget.Switch
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import java.io.File
+
+// FolderEditActivity adds or edits one folder mapping. index < 0 means a new
+// folder.
+class FolderEditActivity : AppCompatActivity() {
+
+ private var index: Int = -1
+ private lateinit var name: EditText
+ private lateinit var local: EditText
+ private lateinit var remote: EditText
+ private lateinit var excludes: EditText
+ private lateinit var delete: Switch
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_folder)
+ setSupportActionBar(findViewById(R.id.toolbar))
+ title = "Folder"
+ fitSystemBars()
+ name = findViewById(R.id.name)
+ local = findViewById(R.id.local)
+ remote = findViewById(R.id.remote)
+ excludes = findViewById(R.id.excludes)
+ delete = findViewById(R.id.delete)
+
+ index = intent.getIntExtra("index", -1)
+ val cfg = Config.load(this)
+ if (index in cfg.folders.indices) {
+ val f = cfg.folders[index]
+ name.setText(f.name)
+ local.setText(f.local)
+ remote.setText(f.remote)
+ excludes.setText(f.excludes.joinToString(", "))
+ delete.isChecked = f.delete
+ }
+
+ findViewById<Button>(R.id.save).setOnClickListener { saveFolder(); finish() }
+ val del = findViewById<Button>(R.id.removeFolder)
+ del.visibility = if (index < 0) View.GONE else View.VISIBLE
+ del.setOnClickListener { removeFolder(); finish() }
+ }
+
+ private fun saveFolder() {
+ val f = Folder(
+ name = name.text.toString().trim(),
+ local = local.text.toString().trim(),
+ remote = remote.text.toString().trim(),
+ delete = delete.isChecked,
+ excludes = excludes.text.toString().split(",").map { it.trim() }.filter { it.isNotEmpty() },
+ )
+ if (f.local.isNotEmpty() && !File(f.local).exists()) {
+ Toast.makeText(this, "Warning: local path does not exist yet.", Toast.LENGTH_LONG).show()
+ }
+ val cfg = Config.load(this)
+ val list = cfg.folders.toMutableList()
+ if (index in list.indices) list[index] = f else list.add(f)
+ Config.save(this, cfg.copy(folders = list))
+ }
+
+ private fun removeFolder() {
+ val cfg = Config.load(this)
+ val list = cfg.folders.toMutableList()
+ if (index in list.indices) {
+ list.removeAt(index)
+ Config.save(this, cfg.copy(folders = list))
+ }
+ }
+}
diff --git a/app/src/main/java/invalid/lena/rsend/Insets.kt b/app/src/main/java/invalid/lena/rsend/Insets.kt
new file mode 100644
index 0000000..e72b5a7
--- /dev/null
+++ b/app/src/main/java/invalid/lena/rsend/Insets.kt
@@ -0,0 +1,34 @@
+package invalid.lena.rsend
+
+import android.content.res.Configuration
+import android.os.Build
+import android.view.View
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+
+// Android 15 (target SDK 35) forces the window edge-to-edge: content otherwise
+// draws behind the status and navigation bars. Pad the screen root (the toolbar
+// plus the scroll content) by the system-bar insets so the toolbar sits below
+// the status bar and the content stays clear of the navigation bar and keyboard.
+// If the decor already insets the content the listener simply sees zero insets,
+// so this is a no-op rather than double padding. Call once after setContentView.
+fun AppCompatActivity.fitSystemBars() {
+ val root = findViewById<View>(R.id.appRoot)
+ ViewCompat.setOnApplyWindowInsetsListener(root) { v, insets ->
+ val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
+ v.setPadding(bars.left, bars.top, bars.right, maxOf(bars.bottom, ime.bottom))
+ WindowInsetsCompat.CONSUMED
+ }
+ // The bars are now transparent and show the window background instead of the
+ // old solid bar colour, so match the icon contrast to day/night.
+ if (Build.VERSION.SDK_INT >= 35) {
+ val night = resources.configuration.uiMode and
+ Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
+ val controller = WindowCompat.getInsetsController(window, window.decorView)
+ controller.isAppearanceLightStatusBars = !night
+ controller.isAppearanceLightNavigationBars = !night
+ }
+}
diff --git a/app/src/main/java/invalid/lena/rsend/KeyVault.kt b/app/src/main/java/invalid/lena/rsend/KeyVault.kt
new file mode 100644
index 0000000..ae3e8d4
--- /dev/null
+++ b/app/src/main/java/invalid/lena/rsend/KeyVault.kt
@@ -0,0 +1,48 @@
+package invalid.lena.rsend
+
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+// KeyVault encrypts the SSH private key at rest with a hardware-backed AES-GCM
+// key in the Android Keystore. The Keystore key cannot be exported, so the blob
+// on disk is useless off this device. Blob layout is IV || ciphertext+tag.
+object KeyVault {
+
+ private const val ALIAS = "rsend-key"
+ private const val TRANSFORM = "AES/GCM/NoPadding"
+ private const val IV_LEN = 12
+ private const val TAG_BITS = 128
+
+ private fun secret(): SecretKey {
+ val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
+ (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
+ val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
+ gen.init(
+ KeyGenParameterSpec.Builder(
+ ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .build(),
+ )
+ return gen.generateKey()
+ }
+
+ fun encrypt(plain: ByteArray): ByteArray {
+ val c = Cipher.getInstance(TRANSFORM)
+ c.init(Cipher.ENCRYPT_MODE, secret())
+ return c.iv + c.doFinal(plain)
+ }
+
+ fun decrypt(blob: ByteArray): ByteArray {
+ val c = Cipher.getInstance(TRANSFORM)
+ c.init(Cipher.DECRYPT_MODE, secret(), GCMParameterSpec(TAG_BITS, blob, 0, IV_LEN))
+ return c.doFinal(blob, IV_LEN, blob.size - IV_LEN)
+ }
+}
diff --git a/app/src/main/java/invalid/lena/rsend/Keys.kt b/app/src/main/java/invalid/lena/rsend/Keys.kt
new file mode 100644
index 0000000..0a5384d
--- /dev/null
+++ b/app/src/main/java/invalid/lena/rsend/Keys.kt
@@ -0,0 +1,75 @@
+package invalid.lena.rsend
+
+import android.content.Context
+import java.io.File
+
+// Keys manages the on-device ed25519 identity and the pinned host keys. The
+// private key is stored Keystore-encrypted (KeyVault) and only ever exists in
+// plaintext in memory, passed to rsh through RSH_KEY_DATA.
+object Keys {
+
+ private fun keyEnc(ctx: Context): File = File(ctx.filesDir, "id_ed25519.enc")
+ private fun publicKey(ctx: Context): File = File(ctx.filesDir, "id_ed25519.pub")
+
+ fun knownHosts(ctx: Context): File = File(ctx.filesDir, "known_hosts")
+
+ fun exists(ctx: Context): Boolean = keyEnc(ctx).exists()
+
+ // generate creates the key pair, stores the private key encrypted, and
+ // returns the public key in authorized_keys format.
+ fun generate(ctx: Context): String {
+ val tmp = File(ctx.cacheDir, "keygen").apply { mkdirs() }
+ Native.run(Native.rsh(ctx), listOf("-keygen", tmp.absolutePath))
+ keyEnc(ctx).writeBytes(KeyVault.encrypt(File(tmp, "id_ed25519").readBytes()))
+ File(tmp, "id_ed25519.pub").copyTo(publicKey(ctx), overwrite = true)
+ tmp.deleteRecursively()
+ return publicKeyText(ctx)
+ }
+
+ // importKey stores a user-supplied private key, replacing any current one.
+ // The bundled rsh derives and validates the public key (-pubkey uses the
+ // same parser as the transport), so a key that imports here will also sync.
+ // Throws IllegalArgumentException with rsh's message if the key is unusable
+ // (wrong type, or passphrase-protected: rsend needs an unencrypted key).
+ fun importKey(ctx: Context, pem: ByteArray) {
+ val r = Native.run(Native.rsh(ctx), listOf("-pubkey"), mapOf("RSH_KEY_DATA" to String(pem)))
+ if (r.code != 0) throw IllegalArgumentException(r.output.trim().ifEmpty { "invalid private key" })
+ val pub = r.output.trim()
+ keyEnc(ctx).writeBytes(KeyVault.encrypt(pem))
+ publicKey(ctx).writeText(pub + "\n")
+ }
+
+ fun publicKeyText(ctx: Context): String =
+ if (publicKey(ctx).exists()) publicKey(ctx).readText().trim() else ""
+
+ // env returns the RSH_* environment rsh needs. The decrypted key is passed
+ // by value so it never touches the filesystem.
+ fun env(ctx: Context, port: Int): Map<String, String> = mapOf(
+ "RSH_KEY_DATA" to String(KeyVault.decrypt(keyEnc(ctx).readBytes())),
+ "RSH_KNOWN_HOSTS" to knownHosts(ctx).absolutePath,
+ "RSH_PORT" to port.toString(),
+ )
+
+ data class Scan(val ok: Boolean, val fingerprint: String, val line: String, val error: String)
+
+ // scan connects (proving the key is installed), captures the host key, and
+ // returns its fingerprint and the known_hosts line to pin. pin() writes it
+ // once the user accepts.
+ fun scan(ctx: Context, remote: Remote): Scan {
+ val r = Native.run(
+ Native.rsh(ctx),
+ listOf("-scan", "${remote.user}@${remote.host}"),
+ env(ctx, remote.port),
+ )
+ if (r.code != 0) return Scan(false, "", "", r.output.trim())
+ val lines = r.output.trim().lines()
+ if (lines.size < 2) return Scan(false, "", "", "unexpected scan output:\n${r.output}")
+ return Scan(true, lines[0], lines[1], "")
+ }
+
+ fun pin(ctx: Context, line: String) {
+ knownHosts(ctx).writeText(line + "\n")
+ }
+
+ fun pinned(ctx: Context): Boolean = knownHosts(ctx).let { it.exists() && it.length() > 0 }
+}
diff --git a/app/src/main/java/invalid/lena/rsend/LastSync.kt b/app/src/main/java/invalid/lena/rsend/LastSync.kt
new file mode 100644
index 0000000..3f1d518
--- /dev/null
+++ b/app/src/main/java/invalid/lena/rsend/LastSync.kt
@@ -0,0 +1,21 @@
+package invalid.lena.rsend
+
+import android.content.Context
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+// LastSync records the outcome of the most recent run so the dashboard can show
+// it without opening the log.
+object LastSync {
+
+ private fun file(ctx: Context): File = File(ctx.filesDir, "last-sync")
+ private val fmt = SimpleDateFormat("MM-dd HH:mm", Locale.US)
+
+ fun set(ctx: Context, ok: Boolean) {
+ file(ctx).writeText("${fmt.format(Date())} ${if (ok) "ok" else "FAILED"}")
+ }
+
+ fun get(ctx: Context): String = if (file(ctx).exists()) file(ctx).readText() else "never"
+}
diff --git a/app/src/main/java/invalid/lena/rsend/LogActivity.kt b/app/src/main/java/invalid/lena/rsend/LogActivity.kt
new file mode 100644
index 0000000..c9cce94
--- /dev/null
+++ b/app/src/main/java/invalid/lena/rsend/LogActivity.kt
@@ -0,0 +1,35 @@
+package invalid.lena.rsend
+
+import android.os.Bundle
+import android.widget.Button
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+
+// LogActivity shows the plain-text sync log with refresh and clear.
+class LogActivity : AppCompatActivity() {
+
+ private lateinit var log: TextView
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_log)
+ setSupportActionBar(findViewById(R.id.toolbar))
+ title = "Log"
+ fitSystemBars()
+ log = findViewById(R.id.log)
+ findViewById<Button>(R.id.refresh).setOnClickListener { load() }
+ findViewById<Button>(R.id.clear).setOnClickListener {
+ SyncLog(this).file.writeText("")
+ load()
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ load()
+ }
+
+ private fun load() {
+ log.text = SyncLog(this).text().ifEmpty { "No log yet." }
+ }
+}
diff --git a/app/src/main/java/invalid/lena/rsend/MainActivity.kt b/app/src/main/java/invalid/lena/rsend/MainActivity.kt
new file mode 100644
index 0000000..6b51252
--- /dev/null
+++ b/app/src/main/java/invalid/lena/rsend/MainActivity.kt
@@ -0,0 +1,338 @@
+package invalid.lena.rsend
+
+import android.Manifest
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.os.Environment
+import android.os.PowerManager
+import android.provider.Settings
+import android.view.View
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.ProgressBar
+import android.widget.TextView
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatActivity
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import java.util.concurrent.TimeUnit
+import kotlin.concurrent.thread
+
+// MainActivity is the dashboard: a status hero, a primary Sync action, grouped
+// Setup and Permissions rows, and the list of folders. Editing happens in
+// RemoteActivity, ScheduleActivity, and FolderEditActivity.
+class MainActivity : AppCompatActivity() {
+
+ private lateinit var lastSync: TextView
+ private lateinit var remoteValue: TextView
+ private lateinit var keyValue: TextView
+ private lateinit var scheduleValue: TextView
+ private lateinit var dotAllFiles: View
+ private lateinit var valAllFiles: TextView
+ private lateinit var dotNotif: View
+ private lateinit var valNotif: TextView
+ private lateinit var dotBattery: View
+ private lateinit var valBattery: TextView
+ private lateinit var folders: LinearLayout
+ private lateinit var btnSync: Button
+ private lateinit var syncBar: ProgressBar
+ private lateinit var syncStatus: TextView
+
+ private val notifPerm =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { refresh() }
+
+ private val openKeyDoc =
+ registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri != null) importKeyFrom(uri)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_main)
+ setSupportActionBar(findViewById(R.id.toolbar))
+ fitSystemBars()
+ lastSync = findViewById(R.id.lastSync)
+ remoteValue = findViewById(R.id.remoteValue)
+ keyValue = findViewById(R.id.keyValue)
+ scheduleValue = findViewById(R.id.scheduleValue)
+ dotAllFiles = findViewById(R.id.dotAllFiles)
+ valAllFiles = findViewById(R.id.valAllFiles)
+ dotNotif = findViewById(R.id.dotNotif)
+ valNotif = findViewById(R.id.valNotif)
+ dotBattery = findViewById(R.id.dotBattery)
+ valBattery = findViewById(R.id.valBattery)
+ folders = findViewById(R.id.folders)
+ btnSync = findViewById(R.id.btnSync)
+ syncBar = findViewById(R.id.syncBar)
+ syncStatus = findViewById(R.id.syncStatus)
+
+ findViewById<TextView>(R.id.appVersion).text = "v${BuildConfig.VERSION_NAME}"
+
+ btnSync.setOnClickListener { syncNow() }
+ observeSync()
+ findViewById<LinearLayout>(R.id.rowRemote).setOnClickListener {
+ startActivity(Intent(this, RemoteActivity::class.java))
+ }
+ findViewById<LinearLayout>(R.id.rowKey).setOnClickListener { showKey() }
+ findViewById<LinearLayout>(R.id.rowSchedule).setOnClickListener {
+ startActivity(Intent(this, ScheduleActivity::class.java))
+ }
+ findViewById<LinearLayout>(R.id.rowAllFiles).setOnClickListener {
+ startActivity(
+ Intent(
+ Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
+ Uri.parse("package:$packageName"),
+ )
+ )
+ }
+ findViewById<LinearLayout>(R.id.rowNotif).setOnClickListener {
+ if (Build.VERSION.SDK_INT >= 33 && !notif()) {
+ notifPerm.launch(Manifest.permission.POST_NOTIFICATIONS)
+ } else {
+ // No runtime permission to request (pre-13) or already granted:
+ // open the app's notification settings so the row still acts.
+ startActivity(
+ Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
+ .putExtra(Settings.EXTRA_APP_PACKAGE, packageName),
+ )
+ }
+ }
+ findViewById<LinearLayout>(R.id.rowBattery).setOnClickListener {
+ if (batteryUnrestricted()) {
+ startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
+ } else {
+ startActivity(
+ Intent(
+ Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
+ Uri.parse("package:$packageName"),
+ ),
+ )
+ }
+ }
+ findViewById<Button>(R.id.btnAddFolder).setOnClickListener {
+ startActivity(Intent(this, FolderEditActivity::class.java).putExtra("index", -1))
+ }
+ findViewById<Button>(R.id.btnLog).setOnClickListener {
+ startActivity(Intent(this, LogActivity::class.java))
+ }
+ }
+
+ private fun syncNow() {
+ if (!Keys.pinned(this)) {
+ Toast.makeText(this, "Set the remote and pin its host key first.", Toast.LENGTH_LONG).show()
+ return
+ }
+ val req = OneTimeWorkRequestBuilder<SyncWorker>().build()
+ WorkManager.getInstance(this).enqueueUniqueWork("sync-now", ExistingWorkPolicy.KEEP, req)
+ Toast.makeText(this, "Sync started.", Toast.LENGTH_SHORT).show()
+ }
+
+ // observeSync follows the manual sync's WorkInfo so the dashboard updates
+ // live: an indeterminate bar and the current folder while it runs, and a
+ // fresh status (last sync) the moment it ends, without leaving the screen.
+ private fun observeSync() {
+ WorkManager.getInstance(this)
+ .getWorkInfosForUniqueWorkLiveData("sync-now")
+ .observe(this) { infos ->
+ val info = infos.lastOrNull()
+ val running = info != null &&
+ (info.state == WorkInfo.State.RUNNING || info.state == WorkInfo.State.ENQUEUED)
+ if (running) {
+ syncBar.visibility = View.VISIBLE
+ syncStatus.visibility = View.VISIBLE
+ btnSync.isEnabled = false
+ btnSync.text = "Syncing..."
+ val p = info!!.progress
+ val folder = p.getString(SyncWorker.KEY_FOLDER)
+ val i = p.getInt(SyncWorker.KEY_INDEX, 0)
+ val n = p.getInt(SyncWorker.KEY_TOTAL, 0)
+ syncStatus.text =
+ if (folder != null && n > 0) "Syncing $folder ($i/$n)" else "Starting sync..."
+ } else {
+ syncBar.visibility = View.GONE
+ syncStatus.visibility = View.GONE
+ btnSync.isEnabled = true
+ btnSync.text = "Sync now"
+ refresh()
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ Scheduler.apply(this)
+ refresh()
+ }
+
+ private fun allFiles() = Environment.isExternalStorageManager()
+
+ private fun notif() = Build.VERSION.SDK_INT < 33 ||
+ checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
+
+ private fun batteryUnrestricted(): Boolean =
+ getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(packageName)
+
+ // jobState reports the WorkManager state of the scheduled sync (ENQUEUED,
+ // RUNNING, none, ...) so the dashboard shows whether the scheduler is armed.
+ private fun jobState(): String = try {
+ WorkManager.getInstance(this)
+ .getWorkInfosForUniqueWork(Scheduler.NAME)
+ .get(500, TimeUnit.MILLISECONDS)
+ .firstOrNull()?.state?.name ?: "none"
+ } catch (e: Exception) {
+ "?"
+ }
+
+ private fun refresh() {
+ val cfg = Config.load(this)
+
+ lastSync.text = "Last sync: ${LastSync.get(this)}"
+
+ val r = cfg.remote
+ remoteValue.text = if (r.host.isEmpty()) {
+ "Not set"
+ } else {
+ "${r.user}@${r.host}:${r.port}, ${if (Keys.pinned(this)) "pinned" else "not pinned"}"
+ }
+
+ keyValue.text = if (Keys.exists(this)) "Generated, tap to view" else "Not created, tap to create"
+
+ val s = cfg.schedule
+ val sched = if (s.enabled) {
+ "Every ${s.intervalMinutes}m" +
+ (if (s.wifiOnly) ", wifi" else "") +
+ (if (s.requireCharging) ", charging" else "")
+ } else {
+ "Off"
+ }
+ scheduleValue.text = "$sched, job ${jobState()}"
+
+ setPerm(dotAllFiles, valAllFiles, allFiles(), "Granted", "Tap to grant")
+ setPerm(dotNotif, valNotif, notif(), "Granted", "Tap to grant")
+ setPerm(dotBattery, valBattery, batteryUnrestricted(), "Unrestricted", "Optimized, tap to fix")
+
+ folders.removeAllViews()
+ if (cfg.folders.isEmpty()) {
+ folders.addView(TextView(this).apply {
+ text = "No folders yet. Tap Add folder below."
+ setTextAppearance(R.style.TextAppearance_Rsend_Caption)
+ setPadding(0, 0, 0, resources.getDimensionPixelSize(R.dimen.space_m))
+ })
+ } else {
+ cfg.folders.forEachIndexed { i, f ->
+ val row = layoutInflater.inflate(R.layout.item_folder, folders, false)
+ row.findViewById<TextView>(R.id.folderName).text = f.name.ifEmpty { f.local }
+ row.findViewById<TextView>(R.id.folderPath).text = "${f.local} -> ${f.remote}"
+ row.findViewById<TextView>(R.id.folderChip).text = if (f.delete) "mirror" else "add"
+ row.setOnClickListener {
+ startActivity(
+ Intent(this@MainActivity, FolderEditActivity::class.java)
+ .putExtra("index", i)
+ )
+ }
+ folders.addView(row)
+ }
+ }
+ }
+
+ private fun setPerm(dot: View, value: TextView, ok: Boolean, okText: String, badText: String) {
+ dot.setBackgroundResource(if (ok) R.drawable.dot_on else R.drawable.dot_off)
+ value.text = if (ok) okText else badText
+ }
+
+ // showKey displays the public key (only the key) and offers to copy it,
+ // generate a fresh key, or import the user's own private key.
+ private fun showKey() {
+ val view = layoutInflater.inflate(R.layout.dialog_key, null)
+ val keyText = view.findViewById<TextView>(R.id.keyText)
+ val pub = Keys.publicKeyText(this)
+ keyText.text = pub.ifEmpty { "No key yet. Generate or import one below." }
+
+ val dialog = AlertDialog.Builder(this)
+ .setTitle("Identity key")
+ .setView(view)
+ .setNegativeButton("Close", null)
+ .create()
+
+ val copy = view.findViewById<Button>(R.id.btnCopyKey)
+ copy.isEnabled = pub.isNotEmpty()
+ copy.setOnClickListener {
+ val cm = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ cm.setPrimaryClip(ClipData.newPlainText("rsend pubkey", pub))
+ Toast.makeText(this, "Copied", Toast.LENGTH_SHORT).show()
+ }
+ view.findViewById<Button>(R.id.btnGenKey).setOnClickListener {
+ dialog.dismiss()
+ confirmGenerate()
+ }
+ view.findViewById<Button>(R.id.btnImportKey).setOnClickListener {
+ dialog.dismiss()
+ openKeyDoc.launch(arrayOf("*/*"))
+ }
+ dialog.show()
+ }
+
+ // confirmGenerate warns before replacing an existing key, then generates a
+ // new one off the main thread and shows it so the user can copy it.
+ private fun confirmGenerate() {
+ 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."
+ } else {
+ "Generate a new identity key?"
+ }
+ AlertDialog.Builder(this)
+ .setTitle("Generate new key")
+ .setMessage(msg)
+ .setPositiveButton("Generate") { _, _ ->
+ thread {
+ Keys.generate(this)
+ runOnUiThread {
+ refresh()
+ Toast.makeText(this, "New key generated.", Toast.LENGTH_SHORT).show()
+ showKey()
+ }
+ }
+ }
+ .setNegativeButton("Cancel", null)
+ .show()
+ }
+
+ // importKeyFrom reads the chosen private-key file and stores it, replacing
+ // the current key. rsh validates the key first; an unusable key (wrong type
+ // or passphrase-protected) is reported rather than saved.
+ private fun importKeyFrom(uri: Uri) {
+ thread {
+ val error = try {
+ val data = contentResolver.openInputStream(uri)?.use { it.readBytes() }
+ ?: throw IllegalArgumentException("could not read the file")
+ Keys.importKey(this, data)
+ null
+ } catch (e: Exception) {
+ e.message ?: "import failed"
+ }
+ 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()
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/invalid/lena/rsend/Native.kt b/app/src/main/java/invalid/lena/rsend/Native.kt
new file mode 100644
index 0000000..ec02cfe
--- /dev/null
+++ b/app/src/main/java/invalid/lena/rsend/Native.kt
@@ -0,0 +1,26 @@
+package invalid.lena.rsend
+
+import android.content.Context
+import java.io.File
+
+// Native locates and runs the executables shipped inside the APK as lib*.so.
+// Android only allows exec of native code from the app's native library dir,
+// so the rsync and rsh binaries are packaged there and run from there.
+object Native {
+
+ fun rsync(ctx: Context): File = File(ctx.applicationInfo.nativeLibraryDir, "libxrsync.so")
+
+ fun rsh(ctx: Context): File = File(ctx.applicationInfo.nativeLibraryDir, "libxrsh.so")
+
+ data class Result(val code: Int, val output: String)
+
+ // Blocking; call off the main thread. stderr is merged into stdout.
+ fun run(bin: File, args: List<String>, env: Map<String, String> = emptyMap()): Result {
+ val pb = ProcessBuilder(listOf(bin.absolutePath) + args).redirectErrorStream(true)
+ pb.environment().putAll(env)
+ val p = pb.start()
+ val out = p.inputStream.bufferedReader().use { it.readText() }
+ val code = p.waitFor()
+ return Result(code, out)
+ }
+}
diff --git a/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt
new file mode 100644
index 0000000..a587667
--- /dev/null
+++ b/app/src/main/java/invalid/lena/rsend/RemoteActivity.kt
@@ -0,0 +1,82 @@
+package invalid.lena.rsend
+
+import android.os.Bundle
+import android.widget.Button
+import android.widget.EditText
+import android.widget.TextView
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatActivity
+import kotlin.concurrent.thread
+
+// RemoteActivity edits the SSH target and runs Test connection, which scans the
+// host key and pins it on the user's confirmation.
+class RemoteActivity : AppCompatActivity() {
+
+ private lateinit var host: EditText
+ private lateinit var port: EditText
+ private lateinit var user: EditText
+ private lateinit var status: TextView
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_remote)
+ setSupportActionBar(findViewById(R.id.toolbar))
+ title = "Remote"
+ fitSystemBars()
+ host = findViewById(R.id.host)
+ port = findViewById(R.id.port)
+ user = findViewById(R.id.user)
+ status = findViewById(R.id.status)
+
+ val cfg = Config.load(this)
+ host.setText(cfg.remote.host)
+ port.setText(cfg.remote.port.toString())
+ user.setText(cfg.remote.user)
+
+ findViewById<Button>(R.id.save).setOnClickListener { save(); finish() }
+ findViewById<Button>(R.id.test).setOnClickListener { test() }
+ }
+
+ private fun current(): Remote = Remote(
+ host = host.text.toString().trim(),
+ port = port.text.toString().toIntOrNull() ?: 22,
+ user = user.text.toString().trim(),
+ )
+
+ private fun save() {
+ val cfg = Config.load(this)
+ Config.save(this, cfg.copy(remote = current()))
+ }
+
+ private fun test() {
+ save()
+ val r = current()
+ when {
+ !Keys.exists(this) ->
+ status.text = "No key yet. Generate one on the main screen and add it to the server first."
+ r.host.isEmpty() || r.user.isEmpty() ->
+ status.text = "Set host and user first."
+ else -> {
+ status.text = "Connecting to ${r.user}@${r.host}:${r.port} ..."
+ thread {
+ val scan = Keys.scan(this, r)
+ runOnUiThread {
+ if (!scan.ok) {
+ status.text = "Connection failed:\n${scan.error}"
+ } else {
+ AlertDialog.Builder(this)
+ .setTitle("Verify host key")
+ .setMessage("Fingerprint:\n${scan.fingerprint}\n\nPin this host?")
+ .setPositiveButton("Pin") { _, _ ->
+ Keys.pin(this, scan.line)
+ status.text = "Host key pinned. Connection OK."
+ }
+ .setNegativeButton("Cancel", null)
+ .show()
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt b/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt
new file mode 100644
index 0000000..d8f8b93
--- /dev/null
+++ b/app/src/main/java/invalid/lena/rsend/RsyncRunner.kt
@@ -0,0 +1,47 @@
+package invalid.lena.rsend
+
+import android.content.Context
+
+// RsyncRunner builds and runs the rsync invocation for one folder, streaming
+// rsync's output into the log. It uses the bundled rsync and rsh binaries and
+// passes key, known_hosts, and port to rsh through the environment.
+object RsyncRunner {
+
+ // args builds the rsync argument vector for one folder. Flags are tuned for
+ // media backup: recursive, preserve mtimes, resume partial files, create
+ // the remote path, and skip the ownership and permission bits that mean
+ // nothing across Android and a server.
+ fun args(rsh: String, remote: Remote, f: Folder): List<String> {
+ val a = ArrayList<String>()
+ a.add("-rt")
+ a.add("--partial")
+ // Abort rather than hang if the network stalls for 5 minutes.
+ a.add("--timeout=300")
+ a.add("--mkpath")
+ a.add("--no-perms")
+ a.add("--no-owner")
+ a.add("--no-group")
+ a.add("--omit-dir-times")
+ a.add("-e")
+ a.add(rsh)
+ for (e in f.excludes) a.add("--exclude=$e")
+ if (f.delete) a.add("--delete")
+ a.add(withSlash(f.local))
+ a.add("${remote.user}@${remote.host}:${withSlash(f.remote)}")
+ return a
+ }
+
+ private fun withSlash(p: String): String = if (p.endsWith("/")) p else "$p/"
+
+ // runFolder execs rsync for one folder, appending every output line to the
+ // log, and returns rsync's exit code.
+ fun runFolder(ctx: Context, remote: Remote, f: Folder, log: SyncLog): Int {
+ val cmd = listOf(Native.rsync(ctx).absolutePath) + args(Native.rsh(ctx).absolutePath, remote, f)
+ log.line("rsync ${cmd.drop(1).joinToString(" ")}")
+ val pb = ProcessBuilder(cmd).redirectErrorStream(true)
+ pb.environment().putAll(Keys.env(ctx, remote.port))
+ val p = pb.start()
+ p.inputStream.bufferedReader().forEachLine { log.line(it) }
+ return p.waitFor()
+ }
+}
diff --git a/app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt b/app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt
new file mode 100644
index 0000000..b917bff
--- /dev/null
+++ b/app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt
@@ -0,0 +1,51 @@
+package invalid.lena.rsend
+
+import android.os.Bundle
+import android.widget.Button
+import android.widget.EditText
+import android.widget.Switch
+import androidx.appcompat.app.AppCompatActivity
+
+// ScheduleActivity edits the periodic sync settings and (re)applies them to
+// WorkManager on save.
+class ScheduleActivity : AppCompatActivity() {
+
+ private lateinit var enabled: Switch
+ private lateinit var interval: EditText
+ private lateinit var wifiOnly: Switch
+ private lateinit var charging: Switch
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_schedule)
+ setSupportActionBar(findViewById(R.id.toolbar))
+ title = "Schedule"
+ fitSystemBars()
+ enabled = findViewById(R.id.enabled)
+ interval = findViewById(R.id.interval)
+ wifiOnly = findViewById(R.id.wifiOnly)
+ charging = findViewById(R.id.charging)
+
+ val s = Config.load(this).schedule
+ enabled.isChecked = s.enabled
+ interval.setText(s.intervalMinutes.toString())
+ wifiOnly.isChecked = s.wifiOnly
+ charging.isChecked = s.requireCharging
+
+ findViewById<Button>(R.id.save).setOnClickListener { save(); finish() }
+ }
+
+ private fun save() {
+ val cfg = Config.load(this)
+ val s = Schedule(
+ enabled = enabled.isChecked,
+ intervalMinutes = interval.text.toString().toIntOrNull()?.coerceAtLeast(1) ?: 120,
+ wifiOnly = wifiOnly.isChecked,
+ requireCharging = charging.isChecked,
+ )
+ Config.save(this, cfg.copy(schedule = s))
+ // Config changed: force a re-arm so the new interval/constraints take
+ // effect now instead of after the current countdown.
+ Scheduler.apply(this, force = true)
+ }
+}
diff --git a/app/src/main/java/invalid/lena/rsend/Scheduler.kt b/app/src/main/java/invalid/lena/rsend/Scheduler.kt
new file mode 100644
index 0000000..af713e0
--- /dev/null
+++ b/app/src/main/java/invalid/lena/rsend/Scheduler.kt
@@ -0,0 +1,71 @@
+package invalid.lena.rsend
+
+import android.content.Context
+import androidx.work.Constraints
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import java.util.concurrent.TimeUnit
+
+// Scheduler runs the sync on a self-perpetuating chain of one-time jobs: each
+// run re-arms the next from SyncWorker. This sidesteps WorkManager's 15-minute
+// periodic floor (so the interval can be as low as 1 minute) and tends to be
+// more reliable across OEMs.
+object Scheduler {
+
+ const val NAME = "periodic-sync"
+
+ // Floor on the chained delay so a sync that runs longer than the interval
+ // cannot turn the chain into a hot loop.
+ private val MIN_DELAY_MS = TimeUnit.SECONDS.toMillis(15)
+
+ // apply (re)arms or cancels the chain to match the schedule config. force
+ // decides what to do when a job is already pending under NAME:
+ // force=false (KEEP) leave a pending countdown running; only arm when
+ // nothing is pending. Used on app open and boot, so
+ // returning to the app does not reset the timer.
+ // force=true (REPLACE) cancel and re-arm now. Used when the schedule
+ // config changes.
+ fun apply(ctx: Context, force: Boolean = false) {
+ val cfg = Config.load(ctx)
+ val intervalMs = TimeUnit.MINUTES.toMillis(intervalMinutes(cfg))
+ arm(ctx, cfg, force, intervalMs)
+ }
+
+ // armAfterRun schedules the next run so the cadence is measured from when
+ // this run STARTED, not when it finished: delay = interval - runtime. That
+ // keeps "every N minutes" close to N minutes instead of N plus the sync
+ // duration. Always replaces, since the finishing run holds the unique name.
+ fun armAfterRun(ctx: Context, runMillis: Long) {
+ val cfg = Config.load(ctx)
+ val intervalMs = TimeUnit.MINUTES.toMillis(intervalMinutes(cfg))
+ arm(ctx, cfg, force = true, delayMs = (intervalMs - runMillis).coerceAtLeast(MIN_DELAY_MS))
+ }
+
+ private fun intervalMinutes(cfg: Config): Long =
+ cfg.schedule.intervalMinutes.toLong().coerceAtLeast(1)
+
+ private fun arm(ctx: Context, cfg: Config, force: Boolean, delayMs: Long) {
+ val s = cfg.schedule
+ val wm = WorkManager.getInstance(ctx)
+
+ val ready = cfg.remote.host.isNotEmpty() && cfg.folders.isNotEmpty() &&
+ Keys.exists(ctx) && Keys.pinned(ctx)
+ if (!s.enabled || !ready) {
+ wm.cancelUniqueWork(NAME)
+ return
+ }
+
+ val constraints = Constraints.Builder()
+ .setRequiredNetworkType(if (s.wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
+ .setRequiresCharging(s.requireCharging)
+ .build()
+ val req = OneTimeWorkRequestBuilder<SyncWorker>()
+ .setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
+ .setConstraints(constraints)
+ .build()
+ val policy = if (force) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP
+ wm.enqueueUniqueWork(NAME, policy, req)
+ }
+}
diff --git a/app/src/main/java/invalid/lena/rsend/SyncLog.kt b/app/src/main/java/invalid/lena/rsend/SyncLog.kt
new file mode 100644
index 0000000..44c4d46
--- /dev/null
+++ b/app/src/main/java/invalid/lena/rsend/SyncLog.kt
@@ -0,0 +1,26 @@
+package invalid.lena.rsend
+
+import android.content.Context
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+// SyncLog is a plain-text, append-only log of sync runs, capped in size. It is
+// 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 fmt = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US)
+
+ fun line(s: String) {
+ file.appendText("${fmt.format(Date())} $s\n")
+ }
+
+ // Truncate once the log grows past the cap so it cannot fill storage.
+ fun rotateIfBig() {
+ if (file.exists() && file.length() > 512 * 1024) file.writeText("")
+ }
+
+ fun text(): String = if (file.exists()) file.readText() else ""
+}
diff --git a/app/src/main/java/invalid/lena/rsend/SyncWorker.kt b/app/src/main/java/invalid/lena/rsend/SyncWorker.kt
new file mode 100644
index 0000000..f4607ea
--- /dev/null
+++ b/app/src/main/java/invalid/lena/rsend/SyncWorker.kt
@@ -0,0 +1,103 @@
+package invalid.lena.rsend
+
+import android.app.Notification
+import android.content.Context
+import android.content.pm.ServiceInfo
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.work.CoroutineWorker
+import androidx.work.ForegroundInfo
+import androidx.work.WorkerParameters
+import androidx.work.workDataOf
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+// SyncWorker runs one full sync as a foreground job: it pushes every configured
+// folder with rsync and records the outcome in the log. It is used for both the
+// manual "Sync now" action and the periodic schedule.
+class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
+
+ companion object {
+ const val KEY_FOLDER = "folder"
+ const val KEY_INDEX = "i"
+ const val KEY_TOTAL = "n"
+ }
+
+ override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
+ val ctx = applicationContext
+ val startedAt = System.currentTimeMillis()
+ val cfg = Config.load(ctx)
+ if (cfg.remote.host.isEmpty() || cfg.folders.isEmpty() || !Keys.exists(ctx) || !Keys.pinned(ctx)) {
+ return@withContext Result.success()
+ }
+
+ val log = SyncLog(ctx)
+ log.rotateIfBig()
+ log.line("sync started (${cfg.folders.size} folders)")
+ // A foreground service lets long syncs survive, but Android 12+ forbids
+ // starting one when the periodic job fires in the background. Try it, and
+ // fall back to a plain background job when it is not allowed.
+ try {
+ setForeground(foregroundInfo())
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ log.line("foreground unavailable, running in background")
+ }
+
+ var ok = true
+ val total = cfg.folders.size
+ cfg.folders.forEachIndexed { i, f ->
+ val label = f.name.ifEmpty { f.local }
+ // Publish progress the dashboard observes (current folder, i of n).
+ setProgress(workDataOf(KEY_FOLDER to label, KEY_INDEX to i + 1, KEY_TOTAL to total))
+ log.line("=== $label ===")
+ val code = try {
+ RsyncRunner.runFolder(ctx, cfg.remote, f, log)
+ } catch (e: Exception) {
+ log.line("error: ${e.message}")
+ 1
+ }
+ log.line("exit=$code")
+ if (code != 0) ok = false
+ }
+ log.line("sync finished ok=$ok")
+ LastSync.set(ctx, ok)
+ if (!ok) notifyError(ctx)
+ // Re-arm the next scheduled run (the chain), measuring the interval from
+ // when this run started so the cadence stays close to the configured
+ // interval. Always succeed so a failed run does not break the chain.
+ Scheduler.armAfterRun(ctx, System.currentTimeMillis() - startedAt)
+ Result.success()
+ }
+
+ 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.
+ }
+ }
+
+ private fun foregroundInfo(): ForegroundInfo {
+ val n: Notification = NotificationCompat.Builder(applicationContext, App.CHANNEL)
+ .setContentTitle("rsend")
+ .setContentText("Syncing")
+ .setSmallIcon(R.drawable.ic_notification)
+ .setOngoing(true)
+ .build()
+ return if (Build.VERSION.SDK_INT >= 29) {
+ ForegroundInfo(1, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
+ } else {
+ ForegroundInfo(1, n)
+ }
+ }
+}