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::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        Ok(BuildResult {
72            platform: Target::Android,
73            app_path: apk_path,
74            test_suite_path: None, // TODO: Add support for building test APK
75        })
76    }
77
78    /// Builds Rust libraries for Android using cargo-ndk
79    fn build_rust_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
80        let bench_mobile_dir = self.project_root.join("bench-mobile");
81
82        if !bench_mobile_dir.exists() {
83            return Err(BenchError::Build(format!(
84                "bench-mobile crate not found at {:?}",
85                bench_mobile_dir
86            )));
87        }
88
89        // Check if cargo-ndk is installed
90        self.check_cargo_ndk()?;
91
92        // Android ABIs to build for
93        let abis = vec!["arm64-v8a", "armeabi-v7a", "x86_64"];
94
95        for abi in abis {
96            if self.verbose {
97                println!("  Building for {}", abi);
98            }
99
100            let mut cmd = Command::new("cargo");
101            cmd.arg("ndk")
102                .arg("--target")
103                .arg(abi)
104                .arg("--platform")
105                .arg("24") // minSdk
106                .arg("build");
107
108            // Add release flag if needed
109            if matches!(config.profile, BuildProfile::Release) {
110                cmd.arg("--release");
111            }
112
113            // Set working directory
114            cmd.current_dir(&bench_mobile_dir);
115
116            // Execute build
117            let output = cmd
118                .output()
119                .map_err(|e| BenchError::Build(format!("Failed to run cargo-ndk: {}", e)))?;
120
121            if !output.status.success() {
122                let stderr = String::from_utf8_lossy(&output.stderr);
123                return Err(BenchError::Build(format!(
124                    "cargo-ndk build failed for {}: {}",
125                    abi, stderr
126                )));
127            }
128        }
129
130        Ok(())
131    }
132
133    /// Checks if cargo-ndk is installed
134    fn check_cargo_ndk(&self) -> Result<(), BenchError> {
135        let output = Command::new("cargo").arg("ndk").arg("--version").output();
136
137        match output {
138            Ok(output) if output.status.success() => Ok(()),
139            _ => Err(BenchError::Build(
140                "cargo-ndk is not installed. Install it with: cargo install cargo-ndk".to_string(),
141            )),
142        }
143    }
144
145    /// Generates UniFFI Kotlin bindings
146    fn generate_uniffi_bindings(&self) -> Result<(), BenchError> {
147        let bench_mobile_dir = self.project_root.join("bench-mobile");
148        if !bench_mobile_dir.exists() {
149            return Err(BenchError::Build(format!(
150                "bench-mobile crate not found at {:?}",
151                bench_mobile_dir
152            )));
153        }
154
155        // Build host library to feed uniffi-bindgen
156        let mut build_cmd = Command::new("cargo");
157        build_cmd.arg("build");
158        build_cmd.current_dir(&bench_mobile_dir);
159        run_command(build_cmd, "cargo build (host)")?;
160
161        let lib_path = host_lib_path(&bench_mobile_dir, &self.crate_name)?;
162        let out_dir = self
163            .project_root
164            .join("android")
165            .join("app")
166            .join("src")
167            .join("main")
168            .join("java");
169
170        let mut cmd = Command::new("uniffi-bindgen");
171        cmd.arg("generate")
172            .arg("--library")
173            .arg(&lib_path)
174            .arg("--language")
175            .arg("kotlin")
176            .arg("--out-dir")
177            .arg(&out_dir);
178        run_command(cmd, "uniffi-bindgen kotlin")?;
179
180        if self.verbose {
181            println!("  Generated UniFFI Kotlin bindings at {:?}", out_dir);
182        }
183        Ok(())
184    }
185
186    /// Copies .so files to Android jniLibs directories
187    fn copy_native_libraries(&self, config: &BuildConfig) -> Result<(), BenchError> {
188        let profile_dir = match config.profile {
189            BuildProfile::Debug => "debug",
190            BuildProfile::Release => "release",
191        };
192
193        let target_dir = self.project_root.join("target");
194        let jni_libs_dir = self.project_root.join("android/app/src/main/jniLibs");
195
196        // Create jniLibs directories if they don't exist
197        std::fs::create_dir_all(&jni_libs_dir)
198            .map_err(|e| BenchError::Build(format!("Failed to create jniLibs directory: {}", e)))?;
199
200        // Map cargo-ndk ABIs to Android jniLibs ABIs
201        let abi_mappings = vec![
202            ("aarch64-linux-android", "arm64-v8a"),
203            ("armv7-linux-androideabi", "armeabi-v7a"),
204            ("x86_64-linux-android", "x86_64"),
205        ];
206
207        for (rust_target, android_abi) in abi_mappings {
208            let src = target_dir
209                .join(rust_target)
210                .join(profile_dir)
211                .join(format!("lib{}.so", self.crate_name.replace("-", "_")));
212
213            let dest_dir = jni_libs_dir.join(android_abi);
214            std::fs::create_dir_all(&dest_dir).map_err(|e| {
215                BenchError::Build(format!("Failed to create {} directory: {}", android_abi, e))
216            })?;
217
218            let dest = dest_dir.join(format!(
219                "lib{}.so",
220                self.crate_name.replace("-", "_")
221            ));
222
223            if src.exists() {
224                std::fs::copy(&src, &dest).map_err(|e| {
225                    BenchError::Build(format!("Failed to copy {} library: {}", android_abi, e))
226                })?;
227
228                if self.verbose {
229                    println!("  Copied {} -> {}", src.display(), dest.display());
230                }
231            } else if self.verbose {
232                println!("  Warning: {} not found, skipping", src.display());
233            }
234        }
235
236        Ok(())
237    }
238
239    /// Builds the Android APK using Gradle
240    fn build_apk(&self, config: &BuildConfig) -> Result<PathBuf, BenchError> {
241        let android_dir = self.project_root.join("android");
242
243        if !android_dir.exists() {
244            return Err(BenchError::Build(format!(
245                "Android project not found at {:?}",
246                android_dir
247            )));
248        }
249
250        // Determine Gradle task
251        let gradle_task = match config.profile {
252            BuildProfile::Debug => "assembleDebug",
253            BuildProfile::Release => "assembleRelease",
254        };
255
256        // Run Gradle build
257        let mut cmd = Command::new("./gradlew");
258        cmd.arg(gradle_task).current_dir(&android_dir);
259
260        if self.verbose {
261            cmd.arg("--info");
262        }
263
264        let output = cmd
265            .output()
266            .map_err(|e| BenchError::Build(format!("Failed to run Gradle: {}", e)))?;
267
268        if !output.status.success() {
269            let stderr = String::from_utf8_lossy(&output.stderr);
270            return Err(BenchError::Build(format!(
271                "Gradle build failed: {}",
272                stderr
273            )));
274        }
275
276        // Determine APK path
277        let profile_name = match config.profile {
278            BuildProfile::Debug => "debug",
279            BuildProfile::Release => "release",
280        };
281
282        let apk_path = android_dir
283            .join("app/build/outputs/apk")
284            .join(profile_name)
285            .join(format!("app-{}.apk", profile_name));
286
287        if !apk_path.exists() {
288            return Err(BenchError::Build(format!(
289                "APK not found at expected location: {:?}",
290                apk_path
291            )));
292        }
293
294        Ok(apk_path)
295    }
296}
297
298// Shared helpers
299fn host_lib_path(project_dir: &PathBuf, crate_name: &str) -> Result<PathBuf, BenchError> {
300    let lib_prefix = if cfg!(target_os = "windows") {
301        ""
302    } else {
303        "lib"
304    };
305    let lib_ext = match env::consts::OS {
306        "macos" => "dylib",
307        "linux" => "so",
308        other => {
309            return Err(BenchError::Build(format!(
310                "unsupported host OS for binding generation: {}",
311                other
312            )));
313        }
314    };
315    let path = project_dir.join("target").join("debug").join(format!(
316        "{}{}.{}",
317        lib_prefix,
318        crate_name.replace('-', "_"),
319        lib_ext
320    ));
321    if !path.exists() {
322        return Err(BenchError::Build(format!(
323            "host library for UniFFI not found at {:?}",
324            path
325        )));
326    }
327    Ok(path)
328}
329
330fn run_command(mut cmd: Command, description: &str) -> Result<(), BenchError> {
331    let output = cmd
332        .output()
333        .map_err(|e| BenchError::Build(format!("Failed to run {}: {}", description, e)))?;
334    if !output.status.success() {
335        let stderr = String::from_utf8_lossy(&output.stderr);
336        return Err(BenchError::Build(format!(
337            "{} failed: {}",
338            description, stderr
339        )));
340    }
341    Ok(())
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_android_builder_creation() {
350        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile");
351        assert!(!builder.verbose);
352    }
353
354    #[test]
355    fn test_android_builder_verbose() {
356        let builder = AndroidBuilder::new("/tmp/test-project", "test-bench-mobile").verbose(true);
357        assert!(builder.verbose);
358    }
359}