cargo-bubba 0.1.6

cargo subcommand for the Bubba mobile framework
//! `cargo bubba build` — compile Rust → shared library → APK.
//!
//! Pipeline:
//! 1. cargo build --target <abi>  → libapp.so  (requires [lib] crate-type=["cdylib"])
//! 2. aapt2 compile assets/       → compiled resources
//! 3. aapt2 link  AndroidManifest → base.apk
//! 4. zip    lib/<abi>/libapp.so  → base.apk  (absolute path, correct structure)
//! 5. zipalign 4                  → aligned.apk
//! 6. apksigner (expanded HOME)   → app-debug.apk

use anyhow::{bail, Context, Result};
use colored::Colorize;
use indicatif::{ProgressBar, ProgressStyle};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;

fn abi_for_target(target: &str) -> &'static str {
    match target {
        "aarch64-linux-android"   => "arm64-v8a",
        "armv7-linux-androideabi" => "armeabi-v7a",
        "x86_64-linux-android"   => "x86_64",
        "i686-linux-android"     => "x86",
        _                        => "x86_64",
    }
}

pub fn run(target: &str, release: bool, out_dir: &str) -> Result<()> {
    println!("{} Building for `{}`…", "".cyan(), target.bold());

    let pb = spinner("Compiling Rust…");
    let mut cargo_args = vec!["build", "--target", target];
    if release { cargo_args.push("--release"); }

    let status = Command::new("cargo")
        .args(&cargo_args)
        .status()
        .context("Failed to invoke `cargo build`. Is Rust installed?")?;

    pb.finish_and_clear();

    if !status.success() {
        bail!(
            "`cargo build` failed.\n\n\
             Tip: run `cargo bubba doctor` to verify your NDK setup.\n\
             Tip: run `rustup target add {}` if the target is missing.\n\
             Tip: ensure your Cargo.toml has [lib] with crate-type = [\"cdylib\"].",
            target
        );
    }
    println!("  {} Rust compiled OK", "".green());

    let profile = if release { "release" } else { "debug" };
    let lib_so = find_built_so(target, profile);

    match &lib_so {
        Some(p) => println!("  {} Library: {}", "".green(), p.display()),
        None => println!(
            "  {} No .so found — ensure Cargo.toml [lib] crate-type = [\"cdylib\"]",
            "".yellow()
        ),
    }

    let pb2 = spinner("Packaging APK…");
    let apk_result = package_apk(target, out_dir, profile, lib_so.as_deref());
    pb2.finish_and_clear();

    match apk_result {
        Ok(apk_path) => {
            println!("  {} APK packaged: {}", "".green(), apk_path.display().to_string().bold());
            println!("\n{} Build complete!\n", "".green().bold());
            println!("  APK → {}", apk_path.display().to_string().bold());
            println!("  Install: {}\n", format!("adb install {}", apk_path.display()).dimmed());
        }
        Err(e) => {
            println!("  {} APK packaging failed:\n     {}", "".yellow(), e.to_string().dimmed());
            println!("\n  Run {} to diagnose.\n", "cargo bubba doctor".bold());
        }
    }
    Ok(())
}

/// Find the .so built by cargo — handles any project/lib name.
fn find_built_so(target: &str, profile: &str) -> Option<PathBuf> {
    let dir = PathBuf::from(format!("target/{}/{}", target, profile));
    if !dir.exists() { return None; }

    if let Ok(entries) = std::fs::read_dir(&dir) {
        let candidates: Vec<PathBuf> = entries
            .flatten()
            .map(|e| e.path())
            .filter(|p| {
                p.is_file()
                && p.extension().map(|e| e == "so").unwrap_or(false)
                && p.file_name()
                    .map(|n| n.to_string_lossy().starts_with("lib"))
                    .unwrap_or(false)
            })
            .collect();

        // Prefer libapp.so if present
        if let Some(p) = candidates.iter().find(|p| {
            p.file_name().map(|n| n == "libapp.so").unwrap_or(false)
        }) {
            return Some(p.clone());
        }
        candidates.into_iter().next()
    } else {
        None
    }
}

