commit 64e33d4d18c6fcc3426b3ae068eb0efd25c80a21
parent 40279340e601ef15e3c1a32d61573cce3441a183
Author: triesap <tyson@radroots.org>
Date: Sat, 21 Mar 2026 03:50:20 +0000
build: add initial android launcher shell
- add the radroots-app-android launcher crate and rust entrypoint
- add the platforms/android host project, resources, and temporary copied icon asset
- add android toolchain bootstrap, host build automation, and emulator launch scripts
- wire the android shell to the shared rust app with the current unsupported onboarding state
Diffstat:
21 files changed, 939 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
@@ -4,6 +4,11 @@
/crates/web/dist/
/native/apple/swift/**/.build/
/native/apple/swift/**/.swiftpm/
+/platforms/android/.gradle/
+/platforms/android/.tooling/
+/platforms/android/app/build/
+/platforms/android/build/
+/platforms/android/local.properties
/platforms/ios/.derived-data*/
/platforms/ios/*.xcodeproj/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
@@ -21,6 +21,8 @@ Install Trunk for the wasm target:
cargo install trunk
```
+On hosts that will build or run the Android shell, ensure Java 17 or newer is available. The Android scripts bootstrap the local Gradle, SDK, NDK, `cargo-ndk`, and emulator resources into `platforms/android/.tooling` on demand.
+
On macOS, ensure the Apple Swift toolchain is available. The desktop target links the shared Apple native security package during build.
Confirm your environment:
@@ -29,6 +31,7 @@ Confirm your environment:
cargo --version
rustc --version
trunk --version
+java --version
```
On macOS, also confirm:
@@ -80,6 +83,24 @@ Run the native application:
cargo run -p radroots-app-desktop
```
+Check the Android target:
+
+```bash
+./scripts/check-android-target.sh
+```
+
+Build the Android host:
+
+```bash
+./scripts/build-android-host.sh
+```
+
+Run the Android app in the emulator:
+
+```bash
+./scripts/run-android-emulator.sh
+```
+
Check the wasm application:
```bash
diff --git a/Cargo.lock b/Cargo.lock
@@ -101,6 +101,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
[[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",
+ "log",
+]
+
+[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -142,6 +159,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -835,6 +858,7 @@ dependencies = [
"egui-wgpu",
"egui-winit",
"egui_glow",
+ "glow",
"glutin",
"glutin-winit",
"image",
@@ -966,6 +990,16 @@ dependencies = [
]
[[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 = "epaint"
version = "0.33.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2736,6 +2770,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
+name = "radroots-app-android"
+version = "0.1.0"
+dependencies = [
+ "android_logger",
+ "eframe",
+ "log",
+ "radroots-app-core",
+ "wgpu",
+ "winit",
+]
+
+[[package]]
name = "radroots-app-apple-security"
version = "0.1.0"
dependencies = [
@@ -2944,6 +2990,18 @@ dependencies = [
]
[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3105,6 +3163,19 @@ dependencies = [
]
[[package]]
+name = "sctk-adwaita"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
+dependencies = [
+ "ab_glyph",
+ "log",
+ "memmap2",
+ "smithay-client-toolkit 0.19.2",
+ "tiny-skia",
+]
+
+[[package]]
name = "secp256k1"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3391,6 +3462,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
+name = "strict-num"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
+
+[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3544,6 +3621,31 @@ dependencies = [
]
[[package]]
+name = "tiny-skia"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "bytemuck",
+ "cfg-if",
+ "log",
+ "tiny-skia-path",
+]
+
+[[package]]
+name = "tiny-skia-path"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
+dependencies = [
+ "arrayref",
+ "bytemuck",
+ "strict-num",
+]
+
+[[package]]
name = "tinystr"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4581,6 +4683,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
+ "sctk-adwaita",
"smithay-client-toolkit 0.19.2",
"smol_str",
"tracing",
diff --git a/Cargo.toml b/Cargo.toml
@@ -1,6 +1,7 @@
[workspace]
members = [
"crates/apple/security",
+ "crates/android",
"crates/core",
"crates/desktop",
"crates/ios",
@@ -19,8 +20,9 @@ homepage = "https://radroots.org"
readme = "README.md"
[workspace.dependencies]
+android_logger = "0.15.1"
directories = "6"
-eframe = { version = "0.33.3", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11"] }
+eframe = { version = "0.33.3", default-features = false, features = ["android-game-activity", "default_fonts", "glow", "wgpu", "wayland", "x11"] }
egui = { version = "0.33.3", features = ["serde"] }
log = "0.4.28"
nostr = { version = "0.44.1", default-features = false, features = ["std"] }
@@ -32,6 +34,7 @@ radroots-nostr-accounts = { path = "../lib/crates/nostr-accounts", default-featu
wasm-bindgen-futures = "0.4.50"
web-sys = { version = "0.3.91", features = ["Document", "HtmlCanvasElement", "Window"] }
wgpu = { version = "27.0.1", default-features = false }
+winit = { version = "0.30.13", features = ["android-game-activity"] }
zeroize = "1.8.2"
[workspace.lints.rust]
diff --git a/crates/android/Cargo.toml b/crates/android/Cargo.toml
@@ -0,0 +1,28 @@
+[package]
+name = "radroots-app-android"
+authors.workspace = true
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+repository.workspace = true
+homepage.workspace = true
+description = "Rad Roots Android launcher"
+publish = false
+
+[lib]
+path = "src/lib.rs"
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+eframe.workspace = true
+log.workspace = true
+radroots-app-core = { path = "../core" }
+
+[target.'cfg(target_os = "android")'.dependencies]
+android_logger.workspace = true
+wgpu = { workspace = true, features = ["vulkan", "gles", "wgsl"] }
+winit.workspace = true
+
+[lints.rust]
+unsafe_code = { level = "allow", priority = 1 }
diff --git a/crates/android/src/lib.rs b/crates/android/src/lib.rs
@@ -0,0 +1,91 @@
+#![allow(unsafe_code)]
+
+#[cfg(target_os = "android")]
+use android_logger::Config;
+#[cfg(target_os = "android")]
+use eframe::egui::ViewportBuilder;
+#[cfg(target_os = "android")]
+use radroots_app_core::{APP_NAME, RadrootsApp};
+#[cfg(any(target_os = "android", test))]
+use radroots_app_core::{IdentityGateState, RadrootsAppBackend, SetupActionState};
+#[cfg(target_os = "android")]
+use winit::platform::android::activity::AndroidApp;
+
+#[cfg(any(target_os = "android", test))]
+struct AndroidBackend;
+
+#[cfg(any(target_os = "android", test))]
+impl RadrootsAppBackend for AndroidBackend {
+ fn load_identity_state(&self) -> Result<IdentityGateState, String> {
+ Ok(IdentityGateState::Unsupported {
+ reason: "Secure onboarding is not yet available on Android.".to_owned(),
+ })
+ }
+
+ fn setup_action_state(&self) -> SetupActionState {
+ SetupActionState {
+ label: "Generate New Key".to_owned(),
+ enabled: false,
+ pending: false,
+ }
+ }
+
+ fn request_setup_action(&self) -> Result<Option<IdentityGateState>, String> {
+ Ok(Some(IdentityGateState::Unsupported {
+ reason: "Secure onboarding is not yet available on Android.".to_owned(),
+ }))
+ }
+}
+
+#[cfg(target_os = "android")]
+fn native_options(android_app: AndroidApp) -> eframe::NativeOptions {
+ eframe::NativeOptions {
+ renderer: eframe::Renderer::Glow,
+ android_app: Some(android_app),
+ viewport: ViewportBuilder::default().with_title(APP_NAME),
+ ..Default::default()
+ }
+}
+
+#[cfg(target_os = "android")]
+fn run_android_app(android_app: AndroidApp) -> Result<(), String> {
+ android_logger::init_once(Config::default().with_max_level(log::LevelFilter::Info));
+ eframe::run_native(
+ APP_NAME,
+ native_options(android_app),
+ Box::new(|_cc| Ok(Box::new(RadrootsApp::new(Box::new(AndroidBackend))))),
+ )
+ .map_err(|err| err.to_string())
+}
+
+#[cfg(target_os = "android")]
+#[allow(improper_ctypes_definitions)]
+#[unsafe(no_mangle)]
+pub extern "C" fn android_main(android_app: AndroidApp) {
+ if let Err(err) = run_android_app(android_app) {
+ log::error!("android launcher failed: {err}");
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn android_backend_reports_unsupported_onboarding() {
+ assert_eq!(
+ AndroidBackend.load_identity_state(),
+ Ok(IdentityGateState::Unsupported {
+ reason: "Secure onboarding is not yet available on Android.".to_owned(),
+ })
+ );
+ assert_eq!(
+ AndroidBackend.setup_action_state(),
+ SetupActionState {
+ label: "Generate New Key".to_owned(),
+ enabled: false,
+ pending: false,
+ }
+ );
+ }
+}
diff --git a/platforms/android/Scripts/android_toolchain_config.sh b/platforms/android/Scripts/android_toolchain_config.sh
@@ -0,0 +1,83 @@
+#!/usr/bin/env bash
+
+android_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+android_dir="$(cd "${android_script_dir}/.." && pwd -P)"
+app_root="$(cd "${android_dir}/../.." && pwd -P)"
+
+android_tooling_dir="${android_dir}/.tooling"
+android_download_dir="${android_tooling_dir}/downloads"
+android_sdk_dir="${android_tooling_dir}/android-sdk"
+android_gradle_user_home="${android_tooling_dir}/gradle-user-home"
+android_user_home="${android_tooling_dir}/android-user-home"
+android_emulator_home="${android_tooling_dir}/emulator-home"
+android_avd_home="${android_tooling_dir}/avd"
+android_cargo_install_root="${android_tooling_dir}/cargo"
+android_cargo_bin_dir="${android_cargo_install_root}/bin"
+android_local_properties_path="${android_dir}/local.properties"
+
+android_sdk_api_level="34"
+android_build_tools_version="34.0.0"
+android_ndk_version="26.1.10909125"
+android_gradle_version="8.7"
+android_gradle_distribution_sha256="544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d"
+android_cmdline_tools_version="14742923"
+android_cargo_ndk_version="4.1.2"
+
+android_gradle_home="${android_tooling_dir}/gradle/gradle-${android_gradle_version}"
+android_gradle_bin="${android_gradle_home}/bin/gradle"
+android_sdkmanager_bin="${android_sdk_dir}/cmdline-tools/latest/bin/sdkmanager"
+android_avdmanager_bin="${android_sdk_dir}/cmdline-tools/latest/bin/avdmanager"
+android_ndk_dir="${android_sdk_dir}/ndk/${android_ndk_version}"
+android_emulator_bin="${android_sdk_dir}/emulator/emulator"
+android_adb_bin="${android_sdk_dir}/platform-tools/adb"
+android_cargo_ndk_bin="${android_cargo_bin_dir}/cargo-ndk"
+
+android_rust_target="aarch64-linux-android"
+android_abi="arm64-v8a"
+
+android_sdk_packages=(
+ "platform-tools"
+ "platforms;android-${android_sdk_api_level}"
+ "build-tools;${android_build_tools_version}"
+ "ndk;${android_ndk_version}"
+)
+
+android_gradle_distribution_url() {
+ echo "https://services.gradle.org/distributions/gradle-${android_gradle_version}-bin.zip"
+}
+
+android_cmdline_tools_platform() {
+ case "$(uname -s)" in
+ Darwin)
+ echo "mac"
+ ;;
+ Linux)
+ echo "linux"
+ ;;
+ *)
+ echo "unsupported"
+ ;;
+ esac
+}
+
+android_cmdline_tools_zip_name() {
+ local platform_name
+ platform_name="$(android_cmdline_tools_platform)"
+ echo "commandlinetools-${platform_name}-${android_cmdline_tools_version}_latest.zip"
+}
+
+android_cmdline_tools_url() {
+ echo "https://dl.google.com/android/repository/$(android_cmdline_tools_zip_name)"
+}
+
+android_emulator_system_image_package() {
+ echo "system-images;android-${android_sdk_api_level};google_apis;${android_abi}"
+}
+
+android_emulator_packages() {
+ printf '%s\n' "emulator" "$(android_emulator_system_image_package)"
+}
+
+android_avd_name() {
+ echo "RadRoots_API_${android_sdk_api_level}"
+}
diff --git a/platforms/android/Scripts/bootstrap_android_toolchain.sh b/platforms/android/Scripts/bootstrap_android_toolchain.sh
@@ -0,0 +1,226 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+source "${script_dir}/android_toolchain_config.sh"
+
+require_command() {
+ if command -v "$1" >/dev/null 2>&1; then
+ return
+ fi
+ echo "missing required command: $1" >&2
+ exit 1
+}
+
+require_java_17() {
+ require_command java
+
+ local version_output
+ version_output="$(java -version 2>&1 | head -n 1)"
+ local java_major
+ java_major="$(echo "${version_output}" | sed -E 's/.*version "([0-9]+).*/\1/')"
+ if [[ -z "${java_major}" || "${java_major}" -lt 17 ]]; then
+ echo "android bootstrap requires java 17 or newer" >&2
+ exit 1
+ fi
+}
+
+checksum_file() {
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "$1" | awk '{print $1}'
+ return
+ fi
+
+ if command -v shasum >/dev/null 2>&1; then
+ shasum -a 256 "$1" | awk '{print $1}'
+ return
+ fi
+
+ echo "missing required command: sha256sum or shasum" >&2
+ exit 1
+}
+
+download_if_missing() {
+ local url="$1"
+ local destination="$2"
+
+ if [[ -f "${destination}" ]]; then
+ return
+ fi
+
+ mkdir -p "$(dirname "${destination}")"
+ curl -fsSL "${url}" -o "${destination}"
+}
+
+validate_zip_archive() {
+ unzip -tqq "$1" >/dev/null 2>&1
+}
+
+ensure_valid_zip_download() {
+ local url="$1"
+ local destination="$2"
+
+ download_if_missing "${url}" "${destination}"
+ if validate_zip_archive "${destination}"; then
+ return
+ fi
+
+ rm -f "${destination}"
+ download_if_missing "${url}" "${destination}"
+ if ! validate_zip_archive "${destination}"; then
+ echo "invalid zip archive: ${destination}" >&2
+ exit 1
+ fi
+}
+
+ensure_gradle_distribution() {
+ if [[ -x "${android_gradle_bin}" ]]; then
+ return
+ fi
+
+ local gradle_zip="${android_download_dir}/gradle-${android_gradle_version}-bin.zip"
+ ensure_valid_zip_download "$(android_gradle_distribution_url)" "${gradle_zip}"
+
+ local actual_checksum
+ actual_checksum="$(checksum_file "${gradle_zip}")"
+ if [[ "${actual_checksum}" != "${android_gradle_distribution_sha256}" ]]; then
+ rm -f "${gradle_zip}"
+ ensure_valid_zip_download "$(android_gradle_distribution_url)" "${gradle_zip}"
+ actual_checksum="$(checksum_file "${gradle_zip}")"
+ if [[ "${actual_checksum}" != "${android_gradle_distribution_sha256}" ]]; then
+ echo "gradle distribution checksum mismatch" >&2
+ exit 1
+ fi
+ fi
+
+ rm -rf "$(dirname "${android_gradle_home}")"
+ mkdir -p "$(dirname "${android_gradle_home}")"
+ unzip -q "${gradle_zip}" -d "$(dirname "${android_gradle_home}")"
+}
+
+ensure_android_cmdline_tools() {
+ if [[ -x "${android_sdkmanager_bin}" ]]; then
+ return
+ fi
+
+ local platform_name
+ platform_name="$(android_cmdline_tools_platform)"
+ if [[ "${platform_name}" == "unsupported" ]]; then
+ echo "android bootstrap supports only darwin and linux hosts" >&2
+ exit 1
+ fi
+
+ local cmdline_zip="${android_download_dir}/$(android_cmdline_tools_zip_name)"
+ local tmp_dir="${android_tooling_dir}/tmp/cmdline-tools"
+
+ ensure_valid_zip_download "$(android_cmdline_tools_url)" "${cmdline_zip}"
+
+ rm -rf "${tmp_dir}" "${android_sdk_dir}/cmdline-tools/latest"
+ mkdir -p "${tmp_dir}" "${android_sdk_dir}/cmdline-tools/latest"
+ unzip -q "${cmdline_zip}" -d "${tmp_dir}"
+ mv "${tmp_dir}/cmdline-tools/"* "${android_sdk_dir}/cmdline-tools/latest/"
+ rm -rf "${tmp_dir}"
+}
+
+accept_android_licenses() {
+ set +o pipefail
+ yes | "${android_sdkmanager_bin}" --sdk_root="${android_sdk_dir}" --licenses >/dev/null
+ set -o pipefail
+}
+
+ensure_android_sdk_packages() {
+ accept_android_licenses
+ "${android_sdkmanager_bin}" --sdk_root="${android_sdk_dir}" "${android_sdk_packages[@]}"
+}
+
+ensure_android_emulator_packages() {
+ accept_android_licenses
+ local packages=()
+ while IFS= read -r package; do
+ packages+=("${package}")
+ done < <(android_emulator_packages)
+ "${android_sdkmanager_bin}" --sdk_root="${android_sdk_dir}" "${packages[@]}"
+}
+
+ensure_cargo_ndk() {
+ if [[ -x "${android_cargo_ndk_bin}" ]]; then
+ local installed_version
+ installed_version="$(PATH="${android_cargo_bin_dir}:${PATH}" cargo ndk --version | awk '{print $2}')"
+ if [[ "${installed_version}" == "${android_cargo_ndk_version}" ]]; then
+ return
+ fi
+ fi
+
+ cargo install \
+ --locked \
+ --force \
+ --root "${android_cargo_install_root}" \
+ --version "${android_cargo_ndk_version}" \
+ cargo-ndk
+}
+
+ensure_rust_target() {
+ if rustup target list --installed | grep -Fx "${android_rust_target}" >/dev/null 2>&1; then
+ return
+ fi
+ rustup target add "${android_rust_target}"
+}
+
+write_local_properties() {
+ cat <<EOF > "${android_local_properties_path}"
+sdk.dir=${android_sdk_dir}
+EOF
+}
+
+main() {
+ local with_emulator="false"
+ local print_gradle_bin="false"
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --with-emulator)
+ with_emulator="true"
+ shift
+ ;;
+ --print-gradle-bin)
+ print_gradle_bin="true"
+ shift
+ ;;
+ *)
+ echo "unknown bootstrap option: $1" >&2
+ exit 1
+ ;;
+ esac
+ done
+
+ require_command curl
+ require_command unzip
+ require_command cargo
+ require_command rustup
+ require_java_17
+
+ mkdir -p \
+ "${android_tooling_dir}" \
+ "${android_download_dir}" \
+ "${android_gradle_user_home}" \
+ "${android_user_home}" \
+ "${android_emulator_home}" \
+ "${android_avd_home}" \
+ "${android_cargo_install_root}"
+
+ ensure_gradle_distribution
+ ensure_android_cmdline_tools
+ ensure_android_sdk_packages
+ if [[ "${with_emulator}" == "true" ]]; then
+ ensure_android_emulator_packages
+ fi
+ ensure_cargo_ndk
+ ensure_rust_target
+ write_local_properties
+
+ if [[ "${print_gradle_bin}" == "true" ]]; then
+ printf '%s\n' "${android_gradle_bin}"
+ fi
+}
+
+main "$@"
diff --git a/platforms/android/Scripts/build_rust_android.sh b/platforms/android/Scripts/build_rust_android.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+source "${script_dir}/android_toolchain_config.sh"
+
+require_command() {
+ if command -v "$1" >/dev/null 2>&1; then
+ return
+ fi
+ echo "missing required command: $1" >&2
+ exit 1
+}
+
+profile_for_build_type() {
+ case "${1}" in
+ Release)
+ echo "release"
+ ;;
+ *)
+ echo "debug"
+ ;;
+ esac
+}
+
+missing_bootstrap() {
+ echo "android build requires bootstrapped local toolchain files under platforms/android/.tooling" >&2
+ exit 1
+}
+
+require_command cargo
+require_command rustup
+
+if [[ ! -d "${android_sdk_dir}" || ! -d "${android_ndk_dir}" || ! -x "${android_cargo_ndk_bin}" ]]; then
+ missing_bootstrap
+fi
+
+if ! rustup target list --installed | grep -Fx "${android_rust_target}" >/dev/null 2>&1; then
+ missing_bootstrap
+fi
+
+build_type="${1:-Debug}"
+profile="$(profile_for_build_type "${build_type}")"
+
+export PATH="${android_cargo_bin_dir}:${PATH}"
+export ANDROID_HOME="${android_sdk_dir}"
+export ANDROID_SDK_ROOT="${android_sdk_dir}"
+export ANDROID_NDK_HOME="${android_ndk_dir}"
+export ANDROID_NDK_ROOT="${android_ndk_dir}"
+export ANDROID_USER_HOME="${android_user_home}"
+
+cargo_args=(
+ ndk
+ -t "${android_abi}"
+ -o "${app_root}/target/android/jniLibs"
+ build
+ --manifest-path "${app_root}/Cargo.toml"
+ -p radroots-app-android
+)
+
+if [[ "${profile}" == "release" ]]; then
+ cargo_args+=(--release)
+fi
+
+cargo "${cargo_args[@]}"
diff --git a/platforms/android/app/build.gradle.kts b/platforms/android/app/build.gradle.kts
@@ -0,0 +1,83 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+}
+
+val rustBuildScript = file("../Scripts/build_rust_android.sh")
+val rustJniLibsDir = file("../../../target/android/jniLibs")
+val rustInputs = files(
+ "../../../Cargo.toml",
+ "../../../Cargo.lock",
+ rustBuildScript,
+ fileTree("../../../crates/core"),
+ fileTree("../../../crates/android"),
+)
+
+android {
+ namespace = "org.radroots.app.android"
+ compileSdk = 34
+ ndkVersion = "26.1.10909125"
+
+ defaultConfig {
+ applicationId = "org.radroots.app.android"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "0.1.0"
+
+ ndk {
+ abiFilters += "arm64-v8a"
+ }
+ }
+
+ buildTypes {
+ debug {}
+ release {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ sourceSets {
+ getByName("main") {
+ jniLibs.srcDir(rustJniLibsDir)
+ }
+ }
+}
+
+val buildRustDebug = tasks.register("buildRustDebug", org.gradle.api.tasks.Exec::class) {
+ workingDir = rootDir
+ commandLine("bash", rustBuildScript.absolutePath, "Debug")
+ inputs.files(rustInputs)
+ outputs.dir(rustJniLibsDir)
+}
+
+val buildRustRelease = tasks.register("buildRustRelease", org.gradle.api.tasks.Exec::class) {
+ workingDir = rootDir
+ commandLine("bash", rustBuildScript.absolutePath, "Release")
+ inputs.files(rustInputs)
+ outputs.dir(rustJniLibsDir)
+}
+
+afterEvaluate {
+ tasks.named("preDebugBuild").configure {
+ dependsOn(buildRustDebug)
+ }
+ tasks.named("preReleaseBuild").configure {
+ dependsOn(buildRustRelease)
+ }
+}
+
+dependencies {
+ implementation("androidx.games:games-activity:2.0.2")
+ implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("androidx.core:core-ktx:1.13.1")
+}
diff --git a/platforms/android/app/src/main/AndroidManifest.xml b/platforms/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+ <application
+ android:allowBackup="false"
+ android:icon="@drawable/radroots_logo"
+ android:label="@string/app_name"
+ android:roundIcon="@drawable/radroots_logo"
+ android:supportsRtl="true"
+ android:theme="@style/Theme.RadRoots">
+ <activity
+ android:name=".MainActivity"
+ android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|keyboard|smallestScreenSize|uiMode|fontScale"
+ android:exported="true"
+ android:launchMode="singleTask"
+ android:windowSoftInputMode="adjustResize">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+
+ <meta-data
+ android:name="android.app.lib_name"
+ android:value="radroots_app_android" />
+ </activity>
+ </application>
+ <uses-permission android:name="android.permission.INTERNET" />
+</manifest>
diff --git a/platforms/android/app/src/main/kotlin/org/radroots/app/android/MainActivity.kt b/platforms/android/app/src/main/kotlin/org/radroots/app/android/MainActivity.kt
@@ -0,0 +1,5 @@
+package org.radroots.app.android
+
+import com.google.androidgamesdk.GameActivity
+
+class MainActivity : GameActivity()
diff --git a/platforms/android/app/src/main/res/drawable-nodpi/radroots_logo.png b/platforms/android/app/src/main/res/drawable-nodpi/radroots_logo.png
Binary files differ.
diff --git a/platforms/android/app/src/main/res/values/strings.xml b/platforms/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="app_name">Rad Roots</string>
+</resources>
diff --git a/platforms/android/app/src/main/res/values/themes.xml b/platforms/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <style name="Theme.RadRoots" parent="Theme.AppCompat.NoActionBar">
+ <item name="windowActionBar">false</item>
+ <item name="windowNoTitle">true</item>
+ </style>
+</resources>
diff --git a/platforms/android/build.gradle.kts b/platforms/android/build.gradle.kts
@@ -0,0 +1,8 @@
+plugins {
+ id("com.android.application") version "8.5.2" apply false
+ id("org.jetbrains.kotlin.android") version "1.9.24" apply false
+}
+
+tasks.register("clean", Delete::class) {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/platforms/android/gradle.properties b/platforms/android/gradle.properties
@@ -0,0 +1,3 @@
+android.useAndroidX=true
+org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
+kotlin.code.style=official
diff --git a/platforms/android/settings.gradle.kts b/platforms/android/settings.gradle.kts
@@ -0,0 +1,19 @@
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ google()
+ mavenCentral()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(org.gradle.api.initialization.resolve.RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "RadRootsAndroid"
+
+include(":app")
diff --git a/scripts/build-android-host.sh b/scripts/build-android-host.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+app_root="$(cd "${script_dir}/.." && pwd -P)"
+android_root="${app_root}/platforms/android"
+configuration="${CONFIGURATION:-Debug}"
+
+source "${android_root}/Scripts/android_toolchain_config.sh"
+
+"${android_root}/Scripts/bootstrap_android_toolchain.sh"
+
+gradle_task=":app:assembleDebug"
+expected_apk="${android_root}/app/build/outputs/apk/debug/app-debug.apk"
+if [[ "${configuration}" == "Release" ]]; then
+ gradle_task=":app:assembleRelease"
+ expected_apk="${android_root}/app/build/outputs/apk/release/app-release-unsigned.apk"
+fi
+
+(
+ cd "${android_root}"
+ GRADLE_USER_HOME="${android_gradle_user_home}" \
+ ANDROID_USER_HOME="${android_user_home}" \
+ ANDROID_HOME="${android_sdk_dir}" \
+ ANDROID_SDK_ROOT="${android_sdk_dir}" \
+ "${android_gradle_bin}" --no-daemon "${gradle_task}"
+)
+
+if [[ ! -f "${expected_apk}" ]]; then
+ echo "missing expected android apk: ${expected_apk}" >&2
+ exit 1
+fi
+
+printf '%s\n' "${expected_apk}"
diff --git a/scripts/check-android-target.sh b/scripts/check-android-target.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+app_root="$(cd "${script_dir}/.." && pwd -P)"
+android_root="${app_root}/platforms/android"
+
+source "${android_root}/Scripts/android_toolchain_config.sh"
+
+"${android_root}/Scripts/bootstrap_android_toolchain.sh"
+
+export PATH="${android_cargo_bin_dir}:${PATH}"
+export ANDROID_HOME="${android_sdk_dir}"
+export ANDROID_SDK_ROOT="${android_sdk_dir}"
+export ANDROID_NDK_HOME="${android_ndk_dir}"
+export ANDROID_NDK_ROOT="${android_ndk_dir}"
+export ANDROID_USER_HOME="${android_user_home}"
+
+CARGO_TARGET_DIR="${app_root}/target" \
+ cargo ndk -t "${android_abi}" check --manifest-path "${app_root}/Cargo.toml" -p radroots-app-android
diff --git a/scripts/run-android-emulator.sh b/scripts/run-android-emulator.sh
@@ -0,0 +1,103 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+app_root="$(cd "${script_dir}/.." && pwd -P)"
+android_root="${app_root}/platforms/android"
+bundle_id="org.radroots.app.android"
+activity_name="${bundle_id}/.MainActivity"
+
+source "${android_root}/Scripts/android_toolchain_config.sh"
+
+require_command() {
+ if command -v "$1" >/dev/null 2>&1; then
+ return
+ fi
+ echo "missing required command: $1" >&2
+ exit 1
+}
+
+running_emulator_serial() {
+ local target_avd="$1"
+ while read -r serial state _; do
+ [[ "${serial}" == emulator-* ]] || continue
+ [[ "${state}" == "device" || "${state}" == "offline" ]] || continue
+ if [[ "$("${android_adb_bin}" -s "${serial}" emu avd name 2>/dev/null | sed -n '1p' | tr -d '\r')" == "${target_avd}" ]]; then
+ printf '%s\n' "${serial}"
+ return
+ fi
+ done < <("${android_adb_bin}" devices | tail -n +2)
+}
+
+ensure_avd() {
+ local avd_name="$1"
+ if [[ -d "${android_avd_home}/${avd_name}.avd" ]]; then
+ return
+ fi
+
+ mkdir -p "${android_avd_home}" "${android_emulator_home}"
+ printf 'no\n' | \
+ ANDROID_AVD_HOME="${android_avd_home}" \
+ ANDROID_EMULATOR_HOME="${android_emulator_home}" \
+ "${android_avdmanager_bin}" create avd --force --name "${avd_name}" --package "$(android_emulator_system_image_package)"
+}
+
+wait_for_boot_complete() {
+ local serial="$1"
+ "${android_adb_bin}" -s "${serial}" wait-for-device >/dev/null
+ until [[ "$("${android_adb_bin}" -s "${serial}" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]]; do
+ sleep 2
+ done
+}
+
+launch_emulator_if_needed() {
+ local avd_name="$1"
+ local serial
+ serial="$(running_emulator_serial "${avd_name}" || true)"
+ if [[ -n "${serial}" ]]; then
+ printf '%s\n' "${serial}"
+ return
+ fi
+
+ ANDROID_AVD_HOME="${android_avd_home}" \
+ ANDROID_EMULATOR_HOME="${android_emulator_home}" \
+ nohup "${android_emulator_bin}" -avd "${avd_name}" -no-snapshot-save >/tmp/radroots-android-emulator.log 2>&1 &
+
+ for _ in $(seq 1 60); do
+ serial="$(running_emulator_serial "${avd_name}" || true)"
+ if [[ -n "${serial}" ]]; then
+ printf '%s\n' "${serial}"
+ return
+ fi
+ sleep 2
+ done
+
+ echo "android emulator failed to start" >&2
+ exit 1
+}
+
+require_command mktemp
+
+avd_name="${1:-$(android_avd_name)}"
+
+"${android_root}/Scripts/bootstrap_android_toolchain.sh" --with-emulator
+
+build_log="$(mktemp)"
+trap 'rm -f "${build_log}"' EXIT
+
+if ! "${script_dir}/build-android-host.sh" | tee "${build_log}"; then
+ exit 1
+fi
+
+apk_path="$(tail -n 1 "${build_log}")"
+if [[ ! -f "${apk_path}" ]]; then
+ echo "missing built android apk: ${apk_path}" >&2
+ exit 1
+fi
+
+ensure_avd "${avd_name}"
+serial="$(launch_emulator_if_needed "${avd_name}")"
+wait_for_boot_complete "${serial}"
+
+"${android_adb_bin}" -s "${serial}" install -r "${apk_path}"
+"${android_adb_bin}" -s "${serial}" shell am start -n "${activity_name}"