Skip to main content

mobench_sdk/builders/
android.rs

1//! Android build automation.
2//!
3//! This module provides [`AndroidBuilder`] which handles the complete pipeline for
4//! building Rust libraries for Android and packaging them into an APK using Gradle.
5//!
6//! ## Build Pipeline
7//!
8//! The builder performs these steps:
9//!
10//! 1. **Project scaffolding** - Auto-generates Android project if missing
11//! 2. **Rust compilation** - Builds native `.so` libraries for Android ABIs using `cargo-ndk`
12//! 3. **Binding generation** - Generates UniFFI Kotlin bindings
13//! 4. **Library packaging** - Copies `.so` files to `jniLibs/` directories
14//! 5. **APK building** - Runs Gradle to build the app APK
15//! 6. **Test APK building** - Builds the androidTest APK for BrowserStack Espresso
16//!
17//! ## Requirements
18//!
19//! - Android NDK (set `ANDROID_NDK_HOME` environment variable)
20//! - `cargo-ndk` (`cargo install cargo-ndk`)
21//! - Rust targets: `aarch64-linux-android` by default
22//! - Optional extra targets can be enabled via `BuildConfig::android_abis`
23//! - Java JDK (for Gradle)
24//!
25//! ## Example
26//!
27//! ```ignore
28//! use mobench_sdk::builders::AndroidBuilder;
29//! use mobench_sdk::{BuildConfig, BuildProfile, Target};
30//!
31//! let builder = AndroidBuilder::new(".", "my-bench-crate")
32//!     .verbose(true)
33//!     .dry_run(false);  // Set to true to preview without building
34//!
35//! let config = BuildConfig {
36//!     target: Target::Android,
37//!     profile: BuildProfile::Release,
38//!     incremental: true,
39//!     android_abis: None,
40//! };
41//!
42//! let result = builder.build(&config)?;
43//! println!("APK at: {:?}", result.app_path);
44//! println!("Test APK at: {:?}", result.test_suite_path);
45//! # Ok::<(), mobench_sdk::BenchError>(())
46//! ```
47//!
48//! ## Dry-Run Mode
49//!
50//! Use `dry_run(true)` to preview the build plan without making changes:
51//!
52//! ```ignore
53//! let builder = AndroidBuilder::new(".", "my-bench")
54//!     .dry_run(true);
55//!
56//! // This will print the build plan but not execute anything
57//! builder.build(&config)?;
58//! ```
59
60use super::common::{get_cargo_target_dir, host_lib_path, run_command, validate_project_root};
61use crate::types::{
62    BenchError, BuildConfig, BuildProfile, BuildResult, NativeLibraryArtifact, Target,
63};
64use std::env;
65use std::fs;
66use std::path::{Path, PathBuf};
67use std::process::Command;
68
69/// Android builder that handles the complete build pipeline.
70///
71/// This builder automates the process of compiling Rust code to Android native
72/// libraries, generating UniFFI Kotlin bindings, and packaging everything into
73/// an APK ready for deployment.
74///
75/// # Example
76///
77/// ```ignore
78/// use mobench_sdk::builders::AndroidBuilder;
79/// use mobench_sdk::{BuildConfig, BuildProfile, Target};
80///
81/// let builder = AndroidBuilder::new(".", "my-bench")
82///     .verbose(true)
83///     .output_dir("target/mobench");
84///
85/// let config = BuildConfig {
86///     target: Target::Android,
87///     profile: BuildProfile::Release,
88///     incremental: true,
89///     android_abis: None,
90/// };
91///
92/// let result = builder.build(&config)?;
93/// # Ok::<(), mobench_sdk::BenchError>(())
94/// ```
95pub struct AndroidBuilder {
96    /// Root directory of the project
97    project_root: PathBuf,
98    /// Output directory for mobile artifacts (defaults to target/mobench)
99    output_dir: PathBuf,
100    /// Name of the bench-mobile crate
101    crate_name: String,
102    /// Whether to use verbose output
103    verbose: bool,
104    /// Optional explicit crate directory (overrides auto-detection)
105    crate_dir: Option<PathBuf>,
106    /// Whether to run in dry-run mode (print what would be done without making changes)
107    dry_run: bool,
108}
109
110const DEFAULT_ANDROID_ABIS: &[&str] = &["arm64-v8a"];
111
112impl AndroidBuilder {
113    /// Creates a new Android builder
114    ///
115    /// # Arguments
116    ///
117    /// * `project_root` - Root directory containing the bench-mobile crate
118    /// * `crate_name` - Name of the bench-mobile crate (e.g., "my-project-bench-mobile")
119    pub fn new(project_root: impl Into<PathBuf>, crate_name: impl Into<String>) -> Self {
120        let root = project_root.into();
121        Self {
122            output_dir: root.join("target/mobench"),
123            project_root: root,
124            crate_name: crate_name.into(),
125            verbose: false,
126            crate_dir: None,
127            dry_run: false,
128        }
129    }
130
131    /// Sets the output directory for mobile artifacts
132    ///
133    /// By default, artifacts are written to `{project_root}/target/mobench/`.
134    /// Use this to customize the output location.
135    pub fn output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
136        self.output_dir = dir.into();
137        self
138    }
139
140    /// Sets the explicit crate directory
141    ///
142    /// By default, the builder searches for the crate in this order:
143    /// 1. `{project_root}/Cargo.toml` - if it exists and has `[package] name` matching `crate_name`
144    /// 2. `{project_root}/bench-mobile/` - SDK-generated projects
145    /// 3. `{project_root}/crates/{crate_name}/` - workspace structure
146    /// 4. `{project_root}/{crate_name}/` - simple nested structure
147    ///
148    /// Use this to override auto-detection and point directly to the crate.
149    pub fn crate_dir(mut self, dir: impl Into<PathBuf>) -> Self {
150        self.crate_dir = Some(dir.into());
151        self
152    }
153
154    /// Enables verbose output
155    pub fn verbose(mut self, verbose: bool) -> Self {
156        self.verbose = verbose;
157        self
158    }
159
160    /// Enables dry-run mode
161    ///
162    /// In dry-run mode, the builder prints what would be done without actually
163    /// making any changes. Useful for previewing the build process.
164    pub fn dry_run(mut self, dry_run: bool) -> Self {
165        self.dry_run = dry_run;
166        self
167    }
168
169    /// Builds the Android app with the given configuration
170    ///
171    /// This performs the following steps:
172    /// 0. Auto-generate project scaffolding if missing
173    /// 1. Build Rust libraries for Android ABIs using cargo-ndk
174    /// 2. Generate UniFFI Kotlin bindings
175    /// 3. Copy .so files to jniLibs directories
176    /// 4. Run Gradle to build the APK
177    ///
178    /// # Returns
179    ///
180    /// * `Ok(BuildResult)` containing the path to the built APK
181    /// * `Err(BenchError)` if the build fails
182    pub fn build(&self, config: &BuildConfig) -> Result<BuildResult, BenchError> {
183        // Validate project root before starting build
184        if self.crate_dir.is_none() {
185            validate_project_root(&self.project_root, &self.crate_name)?;
186        }
187
188        let android_dir = self.output_dir.join("android");
189        let profile_name = match config.profile {
190            BuildProfile::Debug => "debug",
191            BuildProfile::Release => "release",
192        };
193        let android_abis = self.resolve_android_abis(config)?;
194
195        if self.dry_run {
196            println!("\n[dry-run] Android build plan:");
197            println!(
198                "  Step 0: Check/generate Android project scaffolding at {:?}",
199                android_dir
200            );
201            println!("  Step 0.5: Ensure Gradle wrapper exists (run 'gradle wrapper' if needed)");
202            println!(
203                "  Step 1: Build Rust libraries for Android ABIs ({})",
204                android_abis.join(", ")
205            );
206            println!(
207                "    Command: cargo ndk --target <abi> --platform 24 build {}",
208                if matches!(config.profile, BuildProfile::Release) {
209                    "--release"
210                } else {
211                    ""
212                }
213            );
214            println!("  Step 2: Generate UniFFI Kotlin bindings");
215            println!(
216                "    Output: {:?}",
217                android_dir.join("app/src/main/java/uniffi")
218            );
219            println!("  Step 3: Copy .so files to jniLibs directories");
220            println!(
221                "    Destination: {:?}",
222                android_dir.join("app/src/main/jniLibs")
223            );
224            println!("  Step 4: Build Android APK with Gradle");
225            println!(
226                "    Command: ./gradlew assemble{}",
227                if profile_name == "release" {
228                    "Release"
229                } else {
230                    "Debug"
231                }
232            );
233            println!(
234                "    Output: {:?}",
235                android_dir.join(format!(
236                    "app/build/outputs/apk/{}/app-{}.apk",
237                    profile_name, profile_name
238                ))
239            );
240            println!("  Step 5: Build Android test APK");
241            println!(
242                "    Command: ./gradlew assemble{}AndroidTest",
243                if profile_name == "release" {
244                    "Release"
245                } else {
246                    "Debug"
247                }
248            );
249
250            // Return a placeholder result for dry-run
251            return Ok(BuildResult {
252                platform: Target::Android,
253                app_path: android_dir.join(format!(
254                    "app/build/outputs/apk/{}/app-{}.apk",
255                    profile_name, profile_name
256                )),
257                test_suite_path: Some(android_dir.join(format!(
258                    "app/build/outputs/apk/androidTest/{}/app-{}-androidTest.apk",
259                    profile_name, profile_name
260                ))),
261                native_libraries: Vec::new(),
262            });
263        }
264
265        // Step 0: Ensure Android project scaffolding exists
266        // Pass project_root and crate_dir for better benchmark function detection
267        crate::codegen::ensure_android_project_with_options(
268            &self.output_dir,
269            &self.crate_name,
270            Some(&self.project_root),
271            self.crate_dir.as_deref(),
272        )?;
273
274        // Step 0.5: Ensure Gradle wrapper exists
275        self.ensure_gradle_wrapper(&android_dir)?;
276
277        // Step 1: Build Rust libraries
278        println!("Building Rust libraries for Android...");
279        self.build_rust_libraries(config)?;
280
281        // Step 2: Generate UniFFI bindings
282        println!("Generating UniFFI Kotlin bindings...");
283        self.generate_uniffi_bindings()?;
284
285        // Step 3: Copy .so files to jniLibs
286        println!("Copying native libraries to jniLibs...");
287        let native_libraries = self.copy_native_libraries(config)?;
288
289        // Step 4: Build APK with Gradle
290        println!("Building Android APK with Gradle...");
291        let apk_path = self.build_apk(config)?;
292
293        // Step 5: Build Android test APK for BrowserStack
294        println!("Building Android test APK...");
295        let test_suite_path = self.build_test_apk(config)?;
296
297        // Step 6: Validate all expected artifacts exist
298        let result = BuildResult {
299            platform: Target::Android,
300            app_path: apk_path,
301            test_suite_path: Some(test_suite_path),
302            native_libraries,
303        };
304        self.validate_build_artifacts(&result, config)?;
305
306        Ok(result)
307    }
308
309    /// Validates that all expected build artifacts exist after a successful build
310    fn validate_build_artifacts(
311        &self,
312        result: &BuildResult,
313        config: &BuildConfig,
314    ) -> Result<(), BenchError> {
315        let mut missing = Vec::new();
316        let profile_dir = match config.profile {
317            BuildProfile::Debug => "debug",
318            BuildProfile::Release => "release",
319        };
320
321        // Check main APK
322        if !result.app_path.exists() {
323            missing.push(format!("Main APK: {}", result.app_path.display()));
324        }
325
326        // Check test APK
327        if let Some(ref test_path) = result.test_suite_path {
328            if !test_path.exists() {
329                missing.push(format!("Test APK: {}", test_path.display()));
330            }
331        }
332
333        // Check that at least one native library exists in jniLibs
334        let jni_libs_dir = self.output_dir.join("android/app/src/main/jniLibs");
335        let lib_name = format!("lib{}.so", self.crate_name.replace("-", "_"));
336        let required_abis = self.resolve_android_abis(config)?;
337        let mut found_libs = 0;
338        for abi in &required_abis {
339            let lib_path = jni_libs_dir.join(abi).join(&lib_name);
340            if lib_path.exists() {
341                found_libs += 1;
342            } else {
343                missing.push(format!(
344                    "Native library ({} {}): {}",
345                    abi,
346                    profile_dir,
347                    lib_path.display()
348                ));
349            }
350        }
351
352        if found_libs == 0 {
353            return Err(BenchError::Build(format!(
354                "Build validation failed: No native libraries found.\n\n\
355                 Expected at least one .so file in jniLibs directories.\n\
356                 Missing artifacts:\n{}\n\n\
357                 This usually means the Rust build step failed. Check the cargo-ndk output above.",
358                missing
359                    .iter()
360                    .map(|s| format!("  - {}", s))
361                    .collect::<Vec<_>>()
362                    .join("\n")
363            )));
364        }
365
366        if !missing.is_empty() {
367            eprintln!(
368                "Warning: Some build artifacts are missing:\n{}\n\
369                 The build may still work but some features might be unavailable.",
370                missing
371                    .iter()
372                    .map(|s| format!("  - {}", s))
373                    .collect::<Vec<_>>()
374                    .join("\n")
375            );
376        }
377
378        Ok(())
379    }
380
381    fn resolve_android_abis(&self, config: &BuildConfig) -> Result<Vec<String>, BenchError> {
382        let requested = config
383            .android_abis
384            .as_ref()
385            .filter(|abis| !abis.is_empty())
386            .cloned()
387            .unwrap_or_else(|| {
388                DEFAULT_ANDROID_ABIS
389                    .iter()
390                    .map(|abi| (*abi).to_string())
391                    .collect()
392            });
393
394        let mut resolved = Vec::new();
395        for abi in requested {
396            if android_abi_to_rust_target(&abi).is_none() {
397                return Err(BenchError::Build(format!(
398                    "Unsupported Android ABI '{abi}'. Supported values: arm64-v8a, armeabi-v7a, x86_64"
399                )));
400            }
401            if !resolved.contains(&abi) {
402                resolved.push(abi);
403            }
404        }
405
406        Ok(resolved)
407    }
408
409    /// Finds the benchmark crate directory.
410    ///
411    /// Search order:
412    /// 1. Explicit `crate_dir` if set via `.crate_dir()` builder method
413    /// 2. Current directory (`project_root`) if its Cargo.toml has a matching package name
414    /// 3. `{project_root}/bench-mobile/` (SDK projects)
415    /// 4. `{project_root}/crates/{crate_name}/` (repository structure)
416    fn find_crate_dir(&self) -> Result<PathBuf, BenchError> {
417        // If explicit crate_dir was provided, use it
418        if let Some(ref dir) = self.crate_dir {
419            if dir.exists() {
420                return Ok(dir.clone());
421            }
422            return Err(BenchError::Build(format!(
423                "Specified crate path does not exist: {:?}.\n\n\
424                 Tip: pass --crate-path pointing at a directory containing Cargo.toml.",
425                dir
426            )));
427        }
428
429        // Check if the current directory (project_root) IS the crate
430        // This handles the case where user runs `cargo mobench build` from within the crate directory
431        let root_cargo_toml = self.project_root.join("Cargo.toml");
432        if root_cargo_toml.exists() {
433            if let Some(pkg_name) = super::common::read_package_name(&root_cargo_toml) {
434                if pkg_name == self.crate_name {
435                    return Ok(self.project_root.clone());
436                }
437            }
438        }
439
440        // Try bench-mobile/ (SDK projects)
441        let bench_mobile_dir = self.project_root.join("bench-mobile");
442        if bench_mobile_dir.exists() {
443            return Ok(bench_mobile_dir);
444        }
445
446        // Try crates/{crate_name}/ (repository structure)
447        let crates_dir = self.project_root.join("crates").join(&self.crate_name);
448        if crates_dir.exists() {
449            return Ok(crates_dir);
450        }
451
452        // Also try {crate_name}/ in project root (common pattern)
453        let named_dir = self.project_root.join(&self.crate_name);
454        if named_dir.exists() {
455            return Ok(named_dir);
456        }
457
458        let root_manifest = root_cargo_toml;
459        let bench_mobile_manifest = bench_mobile_dir.join("Cargo.toml");
460        let crates_manifest = crates_dir.join("Cargo.toml");
461        let named_manifest = named_dir.join("Cargo.toml");
462        Err(BenchError::Build(format!(
463            "Benchmark crate '{}' not found.\n\n\
464             Searched locations:\n\
465             - {} (checked [package] name)\n\
466             - {}\n\
467             - {}\n\
468             - {}\n\n\
469             To fix this:\n\
470             1. Run from the crate directory (where Cargo.toml has name = \"{}\")\n\
471             2. Create a bench-mobile/ directory with your benchmark crate, or\n\
472             3. Use --crate-path to specify the benchmark crate location:\n\
473                cargo mobench build --target android --crate-path ./my-benchmarks\n\n\
474             Common issues:\n\
475             - Typo in crate name (check Cargo.toml [package] name)\n\
476             - Wrong working directory (run from project root)\n\
477             - Missing Cargo.toml in the crate directory\n\n\
478             Run 'cargo mobench init --help' to generate a new benchmark project.",
479            self.crate_name,
480            root_manifest.display(),
481            bench_mobile_manifest.display(),
482            crates_manifest.display(),
483            named_manifest.display(),
484            self.crate_name,
485        )))
486    }
487
488    /// Builds Rust libraries for Android using cargo-ndk
489    fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
490        let crate_dir = self.find_crate_dir()?;
491
492        // Check if cargo-ndk is installed
493        self.check_cargo_ndk()?;
494
495        let abis = self.resolve_android_abis(config)?;
496        let release_flag = if matches!(config.profile, BuildProfile::Release) {
497            "--release"
498        } else {
499            ""
500        };
501
502        for abi in abis {
503            if self.verbose {
504                println!("  Building for {}", abi);
505            }
506
507            let mut cmd = Command::new("cargo");
508            cmd.arg("ndk")
509                .arg("--target")
510                .arg(&abi)
511                .arg("--platform")
512                .arg("24") // minSdk
513                .arg("build");
514
515            // Add release flag if needed
516            if !release_flag.is_empty() {
517                cmd.arg(release_flag);
518            }
519
520            // Set working directory
521            cmd.current_dir(&crate_dir);
522
523            // Execute build
524            let command_hint = if release_flag.is_empty() {
525                format!("cargo ndk --target {} --platform 24 build", abi)
526            } else {
527                format!(
528                    "cargo ndk --target {} --platform 24 build {}",
529                    abi, release_flag
530                )
531            };
532            let output = cmd.output().map_err(|e| {
533                BenchError::Build(format!(
534                    "Failed to start cargo-ndk for {}.\n\n\
535                     Command: {}\n\
536                     Crate directory: {}\n\
537                     System error: {}\n\n\
538                     Tips:\n\
539                     - Install cargo-ndk: cargo install cargo-ndk\n\
540                     - Ensure cargo is on PATH",
541                    abi,
542                    command_hint,
543                    crate_dir.display(),
544                    e
545                ))
546            })?;
547
548            if !output.status.success() {
549                let stdout = String::from_utf8_lossy(&output.stdout);
550                let stderr = String::from_utf8_lossy(&output.stderr);
551                let profile = if matches!(config.profile, BuildProfile::Release) {
552                    "release"
553                } else {
554                    "debug"
555                };
556                let rust_target = android_abi_to_rust_target(&abi).unwrap_or(abi.as_str());
557                return Err(BenchError::Build(format!(
558                    "cargo-ndk build failed for {} ({} profile).\n\n\
559                     Command: {}\n\
560                     Crate directory: {}\n\
561                     Exit status: {}\n\n\
562                     Stdout:\n{}\n\n\
563                     Stderr:\n{}\n\n\
564                     Common causes:\n\
565                     - Missing Rust target: rustup target add {}\n\
566                     - NDK not found: set ANDROID_NDK_HOME\n\
567                     - Compilation error in Rust code (see output above)\n\
568                     - Incompatible native dependencies (some C libraries do not support Android)",
569                    abi,
570                    profile,
571                    command_hint,
572                    crate_dir.display(),
573                    output.status,
574                    stdout,
575                    stderr,
576                    rust_target,
577                )));
578            }
579        }
580
581        Ok(())
582    }
583
584    /// Checks if cargo-ndk is installed
585    fn check_cargo_ndk(&self) -> Result<(), BenchError> {
586        let output = Command::new("cargo").arg("ndk").arg("--version").output();
587
588        match output {
589            Ok(output) if output.status.success() => Ok(()),
590            _ => Err(BenchError::Build(
591                "cargo-ndk is not installed or not in PATH.\n\n\
592                 cargo-ndk is required to cross-compile Rust for Android.\n\n\
593                 To install:\n\
594                   cargo install cargo-ndk\n\
595                 Verify with:\n\
596                   cargo ndk --version\n\n\
597                 You also need the Android NDK. Set ANDROID_NDK_HOME or install via Android Studio.\n\
598                 See: https://github.com/nickelc/cargo-ndk"
599                    .to_string(),
600            )),
601        }
602    }
603
604    /// Generates UniFFI Kotlin bindings
605    fn generate_uniffi_bindings(&self) -> Result<(), BenchError> {
606        let crate_dir = self.find_crate_dir()?;
607        let crate_name_underscored = self.crate_name.replace("-", "_");
608
609        // Check if bindings already exist (for repository testing with pre-generated bindings)
610        let bindings_path = self
611            .output_dir
612            .join("android")
613            .join("app")
614            .join("src")
615            .join("main")
616            .join("java")
617            .join("uniffi")
618            .join(&crate_name_underscored)
619            .join(format!("{}.kt", crate_name_underscored));
620
621        if bindings_path.exists() {
622            if self.verbose {
623                println!("  Using existing Kotlin bindings at {:?}", bindings_path);
624            }
625            return Ok(());
626        }
627
628        // Build host library to feed uniffi-bindgen
629        let mut build_cmd = Command::new("cargo");
630        build_cmd.arg("build");
631        build_cmd.current_dir(&crate_dir);
632        run_command(build_cmd, "cargo build (host)")?;
633
634        let lib_path = host_lib_path(&crate_dir, &self.crate_name)?;
635        let out_dir = self
636            .output_dir
637            .join("android")
638            .join("app")
639            .join("src")
640            .join("main")
641            .join("java");
642
643        // Try cargo run first (works if crate has uniffi-bindgen binary target)
644        let cargo_run_result = Command::new("cargo")
645            .args([
646                "run",
647                "-p",
648                &self.crate_name,
649                "--bin",
650                "uniffi-bindgen",
651                "--",
652            ])
653            .arg("generate")
654            .arg("--library")
655            .arg(&lib_path)
656            .arg("--language")
657            .arg("kotlin")
658            .arg("--out-dir")
659            .arg(&out_dir)
660            .current_dir(&crate_dir)
661            .output();
662
663        let use_cargo_run = cargo_run_result
664            .as_ref()
665            .map(|o| o.status.success())
666            .unwrap_or(false);
667
668        if use_cargo_run {
669            if self.verbose {
670                println!("  Generated bindings using cargo run uniffi-bindgen");
671            }
672        } else {
673            // Fall back to global uniffi-bindgen
674            let uniffi_available = Command::new("uniffi-bindgen")
675                .arg("--version")
676                .output()
677                .map(|o| o.status.success())
678                .unwrap_or(false);
679
680            if !uniffi_available {
681                return Err(BenchError::Build(
682                    "uniffi-bindgen not found and no pre-generated bindings exist.\n\n\
683                     To fix this, either:\n\
684                     1. Add a uniffi-bindgen binary to your crate:\n\
685                        [[bin]]\n\
686                        name = \"uniffi-bindgen\"\n\
687                        path = \"src/bin/uniffi-bindgen.rs\"\n\n\
688                     2. Or install uniffi-bindgen globally:\n\
689                        cargo install uniffi-bindgen\n\n\
690                     3. Or pre-generate bindings and commit them."
691                        .to_string(),
692                ));
693            }
694
695            let mut cmd = Command::new("uniffi-bindgen");
696            cmd.arg("generate")
697                .arg("--library")
698                .arg(&lib_path)
699                .arg("--language")
700                .arg("kotlin")
701                .arg("--out-dir")
702                .arg(&out_dir);
703            run_command(cmd, "uniffi-bindgen kotlin")?;
704        }
705
706        if self.verbose {
707            println!("  Generated UniFFI Kotlin bindings at {:?}", out_dir);
708        }
709        Ok(())
710    }
711
712    /// Copies .so files to Android jniLibs directories
713    fn copy_native_libraries(
714        &self,
715        config: &BuildConfig,
716    ) -> Result<Vec<NativeLibraryArtifact>, BenchError> {
717        let crate_dir = self.find_crate_dir()?;
718        let profile_dir = match config.profile {
719            BuildProfile::Debug => "debug",
720            BuildProfile::Release => "release",
721        };
722
723        // Use cargo metadata to find the actual target directory (handles workspaces)
724        let target_dir = get_cargo_target_dir(&crate_dir)?;
725        let jni_libs_dir = self.output_dir.join("android/app/src/main/jniLibs");
726
727        // Create jniLibs directories if they don't exist
728        std::fs::create_dir_all(&jni_libs_dir).map_err(|e| {
729            BenchError::Build(format!(
730                "Failed to create jniLibs directory at {}: {}. Check output directory permissions.",
731                jni_libs_dir.display(),
732                e
733            ))
734        })?;
735
736        let mut native_libraries = Vec::new();
737
738        for android_abi in self.resolve_android_abis(config)? {
739            let rust_target = android_abi_to_rust_target(&android_abi).ok_or_else(|| {
740                BenchError::Build(format!(
741                    "Unsupported Android ABI '{android_abi}'. Supported values: arm64-v8a, armeabi-v7a, x86_64"
742                ))
743            })?;
744            let library_name = format!("lib{}.so", self.crate_name.replace("-", "_"));
745            let src = target_dir
746                .join(rust_target)
747                .join(profile_dir)
748                .join(&library_name);
749
750            let dest_dir = jni_libs_dir.join(&android_abi);
751            std::fs::create_dir_all(&dest_dir).map_err(|e| {
752                BenchError::Build(format!(
753                    "Failed to create ABI directory {} at {}: {}. Check output directory permissions.",
754                    android_abi,
755                    dest_dir.display(),
756                    e
757                ))
758            })?;
759
760            let dest = dest_dir.join(&library_name);
761
762            if src.exists() {
763                std::fs::copy(&src, &dest).map_err(|e| {
764                    BenchError::Build(format!(
765                        "Failed to copy {} library from {} to {}: {}. Ensure cargo-ndk completed successfully.",
766                        android_abi,
767                        src.display(),
768                        dest.display(),
769                        e
770                    ))
771                })?;
772
773                if self.verbose {
774                    println!("  Copied {} -> {}", src.display(), dest.display());
775                }
776
777                native_libraries.push(NativeLibraryArtifact {
778                    abi: android_abi.clone(),
779                    library_name: library_name.clone(),
780                    unstripped_path: src,
781                    packaged_path: dest,
782                });
783            } else {
784                // Always warn about missing native libraries - this will cause runtime crashes
785                eprintln!(
786                    "Warning: Native library for {} not found at {}.\n\
787                     This will cause a runtime crash when the app tries to load the library.\n\
788                     Ensure cargo-ndk build completed successfully for this ABI.",
789                    android_abi,
790                    src.display()
791                );
792            }
793        }
794
795        Ok(native_libraries)
796    }
797
798    /// Ensures local.properties exists with sdk.dir set
799    ///
800    /// Gradle requires this file to know where the Android SDK is located.
801    /// This function only generates the file if ANDROID_HOME or ANDROID_SDK_ROOT
802    /// environment variables are set. We intentionally avoid probing filesystem
803    /// paths to prevent writing machine-specific paths that would break builds
804    /// on other machines.
805    ///
806    /// If neither environment variable is set, we skip generating the file and
807    /// let Android Studio or Gradle handle SDK detection.
808    fn ensure_local_properties(&self, android_dir: &Path) -> Result<(), BenchError> {
809        let local_props = android_dir.join("local.properties");
810
811        // If local.properties already exists, leave it alone
812        if local_props.exists() {
813            return Ok(());
814        }
815
816        // Only generate local.properties if an environment variable is set.
817        // This avoids writing machine-specific paths that break on other machines.
818        let sdk_dir = self.find_android_sdk_from_env();
819
820        match sdk_dir {
821            Some(path) => {
822                // Write local.properties with the SDK path from env var
823                let content = format!("sdk.dir={}\n", path.display());
824                fs::write(&local_props, content).map_err(|e| {
825                    BenchError::Build(format!(
826                        "Failed to write local.properties at {:?}: {}. Check output directory permissions.",
827                        local_props, e
828                    ))
829                })?;
830
831                if self.verbose {
832                    println!(
833                        "  Generated local.properties with sdk.dir={}",
834                        path.display()
835                    );
836                }
837            }
838            None => {
839                // No env var set - skip generating local.properties
840                // Gradle/Android Studio will auto-detect the SDK or prompt the user
841                if self.verbose {
842                    println!(
843                        "  Skipping local.properties generation (ANDROID_HOME/ANDROID_SDK_ROOT not set)"
844                    );
845                    println!(
846                        "  Gradle will auto-detect SDK or you can create local.properties manually"
847                    );
848                }
849            }
850        }
851
852        Ok(())
853    }
854
855    /// Finds the Android SDK installation path from environment variables only
856    ///
857    /// Returns Some(path) if ANDROID_HOME or ANDROID_SDK_ROOT is set and the path exists.
858    /// Returns None if neither is set or the paths don't exist.
859    ///
860    /// We intentionally avoid probing common filesystem locations to prevent
861    /// writing machine-specific paths that would break builds on other machines.
862    fn find_android_sdk_from_env(&self) -> Option<PathBuf> {
863        // Check ANDROID_HOME first (standard)
864        if let Ok(path) = env::var("ANDROID_HOME") {
865            let sdk_path = PathBuf::from(&path);
866            if sdk_path.exists() {
867                return Some(sdk_path);
868            }
869        }
870
871        // Check ANDROID_SDK_ROOT (alternative)
872        if let Ok(path) = env::var("ANDROID_SDK_ROOT") {
873            let sdk_path = PathBuf::from(&path);
874            if sdk_path.exists() {
875                return Some(sdk_path);
876            }
877        }
878
879        None
880    }
881
882    /// Ensures the Gradle wrapper (gradlew) exists in the Android project
883    ///
884    /// If gradlew doesn't exist, this runs `gradle wrapper --gradle-version 8.5`
885    /// to generate the wrapper files.
886    fn ensure_gradle_wrapper(&self, android_dir: &Path) -> Result<(), BenchError> {
887        let gradlew = android_dir.join("gradlew");
888
889        // If gradlew already exists, we're good
890        if gradlew.exists() {
891            return Ok(());
892        }
893
894        println!("Gradle wrapper not found, generating...");
895
896        // Check if gradle is available
897        let gradle_available = Command::new("gradle")
898            .arg("--version")
899            .output()
900            .map(|o| o.status.success())
901            .unwrap_or(false);
902
903        if !gradle_available {
904            return Err(BenchError::Build(
905                "Gradle wrapper (gradlew) not found and 'gradle' command is not available.\n\n\
906                 The Android project requires Gradle to build. You have two options:\n\n\
907                 1. Install Gradle globally and run the build again (it will auto-generate the wrapper):\n\
908                    - macOS: brew install gradle\n\
909                    - Linux: sudo apt install gradle\n\
910                    - Or download from https://gradle.org/install/\n\n\
911                 2. Or generate the wrapper manually in the Android project directory:\n\
912                    cd target/mobench/android && gradle wrapper --gradle-version 8.5"
913                    .to_string(),
914            ));
915        }
916
917        // Run gradle wrapper to generate gradlew
918        let mut cmd = Command::new("gradle");
919        cmd.arg("wrapper")
920            .arg("--gradle-version")
921            .arg("8.5")
922            .current_dir(android_dir);
923
924        let output = cmd.output().map_err(|e| {
925            BenchError::Build(format!(
926                "Failed to run 'gradle wrapper' command: {}\n\n\
927                 Ensure Gradle is installed and on your PATH.",
928                e
929            ))
930        })?;
931
932        if !output.status.success() {
933            let stderr = String::from_utf8_lossy(&output.stderr);
934            return Err(BenchError::Build(format!(
935                "Failed to generate Gradle wrapper.\n\n\
936                 Command: gradle wrapper --gradle-version 8.5\n\
937                 Working directory: {}\n\
938                 Exit status: {}\n\
939                 Stderr: {}\n\n\
940                 Try running this command manually in the Android project directory.",
941                android_dir.display(),
942                output.status,
943                stderr
944            )));
945        }
946
947        // Make gradlew executable on Unix systems
948        #[cfg(unix)]
949        {
950            use std::os::unix::fs::PermissionsExt;
951            if let Ok(metadata) = fs::metadata(&gradlew) {
952                let mut perms = metadata.permissions();
953                perms.set_mode(0o755);
954                let _ = fs::set_permissions(&gradlew, perms);
955            }
956        }
957
958        if self.verbose {
959            println!("  Generated Gradle wrapper at {:?}", gradlew);
960        }
961
962        Ok(())
963    }
964
965    /// Builds the Android APK using Gradle
966    fn build_apk(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
967        let android_dir = self.output_dir.join("android");
968
969        if !android_dir.exists() {
970            return Err(BenchError::Build(format!(
971                "Android project not found at {}.\n\n\
972                 Expected a Gradle project under the output directory.\n\
973                 Run `cargo mobench init --target android` or `cargo mobench build --target android` from the project root to generate it.",
974                android_dir.display()
975            )));
976        }
977
978        // Ensure local.properties exists with sdk.dir
979        self.ensure_local_properties(&android_dir)?;
980
981        // Determine Gradle task
982        let gradle_task = match config.profile {
983            BuildProfile::Debug => "assembleDebug",
984            BuildProfile::Release => "assembleRelease",
985        };
986
987        // Run Gradle build
988        let mut cmd = Command::new("./gradlew");
989        cmd.arg(gradle_task).current_dir(&android_dir);
990
991        if self.verbose {
992            cmd.arg("--info");
993        }
994
995        let output = cmd.output().map_err(|e| {
996            BenchError::Build(format!(
997                "Failed to run Gradle wrapper.\n\n\
998                 Command: ./gradlew {}\n\
999                 Working directory: {}\n\
1000                 Error: {}\n\n\
1001                 Tips:\n\
1002                 - Ensure ./gradlew is executable (chmod +x ./gradlew)\n\
1003                 - Run ./gradlew --version in that directory to verify the wrapper",
1004                gradle_task,
1005                android_dir.display(),
1006                e
1007            ))
1008        })?;
1009
1010        if !output.status.success() {
1011            let stdout = String::from_utf8_lossy(&output.stdout);
1012            let stderr = String::from_utf8_lossy(&output.stderr);
1013            return Err(BenchError::Build(format!(
1014                "Gradle build failed.\n\n\
1015                 Command: ./gradlew {}\n\
1016                 Working directory: {}\n\
1017                 Exit status: {}\n\n\
1018                 Stdout:\n{}\n\n\
1019                 Stderr:\n{}\n\n\
1020                 Tips:\n\
1021                 - Re-run with verbose mode to pass --info to Gradle\n\
1022                 - Run ./gradlew {} --stacktrace for a full stack trace",
1023                gradle_task,
1024                android_dir.display(),
1025                output.status,
1026                stdout,
1027                stderr,
1028                gradle_task,
1029            )));
1030        }
1031
1032        // Determine APK path
1033        let profile_name = match config.profile {
1034            BuildProfile::Debug => "debug",
1035            BuildProfile::Release => "release",
1036        };
1037
1038        let apk_dir = android_dir.join("app/build/outputs/apk").join(profile_name);
1039
1040        // Try to find APK - check multiple possible filenames
1041        // Gradle produces different names depending on signing configuration:
1042        // - app-release.apk (signed)
1043        // - app-release-unsigned.apk (unsigned release)
1044        // - app-debug.apk (debug)
1045        let apk_path = self.find_apk(&apk_dir, profile_name, gradle_task)?;
1046
1047        Ok(apk_path)
1048    }
1049
1050    /// Finds the APK file in the build output directory
1051    ///
1052    /// Gradle produces different APK filenames depending on signing configuration:
1053    /// - `app-release.apk` - signed release build
1054    /// - `app-release-unsigned.apk` - unsigned release build
1055    /// - `app-debug.apk` - debug build
1056    ///
1057    /// This method also checks for `output-metadata.json` which contains the actual
1058    /// output filename when present.
1059    fn find_apk(
1060        &self,
1061        apk_dir: &Path,
1062        profile_name: &str,
1063        gradle_task: &str,
1064    ) -> Result<PathBuf, BenchError> {
1065        // First, try to read output-metadata.json for the actual APK name
1066        let metadata_path = apk_dir.join("output-metadata.json");
1067        if metadata_path.exists() {
1068            if let Ok(metadata_content) = fs::read_to_string(&metadata_path) {
1069                // Parse the JSON to find the outputFile
1070                // Format: {"elements":[{"outputFile":"app-release-unsigned.apk",...}]}
1071                if let Some(apk_name) = self.parse_output_metadata(&metadata_content) {
1072                    let apk_path = apk_dir.join(&apk_name);
1073                    if apk_path.exists() {
1074                        if self.verbose {
1075                            println!(
1076                                "  Found APK from output-metadata.json: {}",
1077                                apk_path.display()
1078                            );
1079                        }
1080                        return Ok(apk_path);
1081                    }
1082                }
1083            }
1084        }
1085
1086        // Define candidates in order of preference
1087        let candidates = if profile_name == "release" {
1088            vec![
1089                format!("app-{}.apk", profile_name),          // Signed release
1090                format!("app-{}-unsigned.apk", profile_name), // Unsigned release
1091            ]
1092        } else {
1093            vec![
1094                format!("app-{}.apk", profile_name), // Debug
1095            ]
1096        };
1097
1098        // Check each candidate
1099        for candidate in &candidates {
1100            let apk_path = apk_dir.join(candidate);
1101            if apk_path.exists() {
1102                if self.verbose {
1103                    println!("  Found APK: {}", apk_path.display());
1104                }
1105                return Ok(apk_path);
1106            }
1107        }
1108
1109        // No APK found - provide helpful error message
1110        Err(BenchError::Build(format!(
1111            "APK not found in {}.\n\n\
1112             Gradle task {} reported success but no APK was produced.\n\
1113             Searched for:\n{}\n\n\
1114             Check the build output directory and rerun ./gradlew {} if needed.",
1115            apk_dir.display(),
1116            gradle_task,
1117            candidates
1118                .iter()
1119                .map(|c| format!("  - {}", c))
1120                .collect::<Vec<_>>()
1121                .join("\n"),
1122            gradle_task
1123        )))
1124    }
1125
1126    /// Parses output-metadata.json to extract the APK filename
1127    ///
1128    /// The JSON format is:
1129    /// ```json
1130    /// {
1131    ///   "elements": [
1132    ///     {
1133    ///       "outputFile": "app-release-unsigned.apk",
1134    ///       ...
1135    ///     }
1136    ///   ]
1137    /// }
1138    /// ```
1139    fn parse_output_metadata(&self, content: &str) -> Option<String> {
1140        // Simple JSON parsing without external dependencies
1141        // Look for "outputFile":"<filename>"
1142        let pattern = "\"outputFile\"";
1143        if let Some(pos) = content.find(pattern) {
1144            let after_key = &content[pos + pattern.len()..];
1145            // Skip whitespace and colon
1146            let after_colon = after_key.trim_start().strip_prefix(':')?;
1147            let after_ws = after_colon.trim_start();
1148            // Extract the string value
1149            if after_ws.starts_with('"') {
1150                let value_start = &after_ws[1..];
1151                if let Some(end_quote) = value_start.find('"') {
1152                    let filename = &value_start[..end_quote];
1153                    if filename.ends_with(".apk") {
1154                        return Some(filename.to_string());
1155                    }
1156                }
1157            }
1158        }
1159        None
1160    }
1161
1162    /// Builds the Android test APK using Gradle
1163    fn build_test_apk(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
1164        let android_dir = self.output_dir.join("android");
1165
1166        if !android_dir.exists() {
1167            return Err(BenchError::Build(format!(
1168                "Android project not found at {}.\n\n\
1169                 Expected a Gradle project under the output directory.\n\
1170                 Run `cargo mobench init --target android` or `cargo mobench build --target android` from the project root to generate it.",
1171                android_dir.display()
1172            )));
1173        }
1174
1175        let gradle_task = match config.profile {
1176            BuildProfile::Debug => "assembleDebugAndroidTest",
1177            BuildProfile::Release => "assembleReleaseAndroidTest",
1178        };
1179
1180        let mut cmd = Command::new("./gradlew");
1181        cmd.arg(gradle_task).current_dir(&android_dir);
1182
1183        if self.verbose {
1184            cmd.arg("--info");
1185        }
1186
1187        let output = cmd.output().map_err(|e| {
1188            BenchError::Build(format!(
1189                "Failed to run Gradle wrapper.\n\n\
1190                 Command: ./gradlew {}\n\
1191                 Working directory: {}\n\
1192                 Error: {}\n\n\
1193                 Tips:\n\
1194                 - Ensure ./gradlew is executable (chmod +x ./gradlew)\n\
1195                 - Run ./gradlew --version in that directory to verify the wrapper",
1196                gradle_task,
1197                android_dir.display(),
1198                e
1199            ))
1200        })?;
1201
1202        if !output.status.success() {
1203            let stdout = String::from_utf8_lossy(&output.stdout);
1204            let stderr = String::from_utf8_lossy(&output.stderr);
1205            return Err(BenchError::Build(format!(
1206                "Gradle test APK build failed.\n\n\
1207                 Command: ./gradlew {}\n\
1208                 Working directory: {}\n\
1209                 Exit status: {}\n\n\
1210                 Stdout:\n{}\n\n\
1211                 Stderr:\n{}\n\n\
1212                 Tips:\n\
1213                 - Re-run with verbose mode to pass --info to Gradle\n\
1214                 - Run ./gradlew {} --stacktrace for a full stack trace",
1215                gradle_task,
1216                android_dir.display(),
1217                output.status,
1218                stdout,
1219                stderr,
1220                gradle_task,
1221            )));
1222        }
1223
1224        let profile_name = match config.profile {
1225            BuildProfile::Debug => "debug",
1226            BuildProfile::Release => "release",
1227        };
1228
1229        let test_apk_dir = android_dir
1230            .join("app/build/outputs/apk/androidTest")
1231            .join(profile_name);
1232
1233        // Find the test APK - use similar logic to main APK
1234        let apk_path = self.find_test_apk(&test_apk_dir, profile_name, gradle_task)?;
1235
1236        Ok(apk_path)
1237    }
1238
1239    /// Finds the test APK file in the build output directory
1240    ///
1241    /// Test APKs can have different naming patterns depending on the build:
1242    /// - `app-debug-androidTest.apk`
1243    /// - `app-release-androidTest.apk`
1244    fn find_test_apk(
1245        &self,
1246        apk_dir: &Path,
1247        profile_name: &str,
1248        gradle_task: &str,
1249    ) -> Result<PathBuf, BenchError> {
1250        // First, try to read output-metadata.json for the actual APK name
1251        let metadata_path = apk_dir.join("output-metadata.json");
1252        if metadata_path.exists() {
1253            if let Ok(metadata_content) = fs::read_to_string(&metadata_path) {
1254                if let Some(apk_name) = self.parse_output_metadata(&metadata_content) {
1255                    let apk_path = apk_dir.join(&apk_name);
1256                    if apk_path.exists() {
1257                        if self.verbose {
1258                            println!(
1259                                "  Found test APK from output-metadata.json: {}",
1260                                apk_path.display()
1261                            );
1262                        }
1263                        return Ok(apk_path);
1264                    }
1265                }
1266            }
1267        }
1268
1269        // Check standard naming pattern
1270        let apk_path = apk_dir.join(format!("app-{}-androidTest.apk", profile_name));
1271        if apk_path.exists() {
1272            if self.verbose {
1273                println!("  Found test APK: {}", apk_path.display());
1274            }
1275            return Ok(apk_path);
1276        }
1277
1278        // No test APK found
1279        Err(BenchError::Build(format!(
1280            "Android test APK not found in {}.\n\n\
1281             Gradle task {} reported success but no test APK was produced.\n\
1282             Expected: app-{}-androidTest.apk\n\n\
1283             Check app/build/outputs/apk/androidTest/{} and rerun ./gradlew {} if needed.",
1284            apk_dir.display(),
1285            gradle_task,
1286            profile_name,
1287            profile_name,
1288            gradle_task
1289        )))
1290    }
1291}
1292
1293fn android_abi_to_rust_target(abi: &str) -> Option<&'static str> {
1294    match abi {
1295        "arm64-v8a" => Some("aarch64-linux-android"),
1296        "armeabi-v7a" => Some("armv7-linux-androideabi"),
1297        "x86_64" => Some("x86_64-linux-android"),
1298        _ => None,
1299    }
1300}
1301
1302#[derive(Debug, Clone, PartialEq, Eq)]
1303pub struct AndroidStackSymbolization {
1304    pub line: String,
1305    pub resolved_frames: u64,
1306    pub unresolved_frames: u64,
1307}
1308
1309pub fn symbolize_android_native_stack_line_with_resolver<F>(
1310    line: &str,
1311    mut resolve: F,
1312) -> AndroidStackSymbolization
1313where
1314    F: FnMut(&str, u64) -> Option<String>,
1315{
1316    let (stack, sample_count) = split_folded_stack_line(line);
1317    let mut resolved_frames = 0;
1318    let mut unresolved_frames = 0;
1319    let rewritten = stack
1320        .split(';')
1321        .map(|frame| {
1322            if let Some((library_name, offset)) = parse_android_native_offset_frame(frame) {
1323                if let Some(symbol) = resolve(library_name, offset) {
1324                    resolved_frames += 1;
1325                    return symbol;
1326                }
1327                unresolved_frames += 1;
1328            }
1329            frame.to_string()
1330        })
1331        .collect::<Vec<_>>()
1332        .join(";");
1333
1334    let line = match sample_count {
1335        Some(count) => format!("{rewritten} {count}"),
1336        None => rewritten,
1337    };
1338
1339    AndroidStackSymbolization {
1340        line,
1341        resolved_frames,
1342        unresolved_frames,
1343    }
1344}
1345
1346pub fn resolve_android_native_symbol_with_addr2line(
1347    library_path: &Path,
1348    offset: u64,
1349) -> Option<String> {
1350    let tool_path = locate_android_addr2line_tool_path()?;
1351    resolve_android_native_symbol_with_tool(&tool_path, library_path, offset)
1352}
1353
1354pub fn resolve_android_native_symbol_with_tool(
1355    tool_path: &Path,
1356    library_path: &Path,
1357    offset: u64,
1358) -> Option<String> {
1359    let output = Command::new(tool_path)
1360        .args(["-Cfpe"])
1361        .arg(library_path)
1362        .arg(format!("0x{offset:x}"))
1363        .output()
1364        .ok()?;
1365    if !output.status.success() {
1366        return None;
1367    }
1368
1369    parse_android_addr2line_stdout(&String::from_utf8_lossy(&output.stdout))
1370}
1371
1372fn parse_android_addr2line_stdout(stdout: &str) -> Option<String> {
1373    stdout.lines().find_map(|line| {
1374        let symbol = line.trim();
1375        if symbol.is_empty() || symbol == "??" || symbol.starts_with("?? ") {
1376            None
1377        } else {
1378            Some(
1379                symbol
1380                    .split(" at ")
1381                    .next()
1382                    .unwrap_or(symbol)
1383                    .trim()
1384                    .to_owned(),
1385            )
1386        }
1387    })
1388}
1389
1390fn locate_android_addr2line_tool_path() -> Option<PathBuf> {
1391    let override_path = std::env::var_os("MOBENCH_ANDROID_LLVM_ADDR2LINE")
1392        .or_else(|| std::env::var_os("LLVM_ADDR2LINE"))
1393        .map(PathBuf::from);
1394    if let Some(path) = override_path {
1395        return path.exists().then_some(path);
1396    }
1397
1398    let sdk_root = std::env::var_os("ANDROID_HOME")
1399        .map(PathBuf::from)
1400        .or_else(|| std::env::var_os("ANDROID_SDK_ROOT").map(PathBuf::from))
1401        .or_else(|| {
1402            std::env::var_os("ANDROID_NDK_HOME")
1403                .map(PathBuf::from)
1404                .and_then(|ndk_home| ndk_home.parent().and_then(Path::parent).map(PathBuf::from))
1405        })?;
1406    let ndk_root = std::env::var_os("ANDROID_NDK_HOME")
1407        .map(PathBuf::from)
1408        .or_else(|| {
1409            let ndk_dir = sdk_root.join("ndk");
1410            std::fs::read_dir(&ndk_dir).ok().and_then(|entries| {
1411                entries
1412                    .filter_map(|entry| entry.ok())
1413                    .map(|entry| entry.path())
1414                    .filter(|path| path.is_dir())
1415                    .max()
1416            })
1417        })?;
1418
1419    let tool_name = if cfg!(windows) {
1420        "llvm-addr2line.exe"
1421    } else {
1422        "llvm-addr2line"
1423    };
1424    let prebuilt_root = ndk_root.join("toolchains").join("llvm").join("prebuilt");
1425    let mut candidates = Vec::new();
1426    if let Ok(entries) = std::fs::read_dir(&prebuilt_root) {
1427        for entry in entries.flatten() {
1428            let candidate = entry.path().join("bin").join(tool_name);
1429            if candidate.exists() {
1430                candidates.push(candidate);
1431            }
1432        }
1433    }
1434    candidates.sort();
1435    candidates.into_iter().next()
1436}
1437
1438fn split_folded_stack_line(line: &str) -> (&str, Option<&str>) {
1439    match line.rsplit_once(' ') {
1440        Some((stack, count))
1441            if !stack.is_empty() && count.chars().all(|ch| ch.is_ascii_digit()) =>
1442        {
1443            (stack, Some(count))
1444        }
1445        _ => (line, None),
1446    }
1447}
1448
1449fn parse_android_native_offset_frame(frame: &str) -> Option<(&str, u64)> {
1450    let marker = ".so[+";
1451    let marker_index = frame.find(marker)?;
1452    let library_end = marker_index + 3;
1453    let library_name = frame[..library_end].rsplit('/').next()?;
1454    let offset_start = marker_index + marker.len();
1455    let offset_end = frame[offset_start..].find(']')? + offset_start;
1456    let offset_raw = &frame[offset_start..offset_end];
1457    let offset = if let Some(hex) = offset_raw.strip_prefix("0x") {
1458        u64::from_str_radix(hex, 16).ok()?
1459    } else {
1460        offset_raw.parse().ok()?
1461    };
1462    Some((library_name, offset))
1463}
1464
1465#[cfg(test)]
1466mod tests {
1467    use super::*;
1468
1469    #[test]
1470    fn test_android_builder_creation() {
1471        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1472        assert!(!builder.verbose);
1473        assert_eq!(
1474            builder.output_dir,
1475            PathBuf::from("/tmp/test-project/target/mobench")
1476        );
1477    }
1478
1479    #[test]
1480    fn test_android_builder_verbose() {
1481        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile").verbose(true);
1482        assert!(builder.verbose);
1483    }
1484
1485    #[test]
1486    fn test_android_builder_custom_output_dir() {
1487        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile")
1488            .output_dir("/custom/output");
1489        assert_eq!(builder.output_dir, PathBuf::from("/custom/output"));
1490    }
1491
1492    #[test]
1493    fn test_parse_output_metadata_unsigned() {
1494        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1495        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"}"#;
1496        let result = builder.parse_output_metadata(metadata);
1497        assert_eq!(result, Some("app-release-unsigned.apk".to_string()));
1498    }
1499
1500    #[test]
1501    fn test_parse_output_metadata_signed() {
1502        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1503        let metadata = r#"{"version":3,"elements":[{"outputFile":"app-release.apk"}]}"#;
1504        let result = builder.parse_output_metadata(metadata);
1505        assert_eq!(result, Some("app-release.apk".to_string()));
1506    }
1507
1508    #[test]
1509    fn test_parse_output_metadata_no_apk() {
1510        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1511        let metadata = r#"{"version":3,"elements":[]}"#;
1512        let result = builder.parse_output_metadata(metadata);
1513        assert_eq!(result, None);
1514    }
1515
1516    #[test]
1517    fn test_parse_output_metadata_invalid_json() {
1518        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1519        let metadata = "not valid json";
1520        let result = builder.parse_output_metadata(metadata);
1521        assert_eq!(result, None);
1522    }
1523
1524    #[test]
1525    fn test_android_builder_defaults_to_arm64_only() {
1526        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1527        let config = BuildConfig {
1528            target: Target::Android,
1529            profile: BuildProfile::Debug,
1530            incremental: true,
1531            android_abis: None,
1532        };
1533
1534        let abis = builder
1535            .resolve_android_abis(&config)
1536            .expect("resolve default ABIs");
1537        assert_eq!(abis, vec!["arm64-v8a".to_string()]);
1538    }
1539
1540    #[test]
1541    fn test_android_builder_uses_explicit_abis_when_configured() {
1542        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1543        let config = BuildConfig {
1544            target: Target::Android,
1545            profile: BuildProfile::Release,
1546            incremental: true,
1547            android_abis: Some(vec!["arm64-v8a".to_string(), "x86_64".to_string()]),
1548        };
1549
1550        let abis = builder
1551            .resolve_android_abis(&config)
1552            .expect("resolve configured ABIs");
1553        assert_eq!(abis, vec!["arm64-v8a".to_string(), "x86_64".to_string()]);
1554    }
1555
1556    #[test]
1557    fn android_native_offsets_are_symbolized_into_rust_frames() {
1558        let input = "dev.world.samplefns;uniffi.sample_fns.Sample_fnsKt.runBenchmark;libsample_fns.so[+94138] 1";
1559        let output =
1560            symbolize_android_native_stack_line_with_resolver(input, |library_name, offset| {
1561                if library_name == "libsample_fns.so" && offset == 94_138 {
1562                    Some("sample_fns::fibonacci".into())
1563                } else {
1564                    None
1565                }
1566            });
1567
1568        assert!(
1569            output.line.contains("sample_fns::fibonacci"),
1570            "expected unresolved native offsets to be rewritten into Rust symbols, got: {}",
1571            output.line
1572        );
1573        assert_eq!(output.resolved_frames, 1);
1574        assert_eq!(output.unresolved_frames, 0);
1575    }
1576
1577    #[test]
1578    fn resolve_android_native_symbol_with_tool_invokes_addr2line() {
1579        let temp_dir = std::env::temp_dir().join(format!(
1580            "mobench-addr2line-{}-{}",
1581            std::process::id(),
1582            std::time::SystemTime::now()
1583                .duration_since(std::time::UNIX_EPOCH)
1584                .expect("system time")
1585                .as_nanos()
1586        ));
1587        std::fs::create_dir_all(&temp_dir).expect("create temp dir");
1588        let tool_path = temp_dir.join("llvm-addr2line.sh");
1589        let args_path = temp_dir.join("args.txt");
1590        let script = format!(
1591            "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\nprintf '%s\\n' 'sample_fns::fibonacci at /tmp/src/lib.rs:131'\n",
1592            args_path.display()
1593        );
1594        std::fs::write(&tool_path, script).expect("write shim");
1595
1596        #[cfg(unix)]
1597        {
1598            use std::os::unix::fs::PermissionsExt;
1599            let mut perms = std::fs::metadata(&tool_path)
1600                .expect("metadata")
1601                .permissions();
1602            perms.set_mode(0o755);
1603            std::fs::set_permissions(&tool_path, perms).expect("chmod");
1604        }
1605
1606        let symbol = resolve_android_native_symbol_with_tool(
1607            &tool_path,
1608            Path::new("/cargo/target/aarch64-linux-android/release/libsample_fns.so"),
1609            94_138,
1610        );
1611
1612        assert_eq!(symbol.as_deref(), Some("sample_fns::fibonacci"));
1613
1614        let args = std::fs::read_to_string(&args_path).expect("read args");
1615        let expected_offset = format!("0x{:x}", 94_138);
1616        assert!(
1617            args.lines().any(|line| line == "-Cfpe"),
1618            "expected llvm-addr2line to be called with -Cfpe, got:\n{args}"
1619        );
1620        assert!(
1621            args.lines().any(|line| {
1622                line == "/cargo/target/aarch64-linux-android/release/libsample_fns.so"
1623            }),
1624            "expected llvm-addr2line to use the unstripped library path, got:\n{args}"
1625        );
1626        assert!(
1627            args.lines().any(|line| line == expected_offset),
1628            "expected llvm-addr2line to receive the resolved offset, got:\n{args}"
1629        );
1630    }
1631
1632    #[test]
1633    fn android_native_offsets_preserve_unresolved_frames() {
1634        let input = "dev.world.samplefns;libsample_fns.so[+94138];libother.so[+17] 1";
1635        let output =
1636            symbolize_android_native_stack_line_with_resolver(input, |library_name, offset| {
1637                if library_name == "libsample_fns.so" && offset == 94_138 {
1638                    Some("sample_fns::fibonacci".into())
1639                } else {
1640                    None
1641                }
1642            });
1643
1644        assert!(output.line.contains("sample_fns::fibonacci"));
1645        assert!(output.line.contains("libother.so[+17]"));
1646        assert_eq!(output.resolved_frames, 1);
1647        assert_eq!(output.unresolved_frames, 1);
1648    }
1649
1650    #[test]
1651    fn test_find_crate_dir_current_directory_is_crate() {
1652        // Test case 1: Current directory IS the crate with matching package name
1653        let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-current");
1654        let _ = std::fs::remove_dir_all(&temp_dir);
1655        std::fs::create_dir_all(&temp_dir).unwrap();
1656
1657        // Create Cargo.toml with matching package name
1658        std::fs::write(
1659            temp_dir.join("Cargo.toml"),
1660            r#"[package]
1661name = "bench-mobile"
1662version = "0.1.0"
1663"#,
1664        )
1665        .unwrap();
1666
1667        let builder = AndroidBuilder::new(&temp_dir, "bench-mobile");
1668        let result = builder.find_crate_dir();
1669        assert!(result.is_ok(), "Should find crate in current directory");
1670        assert_eq!(result.unwrap(), temp_dir);
1671
1672        std::fs::remove_dir_all(&temp_dir).unwrap();
1673    }
1674
1675    #[test]
1676    fn test_find_crate_dir_nested_bench_mobile() {
1677        // Test case 2: Crate is in bench-mobile/ subdirectory
1678        let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-nested");
1679        let _ = std::fs::remove_dir_all(&temp_dir);
1680        std::fs::create_dir_all(temp_dir.join("bench-mobile")).unwrap();
1681
1682        // Create parent Cargo.toml (workspace or different crate)
1683        std::fs::write(
1684            temp_dir.join("Cargo.toml"),
1685            r#"[workspace]
1686members = ["bench-mobile"]
1687"#,
1688        )
1689        .unwrap();
1690
1691        // Create bench-mobile/Cargo.toml
1692        std::fs::write(
1693            temp_dir.join("bench-mobile/Cargo.toml"),
1694            r#"[package]
1695name = "bench-mobile"
1696version = "0.1.0"
1697"#,
1698        )
1699        .unwrap();
1700
1701        let builder = AndroidBuilder::new(&temp_dir, "bench-mobile");
1702        let result = builder.find_crate_dir();
1703        assert!(
1704            result.is_ok(),
1705            "Should find crate in bench-mobile/ directory"
1706        );
1707        assert_eq!(result.unwrap(), temp_dir.join("bench-mobile"));
1708
1709        std::fs::remove_dir_all(&temp_dir).unwrap();
1710    }
1711
1712    #[test]
1713    fn test_find_crate_dir_crates_subdir() {
1714        // Test case 3: Crate is in crates/{name}/ subdirectory
1715        let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-crates");
1716        let _ = std::fs::remove_dir_all(&temp_dir);
1717        std::fs::create_dir_all(temp_dir.join("crates/my-bench")).unwrap();
1718
1719        // Create workspace Cargo.toml
1720        std::fs::write(
1721            temp_dir.join("Cargo.toml"),
1722            r#"[workspace]
1723members = ["crates/*"]
1724"#,
1725        )
1726        .unwrap();
1727
1728        // Create crates/my-bench/Cargo.toml
1729        std::fs::write(
1730            temp_dir.join("crates/my-bench/Cargo.toml"),
1731            r#"[package]
1732name = "my-bench"
1733version = "0.1.0"
1734"#,
1735        )
1736        .unwrap();
1737
1738        let builder = AndroidBuilder::new(&temp_dir, "my-bench");
1739        let result = builder.find_crate_dir();
1740        assert!(result.is_ok(), "Should find crate in crates/ directory");
1741        assert_eq!(result.unwrap(), temp_dir.join("crates/my-bench"));
1742
1743        std::fs::remove_dir_all(&temp_dir).unwrap();
1744    }
1745
1746    #[test]
1747    fn test_find_crate_dir_not_found() {
1748        // Test case 4: Crate doesn't exist anywhere
1749        let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-notfound");
1750        let _ = std::fs::remove_dir_all(&temp_dir);
1751        std::fs::create_dir_all(&temp_dir).unwrap();
1752
1753        // Create Cargo.toml with DIFFERENT package name
1754        std::fs::write(
1755            temp_dir.join("Cargo.toml"),
1756            r#"[package]
1757name = "some-other-crate"
1758version = "0.1.0"
1759"#,
1760        )
1761        .unwrap();
1762
1763        let builder = AndroidBuilder::new(&temp_dir, "nonexistent-crate");
1764        let result = builder.find_crate_dir();
1765        assert!(result.is_err(), "Should fail to find nonexistent crate");
1766        let err_msg = result.unwrap_err().to_string();
1767        assert!(err_msg.contains("Benchmark crate 'nonexistent-crate' not found"));
1768        assert!(err_msg.contains("Searched locations"));
1769
1770        std::fs::remove_dir_all(&temp_dir).unwrap();
1771    }
1772
1773    #[test]
1774    fn test_find_crate_dir_explicit_crate_path() {
1775        // Test case 5: Explicit crate_dir overrides auto-detection
1776        let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-explicit");
1777        let _ = std::fs::remove_dir_all(&temp_dir);
1778        std::fs::create_dir_all(temp_dir.join("custom-location")).unwrap();
1779
1780        let builder =
1781            AndroidBuilder::new(&temp_dir, "any-name").crate_dir(temp_dir.join("custom-location"));
1782        let result = builder.find_crate_dir();
1783        assert!(result.is_ok(), "Should use explicit crate_dir");
1784        assert_eq!(result.unwrap(), temp_dir.join("custom-location"));
1785
1786        std::fs::remove_dir_all(&temp_dir).unwrap();
1787    }
1788}