mobench_sdk/builders/
android.rs

1//! Android build automation
2//!
3//! This module provides functionality to build Rust libraries for Android and
4//! package them into an APK using Gradle.
5
6use crate::types::{BenchError, BuildConfig, BuildProfile, BuildResult, Target};
7use std::env;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11/// Android builder that handles the complete build pipeline
12pub struct AndroidBuilder {
13    /// Root directory of the project
14    project_root: PathBuf,
15    /// Output directory for mobile artifacts (defaults to target/mobench)
16    output_dir: PathBuf,
17    /// Name of the bench-mobile crate
18    crate_name: String,
19    /// Whether to use verbose output
20    verbose: bool,
21}
22
23impl AndroidBuilder {
24    /// Creates a new Android builder
25    ///
26    /// # Arguments
27    ///
28    /// * `project_root` - Root directory containing the bench-mobile crate
29    /// * `crate_name` - Name of the bench-mobile crate (e.g., "my-project-bench-mobile")
30    pub fn new(project_root: impl Into<PathBuf>, crate_name: impl Into<String>) -> Self {
31        let root = project_root.into();
32        Self {
33            output_dir: root.join("target/mobench"),
34            project_root: root,
35            crate_name: crate_name.into(),
36            verbose: false,
37        }
38    }
39
40    /// Sets the output directory for mobile artifacts
41    ///
42    /// By default, artifacts are written to `{project_root}/target/mobench/`.
43    /// Use this to customize the output location.
44    pub fn output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
45        self.output_dir = dir.into();
46        self
47    }
48
49    /// Enables verbose output
50    pub fn verbose(mut self, verbose: bool) -> Self {
51        self.verbose = verbose;
52        self
53    }
54
55    /// Builds the Android app with the given configuration
56    ///
57    /// This performs the following steps:
58    /// 1. Build Rust libraries for Android ABIs using cargo-ndk
59    /// 2. Generate UniFFI Kotlin bindings
60    /// 3. Copy .so files to jniLibs directories
61    /// 4. Run Gradle to build the APK
62    ///
63    /// # Returns
64    ///
65    /// * `Ok(BuildResult)` containing the path to the built APK
66    /// * `Err(BenchError)` if the build fails
67    pub fn build(&self, config: &BuildConfig) -> Result<BuildResult, BenchError> {
68        // Step 1: Build Rust libraries
69        println!("Building Rust libraries for Android...");
70        self.build_rust_libraries(config)?;
71
72        // Step 2: Generate UniFFI bindings
73        println!("Generating UniFFI Kotlin bindings...");
74        self.generate_uniffi_bindings()?;
75
76        // Step 3: Copy .so files to jniLibs
77        println!("Copying native libraries to jniLibs...");
78        self.copy_native_libraries(config)?;
79
80        // Step 4: Build APK with Gradle
81        println!("Building Android APK with Gradle...");
82        let apk_path = self.build_apk(config)?;
83
84        // Step 5: Build Android test APK for BrowserStack
85        println!("Building Android test APK...");
86        let test_suite_path = self.build_test_apk(config)?;
87
88        Ok(BuildResult {
89            platform: Target::Android,
90            app_path: apk_path,
91            test_suite_path: Some(test_suite_path),
92        })
93    }
94
95    /// Finds the benchmark crate directory (either bench-mobile/ or crates/{crate_name}/)
96    fn find_crate_dir(&self) -> Result<PathBuf, BenchError> {
97        // Try bench-mobile/ first (SDK projects)
98        let bench_mobile_dir = self.project_root.join("bench-mobile");
99        if bench_mobile_dir.exists() {
100            return Ok(bench_mobile_dir);
101        }
102
103        // Try crates/{crate_name}/ (repository structure)
104        let crates_dir = self.project_root.join("crates").join(&self.crate_name);
105        if crates_dir.exists() {
106            return Ok(crates_dir);
107        }
108
109        Err(BenchError::Build(format!(
110            "Benchmark crate '{}' not found. Tried:\n  - {:?}\n  - {:?}",
111            self.crate_name, bench_mobile_dir, crates_dir
112        )))
113    }
114
115    /// Builds Rust libraries for Android using cargo-ndk
116    fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
117        let crate_dir = self.find_crate_dir()?;
118
119        // Check if cargo-ndk is installed
120        self.check_cargo_ndk()?;
121
122        // Android ABIs to build for
123        let abis = vec!["arm64-v8a", "armeabi-v7a", "x86_64"];
124
125        for abi in abis {
126            if self.verbose {
127                println!("  Building for {}", abi);
128            }
129
130            let mut cmd = Command::new("cargo");
131            cmd.arg("ndk")
132                .arg("--target")
133                .arg(abi)
134                .arg("--platform")
135                .arg("24") // minSdk
136                .arg("build");
137
138            // Add release flag if needed
139            if matches!(config.profile, BuildProfile::Release) {
140                cmd.arg("--release");
141            }
142
143            // Set working directory
144            cmd.current_dir(&crate_dir);
145
146            // Execute build
147            let output = cmd
148                .output()
149                .map_err(|e| BenchError::Build(format!("Failed to run cargo-ndk: {}", e)))?;
150
151            if !output.status.success() {
152                let stderr = String::from_utf8_lossy(&output.stderr);
153                return Err(BenchError::Build(format!(
154                    "cargo-ndk build failed for {}: {}",
155                    abi, stderr
156                )));
157            }
158        }
159
160        Ok(())
161    }
162
163    /// Checks if cargo-ndk is installed
164    fn check_cargo_ndk(&self) -> Result<(), BenchError> {
165        let output = Command::new("cargo").arg("ndk").arg("--version").output();
166
167        match output {
168            Ok(output) if output.status.success() => Ok(()),
169            _ => Err(BenchError::Build(
170                "cargo-ndk is not installed. Install it with: cargo install cargo-ndk".to_string(),
171            )),
172        }
173    }
174
175    /// Generates UniFFI Kotlin bindings
176    fn generate_uniffi_bindings(&self) -> Result<(), BenchError> {
177        let crate_dir = self.find_crate_dir()?;
178        let crate_name_underscored = self.crate_name.replace("-", "_");
179
180        // Check if bindings already exist (for repository testing with pre-generated bindings)
181        let bindings_path = self
182            .output_dir
183            .join("android")
184            .join("app")
185            .join("src")
186            .join("main")
187            .join("java")
188            .join("uniffi")
189            .join(&crate_name_underscored)
190            .join(format!("{}.kt", crate_name_underscored));
191
192        if bindings_path.exists() {
193            if self.verbose {
194                println!("  Using existing Kotlin bindings at {:?}", bindings_path);
195            }
196            return Ok(());
197        }
198
199        // Check if uniffi-bindgen is available
200        let uniffi_available = Command::new("uniffi-bindgen")
201            .arg("--version")
202            .output()
203            .map(|o| o.status.success())
204            .unwrap_or(false);
205
206        if !uniffi_available {
207            return Err(BenchError::Build(
208                "uniffi-bindgen not found and no pre-generated bindings exist.\n\
209                 Install it with: cargo install uniffi-bindgen\n\
210                 Or use pre-generated bindings by copying them to the expected location."
211                    .to_string(),
212            ));
213        }
214
215        // Build host library to feed uniffi-bindgen
216        let mut build_cmd = Command::new("cargo");
217        build_cmd.arg("build");
218        build_cmd.current_dir(&crate_dir);
219        run_command(build_cmd, "cargo build (host)")?;
220
221        let lib_path = host_lib_path(&crate_dir, &self.crate_name)?;
222        let out_dir = self
223            .output_dir
224            .join("android")
225            .join("app")
226            .join("src")
227            .join("main")
228            .join("java");
229
230        let mut cmd = Command::new("uniffi-bindgen");
231        cmd.arg("generate")
232            .arg("--library")
233            .arg(&lib_path)
234            .arg("--language")
235            .arg("kotlin")
236            .arg("--out-dir")
237            .arg(&out_dir);
238        run_command(cmd, "uniffi-bindgen kotlin")?;
239
240        if self.verbose {
241            println!("  Generated UniFFI Kotlin bindings at {:?}", out_dir);
242        }
243        Ok(())
244    }
245
246    /// Copies .so files to Android jniLibs directories
247    fn copy_native_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
248        let profile_dir = match config.profile {
249            BuildProfile::Debug => "debug",
250            BuildProfile::Release => "release",
251        };
252
253        let target_dir = self.project_root.join("target");
254        let jni_libs_dir = self.output_dir.join("android/app/src/main/jniLibs");
255
256        // Create jniLibs directories if they don't exist
257        std::fs::create_dir_all(&jni_libs_dir)
258            .map_err(|e| BenchError::Build(format!("Failed to create jniLibs directory: {}", e)))?;
259
260        // Map cargo-ndk ABIs to Android jniLibs ABIs
261        let abi_mappings = vec![
262            ("aarch64-linux-android", "arm64-v8a"),
263            ("armv7-linux-androideabi", "armeabi-v7a"),
264            ("x86_64-linux-android", "x86_64"),
265        ];
266
267        for (rust_target, android_abi) in abi_mappings {
268            let src = target_dir
269                .join(rust_target)
270                .join(profile_dir)
271                .join(format!("lib{}.so", self.crate_name.replace("-", "_")));
272
273            let dest_dir = jni_libs_dir.join(android_abi);
274            std::fs::create_dir_all(&dest_dir).map_err(|e| {
275                BenchError::Build(format!("Failed to create {} directory: {}", android_abi, e))
276            })?;
277
278            let dest = dest_dir.join(format!("lib{}.so", self.crate_name.replace("-", "_")));
279
280            if src.exists() {
281                std::fs::copy(&src, &dest).map_err(|e| {
282                    BenchError::Build(format!("Failed to copy {} library: {}", android_abi, e))
283                })?;
284
285                if self.verbose {
286                    println!("  Copied {} -> {}", src.display(), dest.display());
287                }
288            } else if self.verbose {
289                println!("  Warning: {} not found, skipping", src.display());
290            }
291        }
292
293        Ok(())
294    }
295
296    /// Builds the Android APK using Gradle
297    fn build_apk(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
298        let android_dir = self.output_dir.join("android");
299
300        if !android_dir.exists() {
301            return Err(BenchError::Build(format!(
302                "Android project not found at {:?}",
303                android_dir
304            )));
305        }
306
307        // Determine Gradle task
308        let gradle_task = match config.profile {
309            BuildProfile::Debug => "assembleDebug",
310            BuildProfile::Release => "assembleRelease",
311        };
312
313        // Run Gradle build
314        let mut cmd = Command::new("./gradlew");
315        cmd.arg(gradle_task).current_dir(&android_dir);
316
317        if self.verbose {
318            cmd.arg("--info");
319        }
320
321        let output = cmd
322            .output()
323            .map_err(|e| BenchError::Build(format!("Failed to run Gradle: {}", e)))?;
324
325        if !output.status.success() {
326            let stderr = String::from_utf8_lossy(&output.stderr);
327            return Err(BenchError::Build(format!(
328                "Gradle build failed: {}",
329                stderr
330            )));
331        }
332
333        // Determine APK path
334        let profile_name = match config.profile {
335            BuildProfile::Debug => "debug",
336            BuildProfile::Release => "release",
337        };
338
339        let apk_path = android_dir
340            .join("app/build/outputs/apk")
341            .join(profile_name)
342            .join(format!("app-{}.apk", profile_name));
343
344        if !apk_path.exists() {
345            return Err(BenchError::Build(format!(
346                "APK not found at expected location: {:?}",
347                apk_path
348            )));
349        }
350
351        Ok(apk_path)
352    }
353
354    /// Builds the Android test APK using Gradle
355    fn build_test_apk(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
356        let android_dir = self.output_dir.join("android");
357
358        if !android_dir.exists() {
359            return Err(BenchError::Build(format!(
360                "Android project not found at {:?}",
361                android_dir
362            )));
363        }
364
365        let gradle_task = match config.profile {
366            BuildProfile::Debug => "assembleDebugAndroidTest",
367            BuildProfile::Release => "assembleReleaseAndroidTest",
368        };
369
370        let mut cmd = Command::new("./gradlew");
371        cmd.arg(gradle_task).current_dir(&android_dir);
372
373        if self.verbose {
374            cmd.arg("--info");
375        }
376
377        let output = cmd
378            .output()
379            .map_err(|e| BenchError::Build(format!("Failed to run Gradle: {}", e)))?;
380
381        if !output.status.success() {
382            let stderr = String::from_utf8_lossy(&output.stderr);
383            return Err(BenchError::Build(format!(
384                "Gradle test APK build failed: {}",
385                stderr
386            )));
387        }
388
389        let profile_name = match config.profile {
390            BuildProfile::Debug => "debug",
391            BuildProfile::Release => "release",
392        };
393
394        let apk_path = android_dir
395            .join("app/build/outputs/apk/androidTest")
396            .join(profile_name)
397            .join(format!("app-{}-androidTest.apk", profile_name));
398
399        if !apk_path.exists() {
400            return Err(BenchError::Build(format!(
401                "Android test APK not found at expected location: {:?}",
402                apk_path
403            )));
404        }
405
406        Ok(apk_path)
407    }
408}
409
410// Shared helpers
411fn host_lib_path(project_dir: &Path, crate_name: &str) -> Result<PathBuf, BenchError> {
412    let lib_prefix = if cfg!(target_os = "windows") {
413        ""
414    } else {
415        "lib"
416    };
417    let lib_ext = match env::consts::OS {
418        "macos" => "dylib",
419        "linux" => "so",
420        other => {
421            return Err(BenchError::Build(format!(
422                "unsupported host OS for binding generation: {}",
423                other
424            )));
425        }
426    };
427    let path = project_dir.join("target").join("debug").join(format!(
428        "{}{}.{}",
429        lib_prefix,
430        crate_name.replace('-', "_"),
431        lib_ext
432    ));
433    if !path.exists() {
434        return Err(BenchError::Build(format!(
435            "host library for UniFFI not found at {:?}",
436            path
437        )));
438    }
439    Ok(path)
440}
441
442fn run_command(mut cmd: Command, description: &str) -> Result<(), BenchError> {
443    let output = cmd
444        .output()
445        .map_err(|e| BenchError::Build(format!("Failed to run {}: {}", description, e)))?;
446    if !output.status.success() {
447        let stderr = String::from_utf8_lossy(&output.stderr);
448        return Err(BenchError::Build(format!(
449            "{} failed: {}",
450            description, stderr
451        )));
452    }
453    Ok(())
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    #[test]
461    fn test_android_builder_creation() {
462        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
463        assert!(!builder.verbose);
464        assert_eq!(
465            builder.output_dir,
466            PathBuf::from("/tmp/test-project/target/mobench")
467        );
468    }
469
470    #[test]
471    fn test_android_builder_verbose() {
472        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile").verbose(true);
473        assert!(builder.verbose);
474    }
475
476    #[test]
477    fn test_android_builder_custom_output_dir() {
478        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile")
479            .output_dir("/custom/output");
480        assert_eq!(builder.output_dir, PathBuf::from("/custom/output"));
481    }
482}