app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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:
M.gitignore | 5+++++
MCONTRIBUTING.md | 21+++++++++++++++++++++
MCargo.lock | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 5++++-
Acrates/android/Cargo.toml | 28++++++++++++++++++++++++++++
Acrates/android/src/lib.rs | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplatforms/android/Scripts/android_toolchain_config.sh | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplatforms/android/Scripts/bootstrap_android_toolchain.sh | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplatforms/android/Scripts/build_rust_android.sh | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplatforms/android/app/build.gradle.kts | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplatforms/android/app/src/main/AndroidManifest.xml | 27+++++++++++++++++++++++++++
Aplatforms/android/app/src/main/kotlin/org/radroots/app/android/MainActivity.kt | 5+++++
Aplatforms/android/app/src/main/res/drawable-nodpi/radroots_logo.png | 0
Aplatforms/android/app/src/main/res/values/strings.xml | 4++++
Aplatforms/android/app/src/main/res/values/themes.xml | 7+++++++
Aplatforms/android/build.gradle.kts | 8++++++++
Aplatforms/android/gradle.properties | 3+++
Aplatforms/android/settings.gradle.kts | 19+++++++++++++++++++
Ascripts/build-android-host.sh | 34++++++++++++++++++++++++++++++++++
Ascripts/check-android-target.sh | 20++++++++++++++++++++
Ascripts/run-android-emulator.sh | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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}"