Skip to main content

whisker_dev_server/hotpatch/
android_ndk.rs

1//! Android NDK toolchain resolution for the hot-patch link step.
2//!
3//! Mirrors `whisker-build/src/android.rs`'s NDK lookup (deliberately
4//! duplicated, not shared) so the dev-server doesn't pull the
5//! whole `whisker-build` crate into its dep tree just for two
6//! helpers. The two implementations need to stay in sync — if NDK
7//! layouts shift, both have to learn about the new shape.
8//!
9//! What this module gives us:
10//!
11//!   - resolve the NDK root (env first, then `$ANDROID_HOME/ndk/<v>`)
12//!   - pick a host-tag for the toolchain bin dir
13//!   - locate `<prefix><api>-clang` for an ABI
14//!
15//! Tier 1's link step (build_link_plan + run_link_plan) uses the
16//! returned clang as both `linker_path` (what we spawn) and
17//! `WHISKER_REAL_LINKER` (what the linker shim forwards to during the
18//! fat build). Same binary on both sides keeps SDK / sysroot
19//! resolution consistent.
20
21use anyhow::Result;
22use std::path::{Path, PathBuf};
23
24/// NDK versions we know work with Whisker, in preference order.
25/// Same list as `whisker-build/src/android.rs` — keep in sync.
26const PREFERRED_NDKS: &[&str] = &[
27    "23.1.7779620",
28    "25.1.8937393",
29    "26.1.10909125",
30    "26.3.11579264",
31    "27.0.12077973",
32    "27.1.12297006",
33];
34
35/// Find the Android SDK root. Honours `ANDROID_HOME`; otherwise
36/// falls back to the macOS default install location.
37pub fn android_home() -> Result<PathBuf> {
38    if let Some(p) = std::env::var_os("ANDROID_HOME").map(PathBuf::from) {
39        if p.is_dir() {
40            return Ok(p);
41        }
42    }
43    if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
44        let cand = home.join("Library/Android/sdk");
45        if cand.is_dir() {
46            return Ok(cand);
47        }
48    }
49    anyhow::bail!(
50        "ANDROID_HOME not set and no SDK found at the default macOS \
51         location ($HOME/Library/Android/sdk)."
52    )
53}
54
55/// Find the NDK root. `ANDROID_NDK_HOME` wins; otherwise picks the
56/// first installed entry from [`PREFERRED_NDKS`] under
57/// `<ANDROID_HOME>/ndk/`.
58pub fn ndk_home() -> Result<PathBuf> {
59    if let Some(p) = std::env::var_os("ANDROID_NDK_HOME").map(PathBuf::from) {
60        if p.is_dir() {
61            return Ok(p);
62        }
63    }
64    let ndk_dir = android_home()?.join("ndk");
65    for version in PREFERRED_NDKS {
66        let cand = ndk_dir.join(version);
67        if cand.is_dir() {
68            return Ok(cand);
69        }
70    }
71    anyhow::bail!(
72        "no supported NDK found in {} (need one of: {})",
73        ndk_dir.display(),
74        PREFERRED_NDKS.join(", "),
75    )
76}
77
78/// NDK toolchain host tag (`darwin-x86_64` / `linux-x86_64` / …).
79/// NDK ships `darwin-x86_64` even on Apple Silicon.
80pub fn host_tag() -> Result<&'static str> {
81    if cfg!(target_os = "macos") {
82        Ok("darwin-x86_64")
83    } else if cfg!(target_os = "linux") {
84        Ok("linux-x86_64")
85    } else if cfg!(target_os = "windows") {
86        Ok("windows-x86_64")
87    } else {
88        anyhow::bail!("unsupported host OS for Android cross-compilation")
89    }
90}
91
92/// clang's `--target=<prefix><api>` prefix per ABI. Differs from
93/// the Rust triple for `armeabi-v7a` (clang wants
94/// `armv7a-linux-androideabi`).
95pub fn clang_target_prefix(abi: &str) -> Result<&'static str> {
96    match abi {
97        "arm64-v8a" => Ok("aarch64-linux-android"),
98        "armeabi-v7a" => Ok("armv7a-linux-androideabi"),
99        "x86_64" => Ok("x86_64-linux-android"),
100        "x86" => Ok("i686-linux-android"),
101        other => anyhow::bail!("unknown Android ABI: {other}"),
102    }
103}
104
105/// Locate the NDK clang for `(abi, api)`. The returned path is the
106/// API-pinned wrapper (e.g. `aarch64-linux-android21-clang`) — both
107/// `Builder::with_capture` (as `WHISKER_REAL_LINKER`) and the
108/// thin-rebuild link step (as `linker_path`) need this same
109/// binary.
110pub fn android_clang_for(abi: &str, api: u32) -> Result<PathBuf> {
111    let bin = ndk_bin_dir()?;
112    let prefix = clang_target_prefix(abi)?;
113    let clang = bin.join(format!("{prefix}{api}-clang"));
114    if !clang.exists() {
115        anyhow::bail!(
116            "NDK clang not found: {} — check that the NDK is installed and \
117             API level {api} is supported",
118            clang.display(),
119        );
120    }
121    Ok(clang)
122}
123
124/// `<NDK>/toolchains/llvm/prebuilt/<host>/bin`. Pulled out so
125/// future helpers (llvm-ar, ranlib, lld-link) don't have to recompose
126/// it.
127pub fn ndk_bin_dir() -> Result<PathBuf> {
128    let ndk = ndk_home()?;
129    let host = host_tag()?;
130    Ok(ndk.join("toolchains/llvm/prebuilt").join(host).join("bin"))
131}
132
133/// Pure helper: convert an existing-on-disk dylib path under a
134/// jniLibs tree to its ABI. Used by the patcher to figure out which
135/// NDK clang to use without the caller having to plumb the ABI
136/// separately. Returns `None` if the path doesn't fit the
137/// `…/jniLibs/<abi>/lib<crate>.so` shape.
138pub fn abi_from_jni_libs_path(p: &Path) -> Option<&'static str> {
139    let parent = p.parent()?.file_name()?.to_str()?;
140    match parent {
141        "arm64-v8a" => Some("arm64-v8a"),
142        "armeabi-v7a" => Some("armeabi-v7a"),
143        "x86_64" => Some("x86_64"),
144        "x86" => Some("x86"),
145        _ => None,
146    }
147}
148
149// ============================================================================
150// Tests
151// ============================================================================
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    // ----- pure helpers ------------------------------------------------
158
159    #[test]
160    fn host_tag_returns_a_known_string_for_this_host() {
161        let t = host_tag().expect("host tag");
162        assert!(matches!(
163            t,
164            "darwin-x86_64" | "linux-x86_64" | "windows-x86_64",
165        ));
166    }
167
168    #[test]
169    fn clang_target_prefix_maps_known_abis() {
170        assert_eq!(
171            clang_target_prefix("arm64-v8a").unwrap(),
172            "aarch64-linux-android"
173        );
174        assert_eq!(
175            clang_target_prefix("armeabi-v7a").unwrap(),
176            "armv7a-linux-androideabi"
177        );
178        assert_eq!(
179            clang_target_prefix("x86_64").unwrap(),
180            "x86_64-linux-android"
181        );
182        assert_eq!(clang_target_prefix("x86").unwrap(), "i686-linux-android");
183    }
184
185    #[test]
186    fn clang_target_prefix_rejects_unknown_abi() {
187        let err = clang_target_prefix("riscv64").unwrap_err();
188        assert!(format!("{err:#}").contains("unknown Android ABI"));
189    }
190
191    #[test]
192    fn abi_from_jni_libs_path_maps_known_layouts() {
193        assert_eq!(
194            abi_from_jni_libs_path(Path::new(
195                "/ws/examples/foo/android/app/src/main/jniLibs/arm64-v8a/libfoo.so",
196            )),
197            Some("arm64-v8a"),
198        );
199        assert_eq!(
200            abi_from_jni_libs_path(Path::new("/ws/jniLibs/x86_64/libfoo.so")),
201            Some("x86_64"),
202        );
203    }
204
205    #[test]
206    fn abi_from_jni_libs_path_returns_none_for_non_abi_layout() {
207        assert_eq!(
208            abi_from_jni_libs_path(Path::new("/random/path/libfoo.so")),
209            None,
210        );
211        assert_eq!(
212            abi_from_jni_libs_path(Path::new("/ws/jniLibs/unknown-abi/libfoo.so")),
213            None,
214        );
215    }
216
217    // ----- environment-dependent (skipped if NDK absent) --------------
218
219    #[test]
220    fn android_clang_returns_a_path_when_ndk_is_installed() {
221        // Skip the whole test if the developer doesn't have an NDK
222        // — it's unreasonable to require one for `cargo test`.
223        let Ok(ndk) = ndk_home() else { return };
224        let clang = android_clang_for("arm64-v8a", 21).expect("ndk clang");
225        assert!(clang.starts_with(&ndk));
226        assert!(clang.is_file(), "{clang:?} should exist");
227    }
228}