1use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target};
59use super::common::{get_cargo_target_dir, host_lib_path, run_command, validate_project_root};
60use std::env;
61use std::fs;
62use std::path::{Path, PathBuf};
63use std::process::Command;
64
65pub struct AndroidBuilder {
91 project_root: PathBuf,
93 output_dir: PathBuf,
95 crate_name: String,
97 verbose: bool,
99 crate_dir: Option<PathBuf>,
101 dry_run: bool,
103}
104
105impl AndroidBuilder {
106 pub fn new(project_root: impl Into<PathBuf>, crate_name: impl Into<String>) -> Self {
113 let root = project_root.into();
114 Self {
115 output_dir: root.join("target/mobench"),
116 project_root: root,
117 crate_name: crate_name.into(),
118 verbose: false,
119 crate_dir: None,
120 dry_run: false,
121 }
122 }
123
124 pub fn output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
129 self.output_dir = dir.into();
130 self
131 }
132
133 pub fn crate_dir(mut self, dir: impl Into<PathBuf>) -> Self {
143 self.crate_dir = Some(dir.into());
144 self
145 }
146
147 pub fn verbose(mut self, verbose: bool) -> Self {
149 self.verbose = verbose;
150 self
151 }
152
153 pub fn dry_run(mut self, dry_run: bool) -> Self {
158 self.dry_run = dry_run;
159 self
160 }
161
162 pub fn build(&self, config: &BuildConfig) -> Result<BuildResult, BenchError> {
176 if self.crate_dir.is_none() {
178 validate_project_root(&self.project_root, &self.crate_name)?;
179 }
180
181 let android_dir = self.output_dir.join("android");
182 let profile_name = match config.profile {
183 BuildProfile::Debug => "debug",
184 BuildProfile::Release => "release",
185 };
186
187 if self.dry_run {
188 println!("\n[dry-run] Android build plan:");
189 println!(" Step 0: Check/generate Android project scaffolding at {:?}", android_dir);
190 println!(" Step 0.5: Ensure Gradle wrapper exists (run 'gradle wrapper' if needed)");
191 println!(" Step 1: Build Rust libraries for Android ABIs (arm64-v8a, armeabi-v7a, x86_64)");
192 println!(" Command: cargo ndk --target <abi> --platform 24 build {}",
193 if matches!(config.profile, BuildProfile::Release) { "--release" } else { "" });
194 println!(" Step 2: Generate UniFFI Kotlin bindings");
195 println!(" Output: {:?}", android_dir.join("app/src/main/java/uniffi"));
196 println!(" Step 3: Copy .so files to jniLibs directories");
197 println!(" Destination: {:?}", android_dir.join("app/src/main/jniLibs"));
198 println!(" Step 4: Build Android APK with Gradle");
199 println!(" Command: ./gradlew assemble{}", if profile_name == "release" { "Release" } else { "Debug" });
200 println!(" Output: {:?}", android_dir.join(format!("app/build/outputs/apk/{}/app-{}.apk", profile_name, profile_name)));
201 println!(" Step 5: Build Android test APK");
202 println!(" Command: ./gradlew assemble{}AndroidTest", if profile_name == "release" { "Release" } else { "Debug" });
203
204 return Ok(BuildResult {
206 platform: Target::Android,
207 app_path: android_dir.join(format!("app/build/outputs/apk/{}/app-{}.apk", profile_name, profile_name)),
208 test_suite_path: Some(android_dir.join(format!("app/build/outputs/apk/androidTest/{}/app-{}-androidTest.apk", profile_name, profile_name))),
209 });
210 }
211
212 crate::codegen::ensure_android_project_with_options(
215 &self.output_dir,
216 &self.crate_name,
217 Some(&self.project_root),
218 self.crate_dir.as_deref(),
219 )?;
220
221 self.ensure_gradle_wrapper(&android_dir)?;
223
224 println!("Building Rust libraries for Android...");
226 self.build_rust_libraries(config)?;
227
228 println!("Generating UniFFI Kotlin bindings...");
230 self.generate_uniffi_bindings()?;
231
232 println!("Copying native libraries to jniLibs...");
234 self.copy_native_libraries(config)?;
235
236 println!("Building Android APK with Gradle...");
238 let apk_path = self.build_apk(config)?;
239
240 println!("Building Android test APK...");
242 let test_suite_path = self.build_test_apk(config)?;
243
244 let result = BuildResult {
246 platform: Target::Android,
247 app_path: apk_path,
248 test_suite_path: Some(test_suite_path),
249 };
250 self.validate_build_artifacts(&result, config)?;
251
252 Ok(result)
253 }
254
255 fn validate_build_artifacts(&self, result: &BuildResult, config: &BuildConfig) -> Result<(), BenchError> {
257 let mut missing = Vec::new();
258 let profile_dir = match config.profile {
259 BuildProfile::Debug => "debug",
260 BuildProfile::Release => "release",
261 };
262
263 if !result.app_path.exists() {
265 missing.push(format!("Main APK: {}", result.app_path.display()));
266 }
267
268 if let Some(ref test_path) = result.test_suite_path {
270 if !test_path.exists() {
271 missing.push(format!("Test APK: {}", test_path.display()));
272 }
273 }
274
275 let jni_libs_dir = self.output_dir.join("android/app/src/main/jniLibs");
277 let lib_name = format!("lib{}.so", self.crate_name.replace("-", "_"));
278 let required_abis = ["arm64-v8a", "armeabi-v7a", "x86_64"];
279 let mut found_libs = 0;
280 for abi in &required_abis {
281 let lib_path = jni_libs_dir.join(abi).join(&lib_name);
282 if lib_path.exists() {
283 found_libs += 1;
284 } else {
285 missing.push(format!("Native library ({} {}): {}", abi, profile_dir, lib_path.display()));
286 }
287 }
288
289 if found_libs == 0 {
290 return Err(BenchError::Build(format!(
291 "Build validation failed: No native libraries found.\n\n\
292 Expected at least one .so file in jniLibs directories.\n\
293 Missing artifacts:\n{}\n\n\
294 This usually means the Rust build step failed. Check the cargo-ndk output above.",
295 missing.iter().map(|s| format!(" - {}", s)).collect::<Vec<_>>().join("\n")
296 )));
297 }
298
299 if !missing.is_empty() {
300 eprintln!(
301 "Warning: Some build artifacts are missing:\n{}\n\
302 The build may still work but some features might be unavailable.",
303 missing.iter().map(|s| format!(" - {}", s)).collect::<Vec<_>>().join("\n")
304 );
305 }
306
307 Ok(())
308 }
309
310 fn find_crate_dir(&self) -> Result<PathBuf, BenchError> {
318 if let Some(ref dir) = self.crate_dir {
320 if dir.exists() {
321 return Ok(dir.clone());
322 }
323 return Err(BenchError::Build(format!(
324 "Specified crate path does not exist: {:?}.\n\n\
325 Tip: pass --crate-path pointing at a directory containing Cargo.toml.",
326 dir
327 )));
328 }
329
330 let root_cargo_toml = self.project_root.join("Cargo.toml");
333 if root_cargo_toml.exists() {
334 if let Some(pkg_name) = super::common::read_package_name(&root_cargo_toml) {
335 if pkg_name == self.crate_name {
336 return Ok(self.project_root.clone());
337 }
338 }
339 }
340
341 let bench_mobile_dir = self.project_root.join("bench-mobile");
343 if bench_mobile_dir.exists() {
344 return Ok(bench_mobile_dir);
345 }
346
347 let crates_dir = self.project_root.join("crates").join(&self.crate_name);
349 if crates_dir.exists() {
350 return Ok(crates_dir);
351 }
352
353 let named_dir = self.project_root.join(&self.crate_name);
355 if named_dir.exists() {
356 return Ok(named_dir);
357 }
358
359 let root_manifest = root_cargo_toml;
360 let bench_mobile_manifest = bench_mobile_dir.join("Cargo.toml");
361 let crates_manifest = crates_dir.join("Cargo.toml");
362 let named_manifest = named_dir.join("Cargo.toml");
363 Err(BenchError::Build(format!(
364 "Benchmark crate '{}' not found.\n\n\
365 Searched locations:\n\
366 - {} (checked [package] name)\n\
367 - {}\n\
368 - {}\n\
369 - {}\n\n\
370 To fix this:\n\
371 1. Run from the crate directory (where Cargo.toml has name = \"{}\")\n\
372 2. Create a bench-mobile/ directory with your benchmark crate, or\n\
373 3. Use --crate-path to specify the benchmark crate location:\n\
374 cargo mobench build --target android --crate-path ./my-benchmarks\n\n\
375 Common issues:\n\
376 - Typo in crate name (check Cargo.toml [package] name)\n\
377 - Wrong working directory (run from project root)\n\
378 - Missing Cargo.toml in the crate directory\n\n\
379 Run 'cargo mobench init --help' to generate a new benchmark project.",
380 self.crate_name,
381 root_manifest.display(),
382 bench_mobile_manifest.display(),
383 crates_manifest.display(),
384 named_manifest.display(),
385 self.crate_name,
386 )))
387 }
388
389 fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
391 let crate_dir = self.find_crate_dir()?;
392
393 self.check_cargo_ndk()?;
395
396 let abis = vec!["arm64-v8a", "armeabi-v7a", "x86_64"];
398 let release_flag = if matches!(config.profile, BuildProfile::Release) {
399 "--release"
400 } else {
401 ""
402 };
403
404 for abi in abis {
405 if self.verbose {
406 println!(" Building for {}", abi);
407 }
408
409 let mut cmd = Command::new("cargo");
410 cmd.arg("ndk")
411 .arg("--target")
412 .arg(abi)
413 .arg("--platform")
414 .arg("24") .arg("build");
416
417 if !release_flag.is_empty() {
419 cmd.arg(release_flag);
420 }
421
422 cmd.current_dir(&crate_dir);
424
425 let command_hint = if release_flag.is_empty() {
427 format!("cargo ndk --target {} --platform 24 build", abi)
428 } else {
429 format!("cargo ndk --target {} --platform 24 build {}", abi, release_flag)
430 };
431 let output = cmd
432 .output()
433 .map_err(|e| BenchError::Build(format!(
434 "Failed to start cargo-ndk for {}.\n\n\
435 Command: {}\n\
436 Crate directory: {}\n\
437 System error: {}\n\n\
438 Tips:\n\
439 - Install cargo-ndk: cargo install cargo-ndk\n\
440 - Ensure cargo is on PATH",
441 abi,
442 command_hint,
443 crate_dir.display(),
444 e
445 )))?;
446
447 if !output.status.success() {
448 let stdout = String::from_utf8_lossy(&output.stdout);
449 let stderr = String::from_utf8_lossy(&output.stderr);
450 let profile = if matches!(config.profile, BuildProfile::Release) {
451 "release"
452 } else {
453 "debug"
454 };
455 let rust_target = match abi {
456 "arm64-v8a" => "aarch64-linux-android",
457 "armeabi-v7a" => "armv7-linux-androideabi",
458 "x86_64" => "x86_64-linux-android",
459 _ => abi,
460 };
461 return Err(BenchError::Build(format!(
462 "cargo-ndk build failed for {} ({} profile).\n\n\
463 Command: {}\n\
464 Crate directory: {}\n\
465 Exit status: {}\n\n\
466 Stdout:\n{}\n\n\
467 Stderr:\n{}\n\n\
468 Common causes:\n\
469 - Missing Rust target: rustup target add {}\n\
470 - NDK not found: set ANDROID_NDK_HOME\n\
471 - Compilation error in Rust code (see output above)\n\
472 - Incompatible native dependencies (some C libraries do not support Android)",
473 abi,
474 profile,
475 command_hint,
476 crate_dir.display(),
477 output.status,
478 stdout,
479 stderr,
480 rust_target,
481 )));
482 }
483 }
484
485 Ok(())
486 }
487
488 fn check_cargo_ndk(&self) -> Result<(), BenchError> {
490 let output = Command::new("cargo").arg("ndk").arg("--version").output();
491
492 match output {
493 Ok(output) if output.status.success() => Ok(()),
494 _ => Err(BenchError::Build(
495 "cargo-ndk is not installed or not in PATH.\n\n\
496 cargo-ndk is required to cross-compile Rust for Android.\n\n\
497 To install:\n\
498 cargo install cargo-ndk\n\
499 Verify with:\n\
500 cargo ndk --version\n\n\
501 You also need the Android NDK. Set ANDROID_NDK_HOME or install via Android Studio.\n\
502 See: https://github.com/nickelc/cargo-ndk"
503 .to_string(),
504 )),
505 }
506 }
507
508 fn generate_uniffi_bindings(&self) -> Result<(), BenchError> {
510 let crate_dir = self.find_crate_dir()?;
511 let crate_name_underscored = self.crate_name.replace("-", "_");
512
513 let bindings_path = self
515 .output_dir
516 .join("android")
517 .join("app")
518 .join("src")
519 .join("main")
520 .join("java")
521 .join("uniffi")
522 .join(&crate_name_underscored)
523 .join(format!("{}.kt", crate_name_underscored));
524
525 if bindings_path.exists() {
526 if self.verbose {
527 println!(" Using existing Kotlin bindings at {:?}", bindings_path);
528 }
529 return Ok(());
530 }
531
532 let mut build_cmd = Command::new("cargo");
534 build_cmd.arg("build");
535 build_cmd.current_dir(&crate_dir);
536 run_command(build_cmd, "cargo build (host)")?;
537
538 let lib_path = host_lib_path(&crate_dir, &self.crate_name)?;
539 let out_dir = self
540 .output_dir
541 .join("android")
542 .join("app")
543 .join("src")
544 .join("main")
545 .join("java");
546
547 let cargo_run_result = Command::new("cargo")
549 .args(["run", "-p", &self.crate_name, "--bin", "uniffi-bindgen", "--"])
550 .arg("generate")
551 .arg("--library")
552 .arg(&lib_path)
553 .arg("--language")
554 .arg("kotlin")
555 .arg("--out-dir")
556 .arg(&out_dir)
557 .current_dir(&crate_dir)
558 .output();
559
560 let use_cargo_run = cargo_run_result
561 .as_ref()
562 .map(|o| o.status.success())
563 .unwrap_or(false);
564
565 if use_cargo_run {
566 if self.verbose {
567 println!(" Generated bindings using cargo run uniffi-bindgen");
568 }
569 } else {
570 let uniffi_available = Command::new("uniffi-bindgen")
572 .arg("--version")
573 .output()
574 .map(|o| o.status.success())
575 .unwrap_or(false);
576
577 if !uniffi_available {
578 return Err(BenchError::Build(
579 "uniffi-bindgen not found and no pre-generated bindings exist.\n\n\
580 To fix this, either:\n\
581 1. Add a uniffi-bindgen binary to your crate:\n\
582 [[bin]]\n\
583 name = \"uniffi-bindgen\"\n\
584 path = \"src/bin/uniffi-bindgen.rs\"\n\n\
585 2. Or install uniffi-bindgen globally:\n\
586 cargo install uniffi-bindgen\n\n\
587 3. Or pre-generate bindings and commit them."
588 .to_string(),
589 ));
590 }
591
592 let mut cmd = Command::new("uniffi-bindgen");
593 cmd.arg("generate")
594 .arg("--library")
595 .arg(&lib_path)
596 .arg("--language")
597 .arg("kotlin")
598 .arg("--out-dir")
599 .arg(&out_dir);
600 run_command(cmd, "uniffi-bindgen kotlin")?;
601 }
602
603 if self.verbose {
604 println!(" Generated UniFFI Kotlin bindings at {:?}", out_dir);
605 }
606 Ok(())
607 }
608
609 fn copy_native_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
611 let crate_dir = self.find_crate_dir()?;
612 let profile_dir = match config.profile {
613 BuildProfile::Debug => "debug",
614 BuildProfile::Release => "release",
615 };
616
617 let target_dir = get_cargo_target_dir(&crate_dir)?;
619 let jni_libs_dir = self.output_dir.join("android/app/src/main/jniLibs");
620
621 std::fs::create_dir_all(&jni_libs_dir).map_err(|e| {
623 BenchError::Build(format!(
624 "Failed to create jniLibs directory at {}: {}. Check output directory permissions.",
625 jni_libs_dir.display(),
626 e
627 ))
628 })?;
629
630 let abi_mappings = vec![
632 ("aarch64-linux-android", "arm64-v8a"),
633 ("armv7-linux-androideabi", "armeabi-v7a"),
634 ("x86_64-linux-android", "x86_64"),
635 ];
636
637 for (rust_target, android_abi) in abi_mappings {
638 let src = target_dir
639 .join(rust_target)
640 .join(profile_dir)
641 .join(format!("lib{}.so", self.crate_name.replace("-", "_")));
642
643 let dest_dir = jni_libs_dir.join(android_abi);
644 std::fs::create_dir_all(&dest_dir).map_err(|e| {
645 BenchError::Build(format!(
646 "Failed to create ABI directory {} at {}: {}. Check output directory permissions.",
647 android_abi,
648 dest_dir.display(),
649 e
650 ))
651 })?;
652
653 let dest = dest_dir.join(format!("lib{}.so", self.crate_name.replace("-", "_")));
654
655 if src.exists() {
656 std::fs::copy(&src, &dest).map_err(|e| {
657 BenchError::Build(format!(
658 "Failed to copy {} library from {} to {}: {}. Ensure cargo-ndk completed successfully.",
659 android_abi,
660 src.display(),
661 dest.display(),
662 e
663 ))
664 })?;
665
666 if self.verbose {
667 println!(" Copied {} -> {}", src.display(), dest.display());
668 }
669 } else {
670 eprintln!(
672 "Warning: Native library for {} not found at {}.\n\
673 This will cause a runtime crash when the app tries to load the library.\n\
674 Ensure cargo-ndk build completed successfully for this ABI.",
675 android_abi,
676 src.display()
677 );
678 }
679 }
680
681 Ok(())
682 }
683
684 fn ensure_local_properties(&self, android_dir: &Path) -> Result<(), BenchError> {
695 let local_props = android_dir.join("local.properties");
696
697 if local_props.exists() {
699 return Ok(());
700 }
701
702 let sdk_dir = self.find_android_sdk_from_env();
705
706 match sdk_dir {
707 Some(path) => {
708 let content = format!("sdk.dir={}\n", path.display());
710 fs::write(&local_props, content).map_err(|e| {
711 BenchError::Build(format!(
712 "Failed to write local.properties at {:?}: {}. Check output directory permissions.",
713 local_props, e
714 ))
715 })?;
716
717 if self.verbose {
718 println!(" Generated local.properties with sdk.dir={}", path.display());
719 }
720 }
721 None => {
722 if self.verbose {
725 println!(" Skipping local.properties generation (ANDROID_HOME/ANDROID_SDK_ROOT not set)");
726 println!(" Gradle will auto-detect SDK or you can create local.properties manually");
727 }
728 }
729 }
730
731 Ok(())
732 }
733
734 fn find_android_sdk_from_env(&self) -> Option<PathBuf> {
742 if let Ok(path) = env::var("ANDROID_HOME") {
744 let sdk_path = PathBuf::from(&path);
745 if sdk_path.exists() {
746 return Some(sdk_path);
747 }
748 }
749
750 if let Ok(path) = env::var("ANDROID_SDK_ROOT") {
752 let sdk_path = PathBuf::from(&path);
753 if sdk_path.exists() {
754 return Some(sdk_path);
755 }
756 }
757
758 None
759 }
760
761 fn ensure_gradle_wrapper(&self, android_dir: &Path) -> Result<(), BenchError> {
766 let gradlew = android_dir.join("gradlew");
767
768 if gradlew.exists() {
770 return Ok(());
771 }
772
773 println!("Gradle wrapper not found, generating...");
774
775 let gradle_available = Command::new("gradle")
777 .arg("--version")
778 .output()
779 .map(|o| o.status.success())
780 .unwrap_or(false);
781
782 if !gradle_available {
783 return Err(BenchError::Build(
784 "Gradle wrapper (gradlew) not found and 'gradle' command is not available.\n\n\
785 The Android project requires Gradle to build. You have two options:\n\n\
786 1. Install Gradle globally and run the build again (it will auto-generate the wrapper):\n\
787 - macOS: brew install gradle\n\
788 - Linux: sudo apt install gradle\n\
789 - Or download from https://gradle.org/install/\n\n\
790 2. Or generate the wrapper manually in the Android project directory:\n\
791 cd target/mobench/android && gradle wrapper --gradle-version 8.5"
792 .to_string(),
793 ));
794 }
795
796 let mut cmd = Command::new("gradle");
798 cmd.arg("wrapper")
799 .arg("--gradle-version")
800 .arg("8.5")
801 .current_dir(android_dir);
802
803 let output = cmd.output().map_err(|e| {
804 BenchError::Build(format!(
805 "Failed to run 'gradle wrapper' command: {}\n\n\
806 Ensure Gradle is installed and on your PATH.",
807 e
808 ))
809 })?;
810
811 if !output.status.success() {
812 let stderr = String::from_utf8_lossy(&output.stderr);
813 return Err(BenchError::Build(format!(
814 "Failed to generate Gradle wrapper.\n\n\
815 Command: gradle wrapper --gradle-version 8.5\n\
816 Working directory: {}\n\
817 Exit status: {}\n\
818 Stderr: {}\n\n\
819 Try running this command manually in the Android project directory.",
820 android_dir.display(),
821 output.status,
822 stderr
823 )));
824 }
825
826 #[cfg(unix)]
828 {
829 use std::os::unix::fs::PermissionsExt;
830 if let Ok(metadata) = fs::metadata(&gradlew) {
831 let mut perms = metadata.permissions();
832 perms.set_mode(0o755);
833 let _ = fs::set_permissions(&gradlew, perms);
834 }
835 }
836
837 if self.verbose {
838 println!(" Generated Gradle wrapper at {:?}", gradlew);
839 }
840
841 Ok(())
842 }
843
844 fn build_apk(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
846 let android_dir = self.output_dir.join("android");
847
848 if !android_dir.exists() {
849 return Err(BenchError::Build(format!(
850 "Android project not found at {}.\n\n\
851 Expected a Gradle project under the output directory.\n\
852 Run `cargo mobench init --target android` or `cargo mobench build --target android` from the project root to generate it.",
853 android_dir.display()
854 )));
855 }
856
857 self.ensure_local_properties(&android_dir)?;
859
860 let gradle_task = match config.profile {
862 BuildProfile::Debug => "assembleDebug",
863 BuildProfile::Release => "assembleRelease",
864 };
865
866 let mut cmd = Command::new("./gradlew");
868 cmd.arg(gradle_task).current_dir(&android_dir);
869
870 if self.verbose {
871 cmd.arg("--info");
872 }
873
874 let output = cmd
875 .output()
876 .map_err(|e| BenchError::Build(format!(
877 "Failed to run Gradle wrapper.\n\n\
878 Command: ./gradlew {}\n\
879 Working directory: {}\n\
880 Error: {}\n\n\
881 Tips:\n\
882 - Ensure ./gradlew is executable (chmod +x ./gradlew)\n\
883 - Run ./gradlew --version in that directory to verify the wrapper",
884 gradle_task,
885 android_dir.display(),
886 e
887 )))?;
888
889 if !output.status.success() {
890 let stdout = String::from_utf8_lossy(&output.stdout);
891 let stderr = String::from_utf8_lossy(&output.stderr);
892 return Err(BenchError::Build(format!(
893 "Gradle build failed.\n\n\
894 Command: ./gradlew {}\n\
895 Working directory: {}\n\
896 Exit status: {}\n\n\
897 Stdout:\n{}\n\n\
898 Stderr:\n{}\n\n\
899 Tips:\n\
900 - Re-run with verbose mode to pass --info to Gradle\n\
901 - Run ./gradlew {} --stacktrace for a full stack trace",
902 gradle_task,
903 android_dir.display(),
904 output.status,
905 stdout,
906 stderr,
907 gradle_task,
908 )));
909 }
910
911 let profile_name = match config.profile {
913 BuildProfile::Debug => "debug",
914 BuildProfile::Release => "release",
915 };
916
917 let apk_dir = android_dir.join("app/build/outputs/apk").join(profile_name);
918
919 let apk_path = self.find_apk(&apk_dir, profile_name, gradle_task)?;
925
926 Ok(apk_path)
927 }
928
929 fn find_apk(&self, apk_dir: &Path, profile_name: &str, gradle_task: &str) -> Result<PathBuf, BenchError> {
939 let metadata_path = apk_dir.join("output-metadata.json");
941 if metadata_path.exists() {
942 if let Ok(metadata_content) = fs::read_to_string(&metadata_path) {
943 if let Some(apk_name) = self.parse_output_metadata(&metadata_content) {
946 let apk_path = apk_dir.join(&apk_name);
947 if apk_path.exists() {
948 if self.verbose {
949 println!(" Found APK from output-metadata.json: {}", apk_path.display());
950 }
951 return Ok(apk_path);
952 }
953 }
954 }
955 }
956
957 let candidates = if profile_name == "release" {
959 vec![
960 format!("app-{}.apk", profile_name), format!("app-{}-unsigned.apk", profile_name), ]
963 } else {
964 vec![
965 format!("app-{}.apk", profile_name), ]
967 };
968
969 for candidate in &candidates {
971 let apk_path = apk_dir.join(candidate);
972 if apk_path.exists() {
973 if self.verbose {
974 println!(" Found APK: {}", apk_path.display());
975 }
976 return Ok(apk_path);
977 }
978 }
979
980 Err(BenchError::Build(format!(
982 "APK not found in {}.\n\n\
983 Gradle task {} reported success but no APK was produced.\n\
984 Searched for:\n{}\n\n\
985 Check the build output directory and rerun ./gradlew {} if needed.",
986 apk_dir.display(),
987 gradle_task,
988 candidates.iter().map(|c| format!(" - {}", c)).collect::<Vec<_>>().join("\n"),
989 gradle_task
990 )))
991 }
992
993 fn parse_output_metadata(&self, content: &str) -> Option<String> {
1007 let pattern = "\"outputFile\"";
1010 if let Some(pos) = content.find(pattern) {
1011 let after_key = &content[pos + pattern.len()..];
1012 let after_colon = after_key.trim_start().strip_prefix(':')?;
1014 let after_ws = after_colon.trim_start();
1015 if after_ws.starts_with('"') {
1017 let value_start = &after_ws[1..];
1018 if let Some(end_quote) = value_start.find('"') {
1019 let filename = &value_start[..end_quote];
1020 if filename.ends_with(".apk") {
1021 return Some(filename.to_string());
1022 }
1023 }
1024 }
1025 }
1026 None
1027 }
1028
1029 fn build_test_apk(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
1031 let android_dir = self.output_dir.join("android");
1032
1033 if !android_dir.exists() {
1034 return Err(BenchError::Build(format!(
1035 "Android project not found at {}.\n\n\
1036 Expected a Gradle project under the output directory.\n\
1037 Run `cargo mobench init --target android` or `cargo mobench build --target android` from the project root to generate it.",
1038 android_dir.display()
1039 )));
1040 }
1041
1042 let gradle_task = match config.profile {
1043 BuildProfile::Debug => "assembleDebugAndroidTest",
1044 BuildProfile::Release => "assembleReleaseAndroidTest",
1045 };
1046
1047 let mut cmd = Command::new("./gradlew");
1048 cmd.arg(gradle_task).current_dir(&android_dir);
1049
1050 if self.verbose {
1051 cmd.arg("--info");
1052 }
1053
1054 let output = cmd
1055 .output()
1056 .map_err(|e| BenchError::Build(format!(
1057 "Failed to run Gradle wrapper.\n\n\
1058 Command: ./gradlew {}\n\
1059 Working directory: {}\n\
1060 Error: {}\n\n\
1061 Tips:\n\
1062 - Ensure ./gradlew is executable (chmod +x ./gradlew)\n\
1063 - Run ./gradlew --version in that directory to verify the wrapper",
1064 gradle_task,
1065 android_dir.display(),
1066 e
1067 )))?;
1068
1069 if !output.status.success() {
1070 let stdout = String::from_utf8_lossy(&output.stdout);
1071 let stderr = String::from_utf8_lossy(&output.stderr);
1072 return Err(BenchError::Build(format!(
1073 "Gradle test APK build failed.\n\n\
1074 Command: ./gradlew {}\n\
1075 Working directory: {}\n\
1076 Exit status: {}\n\n\
1077 Stdout:\n{}\n\n\
1078 Stderr:\n{}\n\n\
1079 Tips:\n\
1080 - Re-run with verbose mode to pass --info to Gradle\n\
1081 - Run ./gradlew {} --stacktrace for a full stack trace",
1082 gradle_task,
1083 android_dir.display(),
1084 output.status,
1085 stdout,
1086 stderr,
1087 gradle_task,
1088 )));
1089 }
1090
1091 let profile_name = match config.profile {
1092 BuildProfile::Debug => "debug",
1093 BuildProfile::Release => "release",
1094 };
1095
1096 let test_apk_dir = android_dir
1097 .join("app/build/outputs/apk/androidTest")
1098 .join(profile_name);
1099
1100 let apk_path = self.find_test_apk(&test_apk_dir, profile_name, gradle_task)?;
1102
1103 Ok(apk_path)
1104 }
1105
1106 fn find_test_apk(&self, apk_dir: &Path, profile_name: &str, gradle_task: &str) -> Result<PathBuf, BenchError> {
1112 let metadata_path = apk_dir.join("output-metadata.json");
1114 if metadata_path.exists() {
1115 if let Ok(metadata_content) = fs::read_to_string(&metadata_path) {
1116 if let Some(apk_name) = self.parse_output_metadata(&metadata_content) {
1117 let apk_path = apk_dir.join(&apk_name);
1118 if apk_path.exists() {
1119 if self.verbose {
1120 println!(" Found test APK from output-metadata.json: {}", apk_path.display());
1121 }
1122 return Ok(apk_path);
1123 }
1124 }
1125 }
1126 }
1127
1128 let apk_path = apk_dir.join(format!("app-{}-androidTest.apk", profile_name));
1130 if apk_path.exists() {
1131 if self.verbose {
1132 println!(" Found test APK: {}", apk_path.display());
1133 }
1134 return Ok(apk_path);
1135 }
1136
1137 Err(BenchError::Build(format!(
1139 "Android test APK not found in {}.\n\n\
1140 Gradle task {} reported success but no test APK was produced.\n\
1141 Expected: app-{}-androidTest.apk\n\n\
1142 Check app/build/outputs/apk/androidTest/{} and rerun ./gradlew {} if needed.",
1143 apk_dir.display(),
1144 gradle_task,
1145 profile_name,
1146 profile_name,
1147 gradle_task
1148 )))
1149 }
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154 use super::*;
1155
1156 #[test]
1157 fn test_android_builder_creation() {
1158 let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1159 assert!(!builder.verbose);
1160 assert_eq!(
1161 builder.output_dir,
1162 PathBuf::from("/tmp/test-project/target/mobench")
1163 );
1164 }
1165
1166 #[test]
1167 fn test_android_builder_verbose() {
1168 let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile").verbose(true);
1169 assert!(builder.verbose);
1170 }
1171
1172 #[test]
1173 fn test_android_builder_custom_output_dir() {
1174 let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile")
1175 .output_dir("/custom/output");
1176 assert_eq!(builder.output_dir, PathBuf::from("/custom/output"));
1177 }
1178
1179 #[test]
1180 fn test_parse_output_metadata_unsigned() {
1181 let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1182 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"}"#;
1183 let result = builder.parse_output_metadata(metadata);
1184 assert_eq!(result, Some("app-release-unsigned.apk".to_string()));
1185 }
1186
1187 #[test]
1188 fn test_parse_output_metadata_signed() {
1189 let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1190 let metadata = r#"{"version":3,"elements":[{"outputFile":"app-release.apk"}]}"#;
1191 let result = builder.parse_output_metadata(metadata);
1192 assert_eq!(result, Some("app-release.apk".to_string()));
1193 }
1194
1195 #[test]
1196 fn test_parse_output_metadata_no_apk() {
1197 let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1198 let metadata = r#"{"version":3,"elements":[]}"#;
1199 let result = builder.parse_output_metadata(metadata);
1200 assert_eq!(result, None);
1201 }
1202
1203 #[test]
1204 fn test_parse_output_metadata_invalid_json() {
1205 let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1206 let metadata = "not valid json";
1207 let result = builder.parse_output_metadata(metadata);
1208 assert_eq!(result, None);
1209 }
1210
1211 #[test]
1212 fn test_find_crate_dir_current_directory_is_crate() {
1213 let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-current");
1215 let _ = std::fs::remove_dir_all(&temp_dir);
1216 std::fs::create_dir_all(&temp_dir).unwrap();
1217
1218 std::fs::write(
1220 temp_dir.join("Cargo.toml"),
1221 r#"[package]
1222name = "bench-mobile"
1223version = "0.1.0"
1224"#,
1225 )
1226 .unwrap();
1227
1228 let builder = AndroidBuilder::new(&temp_dir, "bench-mobile");
1229 let result = builder.find_crate_dir();
1230 assert!(result.is_ok(), "Should find crate in current directory");
1231 assert_eq!(result.unwrap(), temp_dir);
1232
1233 std::fs::remove_dir_all(&temp_dir).unwrap();
1234 }
1235
1236 #[test]
1237 fn test_find_crate_dir_nested_bench_mobile() {
1238 let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-nested");
1240 let _ = std::fs::remove_dir_all(&temp_dir);
1241 std::fs::create_dir_all(temp_dir.join("bench-mobile")).unwrap();
1242
1243 std::fs::write(
1245 temp_dir.join("Cargo.toml"),
1246 r#"[workspace]
1247members = ["bench-mobile"]
1248"#,
1249 )
1250 .unwrap();
1251
1252 std::fs::write(
1254 temp_dir.join("bench-mobile/Cargo.toml"),
1255 r#"[package]
1256name = "bench-mobile"
1257version = "0.1.0"
1258"#,
1259 )
1260 .unwrap();
1261
1262 let builder = AndroidBuilder::new(&temp_dir, "bench-mobile");
1263 let result = builder.find_crate_dir();
1264 assert!(result.is_ok(), "Should find crate in bench-mobile/ directory");
1265 assert_eq!(result.unwrap(), temp_dir.join("bench-mobile"));
1266
1267 std::fs::remove_dir_all(&temp_dir).unwrap();
1268 }
1269
1270 #[test]
1271 fn test_find_crate_dir_crates_subdir() {
1272 let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-crates");
1274 let _ = std::fs::remove_dir_all(&temp_dir);
1275 std::fs::create_dir_all(temp_dir.join("crates/my-bench")).unwrap();
1276
1277 std::fs::write(
1279 temp_dir.join("Cargo.toml"),
1280 r#"[workspace]
1281members = ["crates/*"]
1282"#,
1283 )
1284 .unwrap();
1285
1286 std::fs::write(
1288 temp_dir.join("crates/my-bench/Cargo.toml"),
1289 r#"[package]
1290name = "my-bench"
1291version = "0.1.0"
1292"#,
1293 )
1294 .unwrap();
1295
1296 let builder = AndroidBuilder::new(&temp_dir, "my-bench");
1297 let result = builder.find_crate_dir();
1298 assert!(result.is_ok(), "Should find crate in crates/ directory");
1299 assert_eq!(result.unwrap(), temp_dir.join("crates/my-bench"));
1300
1301 std::fs::remove_dir_all(&temp_dir).unwrap();
1302 }
1303
1304 #[test]
1305 fn test_find_crate_dir_not_found() {
1306 let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-notfound");
1308 let _ = std::fs::remove_dir_all(&temp_dir);
1309 std::fs::create_dir_all(&temp_dir).unwrap();
1310
1311 std::fs::write(
1313 temp_dir.join("Cargo.toml"),
1314 r#"[package]
1315name = "some-other-crate"
1316version = "0.1.0"
1317"#,
1318 )
1319 .unwrap();
1320
1321 let builder = AndroidBuilder::new(&temp_dir, "nonexistent-crate");
1322 let result = builder.find_crate_dir();
1323 assert!(result.is_err(), "Should fail to find nonexistent crate");
1324 let err_msg = result.unwrap_err().to_string();
1325 assert!(err_msg.contains("Benchmark crate 'nonexistent-crate' not found"));
1326 assert!(err_msg.contains("Searched locations"));
1327
1328 std::fs::remove_dir_all(&temp_dir).unwrap();
1329 }
1330
1331 #[test]
1332 fn test_find_crate_dir_explicit_crate_path() {
1333 let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-explicit");
1335 let _ = std::fs::remove_dir_all(&temp_dir);
1336 std::fs::create_dir_all(temp_dir.join("custom-location")).unwrap();
1337
1338 let builder = AndroidBuilder::new(&temp_dir, "any-name")
1339 .crate_dir(temp_dir.join("custom-location"));
1340 let result = builder.find_crate_dir();
1341 assert!(result.is_ok(), "Should use explicit crate_dir");
1342 assert_eq!(result.unwrap(), temp_dir.join("custom-location"));
1343
1344 std::fs::remove_dir_all(&temp_dir).unwrap();
1345 }
1346}