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, 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/// Create a wrapper `CMake` toolchain file that sets `ANDROID_ABI` before including
109/// the NDK's toolchain. This is required because cmake-rs doesn't pass `ANDROID_ABI`
110/// as a -D define, causing the NDK toolchain to default to armeabi-v7a.
111///
112/// Returns the path to the created wrapper toolchain file.
113async fn create_android_toolchain_wrapper(
114    ndk_path: &Path,
115    abi: AndroidAbi,
116    api_level: u32,
117) -> eyre::Result<PathBuf> {
118    // Create wrapper in a temp directory that persists for the build
119    let wrapper_dir = std::env::temp_dir().join("waterui-cmake-toolchains");
120    fs::create_dir_all(&wrapper_dir).await?;
121
122    let wrapper_path = wrapper_dir.join(format!("android-{}.cmake", abi.as_str()));
123    let ndk_toolchain = ndk_path.join("build/cmake/android.toolchain.cmake");
124
125    let content = format!(
126        include_str!("android_toolchain_wrapper.cmake.tpl"),
127        abi = abi.as_str(),
128        api_level = api_level,
129        ndk_toolchain = ndk_toolchain.display(),
130        asm_compiler = ndk_clang_path(ndk_path, abi, false, api_level).display(),
131    );
132    fs::write(&wrapper_path, content).await?;
133
134    Ok(wrapper_path)
135}
136
137/// Get the NDK clang++ (C++ compiler) path for the given ABI.
138fn ndk_cxx_path(ndk_path: &Path, abi: AndroidAbi, api_level: u32) -> PathBuf {
139    ndk_clang_path(ndk_path, abi, true, api_level)
140}
141
142/// Get the path to `libc++_shared.so` in the NDK.
143///
144/// NDK r23+ ships it under `sysroot/usr/lib/<triple>/`, while older Android
145/// NDK releases used `sources/cxx-stl/llvm-libc++/libs/<abi>/`.
146fn ndk_libcxx_path(ndk_path: &Path, abi: AndroidAbi) -> PathBuf {
147    let new_path = ndk_path
148        .join("toolchains/llvm/prebuilt")
149        .join(ndk_host_tag(ndk_path))
150        .join("sysroot/usr/lib")
151        .join(abi.ndk_libcxx_triple())
152        .join("libc++_shared.so");
153
154    if new_path.exists() {
155        return new_path;
156    }
157
158    ndk_path
159        .join("sources/cxx-stl/llvm-libc++/libs")
160        .join(abi.as_str())
161        .join("libc++_shared.so")
162}
163
164/// Represents an Android platform for a specific architecture.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
166pub enum AndroidAbi {
167    /// ARM64 (arm64-v8a) - modern Android devices
168    Arm64V8a,
169    /// `x86_64` - emulators on Intel/AMD
170    X86_64,
171    /// `ARMv7` (armeabi-v7a) - older 32-bit devices
172    ArmeabiV7a,
173    /// x86 - older 32-bit emulators
174    X86,
175}
176
177/// Error returned when parsing an unsupported Android ABI string.
178#[derive(Debug, thiserror::Error)]
179#[error("Unsupported Android ABI: {abi}")]
180pub struct UnsupportedAndroidAbi {
181    abi: String,
182}
183
184impl FromStr for AndroidAbi {
185    type Err = UnsupportedAndroidAbi;
186
187    fn from_str(s: &str) -> Result<Self, Self::Err> {
188        match s {
189            "arm64-v8a" => Ok(Self::Arm64V8a),
190            "x86_64" => Ok(Self::X86_64),
191            "armeabi-v7a" => Ok(Self::ArmeabiV7a),
192            "x86" => Ok(Self::X86),
193            other => Err(UnsupportedAndroidAbi {
194                abi: other.to_string(),
195            }),
196        }
197    }
198}
199
200impl AndroidAbi {
201    #[must_use]
202    /// Android ABI string used by the Android toolchain (e.g. `arm64-v8a`).
203    pub const fn as_str(self) -> &'static str {
204        match self {
205            Self::Arm64V8a => "arm64-v8a",
206            Self::X86_64 => "x86_64",
207            Self::ArmeabiV7a => "armeabi-v7a",
208            Self::X86 => "x86",
209        }
210    }
211
212    #[must_use]
213    /// Target triple prefix used by the NDK toolchain binaries (clang, clang++).
214    pub const fn ndk_target(self) -> &'static str {
215        match self {
216            Self::Arm64V8a => "aarch64-linux-android",
217            Self::X86_64 => "x86_64-linux-android",
218            Self::ArmeabiV7a => "armv7a-linux-androideabi",
219            Self::X86 => "i686-linux-android",
220        }
221    }
222
223    #[must_use]
224    /// Target triple used by NDK sysroot libc++ paths.
225    pub const fn ndk_libcxx_triple(self) -> &'static str {
226        match self {
227            Self::Arm64V8a => "aarch64-linux-android",
228            Self::X86_64 => "x86_64-linux-android",
229            Self::ArmeabiV7a => "arm-linux-androideabi",
230            Self::X86 => "i686-linux-android",
231        }
232    }
233}
234
235/// Represents an Android platform for a specific ABI.
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub struct AndroidPlatform {
238    abi: AndroidAbi,
239}
240
241struct AndroidBuildContext {
242    abi: AndroidAbi,
243    ndk_path: PathBuf,
244    linker: PathBuf,
245    ar: PathBuf,
246    cxx: PathBuf,
247    target_underscore: String,
248    target_upper: String,
249    llvm_envs: Vec<(String, std::ffi::OsString)>,
250    java_home: PathBuf,
251    java_bin_dir: PathBuf,
252    kotlin_compiler: PathBuf,
253    kotlin_bin_dir: PathBuf,
254    kotlin_home: PathBuf,
255    sdk_path: PathBuf,
256    android_jar: PathBuf,
257    wrapper_toolchain: PathBuf,
258    android_platform: String,
259}
260
261impl AndroidPlatform {
262    /// Create a new Android platform with the specified ABI.
263    #[must_use]
264    pub const fn new(abi: AndroidAbi) -> Self {
265        Self { abi }
266    }
267
268    /// Create an Android platform for arm64-v8a (most common modern Android devices).
269    #[must_use]
270    pub const fn arm64() -> Self {
271        Self {
272            abi: AndroidAbi::Arm64V8a,
273        }
274    }
275
276    /// Create an Android platform for `x86_64` (emulators on Intel/AMD).
277    #[must_use]
278    pub const fn x86_64() -> Self {
279        Self {
280            abi: AndroidAbi::X86_64,
281        }
282    }
283
284    #[must_use]
285    /// Return the ABI for this platform.
286    pub const fn abi(&self) -> AndroidAbi {
287        self.abi
288    }
289
290    #[must_use]
291    /// Return the ABI string for this platform.
292    pub const fn abi_str(&self) -> &'static str {
293        self.abi.as_str()
294    }
295
296    /// Create an Android platform from an ABI string.
297    ///
298    /// # Errors
299    /// Returns an error if the ABI is not supported.
300    pub fn try_from_abi(abi: &str) -> eyre::Result<Self> {
301        let abi = AndroidAbi::from_str(abi).map_err(|e| eyre::eyre!(e))?;
302        Ok(Self { abi })
303    }
304}
305
306/// All supported Android ABIs.
307pub const ALL_ABIS: &[AndroidAbi] = &[
308    AndroidAbi::Arm64V8a,
309    AndroidAbi::X86_64,
310    AndroidAbi::ArmeabiV7a,
311    AndroidAbi::X86,
312];
313
314impl AndroidPlatform {
315    /// Returns all supported Android platforms (all architectures).
316    #[must_use]
317    pub fn all() -> Vec<Self> {
318        ALL_ABIS.iter().copied().map(Self::new).collect()
319    }
320
321    /// Get the target triple for this Android platform.
322    #[must_use]
323    pub const fn triple(&self) -> Triple {
324        let architecture = match self.abi {
325            AndroidAbi::Arm64V8a => Architecture::Aarch64(Aarch64Architecture::Aarch64),
326            AndroidAbi::X86_64 => Architecture::X86_64,
327            AndroidAbi::ArmeabiV7a => Architecture::Arm(target_lexicon::ArmArchitecture::Armv7),
328            AndroidAbi::X86 => Architecture::X86_32(target_lexicon::X86_32Architecture::I686),
329        };
330        Triple {
331            architecture,
332            vendor: target_lexicon::Vendor::Unknown,
333            operating_system: target_lexicon::OperatingSystem::Linux,
334            environment: target_lexicon::Environment::Android,
335            binary_format: target_lexicon::BinaryFormat::Elf,
336        }
337    }
338
339    /// Build Rust library for this Android platform.
340    ///
341    /// # Errors
342    /// Returns an error if the build fails.
343    pub async fn build(&self, project: &Project, options: BuildOptions) -> eyre::Result<PathBuf> {
344        // Android cannot share the Rust runtime between the application and a
345        // loadable module. `-Cprefer-dynamic` links `std` from the toolchain's
346        // prebuilt dylib, and rustup ships that one with 4 KB-aligned LOAD
347        // segments; a device with 16 KB pages rejects it, and a debuggable build
348        // is told so in an "Android App Compatibility" dialog. Nothing here can
349        // realign a prebuilt, and giving the two objects a static copy each
350        // would give the process two allocators, two panic runtimes and two sets
351        // of thread-locals. So the runtime is linked in, which is what a
352        // packaged build already does — the `-z max-page-size=16384` below then
353        // covers everything the package ships.
354        let options = options.with_static_runtime();
355        // Resolve fonts BEFORE cargo build - this ensures icons.json is downloaded
356        // for crates like fontawesome7 that need it during build.rs
357        let font_declarations = crate::assets::scan_fonts(project).await?;
358        let _resolved_fonts = crate::assets::resolve_fonts(font_declarations).await?;
359
360        let abi = self.abi();
361        let triple = self.triple();
362        let min_api_level = project
363            .resolved_framework()
364            .await?
365            .android_min_api_level()?;
366        let host = Host::current();
367        let build_context =
368            resolve_android_build_context(&host, abi, &triple, min_api_level).await?;
369        let build = configure_android_rust_build(&host, project, &triple, &build_context, &options)
370            .await?
371            .with_envs(options.cargo_envs().iter().cloned())
372            .with_target_dir(project.water_target_dir(options.linkage()).await?);
373
374        let lib_dir = build.build_lib(options.is_release()).await?;
375        copy_android_build_outputs(project, &options, abi, &build_context.ndk_path, &lib_dir)
376            .await?;
377        Ok(lib_dir)
378    }
379
380    /// Clean all jniLibs directories to remove stale libraries from previous builds.
381    ///
382    /// # Errors
383    /// Returns an error if the directory cannot be removed.
384    pub async fn clean_jni_libs(project: &Project) -> eyre::Result<()> {
385        let jni_libs_dir = project
386            .backend_path::<AndroidBackend>()
387            .join("app/src/main/jniLibs");
388
389        if jni_libs_dir.exists() {
390            fs::remove_dir_all(&jni_libs_dir).await?;
391        }
392        Ok(())
393    }
394
395    /// Package the Android app with specific ABIs.
396    ///
397    /// This is used when building for multiple architectures. The ABIs parameter
398    /// controls which native libraries are included in the final APK.
399    ///
400    /// # Errors
401    /// Returns an error if Gradle build fails.
402    pub async fn package_with_abis(
403        project: &Project,
404        options: PackageOptions,
405        abis: &[AndroidAbi],
406    ) -> eyre::Result<Artifact> {
407        let backend_path = project.backend_path::<AndroidBackend>();
408
409        // Copy project assets and dependency fonts
410        copy_assets_and_fonts(project, &backend_path, None, options.uses_dev_server()).await?;
411
412        let gradlew = backend_path.join(if cfg!(windows) {
413            "gradlew.bat"
414        } else {
415            "gradlew"
416        });
417
418        let (command_name, output_kind, variant) =
419            match (options.is_distribution(), options.is_debug()) {
420                (true, false) => ("bundleRelease", OutputKind::Bundle, "release"),
421                (false, false) => ("assembleRelease", OutputKind::Apk, "release"),
422                (false, true) => ("assembleDebug", OutputKind::Apk, "debug"),
423                (true, true) => ("bundleDebug", OutputKind::Bundle, "debug"),
424            };
425
426        // Join ABIs with comma for the environment variable
427        let abis_str = abis
428            .iter()
429            .map(|a| a.as_str())
430            .collect::<Vec<_>>()
431            .join(",");
432
433        // Set JAVA_HOME to Android Studio's bundled JDK to avoid JDK version conflicts
434        // (e.g., Homebrew's JDK 25 is not supported by Android Gradle Plugin)
435        let mut cmd = gradle_cmd(&gradlew, &backend_path, command_name);
436        cmd.env("WATERUI_SKIP_RUST_BUILD", "1")
437            .env("WATERUI_ANDROID_ABIS", &abis_str);
438
439        let host = Host::current();
440        if let Some(java_home) = Java::detect_home(&host).await {
441            cmd.env("JAVA_HOME", java_home);
442        }
443        if let Some(sdk_path) = AndroidSdk::detect_path(&host) {
444            cmd.env("ANDROID_HOME", &sdk_path)
445                .env("ANDROID_SDK_ROOT", &sdk_path);
446        }
447        apply_gradle_proxy_env(&host, &mut cmd)?;
448
449        let output = cmd.output().await?;
450
451        if !output.status.success() {
452            let stderr = String::from_utf8_lossy(&output.stderr);
453            let stdout = String::from_utf8_lossy(&output.stdout);
454            bail!("Gradle build failed:\n{}\n{}", stdout.trim(), stderr.trim());
455        }
456
457        let path = packaged_artifact(&backend_path, output_kind, variant).await?;
458        Ok(Artifact::new(project.bundle_identifier(), path))
459    }
460
461    /// List available Android Virtual Devices (emulators) on `host`.
462    ///
463    /// # Errors
464    /// Returns an error if the emulator tool is not found.
465    pub async fn list_avds(host: &Host) -> eyre::Result<Vec<String>> {
466        let emulator_path = AndroidSdk::emulator_path(host)
467            .ok_or_else(|| eyre::eyre!("Android emulator not found"))?;
468
469        let output = host.output(&emulator_path, ["-list-avds"]).await?;
470
471        let stdout = String::from_utf8_lossy(&output.stdout);
472        let avds: Vec<String> = stdout
473            .lines()
474            .filter(|line| !line.is_empty())
475            .map(String::from)
476            .collect();
477
478        Ok(avds)
479    }
480}
481
482async fn resolve_android_build_context(
483    host: &Host,
484    abi: AndroidAbi,
485    triple: &Triple,
486    api_level: u32,
487) -> eyre::Result<AndroidBuildContext> {
488    let ndk_path = AndroidNdk::detect_path(host).ok_or_else(|| {
489        eyre::eyre!("Android NDK not found. Please install it via Android Studio.")
490    })?;
491    let linker = ndk_linker_path(&ndk_path, abi, api_level);
492    let ar = ndk_ar_path(&ndk_path);
493    let cxx = ndk_cxx_path(&ndk_path, abi, api_level);
494    // The toolchain gate only proves the NDK's host toolchain executes; the
495    // wrapper for the framework's floor is a separate fact, and a missing one
496    // otherwise surfaces minutes later as a linker cargo cannot find.
497    for wrapper in [&linker, &cxx] {
498        if !wrapper.is_file() {
499            eyre::bail!(
500                "the Android NDK at {} ships no compiler wrapper for API {api_level} \
501                 ({}); the framework's android-min-api-level needs an NDK that targets it",
502                ndk_path.display(),
503                wrapper.display()
504            );
505        }
506    }
507    let target_underscore = triple.to_string().replace('-', "_");
508    let target_upper = target_underscore.to_uppercase();
509    let llvm_envs = resolve_windows_arm64_llvm_envs(host).await?;
510    let (java_home, java_bin_dir) = resolve_java_home(host).await?;
511    let (kotlin_compiler, kotlin_bin_dir, kotlin_home) = resolve_kotlin_home(host).await?;
512    let (sdk_path, android_jar) = resolve_android_sdk_paths(host).await?;
513    let wrapper_toolchain = create_android_toolchain_wrapper(&ndk_path, abi, api_level).await?;
514
515    Ok(AndroidBuildContext {
516        abi,
517        ndk_path,
518        linker,
519        ar,
520        cxx,
521        target_underscore,
522        target_upper,
523        llvm_envs,
524        java_home,
525        java_bin_dir,
526        kotlin_compiler,
527        kotlin_bin_dir,
528        kotlin_home,
529        sdk_path,
530        android_jar,
531        wrapper_toolchain,
532        android_platform: format!("android-{api_level}"),
533    })
534}
535
536async fn resolve_windows_arm64_llvm_envs(
537    host: &Host,
538) -> eyre::Result<Vec<(String, std::ffi::OsString)>> {
539    WindowsArm64LlvmToolchain
540        .cargo_envs(host)
541        .await
542        .map_err(|error| match error {
543            ToolchainError::Fixable(_) => eyre::eyre!(
544                "Windows ARM64 LLVM toolchain is missing. Run `water doctor --fix` to install it automatically."
545            ),
546            ToolchainError::Unfixable(unfixable) => {
547                eyre::eyre!("Windows ARM64 LLVM toolchain check failed: {unfixable}")
548            }
549        })
550}
551
552async fn resolve_java_home(host: &Host) -> eyre::Result<(PathBuf, PathBuf)> {
553    let java_home = Java::detect_home(host).await.ok_or_else(|| {
554        eyre::eyre!(
555            "Java runtime not found. Install a JDK (or Android Studio JBR), then re-run `water doctor --fix`."
556        )
557    })?;
558    let java_bin_dir = java_home.join("bin");
559    Ok((java_home, java_bin_dir))
560}
561
562async fn resolve_kotlin_home(host: &Host) -> eyre::Result<(PathBuf, PathBuf, PathBuf)> {
563    let kotlin_compiler = Kotlin::detect_path(host).await.ok_or_else(|| {
564        eyre::eyre!(
565            "Kotlin compiler (kotlinc) not found. Install Android Studio or set `KOTLIN_HOME`, then re-run `water doctor`."
566        )
567    })?;
568    let kotlin_bin_dir = kotlin_compiler.parent().map(PathBuf::from).ok_or_else(|| {
569        eyre::eyre!(
570            "Failed to determine Kotlin bin directory from `{}`.",
571            kotlin_compiler.display()
572        )
573    })?;
574    let kotlin_home = kotlin_bin_dir.parent().map(PathBuf::from).ok_or_else(|| {
575        eyre::eyre!(
576            "Failed to determine KOTLIN_HOME from `{}`.",
577            kotlin_bin_dir.display()
578        )
579    })?;
580    Ok((kotlin_compiler, kotlin_bin_dir, kotlin_home))
581}
582
583/// Resolve the SDK root and its newest `android.jar` on `host`.
584///
585/// `AndroidSdk::android_jar_path` walks `platforms/` on disk, so the whole
586/// resolution runs on a blocking thread instead of the executor.
587async fn resolve_android_sdk_paths(host: &Host) -> eyre::Result<(PathBuf, PathBuf)> {
588    let host = host.clone();
589    smol::unblock(move || {
590        let sdk_path = AndroidSdk::detect_path(&host).ok_or_else(|| {
591            eyre::eyre!("Android SDK not found. Please install it via Android Studio.")
592        })?;
593        let android_jar = AndroidSdk::android_jar_path(&host).ok_or_else(|| {
594            eyre::eyre!(
595                "Android platforms not found in SDK at {}. Install an Android platform (SDK) in Android Studio.",
596                sdk_path.display()
597            )
598        })?;
599        Ok((sdk_path, android_jar))
600    })
601    .await
602}
603
604/// The `waterui-ffi` features an Android runtime is compiled with.
605///
606/// See [`crate::apple::platform::apple_ffi_dependency_features`] for why anything
607/// loaded into that runtime must be compiled with the same set.
608///
609/// # Errors
610///
611/// Returns an error when the project's enabled capabilities cannot be resolved.
612pub(crate) async fn android_ffi_dependency_features(
613    project: &Project,
614) -> eyre::Result<Vec<String>> {
615    let mut features = vec!["waterui-ffi/android-jni".to_string()];
616    features.extend(crate::project_model::assets::capability_ffi_features(project).await?);
617    // Android has no player or map WaterUI bridges, so it draws both itself.
618    features.extend(crate::project_model::assets::self_drawn_realization_features(project).await?);
619    Ok(features)
620}
621
622async fn configure_android_rust_build(
623    host: &Host,
624    project: &Project,
625    triple: &Triple,
626    context: &AndroidBuildContext,
627    options: &BuildOptions,
628) -> eyre::Result<RustBuild> {
629    // Android loads the JNI shared object and nothing else, so build only that crate
630    // type instead of also archiving the whole dependency graph into a staticlib.
631    let mut build = RustBuild::new(project.ffi_crate_path(), triple.clone())
632        .with_project(project)
633        .with_features(android_ffi_dependency_features(project).await?)
634        .with_crate_type_override("cdylib")
635        // Devices with 16 KB pages (Pixel 9 class and Play's 2025 requirement)
636        // refuse or warn on 4 KB-aligned LOAD segments.
637        .with_rustc_flag("-Clink-arg=-Wl,-z,max-page-size=16384");
638    if let Some(sccache_path) = options.sccache_path() {
639        build = build.with_sccache(sccache_path.to_path_buf());
640    }
641    for (key, value) in &context.llvm_envs {
642        build = build.with_env(key.clone(), value.clone());
643    }
644
645    build = build
646        .with_env(
647            format!("CARGO_TARGET_{}_LINKER", context.target_upper),
648            context.linker.as_os_str(),
649        )
650        .with_env(
651            format!("CARGO_TARGET_{}_AR", context.target_upper),
652            context.ar.as_os_str(),
653        )
654        .with_env(
655            format!("CC_{}", context.target_underscore),
656            context.linker.as_os_str(),
657        )
658        .with_env(
659            format!("CXX_{}", context.target_underscore),
660            context.cxx.as_os_str(),
661        )
662        .with_env(
663            format!("AR_{}", context.target_underscore),
664            context.ar.as_os_str(),
665        )
666        .with_env("ANDROID_NDK", context.ndk_path.as_os_str())
667        .with_env("ANDROID_NDK_HOME", context.ndk_path.as_os_str())
668        .with_env("ANDROID_NDK_ROOT", context.ndk_path.as_os_str())
669        .with_env("ANDROID_HOME", context.sdk_path.as_os_str())
670        .with_env("ANDROID_SDK_ROOT", context.sdk_path.as_os_str())
671        .with_env("ANDROID_JAR", context.android_jar.as_os_str())
672        .with_env("JAVA_HOME", context.java_home.as_os_str())
673        .with_env("KOTLIN_HOME", context.kotlin_home.as_os_str())
674        .with_env("KOTLINC", context.kotlin_compiler.as_os_str())
675        .with_env(
676            "CMAKE_TOOLCHAIN_FILE",
677            context.wrapper_toolchain.as_os_str(),
678        )
679        .with_env(
680            format!("CMAKE_TOOLCHAIN_FILE_{}", context.target_underscore),
681            context.wrapper_toolchain.as_os_str(),
682        )
683        .with_env("CMAKE_ASM_COMPILER", context.linker.as_os_str())
684        .with_env("ANDROID_ABI", context.abi.as_str())
685        .with_env("ANDROID_PLATFORM", &context.android_platform)
686        .with_env("PKG_CONFIG_ALLOW_CROSS", "1")
687        .with_env(
688            format!("PKG_CONFIG_ALLOW_CROSS_{}", context.target_underscore),
689            "1",
690        )
691        .with_env(format!("PKG_CONFIG_ALLOW_CROSS_{triple}"), "1");
692
693    let current_path = host
694        .env("PATH")
695        .ok_or_else(|| eyre::eyre!("PATH environment variable is not set"))?;
696    let mut paths: Vec<PathBuf> = std::env::split_paths(&current_path).collect();
697    paths.insert(0, context.java_bin_dir.clone());
698    paths.insert(0, context.kotlin_bin_dir.clone());
699    let new_path = std::env::join_paths(paths).map_err(|error| {
700        eyre::eyre!("Failed to construct PATH for Java/Kotlin compiler resolution: {error}")
701    })?;
702
703    Ok(build.with_env("PATH", new_path))
704}
705
706async fn copy_android_build_outputs(
707    project: &Project,
708    options: &BuildOptions,
709    abi: AndroidAbi,
710    ndk_path: &Path,
711    lib_dir: &Path,
712) -> eyre::Result<()> {
713    let lib_name = project.ffi_crate_name().replace('-', "_");
714    let source_lib = lib_dir.join(format!("lib{lib_name}.so"));
715
716    if !source_lib.exists() {
717        bail!(
718            "Rust shared library not found at {}. Did the build succeed?",
719            source_lib.display()
720        );
721    }
722
723    let output_dir = options.output_dir().map_or_else(
724        || {
725            project
726                .backend_path::<AndroidBackend>()
727                .join("app/src/main/jniLibs")
728                .join(abi.as_str())
729        },
730        std::path::Path::to_path_buf,
731    );
732    fs::create_dir_all(&output_dir).await?;
733    copy_file(&source_lib, &output_dir.join("libwaterui_app.so")).await?;
734
735    if options.linkage() == RustLinkage::SharedRuntime {
736        let triple = AndroidPlatform::new(abi).triple();
737        let libraries = RustDynamicLibraries::resolve(lib_dir, &triple).await?;
738        libraries.stage(&output_dir).await?;
739    } else {
740        RustDynamicLibraries::remove_staged(&output_dir, &AndroidPlatform::new(abi).triple())
741            .await?;
742    }
743
744    // `libc++_shared.so` only belongs in the package when a staged native
745    // library actually links the C++ STL — Rust-only builds never reference it,
746    // and shipping it unconditionally cost ~9 MB per ABI of dead weight.
747    let libcxx_target = output_dir.join("libc++_shared.so");
748    if staged_libs_need_libcxx(&output_dir).await? {
749        let libcxx_path = ndk_libcxx_path(ndk_path, abi);
750        if libcxx_path.exists() {
751            copy_file(&libcxx_path, &libcxx_target).await?;
752        }
753    } else if libcxx_target.exists() {
754        // Drop the copy an earlier build staged; nothing links it now.
755        fs::remove_file(&libcxx_target).await?;
756    }
757
758    Ok(())
759}
760
761/// True when any `.so` staged in `output_dir` lists `libc++_shared.so` in its
762/// `DT_NEEDED` entries.
763///
764/// An unreadable or unparsable library counts as needing it: including the STL
765/// when in doubt is the same behavior the packaging had before, and a corrupt
766/// native library is going to fail loudly on the device anyway.
767async fn staged_libs_need_libcxx(output_dir: &Path) -> eyre::Result<bool> {
768    let output_dir = output_dir.to_path_buf();
769    unblock(move || {
770        let mut needs = false;
771        for entry in std::fs::read_dir(&output_dir)? {
772            let path = entry?.path();
773            if path.extension() != Some(std::ffi::OsStr::new("so")) {
774                continue;
775            }
776            let needed = std::fs::read(&path)
777                .ok()
778                .and_then(|data| elf_needs_libcxx(&data))
779                .unwrap_or_else(|| {
780                    tracing::warn!(
781                        library = %path.display(),
782                        "could not parse staged library; assuming it needs libc++_shared.so"
783                    );
784                    true
785                });
786            needs |= needed;
787        }
788        Ok(needs)
789    })
790    .await
791}
792
793/// `true` when the ELF data's dynamic section `DT_NEEDED`s `libc++_shared.so`;
794/// `None` when the data is not a parseable ELF image at all.
795fn elf_needs_libcxx(data: &[u8]) -> Option<bool> {
796    use object::read::elf::{Dyn as _, ElfFile, FileHeader};
797
798    fn scan<Elf>(data: &[u8]) -> Option<bool>
799    where
800        Elf: FileHeader<Endian = object::Endianness>,
801    {
802        let file = ElfFile::<Elf>::parse(data).ok()?;
803        let endian = file.endian();
804        let sections = file.elf_section_table();
805        let (dyns, strings_index) = sections.dynamic(endian, data).ok()??;
806        let strings = sections.strings(endian, data, strings_index).ok()?;
807        Some(dyns.iter().any(|d| {
808            d.tag32(endian) == Some(object::elf::DT_NEEDED)
809                && d.string(endian, strings).ok() == Some(&b"libc++_shared.so"[..])
810        }))
811    }
812
813    scan::<object::elf::FileHeader64<object::Endianness>>(data)
814        .or_else(|| scan::<object::elf::FileHeader32<object::Endianness>>(data))
815}
816
817// ============================================================================
818// Clean
819// ============================================================================
820
821/// Clean Gradle build artifacts for Android.
822///
823/// # Errors
824/// Returns an error if the Gradle clean command fails.
825pub async fn clean_android(project: &Project) -> eyre::Result<()> {
826    let backend_path = project.backend_path::<AndroidBackend>();
827    let gradlew = backend_path.join(if cfg!(windows) {
828        "gradlew.bat"
829    } else {
830        "gradlew"
831    });
832
833    if !gradlew.exists() {
834        // No Android project to clean
835        return Ok(());
836    }
837
838    // Set JAVA_HOME to Android Studio's bundled JDK to avoid JDK version conflicts
839    let host = Host::current();
840    let mut cmd = gradle_cmd(&gradlew, &backend_path, "clean");
841
842    if let Some(java_home) = Java::detect_home(&host).await {
843        cmd.env("JAVA_HOME", java_home);
844    }
845    if let Some(sdk_path) = AndroidSdk::detect_path(&host) {
846        cmd.env("ANDROID_HOME", &sdk_path)
847            .env("ANDROID_SDK_ROOT", &sdk_path);
848    }
849    apply_gradle_proxy_env(&host, &mut cmd)?;
850
851    let output = cmd.output().await?;
852
853    if !output.status.success() {
854        let stderr = String::from_utf8_lossy(&output.stderr);
855        bail!("Gradle clean failed: {}", stderr.trim());
856    }
857
858    Ok(())
859}
860
861// ============================================================================
862// Platform Support Check
863// ============================================================================
864
865/// Check if a platform is supported by the Android backend.
866#[must_use]
867pub const fn is_android_platform(platform: TargetPlatform) -> bool {
868    matches!(platform, TargetPlatform::Android)
869}
870
871// ============================================================================
872// Asset and Font Handling
873// ============================================================================
874
875/// Copy project assets and dependency fonts to the Android assets directory.
876async fn copy_assets_and_fonts(
877    project: &Project,
878    backend_path: &Path,
879    sccache_path: Option<&Path>,
880    dev_server: bool,
881) -> eyre::Result<()> {
882    let assets_dir = backend_path.join("app/src/main/assets");
883
884    // Stage project assets using platform-native conventions.
885    let manifest =
886        assets::stage_project_assets_for_android(project, backend_path, sccache_path, dev_server)
887            .await?;
888
889    // Scan and resolve dependency fonts
890    let font_declarations = assets::scan_fonts(project).await?;
891    let mut resolved_fonts = assets::resolve_fonts(font_declarations).await?;
892    resolved_fonts.extend(assets::scan_project_font_assets(&manifest)?);
893
894    if !resolved_fonts.is_empty() {
895        // Copy fonts to assets/fonts/
896        let fonts_dest = assets_dir.join("fonts");
897        assets::copy_fonts(&resolved_fonts, &fonts_dest).await?;
898
899        info!("Copied {} fonts to Android app", resolved_fonts.len());
900    }
901
902    // Always generate WaterUIFonts.kt (even if empty) since MainActivity references it
903    let java_dir = backend_path.join("app/src/main/java");
904    generate_font_registration_kotlin(project, &resolved_fonts, &java_dir).await?;
905
906    Ok(())
907}
908
909#[derive(Template)]
910#[template(
911    path = "src/templates/android_dynamic/WaterUIFonts.kt.tpl",
912    escape = "none"
913)]
914struct WaterUiFontsKotlinTemplate<'a> {
915    namespace: &'a str,
916    font_entries: &'a [FontRegistrationTemplateEntry],
917}
918
919/// Generate WaterUIFonts.kt file for registering custom fonts.
920async fn generate_font_registration_kotlin(
921    project: &Project,
922    fonts: &[ResolvedFont],
923    java_dir: &Path,
924) -> eyre::Result<()> {
925    // Get the package namespace from the project
926    let namespace = project.bundle_identifier().android_package_name();
927
928    // Clean up legacy layout: older CLI versions wrote `WaterUIFonts.kt` directly under
929    // `app/src/main/java/` (but still declared the app package), which can cause
930    // Kotlin redeclaration errors after we started generating into the package dir.
931    let legacy_path = java_dir.join("WaterUIFonts.kt");
932    let _ = fs::remove_file(&legacy_path).await;
933
934    // Build font entries
935    let font_entries = fonts
936        .iter()
937        .map(|font| FontRegistrationTemplateEntry {
938            family_name: font.name.clone(),
939            file_name: font
940                .path
941                .file_name()
942                .and_then(|name| name.to_str())
943                .unwrap_or_default()
944                .to_string(),
945        })
946        .collect::<Vec<_>>();
947
948    let content = WaterUiFontsKotlinTemplate {
949        namespace: namespace.as_str(),
950        font_entries: &font_entries,
951    }
952    .render()
953    .map_err(|error| eyre::eyre!("Failed to render WaterUIFonts.kt template: {error}"))?;
954
955    // Create the package directory structure
956    let package_dir = java_dir.join(namespace.as_str().replace('.', "/"));
957    fs::create_dir_all(&package_dir).await?;
958
959    let kotlin_path = package_dir.join("WaterUIFonts.kt");
960    fs::write(&kotlin_path, content).await?;
961
962    debug!("Generated {}", kotlin_path.display());
963
964    Ok(())
965}
966
967#[cfg(test)]
968mod tests {
969    use super::elf_needs_libcxx;
970
971    #[test]
972    fn elf_needs_libcxx_rejects_non_elf_data() {
973        // Verified against real NDK binaries during development (a clang++
974        // shared object reports `Some(true)`, `libc++_shared.so` itself
975        // `Some(false)`); the committed test covers only the reject path so it
976        // needs no fixtures.
977        assert_eq!(elf_needs_libcxx(b"not an elf"), None);
978        assert_eq!(elf_needs_libcxx(&[]), None);
979        assert_eq!(elf_needs_libcxx(&[0x7f, b'E', b'L', b'F']), None);
980    }
981}