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