use super::common::{get_cargo_target_dir, host_lib_path, run_command, validate_project_root};
use crate::types::{
BenchError, BuildConfig, BuildProfile, BuildResult, NativeLibraryArtifact, Target,
};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
pub struct AndroidBuilder {
project_root: PathBuf,
output_dir: PathBuf,
crate_name: String,
verbose: bool,
crate_dir: Option<PathBuf>,
dry_run: bool,
}
const DEFAULT_ANDROID_ABIS: &[&str] = &["arm64-v8a"];
impl AndroidBuilder {
pub fn new(project_root: impl Into<PathBuf>, crate_name: impl Into<String>) -> Self {
let root = project_root.into();
Self {
output_dir: root.join("target/mobench"),
project_root: root,
crate_name: crate_name.into(),
verbose: false,
crate_dir: None,
dry_run: false,
}
}
pub fn output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.output_dir = dir.into();
self
}
pub fn crate_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.crate_dir = Some(dir.into());
self
}
pub fn verbose(mut self, verbose: bool) -> Self {
self.verbose = verbose;
self
}
pub fn dry_run(mut self, dry_run: bool) -> Self {
self.dry_run = dry_run;
self
}
pub fn build(&self, config: &BuildConfig) -> Result<BuildResult, BenchError> {
if self.crate_dir.is_none() {
validate_project_root(&self.project_root, &self.crate_name)?;
}
let android_dir = self.output_dir.join("android");
let profile_name = match config.profile {
BuildProfile::Debug => "debug",
BuildProfile::Release => "release",
};
let android_abis = self.resolve_android_abis(config)?;
if self.dry_run {
println!("\n[dry-run] Android build plan:");
println!(
" Step 0: Check/generate Android project scaffolding at {:?}",
android_dir
);
println!(" Step 0.5: Ensure Gradle wrapper exists (run 'gradle wrapper' if needed)");
println!(
" Step 1: Build Rust libraries for Android ABIs ({})",
android_abis.join(", ")
);
println!(
" Command: cargo ndk --target <abi> --platform 24 build {}",
if matches!(config.profile, BuildProfile::Release) {
"--release"
} else {
""
}
);
println!(" Step 2: Generate UniFFI Kotlin bindings");
println!(
" Output: {:?}",
android_dir.join("app/src/main/java/uniffi")
);
println!(" Step 3: Copy .so files to jniLibs directories");
println!(
" Destination: {:?}",
android_dir.join("app/src/main/jniLibs")
);
println!(" Step 4: Build Android APK with Gradle");
println!(
" Command: ./gradlew assemble{}",
if profile_name == "release" {
"Release"
} else {
"Debug"
}
);
println!(
" Output: {:?}",
android_dir.join(format!(
"app/build/outputs/apk/{}/app-{}.apk",
profile_name, profile_name
))
);
println!(" Step 5: Build Android test APK");
println!(
" Command: ./gradlew assemble{}AndroidTest",
if profile_name == "release" {
"Release"
} else {
"Debug"
}
);
return Ok(BuildResult {
platform: Target::Android,
app_path: android_dir.join(format!(
"app/build/outputs/apk/{}/app-{}.apk",
profile_name, profile_name
)),
test_suite_path: Some(android_dir.join(format!(
"app/build/outputs/apk/androidTest/{}/app-{}-androidTest.apk",
profile_name, profile_name
))),
native_libraries: Vec::new(),
});
}
crate::codegen::ensure_android_project_with_options(
&self.output_dir,
&self.crate_name,
Some(&self.project_root),
self.crate_dir.as_deref(),
)?;
self.ensure_gradle_wrapper(&android_dir)?;
println!("Building Rust libraries for Android...");
self.build_rust_libraries(config)?;
println!("Generating UniFFI Kotlin bindings...");
self.generate_uniffi_bindings()?;
println!("Copying native libraries to jniLibs...");
let native_libraries = self.copy_native_libraries(config)?;
println!("Building Android APK with Gradle...");
let apk_path = self.build_apk(config)?;
println!("Building Android test APK...");
let test_suite_path = self.build_test_apk(config)?;
let result = BuildResult {
platform: Target::Android,
app_path: apk_path,
test_suite_path: Some(test_suite_path),
native_libraries,
};
self.validate_build_artifacts(&result, config)?;
Ok(result)
}
fn validate_build_artifacts(
&self,
result: &BuildResult,
config: &BuildConfig,
) -> Result<(), BenchError> {
let mut missing = Vec::new();
let profile_dir = match config.profile {
BuildProfile::Debug => "debug",
BuildProfile::Release => "release",
};
if !result.app_path.exists() {
missing.push(format!("Main APK: {}", result.app_path.display()));
}
if let Some(ref test_path) = result.test_suite_path
&& !test_path.exists()
{
missing.push(format!("Test APK: {}", test_path.display()));
}
let jni_libs_dir = self.output_dir.join("android/app/src/main/jniLibs");
let lib_name = format!("lib{}.so", self.crate_name.replace("-", "_"));
let required_abis = self.resolve_android_abis(config)?;
let mut found_libs = 0;
for abi in &required_abis {
let lib_path = jni_libs_dir.join(abi).join(&lib_name);
if lib_path.exists() {
found_libs += 1;
} else {
missing.push(format!(
"Native library ({} {}): {}",
abi,
profile_dir,
lib_path.display()
));
}
}
if found_libs == 0 {
return Err(BenchError::Build(format!(
"Build validation failed: No native libraries found.\n\n\
Expected at least one .so file in jniLibs directories.\n\
Missing artifacts:\n{}\n\n\
This usually means the Rust build step failed. Check the cargo-ndk output above.",
missing
.iter()
.map(|s| format!(" - {}", s))
.collect::<Vec<_>>()
.join("\n")
)));
}
if !missing.is_empty() {
eprintln!(
"Warning: Some build artifacts are missing:\n{}\n\
The build may still work but some features might be unavailable.",
missing
.iter()
.map(|s| format!(" - {}", s))
.collect::<Vec<_>>()
.join("\n")
);
}
Ok(())
}
fn resolve_android_abis(&self, config: &BuildConfig) -> Result<Vec<String>, BenchError> {
let requested = config
.android_abis
.as_ref()
.filter(|abis| !abis.is_empty())
.cloned()
.unwrap_or_else(|| {
DEFAULT_ANDROID_ABIS
.iter()
.map(|abi| (*abi).to_string())
.collect()
});
let mut resolved = Vec::new();
for abi in requested {
if android_abi_to_rust_target(&abi).is_none() {
return Err(BenchError::Build(format!(
"Unsupported Android ABI '{abi}'. Supported values: arm64-v8a, armeabi-v7a, x86_64"
)));
}
if !resolved.contains(&abi) {
resolved.push(abi);
}
}
Ok(resolved)
}
fn find_crate_dir(&self) -> Result<PathBuf, BenchError> {
if let Some(ref dir) = self.crate_dir {
if dir.exists() {
return Ok(dir.clone());
}
return Err(BenchError::Build(format!(
"Specified crate path does not exist: {:?}.\n\n\
Tip: pass --crate-path pointing at a directory containing Cargo.toml.",
dir
)));
}
let root_cargo_toml = self.project_root.join("Cargo.toml");
if root_cargo_toml.exists()
&& let Some(pkg_name) = super::common::read_package_name(&root_cargo_toml)
&& pkg_name == self.crate_name
{
return Ok(self.project_root.clone());
}
let bench_mobile_dir = self.project_root.join("bench-mobile");
if bench_mobile_dir.exists() {
return Ok(bench_mobile_dir);
}
let crates_dir = self.project_root.join("crates").join(&self.crate_name);
if crates_dir.exists() {
return Ok(crates_dir);
}
let named_dir = self.project_root.join(&self.crate_name);
if named_dir.exists() {
return Ok(named_dir);
}
let root_manifest = root_cargo_toml;
let bench_mobile_manifest = bench_mobile_dir.join("Cargo.toml");
let crates_manifest = crates_dir.join("Cargo.toml");
let named_manifest = named_dir.join("Cargo.toml");
Err(BenchError::Build(format!(
"Benchmark crate '{}' not found.\n\n\
Searched locations:\n\
- {} (checked [package] name)\n\
- {}\n\
- {}\n\
- {}\n\n\
To fix this:\n\
1. Run from the crate directory (where Cargo.toml has name = \"{}\")\n\
2. Create a bench-mobile/ directory with your benchmark crate, or\n\
3. Use --crate-path to specify the benchmark crate location:\n\
cargo mobench build --target android --crate-path ./my-benchmarks\n\n\
Common issues:\n\
- Typo in crate name (check Cargo.toml [package] name)\n\
- Wrong working directory (run from project root)\n\
- Missing Cargo.toml in the crate directory\n\n\
Run 'cargo mobench init --help' to generate a new benchmark project.",
self.crate_name,
root_manifest.display(),
bench_mobile_manifest.display(),
crates_manifest.display(),
named_manifest.display(),
self.crate_name,
)))
}
fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
let crate_dir = self.find_crate_dir()?;
self.check_cargo_ndk()?;
let abis = self.resolve_android_abis(config)?;
let release_flag = if matches!(config.profile, BuildProfile::Release) {
"--release"
} else {
""
};
for abi in abis {
if self.verbose {
println!(" Building for {}", abi);
}
let mut cmd = Command::new("cargo");
cmd.arg("ndk")
.arg("--target")
.arg(&abi)
.arg("--platform")
.arg("24") .arg("build");
if !release_flag.is_empty() {
cmd.arg(release_flag);
}
cmd.current_dir(&crate_dir);
let command_hint = if release_flag.is_empty() {
format!("cargo ndk --target {} --platform 24 build", abi)
} else {
format!(
"cargo ndk --target {} --platform 24 build {}",
abi, release_flag
)
};
let output = cmd.output().map_err(|e| {
BenchError::Build(format!(
"Failed to start cargo-ndk for {}.\n\n\
Command: {}\n\
Crate directory: {}\n\
System error: {}\n\n\
Tips:\n\
- Install cargo-ndk: cargo install cargo-ndk\n\
- Ensure cargo is on PATH",
abi,
command_hint,
crate_dir.display(),
e
))
})?;
if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let profile = if matches!(config.profile, BuildProfile::Release) {
"release"
} else {
"debug"
};
let rust_target = android_abi_to_rust_target(&abi).unwrap_or(abi.as_str());
return Err(BenchError::Build(format!(
"cargo-ndk build failed for {} ({} profile).\n\n\
Command: {}\n\
Crate directory: {}\n\
Exit status: {}\n\n\
Stdout:\n{}\n\n\
Stderr:\n{}\n\n\
Common causes:\n\
- Missing Rust target: rustup target add {}\n\
- NDK not found: set ANDROID_NDK_HOME\n\
- Compilation error in Rust code (see output above)\n\
- Incompatible native dependencies (some C libraries do not support Android)",
abi,
profile,
command_hint,
crate_dir.display(),
output.status,
stdout,
stderr,
rust_target,
)));
}
}
Ok(())
}
fn check_cargo_ndk(&self) -> Result<(), BenchError> {
let output = Command::new("cargo").arg("ndk").arg("--version").output();
match output {
Ok(output) if output.status.success() => Ok(()),
_ => Err(BenchError::Build(
"cargo-ndk is not installed or not in PATH.\n\n\
cargo-ndk is required to cross-compile Rust for Android.\n\n\
To install:\n\
cargo install cargo-ndk\n\
Verify with:\n\
cargo ndk --version\n\n\
You also need the Android NDK. Set ANDROID_NDK_HOME or install via Android Studio.\n\
See: https://github.com/nickelc/cargo-ndk"
.to_string(),
)),
}
}
fn generate_uniffi_bindings(&self) -> Result<(), BenchError> {
let crate_dir = self.find_crate_dir()?;
let crate_name_underscored = self.crate_name.replace("-", "_");
let bindings_path = self
.output_dir
.join("android")
.join("app")
.join("src")
.join("main")
.join("java")
.join("uniffi")
.join(&crate_name_underscored)
.join(format!("{}.kt", crate_name_underscored));
if bindings_path.exists() {
if self.verbose {
println!(" Using existing Kotlin bindings at {:?}", bindings_path);
}
return Ok(());
}
let mut build_cmd = Command::new("cargo");
build_cmd.arg("build");
build_cmd.current_dir(&crate_dir);
run_command(build_cmd, "cargo build (host)")?;
let lib_path = host_lib_path(&crate_dir, &self.crate_name)?;
let out_dir = self
.output_dir
.join("android")
.join("app")
.join("src")
.join("main")
.join("java");
let cargo_run_result = Command::new("cargo")
.args([
"run",
"-p",
&self.crate_name,
"--bin",
"uniffi-bindgen",
"--",
])
.arg("generate")
.arg("--library")
.arg(&lib_path)
.arg("--language")
.arg("kotlin")
.arg("--out-dir")
.arg(&out_dir)
.current_dir(&crate_dir)
.output();
let use_cargo_run = cargo_run_result
.as_ref()
.map(|o| o.status.success())
.unwrap_or(false);
if use_cargo_run {
if self.verbose {
println!(" Generated bindings using cargo run uniffi-bindgen");
}
} else {
let uniffi_available = Command::new("uniffi-bindgen")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !uniffi_available {
return Err(BenchError::Build(
"uniffi-bindgen not found and no pre-generated bindings exist.\n\n\
To fix this, either:\n\
1. Add a uniffi-bindgen binary to your crate:\n\
[[bin]]\n\
name = \"uniffi-bindgen\"\n\
path = \"src/bin/uniffi-bindgen.rs\"\n\n\
2. Or install uniffi-bindgen globally:\n\
cargo install uniffi-bindgen\n\n\
3. Or pre-generate bindings and commit them."
.to_string(),
));
}
let mut cmd = Command::new("uniffi-bindgen");
cmd.arg("generate")
.arg("--library")
.arg(&lib_path)
.arg("--language")
.arg("kotlin")
.arg("--out-dir")
.arg(&out_dir);
run_command(cmd, "uniffi-bindgen kotlin")?;
}
if self.verbose {
println!(" Generated UniFFI Kotlin bindings at {:?}", out_dir);
}
Ok(())
}
fn copy_native_libraries(
&self,
config: &BuildConfig,
) -> Result<Vec<NativeLibraryArtifact>, BenchError> {
let crate_dir = self.find_crate_dir()?;
let profile_dir = match config.profile {
BuildProfile::Debug => "debug",
BuildProfile::Release => "release",
};
let target_dir = get_cargo_target_dir(&crate_dir)?;
let jni_libs_dir = self.output_dir.join("android/app/src/main/jniLibs");
std::fs::create_dir_all(&jni_libs_dir).map_err(|e| {
BenchError::Build(format!(
"Failed to create jniLibs directory at {}: {}. Check output directory permissions.",
jni_libs_dir.display(),
e
))
})?;
let mut native_libraries = Vec::new();
for android_abi in self.resolve_android_abis(config)? {
let rust_target = android_abi_to_rust_target(&android_abi).ok_or_else(|| {
BenchError::Build(format!(
"Unsupported Android ABI '{android_abi}'. Supported values: arm64-v8a, armeabi-v7a, x86_64"
))
})?;
let library_name = format!("lib{}.so", self.crate_name.replace("-", "_"));
let src = target_dir
.join(rust_target)
.join(profile_dir)
.join(&library_name);
let dest_dir = jni_libs_dir.join(&android_abi);
std::fs::create_dir_all(&dest_dir).map_err(|e| {
BenchError::Build(format!(
"Failed to create ABI directory {} at {}: {}. Check output directory permissions.",
android_abi,
dest_dir.display(),
e
))
})?;
let dest = dest_dir.join(&library_name);
if src.exists() {
std::fs::copy(&src, &dest).map_err(|e| {
BenchError::Build(format!(
"Failed to copy {} library from {} to {}: {}. Ensure cargo-ndk completed successfully.",
android_abi,
src.display(),
dest.display(),
e
))
})?;
if self.verbose {
println!(" Copied {} -> {}", src.display(), dest.display());
}
native_libraries.push(NativeLibraryArtifact {
abi: android_abi.clone(),
library_name: library_name.clone(),
unstripped_path: src,
packaged_path: dest,
});
} else {
eprintln!(
"Warning: Native library for {} not found at {}.\n\
This will cause a runtime crash when the app tries to load the library.\n\
Ensure cargo-ndk build completed successfully for this ABI.",
android_abi,
src.display()
);
}
}
Ok(native_libraries)
}
fn ensure_local_properties(&self, android_dir: &Path) -> Result<(), BenchError> {
let local_props = android_dir.join("local.properties");
if local_props.exists() {
return Ok(());
}
let sdk_dir = self.find_android_sdk_from_env();
match sdk_dir {
Some(path) => {
let content = format!("sdk.dir={}\n", path.display());
fs::write(&local_props, content).map_err(|e| {
BenchError::Build(format!(
"Failed to write local.properties at {:?}: {}. Check output directory permissions.",
local_props, e
))
})?;
if self.verbose {
println!(
" Generated local.properties with sdk.dir={}",
path.display()
);
}
}
None => {
if self.verbose {
println!(
" Skipping local.properties generation (ANDROID_HOME/ANDROID_SDK_ROOT not set)"
);
println!(
" Gradle will auto-detect SDK or you can create local.properties manually"
);
}
}
}
Ok(())
}
fn find_android_sdk_from_env(&self) -> Option<PathBuf> {
if let Ok(path) = env::var("ANDROID_HOME") {
let sdk_path = PathBuf::from(&path);
if sdk_path.exists() {
return Some(sdk_path);
}
}
if let Ok(path) = env::var("ANDROID_SDK_ROOT") {
let sdk_path = PathBuf::from(&path);
if sdk_path.exists() {
return Some(sdk_path);
}
}
None
}
fn ensure_gradle_wrapper(&self, android_dir: &Path) -> Result<(), BenchError> {
let gradlew = android_dir.join("gradlew");
if gradlew.exists() {
return Ok(());
}
println!("Gradle wrapper not found, generating...");
let gradle_available = Command::new("gradle")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !gradle_available {
return Err(BenchError::Build(
"Gradle wrapper (gradlew) not found and 'gradle' command is not available.\n\n\
The Android project requires Gradle to build. You have two options:\n\n\
1. Install Gradle globally and run the build again (it will auto-generate the wrapper):\n\
- macOS: brew install gradle\n\
- Linux: sudo apt install gradle\n\
- Or download from https://gradle.org/install/\n\n\
2. Or generate the wrapper manually in the Android project directory:\n\
cd target/mobench/android && gradle wrapper --gradle-version 8.5"
.to_string(),
));
}
let mut cmd = Command::new("gradle");
cmd.arg("wrapper")
.arg("--gradle-version")
.arg("8.5")
.current_dir(android_dir);
let output = cmd.output().map_err(|e| {
BenchError::Build(format!(
"Failed to run 'gradle wrapper' command: {}\n\n\
Ensure Gradle is installed and on your PATH.",
e
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(BenchError::Build(format!(
"Failed to generate Gradle wrapper.\n\n\
Command: gradle wrapper --gradle-version 8.5\n\
Working directory: {}\n\
Exit status: {}\n\
Stderr: {}\n\n\
Try running this command manually in the Android project directory.",
android_dir.display(),
output.status,
stderr
)));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = fs::metadata(&gradlew) {
let mut perms = metadata.permissions();
perms.set_mode(0o755);
let _ = fs::set_permissions(&gradlew, perms);
}
}
if self.verbose {
println!(" Generated Gradle wrapper at {:?}", gradlew);
}
Ok(())
}
fn build_apk(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
let android_dir = self.output_dir.join("android");
if !android_dir.exists() {
return Err(BenchError::Build(format!(
"Android project not found at {}.\n\n\
Expected a Gradle project under the output directory.\n\
Run `cargo mobench init --target android` or `cargo mobench build --target android` from the project root to generate it.",
android_dir.display()
)));
}
self.ensure_local_properties(&android_dir)?;
let gradle_task = match config.profile {
BuildProfile::Debug => "assembleDebug",
BuildProfile::Release => "assembleRelease",
};
let mut cmd = Command::new("./gradlew");
cmd.arg(gradle_task).current_dir(&android_dir);
if self.verbose {
cmd.arg("--info");
}
let output = cmd.output().map_err(|e| {
BenchError::Build(format!(
"Failed to run Gradle wrapper.\n\n\
Command: ./gradlew {}\n\
Working directory: {}\n\
Error: {}\n\n\
Tips:\n\
- Ensure ./gradlew is executable (chmod +x ./gradlew)\n\
- Run ./gradlew --version in that directory to verify the wrapper",
gradle_task,
android_dir.display(),
e
))
})?;
if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(BenchError::Build(format!(
"Gradle build failed.\n\n\
Command: ./gradlew {}\n\
Working directory: {}\n\
Exit status: {}\n\n\
Stdout:\n{}\n\n\
Stderr:\n{}\n\n\
Tips:\n\
- Re-run with verbose mode to pass --info to Gradle\n\
- Run ./gradlew {} --stacktrace for a full stack trace",
gradle_task,
android_dir.display(),
output.status,
stdout,
stderr,
gradle_task,
)));
}
let profile_name = match config.profile {
BuildProfile::Debug => "debug",
BuildProfile::Release => "release",
};
let apk_dir = android_dir.join("app/build/outputs/apk").join(profile_name);
let apk_path = self.find_apk(&apk_dir, profile_name, gradle_task)?;
Ok(apk_path)
}
fn find_apk(
&self,
apk_dir: &Path,
profile_name: &str,
gradle_task: &str,
) -> Result<PathBuf, BenchError> {
let metadata_path = apk_dir.join("output-metadata.json");
if metadata_path.exists()
&& let Ok(metadata_content) = fs::read_to_string(&metadata_path)
{
if let Some(apk_name) = self.parse_output_metadata(&metadata_content) {
let apk_path = apk_dir.join(&apk_name);
if apk_path.exists() {
if self.verbose {
println!(
" Found APK from output-metadata.json: {}",
apk_path.display()
);
}
return Ok(apk_path);
}
}
}
let candidates = if profile_name == "release" {
vec![
format!("app-{}.apk", profile_name), format!("app-{}-unsigned.apk", profile_name), ]
} else {
vec![
format!("app-{}.apk", profile_name), ]
};
for candidate in &candidates {
let apk_path = apk_dir.join(candidate);
if apk_path.exists() {
if self.verbose {
println!(" Found APK: {}", apk_path.display());
}
return Ok(apk_path);
}
}
Err(BenchError::Build(format!(
"APK not found in {}.\n\n\
Gradle task {} reported success but no APK was produced.\n\
Searched for:\n{}\n\n\
Check the build output directory and rerun ./gradlew {} if needed.",
apk_dir.display(),
gradle_task,
candidates
.iter()
.map(|c| format!(" - {}", c))
.collect::<Vec<_>>()
.join("\n"),
gradle_task
)))
}
fn parse_output_metadata(&self, content: &str) -> Option<String> {
let pattern = "\"outputFile\"";
if let Some(pos) = content.find(pattern) {
let after_key = &content[pos + pattern.len()..];
let after_colon = after_key.trim_start().strip_prefix(':')?;
let after_ws = after_colon.trim_start();
if let Some(value_start) = after_ws.strip_prefix('"')
&& let Some(end_quote) = value_start.find('"')
{
let filename = &value_start[..end_quote];
if filename.ends_with(".apk") {
return Some(filename.to_string());
}
}
}
None
}
fn build_test_apk(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
let android_dir = self.output_dir.join("android");
if !android_dir.exists() {
return Err(BenchError::Build(format!(
"Android project not found at {}.\n\n\
Expected a Gradle project under the output directory.\n\
Run `cargo mobench init --target android` or `cargo mobench build --target android` from the project root to generate it.",
android_dir.display()
)));
}
let gradle_task = match config.profile {
BuildProfile::Debug => "assembleDebugAndroidTest",
BuildProfile::Release => "assembleReleaseAndroidTest",
};
let profile_name = match config.profile {
BuildProfile::Debug => "debug",
BuildProfile::Release => "release",
};
let mut cmd = Command::new("./gradlew");
cmd.arg(format!("-PmobenchTestBuildType={profile_name}"))
.arg(gradle_task)
.current_dir(&android_dir);
if self.verbose {
cmd.arg("--info");
}
let output = cmd.output().map_err(|e| {
BenchError::Build(format!(
"Failed to run Gradle wrapper.\n\n\
Command: ./gradlew {}\n\
Working directory: {}\n\
Error: {}\n\n\
Tips:\n\
- Ensure ./gradlew is executable (chmod +x ./gradlew)\n\
- Run ./gradlew --version in that directory to verify the wrapper",
gradle_task,
android_dir.display(),
e
))
})?;
if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(BenchError::Build(format!(
"Gradle test APK build failed.\n\n\
Command: ./gradlew {}\n\
Working directory: {}\n\
Exit status: {}\n\n\
Stdout:\n{}\n\n\
Stderr:\n{}\n\n\
Tips:\n\
- Re-run with verbose mode to pass --info to Gradle\n\
- Run ./gradlew {} --stacktrace for a full stack trace",
gradle_task,
android_dir.display(),
output.status,
stdout,
stderr,
gradle_task,
)));
}
let test_apk_dir = android_dir
.join("app/build/outputs/apk/androidTest")
.join(profile_name);
let apk_path = self.find_test_apk(&test_apk_dir, profile_name, gradle_task)?;
Ok(apk_path)
}
fn find_test_apk(
&self,
apk_dir: &Path,
profile_name: &str,
gradle_task: &str,
) -> Result<PathBuf, BenchError> {
let metadata_path = apk_dir.join("output-metadata.json");
if metadata_path.exists()
&& let Ok(metadata_content) = fs::read_to_string(&metadata_path)
&& let Some(apk_name) = self.parse_output_metadata(&metadata_content)
{
let apk_path = apk_dir.join(&apk_name);
if apk_path.exists() {
if self.verbose {
println!(
" Found test APK from output-metadata.json: {}",
apk_path.display()
);
}
return Ok(apk_path);
}
}
let apk_path = apk_dir.join(format!("app-{}-androidTest.apk", profile_name));
if apk_path.exists() {
if self.verbose {
println!(" Found test APK: {}", apk_path.display());
}
return Ok(apk_path);
}
Err(BenchError::Build(format!(
"Android test APK not found in {}.\n\n\
Gradle task {} reported success but no test APK was produced.\n\
Expected: app-{}-androidTest.apk\n\n\
Check app/build/outputs/apk/androidTest/{} and rerun ./gradlew {} if needed.",
apk_dir.display(),
gradle_task,
profile_name,
profile_name,
gradle_task
)))
}
}
fn android_abi_to_rust_target(abi: &str) -> Option<&'static str> {
match abi {
"arm64-v8a" => Some("aarch64-linux-android"),
"armeabi-v7a" => Some("armv7-linux-androideabi"),
"x86_64" => Some("x86_64-linux-android"),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AndroidStackSymbolization {
pub line: String,
pub resolved_frames: u64,
pub unresolved_frames: u64,
}
pub fn symbolize_android_native_stack_line_with_resolver<F>(
line: &str,
mut resolve: F,
) -> AndroidStackSymbolization
where
F: FnMut(&str, u64) -> Option<String>,
{
let (stack, sample_count) = split_folded_stack_line(line);
let mut resolved_frames = 0;
let mut unresolved_frames = 0;
let rewritten = stack
.split(';')
.map(|frame| {
if let Some((library_name, offset)) = parse_android_native_offset_frame(frame) {
if let Some(symbol) = resolve(library_name, offset) {
resolved_frames += 1;
return symbol;
}
unresolved_frames += 1;
}
frame.to_string()
})
.collect::<Vec<_>>()
.join(";");
let line = match sample_count {
Some(count) => format!("{rewritten} {count}"),
None => rewritten,
};
AndroidStackSymbolization {
line,
resolved_frames,
unresolved_frames,
}
}
pub fn resolve_android_native_symbol_with_addr2line(
library_path: &Path,
offset: u64,
) -> Option<String> {
let tool_path = locate_android_addr2line_tool_path()?;
resolve_android_native_symbol_with_tool(&tool_path, library_path, offset)
}
pub fn resolve_android_native_symbol_with_tool(
tool_path: &Path,
library_path: &Path,
offset: u64,
) -> Option<String> {
let output = Command::new(tool_path)
.args(["-Cfpe"])
.arg(library_path)
.arg(format!("0x{offset:x}"))
.output()
.ok()?;
if !output.status.success() {
return None;
}
parse_android_addr2line_stdout(&String::from_utf8_lossy(&output.stdout))
}
fn parse_android_addr2line_stdout(stdout: &str) -> Option<String> {
stdout.lines().find_map(|line| {
let symbol = line.trim();
if symbol.is_empty() || symbol == "??" || symbol.starts_with("?? ") {
None
} else {
Some(
symbol
.split(" at ")
.next()
.unwrap_or(symbol)
.trim()
.to_owned(),
)
}
})
}
fn locate_android_addr2line_tool_path() -> Option<PathBuf> {
let override_path = std::env::var_os("MOBENCH_ANDROID_LLVM_ADDR2LINE")
.or_else(|| std::env::var_os("LLVM_ADDR2LINE"))
.map(PathBuf::from);
if let Some(path) = override_path {
return path.exists().then_some(path);
}
let sdk_root = std::env::var_os("ANDROID_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("ANDROID_SDK_ROOT").map(PathBuf::from))
.or_else(|| {
std::env::var_os("ANDROID_NDK_HOME")
.map(PathBuf::from)
.and_then(|ndk_home| ndk_home.parent().and_then(Path::parent).map(PathBuf::from))
})?;
let ndk_root = std::env::var_os("ANDROID_NDK_HOME")
.map(PathBuf::from)
.or_else(|| {
let ndk_dir = sdk_root.join("ndk");
std::fs::read_dir(&ndk_dir).ok().and_then(|entries| {
entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.max()
})
})?;
let tool_name = if cfg!(windows) {
"llvm-addr2line.exe"
} else {
"llvm-addr2line"
};
let prebuilt_root = ndk_root.join("toolchains").join("llvm").join("prebuilt");
let mut candidates = Vec::new();
if let Ok(entries) = std::fs::read_dir(&prebuilt_root) {
for entry in entries.flatten() {
let candidate = entry.path().join("bin").join(tool_name);
if candidate.exists() {
candidates.push(candidate);
}
}
}
candidates.sort();
candidates.into_iter().next()
}
fn split_folded_stack_line(line: &str) -> (&str, Option<&str>) {
match line.rsplit_once(' ') {
Some((stack, count))
if !stack.is_empty() && count.chars().all(|ch| ch.is_ascii_digit()) =>
{
(stack, Some(count))
}
_ => (line, None),
}
}
fn parse_android_native_offset_frame(frame: &str) -> Option<(&str, u64)> {
let marker = ".so[+";
let marker_index = frame.find(marker)?;
let library_end = marker_index + 3;
let library_name = frame[..library_end].rsplit('/').next()?;
let offset_start = marker_index + marker.len();
let offset_end = frame[offset_start..].find(']')? + offset_start;
let offset_raw = &frame[offset_start..offset_end];
let offset = if let Some(hex) = offset_raw.strip_prefix("0x") {
u64::from_str_radix(hex, 16).ok()?
} else {
offset_raw.parse().ok()?
};
Some((library_name, offset))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_android_builder_creation() {
let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
assert!(!builder.verbose);
assert_eq!(
builder.output_dir,
PathBuf::from("/tmp/test-project/target/mobench")
);
}
#[test]
fn test_android_builder_verbose() {
let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile").verbose(true);
assert!(builder.verbose);
}
#[test]
fn test_android_builder_custom_output_dir() {
let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile")
.output_dir("/custom/output");
assert_eq!(builder.output_dir, PathBuf::from("/custom/output"));
}
#[test]
fn test_parse_output_metadata_unsigned() {
let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
let metadata = r#"{"version":3,"artifactType":{"type":"APK","kind":"Directory"},"applicationId":"dev.world.bench","variantName":"release","elements":[{"type":"SINGLE","filters":[],"attributes":[],"versionCode":1,"versionName":"0.1","outputFile":"app-release-unsigned.apk"}],"elementType":"File"}"#;
let result = builder.parse_output_metadata(metadata);
assert_eq!(result, Some("app-release-unsigned.apk".to_string()));
}
#[test]
fn test_parse_output_metadata_signed() {
let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
let metadata = r#"{"version":3,"elements":[{"outputFile":"app-release.apk"}]}"#;
let result = builder.parse_output_metadata(metadata);
assert_eq!(result, Some("app-release.apk".to_string()));
}
#[test]
fn test_parse_output_metadata_no_apk() {
let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
let metadata = r#"{"version":3,"elements":[]}"#;
let result = builder.parse_output_metadata(metadata);
assert_eq!(result, None);
}
#[test]
fn test_parse_output_metadata_invalid_json() {
let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
let metadata = "not valid json";
let result = builder.parse_output_metadata(metadata);
assert_eq!(result, None);
}
#[test]
fn test_android_builder_defaults_to_arm64_only() {
let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
let config = BuildConfig {
target: Target::Android,
profile: BuildProfile::Debug,
incremental: true,
android_abis: None,
};
let abis = builder
.resolve_android_abis(&config)
.expect("resolve default ABIs");
assert_eq!(abis, vec!["arm64-v8a".to_string()]);
}
#[test]
fn test_android_builder_uses_explicit_abis_when_configured() {
let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
let config = BuildConfig {
target: Target::Android,
profile: BuildProfile::Release,
incremental: true,
android_abis: Some(vec!["arm64-v8a".to_string(), "x86_64".to_string()]),
};
let abis = builder
.resolve_android_abis(&config)
.expect("resolve configured ABIs");
assert_eq!(abis, vec!["arm64-v8a".to_string(), "x86_64".to_string()]);
}
#[test]
fn android_native_offsets_are_symbolized_into_rust_frames() {
let input = "dev.world.samplefns;uniffi.sample_fns.Sample_fnsKt.runBenchmark;libsample_fns.so[+94138] 1";
let output =
symbolize_android_native_stack_line_with_resolver(input, |library_name, offset| {
if library_name == "libsample_fns.so" && offset == 94_138 {
Some("sample_fns::fibonacci".into())
} else {
None
}
});
assert!(
output.line.contains("sample_fns::fibonacci"),
"expected unresolved native offsets to be rewritten into Rust symbols, got: {}",
output.line
);
assert_eq!(output.resolved_frames, 1);
assert_eq!(output.unresolved_frames, 0);
}
#[test]
fn resolve_android_native_symbol_with_tool_invokes_addr2line() {
let temp_dir = std::env::temp_dir().join(format!(
"mobench-addr2line-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time")
.as_nanos()
));
std::fs::create_dir_all(&temp_dir).expect("create temp dir");
let tool_path = temp_dir.join("llvm-addr2line.sh");
let args_path = temp_dir.join("args.txt");
let script = format!(
"#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\nprintf '%s\\n' 'sample_fns::fibonacci at /tmp/src/lib.rs:131'\n",
args_path.display()
);
std::fs::write(&tool_path, script).expect("write shim");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&tool_path)
.expect("metadata")
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&tool_path, perms).expect("chmod");
}
let symbol = resolve_android_native_symbol_with_tool(
&tool_path,
Path::new("/cargo/target/aarch64-linux-android/release/libsample_fns.so"),
94_138,
);
assert_eq!(symbol.as_deref(), Some("sample_fns::fibonacci"));
let args = std::fs::read_to_string(&args_path).expect("read args");
let expected_offset = format!("0x{:x}", 94_138);
assert!(
args.lines().any(|line| line == "-Cfpe"),
"expected llvm-addr2line to be called with -Cfpe, got:\n{args}"
);
assert!(
args.lines().any(|line| {
line == "/cargo/target/aarch64-linux-android/release/libsample_fns.so"
}),
"expected llvm-addr2line to use the unstripped library path, got:\n{args}"
);
assert!(
args.lines().any(|line| line == expected_offset),
"expected llvm-addr2line to receive the resolved offset, got:\n{args}"
);
}
#[test]
fn android_native_offsets_preserve_unresolved_frames() {
let input = "dev.world.samplefns;libsample_fns.so[+94138];libother.so[+17] 1";
let output =
symbolize_android_native_stack_line_with_resolver(input, |library_name, offset| {
if library_name == "libsample_fns.so" && offset == 94_138 {
Some("sample_fns::fibonacci".into())
} else {
None
}
});
assert!(output.line.contains("sample_fns::fibonacci"));
assert!(output.line.contains("libother.so[+17]"));
assert_eq!(output.resolved_frames, 1);
assert_eq!(output.unresolved_frames, 1);
}
#[test]
fn test_find_crate_dir_current_directory_is_crate() {
let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-current");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
std::fs::write(
temp_dir.join("Cargo.toml"),
r#"[package]
name = "bench-mobile"
version = "0.1.0"
"#,
)
.unwrap();
let builder = AndroidBuilder::new(&temp_dir, "bench-mobile");
let result = builder.find_crate_dir();
assert!(result.is_ok(), "Should find crate in current directory");
assert_eq!(result.unwrap(), temp_dir);
std::fs::remove_dir_all(&temp_dir).unwrap();
}
#[test]
fn test_find_crate_dir_nested_bench_mobile() {
let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-nested");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(temp_dir.join("bench-mobile")).unwrap();
std::fs::write(
temp_dir.join("Cargo.toml"),
r#"[workspace]
members = ["bench-mobile"]
"#,
)
.unwrap();
std::fs::write(
temp_dir.join("bench-mobile/Cargo.toml"),
r#"[package]
name = "bench-mobile"
version = "0.1.0"
"#,
)
.unwrap();
let builder = AndroidBuilder::new(&temp_dir, "bench-mobile");
let result = builder.find_crate_dir();
assert!(
result.is_ok(),
"Should find crate in bench-mobile/ directory"
);
assert_eq!(result.unwrap(), temp_dir.join("bench-mobile"));
std::fs::remove_dir_all(&temp_dir).unwrap();
}
#[test]
fn test_find_crate_dir_crates_subdir() {
let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-crates");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(temp_dir.join("crates/my-bench")).unwrap();
std::fs::write(
temp_dir.join("Cargo.toml"),
r#"[workspace]
members = ["crates/*"]
"#,
)
.unwrap();
std::fs::write(
temp_dir.join("crates/my-bench/Cargo.toml"),
r#"[package]
name = "my-bench"
version = "0.1.0"
"#,
)
.unwrap();
let builder = AndroidBuilder::new(&temp_dir, "my-bench");
let result = builder.find_crate_dir();
assert!(result.is_ok(), "Should find crate in crates/ directory");
assert_eq!(result.unwrap(), temp_dir.join("crates/my-bench"));
std::fs::remove_dir_all(&temp_dir).unwrap();
}
#[test]
fn test_find_crate_dir_not_found() {
let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-notfound");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
std::fs::write(
temp_dir.join("Cargo.toml"),
r#"[package]
name = "some-other-crate"
version = "0.1.0"
"#,
)
.unwrap();
let builder = AndroidBuilder::new(&temp_dir, "nonexistent-crate");
let result = builder.find_crate_dir();
assert!(result.is_err(), "Should fail to find nonexistent crate");
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Benchmark crate 'nonexistent-crate' not found"));
assert!(err_msg.contains("Searched locations"));
std::fs::remove_dir_all(&temp_dir).unwrap();
}
#[test]
fn test_find_crate_dir_explicit_crate_path() {
let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-explicit");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(temp_dir.join("custom-location")).unwrap();
let builder =
AndroidBuilder::new(&temp_dir, "any-name").crate_dir(temp_dir.join("custom-location"));
let result = builder.find_crate_dir();
assert!(result.is_ok(), "Should use explicit crate_dir");
assert_eq!(result.unwrap(), temp_dir.join("custom-location"));
std::fs::remove_dir_all(&temp_dir).unwrap();
}
}