From 7e04941bccb2683f8a6e3ee38a99c50129234dd1 Mon Sep 17 00:00:00 2001 From: Lena Date: Thu, 1 Jan 2026 00:00:00 +0000 Subject: 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. --- app/src/main/java/invalid/lena/rsend/App.kt | 22 ++ .../main/java/invalid/lena/rsend/BootReceiver.kt | 15 + app/src/main/java/invalid/lena/rsend/Config.kt | 107 +++++++ .../java/invalid/lena/rsend/FolderEditActivity.kt | 77 +++++ app/src/main/java/invalid/lena/rsend/Insets.kt | 34 +++ app/src/main/java/invalid/lena/rsend/KeyVault.kt | 48 +++ app/src/main/java/invalid/lena/rsend/Keys.kt | 75 +++++ app/src/main/java/invalid/lena/rsend/LastSync.kt | 21 ++ .../main/java/invalid/lena/rsend/LogActivity.kt | 35 +++ .../main/java/invalid/lena/rsend/MainActivity.kt | 338 +++++++++++++++++++++ app/src/main/java/invalid/lena/rsend/Native.kt | 26 ++ .../main/java/invalid/lena/rsend/RemoteActivity.kt | 82 +++++ .../main/java/invalid/lena/rsend/RsyncRunner.kt | 47 +++ .../java/invalid/lena/rsend/ScheduleActivity.kt | 51 ++++ app/src/main/java/invalid/lena/rsend/Scheduler.kt | 71 +++++ app/src/main/java/invalid/lena/rsend/SyncLog.kt | 26 ++ app/src/main/java/invalid/lena/rsend/SyncWorker.kt | 103 +++++++ 17 files changed, 1178 insertions(+) create mode 100644 app/src/main/java/invalid/lena/rsend/App.kt create mode 100644 app/src/main/java/invalid/lena/rsend/BootReceiver.kt create mode 100644 app/src/main/java/invalid/lena/rsend/Config.kt create mode 100644 app/src/main/java/invalid/lena/rsend/FolderEditActivity.kt create mode 100644 app/src/main/java/invalid/lena/rsend/Insets.kt create mode 100644 app/src/main/java/invalid/lena/rsend/KeyVault.kt create mode 100644 app/src/main/java/invalid/lena/rsend/Keys.kt create mode 100644 app/src/main/java/invalid/lena/rsend/LastSync.kt create mode 100644 app/src/main/java/invalid/lena/rsend/LogActivity.kt create mode 100644 app/src/main/java/invalid/lena/rsend/MainActivity.kt create mode 100644 app/src/main/java/invalid/lena/rsend/Native.kt create mode 100644 app/src/main/java/invalid/lena/rsend/RemoteActivity.kt create mode 100644 app/src/main/java/invalid/lena/rsend/RsyncRunner.kt create mode 100644 app/src/main/java/invalid/lena/rsend/ScheduleActivity.kt create mode 100644 app/src/main/java/invalid/lena/rsend/Scheduler.kt create mode 100644 app/src/main/java/invalid/lena/rsend/SyncLog.kt create mode 100644 app/src/main/java/invalid/lena/rsend/SyncWorker.kt (limited to 'app/src/main/java/invalid') 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 = emptyList(), +) + +data class Config( + val remote: Remote = Remote(), + val schedule: Schedule = Schedule(), + val folders: List = 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(fa.length()) + for (i in 0 until fa.length()) { + val fo = fa.getJSONObject(i) + val ex = fo.optJSONArray("excludes") ?: JSONArray() + val excludes = ArrayList(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