fn package_apk(
    target: &str,
    out_dir: &str,
    profile: &str,
    lib_so: Option<&Path>,
) -> Result<PathBuf> {
    let _ = profile;
    let abi = abi_for_target(target);
    let out = PathBuf::from(out_dir);
    std::fs::create_dir_all(&out)?;

    // ── aapt2 compile ─────────────────────────────────────────────────────────
    let aapt2 = find_tool("aapt2").context(
        "aapt2 not found.\n\
         Add to PATH: export PATH=\"$ANDROID_SDK_ROOT/build-tools/<version>:$PATH\""
    )?;

    let res_out = out.join("compiled_res");
    std::fs::create_dir_all(&res_out)?;
    if Path::new("assets").exists() {
        let s = Command::new(&aapt2)
            .args(["compile", "--dir", "assets", "-o"])
            .arg(&res_out)
            .status()?;
        if !s.success() { bail!("aapt2 compile failed"); }
    }

    // ── aapt2 link ────────────────────────────────────────────────────────────
    let manifest = "android/app/src/main/AndroidManifest.xml";
    if !Path::new(manifest).exists() {
        bail!("AndroidManifest.xml not found at `{}`. Run `cargo bubba new` first.", manifest);
    }

    let base_apk = out.join("base.apk");
    let android_jar = find_android_jar()?;

    if base_apk.exists() { std::fs::remove_file(&base_apk)?; }

    // No --proto-format: that produces proto APKs Android cannot install
    let s = Command::new(&aapt2)
        .args([
            "link",
            "-o", base_apk.to_str().unwrap(),
            "--manifest", manifest,
            "-I", &android_jar,
        ])
        .status()?;
    if !s.success() { bail!("aapt2 link failed"); }

    // ── Pack .so into APK ─────────────────────────────────────────────────────
    if let Some(so) = lib_so {
        let zip_entry = format!("lib/{}/libapp.so", abi);
        let tmp = out.join("tmp_lib");
        let tmp_abi = tmp.join("lib").join(abi);
        std::fs::create_dir_all(&tmp_abi)?;
        std::fs::copy(so, tmp_abi.join("libapp.so"))?;

        // MUST use absolute path — zip changes cwd to tmp
        let base_apk_abs = base_apk.canonicalize()
            .unwrap_or_else(|_| base_apk.clone());

        let s = Command::new("zip")
            .current_dir(&tmp)
            .args([base_apk_abs.to_str().unwrap(), &zip_entry])
            .status()?;

        std::fs::remove_dir_all(&tmp)?;

        if !s.success() {
            bail!(
                "Failed to pack libapp.so into APK.\n\
                 Make sure zip is installed: sudo apt install zip"
            );
        }
    }

    // ── zipalign ──────────────────────────────────────────────────────────────
    let zipalign = find_tool("zipalign")
        .context("zipalign not found. Install Android Build Tools.")?;
    let aligned = out.join("aligned.apk");
    if aligned.exists() { std::fs::remove_file(&aligned)?; }

    let s = Command::new(&zipalign)
        .args(["4", base_apk.to_str().unwrap(), aligned.to_str().unwrap()])
        .status()?;
    if !s.success() { bail!("zipalign failed"); }

    // ── apksigner ─────────────────────────────────────────────────────────────
    // Java does NOT expand ~ — must expand HOME manually
    let home = std::env::var("HOME").unwrap_or_default();
    let keystore = format!("{}/.android/debug.keystore", home);

    if !Path::new(&keystore).exists() {
        bail!(
            "Debug keystore not found at {}.\n\
             Generate with:\n  \
             keytool -genkey -v -keystore ~/.android/debug.keystore \\\n  \
               -alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \\\n  \
               -storepass android -keypass android \\\n  \
               -dname \"CN=Android Debug,O=Android,C=US\"",
            keystore
        );
    }

    let apksigner = find_tool("apksigner")
        .context("apksigner not found. Install Android Build Tools.")?;
    let final_apk = out.join("app-debug.apk");
    if final_apk.exists() { std::fs::remove_file(&final_apk)?; }

    let s = Command::new(&apksigner)
        .args([
            "sign",
            "--ks", &keystore,
            "--ks-pass", "pass:android",
            "--out", final_apk.to_str().unwrap(),
            aligned.to_str().unwrap(),
        ])
        .status()?;
    if !s.success() { bail!("apksigner failed"); }

    Ok(final_apk)
}

