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/build.gradle | 88 +++++ app/src/main/AndroidManifest.xml | 62 ++++ 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 +++++ app/src/main/res/drawable/bg_button_primary.xml | 10 + app/src/main/res/drawable/bg_button_secondary.xml | 13 + app/src/main/res/drawable/bg_card.xml | 9 + app/src/main/res/drawable/bg_chip.xml | 6 + app/src/main/res/drawable/bg_input.xml | 21 ++ app/src/main/res/drawable/dot_off.xml | 11 + app/src/main/res/drawable/dot_on.xml | 8 + app/src/main/res/drawable/ic_chevron.xml | 9 + app/src/main/res/drawable/ic_folder.xml | 9 + app/src/main/res/drawable/ic_key.xml | 9 + .../main/res/drawable/ic_launcher_background.xml | 9 + .../main/res/drawable/ic_launcher_foreground.xml | 14 + app/src/main/res/drawable/ic_notification.xml | 10 + app/src/main/res/drawable/ic_remote.xml | 9 + app/src/main/res/drawable/ic_schedule.xml | 12 + app/src/main/res/layout/activity_folder.xml | 123 ++++++ app/src/main/res/layout/activity_log.xml | 56 +++ app/src/main/res/layout/activity_main.xml | 413 +++++++++++++++++++++ app/src/main/res/layout/activity_remote.xml | 107 ++++++ app/src/main/res/layout/activity_schedule.xml | 75 ++++ app/src/main/res/layout/app_toolbar.xml | 11 + app/src/main/res/layout/dialog_key.xml | 40 ++ app/src/main/res/layout/item_folder.xml | 48 +++ app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml | 6 + app/src/main/res/values-night/colors.xml | 18 + app/src/main/res/values/colors.xml | 20 + app/src/main/res/values/dimens.xml | 13 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 103 +++++ app/src/test/java/invalid/lena/rsend/ConfigTest.kt | 26 ++ .../java/invalid/lena/rsend/RsyncRunnerTest.kt | 49 +++ 50 files changed, 2598 insertions(+) create mode 100644 app/build.gradle create mode 100644 app/src/main/AndroidManifest.xml 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 create mode 100644 app/src/main/res/drawable/bg_button_primary.xml create mode 100644 app/src/main/res/drawable/bg_button_secondary.xml create mode 100644 app/src/main/res/drawable/bg_card.xml create mode 100644 app/src/main/res/drawable/bg_chip.xml create mode 100644 app/src/main/res/drawable/bg_input.xml create mode 100644 app/src/main/res/drawable/dot_off.xml create mode 100644 app/src/main/res/drawable/dot_on.xml create mode 100644 app/src/main/res/drawable/ic_chevron.xml create mode 100644 app/src/main/res/drawable/ic_folder.xml create mode 100644 app/src/main/res/drawable/ic_key.xml create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_notification.xml create mode 100644 app/src/main/res/drawable/ic_remote.xml create mode 100644 app/src/main/res/drawable/ic_schedule.xml create mode 100644 app/src/main/res/layout/activity_folder.xml create mode 100644 app/src/main/res/layout/activity_log.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/activity_remote.xml create mode 100644 app/src/main/res/layout/activity_schedule.xml create mode 100644 app/src/main/res/layout/app_toolbar.xml create mode 100644 app/src/main/res/layout/dialog_key.xml create mode 100644 app/src/main/res/layout/item_folder.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/values-night/colors.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/test/java/invalid/lena/rsend/ConfigTest.kt create mode 100644 app/src/test/java/invalid/lena/rsend/RsyncRunnerTest.kt (limited to 'app') diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..56036ab --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,88 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +// Release signing is configured only if a local keystore.properties exists +// (gitignored). Otherwise the release build is unsigned, which is what F-Droid +// and reproducible-build verification want. +def keystoreProps = rootProject.file('keystore.properties') + +android { + namespace 'invalid.lena.rsend' + compileSdk 35 + + defaultConfig { + applicationId 'invalid.lena.rsend' + minSdk 30 + targetSdk 35 + versionCode 1 + versionName '0.1.0' + // Ship only the ABIs we build native libs for. + ndk { abiFilters 'arm64-v8a', 'x86_64' } + } + + signingConfigs { + if (keystoreProps.exists()) { + def props = new Properties() + keystoreProps.withInputStream { props.load(it) } + release { + storeFile rootProject.file(props['storeFile']) + storePassword props['storePassword'] + keyAlias props['keyAlias'] + keyPassword props['keyPassword'] + // Include v1 (JAR) signing too; some sideload installers + // (Samsung One UI on Android 11/12) reject v2-only APKs with + // "problem parsing the package". + enableV1Signing true + enableV2Signing true + enableV3Signing true + } + } + } + + buildTypes { + release { + minifyEnabled false + if (keystoreProps.exists()) { + signingConfig signingConfigs.release + } + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = '17' + } + + buildFeatures { + buildConfig true + } + + // Native executables ship as lib*.so and must be extracted so we can exec + // them from nativeLibraryDir (useLegacyPackaging => extractNativeLibs=true). + packaging { + jniLibs { + useLegacyPackaging true + } + } + + // Reproducible / F-Droid: do not embed the Google dependency metadata block. + dependenciesInfo { + includeInApk false + includeInBundle false + } +} + +dependencies { + implementation 'androidx.core:core-ktx:1.13.1' + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'androidx.work:work-runtime-ktx:2.10.0' + + // The real org.json shadows the android.jar stub so JSON tests run on the JVM. + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.json:json:20240303' +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..31c8b72 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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