,
+ // 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);