Skip to main content

mobench_sdk/builders/
android.rs

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