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