From 4026b0a269995dd8c8c55eda4332b30ff7652227 Mon Sep 17 00:00:00 2001 From: mxmgorin <102797145+mxmgorin@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:07:06 +0300 Subject: [PATCH] feat(android): ship an APK --- .cargo/config.toml | 7 + .github/workflows/build-android.yml | 125 ++++++++++++++++++ CHANGELOG.md | 19 +++ Cargo.lock | 30 ++++- Cargo.toml | 11 ++ README.md | 37 +++++- android/.gitignore | 14 ++ android/README.md | 125 ++++++++++++++++++ android/app/build.gradle | 72 ++++++++++ android/app/proguard-rules.pro | 4 + android/app/src/main/AndroidManifest.xml | 66 +++++++++ .../java/com/retsend/RetsendActivity.java | 123 +++++++++++++++++ .../com/retsend/RetsendLauncherActivity.java | 79 +++++++++++ .../src/main/java/com/retsend/Storage.java | 22 +++ android/app/src/main/res/values/strings.xml | 4 + android/build.gradle | 4 + android/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.properties | 5 + android/icon/ic_launcher.png | Bin 0 -> 6716 bytes android/icon/mipmap-hdpi/ic_launcher.png | Bin 0 -> 1679 bytes android/icon/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1245 bytes android/icon/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 2311 bytes android/icon/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 3377 bytes android/icon/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 4202 bytes android/scripts/build.sh | 76 +++++++++++ android/scripts/sync-sdl.sh | 84 ++++++++++++ android/settings.gradle | 16 +++ src/app/mod.rs | 13 +- src/config/device.rs | 21 ++- src/config/mod.rs | 2 +- src/config/paths.rs | 14 ++ src/event/keyboard.rs | 4 +- src/lib.rs | 48 ++++++- src/net/mod.rs | 10 ++ src/overlay/browser.rs | 6 +- 35 files changed, 1027 insertions(+), 17 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 .github/workflows/build-android.yml create mode 100644 android/.gitignore create mode 100644 android/README.md create mode 100644 android/app/build.gradle create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/com/retsend/RetsendActivity.java create mode 100644 android/app/src/main/java/com/retsend/RetsendLauncherActivity.java create mode 100644 android/app/src/main/java/com/retsend/Storage.java create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/build.gradle create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/icon/ic_launcher.png create mode 100644 android/icon/mipmap-hdpi/ic_launcher.png create mode 100644 android/icon/mipmap-mdpi/ic_launcher.png create mode 100644 android/icon/mipmap-xhdpi/ic_launcher.png create mode 100644 android/icon/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/icon/mipmap-xxxhdpi/ic_launcher.png create mode 100755 android/scripts/build.sh create mode 100755 android/scripts/sync-sdl.sh create mode 100644 android/settings.gradle diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..0c0d0f0 --- /dev/null +++ b/.cargo/config.toml @@ -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"] diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml new file mode 100644 index 0000000..3a29b19 --- /dev/null +++ b/.github/workflows/build-android.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2922402..3452340 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ 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 @@ -16,6 +30,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 diff --git a/Cargo.lock b/Cargo.lock index 8ae7527..57e1bd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter 0.1.4", + "log", +] + [[package]] name = "anstream" version = "1.0.0" @@ -432,6 +449,16 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + [[package]] name = "env_filter" version = "2.0.0" @@ -450,7 +477,7 @@ checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", - "env_filter", + "env_filter 2.0.0", "jiff", "log", ] @@ -1090,6 +1117,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" name = "retsend" version = "0.5.5" dependencies = [ + "android_logger", "egui-sdl2", "env_logger", "getrandom 0.4.3", diff --git a/Cargo.toml b/Cargo.toml index 0326fba..077cd2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] @@ -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" diff --git a/README.md b/README.md index 0932376..82e1d4a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ CI Linux ARM Linux + Android Dependencies @@ -15,7 +16,7 @@ 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 | |:---:|:---:|:---:|:---:| @@ -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: @@ -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 @@ -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 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..3a3e7de --- /dev/null +++ b/android/.gitignore @@ -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 diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..28a652b --- /dev/null +++ b/android/README.md @@ -0,0 +1,125 @@ +# Android port + +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). + +## Install + +Grab `retsend-android-arm64.apk` from +[Releases](https://github.com/mxmgorin/retsend/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**. + +## Building + +One-time setup: + +```sh +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: + +```sh +./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.apk +``` + +It 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`. + +## How the pieces fit + +- **Entry point** — `RetsendActivity.getLibraries()` returns `{"SDL2", + "retsend"}`, so SDL loads `libretsend.so` and calls its `SDL_main`, which hands + off to the same `run_app()` the desktop binary uses. +- **Paths** — `RetsendActivity.onCreate` sets the same `RETSEND_*` env vars the + PortMaster and OnionOS launchers set, before SDL starts: `RETSEND_DATA_DIR` + (internal `getFilesDir()`: config, TLS identity, history), `RETSEND_SAVE_DIR`, + `RETSEND_BROWSER_ROOTS` (the storage volumes it can reach), `RETSEND_ALIAS` + (`Build.MODEL`), `RETSEND_SCALE` (display density) and `RETSEND_PANIC_FILE`. +- **Permissions** — `RetsendLauncherActivity` is 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.MulticastLock` for 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 `AppCommand` a 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_BUTTON` makes it an `AC_BACK` key 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. + +## SDL version coupling + +`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. + +## Not done yet + +- **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_SEND` intent 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 `EguiWindow` doesn't + expose yet, hence the orientation lock. + +## Signing + +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: + +```sh +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_BASE64 +``` + +Non-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. diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..6bc5836 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,72 @@ +apply plugin: 'com.android.application' + +// Cargo.toml is the single source of truth for the version, so the APK can't +// drift from the crate the About screen names. +def crateVersion = { + def toml = new File(rootDir, "../Cargo.toml").getText("UTF-8") + def matcher = (toml =~ /(?m)^version = "([^"]+)"/) + if (!matcher) throw new GradleException("no package version found in ../Cargo.toml") + return matcher[0][1] +}() + +// Strides pack semver into a monotonically increasing versionCode, each bounding +// the place below it (minor < 100, patch < 100). +def MINOR_STRIDE = 100 +def MAJOR_STRIDE = 10000 +def (verMajor, verMinor, verPatch) = crateVersion.split('-')[0].tokenize('.').collect { it.toInteger() } + +android { + namespace 'com.retsend' + compileSdk 34 + + defaultConfig { + applicationId "com.retsend" + minSdk 24 + targetSdk 34 + versionCode verMajor * MAJOR_STRIDE + verMinor * MINOR_STRIDE + verPatch + versionName crateVersion + // One 64-bit ABI, which is every Android handheld. The Rust cdylib, + // libSDL2.so and libc++_shared.so are placed under src/main/jniLibs by + // cargo-ndk and scripts/sync-sdl.sh; Gradle builds no native code here. + ndk { + abiFilters 'arm64-v8a' + } + } + + // Sideloading, not Play: the release APK is signed with the same key CI + // restores from a secret. See android/README.md for a real upload key. + signingConfigs { + release { + storeFile file(System.getenv("RETSEND_KEYSTORE") ?: "debug.keystore") + storePassword System.getenv("RETSEND_KEYSTORE_PASS") ?: "android" + keyAlias System.getenv("RETSEND_KEY_ALIAS") ?: "androiddebugkey" + keyPassword System.getenv("RETSEND_KEY_PASS") ?: "android" + } + } + + buildTypes { + // Debug signs with the SAME key as release. AGP would otherwise use + // ~/.android/debug.keystore, and the signature mismatch makes + // `adb install -r` fail whenever you switch profiles. + debug { + signingConfig signingConfigs.release + } + release { + minifyEnabled false + signingConfig signingConfigs.release + } + } + + sourceSets { + main { + jniLibs.srcDirs = ['src/main/jniLibs'] + } + } + + lint { + abortOnError false + } +} + +// No dependencies: the SDL Java glue compiles from src/main/java/org/libsdl/app. +dependencies {} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..2c8e8c4 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,4 @@ +# Release is not minified, so these only matter if shrinking is ever turned on: +# SDL reaches its Java glue and our activities by name through JNI/reflection. +-keep class org.libsdl.app.** { *; } +-keep class com.retsend.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6dbec4a --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/retsend/RetsendActivity.java b/android/app/src/main/java/com/retsend/RetsendActivity.java new file mode 100644 index 0000000..a5b4523 --- /dev/null +++ b/android/app/src/main/java/com/retsend/RetsendActivity.java @@ -0,0 +1,123 @@ +package com.retsend; + +import android.content.Context; +import android.net.wifi.WifiManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.system.ErrnoException; +import android.system.Os; +import android.util.Log; + +import java.io.File; + +import org.libsdl.app.SDLActivity; + +/** + * SDL entry activity. SDL loads the libraries named here, in order, and then + * calls the {@code SDL_main} the Rust cdylib exports. + */ +public class RetsendActivity extends SDLActivity { + + private static final String TAG = "retsend"; + + /** Held for the app's lifetime: without it the Wi-Fi driver drops the + * multicast announces discovery is built on. */ + private WifiManager.MulticastLock multicastLock; + + @Override + protected String[] getLibraries() { + // SDL2 first, then libretsend.so, whose SDL_main is the entry point. + return new String[] {"SDL2", "retsend"}; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + // The Rust side reads all of these at startup, so they must be set + // before super.onCreate() loads the native libraries and starts SDL. + File data = getFilesDir(); + setEnv("RETSEND_DATA_DIR", data.getAbsolutePath()); + setEnv("RETSEND_PANIC_FILE", new File(data, "retsend-panic.log").getAbsolutePath()); + setEnv("RETSEND_SAVE_DIR", defaultSaveDir()); + setEnv("RETSEND_BROWSER_ROOTS", browserRoots()); + // Only seeds the alias; the Settings screen owns it from then on. + setEnv("RETSEND_ALIAS", Build.MODEL); + // Phone screens are dense enough that the UI would be unreadable at 1:1 + // pixels. ~1.0 (mdpi) .. ~3.5 (xxxhdpi), applied to egui's zoom factor. + setEnv("RETSEND_SCALE", String.valueOf(getResources().getDisplayMetrics().density)); + + acquireMulticastLock(); + + super.onCreate(savedInstanceState); + } + + @Override + protected void onDestroy() { + if (multicastLock != null && multicastLock.isHeld()) { + multicastLock.release(); + } + super.onDestroy(); + } + + /** The public Download folder when the card is writable, else our own + * external directory, which needs no permission but is invisible to file + * managers on Android 11+. */ + private String defaultSaveDir() { + if (Storage.hasAllFilesAccess(this)) { + File shared = Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS); + if (shared != null) { + return shared.getAbsolutePath(); + } + } + File own = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS); + return (own != null ? own : getFilesDir()).getAbsolutePath(); + } + + /** File-browser roots, `:`-separated: every storage volume the app can + * reach. Their paths are per-install, so the Rust side can't guess them. */ + private String browserRoots() { + StringBuilder roots = new StringBuilder(); + boolean all = Storage.hasAllFilesAccess(this); + for (File dir : getExternalFilesDirs(null)) { + if (dir == null) { + continue; + } + String path = dir.getAbsolutePath(); + // /storage//Android/data//files -> /storage/, + // which is the whole card (or the removable one) when granted. + int volume = path.indexOf("/Android/"); + if (all && volume > 0) { + append(roots, path.substring(0, volume)); + } + append(roots, path); + } + return roots.toString(); + } + + private static void append(StringBuilder roots, String path) { + if (roots.length() > 0) { + roots.append(':'); + } + roots.append(path); + } + + private void acquireMulticastLock() { + WifiManager wifi = (WifiManager) getApplicationContext() + .getSystemService(Context.WIFI_SERVICE); + if (wifi == null) { + return; + } + multicastLock = wifi.createMulticastLock(TAG); + multicastLock.setReferenceCounted(false); + multicastLock.acquire(); + } + + private void setEnv(String key, String value) { + try { + Os.setenv(key, value, true); + } catch (ErrnoException e) { + Log.w(TAG, "failed to set env " + key + ": " + e.getMessage()); + } + } +} diff --git a/android/app/src/main/java/com/retsend/RetsendLauncherActivity.java b/android/app/src/main/java/com/retsend/RetsendLauncherActivity.java new file mode 100644 index 0000000..a2255a1 --- /dev/null +++ b/android/app/src/main/java/com/retsend/RetsendLauncherActivity.java @@ -0,0 +1,79 @@ +package com.retsend; + +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Build; +import android.provider.Settings; +import android.util.Log; + +/** + * Asks for all-files access, then starts {@link RetsendActivity}. + * + *

