waterui_cli/android/
platform.rs

1use std::path::{Path, PathBuf};
2
3use color_eyre::eyre::{self, bail};
4use smol::fs;
5use target_lexicon::{Aarch64Architecture, Architecture, Triple};
6
7use crate::{
8    android::{
9        backend::AndroidBackend,
10        device::AndroidDevice,
11        toolchain::{AndroidNdk, AndroidSdk, AndroidToolchain},
12    },
13    build::{BuildOptions, RustBuild},
14    device::Artifact,
15    platform::{PackageOptions, Platform},
16    project::Project,
17    utils::{copy_file, run_command},
18};
19
20fn validate_android_package_name(package: &str) -> eyre::Result<()> {
21    if package.is_empty() {
22        bail!("Android package name is empty (set `[package].bundle_identifier` in `Water.toml`).");
23    }
24
25    if package.contains('-') {
26        bail!(
27            "Invalid Android package name: '{package}' (hyphens are not allowed). \
28Set `[package].bundle_identifier` in `Water.toml` to a valid Java package name (e.g. replace '-' with '_')."
29        );
30    }
31
32    for segment in package.split('.') {
33        if segment.is_empty() {
34            bail!("Invalid Android package name: '{package}' (empty segment).");
35        }
36
37        let mut chars = segment.chars();
38        let Some(first) = chars.next() else {
39            bail!("Invalid Android package name: '{package}' (empty segment).");
40        };
41
42        if !(first.is_ascii_alphabetic() || first == '_') {
43            bail!(
44                "Invalid Android package name: '{package}' (segment '{segment}' must start with a letter or underscore)."
45            );
46        }
47
48        if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
49            bail!(
50                "Invalid Android package name: '{package}' (segment '{segment}' contains invalid characters)."
51            );
52        }
53    }
54
55    Ok(())
56}
57
58/// Get the NDK host tag based on the current machine's OS and architecture.
59fn ndk_host_tag() -> &'static str {
60    use target_lexicon::{Architecture, OperatingSystem, Triple};
61
62    let host = Triple::host();
63
64    // TODO: Better ARM support
65    match (&host.operating_system, &host.architecture) {
66        (OperatingSystem::Darwin(_), Architecture::Aarch64(_) | _) => "darwin-x86_64", // NDK uses x86_64 even on ARM Macs (Rosetta)
67        (OperatingSystem::Windows, _) => "windows-x86_64",
68        // NDK doesn't have native ARM64 Linux builds
69        (OperatingSystem::Linux, _) => "linux-x86_64",
70        _ => unimplemented!(),
71    }
72}
73
74/// Get the NDK clang linker path for the given ABI.
75fn ndk_linker_path(ndk_path: &Path, abi: &str) -> PathBuf {
76    let target = match abi {
77        "arm64-v8a" => "aarch64-linux-android",
78        "x86_64" => "x86_64-linux-android",
79        "armeabi-v7a" => "armv7a-linux-androideabi",
80        "x86" => "i686-linux-android",
81        _ => unimplemented!(),
82    };
83
84    // Use API level 24 as minimum (Android 7.0)
85    let api_level = 24;
86
87    ndk_path
88        .join("toolchains/llvm/prebuilt")
89        .join(ndk_host_tag())
90        .join("bin")
91        .join(format!("{target}{api_level}-clang"))
92}
93
94/// Get the NDK ar path.
95fn ndk_ar_path(ndk_path: &Path) -> PathBuf {
96    ndk_path
97        .join("toolchains/llvm/prebuilt")
98        .join(ndk_host_tag())
99        .join("bin/llvm-ar")
100}
101
102/// Create a wrapper `CMake` toolchain file that sets `ANDROID_ABI` before including
103/// the NDK's toolchain. This is required because cmake-rs doesn't pass `ANDROID_ABI`
104/// as a -D define, causing the NDK toolchain to default to armeabi-v7a.
105///
106/// Returns the path to the created wrapper toolchain file.
107fn create_android_toolchain_wrapper(ndk_path: &Path, abi: &str) -> eyre::Result<PathBuf> {
108    use std::io::Write;
109
110    // Create wrapper in a temp directory that persists for the build
111    let wrapper_dir = std::env::temp_dir().join("waterui-cmake-toolchains");
112    std::fs::create_dir_all(&wrapper_dir)?;
113
114    let wrapper_path = wrapper_dir.join(format!("android-{abi}.cmake"));
115    let ndk_toolchain = ndk_path.join("build/cmake/android.toolchain.cmake");
116
117    let content = format!(
118        r#"# Auto-generated wrapper toolchain for WaterUI Android builds
119# Sets ANDROID_ABI before including the NDK toolchain to fix cmake-rs cross-compilation
120set(ANDROID_ABI "{abi}")
121set(ANDROID_PLATFORM "android-24")
122include("{ndk_toolchain}")
123"#,
124        abi = abi,
125        ndk_toolchain = ndk_toolchain.display()
126    );
127
128    let mut file = std::fs::File::create(&wrapper_path)?;
129    file.write_all(content.as_bytes())?;
130
131    Ok(wrapper_path)
132}
133
134/// Get the NDK clang++ (C++ compiler) path for the given ABI.
135fn ndk_cxx_path(ndk_path: &Path, abi: &str) -> PathBuf {
136    let target = match abi {
137        "arm64-v8a" => "aarch64-linux-android",
138        "x86_64" => "x86_64-linux-android",
139        "armeabi-v7a" => "armv7a-linux-androideabi",
140        "x86" => "i686-linux-android",
141        _ => unimplemented!(),
142    };
143
144    // Use API level 24 as minimum (Android 7.0)
145    let api_level = 24;
146
147    ndk_path
148        .join("toolchains/llvm/prebuilt")
149        .join(ndk_host_tag())
150        .join("bin")
151        .join(format!("{target}{api_level}-clang++"))
152}
153
154/// Represents an Android platform for a specific architecture.
155#[derive(Debug, Clone)]
156pub struct AndroidPlatform {
157    architecture: Architecture,
158}
159
160impl AndroidPlatform {
161    /// Create a new Android platform with the specified architecture.
162    #[must_use]
163    pub const fn new(architecture: Architecture) -> Self {
164        Self { architecture }
165    }
166
167    /// Create an Android platform for arm64-v8a (most common modern Android devices).
168    #[must_use]
169    pub const fn arm64() -> Self {
170        Self {
171            architecture: Architecture::Aarch64(Aarch64Architecture::Aarch64),
172        }
173    }
174
175    /// Create an Android platform for `x86_64` (emulators on Intel/AMD).
176    #[must_use]
177    pub const fn x86_64() -> Self {
178        Self {
179            architecture: Architecture::X86_64,
180        }
181    }
182
183    /// Get the Android ABI name for this architecture.
184    #[must_use]
185    pub const fn abi(&self) -> &'static str {
186        match self.architecture {
187            Architecture::Aarch64(_) => "arm64-v8a",
188            Architecture::X86_64 => "x86_64",
189            Architecture::Arm(_) => "armeabi-v7a",
190            Architecture::X86_32(_) => "x86",
191            _ => unimplemented!(),
192        }
193    }
194
195    /// Get the architecture from an Android ABI name.
196    #[must_use]
197    pub fn from_abi(abi: &str) -> Self {
198        let architecture = match abi {
199            "arm64-v8a" => Architecture::Aarch64(Aarch64Architecture::Aarch64),
200            "x86_64" => Architecture::X86_64,
201            "armeabi-v7a" => Architecture::Arm(target_lexicon::ArmArchitecture::Armv7),
202            "x86" => Architecture::X86_32(target_lexicon::X86_32Architecture::I686),
203            _ => unimplemented!(),
204        };
205        Self { architecture }
206    }
207}
208
209/// All supported Android ABIs.
210pub const ALL_ABIS: &[&str] = &["arm64-v8a", "x86_64", "armeabi-v7a", "x86"];
211
212impl AndroidPlatform {
213    /// Returns all supported Android platforms (all architectures).
214    #[must_use]
215    pub fn all() -> Vec<Self> {
216        ALL_ABIS.iter().map(|abi| Self::from_abi(abi)).collect()
217    }
218
219    /// Clean all jniLibs directories to remove stale libraries from previous builds.
220    ///
221    /// # Errors
222    /// Returns an error if the directory cannot be removed.
223    pub async fn clean_jni_libs(project: &Project) -> eyre::Result<()> {
224        let jni_libs_dir = project
225            .backend_path::<AndroidBackend>()
226            .join("app/src/main/jniLibs");
227
228        if jni_libs_dir.exists() {
229            fs::remove_dir_all(&jni_libs_dir).await?;
230        }
231        Ok(())
232    }
233
234    /// Package the Android app with specific ABIs.
235    ///
236    /// This is used when building for multiple architectures. The ABIs parameter
237    /// controls which native libraries are included in the final APK.
238    ///
239    /// # Errors
240    /// Returns an error if Gradle build fails.
241    ///
242    /// # Panics
243    ///
244    /// Panics if an unsupported ABI is provided.
245    pub async fn package_with_abis(
246        project: &Project,
247        options: PackageOptions,
248        abis: &[&str],
249    ) -> eyre::Result<Artifact> {
250        validate_android_package_name(project.bundle_identifier())?;
251
252        let backend_path = project.backend_path::<AndroidBackend>();
253        let gradlew = backend_path.join(if cfg!(windows) {
254            "gradlew.bat"
255        } else {
256            "gradlew"
257        });
258
259        let (command_name, path) = if options.is_distribution() && !options.is_debug() {
260            (
261                "bundleRelease",
262                backend_path.join("app/build/outputs/bundle/release/app-release.aab"),
263            )
264        } else if !options.is_distribution() && !options.is_debug() {
265            (
266                "assembleRelease",
267                backend_path.join("app/build/outputs/apk/release/app-release.apk"),
268            )
269        } else if !options.is_distribution() && options.is_debug() {
270            (
271                "assembleDebug",
272                backend_path.join("app/build/outputs/apk/debug/app-debug.apk"),
273            )
274        } else if options.is_distribution() && options.is_debug() {
275            (
276                "bundleDebug",
277                backend_path.join("app/build/outputs/bundle/debug/app-debug.aab"),
278            )
279        } else {
280            unreachable!()
281        };
282
283        // Join ABIs with comma for the environment variable
284        let abis_str = abis.join(",");
285
286        let output = smol::process::Command::new(gradlew.to_str().unwrap())
287            .args([
288                command_name,
289                "--project-dir",
290                backend_path.to_str().unwrap(),
291            ])
292            .env("WATERUI_SKIP_RUST_BUILD", "1")
293            .env("WATERUI_ANDROID_ABIS", &abis_str)
294            .output()
295            .await?;
296
297        if !output.status.success() {
298            let stderr = String::from_utf8_lossy(&output.stderr);
299            let stdout = String::from_utf8_lossy(&output.stdout);
300            bail!("Gradle build failed:\n{}\n{}", stdout.trim(), stderr.trim());
301        }
302
303        Ok(Artifact::new(project.bundle_identifier(), path))
304    }
305
306    /// List available Android Virtual Devices (emulators).
307    ///
308    /// # Errors
309    /// Returns an error if the emulator tool is not found.
310    pub async fn list_avds() -> eyre::Result<Vec<String>> {
311        let emulator_path =
312            AndroidSdk::emulator_path().ok_or_else(|| eyre::eyre!("Android emulator not found"))?;
313
314        let output = smol::process::Command::new(&emulator_path)
315            .arg("-list-avds")
316            .output()
317            .await?;
318
319        let stdout = String::from_utf8_lossy(&output.stdout);
320        let avds: Vec<String> = stdout
321            .lines()
322            .filter(|line| !line.is_empty())
323            .map(String::from)
324            .collect();
325
326        Ok(avds)
327    }
328}
329
330impl Platform for AndroidPlatform {
331    type Device = AndroidDevice;
332    type Toolchain = AndroidToolchain;
333
334    async fn scan(&self) -> eyre::Result<Vec<Self::Device>> {
335        let adb = AndroidSdk::adb_path()
336            .ok_or_else(|| eyre::eyre!("Android SDK not found or adb not installed"))?;
337
338        // Use adb to list connected devices
339        let output = run_command(adb.to_str().unwrap(), ["devices"]).await?;
340
341        let mut devices = Vec::new();
342
343        for line in output.lines().skip(1) {
344            let parts: Vec<&str> = line.split_whitespace().collect();
345            if parts.len() >= 2 && parts[1] == "device" {
346                let identifier = parts[0].to_string();
347
348                // Query the device's primary ABI
349                let abi = run_command(
350                    adb.to_str().unwrap(),
351                    ["-s", &identifier, "shell", "getprop", "ro.product.cpu.abi"],
352                )
353                .await
354                .map_or_else(|_| "arm64-v8a".to_string(), |abi| abi.trim().to_string());
355
356                devices.push(AndroidDevice::new(identifier, abi));
357            }
358        }
359
360        Ok(devices)
361    }
362
363    fn toolchain(&self) -> Self::Toolchain {
364        AndroidToolchain::default()
365    }
366
367    async fn clean(&self, project: &Project) -> eyre::Result<()> {
368        let backend_path = project.backend_path::<AndroidBackend>();
369        let gradlew = backend_path.join(if cfg!(windows) {
370            "gradlew.bat"
371        } else {
372            "gradlew"
373        });
374
375        if !gradlew.exists() {
376            // No Android project to clean
377            return Ok(());
378        }
379
380        run_command(
381            gradlew.to_str().unwrap(),
382            ["clean", "--project-dir", backend_path.to_str().unwrap()],
383        )
384        .await?;
385
386        Ok(())
387    }
388
389    async fn build(
390        &self,
391        project: &Project,
392        options: BuildOptions,
393    ) -> eyre::Result<std::path::PathBuf> {
394        // Get NDK path for configuring the linker
395        let ndk_path = AndroidNdk::detect_path().ok_or_else(|| {
396            eyre::eyre!("Android NDK not found. Please install it via Android Studio.")
397        })?;
398
399        // Configure NDK environment for cargo
400        let linker = ndk_linker_path(&ndk_path, self.abi());
401        let ar = ndk_ar_path(&ndk_path);
402        let cxx = ndk_cxx_path(&ndk_path, self.abi());
403
404        // Set environment variables for the linker
405        let target_upper = self.triple().to_string().replace('-', "_").to_uppercase();
406
407        // Build with RustBuild
408        let build = RustBuild::new(project.root(), self.triple(), options.is_hot_reload());
409
410        // Set environment variables for cargo, cc-rs, and cmake before building
411        // SAFETY: CLI is single-threaded at this point
412        unsafe {
413            // For cargo/rustc linker
414            std::env::set_var(format!("CARGO_TARGET_{target_upper}_LINKER"), &linker);
415            std::env::set_var(format!("CARGO_TARGET_{target_upper}_AR"), &ar);
416
417            // For cc-rs crate (used by ring, aws-lc-sys, etc.) - uses underscore format
418            let target_underscore = self.triple().to_string().replace('-', "_");
419            std::env::set_var(format!("CC_{target_underscore}"), &linker);
420            std::env::set_var(format!("CXX_{target_underscore}"), &cxx);
421            std::env::set_var(format!("AR_{target_underscore}"), &ar);
422
423            // For CMake-based builds (aws-lc-sys, etc.)
424            // Set all variants as different crates check different env vars
425            std::env::set_var("ANDROID_NDK", &ndk_path);
426            std::env::set_var("ANDROID_NDK_HOME", &ndk_path);
427            std::env::set_var("ANDROID_NDK_ROOT", &ndk_path);
428
429            // Create a wrapper CMake toolchain file that sets ANDROID_ABI before
430            // including the NDK toolchain. This is required because cmake-rs doesn't
431            // pass ANDROID_ABI as a -D define, causing the NDK toolchain to default
432            // to armeabi-v7a (32-bit ARM) instead of the correct architecture.
433            let android_abi = self.abi();
434            let wrapper_toolchain = create_android_toolchain_wrapper(&ndk_path, android_abi)?;
435
436            std::env::set_var("CMAKE_TOOLCHAIN_FILE", &wrapper_toolchain);
437            std::env::set_var(
438                format!("CMAKE_TOOLCHAIN_FILE_{target_underscore}"),
439                &wrapper_toolchain,
440            );
441
442            // Also set these for other tools that might check them
443            std::env::set_var("ANDROID_ABI", android_abi);
444            std::env::set_var("ANDROID_PLATFORM", "android-24");
445
446            // Use Ninja generator if available to avoid Xcode/Make conflicts on macOS
447            // The system Make on macOS can inject -arch and -isysroot flags that break Android builds
448            if which::which("ninja").is_ok() {
449                std::env::set_var("CMAKE_GENERATOR", "Ninja");
450            }
451        }
452
453        let lib_dir = build.build_lib(options.is_release()).await?;
454
455        // Get the crate name and find the built .so file
456        let lib_name = project.crate_name().replace('-', "_");
457        let source_lib = lib_dir.join(format!("lib{lib_name}.so"));
458
459        if !source_lib.exists() {
460            bail!(
461                "Rust shared library not found at {}. Did the build succeed?",
462                source_lib.display()
463            );
464        }
465
466        // Determine output directory: use specified output_dir or default to jniLibs
467        let output_dir = options.output_dir().map_or_else(
468            || {
469                project
470                    .backend_path::<AndroidBackend>()
471                    .join("app/src/main/jniLibs")
472                    .join(self.abi())
473            },
474            std::path::Path::to_path_buf,
475        );
476        fs::create_dir_all(&output_dir).await?;
477
478        // Copy with standardized name
479        let dest_lib = output_dir.join("libwaterui_app.so");
480        copy_file(&source_lib, &dest_lib).await?;
481
482        Ok(lib_dir)
483    }
484
485    fn triple(&self) -> Triple {
486        Triple {
487            architecture: self.architecture,
488            vendor: target_lexicon::Vendor::Unknown,
489            operating_system: target_lexicon::OperatingSystem::Linux,
490            environment: target_lexicon::Environment::Android,
491            binary_format: target_lexicon::BinaryFormat::Elf,
492        }
493    }
494
495    async fn package(
496        &self,
497        project: &Project,
498        options: PackageOptions,
499    ) -> color_eyre::eyre::Result<Artifact> {
500        let backend_path = project.backend_path::<AndroidBackend>();
501        let gradlew = backend_path.join(if cfg!(windows) {
502            "gradlew.bat"
503        } else {
504            "gradlew"
505        });
506
507        let (command_name, path) = if options.is_distribution() && !options.is_debug() {
508            (
509                "bundleRelease",
510                backend_path.join("app/build/outputs/bundle/release/app-release.aab"),
511            )
512        } else if !options.is_distribution() && !options.is_debug() {
513            (
514                "assembleRelease",
515                backend_path.join("app/build/outputs/apk/release/app-release.apk"),
516            )
517        } else if !options.is_distribution() && options.is_debug() {
518            (
519                "assembleDebug",
520                backend_path.join("app/build/outputs/apk/debug/app-debug.apk"),
521            )
522        } else if options.is_distribution() && options.is_debug() {
523            (
524                "bundleDebug",
525                backend_path.join("app/build/outputs/bundle/debug/app-debug.aab"),
526            )
527        } else {
528            unreachable!()
529        };
530
531        // Skip Rust build in Gradle - we already built the library via `water build`
532        // The Gradle build.gradle.kts checks this env var and skips its buildRust tasks
533        //
534        // Also pass the target ABI to filter which native libraries are included
535        // This ensures only the architectures we built are packaged in the APK
536        let output = smol::process::Command::new(gradlew.to_str().unwrap())
537            .args([
538                command_name,
539                "--project-dir",
540                backend_path.to_str().unwrap(),
541            ])
542            .env("WATERUI_SKIP_RUST_BUILD", "1")
543            .env("WATERUI_ANDROID_ABIS", self.abi())
544            .output()
545            .await?;
546
547        if !output.status.success() {
548            let stderr = String::from_utf8_lossy(&output.stderr);
549            let stdout = String::from_utf8_lossy(&output.stdout);
550            bail!("Gradle build failed:\n{}\n{}", stdout.trim(), stderr.trim());
551        }
552
553        Ok(Artifact::new(project.bundle_identifier(), path))
554    }
555}