Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Android link paths, relative to the repo root (cargo's rustc cwd):
# - NDK r23+ dropped libgcc but rustc's aarch64-linux-android spec still emits
# `-lgcc`; android/scripts/build.sh writes the stub that satisfies it.
# - jniLibs holds the libSDL2.so the linker needs for `-lSDL2`.
# Here rather than in RUSTFLAGS: a changing RUSTFLAGS invalidates every crate.
[target.aarch64-linux-android]
rustflags = ["-L", "target/ndk-libgcc-stub", "-L", "android/app/src/main/jniLibs/arm64-v8a"]
125 changes: 125 additions & 0 deletions .github/workflows/build-android.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
name: Android

on:
workflow_dispatch:
push:
branches:
- "main"
release:
types: [published]

permissions:
contents: write

env:
NDK_VERSION: "27.2.12479018" # r27c
ANDROID_API: "24"
ABI: "arm64-v8a"
RUST_TARGET: "aarch64-linux-android"

jobs:
build-android:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4

- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"

- name: Set up Android SDK + NDK
uses: android-actions/setup-android@v3

- name: Install NDK + platform + build-tools
run: |
set -eux
sdkmanager --install "ndk;${NDK_VERSION}" "platforms;android-34" "build-tools;34.0.0"
echo "ANDROID_NDK_HOME=${ANDROID_SDK_ROOT}/ndk/${NDK_VERSION}" >> "$GITHUB_ENV"

- name: Rust toolchain
run: |
set -eux
# rust-toolchain.toml pins the channel; rustup honors it.
rustup show
rustup target add "${RUST_TARGET}"

- name: Install cargo-ndk
run: cargo install cargo-ndk --locked

# sync-sdl.sh needs the sdl2-sys source for the Java glue and libSDL2.so.
- name: Fetch crates
run: cargo fetch

- name: Sync SDL glue + build libSDL2.so
run: bash android/scripts/sync-sdl.sh

- name: Build Rust cdylib
env:
# CI performance build, kept out of Cargo.toml so local release builds
# stay fast to compile.
CARGO_PROFILE_RELEASE_LTO: "fat"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "1"
run: |
set -eux
# NDK r23+ dropped libgcc but rustc still emits -lgcc; stub it to
# libunwind. The -L paths are in .cargo/config.toml.
mkdir -p target/ndk-libgcc-stub
echo "INPUT(-lunwind)" > target/ndk-libgcc-stub/libgcc.a
cargo ndk -t "${ABI}" -P "${ANDROID_API}" \
-o android/app/src/main/jniLibs build --release

# A stable key keeps `adb install -r` updating in place across releases.
# Without the secret (a fork) fall back to an ephemeral one so the build
# still runs. No `set -x` here: keep the base64 out of the logs.
- name: Restore signing keystore
env:
KS_B64: ${{ secrets.RETSEND_KEYSTORE_BASE64 }}
run: |
set -eu
if [ -n "${KS_B64:-}" ]; then
echo "$KS_B64" | base64 -d > android/app/release.keystore
echo "Using keystore from RETSEND_KEYSTORE_BASE64 (stable signature)."
else
keytool -genkeypair -v -keystore android/app/release.keystore \
-storepass android -keypass android -alias androiddebugkey \
-keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=retsend,O=retsend,C=US"
echo "::warning::RETSEND_KEYSTORE_BASE64 not set; using an ephemeral key (APKs won't update in place)."
fi

- name: Assemble release APK
env:
RETSEND_KEYSTORE: release.keystore
RETSEND_KEYSTORE_PASS: ${{ secrets.RETSEND_KEYSTORE_PASS || 'android' }}
RETSEND_KEY_ALIAS: ${{ secrets.RETSEND_KEY_ALIAS || 'androiddebugkey' }}
RETSEND_KEY_PASS: ${{ secrets.RETSEND_KEY_PASS || 'android' }}
run: |
set -eux
cd android
./gradlew --no-daemon assembleRelease
ls -la app/build/outputs/apk/release/

- name: Stage the APK
run: |
set -eux
cp android/app/build/outputs/apk/release/app-release.apk retsend-android-arm64.apk

- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: retsend-android-arm64
path: retsend-android-arm64.apk

- name: Upload to GitHub Release
if: github.event_name == 'release'
run: sha256sum retsend-android-arm64.apk > retsend-android-arm64.apk.sha256

- name: Attach APK to release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v2
with:
files: |
retsend-android-arm64.apk
retsend-android-arm64.apk.sha256
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **An Android build**, `retsend-android-arm64.apk`. SDL2's Android port loads
the Rust code as a cdylib and enters it through `SDL_main`, so windowing, the
GLES path, gamepad input and the whole net stack are the ones the handhelds
run — what is Android's alone is packaging, storage and the Back button, all of
it behind `cfg(target_os = "android")` or additive Cargo entries. The system
Back button is B and quits at the top level, and the activity holds a
`MulticastLock` so the Wi-Fi driver stops filtering out the announces discovery
is built on. It runs landscape, which is both the UI it already had and one
less relayout to get wrong. The app asks for all-files access before starting:
granted, received files land in `Download/` where an emulator can find them;
denied, it keeps to its own external folder, which file managers can't open on
Android 11+. Built with
`android/scripts/build.sh`; the port is written up in
[android/README.md](android/README.md).
- **Touch and mouse input**, which the UI had never read: it is painted from a
cursor that only a pad or the keyboard moved, so on a phone there was nothing
to press. A tap now becomes the same `AppCommand` a button emits — on a row it
places the cursor there and confirms, so tapping a device sends to it and
tapping a file picks it, and each slot of the footer hint bar *is* the button
it names, which is what makes Start/Select/X/Y reachable without a pad. The
same applies to a desktop mouse, where clicking used to do nothing.
- `RETSEND_BROWSER_ROOTS` (`:`-separated) adds file-browser roots a launcher
knows and no built-in candidate could name, and `RETSEND_ALIAS` seeds the
device name where there is no hostname to read. The Android activity passes
its storage volumes and `Build.MODEL` through them.

## [0.5.5] - 2026-08-18

### Changed
Expand Down
30 changes: 29 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ license = "GPL-3.0"
description = "LocalSend client for retro handhelds (SDL2 + egui)"
repository = "https://github.com/mxmgorin/retsend"

# `cdylib` is the `libretsend.so` SDL's Android shell loads (named in
# RetsendActivity.getLibraries()); `lib` keeps `src/main.rs` and the tests linkable.
[lib]
name = "retsend"
crate-type = ["lib", "cdylib"]

[features]
default = ["software-render"]
sdl2-bundled = ["sdl2/bundled"]
Expand Down Expand Up @@ -58,3 +64,8 @@ socket2 = { version = "0.6", features = ["all"] }
# Random session ids / tokens / fingerprint; a hex helper in net::protocol is
# all we need on top, so no uuid/rand.
getrandom = "0.4"

# Android: SDL loads our cdylib and calls the C `SDL_main` we export. There is no
# stderr to mirror to a file, so `log` goes to logcat via android_logger.
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.15"
37 changes: 32 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
<a href="https://github.com/mxmgorin/retsend/actions/workflows/ci.yml"><img src="https://github.com/mxmgorin/retsend/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://github.com/mxmgorin/retsend/actions/workflows/build-linux-arm.yml"><img src="https://github.com/mxmgorin/retsend/actions/workflows/build-linux-arm.yml/badge.svg" alt="Linux ARM"></a>
<a href="https://github.com/mxmgorin/retsend/actions/workflows/build-linux.yml"><img src="https://github.com/mxmgorin/retsend/actions/workflows/build-linux.yml/badge.svg" alt="Linux"></a>
<a href="https://github.com/mxmgorin/retsend/actions/workflows/build-android.yml"><img src="https://github.com/mxmgorin/retsend/actions/workflows/build-android.yml/badge.svg" alt="Android"></a>
<a href="https://deps.rs/repo/github/mxmgorin/retsend"><img src="https://deps.rs/repo/github/mxmgorin/retsend/status.svg" alt="Dependencies"></a>
</div>

`retsend` (**ret**ro + **send**ing) is a Rust [LocalSend](https://localsend.org)
client for retro handhelds: send and receive files with your phone
or PC over Wi-Fi, no cable or SSH. Compatible with the official LocalSend apps.

It targets [PortMaster-compatible](https://portmaster.games/supported-devices.html) Linux handhelds and the Miyoo Mini Plus and Flip running OnionOS, both of which are gamepad-only systems without a compositor. It also runs on regular desktop Linux too.
It targets [PortMaster-compatible](https://portmaster.games/supported-devices.html) Linux handhelds and the Miyoo Mini Plus and Flip running OnionOS, both of which are gamepad-only systems without a compositor. It also runs on regular desktop Linux and on Android handhelds and phones too.

| Receive | Request | Save | Transfer |
|:---:|:---:|:---:|:---:|
Expand Down Expand Up @@ -60,6 +61,18 @@ The zip carries an SDL2 built for the Miyoo's panel and a launcher that asks for
the software renderer, since the SSD202D has no GPU at all. What ships inside it,
in full: [onionos/App/Retsend/lib/README.md](onionos/App/Retsend/lib/README.md).

## Install (Android)

Grab `retsend-android-arm64.apk` from
[Releases](https://github.com/mxmgorin/retsend/releases) and sideload it. Same
app, driven by touch or by a pad, with the system Back button as B: tap a device
to send to it, a file to pick it, a row to open it, and the button hints along
the bottom are themselves the buttons.

Grant **All files access** when it asks and received files land in `Download/`,
where an emulator or file manager can reach them; deny it and the app is confined
to its own folder. See [android/README.md](android/README.md).

## Building & running (desktop)

System SDL2 is the only native dependency. On Debian/Ubuntu:
Expand All @@ -86,6 +99,20 @@ Tests are headless (no SDL, no network setup needed):
cargo test
```

### Building the APK

With the Android SDK and an NDK installed:

```sh
rustup target add aarch64-linux-android
cargo install cargo-ndk --locked
./android/scripts/build.sh release # android/app/build/outputs/apk/release/app-release.apk
```

It builds `libSDL2.so` on the first run, cross-compiles the Rust cdylib SDL loads,
and assembles the APK — see [android/README.md](android/README.md) for how the
port fits together.

## Controls

Touch works too, where there is a touchscreen: tap a row to act on it, or tap a
Expand Down Expand Up @@ -114,10 +141,10 @@ Settings screen edits everything in it except:
- `[transfer] pinned_paths`, `last_send_dir` — written by Y and by sending

Environment variables override paths and control logging at launch:
`RETSEND_DATA_DIR`, `RETSEND_CONFIG`, `RETSEND_SAVE_DIR`, `RETSEND_SCALE`,
`RETSEND_GLES=0|1`, `RETSEND_SOFTWARE=1`, `RETSEND_BLIT=1`,
`RETSEND_KEYMAP=miyoo|desktop`, `RETSEND_LOG_LEVEL`, `RETSEND_LOG_FILE`,
`RETSEND_PANIC_FILE`.
`RETSEND_DATA_DIR`, `RETSEND_CONFIG`, `RETSEND_SAVE_DIR`, `RETSEND_BROWSER_ROOTS`
(`:`-separated), `RETSEND_ALIAS`, `RETSEND_SCALE`, `RETSEND_GLES=0|1`,
`RETSEND_SOFTWARE=1`, `RETSEND_BLIT=1`, `RETSEND_KEYMAP=miyoo|desktop`,
`RETSEND_LOG_LEVEL`, `RETSEND_LOG_FILE`, `RETSEND_PANIC_FILE`.

`RETSEND_BLIT=1` rasterizes the UI offscreen and presents it as a single texture
copy per frame, for drivers that show nothing else — the Miyoo Mini's panel driver
Expand Down
14 changes: 14 additions & 0 deletions android/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Generated by android/scripts/sync-sdl.sh (copied from sdl2-sys, or built):
app/src/main/java/org/libsdl/app/
app/src/main/jniLibs/
app/src/main/res/mipmap-*/
gradle/wrapper/gradle-wrapper.jar
gradlew
gradlew.bat

# Gradle / build output:
.gradle/
build/
app/build/
local.properties
*.keystore
Loading
Loading