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