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            && !test_path.exists()
329        {
330            missing.push(format!("Test APK: {}", test_path.display()));
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            && let Some(pkg_name) = super::common::read_package_name(&root_cargo_toml)
434            && pkg_name == self.crate_name
435        {
436            return Ok(self.project_root.clone());
437        }
438
439        // Try bench-mobile/ (SDK projects)
440        let bench_mobile_dir = self.project_root.join("bench-mobile");
441        if bench_mobile_dir.exists() {
442            return Ok(bench_mobile_dir);
443        }
444
445        // Try crates/{crate_name}/ (repository structure)
446        let crates_dir = self.project_root.join("crates").join(&self.crate_name);
447        if crates_dir.exists() {
448            return Ok(crates_dir);
449        }
450
451        // Also try {crate_name}/ in project root (common pattern)
452        let named_dir = self.project_root.join(&self.crate_name);
453        if named_dir.exists() {
454            return Ok(named_dir);
455        }
456
457        let root_manifest = root_cargo_toml;
458        let bench_mobile_manifest = bench_mobile_dir.join("Cargo.toml");
459        let crates_manifest = crates_dir.join("Cargo.toml");
460        let named_manifest = named_dir.join("Cargo.toml");
461        Err(BenchError::Build(format!(
462            "Benchmark crate '{}' not found.\n\n\
463             Searched locations:\n\
464             - {} (checked [package] name)\n\
465             - {}\n\
466             - {}\n\
467             - {}\n\n\
468             To fix this:\n\
469             1. Run from the crate directory (where Cargo.toml has name = \"{}\")\n\
470             2. Create a bench-mobile/ directory with your benchmark crate, or\n\
471             3. Use --crate-path to specify the benchmark crate location:\n\
472                cargo mobench build --target android --crate-path ./my-benchmarks\n\n\
473             Common issues:\n\
474             - Typo in crate name (check Cargo.toml [package] name)\n\
475             - Wrong working directory (run from project root)\n\
476             - Missing Cargo.toml in the crate directory\n\n\
477             Run 'cargo mobench init --help' to generate a new benchmark project.",
478            self.crate_name,
479            root_manifest.display(),
480            bench_mobile_manifest.display(),
481            crates_manifest.display(),
482            named_manifest.display(),
483            self.crate_name,
484        )))
485    }
486
487    /// Builds Rust libraries for Android using cargo-ndk
488    fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
489        let crate_dir = self.find_crate_dir()?;
490
491        // Check if cargo-ndk is installed
492        self.check_cargo_ndk()?;
493
494        let abis = self.resolve_android_abis(config)?;
495        let release_flag = if matches!(config.profile, BuildProfile::Release) {
496            "--release"
497        } else {
498            ""
499        };
500
501        for abi in abis {
502            if self.verbose {
503                println!("  Building for {}", abi);
504            }
505
506            let mut cmd = Command::new("cargo");
507            cmd.arg("ndk")
508                .arg("--target")
509                .arg(&abi)
510                .arg("--platform")
511                .arg("24") // minSdk
512                .arg("build");
513
514            // Add release flag if needed
515            if !release_flag.is_empty() {
516                cmd.arg(release_flag);
517            }
518
519            // Set working directory
520            cmd.current_dir(&crate_dir);
521
522            // Execute build
523            let command_hint = if release_flag.is_empty() {
524                format!("cargo ndk --target {} --platform 24 build", abi)
525            } else {
526                format!(
527                    "cargo ndk --target {} --platform 24 build {}",
528                    abi, release_flag
529                )
530            };
531            let output = cmd.output().map_err(|e| {
532                BenchError::Build(format!(
533                    "Failed to start cargo-ndk for {}.\n\n\
534                     Command: {}\n\
535                     Crate directory: {}\n\
536                     System error: {}\n\n\
537                     Tips:\n\
538                     - Install cargo-ndk: cargo install cargo-ndk\n\
539                     - Ensure cargo is on PATH",
540                    abi,
541                    command_hint,
542                    crate_dir.display(),
543                    e
544                ))
545            })?;
546
547            if !output.status.success() {
548                let stdout = String::from_utf8_lossy(&output.stdout);
549                let stderr = String::from_utf8_lossy(&output.stderr);
550                let profile = if matches!(config.profile, BuildProfile::Release) {
551                    "release"
552                } else {
553                    "debug"
554                };
555                let rust_target = android_abi_to_rust_target(&abi).unwrap_or(abi.as_str());
556                return Err(BenchError::Build(format!(
557                    "cargo-ndk build failed for {} ({} profile).\n\n\
558                     Command: {}\n\
559                     Crate directory: {}\n\
560                     Exit status: {}\n\n\
561                     Stdout:\n{}\n\n\
562                     Stderr:\n{}\n\n\
563                     Common causes:\n\
564                     - Missing Rust target: rustup target add {}\n\
565                     - NDK not found: set ANDROID_NDK_HOME\n\
566                     - Compilation error in Rust code (see output above)\n\
567                     - Incompatible native dependencies (some C libraries do not support Android)",
568                    abi,
569                    profile,
570                    command_hint,
571                    crate_dir.display(),
572                    output.status,
573                    stdout,
574                    stderr,
575                    rust_target,
576                )));
577            }
578        }
579
580        Ok(())
581    }
582
583    /// Checks if cargo-ndk is installed
584    fn check_cargo_ndk(&self) -> Result<(), BenchError> {
585        let output = Command::new("cargo").arg("ndk").arg("--version").output();
586
587        match output {
588            Ok(output) if output.status.success() => Ok(()),
589            _ => Err(BenchError::Build(
590                "cargo-ndk is not installed or not in PATH.\n\n\
591                 cargo-ndk is required to cross-compile Rust for Android.\n\n\
592                 To install:\n\
593                   cargo install cargo-ndk\n\
594                 Verify with:\n\
595                   cargo ndk --version\n\n\
596                 You also need the Android NDK. Set ANDROID_NDK_HOME or install via Android Studio.\n\
597                 See: https://github.com/nickelc/cargo-ndk"
598                    .to_string(),
599            )),
600        }
601    }
602
603    /// Generates UniFFI Kotlin bindings
604    fn generate_uniffi_bindings(&self) -> Result<(), BenchError> {
605        let crate_dir = self.find_crate_dir()?;
606        let crate_name_underscored = self.crate_name.replace("-", "_");
607
608        // Check if bindings already exist (for repository testing with pre-generated bindings)
609        let bindings_path = self
610            .output_dir
611            .join("android")
612            .join("app")
613            .join("src")
614            .join("main")
615            .join("java")
616            .join("uniffi")
617            .join(&crate_name_underscored)
618            .join(format!("{}.kt", crate_name_underscored));
619
620        if bindings_path.exists() {
621            if self.verbose {
622                println!("  Using existing Kotlin bindings at {:?}", bindings_path);
623            }
624            return Ok(());
625        }
626
627        // Build host library to feed uniffi-bindgen
628        let mut build_cmd = Command::new("cargo");
629        build_cmd.arg("build");
630        build_cmd.current_dir(&crate_dir);
631        run_command(build_cmd, "cargo build (host)")?;
632
633        let lib_path = host_lib_path(&crate_dir, &self.crate_name)?;
634        let out_dir = self
635            .output_dir
636            .join("android")
637            .join("app")
638            .join("src")
639            .join("main")
640            .join("java");
641
642        // Try cargo run first (works if crate has uniffi-bindgen binary target)
643        let cargo_run_result = Command::new("cargo")
644            .args([
645                "run",
646                "-p",
647                &self.crate_name,
648                "--bin",
649                "uniffi-bindgen",
650                "--",
651            ])
652            .arg("generate")
653            .arg("--library")
654            .arg(&lib_path)
655            .arg("--language")
656            .arg("kotlin")
657            .arg("--out-dir")
658            .arg(&out_dir)
659            .current_dir(&crate_dir)
660            .output();
661
662        let use_cargo_run = cargo_run_result
663            .as_ref()
664            .map(|o| o.status.success())
665            .unwrap_or(false);
666
667        if use_cargo_run {
668            if self.verbose {
669                println!("  Generated bindings using cargo run uniffi-bindgen");
670            }
671        } else {
672            // Fall back to global uniffi-bindgen
673            let uniffi_available = Command::new("uniffi-bindgen")
674                .arg("--version")
675                .output()
676                .map(|o| o.status.success())
677                .unwrap_or(false);
678
679            if !uniffi_available {
680                return Err(BenchError::Build(
681                    "uniffi-bindgen not found and no pre-generated bindings exist.\n\n\
682                     To fix this, either:\n\
683                     1. Add a uniffi-bindgen binary to your crate:\n\
684                        [[bin]]\n\
685                        name = \"uniffi-bindgen\"\n\
686                        path = \"src/bin/uniffi-bindgen.rs\"\n\n\
687                     2. Or install uniffi-bindgen globally:\n\
688                        cargo install uniffi-bindgen\n\n\
689                     3. Or pre-generate bindings and commit them."
690                        .to_string(),
691                ));
692            }
693
694            let mut cmd = Command::new("uniffi-bindgen");
695            cmd.arg("generate")
696                .arg("--library")
697                .arg(&lib_path)
698                .arg("--language")
699                .arg("kotlin")
700                .arg("--out-dir")
701                .arg(&out_dir);
702            run_command(cmd, "uniffi-bindgen kotlin")?;
703        }
704
705        if self.verbose {
706            println!("  Generated UniFFI Kotlin bindings at {:?}", out_dir);
707        }
708        Ok(())
709    }
710
711    /// Copies .so files to Android jniLibs directories
712    fn copy_native_libraries(
713        &self,
714        config: &BuildConfig,
715    ) -> Result<Vec<NativeLibraryArtifact>, BenchError> {
716        let crate_dir = self.find_crate_dir()?;
717        let profile_dir = match config.profile {
718            BuildProfile::Debug => "debug",
719            BuildProfile::Release => "release",
720        };
721
722        // Use cargo metadata to find the actual target directory (handles workspaces)
723        let target_dir = get_cargo_target_dir(&crate_dir)?;
724        let jni_libs_dir = self.output_dir.join("android/app/src/main/jniLibs");
725
726        // Create jniLibs directories if they don't exist
727        std::fs::create_dir_all(&jni_libs_dir).map_err(|e| {
728            BenchError::Build(format!(
729                "Failed to create jniLibs directory at {}: {}. Check output directory permissions.",
730                jni_libs_dir.display(),
731                e
732            ))
733        })?;
734
735        let mut native_libraries = Vec::new();
736
737        for android_abi in self.resolve_android_abis(config)? {
738            let rust_target = android_abi_to_rust_target(&android_abi).ok_or_else(|| {
739                BenchError::Build(format!(
740                    "Unsupported Android ABI '{android_abi}'. Supported values: arm64-v8a, armeabi-v7a, x86_64"
741                ))
742            })?;
743            let library_name = format!("lib{}.so", self.crate_name.replace("-", "_"));
744            let src = target_dir
745                .join(rust_target)
746                .join(profile_dir)
747                .join(&library_name);
748
749            let dest_dir = jni_libs_dir.join(&android_abi);
750            std::fs::create_dir_all(&dest_dir).map_err(|e| {
751                BenchError::Build(format!(
752                    "Failed to create ABI directory {} at {}: {}. Check output directory permissions.",
753                    android_abi,
754                    dest_dir.display(),
755                    e
756                ))
757            })?;
758
759            let dest = dest_dir.join(&library_name);
760
761            if src.exists() {
762                std::fs::copy(&src, &dest).map_err(|e| {
763                    BenchError::Build(format!(
764                        "Failed to copy {} library from {} to {}: {}. Ensure cargo-ndk completed successfully.",
765                        android_abi,
766                        src.display(),
767                        dest.display(),
768                        e
769                    ))
770                })?;
771
772                if self.verbose {
773                    println!("  Copied {} -> {}", src.display(), dest.display());
774                }
775
776                native_libraries.push(NativeLibraryArtifact {
777                    abi: android_abi.clone(),
778                    library_name: library_name.clone(),
779                    unstripped_path: src,
780                    packaged_path: dest,
781                });
782            } else {
783                // Always warn about missing native libraries - this will cause runtime crashes
784                eprintln!(
785                    "Warning: Native library for {} not found at {}.\n\
786                     This will cause a runtime crash when the app tries to load the library.\n\
787                     Ensure cargo-ndk build completed successfully for this ABI.",
788                    android_abi,
789                    src.display()
790                );
791            }
792        }
793
794        Ok(native_libraries)
795    }
796
797    /// Ensures local.properties exists with sdk.dir set
798    ///
799    /// Gradle requires this file to know where the Android SDK is located.
800    /// This function only generates the file if ANDROID_HOME or ANDROID_SDK_ROOT
801    /// environment variables are set. We intentionally avoid probing filesystem
802    /// paths to prevent writing machine-specific paths that would break builds
803    /// on other machines.
804    ///
805    /// If neither environment variable is set, we skip generating the file and
806    /// let Android Studio or Gradle handle SDK detection.
807    fn ensure_local_properties(&self, android_dir: &Path) -> Result<(), BenchError> {
808        let local_props = android_dir.join("local.properties");
809
810        // If local.properties already exists, leave it alone
811        if local_props.exists() {
812            return Ok(());
813        }
814
815        // Only generate local.properties if an environment variable is set.
816        // This avoids writing machine-specific paths that break on other machines.
817        let sdk_dir = self.find_android_sdk_from_env();
818
819        match sdk_dir {
820            Some(path) => {
821                // Write local.properties with the SDK path from env var
822                let content = format!("sdk.dir={}\n", path.display());
823                fs::write(&local_props, content).map_err(|e| {
824                    BenchError::Build(format!(
825                        "Failed to write local.properties at {:?}: {}. Check output directory permissions.",
826                        local_props, e
827                    ))
828                })?;
829
830                if self.verbose {
831                    println!(
832                        "  Generated local.properties with sdk.dir={}",
833                        path.display()
834                    );
835                }
836            }
837            None => {
838                // No env var set - skip generating local.properties
839                // Gradle/Android Studio will auto-detect the SDK or prompt the user
840                if self.verbose {
841                    println!(
842                        "  Skipping local.properties generation (ANDROID_HOME/ANDROID_SDK_ROOT not set)"
843                    );
844                    println!(
845                        "  Gradle will auto-detect SDK or you can create local.properties manually"
846                    );
847                }
848            }
849        }
850
851        Ok(())
852    }
853
854    /// Finds the Android SDK installation path from environment variables only
855    ///
856    /// Returns Some(path) if ANDROID_HOME or ANDROID_SDK_ROOT is set and the path exists.
857    /// Returns None if neither is set or the paths don't exist.
858    ///
859    /// We intentionally avoid probing common filesystem locations to prevent
860    /// writing machine-specific paths that would break builds on other machines.
861    fn find_android_sdk_from_env(&self) -> Option<PathBuf> {
862        // Check ANDROID_HOME first (standard)
863        if let Ok(path) = env::var("ANDROID_HOME") {
864            let sdk_path = PathBuf::from(&path);
865            if sdk_path.exists() {
866                return Some(sdk_path);
867            }
868        }
869
870        // Check ANDROID_SDK_ROOT (alternative)
871        if let Ok(path) = env::var("ANDROID_SDK_ROOT") {
872            let sdk_path = PathBuf::from(&path);
873            if sdk_path.exists() {
874                return Some(sdk_path);
875            }
876        }
877
878        None
879    }
880
881    /// Ensures the Gradle wrapper (gradlew) exists in the Android project
882    ///
883    /// If gradlew doesn't exist, this runs `gradle wrapper --gradle-version 8.5`
884    /// to generate the wrapper files.
885    fn ensure_gradle_wrapper(&self, android_dir: &Path) -> Result<(), BenchError> {
886        let gradlew = android_dir.join("gradlew");
887
888        // If gradlew already exists, we're good
889        if gradlew.exists() {
890            return Ok(());
891        }
892
893        println!("Gradle wrapper not found, generating...");
894
895        // Check if gradle is available
896        let gradle_available = Command::new("gradle")
897            .arg("--version")
898            .output()
899            .map(|o| o.status.success())
900            .unwrap_or(false);
901
902        if !gradle_available {
903            return Err(BenchError::Build(
904                "Gradle wrapper (gradlew) not found and 'gradle' command is not available.\n\n\
905                 The Android project requires Gradle to build. You have two options:\n\n\
906                 1. Install Gradle globally and run the build again (it will auto-generate the wrapper):\n\
907                    - macOS: brew install gradle\n\
908                    - Linux: sudo apt install gradle\n\
909                    - Or download from https://gradle.org/install/\n\n\
910                 2. Or generate the wrapper manually in the Android project directory:\n\
911                    cd target/mobench/android && gradle wrapper --gradle-version 8.5"
912                    .to_string(),
913            ));
914        }
915
916        // Run gradle wrapper to generate gradlew
917        let mut cmd = Command::new("gradle");
918        cmd.arg("wrapper")
919            .arg("--gradle-version")
920            .arg("8.5")
921            .current_dir(android_dir);
922
923        let output = cmd.output().map_err(|e| {
924            BenchError::Build(format!(
925                "Failed to run 'gradle wrapper' command: {}\n\n\
926                 Ensure Gradle is installed and on your PATH.",
927                e
928            ))
929        })?;
930
931        if !output.status.success() {
932            let stderr = String::from_utf8_lossy(&output.stderr);
933            return Err(BenchError::Build(format!(
934                "Failed to generate Gradle wrapper.\n\n\
935                 Command: gradle wrapper --gradle-version 8.5\n\
936                 Working directory: {}\n\
937                 Exit status: {}\n\
938                 Stderr: {}\n\n\
939                 Try running this command manually in the Android project directory.",
940                android_dir.display(),
941                output.status,
942                stderr
943            )));
944        }
945
946        // Make gradlew executable on Unix systems
947        #[cfg(unix)]
948        {
949            use std::os::unix::fs::PermissionsExt;
950            if let Ok(metadata) = fs::metadata(&gradlew) {
951                let mut perms = metadata.permissions();
952                perms.set_mode(0o755);
953                let _ = fs::set_permissions(&gradlew, perms);
954            }
955        }
956
957        if self.verbose {
958            println!("  Generated Gradle wrapper at {:?}", gradlew);
959        }
960
961        Ok(())
962    }
963
964    /// Builds the Android APK using Gradle
965    fn build_apk(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
966        let android_dir = self.output_dir.join("android");
967
968        if !android_dir.exists() {
969            return Err(BenchError::Build(format!(
970                "Android project not found at {}.\n\n\
971                 Expected a Gradle project under the output directory.\n\
972                 Run `cargo mobench init --target android` or `cargo mobench build --target android` from the project root to generate it.",
973                android_dir.display()
974            )));
975        }
976
977        // Ensure local.properties exists with sdk.dir
978        self.ensure_local_properties(&android_dir)?;
979
980        // Determine Gradle task
981        let gradle_task = match config.profile {
982            BuildProfile::Debug => "assembleDebug",
983            BuildProfile::Release => "assembleRelease",
984        };
985
986        // Run Gradle build
987        let mut cmd = Command::new("./gradlew");
988        cmd.arg(gradle_task).current_dir(&android_dir);
989
990        if self.verbose {
991            cmd.arg("--info");
992        }
993
994        let output = cmd.output().map_err(|e| {
995            BenchError::Build(format!(
996                "Failed to run Gradle wrapper.\n\n\
997                 Command: ./gradlew {}\n\
998                 Working directory: {}\n\
999                 Error: {}\n\n\
1000                 Tips:\n\
1001                 - Ensure ./gradlew is executable (chmod +x ./gradlew)\n\
1002                 - Run ./gradlew --version in that directory to verify the wrapper",
1003                gradle_task,
1004                android_dir.display(),
1005                e
1006            ))
1007        })?;
1008
1009        if !output.status.success() {
1010            let stdout = String::from_utf8_lossy(&output.stdout);
1011            let stderr = String::from_utf8_lossy(&output.stderr);
1012            return Err(BenchError::Build(format!(
1013                "Gradle build failed.\n\n\
1014                 Command: ./gradlew {}\n\
1015                 Working directory: {}\n\
1016                 Exit status: {}\n\n\
1017                 Stdout:\n{}\n\n\
1018                 Stderr:\n{}\n\n\
1019                 Tips:\n\
1020                 - Re-run with verbose mode to pass --info to Gradle\n\
1021                 - Run ./gradlew {} --stacktrace for a full stack trace",
1022                gradle_task,
1023                android_dir.display(),
1024                output.status,
1025                stdout,
1026                stderr,
1027                gradle_task,
1028            )));
1029        }
1030
1031        // Determine APK path
1032        let profile_name = match config.profile {
1033            BuildProfile::Debug => "debug",
1034            BuildProfile::Release => "release",
1035        };
1036
1037        let apk_dir = android_dir.join("app/build/outputs/apk").join(profile_name);
1038
1039        // Try to find APK - check multiple possible filenames
1040        // Gradle produces different names depending on signing configuration:
1041        // - app-release.apk (signed)
1042        // - app-release-unsigned.apk (unsigned release)
1043        // - app-debug.apk (debug)
1044        let apk_path = self.find_apk(&apk_dir, profile_name, gradle_task)?;
1045
1046        Ok(apk_path)
1047    }
1048
1049    /// Finds the APK file in the build output directory
1050    ///
1051    /// Gradle produces different APK filenames depending on signing configuration:
1052    /// - `app-release.apk` - signed release build
1053    /// - `app-release-unsigned.apk` - unsigned release build
1054    /// - `app-debug.apk` - debug build
1055    ///
1056    /// This method also checks for `output-metadata.json` which contains the actual
1057    /// output filename when present.
1058    fn find_apk(
1059        &self,
1060        apk_dir: &Path,
1061        profile_name: &str,
1062        gradle_task: &str,
1063    ) -> Result<PathBuf, BenchError> {
1064        // First, try to read output-metadata.json for the actual APK name
1065        let metadata_path = apk_dir.join("output-metadata.json");
1066        if metadata_path.exists()
1067            && let Ok(metadata_content) = fs::read_to_string(&metadata_path)
1068        {
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        // Define candidates in order of preference
1086        let candidates = if profile_name == "release" {
1087            vec![
1088                format!("app-{}.apk", profile_name),          // Signed release
1089                format!("app-{}-unsigned.apk", profile_name), // Unsigned release
1090            ]
1091        } else {
1092            vec![
1093                format!("app-{}.apk", profile_name), // Debug
1094            ]
1095        };
1096
1097        // Check each candidate
1098        for candidate in &candidates {
1099            let apk_path = apk_dir.join(candidate);
1100            if apk_path.exists() {
1101                if self.verbose {
1102                    println!("  Found APK: {}", apk_path.display());
1103                }
1104                return Ok(apk_path);
1105            }
1106        }
1107
1108        // No APK found - provide helpful error message
1109        Err(BenchError::Build(format!(
1110            "APK not found in {}.\n\n\
1111             Gradle task {} reported success but no APK was produced.\n\
1112             Searched for:\n{}\n\n\
1113             Check the build output directory and rerun ./gradlew {} if needed.",
1114            apk_dir.display(),
1115            gradle_task,
1116            candidates
1117                .iter()
1118                .map(|c| format!("  - {}", c))
1119                .collect::<Vec<_>>()
1120                .join("\n"),
1121            gradle_task
1122        )))
1123    }
1124
1125    /// Parses output-metadata.json to extract the APK filename
1126    ///
1127    /// The JSON format is:
1128    /// ```json
1129    /// {
1130    ///   "elements": [
1131    ///     {
1132    ///       "outputFile": "app-release-unsigned.apk",
1133    ///       ...
1134    ///     }
1135    ///   ]
1136    /// }
1137    /// ```
1138    fn parse_output_metadata(&self, content: &str) -> Option<String> {
1139        // Simple JSON parsing without external dependencies
1140        // Look for "outputFile":"<filename>"
1141        let pattern = "\"outputFile\"";
1142        if let Some(pos) = content.find(pattern) {
1143            let after_key = &content[pos + pattern.len()..];
1144            // Skip whitespace and colon
1145            let after_colon = after_key.trim_start().strip_prefix(':')?;
1146            let after_ws = after_colon.trim_start();
1147            // Extract the string value
1148            if let Some(value_start) = after_ws.strip_prefix('"')
1149                && let Some(end_quote) = value_start.find('"')
1150            {
1151                let filename = &value_start[..end_quote];
1152                if filename.ends_with(".apk") {
1153                    return Some(filename.to_string());
1154                }
1155            }
1156        }
1157        None
1158    }
1159
1160    /// Builds the Android test APK using Gradle
1161    fn build_test_apk(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
1162        let android_dir = self.output_dir.join("android");
1163
1164        if !android_dir.exists() {
1165            return Err(BenchError::Build(format!(
1166                "Android project not found at {}.\n\n\
1167                 Expected a Gradle project under the output directory.\n\
1168                 Run `cargo mobench init --target android` or `cargo mobench build --target android` from the project root to generate it.",
1169                android_dir.display()
1170            )));
1171        }
1172
1173        let gradle_task = match config.profile {
1174            BuildProfile::Debug => "assembleDebugAndroidTest",
1175            BuildProfile::Release => "assembleReleaseAndroidTest",
1176        };
1177        let profile_name = match config.profile {
1178            BuildProfile::Debug => "debug",
1179            BuildProfile::Release => "release",
1180        };
1181
1182        let mut cmd = Command::new("./gradlew");
1183        cmd.arg(format!("-PmobenchTestBuildType={profile_name}"))
1184            .arg(gradle_task)
1185            .current_dir(&android_dir);
1186
1187        if self.verbose {
1188            cmd.arg("--info");
1189        }
1190
1191        let output = cmd.output().map_err(|e| {
1192            BenchError::Build(format!(
1193                "Failed to run Gradle wrapper.\n\n\
1194                 Command: ./gradlew {}\n\
1195                 Working directory: {}\n\
1196                 Error: {}\n\n\
1197                 Tips:\n\
1198                 - Ensure ./gradlew is executable (chmod +x ./gradlew)\n\
1199                 - Run ./gradlew --version in that directory to verify the wrapper",
1200                gradle_task,
1201                android_dir.display(),
1202                e
1203            ))
1204        })?;
1205
1206        if !output.status.success() {
1207            let stdout = String::from_utf8_lossy(&output.stdout);
1208            let stderr = String::from_utf8_lossy(&output.stderr);
1209            return Err(BenchError::Build(format!(
1210                "Gradle test APK build failed.\n\n\
1211                 Command: ./gradlew {}\n\
1212                 Working directory: {}\n\
1213                 Exit status: {}\n\n\
1214                 Stdout:\n{}\n\n\
1215                 Stderr:\n{}\n\n\
1216                 Tips:\n\
1217                 - Re-run with verbose mode to pass --info to Gradle\n\
1218                 - Run ./gradlew {} --stacktrace for a full stack trace",
1219                gradle_task,
1220                android_dir.display(),
1221                output.status,
1222                stdout,
1223                stderr,
1224                gradle_task,
1225            )));
1226        }
1227
1228        let test_apk_dir = android_dir
1229            .join("app/build/outputs/apk/androidTest")
1230            .join(profile_name);
1231
1232        // Find the test APK - use similar logic to main APK
1233        let apk_path = self.find_test_apk(&test_apk_dir, profile_name, gradle_task)?;
1234
1235        Ok(apk_path)
1236    }
1237
1238    /// Finds the test APK file in the build output directory
1239    ///
1240    /// Test APKs can have different naming patterns depending on the build:
1241    /// - `app-debug-androidTest.apk`
1242    /// - `app-release-androidTest.apk`
1243    fn find_test_apk(
1244        &self,
1245        apk_dir: &Path,
1246        profile_name: &str,
1247        gradle_task: &str,
1248    ) -> Result<PathBuf, BenchError> {
1249        // First, try to read output-metadata.json for the actual APK name
1250        let metadata_path = apk_dir.join("output-metadata.json");
1251        if metadata_path.exists()
1252            && let Ok(metadata_content) = fs::read_to_string(&metadata_path)
1253            && let Some(apk_name) = self.parse_output_metadata(&metadata_content)
1254        {
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        // Check standard naming pattern
1268        let apk_path = apk_dir.join(format!("app-{}-androidTest.apk", profile_name));
1269        if apk_path.exists() {
1270            if self.verbose {
1271                println!("  Found test APK: {}", apk_path.display());
1272            }
1273            return Ok(apk_path);
1274        }
1275
1276        // No test APK found
1277        Err(BenchError::Build(format!(
1278            "Android test APK not found in {}.\n\n\
1279             Gradle task {} reported success but no test APK was produced.\n\
1280             Expected: app-{}-androidTest.apk\n\n\
1281             Check app/build/outputs/apk/androidTest/{} and rerun ./gradlew {} if needed.",
1282            apk_dir.display(),
1283            gradle_task,
1284            profile_name,
1285            profile_name,
1286            gradle_task
1287        )))
1288    }
1289}
1290
1291fn android_abi_to_rust_target(abi: &str) -> Option<&'static str> {
1292    match abi {
1293        "arm64-v8a" => Some("aarch64-linux-android"),
1294        "armeabi-v7a" => Some("armv7-linux-androideabi"),
1295        "x86_64" => Some("x86_64-linux-android"),
1296        _ => None,
1297    }
1298}
1299
1300#[derive(Debug, Clone, PartialEq, Eq)]
1301pub struct AndroidStackSymbolization {
1302    pub line: String,
1303    pub resolved_frames: u64,
1304    pub unresolved_frames: u64,
1305}
1306
1307pub fn symbolize_android_native_stack_line_with_resolver<F>(
1308    line: &str,
1309    mut resolve: F,
1310) -> AndroidStackSymbolization
1311where
1312    F: FnMut(&str, u64) -> Option<String>,
1313{
1314    let (stack, sample_count) = split_folded_stack_line(line);
1315    let mut resolved_frames = 0;
1316    let mut unresolved_frames = 0;
1317    let rewritten = stack
1318        .split(';')
1319        .map(|frame| {
1320            if let Some((library_name, offset)) = parse_android_native_offset_frame(frame) {
1321                if let Some(symbol) = resolve(library_name, offset) {
1322                    resolved_frames += 1;
1323                    return symbol;
1324                }
1325                unresolved_frames += 1;
1326            }
1327            frame.to_string()
1328        })
1329        .collect::<Vec<_>>()
1330        .join(";");
1331
1332    let line = match sample_count {
1333        Some(count) => format!("{rewritten} {count}"),
1334        None => rewritten,
1335    };
1336
1337    AndroidStackSymbolization {
1338        line,
1339        resolved_frames,
1340        unresolved_frames,
1341    }
1342}
1343
1344pub fn resolve_android_native_symbol_with_addr2line(
1345    library_path: &Path,
1346    offset: u64,
1347) -> Option<String> {
1348    let tool_path = locate_android_addr2line_tool_path()?;
1349    resolve_android_native_symbol_with_tool(&tool_path, library_path, offset)
1350}
1351
1352pub fn resolve_android_native_symbol_with_tool(
1353    tool_path: &Path,
1354    library_path: &Path,
1355    offset: u64,
1356) -> Option<String> {
1357    let output = Command::new(tool_path)
1358        .args(["-Cfpe"])
1359        .arg(library_path)
1360        .arg(format!("0x{offset:x}"))
1361        .output()
1362        .ok()?;
1363    if !output.status.success() {
1364        return None;
1365    }
1366
1367    parse_android_addr2line_stdout(&String::from_utf8_lossy(&output.stdout))
1368}
1369
1370fn parse_android_addr2line_stdout(stdout: &str) -> Option<String> {
1371    stdout.lines().find_map(|line| {
1372        let symbol = line.trim();
1373        if symbol.is_empty() || symbol == "??" || symbol.starts_with("?? ") {
1374            None
1375        } else {
1376            Some(
1377                symbol
1378                    .split(" at ")
1379                    .next()
1380                    .unwrap_or(symbol)
1381                    .trim()
1382                    .to_owned(),
1383            )
1384        }
1385    })
1386}
1387
1388fn locate_android_addr2line_tool_path() -> Option<PathBuf> {
1389    let override_path = std::env::var_os("MOBENCH_ANDROID_LLVM_ADDR2LINE")
1390        .or_else(|| std::env::var_os("LLVM_ADDR2LINE"))
1391        .map(PathBuf::from);
1392    if let Some(path) = override_path {
1393        return path.exists().then_some(path);
1394    }
1395
1396    let sdk_root = std::env::var_os("ANDROID_HOME")
1397        .map(PathBuf::from)
1398        .or_else(|| std::env::var_os("ANDROID_SDK_ROOT").map(PathBuf::from))
1399        .or_else(|| {
1400            std::env::var_os("ANDROID_NDK_HOME")
1401                .map(PathBuf::from)
1402                .and_then(|ndk_home| ndk_home.parent().and_then(Path::parent).map(PathBuf::from))
1403        })?;
1404    let ndk_root = std::env::var_os("ANDROID_NDK_HOME")
1405        .map(PathBuf::from)
1406        .or_else(|| {
1407            let ndk_dir = sdk_root.join("ndk");
1408            std::fs::read_dir(&ndk_dir).ok().and_then(|entries| {
1409                entries
1410                    .filter_map(|entry| entry.ok())
1411                    .map(|entry| entry.path())
1412                    .filter(|path| path.is_dir())
1413                    .max()
1414            })
1415        })?;
1416
1417    let tool_name = if cfg!(windows) {
1418        "llvm-addr2line.exe"
1419    } else {
1420        "llvm-addr2line"
1421    };
1422    let prebuilt_root = ndk_root.join("toolchains").join("llvm").join("prebuilt");
1423    let mut candidates = Vec::new();
1424    if let Ok(entries) = std::fs::read_dir(&prebuilt_root) {
1425        for entry in entries.flatten() {
1426            let candidate = entry.path().join("bin").join(tool_name);
1427            if candidate.exists() {
1428                candidates.push(candidate);
1429            }
1430        }
1431    }
1432    candidates.sort();
1433    candidates.into_iter().next()
1434}
1435
1436fn split_folded_stack_line(line: &str) -> (&str, Option<&str>) {
1437    match line.rsplit_once(' ') {
1438        Some((stack, count))
1439            if !stack.is_empty() && count.chars().all(|ch| ch.is_ascii_digit()) =>
1440        {
1441            (stack, Some(count))
1442        }
1443        _ => (line, None),
1444    }
1445}
1446
1447fn parse_android_native_offset_frame(frame: &str) -> Option<(&str, u64)> {
1448    let marker = ".so[+";
1449    let marker_index = frame.find(marker)?;
1450    let library_end = marker_index + 3;
1451    let library_name = frame[..library_end].rsplit('/').next()?;
1452    let offset_start = marker_index + marker.len();
1453    let offset_end = frame[offset_start..].find(']')? + offset_start;
1454    let offset_raw = &frame[offset_start..offset_end];
1455    let offset = if let Some(hex) = offset_raw.strip_prefix("0x") {
1456        u64::from_str_radix(hex, 16).ok()?
1457    } else {
1458        offset_raw.parse().ok()?
1459    };
1460    Some((library_name, offset))
1461}
1462
1463#[cfg(test)]
1464mod tests {
1465    use super::*;
1466
1467    #[test]
1468    fn test_android_builder_creation() {
1469        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1470        assert!(!builder.verbose);
1471        assert_eq!(
1472            builder.output_dir,
1473            PathBuf::from("/tmp/test-project/target/mobench")
1474        );
1475    }
1476
1477    #[test]
1478    fn test_android_builder_verbose() {
1479        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile").verbose(true);
1480        assert!(builder.verbose);
1481    }
1482
1483    #[test]
1484    fn test_android_builder_custom_output_dir() {
1485        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile")
1486            .output_dir("/custom/output");
1487        assert_eq!(builder.output_dir, PathBuf::from("/custom/output"));
1488    }
1489
1490    #[test]
1491    fn test_parse_output_metadata_unsigned() {
1492        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1493        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"}"#;
1494        let result = builder.parse_output_metadata(metadata);
1495        assert_eq!(result, Some("app-release-unsigned.apk".to_string()));
1496    }
1497
1498    #[test]
1499    fn test_parse_output_metadata_signed() {
1500        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1501        let metadata = r#"{"version":3,"elements":[{"outputFile":"app-release.apk"}]}"#;
1502        let result = builder.parse_output_metadata(metadata);
1503        assert_eq!(result, Some("app-release.apk".to_string()));
1504    }
1505
1506    #[test]
1507    fn test_parse_output_metadata_no_apk() {
1508        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1509        let metadata = r#"{"version":3,"elements":[]}"#;
1510        let result = builder.parse_output_metadata(metadata);
1511        assert_eq!(result, None);
1512    }
1513
1514    #[test]
1515    fn test_parse_output_metadata_invalid_json() {
1516        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1517        let metadata = "not valid json";
1518        let result = builder.parse_output_metadata(metadata);
1519        assert_eq!(result, None);
1520    }
1521
1522    #[test]
1523    fn test_android_builder_defaults_to_arm64_only() {
1524        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1525        let config = BuildConfig {
1526            target: Target::Android,
1527            profile: BuildProfile::Debug,
1528            incremental: true,
1529            android_abis: None,
1530        };
1531
1532        let abis = builder
1533            .resolve_android_abis(&config)
1534            .expect("resolve default ABIs");
1535        assert_eq!(abis, vec!["arm64-v8a".to_string()]);
1536    }
1537
1538    #[test]
1539    fn test_android_builder_uses_explicit_abis_when_configured() {
1540        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1541        let config = BuildConfig {
1542            target: Target::Android,
1543            profile: BuildProfile::Release,
1544            incremental: true,
1545            android_abis: Some(vec!["arm64-v8a".to_string(), "x86_64".to_string()]),
1546        };
1547
1548        let abis = builder
1549            .resolve_android_abis(&config)
1550            .expect("resolve configured ABIs");
1551        assert_eq!(abis, vec!["arm64-v8a".to_string(), "x86_64".to_string()]);
1552    }
1553
1554    #[test]
1555    fn android_native_offsets_are_symbolized_into_rust_frames() {
1556        let input = "dev.world.samplefns;uniffi.sample_fns.Sample_fnsKt.runBenchmark;libsample_fns.so[+94138] 1";
1557        let output =
1558            symbolize_android_native_stack_line_with_resolver(input, |library_name, offset| {
1559                if library_name == "libsample_fns.so" && offset == 94_138 {
1560                    Some("sample_fns::fibonacci".into())
1561                } else {
1562                    None
1563                }
1564            });
1565
1566        assert!(
1567            output.line.contains("sample_fns::fibonacci"),
1568            "expected unresolved native offsets to be rewritten into Rust symbols, got: {}",
1569            output.line
1570        );
1571        assert_eq!(output.resolved_frames, 1);
1572        assert_eq!(output.unresolved_frames, 0);
1573    }
1574
1575    #[test]
1576    fn resolve_android_native_symbol_with_tool_invokes_addr2line() {
1577        let temp_dir = std::env::temp_dir().join(format!(
1578            "mobench-addr2line-{}-{}",
1579            std::process::id(),
1580            std::time::SystemTime::now()
1581                .duration_since(std::time::UNIX_EPOCH)
1582                .expect("system time")
1583                .as_nanos()
1584        ));
1585        std::fs::create_dir_all(&temp_dir).expect("create temp dir");
1586        let tool_path = temp_dir.join("llvm-addr2line.sh");
1587        let args_path = temp_dir.join("args.txt");
1588        let script = format!(
1589            "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\nprintf '%s\\n' 'sample_fns::fibonacci at /tmp/src/lib.rs:131'\n",
1590            args_path.display()
1591        );
1592        std::fs::write(&tool_path, script).expect("write shim");
1593
1594        #[cfg(unix)]
1595        {
1596            use std::os::unix::fs::PermissionsExt;
1597            let mut perms = std::fs::metadata(&tool_path)
1598                .expect("metadata")
1599                .permissions();
1600            perms.set_mode(0o755);
1601            std::fs::set_permissions(&tool_path, perms).expect("chmod");
1602        }
1603
1604        let symbol = resolve_android_native_symbol_with_tool(
1605            &tool_path,
1606            Path::new("/cargo/target/aarch64-linux-android/release/libsample_fns.so"),
1607            94_138,
1608        );
1609
1610        assert_eq!(symbol.as_deref(), Some("sample_fns::fibonacci"));
1611
1612        let args = std::fs::read_to_string(&args_path).expect("read args");
1613        let expected_offset = format!("0x{:x}", 94_138);
1614        assert!(
1615            args.lines().any(|line| line == "-Cfpe"),
1616            "expected llvm-addr2line to be called with -Cfpe, got:\n{args}"
1617        );
1618        assert!(
1619            args.lines().any(|line| {
1620                line == "/cargo/target/aarch64-linux-android/release/libsample_fns.so"
1621            }),
1622            "expected llvm-addr2line to use the unstripped library path, got:\n{args}"
1623        );
1624        assert!(
1625            args.lines().any(|line| line == expected_offset),
1626            "expected llvm-addr2line to receive the resolved offset, got:\n{args}"
1627        );
1628    }
1629
1630    #[test]
1631    fn android_native_offsets_preserve_unresolved_frames() {
1632        let input = "dev.world.samplefns;libsample_fns.so[+94138];libother.so[+17] 1";
1633        let output =
1634            symbolize_android_native_stack_line_with_resolver(input, |library_name, offset| {
1635                if library_name == "libsample_fns.so" && offset == 94_138 {
1636                    Some("sample_fns::fibonacci".into())
1637                } else {
1638                    None
1639                }
1640            });
1641
1642        assert!(output.line.contains("sample_fns::fibonacci"));
1643        assert!(output.line.contains("libother.so[+17]"));
1644        assert_eq!(output.resolved_frames, 1);
1645        assert_eq!(output.unresolved_frames, 1);
1646    }
1647
1648    #[test]
1649    fn test_find_crate_dir_current_directory_is_crate() {
1650        // Test case 1: Current directory IS the crate with matching package name
1651        let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-current");
1652        let _ = std::fs::remove_dir_all(&temp_dir);
1653        std::fs::create_dir_all(&temp_dir).unwrap();
1654
1655        // Create Cargo.toml with matching package name
1656        std::fs::write(
1657            temp_dir.join("Cargo.toml"),
1658            r#"[package]
1659name = "bench-mobile"
1660version = "0.1.0"
1661"#,
1662        )
1663        .unwrap();
1664
1665        let builder = AndroidBuilder::new(&temp_dir, "bench-mobile");
1666        let result = builder.find_crate_dir();
1667        assert!(result.is_ok(), "Should find crate in current directory");
1668        assert_eq!(result.unwrap(), temp_dir);
1669
1670        std::fs::remove_dir_all(&temp_dir).unwrap();
1671    }
1672
1673    #[test]
1674    fn test_find_crate_dir_nested_bench_mobile() {
1675        // Test case 2: Crate is in bench-mobile/ subdirectory
1676        let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-nested");
1677        let _ = std::fs::remove_dir_all(&temp_dir);
1678        std::fs::create_dir_all(temp_dir.join("bench-mobile")).unwrap();
1679
1680        // Create parent Cargo.toml (workspace or different crate)
1681        std::fs::write(
1682            temp_dir.join("Cargo.toml"),
1683            r#"[workspace]
1684members = ["bench-mobile"]
1685"#,
1686        )
1687        .unwrap();
1688
1689        // Create bench-mobile/Cargo.toml
1690        std::fs::write(
1691            temp_dir.join("bench-mobile/Cargo.toml"),
1692            r#"[package]
1693name = "bench-mobile"
1694version = "0.1.0"
1695"#,
1696        )
1697        .unwrap();
1698
1699        let builder = AndroidBuilder::new(&temp_dir, "bench-mobile");
1700        let result = builder.find_crate_dir();
1701        assert!(
1702            result.is_ok(),
1703            "Should find crate in bench-mobile/ directory"
1704        );
1705        assert_eq!(result.unwrap(), temp_dir.join("bench-mobile"));
1706
1707        std::fs::remove_dir_all(&temp_dir).unwrap();
1708    }
1709
1710    #[test]
1711    fn test_find_crate_dir_crates_subdir() {
1712        // Test case 3: Crate is in crates/{name}/ subdirectory
1713        let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-crates");
1714        let _ = std::fs::remove_dir_all(&temp_dir);
1715        std::fs::create_dir_all(temp_dir.join("crates/my-bench")).unwrap();
1716
1717        // Create workspace Cargo.toml
1718        std::fs::write(
1719            temp_dir.join("Cargo.toml"),
1720            r#"[workspace]
1721members = ["crates/*"]
1722"#,
1723        )
1724        .unwrap();
1725
1726        // Create crates/my-bench/Cargo.toml
1727        std::fs::write(
1728            temp_dir.join("crates/my-bench/Cargo.toml"),
1729            r#"[package]
1730name = "my-bench"
1731version = "0.1.0"
1732"#,
1733        )
1734        .unwrap();
1735
1736        let builder = AndroidBuilder::new(&temp_dir, "my-bench");
1737        let result = builder.find_crate_dir();
1738        assert!(result.is_ok(), "Should find crate in crates/ directory");
1739        assert_eq!(result.unwrap(), temp_dir.join("crates/my-bench"));
1740
1741        std::fs::remove_dir_all(&temp_dir).unwrap();
1742    }
1743
1744    #[test]
1745    fn test_find_crate_dir_not_found() {
1746        // Test case 4: Crate doesn't exist anywhere
1747        let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-notfound");
1748        let _ = std::fs::remove_dir_all(&temp_dir);
1749        std::fs::create_dir_all(&temp_dir).unwrap();
1750
1751        // Create Cargo.toml with DIFFERENT package name
1752        std::fs::write(
1753            temp_dir.join("Cargo.toml"),
1754            r#"[package]
1755name = "some-other-crate"
1756version = "0.1.0"
1757"#,
1758        )
1759        .unwrap();
1760
1761        let builder = AndroidBuilder::new(&temp_dir, "nonexistent-crate");
1762        let result = builder.find_crate_dir();
1763        assert!(result.is_err(), "Should fail to find nonexistent crate");
1764        let err_msg = result.unwrap_err().to_string();
1765        assert!(err_msg.contains("Benchmark crate 'nonexistent-crate' not found"));
1766        assert!(err_msg.contains("Searched locations"));
1767
1768        std::fs::remove_dir_all(&temp_dir).unwrap();
1769    }
1770
1771    #[test]
1772    fn test_find_crate_dir_explicit_crate_path() {
1773        // Test case 5: Explicit crate_dir overrides auto-detection
1774        let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-explicit");
1775        let _ = std::fs::remove_dir_all(&temp_dir);
1776        std::fs::create_dir_all(temp_dir.join("custom-location")).unwrap();
1777
1778        let builder =
1779            AndroidBuilder::new(&temp_dir, "any-name").crate_dir(temp_dir.join("custom-location"));
1780        let result = builder.find_crate_dir();
1781        assert!(result.is_ok(), "Should use explicit crate_dir");
1782        assert_eq!(result.unwrap(), temp_dir.join("custom-location"));
1783
1784        std::fs::remove_dir_all(&temp_dir).unwrap();
1785    }
1786}