Skip to main content

waterui_cli/android/
platform.rs

1//! Android platform build and package utilities.
2//!
3//! This module provides utility functions for building and packaging Android apps.
4//! These functions are used by `AndroidBackend` to implement the `Backend` trait.
5
6use std::path::{Path, PathBuf};
7
8use askama::Template;
9use eyre::{self, bail};
10use smol::{fs, unblock};
11use target_lexicon::{Aarch64Architecture, Architecture, Triple};
12
13use tracing::{debug, info};
14
15use std::str::FromStr;
16
17use crate::{
18    android::{
19        backend::AndroidBackend,
20        output_metadata::{OutputKind, packaged_artifact},
21        toolchain::{AndroidNdk, AndroidSdk, Java, Kotlin, java_proxy_properties_from_env},
22    },
23    assets::{self, ResolvedFont},
24    build::{BuildOptions, BuildProgress, RustBuild, RustDynamicLibraries, RustLinkage},
25    device::Artifact,
26    platform::{PackageOptions, TargetPlatform},
27    project::Project,
28    templates::FontRegistrationTemplateEntry,
29    toolchain::{Host, ToolchainError, windows_arm64_llvm::WindowsArm64LlvmToolchain},
30    utils::copy_file,
31};
32
33fn gradle_cmd(gradlew: &Path, backend_path: &Path, task: &str) -> smol::process::Command {
34    let mut cmd = smol::process::Command::new(gradlew);
35    cmd.arg(task).arg("--project-dir").arg(backend_path);
36    cmd
37}
38
39fn apply_gradle_proxy_env(host: &Host, cmd: &mut smol::process::Command) -> eyre::Result<()> {
40    let proxy_properties = java_proxy_properties_from_env(host)?;
41    if proxy_properties.is_empty() {
42        return Ok(());
43    }
44
45    cmd.args(&proxy_properties);
46
47    let mut gradle_opts = proxy_properties.join(" ");
48    if let Some(existing) = host.env_string("GRADLE_OPTS")
49        && !existing.trim().is_empty()
50    {
51        gradle_opts.push(' ');
52        gradle_opts.push_str(&existing);
53    }
54    cmd.env("GRADLE_OPTS", gradle_opts);
55    Ok(())
56}
57
58/// Get the NDK host tag based on the current machine's OS and architecture.
59///
60/// On Apple Silicon, prefer the native `darwin-arm64` toolchain when present,
61/// falling back to `darwin-x86_64` for older Android NDK releases (Rosetta).
62fn ndk_host_tag(ndk_path: &Path) -> &'static str {
63    use target_lexicon::{Architecture, OperatingSystem, Triple};
64
65    let host = Triple::host();
66
67    match (&host.operating_system, &host.architecture) {
68        (OperatingSystem::Darwin(_), Architecture::Aarch64(_)) => {
69            let native = ndk_path
70                .join("toolchains/llvm/prebuilt")
71                .join("darwin-arm64");
72            if native.exists() {
73                "darwin-arm64"
74            } else {
75                "darwin-x86_64"
76            }
77        }
78        (OperatingSystem::Darwin(_), _) => "darwin-x86_64",
79        (OperatingSystem::Windows, _) => "windows-x86_64",
80        // NDK doesn't have native ARM64 Linux builds
81        (OperatingSystem::Linux, _) => "linux-x86_64",
82        _ => panic!("Unsupported host triple for Android NDK: {host}"),
83    }
84}
85
86fn ndk_bin_dir(ndk_path: &Path) -> PathBuf {
87    ndk_path
88        .join("toolchains/llvm/prebuilt")
89        .join(ndk_host_tag(ndk_path))
90        .join("bin")
91}
92
93/// Get the NDK ar path.
94fn ndk_ar_path(ndk_path: &Path) -> PathBuf {
95    ndk_bin_dir(ndk_path).join("llvm-ar")
96}
97
98fn ndk_clang_path(ndk_path: &Path, abi: AndroidAbi, cxx: bool, api_level: u32) -> PathBuf {
99    let suffix = if cxx { "clang++" } else { "clang" };
100    ndk_bin_dir(ndk_path).join(format!("{}{api_level}-{suffix}", abi.ndk_target()))
101}
102
103/// Get the NDK clang linker path for the given ABI.
104fn ndk_linker_path(ndk_path: &Path, abi: AndroidAbi, api_level: u32) -> PathBuf {
105    ndk_clang_path(ndk_path, abi, false, api_level)
106}
107
108/// The NDK's prebuilt `libclang_rt.builtins-<arch>-android.a` for `abi`.
109///
110/// `-Zbuild-std` builds `compiler_builtins` with `compiler-builtins-c`, whose
111/// build script links the archive named by `LLVM_COMPILER_RT_LIB` instead of
112/// rebuilding compiler-rt from source (rust-src ships no compiler-rt C
113/// sources). On aarch64 that archive is what provides the LSE outline-atomics
114/// helpers (`__aarch64_ldadd4_acq_rel` & friends) NDK-compiled C objects
115/// reference — rustc links with `-nodefaultlibs`, so the clang driver's own
116/// copy never reaches the link.
117fn ndk_builtins_lib(ndk_path: &Path, abi: AndroidAbi) -> eyre::Result<PathBuf> {
118    let arch = match abi {
119        AndroidAbi::Arm64V8a => "aarch64",
120        AndroidAbi::X86_64 => "x86_64",
121        AndroidAbi::ArmeabiV7a => "arm",
122        AndroidAbi::X86 => "i686",
123    };
124    let clang_libs = ndk_path
125        .join("toolchains/llvm/prebuilt")
126        .join(ndk_host_tag(ndk_path))
127        .join("lib/clang");
128    let mut candidates: Vec<PathBuf> = std::fs::read_dir(&clang_libs)
129        .map_err(|error| {
130            eyre::eyre!(
131                "Failed to read NDK clang libraries at {}: {error}",
132                clang_libs.display()
133            )
134        })?
135        .filter_map(|entry| entry.ok().map(|entry| entry.path()))
136        .map(|version_dir| {
137            version_dir.join(format!("lib/linux/libclang_rt.builtins-{arch}-android.a"))
138        })
139        .filter(|path| path.is_file())
140        .collect();
141    candidates.sort_unstable();
142    match candidates.as_slice() {
143        [path] => Ok(path.clone()),
144        [] => Err(eyre::eyre!(
145            "The NDK at {} ships no libclang_rt.builtins-{arch}-android.a; \
146             a `-Zbuild-std` build needs it for the compiler-rt builtins",
147            ndk_path.display()
148        )),
149        _ => Err(eyre::eyre!(
150            "The NDK at {} ships multiple libclang_rt.builtins-{arch}-android.a \
151             copies: {candidates:?}",
152            ndk_path.display()
153        )),
154    }
155}
156
157/// Create a wrapper `CMake` toolchain file that sets `ANDROID_ABI` before including
158/// the NDK's toolchain. This is required because cmake-rs doesn't pass `ANDROID_ABI`
159/// as a -D define, causing the NDK toolchain to default to armeabi-v7a.
160///
161/// Returns the path to the created wrapper toolchain file.
162async fn create_android_toolchain_wrapper(
163    ndk_path: &Path,
164    abi: AndroidAbi,
165    api_level: u32,
166) -> eyre::Result<PathBuf> {
167    // Create wrapper in a temp directory that persists for the build
168    let wrapper_dir = std::env::temp_dir().join("waterui-cmake-toolchains");
169    fs::create_dir_all(&wrapper_dir).await?;
170
171    let wrapper_path = wrapper_dir.join(format!("android-{}.cmake", abi.as_str()));
172    let ndk_toolchain = ndk_path.join("build/cmake/android.toolchain.cmake");
173
174    let content = format!(
175        include_str!("android_toolchain_wrapper.cmake.tpl"),
176        abi = abi.as_str(),
177        api_level = api_level,
178        ndk_toolchain = ndk_toolchain.display(),
179        asm_compiler = ndk_clang_path(ndk_path, abi, false, api_level).display(),
180    );
181    fs::write(&wrapper_path, content).await?;
182
183    Ok(wrapper_path)
184}
185
186/// Get the NDK clang++ (C++ compiler) path for the given ABI.
187fn ndk_cxx_path(ndk_path: &Path, abi: AndroidAbi, api_level: u32) -> PathBuf {
188    ndk_clang_path(ndk_path, abi, true, api_level)
189}
190
191/// Get the path to `libc++_shared.so` in the NDK.
192///
193/// NDK r23+ ships it under `sysroot/usr/lib/<triple>/`, while older Android
194/// NDK releases used `sources/cxx-stl/llvm-libc++/libs/<abi>/`.
195fn ndk_libcxx_path(ndk_path: &Path, abi: AndroidAbi) -> PathBuf {
196    let new_path = ndk_path
197        .join("toolchains/llvm/prebuilt")
198        .join(ndk_host_tag(ndk_path))
199        .join("sysroot/usr/lib")
200        .join(abi.ndk_libcxx_triple())
201        .join("libc++_shared.so");
202
203    if new_path.exists() {
204        return new_path;
205    }
206
207    ndk_path
208        .join("sources/cxx-stl/llvm-libc++/libs")
209        .join(abi.as_str())
210        .join("libc++_shared.so")
211}
212
213/// The linker flag that aligns every produced ELF's `LOAD` segments to 16 KB
214/// pages — the largest page size Android ships (Pixel 9 class) and Google
215/// Play's packaging requirement. The app and every preview module it
216/// `dlopen`s must carry the same flag.
217pub(crate) const ANDROID_MAX_PAGE_SIZE_LINK_ARG: &str = "-Clink-arg=-Wl,-z,max-page-size=16384";
218
219/// Represents an Android platform for a specific architecture.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
221pub enum AndroidAbi {
222    /// ARM64 (arm64-v8a) - modern Android devices
223    Arm64V8a,
224    /// `x86_64` - emulators on Intel/AMD
225    X86_64,
226    /// `ARMv7` (armeabi-v7a) - older 32-bit devices
227    ArmeabiV7a,
228    /// x86 - older 32-bit emulators
229    X86,
230}
231
232/// Error returned when parsing an unsupported Android ABI string.
233#[derive(Debug, thiserror::Error)]
234#[error("Unsupported Android ABI: {abi}")]
235pub struct UnsupportedAndroidAbi {
236    abi: String,
237}
238
239impl FromStr for AndroidAbi {
240    type Err = UnsupportedAndroidAbi;
241
242    fn from_str(s: &str) -> Result<Self, Self::Err> {
243        match s {
244            "arm64-v8a" => Ok(Self::Arm64V8a),
245            "x86_64" => Ok(Self::X86_64),
246            "armeabi-v7a" => Ok(Self::ArmeabiV7a),
247            "x86" => Ok(Self::X86),
248            other => Err(UnsupportedAndroidAbi {
249                abi: other.to_string(),
250            }),
251        }
252    }
253}
254
255impl AndroidAbi {
256    #[must_use]
257    /// Android ABI string used by the Android toolchain (e.g. `arm64-v8a`).
258    pub const fn as_str(self) -> &'static str {
259        match self {
260            Self::Arm64V8a => "arm64-v8a",
261            Self::X86_64 => "x86_64",
262            Self::ArmeabiV7a => "armeabi-v7a",
263            Self::X86 => "x86",
264        }
265    }
266
267    #[must_use]
268    /// Target triple prefix used by the NDK toolchain binaries (clang, clang++).
269    pub const fn ndk_target(self) -> &'static str {
270        match self {
271            Self::Arm64V8a => "aarch64-linux-android",
272            Self::X86_64 => "x86_64-linux-android",
273            Self::ArmeabiV7a => "armv7a-linux-androideabi",
274            Self::X86 => "i686-linux-android",
275        }
276    }
277
278    #[must_use]
279    /// Target triple used by NDK sysroot libc++ paths.
280    pub const fn ndk_libcxx_triple(self) -> &'static str {
281        match self {
282            Self::Arm64V8a => "aarch64-linux-android",
283            Self::X86_64 => "x86_64-linux-android",
284            Self::ArmeabiV7a => "arm-linux-androideabi",
285            Self::X86 => "i686-linux-android",
286        }
287    }
288
289    /// The ABI a Rust target triple names — the inverse of
290    /// [`AndroidPlatform::triple`], so call sites holding a triple never keep
291    /// a second copy of the architecture mapping.
292    #[must_use]
293    pub const fn from_triple(triple: &Triple) -> Option<Self> {
294        match triple.architecture {
295            Architecture::Aarch64(_) => Some(Self::Arm64V8a),
296            Architecture::X86_64 => Some(Self::X86_64),
297            Architecture::Arm(target_lexicon::ArmArchitecture::Armv7) => Some(Self::ArmeabiV7a),
298            Architecture::X86_32(target_lexicon::X86_32Architecture::I686) => Some(Self::X86),
299            _ => None,
300        }
301    }
302}
303
304/// Represents an Android platform for a specific ABI.
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub struct AndroidPlatform {
307    abi: AndroidAbi,
308}
309
310struct AndroidBuildContext {
311    abi: AndroidAbi,
312    ndk_path: PathBuf,
313    linker: PathBuf,
314    ar: PathBuf,
315    cxx: PathBuf,
316    target_underscore: String,
317    target_upper: String,
318    llvm_envs: Vec<(String, std::ffi::OsString)>,
319    java_home: PathBuf,
320    java_bin_dir: PathBuf,
321    kotlin_compiler: PathBuf,
322    kotlin_bin_dir: PathBuf,
323    kotlin_home: PathBuf,
324    sdk_path: PathBuf,
325    android_jar: PathBuf,
326    wrapper_toolchain: PathBuf,
327    android_platform: String,
328}
329
330impl AndroidPlatform {
331    /// Create a new Android platform with the specified ABI.
332    #[must_use]
333    pub const fn new(abi: AndroidAbi) -> Self {
334        Self { abi }
335    }
336
337    /// Create an Android platform for arm64-v8a (most common modern Android devices).
338    #[must_use]
339    pub const fn arm64() -> Self {
340        Self {
341            abi: AndroidAbi::Arm64V8a,
342        }
343    }
344
345    /// Create an Android platform for `x86_64` (emulators on Intel/AMD).
346    #[must_use]
347    pub const fn x86_64() -> Self {
348        Self {
349            abi: AndroidAbi::X86_64,
350        }
351    }
352
353    #[must_use]
354    /// Return the ABI for this platform.
355    pub const fn abi(&self) -> AndroidAbi {
356        self.abi
357    }
358
359    #[must_use]
360    /// Return the ABI string for this platform.
361    pub const fn abi_str(&self) -> &'static str {
362        self.abi.as_str()
363    }
364
365    /// Create an Android platform from an ABI string.
366    ///
367    /// # Errors
368    /// Returns an error if the ABI is not supported.
369    pub fn try_from_abi(abi: &str) -> eyre::Result<Self> {
370        let abi = AndroidAbi::from_str(abi).map_err(|e| eyre::eyre!(e))?;
371        Ok(Self { abi })
372    }
373}
374
375/// All supported Android ABIs.
376pub const ALL_ABIS: &[AndroidAbi] = &[
377    AndroidAbi::Arm64V8a,
378    AndroidAbi::X86_64,
379    AndroidAbi::ArmeabiV7a,
380    AndroidAbi::X86,
381];
382
383impl AndroidPlatform {
384    /// Returns all supported Android platforms (all architectures).
385    #[must_use]
386    pub fn all() -> Vec<Self> {
387        ALL_ABIS.iter().copied().map(Self::new).collect()
388    }
389
390    /// Get the target triple for this Android platform.
391    #[must_use]
392    pub const fn triple(&self) -> Triple {
393        let architecture = match self.abi {
394            AndroidAbi::Arm64V8a => Architecture::Aarch64(Aarch64Architecture::Aarch64),
395            AndroidAbi::X86_64 => Architecture::X86_64,
396            AndroidAbi::ArmeabiV7a => Architecture::Arm(target_lexicon::ArmArchitecture::Armv7),
397            AndroidAbi::X86 => Architecture::X86_32(target_lexicon::X86_32Architecture::I686),
398        };
399        Triple {
400            architecture,
401            vendor: target_lexicon::Vendor::Unknown,
402            operating_system: target_lexicon::OperatingSystem::Linux,
403            environment: target_lexicon::Environment::Android,
404            binary_format: target_lexicon::BinaryFormat::Elf,
405        }
406    }
407
408    /// Build Rust library for this Android platform.
409    ///
410    /// # Errors
411    /// Returns an error if the build fails.
412    pub async fn build(&self, project: &Project, options: BuildOptions) -> eyre::Result<PathBuf> {
413        // Only an app that will `dlopen` WaterUI modules — the preview support
414        // app — ships the shared Rust runtime. `-Cprefer-dynamic` on Android
415        // cannot resolve `std` to rustup's prebuilt `libstd.so` (its LOAD
416        // segments are 4 KB-aligned and 16 KB-page devices reject the whole
417        // package), so the shared-runtime path below builds `std` from source
418        // under the page-size link flag instead. Every other build links the
419        // runtime in, which is what a packaged build already does.
420        let options = if options.loads_dynamic_modules() {
421            options
422        } else {
423            options.with_static_runtime()
424        };
425        // Resolve fonts BEFORE cargo build - this ensures icons.json is downloaded
426        // for crates like fontawesome7 that need it during build.rs
427        let font_declarations = crate::assets::scan_fonts(project).await?;
428        let _resolved_fonts = crate::assets::resolve_fonts(font_declarations).await?;
429
430        let abi = self.abi();
431        let triple = self.triple();
432        let min_api_level = project
433            .resolved_framework()
434            .await?
435            .android_min_api_level()?;
436        let host = Host::current();
437        let build_context =
438            resolve_android_build_context(&host, abi, &triple, min_api_level).await?;
439        let build = configure_android_rust_build(&host, project, &triple, &build_context, &options)
440            .await?
441            .with_envs(options.cargo_envs().iter().cloned())
442            .with_target_dir(project.water_target_dir(options.linkage()).await?);
443
444        let built_target = build.build_lib(options.is_release()).await?;
445        copy_android_build_outputs(
446            project,
447            &options,
448            abi,
449            &build_context.ndk_path,
450            &built_target.profile_dir,
451            &built_target.artifact,
452        )
453        .await?;
454        Ok(built_target.profile_dir)
455    }
456
457    /// Clean all jniLibs directories to remove stale libraries from previous builds.
458    ///
459    /// # Errors
460    /// Returns an error if the directory cannot be removed.
461    pub async fn clean_jni_libs(project: &Project) -> eyre::Result<()> {
462        let jni_libs_dir = project
463            .backend_path::<AndroidBackend>()
464            .join("app/src/main/jniLibs");
465
466        if jni_libs_dir.exists() {
467            fs::remove_dir_all(&jni_libs_dir).await?;
468        }
469        Ok(())
470    }
471
472    /// Package the Android app with specific ABIs.
473    ///
474    /// This is used when building for multiple architectures. The ABIs parameter
475    /// controls which native libraries are included in the final APK.
476    ///
477    /// # Errors
478    /// Returns an error if Gradle build fails.
479    pub async fn package_with_abis(
480        project: &Project,
481        options: PackageOptions,
482        abis: &[AndroidAbi],
483    ) -> eyre::Result<Artifact> {
484        let backend_path = project.backend_path::<AndroidBackend>();
485
486        // Copy project assets and dependency fonts
487        copy_assets_and_fonts(
488            project,
489            &backend_path,
490            None,
491            options.uses_dev_server(),
492            options.progress(),
493        )
494        .await?;
495
496        let gradlew = backend_path.join(if cfg!(windows) {
497            "gradlew.bat"
498        } else {
499            "gradlew"
500        });
501
502        let (command_name, output_kind, variant) =
503            match (options.is_distribution(), options.is_debug()) {
504                (true, false) => ("bundleRelease", OutputKind::Bundle, "release"),
505                (false, false) => ("assembleRelease", OutputKind::Apk, "release"),
506                (false, true) => ("assembleDebug", OutputKind::Apk, "debug"),
507                (true, true) => ("bundleDebug", OutputKind::Bundle, "debug"),
508            };
509
510        // Join ABIs with comma for the environment variable
511        let abis_str = abis
512            .iter()
513            .map(|a| a.as_str())
514            .collect::<Vec<_>>()
515            .join(",");
516
517        // Set JAVA_HOME to Android Studio's bundled JDK to avoid JDK version conflicts
518        // (e.g., Homebrew's JDK 25 is not supported by Android Gradle Plugin)
519        let mut cmd = gradle_cmd(&gradlew, &backend_path, command_name);
520        cmd.env("WATERUI_SKIP_RUST_BUILD", "1")
521            .env("WATERUI_ANDROID_ABIS", &abis_str);
522
523        let host = Host::current();
524        if let Some(java_home) = Java::detect_home(&host).await {
525            cmd.env("JAVA_HOME", java_home);
526        }
527        if let Some(sdk_path) = AndroidSdk::detect_path(&host) {
528            cmd.env("ANDROID_HOME", &sdk_path)
529                .env("ANDROID_SDK_ROOT", &sdk_path);
530        }
531        apply_gradle_proxy_env(&host, &mut cmd)?;
532
533        let output = cmd.output().await?;
534
535        if !output.status.success() {
536            let stderr = String::from_utf8_lossy(&output.stderr);
537            let stdout = String::from_utf8_lossy(&output.stdout);
538            bail!("Gradle build failed:\n{}\n{}", stdout.trim(), stderr.trim());
539        }
540
541        let path = packaged_artifact(&backend_path, output_kind, variant).await?;
542        Ok(Artifact::new(project.bundle_identifier(), path))
543    }
544
545    /// List available Android Virtual Devices (emulators) on `host`.
546    ///
547    /// # Errors
548    /// Returns an error if the emulator tool is not found.
549    pub async fn list_avds(host: &Host) -> eyre::Result<Vec<String>> {
550        let emulator_path = AndroidSdk::emulator_path(host)
551            .ok_or_else(|| eyre::eyre!("Android emulator not found"))?;
552
553        let output = host.output(&emulator_path, ["-list-avds"]).await?;
554
555        let stdout = String::from_utf8_lossy(&output.stdout);
556        let avds: Vec<String> = stdout
557            .lines()
558            .filter(|line| !line.is_empty())
559            .map(String::from)
560            .collect();
561
562        Ok(avds)
563    }
564}
565
566async fn resolve_android_build_context(
567    host: &Host,
568    abi: AndroidAbi,
569    triple: &Triple,
570    api_level: u32,
571) -> eyre::Result<AndroidBuildContext> {
572    let ndk_path = AndroidNdk::detect_path(host).ok_or_else(|| {
573        eyre::eyre!("Android NDK not found. Please install it via Android Studio.")
574    })?;
575    let linker = ndk_linker_path(&ndk_path, abi, api_level);
576    let ar = ndk_ar_path(&ndk_path);
577    let cxx = ndk_cxx_path(&ndk_path, abi, api_level);
578    // The toolchain gate only proves the NDK's host toolchain executes; the
579    // wrapper for the framework's floor is a separate fact, and a missing one
580    // otherwise surfaces minutes later as a linker cargo cannot find.
581    for wrapper in [&linker, &cxx] {
582        if !wrapper.is_file() {
583            eyre::bail!(
584                "the Android NDK at {} ships no compiler wrapper for API {api_level} \
585                 ({}); the framework's android-min-api-level needs an NDK that targets it",
586                ndk_path.display(),
587                wrapper.display()
588            );
589        }
590    }
591    let target_underscore = triple.to_string().replace('-', "_");
592    let target_upper = target_underscore.to_uppercase();
593    let llvm_envs = resolve_windows_arm64_llvm_envs(host).await?;
594    let (java_home, java_bin_dir) = resolve_java_home(host).await?;
595    let (kotlin_compiler, kotlin_bin_dir, kotlin_home) = resolve_kotlin_home(host).await?;
596    let (sdk_path, android_jar) = resolve_android_sdk_paths(host).await?;
597    let wrapper_toolchain = create_android_toolchain_wrapper(&ndk_path, abi, api_level).await?;
598
599    Ok(AndroidBuildContext {
600        abi,
601        ndk_path,
602        linker,
603        ar,
604        cxx,
605        target_underscore,
606        target_upper,
607        llvm_envs,
608        java_home,
609        java_bin_dir,
610        kotlin_compiler,
611        kotlin_bin_dir,
612        kotlin_home,
613        sdk_path,
614        android_jar,
615        wrapper_toolchain,
616        android_platform: format!("android-{api_level}"),
617    })
618}
619
620async fn resolve_windows_arm64_llvm_envs(
621    host: &Host,
622) -> eyre::Result<Vec<(String, std::ffi::OsString)>> {
623    WindowsArm64LlvmToolchain
624        .cargo_envs(host)
625        .await
626        .map_err(|error| match error {
627            ToolchainError::Fixable(_) => eyre::eyre!(
628                "Windows ARM64 LLVM toolchain is missing. Run `water doctor --fix` to install it automatically."
629            ),
630            ToolchainError::Unfixable(unfixable) => {
631                eyre::eyre!("Windows ARM64 LLVM toolchain check failed: {unfixable}")
632            }
633        })
634}
635
636async fn resolve_java_home(host: &Host) -> eyre::Result<(PathBuf, PathBuf)> {
637    let java_home = Java::detect_home(host).await.ok_or_else(|| {
638        eyre::eyre!(
639            "Java runtime not found. Install a JDK (or Android Studio JBR), then re-run `water doctor --fix`."
640        )
641    })?;
642    let java_bin_dir = java_home.join("bin");
643    Ok((java_home, java_bin_dir))
644}
645
646async fn resolve_kotlin_home(host: &Host) -> eyre::Result<(PathBuf, PathBuf, PathBuf)> {
647    let kotlin_compiler = Kotlin::detect_path(host).await.ok_or_else(|| {
648        eyre::eyre!(
649            "Kotlin compiler (kotlinc) not found. Install Android Studio or set `KOTLIN_HOME`, then re-run `water doctor`."
650        )
651    })?;
652    let kotlin_bin_dir = kotlin_compiler.parent().map(PathBuf::from).ok_or_else(|| {
653        eyre::eyre!(
654            "Failed to determine Kotlin bin directory from `{}`.",
655            kotlin_compiler.display()
656        )
657    })?;
658    let kotlin_home = kotlin_bin_dir.parent().map(PathBuf::from).ok_or_else(|| {
659        eyre::eyre!(
660            "Failed to determine KOTLIN_HOME from `{}`.",
661            kotlin_bin_dir.display()
662        )
663    })?;
664    Ok((kotlin_compiler, kotlin_bin_dir, kotlin_home))
665}
666
667/// Resolve the SDK root and its newest `android.jar` on `host`.
668///
669/// `AndroidSdk::android_jar_path` walks `platforms/` on disk, so the whole
670/// resolution runs on a blocking thread instead of the executor.
671async fn resolve_android_sdk_paths(host: &Host) -> eyre::Result<(PathBuf, PathBuf)> {
672    let host = host.clone();
673    smol::unblock(move || {
674        let sdk_path = AndroidSdk::detect_path(&host).ok_or_else(|| {
675            eyre::eyre!("Android SDK not found. Please install it via Android Studio.")
676        })?;
677        let android_jar = AndroidSdk::android_jar_path(&host).ok_or_else(|| {
678            eyre::eyre!(
679                "Android platforms not found in SDK at {}. Install an Android platform (SDK) in Android Studio.",
680                sdk_path.display()
681            )
682        })?;
683        Ok((sdk_path, android_jar))
684    })
685    .await
686}
687
688/// The `waterui-ffi` features an Android runtime is compiled with.
689///
690/// See [`crate::apple::platform::apple_ffi_dependency_features`] for why anything
691/// loaded into that runtime must be compiled with the same set.
692///
693/// # Errors
694///
695/// Returns an error when the project's enabled capabilities cannot be resolved.
696pub(crate) async fn android_ffi_dependency_features(
697    project: &Project,
698) -> eyre::Result<Vec<String>> {
699    let mut features = vec!["waterui-ffi/android-jni".to_string()];
700    features.extend(crate::project_model::assets::capability_ffi_features(project).await?);
701    // Android has no player or map WaterUI bridges, so it draws both itself.
702    features.extend(crate::project_model::assets::self_drawn_realization_features(project).await?);
703    Ok(features)
704}
705
706async fn configure_android_rust_build(
707    host: &Host,
708    project: &Project,
709    triple: &Triple,
710    context: &AndroidBuildContext,
711    options: &BuildOptions,
712) -> eyre::Result<RustBuild> {
713    // Android loads the JNI shared object and nothing else, so build only that crate
714    // type instead of also archiving the whole dependency graph into a staticlib.
715    let mut build = RustBuild::new(project.ffi_crate_path(), triple.clone())
716        .with_project(project)
717        .with_features(android_ffi_dependency_features(project).await?)
718        .with_crate_type_override("cdylib")
719        .with_rustc_flag(ANDROID_MAX_PAGE_SIZE_LINK_ARG);
720    if options.linkage() == RustLinkage::SharedRuntime {
721        // The preview support app dlopens the pushed module, so the runtime is
722        // shared: the `dev` feature resolves `waterui-dylib`, `-Cprefer-dynamic`
723        // links `std` dynamically, and `-Zbuild-std` compiles that `libstd` from
724        // source — rustup's prebuilt one is 4 KB-aligned and a 16 KB-page device
725        // would reject the package for it. The `water` rustc wrapper Cargo runs
726        // under supplies the `dylib` crate type Cargo strips from `std`.
727        let nightly = crate::toolchain::rust::nightly_toolchain_with_rust_src(host).await?;
728        build = build
729            .with_feature("dev")
730            .with_preferred_dynamic_linking()
731            .with_build_std(nightly)
732            .with_env(
733                "LLVM_COMPILER_RT_LIB",
734                ndk_builtins_lib(&context.ndk_path, context.abi)?,
735            );
736    }
737    if let Some(sccache_path) = options.sccache_path() {
738        build = build.with_sccache(sccache_path.to_path_buf());
739    }
740    if let Some(progress) = options.progress() {
741        build = build.with_progress(progress.clone());
742    }
743    for (key, value) in &context.llvm_envs {
744        build = build.with_env(key.clone(), value.clone());
745    }
746
747    build = build.with_envs(android_cargo_envs(context, triple));
748
749    let current_path = host
750        .env("PATH")
751        .ok_or_else(|| eyre::eyre!("PATH environment variable is not set"))?;
752    let mut paths: Vec<PathBuf> = std::env::split_paths(&current_path).collect();
753    paths.insert(0, context.java_bin_dir.clone());
754    paths.insert(0, context.kotlin_bin_dir.clone());
755    let new_path = std::env::join_paths(paths).map_err(|error| {
756        eyre::eyre!("Failed to construct PATH for Java/Kotlin compiler resolution: {error}")
757    })?;
758
759    Ok(build.with_env("PATH", new_path))
760}
761
762/// The environment every Cargo invocation targeting an Android ABI needs:
763/// the NDK clang as linker and `cc`/`cxx`/`ar`, the SDK/NDK locations build
764/// scripts probe, the `CMake` toolchain file for native dependencies, and the
765/// `pkg-config` cross overrides.
766///
767/// The support app and the preview module it loads must compile their shared
768/// dependency graph identically, so both take their environment from this one
769/// list rather than each spelling it out.
770fn android_cargo_envs(
771    context: &AndroidBuildContext,
772    triple: &Triple,
773) -> Vec<(String, std::ffi::OsString)> {
774    [
775        (
776            format!("CARGO_TARGET_{}_LINKER", context.target_upper),
777            context.linker.as_os_str().to_os_string(),
778        ),
779        (
780            format!("CARGO_TARGET_{}_AR", context.target_upper),
781            context.ar.as_os_str().to_os_string(),
782        ),
783        (
784            format!("CC_{}", context.target_underscore),
785            context.linker.as_os_str().to_os_string(),
786        ),
787        (
788            format!("CXX_{}", context.target_underscore),
789            context.cxx.as_os_str().to_os_string(),
790        ),
791        (
792            format!("AR_{}", context.target_underscore),
793            context.ar.as_os_str().to_os_string(),
794        ),
795        (
796            "ANDROID_NDK".to_string(),
797            context.ndk_path.as_os_str().to_os_string(),
798        ),
799        (
800            "ANDROID_NDK_HOME".to_string(),
801            context.ndk_path.as_os_str().to_os_string(),
802        ),
803        (
804            "ANDROID_NDK_ROOT".to_string(),
805            context.ndk_path.as_os_str().to_os_string(),
806        ),
807        (
808            "ANDROID_HOME".to_string(),
809            context.sdk_path.as_os_str().to_os_string(),
810        ),
811        (
812            "ANDROID_SDK_ROOT".to_string(),
813            context.sdk_path.as_os_str().to_os_string(),
814        ),
815        (
816            "ANDROID_JAR".to_string(),
817            context.android_jar.as_os_str().to_os_string(),
818        ),
819        (
820            "JAVA_HOME".to_string(),
821            context.java_home.as_os_str().to_os_string(),
822        ),
823        (
824            "KOTLIN_HOME".to_string(),
825            context.kotlin_home.as_os_str().to_os_string(),
826        ),
827        (
828            "KOTLINC".to_string(),
829            context.kotlin_compiler.as_os_str().to_os_string(),
830        ),
831        (
832            "CMAKE_TOOLCHAIN_FILE".to_string(),
833            context.wrapper_toolchain.as_os_str().to_os_string(),
834        ),
835        (
836            format!("CMAKE_TOOLCHAIN_FILE_{}", context.target_underscore),
837            context.wrapper_toolchain.as_os_str().to_os_string(),
838        ),
839        (
840            "CMAKE_ASM_COMPILER".to_string(),
841            context.linker.as_os_str().to_os_string(),
842        ),
843        ("ANDROID_ABI".to_string(), context.abi.as_str().into()),
844        (
845            "ANDROID_PLATFORM".to_string(),
846            context.android_platform.clone().into(),
847        ),
848        ("PKG_CONFIG_ALLOW_CROSS".to_string(), "1".into()),
849        (
850            format!("PKG_CONFIG_ALLOW_CROSS_{}", context.target_underscore),
851            "1".into(),
852        ),
853        (format!("PKG_CONFIG_ALLOW_CROSS_{triple}"), "1".into()),
854    ]
855    .into_iter()
856    .collect()
857}
858
859/// The NDK/SDK toolchain environment a Rust build for `triple` on `abi`
860/// needs — shared between the app build and the preview module it `dlopen`s.
861///
862/// # Errors
863/// Returns an error when the NDK or the SDK-side tooling cannot be resolved.
864pub(crate) async fn android_rust_build_envs(
865    host: &Host,
866    project: &Project,
867    abi: AndroidAbi,
868    triple: &Triple,
869    build_std: bool,
870) -> eyre::Result<Vec<(String, std::ffi::OsString)>> {
871    let min_api_level = project
872        .resolved_framework()
873        .await?
874        .android_min_api_level()?;
875    let context = resolve_android_build_context(host, abi, triple, min_api_level).await?;
876    let mut envs = android_cargo_envs(&context, triple);
877    if build_std {
878        envs.push((
879            "LLVM_COMPILER_RT_LIB".to_string(),
880            ndk_builtins_lib(&context.ndk_path, abi)?.into_os_string(),
881        ));
882    }
883    Ok(envs)
884}
885
886async fn copy_android_build_outputs(
887    project: &Project,
888    options: &BuildOptions,
889    abi: AndroidAbi,
890    ndk_path: &Path,
891    lib_dir: &Path,
892    source_lib: &Path,
893) -> eyre::Result<()> {
894    let output_dir = options.output_dir().map_or_else(
895        || {
896            project
897                .backend_path::<AndroidBackend>()
898                .join("app/src/main/jniLibs")
899                .join(abi.as_str())
900        },
901        std::path::Path::to_path_buf,
902    );
903    fs::create_dir_all(&output_dir).await?;
904    copy_file(source_lib, &output_dir.join("libwaterui_app.so")).await?;
905
906    if options.linkage() == RustLinkage::SharedRuntime {
907        let triple = AndroidPlatform::new(abi).triple();
908        let libraries = RustDynamicLibraries::resolve(lib_dir, &triple, project).await?;
909        libraries.stage(&output_dir).await?;
910    } else {
911        RustDynamicLibraries::remove_staged(&output_dir, &AndroidPlatform::new(abi).triple())
912            .await?;
913    }
914
915    // `libc++_shared.so` only belongs in the package when a staged native
916    // library actually links the C++ STL — Rust-only builds never reference it,
917    // and shipping it unconditionally cost ~9 MB per ABI of dead weight.
918    let libcxx_target = output_dir.join("libc++_shared.so");
919    if staged_libs_need_libcxx(&output_dir).await? {
920        let libcxx_path = ndk_libcxx_path(ndk_path, abi);
921        if libcxx_path.exists() {
922            copy_file(&libcxx_path, &libcxx_target).await?;
923        }
924    } else if libcxx_target.exists() {
925        // Drop the copy an earlier build staged; nothing links it now.
926        fs::remove_file(&libcxx_target).await?;
927    }
928
929    // Every library about to ship must map its LOAD segments at the largest
930    // page size Android runs with; a 4 KB-aligned one is rejected at install
931    // time on 16 KB devices, so fail here naming the file instead.
932    crate::elf::require_aligned_shared_libraries(&output_dir).await?;
933
934    Ok(())
935}
936
937/// True when any `.so` staged in `output_dir` lists `libc++_shared.so` in its
938/// `DT_NEEDED` entries.
939///
940/// An unreadable or unparsable library counts as needing it: including the STL
941/// when in doubt is the same behavior the packaging had before, and a corrupt
942/// native library is going to fail loudly on the device anyway.
943async fn staged_libs_need_libcxx(output_dir: &Path) -> eyre::Result<bool> {
944    let output_dir = output_dir.to_path_buf();
945    unblock(move || {
946        let mut needs = false;
947        for entry in std::fs::read_dir(&output_dir)? {
948            let path = entry?.path();
949            if path.extension() != Some(std::ffi::OsStr::new("so")) {
950                continue;
951            }
952            let needed = std::fs::read(&path)
953                .ok()
954                .and_then(|data| elf_needs_libcxx(&data))
955                .unwrap_or_else(|| {
956                    tracing::warn!(
957                        library = %path.display(),
958                        "could not parse staged library; assuming it needs libc++_shared.so"
959                    );
960                    true
961                });
962            needs |= needed;
963        }
964        Ok(needs)
965    })
966    .await
967}
968
969/// `true` when the ELF data's dynamic section `DT_NEEDED`s `libc++_shared.so`;
970/// `None` when the data is not a parseable ELF image at all.
971fn elf_needs_libcxx(data: &[u8]) -> Option<bool> {
972    use object::read::elf::{Dyn as _, ElfFile, FileHeader};
973
974    fn scan<Elf>(data: &[u8]) -> Option<bool>
975    where
976        Elf: FileHeader<Endian = object::Endianness>,
977    {
978        let file = ElfFile::<Elf>::parse(data).ok()?;
979        let endian = file.endian();
980        let sections = file.elf_section_table();
981        let (dyns, strings_index) = sections.dynamic(endian, data).ok()??;
982        let strings = sections.strings(endian, data, strings_index).ok()?;
983        Some(dyns.iter().any(|d| {
984            d.tag32(endian) == Some(object::elf::DT_NEEDED)
985                && d.string(endian, strings).ok() == Some(&b"libc++_shared.so"[..])
986        }))
987    }
988
989    scan::<object::elf::FileHeader64<object::Endianness>>(data)
990        .or_else(|| scan::<object::elf::FileHeader32<object::Endianness>>(data))
991}
992
993// ============================================================================
994// Clean
995// ============================================================================
996
997/// Clean Gradle build artifacts for Android.
998///
999/// # Errors
1000/// Returns an error if the Gradle clean command fails.
1001pub async fn clean_android(project: &Project) -> eyre::Result<()> {
1002    let backend_path = project.backend_path::<AndroidBackend>();
1003    let gradlew = backend_path.join(if cfg!(windows) {
1004        "gradlew.bat"
1005    } else {
1006        "gradlew"
1007    });
1008
1009    if !gradlew.exists() {
1010        // No Android project to clean
1011        return Ok(());
1012    }
1013
1014    // Set JAVA_HOME to Android Studio's bundled JDK to avoid JDK version conflicts
1015    let host = Host::current();
1016    let mut cmd = gradle_cmd(&gradlew, &backend_path, "clean");
1017
1018    if let Some(java_home) = Java::detect_home(&host).await {
1019        cmd.env("JAVA_HOME", java_home);
1020    }
1021    if let Some(sdk_path) = AndroidSdk::detect_path(&host) {
1022        cmd.env("ANDROID_HOME", &sdk_path)
1023            .env("ANDROID_SDK_ROOT", &sdk_path);
1024    }
1025    apply_gradle_proxy_env(&host, &mut cmd)?;
1026
1027    let output = cmd.output().await?;
1028
1029    if !output.status.success() {
1030        let stderr = String::from_utf8_lossy(&output.stderr);
1031        bail!("Gradle clean failed: {}", stderr.trim());
1032    }
1033
1034    Ok(())
1035}
1036
1037// ============================================================================
1038// Platform Support Check
1039// ============================================================================
1040
1041/// Check if a platform is supported by the Android backend.
1042#[must_use]
1043pub const fn is_android_platform(platform: TargetPlatform) -> bool {
1044    matches!(platform, TargetPlatform::Android)
1045}
1046
1047// ============================================================================
1048// Asset and Font Handling
1049// ============================================================================
1050
1051/// Copy project assets and dependency fonts to the Android assets directory.
1052async fn copy_assets_and_fonts(
1053    project: &Project,
1054    backend_path: &Path,
1055    sccache_path: Option<&Path>,
1056    dev_server: bool,
1057    progress: Option<&BuildProgress>,
1058) -> eyre::Result<()> {
1059    let assets_dir = backend_path.join("app/src/main/assets");
1060
1061    // Stage project assets using platform-native conventions.
1062    let manifest = assets::stage_project_assets_for_android(
1063        project,
1064        backend_path,
1065        sccache_path,
1066        dev_server,
1067        progress,
1068    )
1069    .await?;
1070
1071    // Scan and resolve dependency fonts
1072    let font_declarations = assets::scan_fonts(project).await?;
1073    let mut resolved_fonts = assets::resolve_fonts(font_declarations).await?;
1074    resolved_fonts.extend(assets::scan_project_font_assets(&manifest)?);
1075
1076    if !resolved_fonts.is_empty() {
1077        // Copy fonts to assets/fonts/
1078        let fonts_dest = assets_dir.join("fonts");
1079        assets::copy_fonts(&resolved_fonts, &fonts_dest).await?;
1080
1081        info!("Copied {} fonts to Android app", resolved_fonts.len());
1082    }
1083
1084    // Always generate WaterUIFonts.kt (even if empty) since MainActivity references it
1085    let java_dir = backend_path.join("app/src/main/java");
1086    generate_font_registration_kotlin(project, &resolved_fonts, &java_dir).await?;
1087
1088    Ok(())
1089}
1090
1091#[derive(Template)]
1092#[template(
1093    path = "src/templates/android_dynamic/WaterUIFonts.kt.tpl",
1094    escape = "none"
1095)]
1096struct WaterUiFontsKotlinTemplate<'a> {
1097    namespace: &'a str,
1098    font_entries: &'a [FontRegistrationTemplateEntry],
1099}
1100
1101/// Generate WaterUIFonts.kt file for registering custom fonts.
1102async fn generate_font_registration_kotlin(
1103    project: &Project,
1104    fonts: &[ResolvedFont],
1105    java_dir: &Path,
1106) -> eyre::Result<()> {
1107    // Get the package namespace from the project
1108    let namespace = project.bundle_identifier().android_package_name();
1109
1110    // Clean up legacy layout: older CLI versions wrote `WaterUIFonts.kt` directly under
1111    // `app/src/main/java/` (but still declared the app package), which can cause
1112    // Kotlin redeclaration errors after we started generating into the package dir.
1113    let legacy_path = java_dir.join("WaterUIFonts.kt");
1114    let _ = fs::remove_file(&legacy_path).await;
1115
1116    // Build font entries
1117    let font_entries = fonts
1118        .iter()
1119        .map(|font| FontRegistrationTemplateEntry {
1120            family_name: font.name.clone(),
1121            file_name: font
1122                .path
1123                .file_name()
1124                .and_then(|name| name.to_str())
1125                .unwrap_or_default()
1126                .to_string(),
1127        })
1128        .collect::<Vec<_>>();
1129
1130    let content = WaterUiFontsKotlinTemplate {
1131        namespace: namespace.as_str(),
1132        font_entries: &font_entries,
1133    }
1134    .render()
1135    .map_err(|error| eyre::eyre!("Failed to render WaterUIFonts.kt template: {error}"))?;
1136
1137    // Create the package directory structure
1138    let package_dir = java_dir.join(namespace.as_str().replace('.', "/"));
1139    fs::create_dir_all(&package_dir).await?;
1140
1141    let kotlin_path = package_dir.join("WaterUIFonts.kt");
1142    fs::write(&kotlin_path, content).await?;
1143
1144    debug!("Generated {}", kotlin_path.display());
1145
1146    Ok(())
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151    use super::elf_needs_libcxx;
1152
1153    #[test]
1154    fn elf_needs_libcxx_rejects_non_elf_data() {
1155        // Verified against real NDK binaries during development (a clang++
1156        // shared object reports `Some(true)`, `libc++_shared.so` itself
1157        // `Some(false)`); the committed test covers only the reject path so it
1158        // needs no fixtures.
1159        assert_eq!(elf_needs_libcxx(b"not an elf"), None);
1160        assert_eq!(elf_needs_libcxx(&[]), None);
1161        assert_eq!(elf_needs_libcxx(&[0x7f, b'E', b'L', b'F']), None);
1162    }
1163}