It is a separate activity because the save folder and browser roots are + * derived from the granted permissions and read once, before SDL starts: asking + * from inside the SDL activity would settle those paths a launch too early, and + * the save folder is persisted to config.toml on first run. + */ +public class RetsendLauncherActivity extends Activity { + + private static final String TAG = "retsend"; + private static final String PREFS = "retsend"; + private static final String KEY_ASKED = "storage_asked"; + + /** Set while the grant UI is up; the return trip runs onResume again. */ + private boolean asking; + + @Override + protected void onResume() { + super.onResume(); + + if (!asking && !Storage.hasAllFilesAccess(this) && !alreadyAsked()) { + markAsked(); + if (requestAllFilesAccess()) { + asking = true; + return; + } + } + + // Whatever the answer: denied only costs the shared folders. + startActivity(new Intent(this, RetsendActivity.class)); + finish(); + } + + /** Whether any grant UI came up — false means carry on without it. */ + private boolean requestAllFilesAccess() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + requestPermissions( + new String[] {android.Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1); + return true; + } + Intent perApp = new Intent( + Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + Uri.parse("package:" + getPackageName())); + Intent list = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION); + for (Intent intent : new Intent[] {perApp, list}) { + try { + startActivity(intent); + return true; + } catch (ActivityNotFoundException e) { + Log.w(TAG, "no screen for " + intent.getAction()); + } + } + return false; + } + + private boolean alreadyAsked() { + return prefs().getBoolean(KEY_ASKED, false); + } + + private void markAsked() { + prefs().edit().putBoolean(KEY_ASKED, true).apply(); + } + + private SharedPreferences prefs() { + return getSharedPreferences(PREFS, MODE_PRIVATE); + } +} diff --git a/android/app/src/main/java/com/retsend/Storage.java b/android/app/src/main/java/com/retsend/Storage.java new file mode 100644 index 0000000..096be94 --- /dev/null +++ b/android/app/src/main/java/com/retsend/Storage.java @@ -0,0 +1,22 @@ +package com.retsend; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Environment; + +/** Whether the app may use plain file I/O across the card. */ +final class Storage { + + private Storage() {} + + static boolean hasAllFilesAccess(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + return Environment.isExternalStorageManager(); + } + // Up to API 29 the legacy runtime permission is the same thing, given + // android:requestLegacyExternalStorage in the manifest. + return context.checkSelfPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) + == PackageManager.PERMISSION_GRANTED; + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..2e6fa2e --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + retsend + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..14e8545 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,4 @@ +// Top-level build file. Plugin versions are declared here and applied in app/. +plugins { + id 'com.android.application' version '8.5.2' apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..2e11322 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..48c0a02 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/icon/ic_launcher.png b/android/icon/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..5781eab6d9579d38d428735c40ffda3982863e0c GIT binary patch literal 6716 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 Q=O6(3X17iG8~tPd30d3#+W-In literal 0 HcmV?d00001 diff --git a/android/icon/mipmap-hdpi/ic_launcher.png b/android/icon/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..78a9be2ebe90a786ff8afe16510bcc8c20f381b9 GIT binary patch literal 1679 zcmV;A25|X_P)M2yx~dIB+9?5+#W308y9>QG_6Y zKv;zU1vs$?BuIqBn#`8WOlC5fo~66(R~-CIoMwWj>9%{4_D{0hi`(w{x?Z`;eq9Qy zyFMN!CKvGS<4wT*z{?%jp3^F^HUcwV@CdL;+az$SJ(c(4&}x;kEVcsoYnvhGT3z>PTRza|v8PoLi(9p=t;mPh zMT%H#)3)%;wYsY7tF{sWBfb6>ajEqacZ{G49=ccnW4f@z?r2>A5@XPnBFn#>qW0r2 zIMoUgtPe=N2V?|Y@bJ&o7=P{nqdSLLIW>V(E+Xr|lX?qC1RMndW4rg$n4Cr$O-7%6 z4S!(~?RhRKHmA9yUe*&jcLKh3vPfjqtXT)ApP$9^E10;dL8LBY-<|oX4 z^fm5=DiWCR^}z}Y-yUc3)x$XDV*de>Ai&*F<=hwFFm>R4oWYBOa`gV}2VXLM=u@1b zYR`gp=71zHIMp(XKmW?aOK+eD3YXq)u)>*lRr&`Y3ld=OOf^uD?(O_)XFwL2{KJMmJArB4oHFkcc{wQqsN#&9G#Kc54PccDx5p^9cM<4 zhG)JEhFYqCBnWT^t1Nu;6Q}nc#;KG#8X95lRAuJSr_6rvrOlnvJ+q|-NCJaXF0uUQ z7-M^04>RI!WW^%ra*@eb53%s=ahpS>2PqvuM$iKu{(OzGox=pR~AnVX;-2mgGH zzn*-Fz{gzfor!VekMtsdyu2? z=gzbFo;%PF-3@^W&nTC`t~{5<=^18@e7?3{S1F|4VDr6qQo89n%t|Blqbv4b;^go7Wj2rI1FG;tkhOzU9WmVIxLx$^$GO|DBn)KePFgBpgCv{DA6hH?#5X z+X>b(Y_vk^O-#Mfk@s{CCvsxXzUWN>C0cK{=oV<$w~D14>X1C_y=( z1m%Ddlmkjo4k$r6pak{i-#y9}pNiyZ+R?T>n$F!DKqBFlJ|uYpg4W@-Vo*JRlr122 zVV;n8)kc)-tl=}^wPsZ)AR?I~>g<~I|CI=);jewvh@d<-)9XNy+~{&trbo42vGxf* z3dCeGAE-s*+I9_tnIF2Xn|so$7+ur0mj&6(*NRECxn!$|#TjiIeGvO|eXa{Ee$ciH zO0L!Qp|&kzreeJ;`dv}4k}2R_ZTsi!vv2wx?)*wn#9$vV7bOg}{=SJt`J+9*n{H$IiUTTT+v^=tBU Ze**z3#-jNl!3#* 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 literal 0 HcmV?d00001 diff --git a/android/icon/mipmap-xxhdpi/ic_launcher.png b/android/icon/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d6cca71e6ae1bd417b8be10e6a438f38377708e0 GIT binary patch literal 3377 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 literal 0 HcmV?d00001 diff --git a/android/icon/mipmap-xxxhdpi/ic_launcher.png b/android/icon/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4848f02f7bd131cdaa6195fb860288a182d3aee1 GIT binary patch literal 4202 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 literal 0 HcmV?d00001 diff --git a/android/scripts/build.sh b/android/scripts/build.sh new file mode 100755 index 0000000..c53081e --- /dev/null +++ b/android/scripts/build.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# One-command Android build: cross-compiles the Rust cdylib and assembles the APK. +# +# ./android/scripts/build.sh # debug APK (faster) +# ./android/scripts/build.sh release # release APK +# +# Prereqs (one-time): +# rustup target add aarch64-linux-android +# cargo install cargo-ndk --locked +# Android SDK + an NDK + CMake (Android Studio installs these). +# +# Auto-detects the SDK and the newest installed NDK; override with +# ANDROID_SDK_ROOT / ANDROID_NDK_HOME. +set -euo pipefail + +profile="${1:-debug}" +abi="arm64-v8a" +api="24" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # android/ +repo="$(cd "$here/.." && pwd)" + +# --- locate SDK + NDK --------------------------------------------------------- +sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Android/Sdk}}" +[ -d "$sdk" ] || { echo "Android SDK not found (set ANDROID_SDK_ROOT)"; exit 1; } +export ANDROID_SDK_ROOT="$sdk" + +ndk="${ANDROID_NDK_HOME:-${ANDROID_NDK:-}}" +if [ -z "$ndk" ]; then + ndk="$(ls -d "$sdk"/ndk/* 2>/dev/null | sort -V | tail -1 || true)" +fi +[ -n "$ndk" ] && [ -d "$ndk" ] || { echo "NDK not found (set ANDROID_NDK_HOME)"; exit 1; } +export ANDROID_NDK_HOME="$ndk" +echo "SDK: $sdk" +echo "NDK: $ndk" + +# --- toolchain checks --------------------------------------------------------- +rustup target list --installed | grep -q aarch64-linux-android \ + || { echo "run: rustup target add aarch64-linux-android"; exit 1; } +command -v cargo-ndk >/dev/null \ + || { echo "run: cargo install cargo-ndk --locked"; exit 1; } + +# --- SDL libs + glue (only if missing) ---------------------------------------- +if [ ! -f "$here/app/src/main/jniLibs/$abi/libSDL2.so" ]; then + echo "==> building libSDL2.so + syncing SDL glue" + bash "$here/scripts/sync-sdl.sh" +fi + +# --- link workaround ---------------------------------------------------------- +# NDK r23+ dropped libgcc, but rustc's aarch64-linux-android target spec still +# emits `-lgcc`. Redirect it to libunwind; the `-L` for this stub and for the +# bundled libSDL2.so live in .cargo/config.toml. +mkdir -p "$repo/target/ndk-libgcc-stub" +echo "INPUT(-lunwind)" > "$repo/target/ndk-libgcc-stub/libgcc.a" + +# --- build cdylib ------------------------------------------------------------- +echo "==> cross-compiling libretsend.so ($profile)" +cd "$repo" +ndk_flags=(-t "$abi" -P "$api" -o android/app/src/main/jniLibs build) +[ "$profile" = "release" ] && ndk_flags+=(--release) +cargo ndk "${ndk_flags[@]}" + +# --- assemble APK ------------------------------------------------------------- +echo "==> assembling APK ($profile)" +cd "$here" +# Both build types sign with this one keystore (see app/build.gradle) so +# reinstalls update in place instead of failing on a signature mismatch. +[ -f app/debug.keystore ] || keytool -genkeypair -v -keystore app/debug.keystore \ + -storepass android -keypass android -alias androiddebugkey \ + -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=retsend,O=retsend,C=US" +if [ "$profile" = "release" ]; then + ./gradlew --no-daemon assembleRelease + echo "APK: android/app/build/outputs/apk/release/app-release.apk" +else + ./gradlew --no-daemon assembleDebug + echo "APK: android/app/build/outputs/apk/debug/app-debug.apk" +fi diff --git a/android/scripts/sync-sdl.sh b/android/scripts/sync-sdl.sh new file mode 100755 index 0000000..1771a52 --- /dev/null +++ b/android/scripts/sync-sdl.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Pull the SDL pieces that must match the linked sdl2-sys version out of the +# Cargo registry into this Gradle project, and build the matching libSDL2.so: +# +# 1. Java glue -> app/src/main/java/org/libsdl/app/*.java +# 2. Gradle wrapper jar + gradlew (so ./gradlew works without a system Gradle) +# 3. libSDL2.so, built from that same SDL source for arm64-v8a +# 4. libc++_shared.so, from the NDK sysroot +# 5. Launcher icons, into the generated res/mipmap-* dirs +# +# Run `cargo fetch` first so the sdl2-sys source is present. Requires +# ANDROID_NDK_HOME (or ANDROID_NDK) and cmake on PATH. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # android/ +repo="$(cd "$here/.." && pwd)" +abi="arm64-v8a" +api="24" + +ndk="${ANDROID_NDK_HOME:-${ANDROID_NDK:-}}" +[ -n "$ndk" ] || { echo "set ANDROID_NDK_HOME (or ANDROID_NDK)"; exit 1; } + +# SDL 2.26 declares cmake_minimum_required 3.0.0, which CMake 4.x rejects. +# Prefer an Android-SDK-bundled CMake (3.x) over a too-new system one. +cmake_bin="${CMAKE:-}" +if [ -z "$cmake_bin" ]; then + sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Android/Sdk}}" + bundled="$(ls -d "$sdk"/cmake/*/bin/cmake 2>/dev/null | sort -V | tail -1 || true)" + cmake_bin="${bundled:-cmake}" +fi +echo "using cmake: $cmake_bin ($("$cmake_bin" --version | head -1))" + +cargo_home="${CARGO_HOME:-$HOME/.cargo}" +sdl_crate="$(find "$cargo_home/registry/src" -maxdepth 2 -type d -name 'sdl2-sys-*' | sort -V | tail -1)" +[ -n "$sdl_crate" ] || { echo "sdl2-sys not found; run 'cargo fetch' first"; exit 1; } +sdl_src="$sdl_crate/SDL" +sdl_proj="$sdl_src/android-project" +echo "using SDL from: $sdl_crate" + +# 1. Java glue +glue_dst="$here/app/src/main/java/org/libsdl/app" +mkdir -p "$glue_dst" +cp "$sdl_proj"/app/src/main/java/org/libsdl/app/*.java "$glue_dst/" + +# 2. Gradle wrapper (the jar is forward-compatible; gradle-wrapper.properties pins 8.7) +mkdir -p "$here/gradle/wrapper" +cp "$sdl_proj/gradle/wrapper/gradle-wrapper.jar" "$here/gradle/wrapper/" +cp "$sdl_proj/gradlew" "$sdl_proj/gradlew.bat" "$here/" +chmod +x "$here/gradlew" + +# 3. libSDL2.so +jnilibs="$here/app/src/main/jniLibs/$abi" +mkdir -p "$jnilibs" +build="$repo/target/sdl-android-$abi" +"$cmake_bin" -S "$sdl_src" -B "$build" \ + -DCMAKE_TOOLCHAIN_FILE="$ndk/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI="$abi" -DANDROID_PLATFORM="android-$api" \ + -DSDL_STATIC=OFF -DSDL_SHARED=ON \ + -DSDL_SENSOR=OFF >/dev/null # SDL 2.26's Android sensor calls ALooper_pollAll, + # gone from the NDK 27 headers and unused here. +"$cmake_bin" --build "$build" --target SDL2 -j"$(nproc 2>/dev/null || echo 4)" >/dev/null +cp "$build"/libSDL2.so "$jnilibs/" + +# 4. libc++_shared.so +host="linux-x86_64"; [ "$(uname)" = "Darwin" ] && host="darwin-x86_64" +cp "$ndk/toolchains/llvm/prebuilt/$host/sysroot/usr/lib/aarch64-linux-android/libc++_shared.so" \ + "$jnilibs/" + +# 5. Icons. res/mipmap-* is generated, so lay down SDL's placeholder for every +# density first (keeping @mipmap/ic_launcher resolvable) and overlay ours. +for dir in "$sdl_proj"/app/src/main/res/mipmap-*; do + [ -d "$dir" ] || continue + dst="$here/app/src/main/res/$(basename "$dir")" + mkdir -p "$dst" + cp "$dir"/ic_launcher.png "$dst/" 2>/dev/null || true +done +for dir in "$here"/icon/mipmap-*; do + [ -d "$dir" ] || continue + dst="$here/app/src/main/res/$(basename "$dir")" + mkdir -p "$dst" + cp "$dir"/ic_launcher.png "$dst/" +done + +echo "synced SDL glue + libs into $here" diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..46aa1e3 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "retsend" +include ':app' diff --git a/src/app/mod.rs b/src/app/mod.rs index 05c3ae1..34d53f5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -325,10 +325,15 @@ impl App { .osk .open(OskTarget::PeerAddress, &local_subnet_prefix()); } - ( - Focus::Tabs, - AppCommand::Start | AppCommand::Back | AppCommand::TogglePin | AppCommand::Alt, - ) => {} + // Nothing left to leave. Android's Back is a system button that has + // to lead somewhere, so there it quits; on the handhelds the + // launcher owns quitting and this stays inert. + (Focus::Tabs, AppCommand::Back) => { + if cfg!(target_os = "android") { + self.running = false; + } + } + (Focus::Tabs, AppCommand::Start | AppCommand::TogglePin | AppCommand::Alt) => {} (_, AppCommand::PickRow(_) | AppCommand::PickKey { .. } | AppCommand::PickTab(_)) => { unreachable!("taps are routed by their target above, before the focus match") } diff --git a/src/config/device.rs b/src/config/device.rs index e0138db..8a939bf 100644 --- a/src/config/device.rs +++ b/src/config/device.rs @@ -18,16 +18,33 @@ impl Default for DeviceConfig { Self { alias: default_alias(), device_model: "Retro Handheld".to_string(), - device_type: "desktop".to_string(), + device_type: default_device_type().to_string(), } } } -/// Hostname when readable (Linux-only targets), else a recognizable fallback. +/// `RETSEND_ALIAS` (Android has no hostname; the activity passes the device +/// model), else the hostname, else a recognizable fallback. fn default_alias() -> String { + if let Ok(alias) = std::env::var("RETSEND_ALIAS") { + let alias = alias.trim(); + if !alias.is_empty() { + return alias.to_string(); + } + } std::fs::read_to_string("/etc/hostname") .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| "retsend".to_string()) } + +/// The protocol has no handheld type, so they ride as desktops; Android is a +/// mobile. +fn default_device_type() -> &'static str { + if cfg!(target_os = "android") { + "mobile" + } else { + "desktop" + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 1e5513e..09f8da5 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -20,7 +20,7 @@ pub use device::DeviceConfig; pub use display::DisplayConfig; pub use input::InputConfig; pub use network::NetworkConfig; -pub use paths::{data_dir, device_scale}; +pub use paths::{data_dir, device_scale, env_browser_roots}; pub use transfer::TransferConfig; #[derive(Clone, Default, Serialize, Deserialize)] diff --git a/src/config/paths.rs b/src/config/paths.rs index 3711c03..ea549a2 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -46,6 +46,20 @@ pub(super) fn config_path() -> String { format!("{}config.toml", data_dir()) } +/// Extra file-browser roots from `RETSEND_BROWSER_ROOTS`, `:`-separated, joining +/// the detected mount points and `[transfer] browser_roots`. For launchers that +/// know paths no built-in candidate could name — the Android activity passes the +/// storage volumes it can reach, which are per-install. +pub fn env_browser_roots() -> Vec { + std::env::var("RETSEND_BROWSER_ROOTS") + .unwrap_or_default() + .split(':') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + /// Default directory for received files: `RETSEND_SAVE_DIR` (the PortMaster /// launcher points it at the device's ROMs root), else `~/Downloads` when it /// exists, else `received/` inside [`data_dir`]. diff --git a/src/event/keyboard.rs b/src/event/keyboard.rs index 8eb7260..d46c213 100644 --- a/src/event/keyboard.rs +++ b/src/event/keyboard.rs @@ -58,7 +58,9 @@ pub fn on_key_down(keymap: Keymap, kc: Keycode, repeat: bool, commands: &mut Vec fn desktop(kc: Keycode) -> Option { Some(match kc { Keycode::Return | Keycode::KpEnter => AppCommand::Confirm, - Keycode::Escape => AppCommand::Back, + // AcBack is Android's hardware/gesture Back, trapped into a key event by + // the hint `run_app` sets there. + Keycode::Escape | Keycode::AcBack => AppCommand::Back, // Both the pad's label and the key a desktop hand reaches for. Keycode::X | Keycode::Backspace => AppCommand::Alt, Keycode::F1 => AppCommand::Start, diff --git a/src/lib.rs b/src/lib.rs index 693f0b7..bf0867f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,8 +11,8 @@ mod ui; use crate::app::App; -/// Shared startup for the desktop binary (and later the handheld launcher — -/// same binary, different env). Mirrors retsurf's `run_app`. +/// Shared startup for the desktop binary, the handheld launcher (same binary, +/// different env), and Android's `SDL_main`. Mirrors retsurf's `run_app`. pub fn run_app() { // Capture panics before anything else can panic. On the handheld the // launcher usually discards stderr, so a bare panic leaves no trace beyond @@ -29,6 +29,8 @@ pub fn run_app() { if let Ok(v) = std::env::var("RETSEND_GLES") { app_config.display.use_gles = v != "0"; } + #[cfg(target_os = "android")] + android_startup(&mut app_config); transfer::files::sweep_stale_parts(std::path::Path::new(&app_config.transfer.save_dir)); @@ -42,6 +44,8 @@ pub fn run_app() { // On a Wayland desktop SDL still often defaults to x11; align it to // Wayland. On the handheld (no WAYLAND_DISPLAY) this is skipped and SDL // falls back to its kmsdrm driver. An explicit SDL_VIDEODRIVER wins. + // Android has its own video driver and no WAYLAND_DISPLAY, so skip it. + #[cfg(not(target_os = "android"))] if std::env::var_os("SDL_VIDEODRIVER").is_none() && std::env::var_os("WAYLAND_DISPLAY").is_some() { @@ -54,6 +58,46 @@ pub fn run_app() { app.run(); } +/// SDL's Android shell (`SDLActivity`) `dlopen`s our cdylib and calls this on +/// its own thread — Android's `src/main.rs`. +#[cfg(target_os = "android")] +#[no_mangle] +pub extern "C" fn SDL_main( + _argc: std::os::raw::c_int, + _argv: *const *const std::os::raw::c_char, +) -> std::os::raw::c_int { + run_app(); + 0 +} + +/// What only Android decides. Its paths, alias and UI scale arrive as the same +/// `RETSEND_*` env vars the handheld launchers set, written by `RetsendActivity` +/// before SDL starts. +#[cfg(target_os = "android")] +fn android_startup(config: &mut config::AppConfig) { + // Mali/Adreno/PowerVR expose only GLES, so desktop GL is never an option. + config.display.use_gles = true; + + // egui-sdl2 synthesizes a pointer stream from finger events itself; SDL's own + // synthesis would deliver every tap twice. + std::env::set_var("SDL_TOUCH_MOUSE_EVENTS", "0"); + + // Untrapped, Back backgrounds the activity and the app never sees it. Trapped, + // it arrives as an AC_BACK key — the only way out of a screen without a pad. + std::env::set_var("SDL_ANDROID_TRAP_BACK_BUTTON", "1"); +} + +#[cfg(target_os = "android")] +fn init_logging() { + // No stderr on Android; route `log` to logcat (`adb logcat -s retsend`). + android_logger::init_once( + android_logger::Config::default() + .with_max_level(log::LevelFilter::Info) + .with_tag("retsend"), + ); +} + +#[cfg(not(target_os = "android"))] fn init_logging() { let env = env_logger::Env::default() .filter_or("RETSEND_LOG_LEVEL", "info") diff --git a/src/net/mod.rs b/src/net/mod.rs index 44c6e13..6c7daa7 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -244,9 +244,17 @@ pub fn sample_status() -> NetStatus { } } +/// Android has neither wireless tool, and the framework's SSID needs the +/// location permission — the Receive screen shows the address alone. +#[cfg(target_os = "android")] +pub fn wifi_ssid() -> Option { + None +} + /// Best-effort connected Wi-Fi SSID. Tries `iwgetid -r`, then `iw dev /// link` for each wireless interface under `/sys/class/net`. None if no /// wireless link is up or neither tool exists (e.g. desktop on ethernet). +#[cfg(not(target_os = "android"))] pub fn wifi_ssid() -> Option { if let Some(out) = run_stdout("iwgetid", &["-r"]) { let ssid = out.trim(); @@ -272,6 +280,7 @@ pub fn wifi_ssid() -> Option { /// Run a command, returning its stdout on a zero exit (None if it can't spawn /// or fails). Used only for the optional Wi-Fi probes above. +#[cfg(not(target_os = "android"))] fn run_stdout(cmd: &str, args: &[&str]) -> Option { let out = std::process::Command::new(cmd).args(args).output().ok()?; out.status @@ -281,6 +290,7 @@ fn run_stdout(cmd: &str, args: &[&str]) -> Option { /// Wireless interface names: `/sys/class/net` entries exposing a `wireless` /// subdirectory. +#[cfg(not(target_os = "android"))] fn wireless_interfaces() -> Vec { let Ok(entries) = std::fs::read_dir("/sys/class/net") else { return Vec::new(); diff --git a/src/overlay/browser.rs b/src/overlay/browser.rs index 8d0b18f..dfd9751 100644 --- a/src/overlay/browser.rs +++ b/src/overlay/browser.rs @@ -396,7 +396,11 @@ fn build_roots(extra: &[String]) -> Vec { .map(PathBuf::from) .filter(|p| p.is_dir()) .collect(); - for path in extra { + for path in extra + .iter() + .cloned() + .chain(crate::config::env_browser_roots()) + { let path = PathBuf::from(path); if path.is_dir() && !roots.contains(&path) { roots.push(path);