1use super::common::{get_cargo_target_dir, host_lib_path, run_command, validate_project_root};
59use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target};
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!(
190 " Step 0: Check/generate Android project scaffolding at {:?}",
191 android_dir
192 );
193 println!(" Step 0.5: Ensure Gradle wrapper exists (run 'gradle wrapper' if needed)");
194 println!(
195 " Step 1: Build Rust libraries for Android ABIs (arm64-v8a, armeabi-v7a, x86_64)"
196 );
197 println!(
198 " Command: cargo ndk --target <abi> --platform 24 build {}",
199 if matches!(config.profile, BuildProfile::Release) {
200 "--release"
201 } else {
202 ""
203 }
204 );
205 println!(" Step 2: Generate UniFFI Kotlin bindings");
206 println!(
207 " Output: {:?}",
208 android_dir.join("app/src/main/java/uniffi")
209 );
210 println!(" Step 3: Copy .so files to jniLibs directories");
211 println!(
212 " Destination: {:?}",
213 android_dir.join("app/src/main/jniLibs")
214 );
215 println!(" Step 4: Build Android APK with Gradle");
216 println!(
217 " Command: ./gradlew assemble{}",
218 if profile_name == "release" {
219 "Release"
220 } else {
221 "Debug"
222 }
223 );
224 println!(
225 " Output: {:?}",
226 android_dir.join(format!(
227 "app/build/outputs/apk/{}/app-{}.apk",
228 profile_name, profile_name
229 ))
230 );
231 println!(" Step 5: Build Android test APK");
232 println!(
233 " Command: ./gradlew assemble{}AndroidTest",
234 if profile_name == "release" {
235 "Release"
236 } else {
237 "Debug"
238 }
239 );
240
241 return Ok(BuildResult {
243 platform: Target::Android,
244 app_path: android_dir.join(format!(
245 "app/build/outputs/apk/{}/app-{}.apk",
246 profile_name, profile_name
247 )),
248 test_suite_path: Some(android_dir.join(format!(
249 "app/build/outputs/apk/androidTest/{}/app-{}-androidTest.apk",
250 profile_name, profile_name
251 ))),
252 });
253 }
254
255 crate::codegen::ensure_android_project_with_options(
258 &self.output_dir,
259 &self.crate_name,
260 Some(&self.project_root),
261 self.crate_dir.as_deref(),
262 )?;
263
264 self.ensure_gradle_wrapper(&android_dir)?;
266
267 println!("Building Rust libraries for Android...");
269 self.build_rust_libraries(config)?;
270
271 println!("Generating UniFFI Kotlin bindings...");
273 self.generate_uniffi_bindings()?;
274
275 println!("Copying native libraries to jniLibs...");
277 self.copy_native_libraries(config)?;
278
279 println!("Building Android APK with Gradle...");
281 let apk_path = self.build_apk(config)?;
282
283 println!("Building Android test APK...");
285 let test_suite_path = self.build_test_apk(config)?;
286
287 let result = BuildResult {
289 platform: Target::Android,
290 app_path: apk_path,
291 test_suite_path: Some(test_suite_path),
292 };
293 self.validate_build_artifacts(&result, config)?;
294
295 Ok(result)
296 }
297
298 fn validate_build_artifacts(
300 &self,
301 result: &BuildResult,
302 config: &BuildConfig,
303 ) -> Result<(), BenchError> {
304 let mut missing = Vec::new();
305 let profile_dir = match config.profile {
306 BuildProfile::Debug => "debug",
307 BuildProfile::Release => "release",
308 };
309
310 if !result.app_path.exists() {
312 missing.push(format!("Main APK: {}", result.app_path.display()));
313 }
314
315 if let Some(ref test_path) = result.test_suite_path {
317 if !test_path.exists() {
318 missing.push(format!("Test APK: {}", test_path.display()));
319 }
320 }
321
322 let jni_libs_dir = self.output_dir.join("android/app/src/main/jniLibs");
324 let lib_name = format!("lib{}.so", self.crate_name.replace("-", "_"));
325 let required_abis = ["arm64-v8a", "armeabi-v7a", "x86_64"];
326 let mut found_libs = 0;
327 for abi in &required_abis {
328 let lib_path = jni_libs_dir.join(abi).join(&lib_name);
329 if lib_path.exists() {
330 found_libs += 1;
331 } else {
332 missing.push(format!(
333 "Native library ({} {}): {}",
334 abi,
335 profile_dir,
336 lib_path.display()
337 ));
338 }
339 }
340
341 if found_libs == 0 {
342 return Err(BenchError::Build(format!(
343 "Build validation failed: No native libraries found.\n\n\
344 Expected at least one .so file in jniLibs directories.\n\
345 Missing artifacts:\n{}\n\n\
346 This usually means the Rust build step failed. Check the cargo-ndk output above.",
347 missing
348 .iter()
349 .map(|s| format!(" - {}", s))
350 .collect::<Vec<_>>()
351 .join("\n")
352 )));
353 }
354
355 if !missing.is_empty() {
356 eprintln!(
357 "Warning: Some build artifacts are missing:\n{}\n\
358 The build may still work but some features might be unavailable.",
359 missing
360 .iter()
361 .map(|s| format!(" - {}", s))
362 .collect::<Vec<_>>()
363 .join("\n")
364 );
365 }
366
367 Ok(())
368 }
369
370 fn find_crate_dir(&self) -> Result<PathBuf, BenchError> {
378 if let Some(ref dir) = self.crate_dir {
380 if dir.exists() {
381 return Ok(dir.clone());
382 }
383 return Err(BenchError::Build(format!(
384 "Specified crate path does not exist: {:?}.\n\n\
385 Tip: pass --crate-path pointing at a directory containing Cargo.toml.",
386 dir
387 )));
388 }
389
390 let root_cargo_toml = self.project_root.join("Cargo.toml");
393 if root_cargo_toml.exists() {
394 if let Some(pkg_name) = super::common::read_package_name(&root_cargo_toml) {
395 if pkg_name == self.crate_name {
396 return Ok(self.project_root.clone());
397 }
398 }
399 }
400
401 let bench_mobile_dir = self.project_root.join("bench-mobile");
403 if bench_mobile_dir.exists() {
404 return Ok(bench_mobile_dir);
405 }
406
407 let crates_dir = self.project_root.join("crates").join(&self.crate_name);
409 if crates_dir.exists() {
410 return Ok(crates_dir);
411 }
412
413 let named_dir = self.project_root.join(&self.crate_name);
415 if named_dir.exists() {
416 return Ok(named_dir);
417 }
418
419 let root_manifest = root_cargo_toml;
420 let bench_mobile_manifest = bench_mobile_dir.join("Cargo.toml");
421 let crates_manifest = crates_dir.join("Cargo.toml");
422 let named_manifest = named_dir.join("Cargo.toml");
423 Err(BenchError::Build(format!(
424 "Benchmark crate '{}' not found.\n\n\
425 Searched locations:\n\
426 - {} (checked [package] name)\n\
427 - {}\n\
428 - {}\n\
429 - {}\n\n\
430 To fix this:\n\
431 1. Run from the crate directory (where Cargo.toml has name = \"{}\")\n\
432 2. Create a bench-mobile/ directory with your benchmark crate, or\n\
433 3. Use --crate-path to specify the benchmark crate location:\n\
434 cargo mobench build --target android --crate-path ./my-benchmarks\n\n\
435 Common issues:\n\
436 - Typo in crate name (check Cargo.toml [package] name)\n\
437 - Wrong working directory (run from project root)\n\
438 - Missing Cargo.toml in the crate directory\n\n\
439 Run 'cargo mobench init --help' to generate a new benchmark project.",
440 self.crate_name,
441 root_manifest.display(),
442 bench_mobile_manifest.display(),
443 crates_manifest.display(),
444 named_manifest.display(),
445 self.crate_name,
446 )))
447 }
448
449 fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
451 let crate_dir = self.find_crate_dir()?;
452
453 self.check_cargo_ndk()?;
455
456 let abis = vec!["arm64-v8a", "armeabi-v7a", "x86_64"];
458 let release_flag = if matches!(config.profile, BuildProfile::Release) {
459 "--release"
460 } else {
461 ""
462 };
463
464 for abi in abis {
465 if self.verbose {
466 println!(" Building for {}", abi);
467 }
468
469 let mut cmd = Command::new("cargo");
470 cmd.arg("ndk")
471 .arg("--target")
472 .arg(abi)
473 .arg("--platform")
474 .arg("24") .arg("build");
476
477 if !release_flag.is_empty() {
479 cmd.arg(release_flag);
480 }
481
482 cmd.current_dir(&crate_dir);
484
485 let command_hint = if release_flag.is_empty() {
487 format!("cargo ndk --target {} --platform 24 build", abi)
488 } else {
489 format!(
490 "cargo ndk --target {} --platform 24 build {}",
491 abi, release_flag
492 )
493 };
494 let output = cmd.output().map_err(|e| {
495 BenchError::Build(format!(
496 "Failed to start cargo-ndk for {}.\n\n\
497 Command: {}\n\
498 Crate directory: {}\n\
499 System error: {}\n\n\
500 Tips:\n\
501 - Install cargo-ndk: cargo install cargo-ndk\n\
502 - Ensure cargo is on PATH",
503 abi,
504 command_hint,
505 crate_dir.display(),
506 e
507 ))
508 })?;
509
510 if !output.status.success() {
511 let stdout = String::from_utf8_lossy(&output.stdout);
512 let stderr = String::from_utf8_lossy(&output.stderr);
513 let profile = if matches!(config.profile, BuildProfile::Release) {
514 "release"
515 } else {
516 "debug"
517 };
518 let rust_target = match abi {
519 "arm64-v8a" => "aarch64-linux-android",
520 "armeabi-v7a" => "armv7-linux-androideabi",
521 "x86_64" => "x86_64-linux-android",
522 _ => abi,
523 };
524 return Err(BenchError::Build(format!(
525 "cargo-ndk build failed for {} ({} profile).\n\n\
526 Command: {}\n\
527 Crate directory: {}\n\
528 Exit status: {}\n\n\
529 Stdout:\n{}\n\n\
530 Stderr:\n{}\n\n\
531 Common causes:\n\
532 - Missing Rust target: rustup target add {}\n\
533 - NDK not found: set ANDROID_NDK_HOME\n\
534 - Compilation error in Rust code (see output above)\n\
535 - Incompatible native dependencies (some C libraries do not support Android)",
536 abi,
537 profile,
538 command_hint,
539 crate_dir.display(),
540 output.status,
541 stdout,
542 stderr,
543 rust_target,
544 )));
545 }
546 }
547
548 Ok(())
549 }
550
551 fn check_cargo_ndk(&self) -> Result<(), BenchError> {
553 let output = Command::new("cargo").arg("ndk").arg("--version").output();
554
555 match output {
556 Ok(output) if output.status.success() => Ok(()),
557 _ => Err(BenchError::Build(
558 "cargo-ndk is not installed or not in PATH.\n\n\
559 cargo-ndk is required to cross-compile Rust for Android.\n\n\
560 To install:\n\
561 cargo install cargo-ndk\n\
562 Verify with:\n\
563 cargo ndk --version\n\n\
564 You also need the Android NDK. Set ANDROID_NDK_HOME or install via Android Studio.\n\
565 See: https://github.com/nickelc/cargo-ndk"
566 .to_string(),
567 )),
568 }
569 }
570
571 fn generate_uniffi_bindings(&self) -> Result<(), BenchError> {
573 let crate_dir = self.find_crate_dir()?;
574 let crate_name_underscored = self.crate_name.replace("-", "_");
575
576 let bindings_path = self
578 .output_dir
579 .join("android")
580 .join("app")
581 .join("src")
582 .join("main")
583 .join("java")
584 .join("uniffi")
585 .join(&crate_name_underscored)
586 .join(format!("{}.kt", crate_name_underscored));
587
588 if bindings_path.exists() {
589 if self.verbose {
590 println!(" Using existing Kotlin bindings at {:?}", bindings_path);
591 }
592 return Ok(());
593 }
594
595 let mut build_cmd = Command::new("cargo");
597 build_cmd.arg("build");
598 build_cmd.current_dir(&crate_dir);
599 run_command(build_cmd, "cargo build (host)")?;
600
601 let lib_path = host_lib_path(&crate_dir, &self.crate_name)?;
602 let out_dir = self
603 .output_dir
604 .join("android")
605 .join("app")
606 .join("src")
607 .join("main")
608 .join("java");
609
610 let cargo_run_result = Command::new("cargo")
612 .args([
613 "run",
614 "-p",
615 &self.crate_name,
616 "--bin",
617 "uniffi-bindgen",
618 "--",
619 ])
620 .arg("generate")
621 .arg("--library")
622 .arg(&lib_path)
623 .arg("--language")
624 .arg("kotlin")
625 .arg("--out-dir")
626 .arg(&out_dir)
627 .current_dir(&crate_dir)
628 .output();
629
630 let use_cargo_run = cargo_run_result
631 .as_ref()
632 .map(|o| o.status.success())
633 .unwrap_or(false);
634
635 if use_cargo_run {
636 if self.verbose {
637 println!(" Generated bindings using cargo run uniffi-bindgen");
638 }
639 } else {
640 let uniffi_available = Command::new("uniffi-bindgen")
642 .arg("--version")
643 .output()
644 .map(|o| o.status.success())
645 .unwrap_or(false);
646
647 if !uniffi_available {
648 return Err(BenchError::Build(
649 "uniffi-bindgen not found and no pre-generated bindings exist.\n\n\
650 To fix this, either:\n\
651 1. Add a uniffi-bindgen binary to your crate:\n\
652 [[bin]]\n\
653 name = \"uniffi-bindgen\"\n\
654 path = \"src/bin/uniffi-bindgen.rs\"\n\n\
655 2. Or install uniffi-bindgen globally:\n\
656 cargo install uniffi-bindgen\n\n\
657 3. Or pre-generate bindings and commit them."
658 .to_string(),
659 ));
660 }
661
662 let mut cmd = Command::new("uniffi-bindgen");
663 cmd.arg("generate")
664 .arg("--library")
665 .arg(&lib_path)
666 .arg("--language")
667 .arg("kotlin")
668 .arg("--out-dir")
669 .arg(&out_dir);
670 run_command(cmd, "uniffi-bindgen kotlin")?;
671 }
672
673 if self.verbose {
674 println!(" Generated UniFFI Kotlin bindings at {:?}", out_dir);
675 }
676 Ok(())
677 }
678
679 fn copy_native_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
681 let crate_dir = self.find_crate_dir()?;
682 let profile_dir = match config.profile {
683 BuildProfile::Debug => "debug",
684 BuildProfile::Release => "release",
685 };
686
687 let target_dir = get_cargo_target_dir(&crate_dir)?;
689 let jni_libs_dir = self.output_dir.join("android/app/src/main/jniLibs");
690
691 std::fs::create_dir_all(&jni_libs_dir).map_err(|e| {
693 BenchError::Build(format!(
694 "Failed to create jniLibs directory at {}: {}. Check output directory permissions.",
695 jni_libs_dir.display(),
696 e
697 ))
698 })?;
699
700 let abi_mappings = vec![
702 ("aarch64-linux-android", "arm64-v8a"),
703 ("armv7-linux-androideabi", "armeabi-v7a"),
704 ("x86_64-linux-android", "x86_64"),
705 ];
706
707 for (rust_target, android_abi) in abi_mappings {
708 let src = target_dir
709 .join(rust_target)
710 .join(profile_dir)
711 .join(format!("lib{}.so", self.crate_name.replace("-", "_")));
712
713 let dest_dir = jni_libs_dir.join(android_abi);
714 std::fs::create_dir_all(&dest_dir).map_err(|e| {
715 BenchError::Build(format!(
716 "Failed to create ABI directory {} at {}: {}. Check output directory permissions.",
717 android_abi,
718 dest_dir.display(),
719 e
720 ))
721 })?;
722
723 let dest = dest_dir.join(format!("lib{}.so", self.crate_name.replace("-", "_")));
724
725 if src.exists() {
726 std::fs::copy(&src, &dest).map_err(|e| {
727 BenchError::Build(format!(
728 "Failed to copy {} library from {} to {}: {}. Ensure cargo-ndk completed successfully.",
729 android_abi,
730 src.display(),
731 dest.display(),
732 e
733 ))
734 })?;
735
736 if self.verbose {
737 println!(" Copied {} -> {}", src.display(), dest.display());
738 }
739 } else {
740 eprintln!(
742 "Warning: Native library for {} not found at {}.\n\
743 This will cause a runtime crash when the app tries to load the library.\n\
744 Ensure cargo-ndk build completed successfully for this ABI.",
745 android_abi,
746 src.display()
747 );
748 }
749 }
750
751 Ok(())
752 }
753
754 fn ensure_local_properties(&self, android_dir: &Path) -> Result<(), BenchError> {
765 let local_props = android_dir.join("local.properties");
766
767 if local_props.exists() {
769 return Ok(());
770 }
771
772 let sdk_dir = self.find_android_sdk_from_env();
775
776 match sdk_dir {
777 Some(path) => {
778 let content = format!("sdk.dir={}\n", path.display());
780 fs::write(&local_props, content).map_err(|e| {
781 BenchError::Build(format!(
782 "Failed to write local.properties at {:?}: {}. Check output directory permissions.",
783 local_props, e
784 ))
785 })?;
786
787 if self.verbose {
788 println!(
789 " Generated local.properties with sdk.dir={}",
790 path.display()
791 );
792 }
793 }
794 None => {
795 if self.verbose {
798 println!(
799 " Skipping local.properties generation (ANDROID_HOME/ANDROID_SDK_ROOT not set)"
800 );
801 println!(
802 " Gradle will auto-detect SDK or you can create local.properties manually"
803 );
804 }
805 }
806 }
807
808 Ok(())
809 }
810
811 fn find_android_sdk_from_env(&self) -> Option<PathBuf> {
819 if let Ok(path) = env::var("ANDROID_HOME") {
821 let sdk_path = PathBuf::from(&path);
822 if sdk_path.exists() {
823 return Some(sdk_path);
824 }
825 }
826
827 if let Ok(path) = env::var("ANDROID_SDK_ROOT") {
829 let sdk_path = PathBuf::from(&path);
830 if sdk_path.exists() {
831 return Some(sdk_path);
832 }
833 }
834
835 None
836 }
837
838 fn ensure_gradle_wrapper(&self, android_dir: &Path) -> Result<(), BenchError> {
843 let gradlew = android_dir.join("gradlew");
844
845 if gradlew.exists() {
847 return Ok(());
848 }
849
850 println!("Gradle wrapper not found, generating...");
851
852 let gradle_available = Command::new("gradle")
854 .arg("--version")
855 .output()
856 .map(|o| o.status.success())
857 .unwrap_or(false);
858
859 if !gradle_available {
860 return Err(BenchError::Build(
861 "Gradle wrapper (gradlew) not found and 'gradle' command is not available.\n\n\
862 The Android project requires Gradle to build. You have two options:\n\n\
863 1. Install Gradle globally and run the build again (it will auto-generate the wrapper):\n\
864 - macOS: brew install gradle\n\
865 - Linux: sudo apt install gradle\n\
866 - Or download from https://gradle.org/install/\n\n\
867 2. Or generate the wrapper manually in the Android project directory:\n\
868 cd target/mobench/android && gradle wrapper --gradle-version 8.5"
869 .to_string(),
870 ));
871 }
872
873 let mut cmd = Command::new("gradle");
875 cmd.arg("wrapper")
876 .arg("--gradle-version")
877 .arg("8.5")
878 .current_dir(android_dir);
879
880 let output = cmd.output().map_err(|e| {
881 BenchError::Build(format!(
882 "Failed to run 'gradle wrapper' command: {}\n\n\
883 Ensure Gradle is installed and on your PATH.",
884 e
885 ))
886 })?;
887
888 if !output.status.success() {
889 let stderr = String::from_utf8_lossy(&output.stderr);
890 return Err(BenchError::Build(format!(
891 "Failed to generate Gradle wrapper.\n\n\
892 Command: gradle wrapper --gradle-version 8.5\n\
893 Working directory: {}\n\
894 Exit status: {}\n\
895 Stderr: {}\n\n\
896 Try running this command manually in the Android project directory.",
897 android_dir.display(),
898 output.status,
899 stderr
900 )));
901 }
902
903 #[cfg(unix)]
905 {
906 use std::os::unix::fs::PermissionsExt;
907 if let Ok(metadata) = fs::metadata(&gradlew) {
908 let mut perms = metadata.permissions();
909 perms.set_mode(0o755);
910 let _ = fs::set_permissions(&gradlew, perms);
911 }
912 }
913
914 if self.verbose {
915 println!(" Generated Gradle wrapper at {:?}", gradlew);
916 }
917
918 Ok(())
919 }
920
921 fn build_apk(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
923 let android_dir = self.output_dir.join("android");
924
925 if !android_dir.exists() {
926 return Err(BenchError::Build(format!(
927 "Android project not found at {}.\n\n\
928 Expected a Gradle project under the output directory.\n\
929 Run `cargo mobench init --target android` or `cargo mobench build --target android` from the project root to generate it.",
930 android_dir.display()
931 )));
932 }
933
934 self.ensure_local_properties(&android_dir)?;
936
937 let gradle_task = match config.profile {
939 BuildProfile::Debug => "assembleDebug",
940 BuildProfile::Release => "assembleRelease",
941 };
942
943 let mut cmd = Command::new("./gradlew");
945 cmd.arg(gradle_task).current_dir(&android_dir);
946
947 if self.verbose {
948 cmd.arg("--info");
949 }
950
951 let output = cmd.output().map_err(|e| {
952 BenchError::Build(format!(
953 "Failed to run Gradle wrapper.\n\n\
954 Command: ./gradlew {}\n\
955 Working directory: {}\n\
956 Error: {}\n\n\
957 Tips:\n\
958 - Ensure ./gradlew is executable (chmod +x ./gradlew)\n\
959 - Run ./gradlew --version in that directory to verify the wrapper",
960 gradle_task,
961 android_dir.display(),
962 e
963 ))
964 })?;
965
966 if !output.status.success() {
967 let stdout = String::from_utf8_lossy(&output.stdout);
968 let stderr = String::from_utf8_lossy(&output.stderr);
969 return Err(BenchError::Build(format!(
970 "Gradle build failed.\n\n\
971 Command: ./gradlew {}\n\
972 Working directory: {}\n\
973 Exit status: {}\n\n\
974 Stdout:\n{}\n\n\
975 Stderr:\n{}\n\n\
976 Tips:\n\
977 - Re-run with verbose mode to pass --info to Gradle\n\
978 - Run ./gradlew {} --stacktrace for a full stack trace",
979 gradle_task,
980 android_dir.display(),
981 output.status,
982 stdout,
983 stderr,
984 gradle_task,
985 )));
986 }
987
988 let profile_name = match config.profile {
990 BuildProfile::Debug => "debug",
991 BuildProfile::Release => "release",
992 };
993
994 let apk_dir = android_dir.join("app/build/outputs/apk").join(profile_name);
995
996 let apk_path = self.find_apk(&apk_dir, profile_name, gradle_task)?;
1002
1003 Ok(apk_path)
1004 }
1005
1006 fn find_apk(
1016 &self,
1017 apk_dir: &Path,
1018 profile_name: &str,
1019 gradle_task: &str,
1020 ) -> Result<PathBuf, BenchError> {
1021 let metadata_path = apk_dir.join("output-metadata.json");
1023 if metadata_path.exists() {
1024 if let Ok(metadata_content) = fs::read_to_string(&metadata_path) {
1025 if let Some(apk_name) = self.parse_output_metadata(&metadata_content) {
1028 let apk_path = apk_dir.join(&apk_name);
1029 if apk_path.exists() {
1030 if self.verbose {
1031 println!(
1032 " Found APK from output-metadata.json: {}",
1033 apk_path.display()
1034 );
1035 }
1036 return Ok(apk_path);
1037 }
1038 }
1039 }
1040 }
1041
1042 let candidates = if profile_name == "release" {
1044 vec![
1045 format!("app-{}.apk", profile_name), format!("app-{}-unsigned.apk", profile_name), ]
1048 } else {
1049 vec![
1050 format!("app-{}.apk", profile_name), ]
1052 };
1053
1054 for candidate in &candidates {
1056 let apk_path = apk_dir.join(candidate);
1057 if apk_path.exists() {
1058 if self.verbose {
1059 println!(" Found APK: {}", apk_path.display());
1060 }
1061 return Ok(apk_path);
1062 }
1063 }
1064
1065 Err(BenchError::Build(format!(
1067 "APK not found in {}.\n\n\
1068 Gradle task {} reported success but no APK was produced.\n\
1069 Searched for:\n{}\n\n\
1070 Check the build output directory and rerun ./gradlew {} if needed.",
1071 apk_dir.display(),
1072 gradle_task,
1073 candidates
1074 .iter()
1075 .map(|c| format!(" - {}", c))
1076 .collect::<Vec<_>>()
1077 .join("\n"),
1078 gradle_task
1079 )))
1080 }
1081
1082 fn parse_output_metadata(&self, content: &str) -> Option<String> {
1096 let pattern = "\"outputFile\"";
1099 if let Some(pos) = content.find(pattern) {
1100 let after_key = &content[pos + pattern.len()..];
1101 let after_colon = after_key.trim_start().strip_prefix(':')?;
1103 let after_ws = after_colon.trim_start();
1104 if after_ws.starts_with('"') {
1106 let value_start = &after_ws[1..];
1107 if let Some(end_quote) = value_start.find('"') {
1108 let filename = &value_start[..end_quote];
1109 if filename.ends_with(".apk") {
1110 return Some(filename.to_string());
1111 }
1112 }
1113 }
1114 }
1115 None
1116 }
1117
1118 fn build_test_apk(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
1120 let android_dir = self.output_dir.join("android");
1121
1122 if !android_dir.exists() {
1123 return Err(BenchError::Build(format!(
1124 "Android project not found at {}.\n\n\
1125 Expected a Gradle project under the output directory.\n\
1126 Run `cargo mobench init --target android` or `cargo mobench build --target android` from the project root to generate it.",
1127 android_dir.display()
1128 )));
1129 }
1130
1131 let gradle_task = match config.profile {
1132 BuildProfile::Debug => "assembleDebugAndroidTest",
1133 BuildProfile::Release => "assembleReleaseAndroidTest",
1134 };
1135
1136 let mut cmd = Command::new("./gradlew");
1137 cmd.arg(gradle_task).current_dir(&android_dir);
1138
1139 if self.verbose {
1140 cmd.arg("--info");
1141 }
1142
1143 let output = cmd.output().map_err(|e| {
1144 BenchError::Build(format!(
1145 "Failed to run Gradle wrapper.\n\n\
1146 Command: ./gradlew {}\n\
1147 Working directory: {}\n\
1148 Error: {}\n\n\
1149 Tips:\n\
1150 - Ensure ./gradlew is executable (chmod +x ./gradlew)\n\
1151 - Run ./gradlew --version in that directory to verify the wrapper",
1152 gradle_task,
1153 android_dir.display(),
1154 e
1155 ))
1156 })?;
1157
1158 if !output.status.success() {
1159 let stdout = String::from_utf8_lossy(&output.stdout);
1160 let stderr = String::from_utf8_lossy(&output.stderr);
1161 return Err(BenchError::Build(format!(
1162 "Gradle test APK build failed.\n\n\
1163 Command: ./gradlew {}\n\
1164 Working directory: {}\n\
1165 Exit status: {}\n\n\
1166 Stdout:\n{}\n\n\
1167 Stderr:\n{}\n\n\
1168 Tips:\n\
1169 - Re-run with verbose mode to pass --info to Gradle\n\
1170 - Run ./gradlew {} --stacktrace for a full stack trace",
1171 gradle_task,
1172 android_dir.display(),
1173 output.status,
1174 stdout,
1175 stderr,
1176 gradle_task,
1177 )));
1178 }
1179
1180 let profile_name = match config.profile {
1181 BuildProfile::Debug => "debug",
1182 BuildProfile::Release => "release",
1183 };
1184
1185 let test_apk_dir = android_dir
1186 .join("app/build/outputs/apk/androidTest")
1187 .join(profile_name);
1188
1189 let apk_path = self.find_test_apk(&test_apk_dir, profile_name, gradle_task)?;
1191
1192 Ok(apk_path)
1193 }
1194
1195 fn find_test_apk(
1201 &self,
1202 apk_dir: &Path,
1203 profile_name: &str,
1204 gradle_task: &str,
1205 ) -> Result<PathBuf, BenchError> {
1206 let metadata_path = apk_dir.join("output-metadata.json");
1208 if metadata_path.exists() {
1209 if let Ok(metadata_content) = fs::read_to_string(&metadata_path) {
1210 if let Some(apk_name) = self.parse_output_metadata(&metadata_content) {
1211 let apk_path = apk_dir.join(&apk_name);
1212 if apk_path.exists() {
1213 if self.verbose {
1214 println!(
1215 " Found test APK from output-metadata.json: {}",
1216 apk_path.display()
1217 );
1218 }
1219 return Ok(apk_path);
1220 }
1221 }
1222 }
1223 }
1224
1225 let apk_path = apk_dir.join(format!("app-{}-androidTest.apk", profile_name));
1227 if apk_path.exists() {
1228 if self.verbose {
1229 println!(" Found test APK: {}", apk_path.display());
1230 }
1231 return Ok(apk_path);
1232 }
1233
1234 Err(BenchError::Build(format!(
1236 "Android test APK not found in {}.\n\n\
1237 Gradle task {} reported success but no test APK was produced.\n\
1238 Expected: app-{}-androidTest.apk\n\n\
1239 Check app/build/outputs/apk/androidTest/{} and rerun ./gradlew {} if needed.",
1240 apk_dir.display(),
1241 gradle_task,
1242 profile_name,
1243 profile_name,
1244 gradle_task
1245 )))
1246 }
1247}
1248
1249#[cfg(test)]
1250mod tests {
1251 use super::*;
1252
1253 #[test]
1254 fn test_android_builder_creation() {
1255 let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1256 assert!(!builder.verbose);
1257 assert_eq!(
1258 builder.output_dir,
1259 PathBuf::from("/tmp/test-project/target/mobench")
1260 );
1261 }
1262
1263 #[test]
1264 fn test_android_builder_verbose() {
1265 let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile").verbose(true);
1266 assert!(builder.verbose);
1267 }
1268
1269 #[test]
1270 fn test_android_builder_custom_output_dir() {
1271 let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile")
1272 .output_dir("/custom/output");
1273 assert_eq!(builder.output_dir, PathBuf::from("/custom/output"));
1274 }
1275
1276 #[test]
1277 fn test_parse_output_metadata_unsigned() {
1278 let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1279 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"}"#;
1280 let result = builder.parse_output_metadata(metadata);
1281 assert_eq!(result, Some("app-release-unsigned.apk".to_string()));
1282 }
1283
1284 #[test]
1285 fn test_parse_output_metadata_signed() {
1286 let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1287 let metadata = r#"{"version":3,"elements":[{"outputFile":"app-release.apk"}]}"#;
1288 let result = builder.parse_output_metadata(metadata);
1289 assert_eq!(result, Some("app-release.apk".to_string()));
1290 }
1291
1292 #[test]
1293 fn test_parse_output_metadata_no_apk() {
1294 let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1295 let metadata = r#"{"version":3,"elements":[]}"#;
1296 let result = builder.parse_output_metadata(metadata);
1297 assert_eq!(result, None);
1298 }
1299
1300 #[test]
1301 fn test_parse_output_metadata_invalid_json() {
1302 let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
1303 let metadata = "not valid json";
1304 let result = builder.parse_output_metadata(metadata);
1305 assert_eq!(result, None);
1306 }
1307
1308 #[test]
1309 fn test_find_crate_dir_current_directory_is_crate() {
1310 let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-current");
1312 let _ = std::fs::remove_dir_all(&temp_dir);
1313 std::fs::create_dir_all(&temp_dir).unwrap();
1314
1315 std::fs::write(
1317 temp_dir.join("Cargo.toml"),
1318 r#"[package]
1319name = "bench-mobile"
1320version = "0.1.0"
1321"#,
1322 )
1323 .unwrap();
1324
1325 let builder = AndroidBuilder::new(&temp_dir, "bench-mobile");
1326 let result = builder.find_crate_dir();
1327 assert!(result.is_ok(), "Should find crate in current directory");
1328 assert_eq!(result.unwrap(), temp_dir);
1329
1330 std::fs::remove_dir_all(&temp_dir).unwrap();
1331 }
1332
1333 #[test]
1334 fn test_find_crate_dir_nested_bench_mobile() {
1335 let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-nested");
1337 let _ = std::fs::remove_dir_all(&temp_dir);
1338 std::fs::create_dir_all(temp_dir.join("bench-mobile")).unwrap();
1339
1340 std::fs::write(
1342 temp_dir.join("Cargo.toml"),
1343 r#"[workspace]
1344members = ["bench-mobile"]
1345"#,
1346 )
1347 .unwrap();
1348
1349 std::fs::write(
1351 temp_dir.join("bench-mobile/Cargo.toml"),
1352 r#"[package]
1353name = "bench-mobile"
1354version = "0.1.0"
1355"#,
1356 )
1357 .unwrap();
1358
1359 let builder = AndroidBuilder::new(&temp_dir, "bench-mobile");
1360 let result = builder.find_crate_dir();
1361 assert!(
1362 result.is_ok(),
1363 "Should find crate in bench-mobile/ directory"
1364 );
1365 assert_eq!(result.unwrap(), temp_dir.join("bench-mobile"));
1366
1367 std::fs::remove_dir_all(&temp_dir).unwrap();
1368 }
1369
1370 #[test]
1371 fn test_find_crate_dir_crates_subdir() {
1372 let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-crates");
1374 let _ = std::fs::remove_dir_all(&temp_dir);
1375 std::fs::create_dir_all(temp_dir.join("crates/my-bench")).unwrap();
1376
1377 std::fs::write(
1379 temp_dir.join("Cargo.toml"),
1380 r#"[workspace]
1381members = ["crates/*"]
1382"#,
1383 )
1384 .unwrap();
1385
1386 std::fs::write(
1388 temp_dir.join("crates/my-bench/Cargo.toml"),
1389 r#"[package]
1390name = "my-bench"
1391version = "0.1.0"
1392"#,
1393 )
1394 .unwrap();
1395
1396 let builder = AndroidBuilder::new(&temp_dir, "my-bench");
1397 let result = builder.find_crate_dir();
1398 assert!(result.is_ok(), "Should find crate in crates/ directory");
1399 assert_eq!(result.unwrap(), temp_dir.join("crates/my-bench"));
1400
1401 std::fs::remove_dir_all(&temp_dir).unwrap();
1402 }
1403
1404 #[test]
1405 fn test_find_crate_dir_not_found() {
1406 let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-notfound");
1408 let _ = std::fs::remove_dir_all(&temp_dir);
1409 std::fs::create_dir_all(&temp_dir).unwrap();
1410
1411 std::fs::write(
1413 temp_dir.join("Cargo.toml"),
1414 r#"[package]
1415name = "some-other-crate"
1416version = "0.1.0"
1417"#,
1418 )
1419 .unwrap();
1420
1421 let builder = AndroidBuilder::new(&temp_dir, "nonexistent-crate");
1422 let result = builder.find_crate_dir();
1423 assert!(result.is_err(), "Should fail to find nonexistent crate");
1424 let err_msg = result.unwrap_err().to_string();
1425 assert!(err_msg.contains("Benchmark crate 'nonexistent-crate' not found"));
1426 assert!(err_msg.contains("Searched locations"));
1427
1428 std::fs::remove_dir_all(&temp_dir).unwrap();
1429 }
1430
1431 #[test]
1432 fn test_find_crate_dir_explicit_crate_path() {
1433 let temp_dir = std::env::temp_dir().join("mobench-test-find-crate-explicit");
1435 let _ = std::fs::remove_dir_all(&temp_dir);
1436 std::fs::create_dir_all(temp_dir.join("custom-location")).unwrap();
1437
1438 let builder =
1439 AndroidBuilder::new(&temp_dir, "any-name").crate_dir(temp_dir.join("custom-location"));
1440 let result = builder.find_crate_dir();
1441 assert!(result.is_ok(), "Should use explicit crate_dir");
1442 assert_eq!(result.unwrap(), temp_dir.join("custom-location"));
1443
1444 std::fs::remove_dir_all(&temp_dir).unwrap();
1445 }
1446}