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