rsend
Push configured phone folders to home SSH hosts using real rsync.
rsend backs up folders such as DCIM, WhatsApp media, and call recordings to one or more remote hosts over SSH. One-way push only, on an unmetered network by default, periodic or manual. Small, boring, self-contained: real rsync and a pure-Go SSH transport, both built from source and shipped inside the APK. No downloaded application .so files are repackaged.
What it does
- Pushes one or more local folders to one or more SSH hosts over rsync, each folder mapped to a named remote.
- Incremental: only changed files move; large videos resume. Partial transfers are staged in a .rsend-partial directory, so an interrupted sync never replaces a complete file on the server with a truncated one.
- Per-folder deletion policy: additive backup (default) or mirror (--delete-after). A mirror follows rsync exactly: an empty readable source empties its remote destination. A missing, non-directory, root, or unreadable source fails before rsync starts. Deletions happen only after transfers, so an interrupted run never removes the server's copy of a file whose replacement has not arrived. Every item rsync removes there is named in the log, and a mirror that ends with files unaccounted for is reported as failed.
- Rejects duplicate and ancestor remote destinations on the same configured endpoint. The check is lexical, case-insensitive, and does not allow absolute and relative paths to be mixed for one endpoint. Root, bare home, dot, and parent components are rejected. Separate folder mappings use separate trees.
- Regular files and directories only. A source path that is itself a symlink is refused. Symlinks, devices and sockets inside it are skipped and named in the log ("skipping non-regular file"), which is what media backup wants; rsync's -l would copy them if you ever need it.
- Native executables and APK packaging are aligned for Android devices with 4 KB or 16 KB memory pages.
- Runs on an unmetered network, periodically (WorkManager) or on demand. A manual sync obeys the same unmetered setting, and says so and stops rather than queueing itself until the phone next sees wifi.
- Ed25519 client-key auth with strict, pinned host-key verification. rsend accepts modern Ed25519, ECDSA, and RSA-SHA2 host keys, pins the key type with the key, and warns loudly if a pinned host presents a different one.
Layout
- rsh/ pure-Go SSH transport used as rsync's remote shell (-e).
- rsync/ build script that compiles pinned rsync from source via the NDK.
- app/ Android app (Kotlin, classic Views); bundles both as lib*.so.
- ci/ CI-agnostic build and test scripts; the Makefile drives them.
- fastlane/ F-Droid store listing: descriptions, changelogs, screenshots.
- THIRD_PARTY complete shipped-component inventory and license notices.
- APACHE-2.0 license terms for the shipped Apache-licensed components.
- REPRODUCIBLE-BUILDS determinism, pinning, and F-Droid build notes.
- versions pinned toolchain and source versions; the single source of truth.
Build
The repository is self-contained: clone it and the build fetches everything else. Toolchain setup supports x86_64 Linux with glibc or musl and selects a separately checksummed Temurin JDK for each. The host prerequisites are a POSIX shell, coreutils, make, curl, python3, tar, git, a C compiler for the Go race detector, and the commands checked by ci/test.sh. Alpine also needs bash for the NDK compiler launchers and gcompat for the NDK host binaries. The CI file gives exact Debian and Alpine package lists. Toolchain archives, the SDK platform revision, and rsync source are version- and checksum-pinned by the build; see versions.
make setup # provision the toolchain into $HOME/toolchains
. "$HOME/toolchains/env.sh" # put it on PATH (do this in each shell)
make # rsync (NDK) -> rsh (Go) -> APK
make test # tests, race detector, lint, vulnerability scan
make verify-repro # clean commit, two paths, byte-identical APKs
make test also requires host rsync and lets govulncheck query the public Go
vulnerability database. No source code is uploaded. The APK lands under
app/build/outputs/apk/. It is unsigned by default and cannot be installed until
signed. .gitlab-ci.yml runs exactly these scripts and holds no build logic of
its own: ci/setup-toolchain.sh, ci/test.sh, ci/build.sh, and, on a tag,
ci/verify-repro.sh.
For a locally installable APK, create a keystore and a gitignored keystore.properties before building:
keytool -genkeypair -keystore rsend.jks -alias rsend -keyalg EC -validity 3650
cat > keystore.properties <<EOF
storeFile=rsend.jks
storePassword=change-me
keyAlias=rsend
keyPassword=change-me
EOF
Keep both files private and backed up. The signed output is app/build/outputs/apk/release/app-release.apk.
Run
- Install the APK and grant all-files access and notifications. All-files access is required: a sync refuses to start without it because configured storage may otherwise be unreadable.
- Generate a key in the app and add the shown public key to the home host's ~/.ssh/authorized_keys. Import accepts unencrypted Ed25519 private keys only.
- Add a remote (name, host, user, port); run Test connection and accept the pinned host-key fingerprint. The dialog shows the key type next to the fingerprint; verify it against the matching key file on the server. Nothing is saved until you accept a key, so a failed test cannot disturb a remote that already works. Repeat for every host you push to.
- Add folders, pick each folder's remote and deletion policy, set the schedule. Enabling mirror shows its exact deletion semantics before saving.
- Tap Sync now, or wait for the periodic sync. Scheduled intervals have a 15-minute minimum imposed by WorkManager.
For scheduled backup to run reliably, tap Battery and allow rsend to ignore battery optimization. Phones with aggressive power management otherwise delay or skip background jobs. Android 16 also counts long WorkManager foreground runs against the app's job quota; a very large first backup may need several runs and resumes from .rsend-partial each time.
Server
Any rsync-compatible SSH service works, including ordinary shell accounts and older rsync releases such as stock macOS. rsend pushes over rsync-over-SSH, not SFTP. Remote paths may be absolute, relative, or home-relative, but mappings on one endpoint must use either absolute or relative paths consistently. A path must name a directory below root or home and cannot contain dot or parent components. rsync creates the final component on its own; create deeper missing parent directories once with mkdir -p on the server. A remote path cannot begin with a colon: rsync interprets host::path as its unencrypted daemon protocol instead of SSH.
Protocol compatibility is not a security promise. This release bundles rsync 3.5.0, but it cannot update the rsync receiver or rrsync wrapper on the server. Install upstream 3.5.0 or a vendor package carrying the August 2026 security fixes on the server before relying on rrsync confinement.
The recommended deployment confines the account to "rsync into one folder and nothing else" with a forced rrsync command. This is not a protocol requirement. sshd runs the forced command through the account's login shell, so the shell must be real: /bin/sh works, while /bin/false or /sbin/nologin breaks rsync. The confinement comes from key-only auth, the forced command, and rrsync's write-only root. As root on the server:
useradd -m -d /srv/backup -s /bin/sh rsendbackup
passwd -l rsendbackup
mkdir -p /srv/backup/phone && chown -R rsendbackup:rsendbackup /srv/backup
This assumes UsePAM yes in sshd_config (the default on most distros; set it if your build has it off). With UsePAM yes a password-locked account still accepts key logins. With UsePAM no, sshd refuses any account whose password field is locked, even for keys, and useradd writes a locked field of its own, so omitting passwd -l is not enough. There, set PasswordAuthentication no and give the account a password nobody knows:
printf 'rsendbackup:%s\n' "$(head -c 24 /dev/urandom | base64)" | chpasswd
Put the app's public key in /srv/backup/.ssh/authorized_keys as one restricted line that forces rrsync, write-only, into that directory:
command="/usr/bin/rrsync -wo /srv/backup/phone",restrict ssh-ed25519 AAAA... rsend
To apply the same limit account-wide, so it holds even if another key is added later and not only on this key, force the command in sshd_config and reload sshd:
Match User rsendbackup
ForceCommand /usr/bin/rrsync -wo /srv/backup/phone
PasswordAuthentication no
PermitTTY no
AllowTcpForwarding no
AllowAgentForwarding no
AllowStreamLocalForwarding no
X11Forwarding no
The account can then do nothing but receive rsync into that folder: every session, with any key, is forced through rrsync, which rejects anything but a plain rsync transfer into its root. Remote paths are relative to that root, so set a folder's remote path to DCIM, not an absolute path. rrsync may instead live at /usr/share/rsync/scripts/rrsync; check command -v rrsync. Verify the host key from Test connection against ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on the server. The Test connection dialog prints the key type it pinned; if it says something other than ssh-ed25519, the server has no ed25519 host key and you should compare against that type's .pub file instead.
Interrupted transfers leave a .rsend-partial directory inside the destination folder. rsync reuses it to resume and excludes it from the transfer, so it is never deleted by a mirror. Abandoned partials from a sync that never resumed can be removed by hand.
Upgrading
A configuration written by 0.1.x is read once and rewritten in the current shape on first launch. The single remote becomes the sole entry, named after its host; every safe folder points at it; schedule intervals below 15 minutes are raised to WorkManager's minimum; and the pin 0.1.x kept in its own known_hosts file is carried over. A pin that no longer describes that host and port is dropped rather than failing the upgrade, which costs one Test connection instead of the whole configuration. The exact old JSON is preserved as config.json.0.1. A folder or endpoint that violates current safety rules is omitted, preserved in that file, and reported in the app instead of causing the rest of the configuration to be discarded.
This is a deliberate, time-limited exception to the project's rule against compatibility shims, kept because 0.1.3 is the last published release and is therefore what every upgrading install has. It will be removed in 0.4.0: a 0.1.x configuration not opened by then is read as unparsable, preserved alongside as config.json.broken, and the app starts empty.
Debug
- The in-app log viewer displays the plain-text rsync log; tap Refresh to reload.
- The log records the rsync arguments, the errors, and every file a mirror deleted on the server ("del. "). Transfers are counted rather than listed one line each: on a first sync of a large library the filenames would push everything worth reading out of the capped log. The log is capped at 512 KB and rolls over to sync.log.1. The viewer reads at most the newest 64 KB across both files so layout cost stays bounded. Control characters are escaped before writing so remote output cannot forge terminal log lines.
- If config.json cannot be read or parsed, rsend atomically renames it to a unique config.json.broken file, starts from an empty configuration, and shows the preserved filename in the app. It never deletes unreadable config bytes.
- rsh transport: RSH_KEY, RSH_KNOWN_HOSTS, and RSH_PORT select the key, known_hosts file, and port; RSH_KEY_DATA passes the key itself, which is what the app uses so the plaintext key never reaches the filesystem. Run rsh by hand to isolate SSH from rsync.
- During transport, rsh offers only the host-key type already pinned for that remote. Test connection prefers the pinned type so an untouched server reproduces the same key, but can show a newly rotated type for explicit approval. Re-run Test connection after deliberately rotating a host key.
- An IPv6 host goes in the host field as a bare literal (2001:db8::1); rsend adds the brackets where rsh and rsync each need them. A bracketed literal is accepted too.
- "foreground service refused" in the log means the sync ran as a plain background job, which the system may stop early. Grant Battery > unrestricted; rsync resumes from .rsend-partial on the next run either way.
- A host that drops packets instead of refusing them, a firewall closed to the phone's current network for instance, stalls the connect for the full budget. That budget is 10 seconds; RSH_CONNECT_TIMEOUT overrides it in whole seconds. The log line reads "rsh: unreachable:", and the remaining folders bound to that remote are skipped for the rest of the run rather than each paying the timeout again.
License
Copyright (C) 2026 Lena. GPLv3; see LICENSE.
Bundling rsync makes the whole app GPLv3. THIRD_PARTY records every native and JVM component shipped in the APK, its license, and its corresponding source. Native source and tool archives are pinned and checksummed; JVM dependencies use exact Maven versions. Test and build-only dependencies are listed separately because they are not part of the APK. LICENSE and THIRD_PARTY are packaged as plain APK assets, along with APACHE-2.0.