Android support #761

Merged
SakulFlee merged 66 commits from android into main 2026-08-18 14:33:26 +00:00
Owner

Fixes #573

Fixes #573
- New CLI tool for building Orbital projects for Android
- Orbital.toml config file format (replaces Cargo.toml metadata)
- Commands: init, build, run
- Supports both standalone projects and workspaces
- Uses --package flag (optional in standalone mode)
- Android/ is now generated by 'orbital init android'
- Added Android/ to .gitignore
- Removed stale cargo-ndk-android Gradle plugin dependency
- Add Tools/* to workspace members
- Remove old workspace.metadata.orbital.android section (now in Orbital.toml)
- New 'orbital init' command for creating projects from scratch
- Interactive prompts for project name, package, template, SDK versions
- Non-interactive mode with --yes flag
- Templates: minimal, skybox, instancing, gltf
- Generates Cargo.toml, Orbital.toml, src/lib.rs, src/main.rs
- Android project generation is optional (use 'orbital init android' later)
- Reorder prompts: Android support is now asked first
- SDK versions only asked if Android is enabled
- Auto-run 'orbital init android' after project creation
- Add --android flag for non-interactive mode
- Better prompt flow: name -> android -> package -> sdk -> template
- Change 'Android/' to '/Android/' to only ignore top-level directory
- Add check for cargo-ndk before attempting build
- Provide clear installation instructions when cargo-ndk is missing
- Check for cargo-ndk before building
- Automatically install via 'cargo install cargo-ndk' if not found
- Verify installation after auto-install
- Provide fallback manual instructions if auto-install fails
- Add 'targets' field to Orbital.toml (arm64-v8a, armeabi-v7a, x86_64, x86)
- Add 'apk_mode' field: 'multiarch', 'single', or 'both'
- Auto-check for missing Rust targets and prompt to install
- Auto-install cargo-ndk if missing
- Add SDK/NDK detection and setup wizard
- Support downloading Android command-line tools
- Interactive prompts for SDK path or download option
- Fix init message to say 'orbital build android' (not --package)
- Add -UseBasicParsing to PowerShell Invoke-WebRequest
- Show manual instructions when download fails
- Don't bail on download failure, let user continue with manual setup
- Update to correct version (15859902) from Google's repository
- Add macOS ARM64 support (Apple Silicon)
- Fetch repository index from Google
- Parse XML to find latest version for each platform
- Support Linux, macOS (x86_64 and ARM64), and Windows
- Fallback to known version if parsing fails
- Fix export syntax for Windows (set vs export)
- Fix path separator in manual instructions
- Use to_string_lossy() instead of to_str() for non-UTF-8 paths
- Platform-specific environment variable instructions
- Find cmdline-tools;latest package first
- Then find the archive with matching host-os
- Extract URL from the correct location
- Handle indented XML tags properly
- Remove complex state machine parsing
- Simply find all <url> tags with commandlinetools and platform suffix
- Extract version from the URL directly
- Much more reliable and easier to maintain
- Add 10 second timeout to network requests
- Gracefully handle failures and fall back to known version
- Don't fail if network is unavailable
- Much more robust approach
- Replace PowerShell Expand-Archive with tar (available on Windows 10+)
- tar is more reliable and doesn't require module loading
- unzip remains for macOS/Linux
- Add 500ms delay after download completes
- Ensures file handle is released before extraction
- Fixes 'file is being used by another process' error
- Replace PowerShell Invoke-WebRequest with curl
- curl is available on Windows 10+, macOS, and Linux
- Avoids file locking issues with PowerShell
- More consistent cross-platform behavior
- Add curl progress bar (-# flag)
- Remove existing partial downloads before starting
- Wait for file to be fully written before extracting
- Retry extraction up to 3 times with delays
- Clean up duplicate code blocks
- Replace curl with ureq for cross-platform HTTP downloads
- No external dependencies needed (curl, PowerShell)
- Built-in Rust solution that works everywhere
- Proper error handling and progress
- Download to .tmp file first, then rename to final name
- Ensures file is fully written and closed before extraction
- Avoids Windows file locking issues with antivirus/indexer
- Explicitly drop file handle before rename
- Replace tar/unzip with zip crate for extraction
- No external process dependencies (tar, unzip, curl, PowerShell)
- Avoids Windows file locking issues with antivirus/indexer
- Also use ureq for fetching repository index
- Fully cross-platform Rust solution
- Windows antivirus/indexer may lock extracted .exe files
- Retry rename up to 5 times with 1 second delays
- Gracefully handle failure with manual instructions
- Strip top-level cmdline-tools/ folder from zip entries during extraction
- Write files straight to <sdk>/cmdline-tools/latest/...
- Eliminates the directory rename that Windows blocks on fresh files
- Add ndk_version to AndroidConfig (default 26.2.11394342)
- Write ndk_version into generated Gradle template (keeps in sync)
- Replace fragile sdkmanager --list parsing with direct version install
- Auto-accept Android SDK licenses via standard hash files
- Print warning that licenses are being auto-accepted
- Update engine Orbital.toml and init template with ndk_version
- Add tooling.rs: Orbital cache dir helpers (matches engine IBL path)
  - Linux ~/.cache/Orbital, macOS ~/Library/Caches/Orbital, Windows %LOCALAPPDATA%\\Orbital
- Add java.rs: find/download/ensure Temurin 25 JRE via Adoptium API
  - Detection: Orbital cache -> JAVA_HOME -> PATH -> prompt to install
  - Download via ureq, extract via zip/tar+flate2, cached in ~/.cache/Orbital/jdk/25
- Refactor ensure_android_sdk detection chain:
  Orbital cache -> ANDROID_HOME -> ANDROID_SDK_ROOT -> ANDROID_SDK -> Studio defaults
- download_sdk default path now ~/.cache/Orbital/android-sdk
- Wire JAVA_HOME into sdkmanager and gradlew subprocesses
- Add tar + flate2 deps
- find_java() now scans jdk/25/ subdirectories via find_jre_home()
- Generated projects use git dependency instead of broken path/to/orbital
- Add engine_repo/engine_branch to Orbital.toml [orbital] config
- Add --engine-repo/--engine-branch flags to orbital init
- Configurable: Orbital.toml, CLI flags, or env vars (ORBITAL_ENGINE_REPO)
- Default: Forgejo repo, main branch
Engine:
- Add android_logger to orbital_core android-target deps

CLI:
- Add indicatif progress bar for SDK and JRE downloads
- Add shared download_with_progress helper in tooling.rs
- Make platform optional in build/run, defaults to desktop
- Add desktop module (cargo build / cargo run)
- Simplify init/next-steps messaging (drop manual SDK/NDK install)
- Default engine_branch to android
- Update Orbital.toml engine_branch
Engine:
- Add make_main! macro combining desktop + android entry points
- Re-export make_main from orbital facade crate

CLI:
- Remove unused winit:⌨️:KeyCode import from all init templates
- Qualify AndroidApp in fn signature (use inside body doesn't cover signature scope)
- Use #[unsafe(no_mangle)] required by Rust 2024
- Verified: compiles for host and aarch64-linux-android
- Use proguard-android-optimize.txt (proguard-android.txt removed in AGP)
- Remove dead text templates from src/template/ (only copied files kept)
- Lowercase generated lib/bin name to satisfy snake_case lint
- Keep Cargo.toml and main.rs references consistent
- Switch Adoptium URL from /jre/ to /jdk/ (Gradle needs the compiler)
- Require javac in find_java, so cached JRE is rejected
- Prefer full JDK dir when scanning cache
- Verify javac exists after extraction
The template referenced @mipmap/ic_launcher but never generated icon
resources, causing AAPT failure. Use Android's default system icon
until custom icons are supported.
- New android/device.rs: resolve adb/emulator from the SDK (PATH fallback),
  list devices/AVDs, boot AVDs in the background and wait for boot.
- android/sdk.rs: add current_sdk_path, sdkmanager_install, and
  avdmanager-based AVD creation with a smart-default system image
  (target_sdk API, host-matching ABI).
- android/run.rs: build -> select device/emulator -> install -> launch.
  If no device is connected, boot an existing AVD; if none exists, offer a
  single-confirm setup (~2GB) that installs the emulator + system image and
  creates an AVD. APK selection is ABI-aware for single-mode builds.
- orbital run gains --device, --skip-build, and --logcat flags.
resolve_emulator now returns None when neither the SDK emulator package
nor a PATH emulator exists, so 'orbital run android' reaches the
single-confirm setup instead of crashing on 'emulator -list-avds'.
The emulator path is re-resolved after setup installs the package.
- Redirect emulator stdout/stderr to a log file in the temp dir and print
  its path, so startup failures are diagnosable instead of invisible.
- Raise the wait-for-appearance timeout from 120s to 300s and print
  periodic progress; same for the full boot-wait timeout.
- Drop -no-snapshot (full cold boot each time) in favor of -no-boot-anim,
  so subsequent boots resume from the quick-boot snapshot.
The emulator Fatal'd with 'Cannot find AVD system path' because an
existing Android Studio AVD referenced a system image that isn't installed
in any SDK. Now:

- boot_avd reads the AVD's config.ini (image.sysdir.1) and sets
  ANDROID_SDK_ROOT/ANDROID_HOME to whichever SDK actually hosts the image.
- select_device filters to AVDs whose system image is present; if none are
  bootable it falls back to creating a working AVD.
- setup_new_avd only installs what's missing and detects incomplete system
  images (dir with only an .installer marker, no system.img).
- run: launch the actual applicationId (the [android] package value)
  instead of appending the crate name, which produced a package that
  didn't exist (Error type 3 / Activity class does not exist).
- project init: stop pre-replacing @@@LIBRARY_NAME@@@ with 'placeholder'
  and @@@APP_NAME@@@ with 'Orbital App'; these are now finalized during
  build with the real crate/lib names, so NativeActivity loads the
  correct .so.
- run: detect am start failures by inspecting output (am exits 0 even on
  failure) instead of only checking the exit code.
- run: automatically attach to logcat after launch by default; --no-logcat
  opts out and prints the resolved adb path (adb is not on PATH).
On Windows the emulator was shut down ('Wait for emulator ... to shutdown
gracefully before kill') as soon as orbital run exited. Spawn it detached
(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, own process group on Unix)
so the app keeps running after the CLI returns.
- Clear the logcat buffer right before am start so the attached stream
  only shows the current run (previously stale buffered crashes like the
  old 'placeholder' lib error flooded the output).
- Include AndroidRuntime/DEBUG tags in the logcat stream so Java
  exceptions and native SIGSEGV are visible instead of looking like
  'nothing happened'.
- After launch, poll pidof to confirm the app process is running and
  report its pid, or warn if it never appears.
- Add ndk_path() and ensure_ndk() helpers to resolve and verify NDK location
- Refactor ensure_sdkmanager() to delegate NDK checking to ensure_ndk()
- In build(), resolve SDK/NDK paths and pass ANDROID_NDK_HOME to cargo ndk subprocess
- Fixes 'Could not find any NDK' error when NDK is installed inside ANDROID_HOME
The zip crate's manual extraction via std::io::copy drops Unix permission
bits, leaving shell scripts like sdkmanager at 644 (non-executable).
This caused 'Permission denied' errors when running sdkmanager on Linux.

Set 0o755 on all extracted files after writing them.
find_java_on_path() returns /usr/bin/java, then the old code went two
parent() calls up to get JAVA_HOME=/usr — which is wrong.

Add resolve_jdk_home() that follows symlinks via canonicalize(), then
walks up from bin/ looking for include/ or release/ to find the real
JDK root (e.g. /usr/lib/jvm/java-26-openjdk).
The template gradlew script has CRLF line endings from being checked
out on Windows. On Linux the shebang becomes #!/bin/sh\r which causes
'No such file or directory' when executing gradlew.
The android_logger default tag is AndroidStdOut, but the orbital run
android command filters logcat by the tag rust_std_out. This mismatch
meant engine logs were never visible in the logcat stream, making it
impossible to diagnose runtime issues on Android.
gilrs has no Android support (Input: ✕, Hotplugging: ✕, Force feedback:
✕). The Gilrs::new().expect() call in module_runtime.rs hard-crashes on
Android because the initialization fails on unsupported platforms.

Gate all gilrs-dependent code behind #[cfg(all(feature = gamepad_input,
not(target_os = android)))] so it is excluded from Android builds. This
prevents the crash while keeping gamepad support intact for desktop.
FileManager::init_android_global() must be called before the engine
starts on Android, otherwise FileManager::global() returns
FsError::NotInitialized and any asset/shader loading panics.

All four entrypoint templates (minimal, skybox, instancing, gltf) now
call FileManager::init_android_global(app.asset_manager(),
app.internal_data_path()) gated behind #[cfg(target_os = android)].

This requires EventLoopExtAndroid::android_app() which is available in
winit 0.30.5+ (resolved to 0.30.13 via semver).
All examples previously used make_desktop_main! which only generates a
desktop fn main() — no android_main symbol was exported, causing the
APK to crash instantly on launch with no symbol found.

Switch all 7 examples to make_main! which generates both desktop main()
and Android android_main(app). Each entrypoint now also initializes
FileManager for Android via FileManager::init_android_global() so that
asset loading from the APK works at runtime.
The logcat filter previously only captured rust_std_out, AndroidRuntime,
and DEBUG. Adding the linker tag captures dynamic linker errors (missing
symbols, unresolved shared libraries) which are common causes of
instant crashes on Android that otherwise produce no visible output.
The FileManager import was unconditional, causing unused_imports warnings
on desktop builds (where the only usage is inside #[cfg(target_os =
android)]). Gate the import to match its usage.
- Move logging::init() and FileManager::init_android_global() into
  make_android_main! macro so they run before event loop creation
- Handle EventLoopError::RecreationAttempt gracefully (log and return
  instead of panicking) so activity recreation in the same process
  doesn't crash the app
- Remove redundant logging/FileManager init from all 4 CLI templates
  and all 7 examples
- Gate logging::init() to desktop-only in entrypoints since the macro
  handles it on Android
- Move logging::init() and FileManager::init_android_global() into
  make_android_main! macro so they run before event loop creation
- Handle EventLoopError::RecreationAttempt gracefully (log and return
  instead of panicking) so activity recreation in the same process
  doesn't crash the app
- Remove redundant logging/FileManager init from all 4 CLI templates
  and all 7 examples
- Gate logging::init() to desktop-only in entrypoints since the macro
  handles it on Android
The Android emulator's Vulkan adapter lacks the SURFACE_VIEW_FORMATS
downlevel flag. wgpu-core requires it whenever a surface is configured
with non-empty view_formats, producing a MissingDownlevelFlags error
that panics (handled as fatal). Skip view_formats unless the adapter
supports the flag; the frame texture view already uses the config's
main format via get_first_view_format.

Also register an on_uncaptured_error handler so future wgpu errors are
logged through the log crate (visible in logcat) instead of a panic
whose output gets filtered.
Add back_presses_to_exit (default 0 = disabled) and back_exit_window
(default 2s) to AppSettings. The runtime tracks consecutive Android
Back presses (NamedKey::BrowserBack) in window_event; when the
configured count is reached it exits gracefully.

Back presses are always still forwarded to the input system, so apps
using single back actions keep full control by setting
back_presses_to_exit = 0. Templates and examples set it to 3 (triple
back to exit).
Part 1 - universal lifecycle recovery:
- make_android_main! terminates the process when the event loop build
  fails (RecreationAttempt) and after a clean exit. winit allows only one
  EventLoop per process; an aggressive OOM that destroys the activity but
  keeps the process alive used to leave it poisoned, requiring a manual
  kill. Now any relaunch starts a fresh process.

Part 2 - persistence hooks:
- Add save_state/restore_state/clear_state defaults to the Module trait.
- ModuleRuntime calls save_state on suspend (Pause), restore_state once
  after setup (fresh process only), and clear_state on clean exit.
- Add FileManager::remove_file (Storage trait + Dir/Desktop/Android impls)
  so clean exit can delete saved state.
- Minimal template demonstrates by persisting/restoring/clearing the
  camera position via FileManager.
Diagnosis from a Bevy 0.19 Android comparison on-device: Bevy hits the
same winit RecreationAttempt panic on a recreated activity, but survives
because it does NOT kill the process - the panicking thread is the new
android_main thread, while the ORIGINAL event loop keeps running and
resumes on reopen. Our process::exit(0) killed the whole process,
causing the close-then-auto-restart.

Changes:
- make_android_main!: on RecreationAttempt, just return (let the
  android_main thread end) instead of process::exit(0); also remove the
  exit(0) after the event loop returns.
- AppState::Paused now carries the AppContext so the native window
  survives suspend instead of being dropped.
- suspended(): move the context into Paused rather than dropping it.
- resumed(): reuse the existing context (no create_window) when resuming
  from a pause; only build a fresh context on first start. Mirrors
  Bevy's suspend/resume handling.
On Android the native window is destroyed on suspend and recreated on
resume. Reusing the old wgpu Surface (bound to the destroyed native
window) left the screen black after resume, even though frames kept
rendering at 60fps.

- AppContext.surface is now Option<Surface>, dropped on suspend and
  rebuilt against the (recreated) native window on resume via
  AppContext::recreate_surface (mirrors Bevy's surface teardown).
- suspended() drops the surface and the renderer (which holds
  surface-format-dependent GPU resources); resumed() recreates both.
- Keeps window/device/queue alive across suspend (no expensive device
  rebuild, matching Bevy). ECS-side caches (import/mesh/material) are
  CPU-side and retained.
fix(android): only recreate surface on resume, not first start
Some checks failed
ci/woodpecker/pr/linting Pipeline failed
ci/woodpecker/pr/test unknown status
41cefc1114
The previous change called recreate_surface() unconditionally in
resumed(), including on first start where AppContext::new already
created and configured the surface. Creating a second surface from the
same winit window and dropping the first crashes instantly on Android
(no Rust panic - a native failure), so the app would not launch at all.

Now recreate_surface() is only called when resuming from a Paused state
(surface was dropped on suspend). On first start the surface from
AppContext::new is used as-is. Preserves the black-screen fix while
restoring normal first launch.
style: auto-format code
Some checks failed
ci/woodpecker/pr/linting Pipeline failed
ci/woodpecker/pr/test unknown status
fd51c647cd
fix: auto-fix clippy warnings
Some checks failed
ci/woodpecker/pr/linting Pipeline failed
ci/woodpecker/pr/test unknown status
b2b697af2e
style: auto-format code
All checks were successful
ci/woodpecker/pr/linting Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
127d2a5c11
Sign in to join this conversation.
No description provided.