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 0000000..5781eab Binary files /dev/null and b/android/icon/ic_launcher.png differ diff --git a/android/icon/mipmap-hdpi/ic_launcher.png b/android/icon/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..78a9be2 Binary files /dev/null and b/android/icon/mipmap-hdpi/ic_launcher.png differ diff --git a/android/icon/mipmap-mdpi/ic_launcher.png b/android/icon/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..6af55ab Binary files /dev/null and b/android/icon/mipmap-mdpi/ic_launcher.png differ diff --git a/android/icon/mipmap-xhdpi/ic_launcher.png b/android/icon/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..8107103 Binary files /dev/null and b/android/icon/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/icon/mipmap-xxhdpi/ic_launcher.png b/android/icon/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d6cca71 Binary files /dev/null and b/android/icon/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/icon/mipmap-xxxhdpi/ic_launcher.png b/android/icon/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4848f02 Binary files /dev/null and b/android/icon/mipmap-xxxhdpi/ic_launcher.png differ 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);