// ── Tool discovery ────────────────────────────────────────────────────────────

fn find_tool(name: &str) -> Option<String> {
    if let Ok(p) = which::which(name) {
        return Some(p.to_string_lossy().into_owned());
    }
    for env_var in &["ANDROID_SDK_ROOT", "ANDROID_HOME"] {
        if let Ok(sdk) = std::env::var(env_var) {
            let bt = PathBuf::from(&sdk).join("build-tools");
            if let Ok(entries) = std::fs::read_dir(&bt) {
                let mut versions: Vec<String> = entries
                    .flatten()
                    .filter(|e| e.path().is_dir())
                    .map(|e| e.file_name().to_string_lossy().into_owned())
                    .collect();
                versions.sort_by(|a, b| b.cmp(a));
                for ver in &versions {
                    let c = bt.join(ver).join(name);
                    if c.exists() { return Some(c.to_string_lossy().into_owned()); }
                }
            }
        }
    }
    None
}

fn find_android_jar() -> Result<String> {
    for env_var in &["ANDROID_SDK_ROOT", "ANDROID_HOME"] {
        if let Ok(sdk) = std::env::var(env_var) {
            let platforms = PathBuf::from(&sdk).join("platforms");
            if let Ok(entries) = std::fs::read_dir(&platforms) {
                let mut dirs: Vec<String> = entries
                    .flatten()
                    .filter(|e| e.path().is_dir())
                    .map(|e| e.file_name().to_string_lossy().into_owned())
                    .collect();
                dirs.sort_by(|a, b| b.cmp(a));
                for dir in &dirs {
                    let jar = platforms.join(dir).join("android.jar");
                    if jar.exists() { return Ok(jar.to_string_lossy().into_owned()); }
                }
            }
        }
    }
    bail!(
        "android.jar not found.\n\
         Install an Android Platform: Android Studio → SDK Manager → SDK Platforms."
    )
}

fn spinner(msg: &str) -> ProgressBar {
    let pb = ProgressBar::new_spinner();
    pb.set_style(
        ProgressStyle::with_template("{spinner:.cyan} {msg}")
            .unwrap()
            .tick_strings(&["", "", "", "", "", "", "", "", "", ""]),
    );
    pb.set_message(msg.to_string());
    pb.enable_steady_tick(Duration::from_millis(80));
    pb
}

// ── DEX compilation ───────────────────────────────────────────────────────────
// (appended — called from package_apk after aapt2 link)

/// Compile Java sources → classes.dex → add to APK.
/// Requires javac and d8 on PATH or in build-tools.
pub fn compile_and_add_dex(base_apk: &std::path::Path) -> anyhow::Result<()> {
    let dex_script = std::path::Path::new("android/build_dex.sh");
    if !dex_script.exists() {
        // No Java bridge — NativeActivity mode, skip DEX
        return Ok(());
    }

    let status = std::process::Command::new("bash")
        .arg(dex_script)
        .status()
        .context("Failed to run android/build_dex.sh")?;

    if !status.success() {
        anyhow::bail!("DEX compilation failed. Check that javac and d8 are installed.");
    }
    Ok(())
}