retsend on Android is the same app: SDL2 has a mature Android port, so the Rust
code ships as a cdylib (libretsend.so) that SDL's Java SDLActivity loads and
enters through the C SDL_main in src/lib.rs. Windowing, the GLES context,
gamepad input and the whole net stack carry over. What is Android's alone is
packaging, storage paths, touch, and the Back button — all of it behind
#[cfg(target_os = "android")] or additive Cargo entries, so the Linux and
handheld builds are untouched.
Targets arm64-v8a only, which is every Android handheld and phone worth
running this on, from API 24 (Android 7).
Grab retsend-android-arm64.apk from
Releases and sideload it.
On first launch it asks for All files access. Grant it and received files
land in Download/, and the file browser can reach the whole card — which is
the point on a device where an emulator has to find what you sent. Denied, the
app still works, but it is confined to its own external directory
(Android/data/com.retsend/files/), which file managers can't open on Android
11+. The grant screen is only offered once; afterwards it's
Settings → Apps → retsend → All files access, and the save folder is then
changed in-app under Settings → Save folder.
One-time setup:
rustup target add aarch64-linux-android
cargo install cargo-ndk --locked
# Android Studio (SDK + an NDK + CMake), or the sdkmanager equivalents:
sdkmanager --install "ndk;27.2.12479018" "platforms;android-34" "build-tools;34.0.0"Then one command builds libSDL2.so (first run only), cross-compiles the cdylib
and assembles the APK:
./android/scripts/build.sh # debug -> android/app/build/outputs/apk/debug/app-debug.apk
./android/scripts/build.sh release # release -> android/app/build/outputs/apk/release/app-release.apk
adb install -r android/app/build/outputs/apk/release/app-release.apkIt auto-detects the SDK and the newest installed NDK (override with
ANDROID_SDK_ROOT / ANDROID_NDK_HOME). Debug and release sign with the same
app/debug.keystore, so -r updates in place.
In Android Studio: run build.sh once so the .so files exist under
app/src/main/jniLibs/, then open android/ and use Run — Gradle only packages
what is already there, it never builds Rust. Re-run the cargo ndk step
whenever the Rust code changes.
Logs go to logcat: adb logcat -s retsend.
- Entry point —
RetsendActivity.getLibraries()returns{"SDL2", "retsend"}, so SDL loadslibretsend.soand calls itsSDL_main, which hands off to the samerun_app()the desktop binary uses. - Paths —
RetsendActivity.onCreatesets the sameRETSEND_*env vars the PortMaster and OnionOS launchers set, before SDL starts:RETSEND_DATA_DIR(internalgetFilesDir(): config, TLS identity, history),RETSEND_SAVE_DIR,RETSEND_BROWSER_ROOTS(the storage volumes it can reach),RETSEND_ALIAS(Build.MODEL) andRETSEND_PANIC_FILE. Not the scale: fitting the 640×480 design to the panel already lands on the same physical size at any density. - Permissions —
RetsendLauncherActivityis the launcher entry and asks for all-files access before starting SDL, because the paths above are read once at startup and the save folder is persisted on first run. - Discovery — the activity holds a
WifiManager.MulticastLockfor its lifetime; without one the Wi-Fi driver filters out the multicast announces. - Input — touch or a gamepad. SDL's own touch→mouse synthesis is off, since
egui-sdl2 builds a pointer stream from the finger events itself. A tap becomes
the same
AppCommanda button would: on a row it places the cursor and confirms, and each footer hint slot is the button it names, which is what makes Start/Select/X/Y reachable without a pad. - Back —
SDL_ANDROID_TRAP_BACK_BUTTONmakes it anAC_BACKkey instead of backgrounding the activity, mapped to the Back command; at the top level it quits, since on Android Back has to lead somewhere. - Orientation — locked to
sensorLandscape. The UI is the handhelds' 640x480 one, and a fixed aspect also avoids relayout on rotation, which egui-sdl2 drives off a cached window size that an Android rotation can outrun.
sdl2-sys 0.38 vendors SDL 2.26.4. scripts/sync-sdl.sh copies the
org.libsdl.app Java glue and builds libSDL2.so from that same source, so the
glue, the runtime .so and the Rust bindings all match. Everything it produces
(Java glue, wrapper jar, jniLibs/, res/mipmap-*) is git-ignored and
regenerated.
- GL surface loss on background. Android destroys the EGL surface when the app leaves the foreground; SDL blocks the app thread across that, but a context loss would leave egui's textures stale. Transfers run on their own threads and are unaffected. Untested on a device.
- Sending files the system hands us. No
ACTION_SENDintent filter yet, so retsend can't be a share target; files are picked in its own browser. - Save folder via SAF. All-files access is used instead, which is what plain file I/O needs and what sideloading allows.
- Portrait. Would need a per-frame window-size sync that
EguiWindowdoesn't expose yet, hence the orientation lock.
CI restores a stable key from the RETSEND_KEYSTORE_BASE64 secret (decoded to
app/release.keystore, passed via RETSEND_KEYSTORE) so every release APK has
the same signature; without the secret it falls back to an ephemeral key so
forks still build. One-time setup:
keytool -genkeypair -keystore release.keystore -storepass android -keypass android \
-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=retsend,O=retsend,C=US"
base64 -w0 release.keystore # save as the repo secret RETSEND_KEYSTORE_BASE64Non-default passwords or alias also need RETSEND_KEYSTORE_PASS,
RETSEND_KEY_ALIAS and RETSEND_KEY_PASS. Play distribution would need a real
upload key through the same env — and a different storage story, since
MANAGE_EXTERNAL_STORAGE is not something Play grants a file-transfer app
lightly.