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