Skip to main content

waterui_cli/android/
toolchain.rs

1use std::{
2    cmp::Ordering,
3    env,
4    ffi::{OsStr, OsString},
5    io,
6    path::{Path, PathBuf},
7    process::Output,
8};
9
10use url::Url;
11use walkdir::WalkDir;
12use waterui_assets_core::{AssetError, download_remote_bytes, write_bytes_atomically};
13
14use crate::{
15    android::{
16        ndk_version,
17        platform::{ALL_ABIS, AndroidAbi},
18    },
19    brew::Brew,
20    build_info,
21    toolchain::{
22        Host, Installation, Toolchain, ToolchainError,
23        linux::{
24            LinuxPackageManagerError, has_supported_package_manager, install_java_jdk,
25            install_named_packages,
26        },
27        winget::{WingetInstallError, ensure_package_installed},
28    },
29    utils::{CommandError, command},
30    water_dir::{HomeDirError, water_home_dir_in},
31};
32
33/// Errors from Android SDK/NDK inspection and installation pipelines.
34#[derive(Debug, thiserror::Error)]
35pub enum AndroidToolchainError {
36    /// `sdkmanager` could not be located.
37    #[error("Android SDK command-line tools (`sdkmanager`) not found")]
38    SdkManagerNotFound,
39    /// The Android SDK root could not be derived from the environment or `sdkmanager` path.
40    #[error("Android SDK root could not be determined from environment or sdkmanager path")]
41    SdkRootUndetermined,
42    /// The Android SDK root cannot be determined on this host.
43    #[error("Android SDK root cannot be determined on this host")]
44    SdkRootUnavailable,
45    /// No Java runtime is available for `sdkmanager`.
46    #[error("Java runtime not found while invoking sdkmanager")]
47    JavaNotFound,
48    /// The Water home directory could not be resolved.
49    #[error(transparent)]
50    HomeDir(#[from] HomeDirError),
51    /// The SDK repository metadata request failed.
52    #[error("Failed to query Android SDK repository metadata: {0}")]
53    RepositoryQuery(#[source] zenwave::Error),
54    /// The SDK repository metadata request returned an unsuccessful status.
55    #[error("Failed to query Android SDK repository metadata: HTTP {0}")]
56    RepositoryStatus(zenwave::StatusCode),
57    /// The SDK repository metadata body could not be read.
58    #[error("Failed to read Android SDK repository metadata: {0}")]
59    RepositoryBody(#[from] zenwave::BodyError),
60    /// The command-line tools archive is absent from the repository metadata.
61    #[error("Could not locate Android command-line tools archive")]
62    CmdlineToolsArchiveNotFound,
63    /// A remote archive could not be downloaded.
64    #[error("Failed to download {url}: {source}")]
65    Download {
66        /// The URL that failed to download.
67        url: String,
68        /// The underlying asset error.
69        #[source]
70        source: AssetError,
71    },
72    /// A downloaded archive could not be written to disk.
73    #[error("Failed to write downloaded archive to {}: {source}", path.display())]
74    ArchiveWrite {
75        /// The destination path.
76        path: PathBuf,
77        /// The underlying asset error.
78        #[source]
79        source: AssetError,
80    },
81    /// The command-line tools archive has no `bin` directory.
82    #[error("Invalid Android command-line tools archive layout (missing bin directory)")]
83    CmdlineToolsMissingBinDir,
84    /// The command-line tools archive has no `cmdline-tools` root.
85    #[error("Invalid Android command-line tools archive layout (missing cmdline-tools root)")]
86    CmdlineToolsMissingRoot,
87    /// The command-line tools archive does not contain `sdkmanager`.
88    #[error("Android command-line tools archive does not contain sdkmanager")]
89    CmdlineToolsMissingSdkManager,
90    /// `sdkmanager` is still absent after extraction.
91    #[error("Android command-line tools were extracted but sdkmanager is still missing")]
92    CmdlineToolsStillMissingSdkManager,
93    /// The Kotlin compiler archive has no `bin` directory.
94    #[error("Invalid Kotlin compiler archive layout (missing bin directory)")]
95    KotlinMissingBinDir,
96    /// The Kotlin compiler archive has no compiler root.
97    #[error("Invalid Kotlin compiler archive layout (missing compiler root)")]
98    KotlinMissingRoot,
99    /// The Kotlin compiler archive does not contain the `kotlinc` executable.
100    #[error("Kotlin compiler archive does not contain {0}")]
101    KotlinMissingCompiler(String),
102    /// The managed Kotlin install path has no parent directory.
103    #[error("Managed Kotlin install path has no parent")]
104    KotlinInstallPathNoParent,
105    /// `kotlinc` is still absent after extraction.
106    #[error("Kotlin compiler `{version}` was extracted but `{executable}` is still missing")]
107    KotlinCompilerStillMissing {
108        /// The requested Kotlin version.
109        version: String,
110        /// The executable that is missing.
111        executable: &'static str,
112    },
113    /// The installed Kotlin compiler does not satisfy the required version.
114    #[error(
115        "Installed Kotlin compiler version `{installed}` does not satisfy required version `{required}`"
116    )]
117    KotlinVersionMismatch {
118        /// The version reported by the installed compiler.
119        installed: String,
120        /// The required Kotlin version.
121        required: String,
122    },
123    /// The Kotlin compiler version output could not be parsed.
124    #[error(
125        "Failed to parse Kotlin compiler version from `{}` output: {output}",
126        path.display()
127    )]
128    KotlinVersionParse {
129        /// The `kotlinc` path that was probed.
130        path: PathBuf,
131        /// The combined compiler output.
132        output: String,
133    },
134    /// The proxy environment value is not a valid URL.
135    #[error("Failed to parse proxy URL `{url}` for sdkmanager: {source}")]
136    ProxyParse {
137        /// The offending proxy value.
138        url: String,
139        /// The URL parse error.
140        #[source]
141        source: url::ParseError,
142    },
143    /// The proxy URL has no host.
144    #[error("Proxy URL `{0}` is missing a host")]
145    ProxyMissingHost(String),
146    /// The proxy URL has no port.
147    #[error("Proxy URL `{0}` is missing a port")]
148    ProxyMissingPort(String),
149    /// The proxy URL scheme is not supported by `sdkmanager`.
150    #[error("Unsupported proxy scheme `{0}` for sdkmanager")]
151    ProxyUnsupportedScheme(String),
152    /// A PATH entry could not be joined into `PATH`.
153    #[error("Failed to construct PATH with required entry '{}': {source}", entry.display())]
154    PathJoin {
155        /// The entry that could not be joined.
156        entry: PathBuf,
157        /// The path-join error.
158        #[source]
159        source: env::JoinPathsError,
160    },
161    /// `sdkmanager --licenses` did not succeed.
162    #[error("Failed to accept Android SDK licenses. {0}")]
163    LicenseAcceptance(String),
164    /// `sdkmanager --install` did not succeed.
165    #[error("Failed to install package `{package_id}` via sdkmanager. {output}")]
166    PackageInstall {
167        /// The SDK package that failed to install.
168        package_id: String,
169        /// The combined `sdkmanager` output.
170        output: String,
171    },
172    /// `sdkmanager --list` did not succeed.
173    #[error("Failed to list Android SDK packages via sdkmanager. {0}")]
174    PackageList(String),
175    /// An `ndk;` package id is malformed.
176    #[error("Invalid Android NDK package id `{0}`")]
177    InvalidNdkPackageId(String),
178    /// The required NDK package is not offered by `sdkmanager`.
179    #[error("Required Android NDK package `{package_id}` is not available via `sdkmanager --list`")]
180    NdkPackageUnavailable {
181        /// The required NDK package id.
182        package_id: String,
183    },
184    /// No Android platform package is offered by `sdkmanager`.
185    #[error("No installable Android platform package found via `sdkmanager --list`")]
186    NoPlatformPackage,
187    /// No Android build-tools package is offered by `sdkmanager`.
188    #[error("No installable Android build-tools package found via `sdkmanager --list`")]
189    NoBuildToolsPackage,
190    /// An external command failed.
191    #[error(transparent)]
192    Command(#[from] CommandError),
193    /// An I/O operation failed.
194    #[error(transparent)]
195    Io(#[from] io::Error),
196    /// A ZIP archive operation failed.
197    #[error(transparent)]
198    Zip(#[from] zip::result::ZipError),
199    /// A directory-tree walk failed.
200    #[error(transparent)]
201    WalkDir(#[from] walkdir::Error),
202}
203
204/// Android SDK toolchain component.
205#[derive(Debug, Clone, Default)]
206pub struct AndroidSdk;
207
208/// Android Platform-Tools (`adb`) toolchain component.
209#[derive(Debug, Clone, Default)]
210pub struct AndroidPlatformTools;
211
212/// Android SDK platform packages (`platforms/android-*`) used for compilation.
213#[derive(Debug, Clone, Default)]
214pub struct AndroidSdkPlatforms;
215
216/// Android SDK build-tools packages (`build-tools;*`) used for D8/Kotlin dexing.
217#[derive(Debug, Clone, Default)]
218pub struct AndroidBuildTools;
219
220/// Rust targets required for Android cross-compilation.
221#[derive(Debug, Clone)]
222pub struct AndroidRustTargets {
223    required_targets: Vec<String>,
224}
225
226impl AndroidRustTargets {
227    /// Build the Rust-target requirement set for the requested Android ABIs.
228    ///
229    /// # Panics
230    ///
231    /// Panics when `abis` is empty. Android packaging always needs at least one ABI.
232    #[must_use]
233    pub fn for_abis(abis: &[AndroidAbi]) -> Self {
234        assert!(
235            !abis.is_empty(),
236            "AndroidRustTargets::for_abis requires at least one ABI"
237        );
238        Self {
239            required_targets: required_android_rust_targets_for_abis(abis),
240        }
241    }
242}
243
244impl Default for AndroidRustTargets {
245    fn default() -> Self {
246        Self::for_abis(ALL_ABIS)
247    }
248}
249
250/// An Android NDK toolchain component.
251#[derive(Debug, Clone, Default)]
252pub struct AndroidNdk;
253
254/// Java toolchain component for Android development.
255#[derive(Debug, Clone, Default)]
256pub struct Java;
257
258/// Kotlin toolchain component for Android development.
259#[derive(Debug, Clone, Default)]
260pub struct Kotlin;
261
262const ANDROID_LINUX_X86_64_HOST_TOOLS_COMPAT_PACKAGES: &[&str] =
263    &["libc6:amd64", "libstdc++6:amd64", "zlib1g:amd64"];
264
265const fn is_linux_arm_host() -> bool {
266    cfg!(target_os = "linux")
267        && (cfg!(target_arch = "aarch64")
268            || cfg!(target_arch = "arm")
269            || cfg!(target_arch = "arm64ec"))
270}
271
272fn needs_linux_x86_64_host_tools_compat(detail: &str) -> bool {
273    is_linux_arm_host() && detail.contains("ld-linux-x86-64.so.2")
274}
275
276async fn install_android_linux_x86_64_host_tools_compat(
277    host: &Host,
278) -> Result<(), LinuxPackageManagerError> {
279    install_named_packages(host, ANDROID_LINUX_X86_64_HOST_TOOLS_COMPAT_PACKAGES).await
280}
281
282/// Android command-line tools guidance for headless/server environments.
283#[must_use]
284pub const fn android_cmdline_tools_suggestion() -> &'static str {
285    "Install Android SDK command-line tools and ensure `sdkmanager` is available in PATH."
286}
287
288/// Host-specific Android SDK default path guidance.
289#[must_use]
290pub const fn android_sdk_path_suggestion() -> &'static str {
291    if cfg!(target_os = "windows") {
292        "Expected default SDK path is `%LOCALAPPDATA%\\Android\\Sdk`. Set `ANDROID_SDK_ROOT` to that path if needed."
293    } else if cfg!(target_os = "macos") {
294        "Expected default SDK path is `$HOME/Library/Android/sdk`. Set `ANDROID_SDK_ROOT` to that path if needed."
295    } else if cfg!(target_os = "linux") {
296        "Expected default SDK path is `$HOME/Android/Sdk`. Set `ANDROID_SDK_ROOT` to that path if needed."
297    } else {
298        "Set `ANDROID_SDK_ROOT` to your Android SDK path."
299    }
300}
301
302/// Guidance for installing Android Platform-Tools (`adb`) without assuming Android Studio.
303#[must_use]
304pub const fn android_platform_tools_suggestion() -> &'static str {
305    "Install Android Platform-Tools with `sdkmanager --install \"platform-tools\"` (or Android Studio SDK Manager), then ensure `ANDROID_SDK_ROOT` points to that SDK."
306}
307
308/// Guidance for installing Android NDK without assuming Android Studio.
309#[must_use]
310pub const fn android_ndk_install_suggestion() -> &'static str {
311    "Install Android NDK with `sdkmanager --install \"ndk;<version>\"` (or Android Studio SDK Manager), then set `ANDROID_NDK_ROOT` if using a custom location."
312}
313
314/// Guidance for installing Android SDK platforms needed by build/package workflows.
315#[must_use]
316pub const fn android_platforms_install_suggestion() -> &'static str {
317    "Install Android SDK platform packages with `sdkmanager --install \"platforms;android-<api>\"` (or Android Studio SDK Manager)."
318}
319
320/// Guidance for installing Android SDK Build-Tools needed by build/package workflows.
321#[must_use]
322pub const fn android_build_tools_install_suggestion() -> &'static str {
323    "Install Android SDK Build-Tools with `sdkmanager --install \"build-tools;<version>\"` (or Android Studio SDK Manager)."
324}
325
326const fn sdkmanager_search_names() -> &'static [&'static str] {
327    if cfg!(target_os = "windows") {
328        &["sdkmanager.bat", "sdkmanager.exe", "sdkmanager"]
329    } else {
330        &["sdkmanager"]
331    }
332}
333
334const fn sdkmanager_binary_name() -> &'static str {
335    if cfg!(target_os = "windows") {
336        "sdkmanager.bat"
337    } else {
338        "sdkmanager"
339    }
340}
341
342const fn cmdline_tools_host_tag() -> Option<&'static str> {
343    if cfg!(target_os = "windows") {
344        Some("win")
345    } else if cfg!(target_os = "macos") {
346        Some("mac")
347    } else if cfg!(target_os = "linux") {
348        Some("linux")
349    } else {
350        None
351    }
352}
353
354fn default_android_sdk_path(host: &Host) -> Option<PathBuf> {
355    if cfg!(target_os = "windows") {
356        let localappdata = host.env_string("LOCALAPPDATA")?;
357        return Some(PathBuf::from(localappdata).join("Android/Sdk"));
358    }
359
360    let home = host.home_dir()?;
361    if cfg!(target_os = "macos") {
362        return Some(home.join("Library/Android/sdk"));
363    }
364
365    if cfg!(target_os = "linux") {
366        return Some(home.join("Android/Sdk"));
367    }
368
369    None
370}
371
372fn configured_android_sdk_path(host: &Host) -> Option<PathBuf> {
373    for key in ["ANDROID_SDK_ROOT", "ANDROID_HOME"] {
374        if let Some(raw) = host.env_string(key) {
375            return Some(PathBuf::from(raw));
376        }
377    }
378    default_android_sdk_path(host)
379}
380
381fn sdkmanager_candidates_under_sdk_root(sdk_root: &Path) -> Vec<PathBuf> {
382    if cfg!(target_os = "windows") {
383        vec![
384            sdk_root.join("cmdline-tools/latest/bin/sdkmanager.bat"),
385            sdk_root.join("cmdline-tools/bin/sdkmanager.bat"),
386            sdk_root.join("tools/bin/sdkmanager.bat"),
387        ]
388    } else {
389        vec![
390            sdk_root.join("cmdline-tools/latest/bin/sdkmanager"),
391            sdk_root.join("cmdline-tools/bin/sdkmanager"),
392            sdk_root.join("tools/bin/sdkmanager"),
393        ]
394    }
395}
396
397fn parse_latest_cmdline_tools_archive(repository_xml: &str) -> Option<String> {
398    let host_tag = cmdline_tools_host_tag()?;
399    let prefix = format!("commandlinetools-{host_tag}-");
400    let suffix = "_latest.zip";
401
402    let mut cursor = 0usize;
403    let mut best: Option<(u64, String)> = None;
404
405    while let Some(offset) = repository_xml[cursor..].find(&prefix) {
406        let start = cursor + offset + prefix.len();
407        let remainder = &repository_xml[start..];
408        let Some(suffix_offset) = remainder.find(suffix) else {
409            cursor = start;
410            continue;
411        };
412
413        let build_id = &remainder[..suffix_offset];
414        let filename = format!("{prefix}{build_id}{suffix}");
415        cursor = start + suffix_offset + suffix.len();
416
417        if build_id.is_empty() || !build_id.chars().all(|ch| ch.is_ascii_digit()) {
418            continue;
419        }
420
421        let Ok(build_id) = build_id.parse::<u64>() else {
422            continue;
423        };
424
425        match best {
426            Some((current, _)) if build_id <= current => {}
427            _ => best = Some((build_id, filename)),
428        }
429    }
430
431    best.map(|(_, filename)| filename)
432}
433
434async fn latest_cmdline_tools_archive_url() -> Result<String, AndroidToolchainError> {
435    use zenwave::{Client, Method};
436
437    const REPOSITORY_URL: &str = "https://dl.google.com/android/repository/repository2-3.xml";
438    const REPOSITORY_PREFIX: &str = "https://dl.google.com/android/repository/";
439
440    let mut client = zenwave::client();
441    let response = client
442        .method(Method::GET, REPOSITORY_URL)
443        .map_err(AndroidToolchainError::RepositoryQuery)?
444        .await
445        .map_err(AndroidToolchainError::RepositoryQuery)?;
446    if !response.status().is_success() {
447        return Err(AndroidToolchainError::RepositoryStatus(response.status()));
448    }
449
450    let bytes = response.into_body().into_bytes().await?;
451    let repository_xml = String::from_utf8_lossy(&bytes).into_owned();
452    let archive_name = parse_latest_cmdline_tools_archive(&repository_xml)
453        .ok_or(AndroidToolchainError::CmdlineToolsArchiveNotFound)?;
454    Ok(format!("{REPOSITORY_PREFIX}{archive_name}"))
455}
456
457async fn download_file_with_redirect(
458    url: &str,
459    destination: &Path,
460) -> Result<(), AndroidToolchainError> {
461    let bytes =
462        download_remote_bytes(url)
463            .await
464            .map_err(|source| AndroidToolchainError::Download {
465                url: url.to_owned(),
466                source,
467            })?;
468    write_bytes_atomically(destination, &bytes)
469        .await
470        .map_err(|source| AndroidToolchainError::ArchiveWrite {
471            path: destination.to_path_buf(),
472            source,
473        })?;
474    Ok(())
475}
476
477fn find_cmdline_tools_dir(root: &Path) -> Result<PathBuf, AndroidToolchainError> {
478    let sdkmanager_name = sdkmanager_binary_name();
479
480    for entry in WalkDir::new(root) {
481        let entry = entry?;
482        if !entry.file_type().is_file() {
483            continue;
484        }
485
486        let path = entry.path();
487        let is_sdkmanager = path
488            .file_name()
489            .and_then(|name| name.to_str())
490            .is_some_and(|name| name.eq_ignore_ascii_case(sdkmanager_name));
491        if !is_sdkmanager {
492            continue;
493        }
494
495        let bin_dir = path
496            .parent()
497            .ok_or(AndroidToolchainError::CmdlineToolsMissingBinDir)?;
498        let cmdline_tools_dir = bin_dir
499            .parent()
500            .ok_or(AndroidToolchainError::CmdlineToolsMissingRoot)?;
501        return Ok(cmdline_tools_dir.to_path_buf());
502    }
503
504    Err(AndroidToolchainError::CmdlineToolsMissingSdkManager)
505}
506
507async fn ensure_cmdline_tools_available(sdk_root: &Path) -> Result<(), AndroidToolchainError> {
508    let latest_dir = sdk_root.join("cmdline-tools/latest");
509    let sdkmanager = latest_dir.join("bin").join(sdkmanager_binary_name());
510    if sdkmanager.exists() {
511        return Ok(());
512    }
513
514    let cmdline_tools_root = sdk_root.join("cmdline-tools");
515    let temp_dir = {
516        let cmdline_tools_root = cmdline_tools_root.clone();
517        smol::unblock(move || -> io::Result<_> {
518            std::fs::create_dir_all(&cmdline_tools_root)?;
519            tempfile::Builder::new()
520                .prefix(".water-cmdline-tools-")
521                .tempdir_in(&cmdline_tools_root)
522        })
523        .await?
524    };
525    let extract_dir = temp_dir.path().join("extract");
526    let archive_path = temp_dir.path().join("commandline-tools.zip");
527
528    {
529        let extract_dir = extract_dir.clone();
530        smol::unblock(move || std::fs::create_dir_all(&extract_dir)).await?;
531    }
532
533    let archive_url = latest_cmdline_tools_archive_url().await?;
534    download_file_with_redirect(&archive_url, &archive_path).await?;
535
536    {
537        let archive_path = archive_path.clone();
538        let extract_dir = extract_dir.clone();
539        smol::unblock(move || -> Result<(), AndroidToolchainError> {
540            let archive_file = std::fs::File::open(&archive_path)?;
541            let mut archive = zip::ZipArchive::new(archive_file)?;
542            archive.extract(&extract_dir)?;
543            Ok(())
544        })
545        .await?;
546    }
547
548    let extracted_cmdline_dir = {
549        let extract_dir = extract_dir.clone();
550        smol::unblock(move || find_cmdline_tools_dir(&extract_dir)).await?
551    };
552
553    if latest_dir.exists() {
554        let latest_dir = latest_dir.clone();
555        smol::unblock(move || std::fs::remove_dir_all(latest_dir)).await?;
556    }
557
558    {
559        let extracted_cmdline_dir = extracted_cmdline_dir.clone();
560        let latest_dir = latest_dir.clone();
561        smol::unblock(move || std::fs::rename(extracted_cmdline_dir, latest_dir)).await?;
562    }
563
564    if sdkmanager.exists() {
565        Ok(())
566    } else {
567        Err(AndroidToolchainError::CmdlineToolsStillMissingSdkManager)
568    }
569}
570
571fn looks_like_android_sdk_root(path: &Path) -> bool {
572    path.join("cmdline-tools").exists()
573        || path.join("platform-tools").exists()
574        || path.join("platforms").exists()
575        || path.join("ndk").exists()
576}
577
578fn find_android_jar_in_sdk(sdk_root: &Path) -> Option<PathBuf> {
579    let platforms_dir = sdk_root.join("platforms");
580    if !platforms_dir.exists() {
581        return None;
582    }
583
584    let mut platforms = std::fs::read_dir(&platforms_dir)
585        .ok()?
586        .filter_map(std::result::Result::ok)
587        .map(|entry| entry.path())
588        .filter(|path| path.is_dir())
589        .collect::<Vec<_>>();
590    platforms.sort_by(|left, right| {
591        let left_api = left
592            .file_name()
593            .and_then(|name| name.to_str())
594            .and_then(|name| name.strip_prefix("android-"))
595            .and_then(parse_android_version_pair)
596            .unwrap_or((0, 0));
597        let right_api = right
598            .file_name()
599            .and_then(|name| name.to_str())
600            .and_then(|name| name.strip_prefix("android-"))
601            .and_then(parse_android_version_pair)
602            .unwrap_or((0, 0));
603        right_api.cmp(&left_api)
604    });
605
606    for platform in platforms {
607        let android_jar = platform.join("android.jar");
608        if android_jar.exists() {
609            return Some(android_jar);
610        }
611    }
612    None
613}
614
615fn derive_sdk_root_from_sdkmanager_path(path: &Path) -> Option<PathBuf> {
616    let bin_dir = path.parent()?;
617    if !bin_dir
618        .file_name()?
619        .to_string_lossy()
620        .eq_ignore_ascii_case("bin")
621    {
622        return None;
623    }
624
625    let parent = bin_dir.parent()?;
626    if parent
627        .file_name()?
628        .to_string_lossy()
629        .eq_ignore_ascii_case("tools")
630        || parent
631            .file_name()?
632            .to_string_lossy()
633            .eq_ignore_ascii_case("cmdline-tools")
634    {
635        return Some(parent.parent()?.to_path_buf());
636    }
637
638    let maybe_cmdline_tools = parent.parent()?;
639    if maybe_cmdline_tools
640        .file_name()?
641        .to_string_lossy()
642        .eq_ignore_ascii_case("cmdline-tools")
643    {
644        return Some(maybe_cmdline_tools.parent()?.to_path_buf());
645    }
646
647    None
648}
649
650fn find_sdkmanager_on_host_path(host: &Host) -> Option<PathBuf> {
651    let path_env = host.env("PATH")?;
652    for path_dir in env::split_paths(path_env) {
653        for candidate_name in sdkmanager_search_names() {
654            let candidate = path_dir.join(candidate_name);
655            if candidate.exists() {
656                return Some(candidate);
657            }
658        }
659    }
660    None
661}
662
663fn parse_sdkmanager_package_id(line: &str) -> Option<&str> {
664    let trimmed = line.trim();
665    if trimmed.is_empty() {
666        return None;
667    }
668    let (first_column, _) = trimmed.split_once('|')?;
669    let package_id = first_column.trim();
670    if package_id.is_empty() || package_id == "Path" || package_id.starts_with('-') {
671        return None;
672    }
673    Some(package_id)
674}
675
676/// The `(major, minor)` API-level pair of an Android platform identifier.
677///
678/// `sdkmanager` lists packages like `platforms;android-37` or, for minor
679/// API revisions, `platforms;android-37.0` (#633): `android-36` parses as
680/// `(36, 0)`, `android-36.1` as `(36, 1)`, so pair ordering gives
681/// `android-36 < android-36.1 < android-37.0`. A non-numeric identifier is
682/// not a numbered platform at all.
683fn parse_android_version_pair(value: &str) -> Option<(u32, u32)> {
684    let mut segments = value.split('.');
685    let major = segments.next()?.parse().ok()?;
686    let minor = match segments.next() {
687        Some(segment) => segment.parse().ok()?,
688        None => 0,
689    };
690    if segments.next().is_some() {
691        return None;
692    }
693    Some((major, minor))
694}
695
696fn parse_android_platform_api_level(package_id: &str) -> Option<(u32, u32)> {
697    parse_android_version_pair(package_id.strip_prefix("platforms;android-")?)
698}
699
700fn parse_android_build_tools_version(package_id: &str) -> Option<&str> {
701    package_id.strip_prefix("build-tools;")
702}
703
704fn parse_numeric_prefix(segment: &str) -> u64 {
705    let digits: String = segment
706        .chars()
707        .take_while(char::is_ascii_digit)
708        .collect::<String>();
709    digits.parse().unwrap_or(0)
710}
711
712fn compare_version_segments(left: &[u64], right: &[u64]) -> Ordering {
713    let max_len = left.len().max(right.len());
714    for idx in 0..max_len {
715        let l = left.get(idx).copied().unwrap_or(0);
716        let r = right.get(idx).copied().unwrap_or(0);
717        match l.cmp(&r) {
718            Ordering::Equal => {}
719            ordering => return ordering,
720        }
721    }
722    Ordering::Equal
723}
724
725fn compare_sdk_package_ids(left: &str, right: &str) -> Ordering {
726    let left_version = left
727        .split_once(';')
728        .map_or("", |(_, version)| version)
729        .split('.')
730        .map(parse_numeric_prefix)
731        .collect::<Vec<_>>();
732    let right_version = right
733        .split_once(';')
734        .map_or("", |(_, version)| version)
735        .split('.')
736        .map(parse_numeric_prefix)
737        .collect::<Vec<_>>();
738
739    match compare_version_segments(&left_version, &right_version) {
740        Ordering::Equal => left.cmp(right),
741        ordering => ordering,
742    }
743}
744
745fn find_d8_jar_in_sdk(sdk_root: &Path) -> Option<PathBuf> {
746    let build_tools_dir = sdk_root.join("build-tools");
747    if !build_tools_dir.exists() {
748        return None;
749    }
750
751    let mut build_tools_versions = std::fs::read_dir(&build_tools_dir)
752        .ok()?
753        .filter_map(std::result::Result::ok)
754        .map(|entry| entry.path())
755        .filter(|path| path.is_dir())
756        .filter_map(|path| {
757            let version = path.file_name()?.to_str()?;
758            Some((format!("build-tools;{version}"), path))
759        })
760        .collect::<Vec<_>>();
761    build_tools_versions.sort_by(|(left, _), (right, _)| compare_sdk_package_ids(left, right));
762
763    while let Some((_, version_dir)) = build_tools_versions.pop() {
764        let d8_jar = version_dir.join("lib/d8.jar");
765        if d8_jar.exists() {
766            return Some(d8_jar);
767        }
768    }
769
770    None
771}
772
773async fn resolve_sdkmanager_and_root(
774    host: &Host,
775) -> Result<(PathBuf, PathBuf), AndroidToolchainError> {
776    let sdkmanager_path = AndroidSdk::sdkmanager_path(host)
777        .await
778        .ok_or(AndroidToolchainError::SdkManagerNotFound)?;
779    let sdk_root = AndroidSdk::detect_path(host)
780        .or_else(|| derive_sdk_root_from_sdkmanager_path(&sdkmanager_path))
781        .ok_or(AndroidToolchainError::SdkRootUndetermined)?;
782    Ok((sdkmanager_path, sdk_root))
783}
784
785fn prepend_path_entry(
786    entry: &Path,
787    existing: Option<OsString>,
788) -> Result<OsString, AndroidToolchainError> {
789    let mut entries = vec![entry.to_path_buf()];
790    if let Some(existing) = existing {
791        entries.extend(env::split_paths(&existing));
792    }
793    env::join_paths(entries).map_err(|source| AndroidToolchainError::PathJoin {
794        entry: entry.to_path_buf(),
795        source,
796    })
797}
798
799fn sdkmanager_combined_output(output: &Output) -> String {
800    let stdout = String::from_utf8_lossy(&output.stdout);
801    let stderr = String::from_utf8_lossy(&output.stderr);
802    format!("stdout: {} stderr: {}", stdout.trim(), stderr.trim())
803}
804
805fn sdkmanager_confirmation_input() -> String {
806    "y\n".repeat(128)
807}
808
809#[derive(Debug, Clone, Copy, PartialEq, Eq)]
810enum SdkManagerProxyType {
811    Http,
812    Socks,
813}
814
815impl SdkManagerProxyType {
816    const fn as_flag(self) -> &'static str {
817        match self {
818            Self::Http => "http",
819            Self::Socks => "socks",
820        }
821    }
822}
823
824#[derive(Debug, Clone, PartialEq, Eq)]
825struct SdkManagerProxyConfig {
826    proxy_type: SdkManagerProxyType,
827    host: String,
828    port: u16,
829}
830
831fn proxy_env_value(host: &Host) -> Option<String> {
832    [
833        "HTTPS_PROXY",
834        "https_proxy",
835        "ALL_PROXY",
836        "all_proxy",
837        "HTTP_PROXY",
838        "http_proxy",
839    ]
840    .into_iter()
841    .find_map(|key| {
842        host.env_string(key)
843            .filter(|value| !value.trim().is_empty())
844    })
845}
846
847fn parse_sdkmanager_proxy_config(
848    proxy: &str,
849) -> Result<SdkManagerProxyConfig, AndroidToolchainError> {
850    let trimmed = proxy.trim();
851    let normalized = if trimmed.contains("://") {
852        trimmed.to_string()
853    } else {
854        format!("http://{trimmed}")
855    };
856    let url = Url::parse(&normalized).map_err(|source| AndroidToolchainError::ProxyParse {
857        url: trimmed.to_owned(),
858        source,
859    })?;
860    let host = url
861        .host_str()
862        .ok_or_else(|| AndroidToolchainError::ProxyMissingHost(trimmed.to_owned()))?
863        .to_string();
864    let port = url
865        .port_or_known_default()
866        .ok_or_else(|| AndroidToolchainError::ProxyMissingPort(trimmed.to_owned()))?;
867    let proxy_type = match url.scheme() {
868        "http" | "https" => SdkManagerProxyType::Http,
869        "socks" | "socks5" | "socks5h" => SdkManagerProxyType::Socks,
870        scheme => {
871            return Err(AndroidToolchainError::ProxyUnsupportedScheme(
872                scheme.to_owned(),
873            ));
874        }
875    };
876    Ok(SdkManagerProxyConfig {
877        proxy_type,
878        host,
879        port,
880    })
881}
882
883fn sdkmanager_proxy_args(host: &Host) -> Result<Vec<OsString>, AndroidToolchainError> {
884    let Some(proxy) = proxy_env_value(host) else {
885        return Ok(Vec::new());
886    };
887    let proxy = parse_sdkmanager_proxy_config(&proxy)?;
888    Ok(vec![
889        OsString::from(format!("--proxy={}", proxy.proxy_type.as_flag())),
890        OsString::from(format!("--proxy_host={}", proxy.host)),
891        OsString::from(format!("--proxy_port={}", proxy.port)),
892    ])
893}
894
895pub(super) fn java_proxy_properties_from_env(
896    host: &Host,
897) -> Result<Vec<String>, AndroidToolchainError> {
898    let Some(proxy) = proxy_env_value(host) else {
899        return Ok(Vec::new());
900    };
901    let proxy = parse_sdkmanager_proxy_config(&proxy)?;
902    Ok(match proxy.proxy_type {
903        SdkManagerProxyType::Http => vec![
904            format!("-Dhttp.proxyHost={}", proxy.host),
905            format!("-Dhttp.proxyPort={}", proxy.port),
906            format!("-Dhttps.proxyHost={}", proxy.host),
907            format!("-Dhttps.proxyPort={}", proxy.port),
908        ],
909        SdkManagerProxyType::Socks => vec![
910            format!("-DsocksProxyHost={}", proxy.host),
911            format!("-DsocksProxyPort={}", proxy.port),
912        ],
913    })
914}
915
916fn sdkmanager_requires_license_acceptance(output: &Output) -> bool {
917    let lower = sdkmanager_combined_output(output).to_ascii_lowercase();
918    lower.contains("license is not accepted")
919        || lower.contains("licenses or those of the packages they depend on were not accepted")
920        || lower.contains("accept? (y/n):")
921}
922
923async fn run_sdkmanager_output_with_java(
924    host: &Host,
925    args: Vec<OsString>,
926    stdin_payload: Option<&str>,
927) -> Result<Output, AndroidToolchainError> {
928    let (sdkmanager_path, sdk_root) = resolve_sdkmanager_and_root(host).await?;
929    let java_home = Java::detect_home(host)
930        .await
931        .ok_or(AndroidToolchainError::JavaNotFound)?;
932    let java_bin = java_home.join("bin");
933    let path_env = prepend_path_entry(&java_bin, host.env("PATH").map(OsStr::to_os_string))?;
934
935    let mut sdk_root_arg = OsString::from("--sdk_root=");
936    sdk_root_arg.push(&sdk_root);
937    let mut full_args = vec![sdk_root_arg];
938    full_args.extend(sdkmanager_proxy_args(host)?);
939    full_args.extend(args);
940
941    let mut cmd = host.command(&sdkmanager_path);
942    cmd.args(full_args)
943        .env("ANDROID_SDK_ROOT", &sdk_root)
944        .env("ANDROID_HOME", &sdk_root)
945        .env("JAVA_HOME", &java_home)
946        .env("PATH", path_env)
947        .env_remove("HTTP_PROXY")
948        .env_remove("http_proxy")
949        .env_remove("HTTPS_PROXY")
950        .env_remove("https_proxy")
951        .env_remove("ALL_PROXY")
952        .env_remove("all_proxy");
953
954    if let Some(stdin_payload) = stdin_payload {
955        use smol::io::AsyncWriteExt;
956        use std::process::Stdio;
957
958        cmd.stdin(Stdio::piped());
959        let mut child = command(&mut cmd).spawn()?;
960        if let Some(mut stdin) = child.stdin.take() {
961            stdin.write_all(stdin_payload.as_bytes()).await?;
962            stdin.flush().await?;
963        }
964        child.output().await.map_err(AndroidToolchainError::from)
965    } else {
966        command(&mut cmd)
967            .output()
968            .await
969            .map_err(AndroidToolchainError::from)
970    }
971}
972
973async fn accept_sdkmanager_licenses(host: &Host) -> Result<(), AndroidToolchainError> {
974    let license_input = sdkmanager_confirmation_input();
975    let output = run_sdkmanager_output_with_java(
976        host,
977        vec![OsString::from("--licenses")],
978        Some(&license_input),
979    )
980    .await?;
981    if output.status.success() {
982        return Ok(());
983    }
984    Err(AndroidToolchainError::LicenseAcceptance(
985        sdkmanager_combined_output(&output),
986    ))
987}
988
989async fn install_android_sdk_package(
990    host: &Host,
991    package_id: &str,
992) -> Result<(), AndroidToolchainError> {
993    let install_args = vec![OsString::from("--install"), OsString::from(package_id)];
994    let confirmation_input = sdkmanager_confirmation_input();
995    let mut output =
996        run_sdkmanager_output_with_java(host, install_args.clone(), Some(&confirmation_input))
997            .await?;
998    if sdkmanager_requires_license_acceptance(&output) {
999        accept_sdkmanager_licenses(host).await?;
1000        output =
1001            run_sdkmanager_output_with_java(host, install_args, Some(&confirmation_input)).await?;
1002    }
1003    if output.status.success() {
1004        return Ok(());
1005    }
1006
1007    Err(AndroidToolchainError::PackageInstall {
1008        package_id: package_id.to_owned(),
1009        output: sdkmanager_combined_output(&output),
1010    })
1011}
1012
1013async fn list_sdk_package_ids(host: &Host) -> Result<Vec<String>, AndroidToolchainError> {
1014    let output =
1015        run_sdkmanager_output_with_java(host, vec![OsString::from("--list")], None).await?;
1016    if !output.status.success() {
1017        return Err(AndroidToolchainError::PackageList(
1018            sdkmanager_combined_output(&output),
1019        ));
1020    }
1021
1022    let stdout = String::from_utf8_lossy(&output.stdout);
1023    Ok(stdout
1024        .lines()
1025        .filter_map(parse_sdkmanager_package_id)
1026        .map(ToOwned::to_owned)
1027        .collect::<Vec<_>>())
1028}
1029
1030fn select_installed_ndk_path(ndk_dir: &Path, required_version: Option<&str>) -> Option<PathBuf> {
1031    if !ndk_dir.exists() {
1032        return None;
1033    }
1034
1035    if let Some(required_version) = required_version {
1036        let required_path = ndk_dir.join(required_version);
1037        if required_path.is_dir() {
1038            return Some(required_path);
1039        }
1040    }
1041
1042    let mut versions: Vec<PathBuf> = std::fs::read_dir(ndk_dir)
1043        .ok()?
1044        .filter_map(std::result::Result::ok)
1045        .map(|entry| entry.path())
1046        .filter(|path| path.is_dir())
1047        .collect();
1048    versions.sort();
1049    versions.pop()
1050}
1051
1052fn ndk_version_from_package_id(package_id: &str) -> Result<&str, AndroidToolchainError> {
1053    package_id
1054        .strip_prefix("ndk;")
1055        .ok_or_else(|| AndroidToolchainError::InvalidNdkPackageId(package_id.to_owned()))
1056}
1057
1058fn ndk_path_for_package_id(
1059    sdk_root: &Path,
1060    package_id: &str,
1061) -> Result<PathBuf, AndroidToolchainError> {
1062    Ok(sdk_root
1063        .join("ndk")
1064        .join(ndk_version_from_package_id(package_id)?))
1065}
1066
1067fn ndk_layout_is_complete(ndk_path: &Path) -> bool {
1068    ndk_path.join("toolchains/llvm/prebuilt").exists()
1069}
1070
1071async fn remove_directory_if_exists(path: &Path) -> io::Result<()> {
1072    let path = path.to_path_buf();
1073    smol::unblock(move || {
1074        if path.exists() {
1075            remove_dir_all::remove_dir_all(&path)?;
1076        }
1077        Ok(())
1078    })
1079    .await
1080}
1081
1082const fn kotlinc_binary_name() -> &'static str {
1083    if cfg!(target_os = "windows") {
1084        "kotlinc.bat"
1085    } else {
1086        "kotlinc"
1087    }
1088}
1089
1090fn kotlin_executable_from_home(home: &Path) -> Option<PathBuf> {
1091    let executable = home.join("bin").join(kotlinc_binary_name());
1092    executable.exists().then_some(executable)
1093}
1094
1095fn managed_kotlin_home(host: &Host, version: &str) -> Result<PathBuf, AndroidToolchainError> {
1096    Ok(water_home_dir_in(host)?
1097        .join("toolchains/kotlin")
1098        .join(version))
1099}
1100
1101fn kotlin_compiler_release_url(version: &str) -> String {
1102    format!(
1103        "https://github.com/JetBrains/kotlin/releases/download/v{version}/kotlin-compiler-{version}.zip"
1104    )
1105}
1106
1107fn find_kotlin_home_dir(root: &Path) -> Result<PathBuf, AndroidToolchainError> {
1108    let executable_name = kotlinc_binary_name();
1109    for entry in WalkDir::new(root) {
1110        let entry = entry?;
1111        if !entry.file_type().is_file() {
1112            continue;
1113        }
1114        let path = entry.path();
1115        let is_kotlinc = path
1116            .file_name()
1117            .and_then(|name| name.to_str())
1118            .is_some_and(|name| name.eq_ignore_ascii_case(executable_name));
1119        if !is_kotlinc {
1120            continue;
1121        }
1122
1123        let bin_dir = path
1124            .parent()
1125            .ok_or(AndroidToolchainError::KotlinMissingBinDir)?;
1126        let kotlin_home = bin_dir
1127            .parent()
1128            .ok_or(AndroidToolchainError::KotlinMissingRoot)?;
1129        return Ok(kotlin_home.to_path_buf());
1130    }
1131
1132    Err(AndroidToolchainError::KotlinMissingCompiler(
1133        executable_name.to_owned(),
1134    ))
1135}
1136
1137fn parse_kotlinc_version_output(output: &str) -> Option<String> {
1138    output.lines().find_map(|line| {
1139        let mut tokens = line
1140            .split_whitespace()
1141            .map(|token| token.trim_matches(|ch: char| ch == ':' || ch == '(' || ch == ')'));
1142        while let Some(token) = tokens.next() {
1143            if token.starts_with("kotlinc") {
1144                return tokens
1145                    .find(|candidate| {
1146                        candidate
1147                            .chars()
1148                            .next()
1149                            .is_some_and(|ch| ch.is_ascii_digit())
1150                    })
1151                    .map(ToOwned::to_owned);
1152            }
1153        }
1154        None
1155    })
1156}
1157
1158fn kotlin_version_is_compatible(installed: &str, required: &str) -> bool {
1159    let installed_segments = installed
1160        .split('.')
1161        .map(parse_numeric_prefix)
1162        .collect::<Vec<_>>();
1163    let required_segments = required
1164        .split('.')
1165        .map(parse_numeric_prefix)
1166        .collect::<Vec<_>>();
1167    compare_version_segments(&installed_segments, &required_segments) != Ordering::Less
1168}
1169
1170async fn kotlin_compiler_version(
1171    host: &Host,
1172    kotlinc_path: &Path,
1173) -> Result<String, AndroidToolchainError> {
1174    let output = host.output(kotlinc_path, ["-version"]).await?;
1175    let combined = format!(
1176        "{} {}",
1177        String::from_utf8_lossy(&output.stdout),
1178        String::from_utf8_lossy(&output.stderr)
1179    );
1180    parse_kotlinc_version_output(&combined).ok_or_else(|| {
1181        AndroidToolchainError::KotlinVersionParse {
1182            path: kotlinc_path.to_path_buf(),
1183            output: combined.trim().to_owned(),
1184        }
1185    })
1186}
1187
1188async fn install_managed_kotlin_compiler(
1189    host: &Host,
1190    version: &str,
1191) -> Result<PathBuf, AndroidToolchainError> {
1192    let install_home = managed_kotlin_home(host, version)?;
1193    if let Some(kotlinc_path) = kotlin_executable_from_home(&install_home)
1194        && let Ok(installed_version) = kotlin_compiler_version(host, &kotlinc_path).await
1195        && kotlin_version_is_compatible(&installed_version, version)
1196    {
1197        return Ok(kotlinc_path);
1198    }
1199
1200    let install_parent = install_home
1201        .parent()
1202        .ok_or(AndroidToolchainError::KotlinInstallPathNoParent)?
1203        .to_path_buf();
1204    {
1205        let install_parent = install_parent.clone();
1206        smol::unblock(move || std::fs::create_dir_all(&install_parent)).await?;
1207    }
1208
1209    let temp_dir = {
1210        let install_parent = install_parent.clone();
1211        smol::unblock(move || {
1212            tempfile::Builder::new()
1213                .prefix(".water-kotlin-")
1214                .tempdir_in(&install_parent)
1215        })
1216        .await?
1217    };
1218    let extract_dir = temp_dir.path().join("extract");
1219    let archive_path = temp_dir
1220        .path()
1221        .join(format!("kotlin-compiler-{version}.zip"));
1222    {
1223        let extract_dir = extract_dir.clone();
1224        smol::unblock(move || std::fs::create_dir_all(&extract_dir)).await?;
1225    }
1226
1227    download_file_with_redirect(&kotlin_compiler_release_url(version), &archive_path).await?;
1228    {
1229        let archive_path = archive_path.clone();
1230        let extract_dir = extract_dir.clone();
1231        smol::unblock(move || -> Result<(), AndroidToolchainError> {
1232            let archive_file = std::fs::File::open(&archive_path)?;
1233            let mut archive = zip::ZipArchive::new(archive_file)?;
1234            archive.extract(&extract_dir)?;
1235            Ok(())
1236        })
1237        .await?;
1238    }
1239
1240    let extracted_home = {
1241        let extract_dir = extract_dir.clone();
1242        smol::unblock(move || find_kotlin_home_dir(&extract_dir)).await?
1243    };
1244    remove_directory_if_exists(&install_home).await?;
1245    {
1246        let extracted_home = extracted_home.clone();
1247        let install_home = install_home.clone();
1248        smol::unblock(move || std::fs::rename(extracted_home, install_home)).await?;
1249    }
1250
1251    let kotlinc_path = kotlin_executable_from_home(&install_home).ok_or(
1252        AndroidToolchainError::KotlinCompilerStillMissing {
1253            version: version.to_owned(),
1254            executable: kotlinc_binary_name(),
1255        },
1256    )?;
1257    let installed_version = kotlin_compiler_version(host, &kotlinc_path).await?;
1258    if kotlin_version_is_compatible(&installed_version, version) {
1259        Ok(kotlinc_path)
1260    } else {
1261        Err(AndroidToolchainError::KotlinVersionMismatch {
1262            installed: installed_version,
1263            required: version.to_owned(),
1264        })
1265    }
1266}
1267
1268const fn required_kotlin_version() -> &'static str {
1269    build_info::ANDROID_KOTLIN_VERSION
1270}
1271
1272async fn required_ndk_package_id(host: &Host) -> Result<String, AndroidToolchainError> {
1273    // The NDK the runtime's Gradle `ndkVersion` demands is embedded in the
1274    // binary — an installed CLI has no source checkout to read it from.
1275    let package_id = format!("ndk;{}", ndk_version::ANDROID_NDK_VERSION);
1276    let available_packages = list_sdk_package_ids(host).await?;
1277    if available_packages
1278        .iter()
1279        .any(|candidate| candidate == &package_id)
1280    {
1281        Ok(package_id)
1282    } else {
1283        Err(AndroidToolchainError::NdkPackageUnavailable { package_id })
1284    }
1285}
1286
1287async fn latest_android_platform_package_id(host: &Host) -> Result<String, AndroidToolchainError> {
1288    list_sdk_package_ids(host)
1289        .await?
1290        .into_iter()
1291        .filter_map(|package_id| {
1292            parse_android_platform_api_level(&package_id).map(|api_level| (api_level, package_id))
1293        })
1294        .max_by_key(|(api_level, _)| *api_level)
1295        .map(|(_, package_id)| package_id)
1296        .ok_or(AndroidToolchainError::NoPlatformPackage)
1297}
1298
1299async fn latest_android_build_tools_package_id(
1300    host: &Host,
1301) -> Result<String, AndroidToolchainError> {
1302    let mut build_tools_packages = list_sdk_package_ids(host)
1303        .await?
1304        .into_iter()
1305        .filter(|package_id| parse_android_build_tools_version(package_id).is_some())
1306        .collect::<Vec<_>>();
1307    build_tools_packages.sort_by(|left, right| compare_sdk_package_ids(left, right));
1308    build_tools_packages.dedup();
1309
1310    build_tools_packages
1311        .pop()
1312        .ok_or(AndroidToolchainError::NoBuildToolsPackage)
1313}
1314
1315const fn rust_target_for_android_abi(abi: AndroidAbi) -> &'static str {
1316    match abi {
1317        AndroidAbi::Arm64V8a => "aarch64-linux-android",
1318        AndroidAbi::X86_64 => "x86_64-linux-android",
1319        AndroidAbi::ArmeabiV7a => "armv7-linux-androideabi",
1320        AndroidAbi::X86 => "i686-linux-android",
1321    }
1322}
1323
1324fn required_android_rust_targets_for_abis(abis: &[AndroidAbi]) -> Vec<String> {
1325    let mut targets = abis
1326        .iter()
1327        .map(|abi| rust_target_for_android_abi(*abi).to_owned())
1328        .collect::<Vec<_>>();
1329    targets.sort_unstable();
1330    targets.dedup();
1331    targets
1332}
1333
1334async fn installed_rustup_targets(host: &Host) -> Result<Vec<String>, CommandError> {
1335    let installed = host
1336        .run("rustup", ["target", "list", "--installed"])
1337        .await?;
1338    Ok(installed
1339        .lines()
1340        .map(str::trim)
1341        .filter(|line| !line.is_empty())
1342        .map(ToOwned::to_owned)
1343        .collect())
1344}
1345
1346fn missing_android_rust_targets(
1347    installed_targets: &[String],
1348    required_targets: &[String],
1349) -> Vec<String> {
1350    required_targets
1351        .iter()
1352        .filter(|target| {
1353            !installed_targets
1354                .iter()
1355                .any(|installed| installed == *target)
1356        })
1357        .cloned()
1358        .collect()
1359}
1360
1361impl AndroidSdk {
1362    /// Detect the path to the Android SDK installation on `host`.
1363    #[must_use]
1364    pub fn detect_path(host: &Host) -> Option<PathBuf> {
1365        if let Some(configured) = configured_android_sdk_path(host)
1366            && configured.exists()
1367            && looks_like_android_sdk_root(&configured)
1368        {
1369            return Some(configured);
1370        }
1371
1372        if let Some(sdkmanager_path) = find_sdkmanager_on_host_path(host)
1373            && let Some(sdk_root) = derive_sdk_root_from_sdkmanager_path(&sdkmanager_path)
1374            && sdk_root.exists()
1375            && looks_like_android_sdk_root(&sdk_root)
1376        {
1377            return Some(sdk_root);
1378        }
1379
1380        None
1381    }
1382
1383    /// Detect the highest available `android.jar` from installed SDK platforms on `host`.
1384    #[must_use]
1385    pub fn android_jar_path(host: &Host) -> Option<PathBuf> {
1386        let sdk_root = Self::detect_path(host)?;
1387        find_android_jar_in_sdk(&sdk_root)
1388    }
1389
1390    /// Detect the highest available `d8.jar` from installed SDK build-tools on `host`.
1391    #[must_use]
1392    pub fn d8_jar_path(host: &Host) -> Option<PathBuf> {
1393        let sdk_root = Self::detect_path(host)?;
1394        find_d8_jar_in_sdk(&sdk_root)
1395    }
1396
1397    /// Detect the sdkmanager executable path on `host`.
1398    pub async fn sdkmanager_path(host: &Host) -> Option<PathBuf> {
1399        if let Some(sdk_root) = Self::detect_path(host) {
1400            for candidate in sdkmanager_candidates_under_sdk_root(&sdk_root) {
1401                if candidate.exists() {
1402                    return Some(candidate);
1403                }
1404            }
1405        }
1406
1407        for name in sdkmanager_search_names() {
1408            if let Ok(path) = host.which(name).await {
1409                return Some(path);
1410            }
1411        }
1412
1413        find_sdkmanager_on_host_path(host)
1414    }
1415
1416    /// Get the path to the `adb` executable on `host`.
1417    #[must_use]
1418    pub fn adb_path(host: &Host) -> Option<PathBuf> {
1419        let sdk_path = Self::detect_path(host)?;
1420        let adb = sdk_path
1421            .join("platform-tools")
1422            .join(if cfg!(target_os = "windows") {
1423                "adb.exe"
1424            } else {
1425                "adb"
1426            });
1427        if adb.exists() { Some(adb) } else { None }
1428    }
1429
1430    /// Get the path to the `emulator` executable on `host`.
1431    #[must_use]
1432    pub fn emulator_path(host: &Host) -> Option<PathBuf> {
1433        let sdk_path = Self::detect_path(host)?;
1434        let emulator = sdk_path
1435            .join("emulator")
1436            .join(if cfg!(target_os = "windows") {
1437                "emulator.exe"
1438            } else {
1439                "emulator"
1440            });
1441        if emulator.exists() {
1442            Some(emulator)
1443        } else {
1444            None
1445        }
1446    }
1447}
1448
1449/// Installation procedure for the Android SDK.
1450#[derive(Debug, Clone, Default)]
1451pub struct AndroidSdkInstallation;
1452
1453/// Errors that can occur when installing the Android SDK.
1454#[derive(Debug, thiserror::Error)]
1455pub enum FailToInstallAndroidSdk {
1456    #[error("Homebrew not found. Install Homebrew first, then retry `water doctor --fix`.")]
1457    BrewNotFound,
1458    #[error(
1459        "winget is required for automatic Android Studio installation on Windows. Install App Installer and retry."
1460    )]
1461    WingetNotFound,
1462    #[error("Failed to install Android Studio via winget: {0}")]
1463    WingetInstallFailed(String),
1464    #[error("Failed to install Android SDK prerequisites: {0}")]
1465    InstallFailed(#[from] AndroidToolchainError),
1466    #[error(
1467        "Android SDK setup completed, but SDK root is still not detectable. Install Android command-line tools and set `ANDROID_SDK_ROOT`."
1468    )]
1469    PostInstallSetupRequired,
1470    #[error(
1471        "Automatic Android SDK command-line tools installation is unsupported on this host. Set up Android SDK manually and set `ANDROID_SDK_ROOT`."
1472    )]
1473    UnsupportedPlatform,
1474}
1475
1476impl Toolchain for AndroidSdk {
1477    type Installation = AndroidSdkInstallation;
1478
1479    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
1480        if Self::detect_path(host).is_some() {
1481            if Self::sdkmanager_path(host).await.is_some() {
1482                Ok(())
1483            } else {
1484                Err(ToolchainError::fixable(AndroidSdkInstallation))
1485            }
1486        } else if cfg!(target_os = "windows") {
1487            if host.which("winget").await.is_ok() {
1488                Err(ToolchainError::fixable(AndroidSdkInstallation))
1489            } else {
1490                Err(ToolchainError::unfixable(
1491                    "Android SDK not found and winget is unavailable",
1492                    format!(
1493                        "Install Microsoft App Installer to provide winget, then retry `water doctor --fix`. {} {}",
1494                        android_cmdline_tools_suggestion(),
1495                        android_sdk_path_suggestion()
1496                    ),
1497                ))
1498            }
1499        } else if cfg!(target_os = "macos") {
1500            if host.which("brew").await.is_ok() {
1501                Err(ToolchainError::fixable(AndroidSdkInstallation))
1502            } else {
1503                Err(ToolchainError::unfixable(
1504                    "Android SDK not found and Homebrew is unavailable",
1505                    format!(
1506                        "Install Homebrew to enable automatic fixes, or install Android SDK manually. {} {}",
1507                        android_cmdline_tools_suggestion(),
1508                        android_sdk_path_suggestion()
1509                    ),
1510                ))
1511            }
1512        } else if cfg!(target_os = "linux") {
1513            if configured_android_sdk_path(host).is_some() {
1514                Err(ToolchainError::fixable(AndroidSdkInstallation))
1515            } else {
1516                Err(ToolchainError::unfixable(
1517                    "Android SDK root cannot be determined",
1518                    format!(
1519                        "Set `ANDROID_SDK_ROOT` to your Android SDK path, then retry `water doctor --fix`. {}",
1520                        android_cmdline_tools_suggestion()
1521                    ),
1522                ))
1523            }
1524        } else {
1525            Err(ToolchainError::unfixable(
1526                "Android SDK not found",
1527                format!(
1528                    "{} {}",
1529                    android_cmdline_tools_suggestion(),
1530                    android_sdk_path_suggestion()
1531                ),
1532            ))
1533        }
1534    }
1535}
1536
1537impl Installation for AndroidSdkInstallation {
1538    type Error = FailToInstallAndroidSdk;
1539
1540    async fn install(&self, host: &Host) -> Result<(), Self::Error> {
1541        if cfg!(target_os = "windows") {
1542            ensure_package_installed(host, "Google.AndroidStudio")
1543                .await
1544                .map_err(map_winget_error_for_android_sdk)?;
1545        } else if cfg!(target_os = "macos") {
1546            let brew = Brew::default();
1547            brew.check(host)
1548                .await
1549                .map_err(|_| FailToInstallAndroidSdk::BrewNotFound)?;
1550            brew.install_cask(host, "android-studio")
1551                .await
1552                .map_err(|source| {
1553                    FailToInstallAndroidSdk::InstallFailed(AndroidToolchainError::from(source))
1554                })?;
1555        } else if cfg!(target_os = "linux") {
1556            // Linux CI/headless containers only need command-line tools in the SDK root.
1557        } else {
1558            return Err(FailToInstallAndroidSdk::UnsupportedPlatform);
1559        }
1560
1561        let sdk_root = configured_android_sdk_path(host)
1562            .ok_or(AndroidToolchainError::SdkRootUnavailable)
1563            .map_err(FailToInstallAndroidSdk::InstallFailed)?;
1564        {
1565            let sdk_root = sdk_root.clone();
1566            smol::unblock(move || std::fs::create_dir_all(&sdk_root))
1567                .await
1568                .map_err(AndroidToolchainError::from)
1569                .map_err(FailToInstallAndroidSdk::InstallFailed)?;
1570        }
1571        ensure_cmdline_tools_available(&sdk_root)
1572            .await
1573            .map_err(FailToInstallAndroidSdk::InstallFailed)?;
1574
1575        if AndroidSdk::sdkmanager_path(host).await.is_some() {
1576            Ok(())
1577        } else {
1578            Err(FailToInstallAndroidSdk::PostInstallSetupRequired)
1579        }
1580    }
1581}
1582
1583fn map_winget_error_for_android_sdk(error: WingetInstallError) -> FailToInstallAndroidSdk {
1584    match error {
1585        WingetInstallError::WingetNotFound => FailToInstallAndroidSdk::WingetNotFound,
1586        WingetInstallError::CommandFailed(err) => {
1587            FailToInstallAndroidSdk::WingetInstallFailed(err.to_string())
1588        }
1589        WingetInstallError::NotInstalled { package_id } => {
1590            FailToInstallAndroidSdk::WingetInstallFailed(format!(
1591                "Package `{package_id}` is still missing after winget install; verify winget sources and retry."
1592            ))
1593        }
1594    }
1595}
1596
1597/// Installation procedure for Android Platform-Tools.
1598#[derive(Debug, Clone, Copy, Default)]
1599pub enum AndroidPlatformToolsInstallation {
1600    /// Install the `platform-tools` SDK package with `sdkmanager`.
1601    #[default]
1602    SdkPackage,
1603    /// Install `x86_64` userspace libraries needed by Google's Linux host tools on ARM Linux.
1604    LinuxX86_64HostToolsCompat,
1605}
1606
1607/// Errors that can occur when installing Android Platform-Tools.
1608#[derive(Debug, thiserror::Error)]
1609pub enum FailToInstallAndroidPlatformTools {
1610    #[error("Android SDK command-line tools (`sdkmanager`) not found.")]
1611    SdkManagerNotFound,
1612    #[error("Failed to install Android Platform-Tools via sdkmanager: {0}")]
1613    InstallFailed(#[from] AndroidToolchainError),
1614    #[error("Failed to install Android x86_64 host-tools compatibility packages: {0}")]
1615    HostToolsCompatFailed(#[from] LinuxPackageManagerError),
1616    /// Post-install `adb` verification reported an unhealthy toolchain state.
1617    #[error("{0}")]
1618    VerificationFailed(#[from] ToolchainError<AndroidPlatformToolsInstallation>),
1619    #[error("Android Platform-Tools (`adb`) is still missing after installation.")]
1620    StillMissing,
1621}
1622
1623impl Toolchain for AndroidPlatformTools {
1624    type Installation = AndroidPlatformToolsInstallation;
1625
1626    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
1627        if let Some(adb_path) = AndroidSdk::adb_path(host) {
1628            return verify_android_platform_tools_executable(host, &adb_path).await;
1629        }
1630
1631        if AndroidSdk::sdkmanager_path(host).await.is_some() {
1632            Err(ToolchainError::fixable(
1633                AndroidPlatformToolsInstallation::SdkPackage,
1634            ))
1635        } else {
1636            Err(ToolchainError::unfixable(
1637                "Android Platform-Tools (`adb`) not found",
1638                format!(
1639                    "{} {}",
1640                    android_platform_tools_suggestion(),
1641                    android_cmdline_tools_suggestion()
1642                ),
1643            ))
1644        }
1645    }
1646}
1647
1648impl Installation for AndroidPlatformToolsInstallation {
1649    type Error = FailToInstallAndroidPlatformTools;
1650
1651    async fn install(&self, host: &Host) -> Result<(), Self::Error> {
1652        if matches!(self, Self::LinuxX86_64HostToolsCompat) {
1653            return install_android_linux_x86_64_host_tools_compat(host)
1654                .await
1655                .map_err(FailToInstallAndroidPlatformTools::HostToolsCompatFailed);
1656        }
1657
1658        if AndroidSdk::sdkmanager_path(host).await.is_none() {
1659            return Err(FailToInstallAndroidPlatformTools::SdkManagerNotFound);
1660        }
1661
1662        install_android_sdk_package(host, "platform-tools")
1663            .await
1664            .map_err(FailToInstallAndroidPlatformTools::InstallFailed)?;
1665
1666        verify_android_platform_tools_after_install(host).await
1667    }
1668}
1669
1670async fn verify_android_platform_tools_after_install(
1671    host: &Host,
1672) -> Result<(), FailToInstallAndroidPlatformTools> {
1673    let adb_path =
1674        AndroidSdk::adb_path(host).ok_or(FailToInstallAndroidPlatformTools::StillMissing)?;
1675    match verify_android_platform_tools_executable(host, &adb_path).await {
1676        Ok(()) => Ok(()),
1677        Err(ToolchainError::Fixable(
1678            AndroidPlatformToolsInstallation::LinuxX86_64HostToolsCompat,
1679        )) => {
1680            install_android_linux_x86_64_host_tools_compat(host)
1681                .await
1682                .map_err(FailToInstallAndroidPlatformTools::HostToolsCompatFailed)?;
1683            verify_android_platform_tools_executable(host, &adb_path)
1684                .await
1685                .map_err(FailToInstallAndroidPlatformTools::VerificationFailed)
1686        }
1687        Err(error) => Err(FailToInstallAndroidPlatformTools::VerificationFailed(error)),
1688    }
1689}
1690
1691/// Installation procedure for Android SDK platform packages.
1692#[derive(Debug, Clone, Default)]
1693pub struct AndroidSdkPlatformsInstallation;
1694
1695/// Errors that can occur when installing Android SDK platform packages.
1696#[derive(Debug, thiserror::Error)]
1697pub enum FailToInstallAndroidSdkPlatforms {
1698    #[error("Android SDK command-line tools (`sdkmanager`) not found.")]
1699    SdkManagerNotFound,
1700    #[error("Failed to install Android SDK platform package via sdkmanager: {0}")]
1701    InstallFailed(#[from] AndroidToolchainError),
1702    #[error("Android SDK platforms are still missing after installation.")]
1703    StillMissing,
1704}
1705
1706impl Toolchain for AndroidSdkPlatforms {
1707    type Installation = AndroidSdkPlatformsInstallation;
1708
1709    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
1710        if AndroidSdk::android_jar_path(host).is_some() {
1711            return Ok(());
1712        }
1713
1714        if AndroidSdk::sdkmanager_path(host).await.is_some() {
1715            Err(ToolchainError::fixable(AndroidSdkPlatformsInstallation))
1716        } else {
1717            Err(ToolchainError::unfixable(
1718                "Android SDK platforms are missing",
1719                format!(
1720                    "{} {}",
1721                    android_platforms_install_suggestion(),
1722                    android_cmdline_tools_suggestion()
1723                ),
1724            ))
1725        }
1726    }
1727}
1728
1729impl Installation for AndroidSdkPlatformsInstallation {
1730    type Error = FailToInstallAndroidSdkPlatforms;
1731
1732    async fn install(&self, host: &Host) -> Result<(), Self::Error> {
1733        if AndroidSdk::sdkmanager_path(host).await.is_none() {
1734            return Err(FailToInstallAndroidSdkPlatforms::SdkManagerNotFound);
1735        }
1736
1737        let platform_package = latest_android_platform_package_id(host)
1738            .await
1739            .map_err(FailToInstallAndroidSdkPlatforms::InstallFailed)?;
1740        install_android_sdk_package(host, &platform_package)
1741            .await
1742            .map_err(FailToInstallAndroidSdkPlatforms::InstallFailed)?;
1743
1744        if AndroidSdk::android_jar_path(host).is_some() {
1745            Ok(())
1746        } else {
1747            Err(FailToInstallAndroidSdkPlatforms::StillMissing)
1748        }
1749    }
1750}
1751
1752/// Installation procedure for Android SDK build-tools packages.
1753#[derive(Debug, Clone, Default)]
1754pub struct AndroidBuildToolsInstallation;
1755
1756/// Errors that can occur when installing Android SDK build-tools packages.
1757#[derive(Debug, thiserror::Error)]
1758pub enum FailToInstallAndroidBuildTools {
1759    #[error("Android SDK command-line tools (`sdkmanager`) not found.")]
1760    SdkManagerNotFound,
1761    #[error("Failed to install Android SDK build-tools package via sdkmanager: {0}")]
1762    InstallFailed(#[from] AndroidToolchainError),
1763    #[error("Android SDK build-tools are still missing after installation.")]
1764    StillMissing,
1765}
1766
1767impl Toolchain for AndroidBuildTools {
1768    type Installation = AndroidBuildToolsInstallation;
1769
1770    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
1771        if AndroidSdk::d8_jar_path(host).is_some() {
1772            return Ok(());
1773        }
1774
1775        if AndroidSdk::sdkmanager_path(host).await.is_some() {
1776            Err(ToolchainError::fixable(AndroidBuildToolsInstallation))
1777        } else {
1778            Err(ToolchainError::unfixable(
1779                "Android SDK build-tools are missing",
1780                format!(
1781                    "{} {}",
1782                    android_build_tools_install_suggestion(),
1783                    android_cmdline_tools_suggestion()
1784                ),
1785            ))
1786        }
1787    }
1788}
1789
1790impl Installation for AndroidBuildToolsInstallation {
1791    type Error = FailToInstallAndroidBuildTools;
1792
1793    async fn install(&self, host: &Host) -> Result<(), Self::Error> {
1794        if AndroidSdk::sdkmanager_path(host).await.is_none() {
1795            return Err(FailToInstallAndroidBuildTools::SdkManagerNotFound);
1796        }
1797
1798        let build_tools_package = latest_android_build_tools_package_id(host)
1799            .await
1800            .map_err(FailToInstallAndroidBuildTools::InstallFailed)?;
1801        install_android_sdk_package(host, &build_tools_package)
1802            .await
1803            .map_err(FailToInstallAndroidBuildTools::InstallFailed)?;
1804
1805        if AndroidSdk::d8_jar_path(host).is_some() {
1806            Ok(())
1807        } else {
1808            Err(FailToInstallAndroidBuildTools::StillMissing)
1809        }
1810    }
1811}
1812
1813/// Installation procedure for Rust Android targets.
1814#[derive(Debug, Clone)]
1815pub struct AndroidRustTargetsInstallation {
1816    missing_targets: Vec<String>,
1817}
1818
1819impl AndroidRustTargetsInstallation {
1820    fn new(missing_targets: Vec<String>) -> Self {
1821        assert!(
1822            !missing_targets.is_empty(),
1823            "AndroidRustTargetsInstallation requires at least one missing target"
1824        );
1825        Self { missing_targets }
1826    }
1827}
1828
1829/// Errors that can occur when installing Rust Android targets.
1830#[derive(Debug, thiserror::Error)]
1831pub enum FailToInstallAndroidRustTargets {
1832    #[error("rustup is required to install Android Rust targets but was not found in PATH.")]
1833    RustupNotFound,
1834    #[error("Failed to install Rust Android target `{target}`: {source}")]
1835    AddTarget {
1836        /// Target triple that failed to install.
1837        target: String,
1838        /// Underlying command error.
1839        source: CommandError,
1840    },
1841    #[error("Failed to list installed Rust targets after installation: {0}")]
1842    QueryTargets(CommandError),
1843    #[error("Android Rust targets are still missing after installation: {missing_targets}")]
1844    StillMissing {
1845        /// Comma-separated missing targets.
1846        missing_targets: String,
1847    },
1848}
1849
1850impl Toolchain for AndroidRustTargets {
1851    type Installation = AndroidRustTargetsInstallation;
1852
1853    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
1854        if host.which("rustup").await.is_err() {
1855            return Err(ToolchainError::unfixable(
1856                "rustup is not available, so Android Rust targets cannot be managed automatically",
1857                "Install rustup from https://rustup.rs, then run `water doctor --fix`.",
1858            ));
1859        }
1860
1861        let installed_targets = installed_rustup_targets(host).await.map_err(|error| {
1862            ToolchainError::unfixable(
1863                format!("Failed to query installed Rust targets: {error}"),
1864                "Run `rustup target list --installed`; if it fails, repair rustup with `rustup self update` or reinstall rustup.",
1865            )
1866        })?;
1867
1868        let missing_targets =
1869            missing_android_rust_targets(&installed_targets, &self.required_targets);
1870        if missing_targets.is_empty() {
1871            Ok(())
1872        } else {
1873            Err(ToolchainError::fixable(
1874                AndroidRustTargetsInstallation::new(missing_targets),
1875            ))
1876        }
1877    }
1878}
1879
1880impl Installation for AndroidRustTargetsInstallation {
1881    type Error = FailToInstallAndroidRustTargets;
1882
1883    async fn install(&self, host: &Host) -> Result<(), Self::Error> {
1884        if host.which("rustup").await.is_err() {
1885            return Err(FailToInstallAndroidRustTargets::RustupNotFound);
1886        }
1887
1888        for target in &self.missing_targets {
1889            host.run("rustup", ["target", "add", target.as_str()])
1890                .await
1891                .map_err(|source| FailToInstallAndroidRustTargets::AddTarget {
1892                    target: target.clone(),
1893                    source,
1894                })?;
1895        }
1896
1897        let installed_targets = installed_rustup_targets(host)
1898            .await
1899            .map_err(FailToInstallAndroidRustTargets::QueryTargets)?;
1900        let still_missing = missing_android_rust_targets(&installed_targets, &self.missing_targets);
1901        if still_missing.is_empty() {
1902            Ok(())
1903        } else {
1904            Err(FailToInstallAndroidRustTargets::StillMissing {
1905                missing_targets: still_missing.join(", "),
1906            })
1907        }
1908    }
1909}
1910
1911#[cfg(test)]
1912mod tests {
1913    use super::*;
1914
1915    #[test]
1916    fn requested_android_rust_targets_are_deduplicated() {
1917        let required = required_android_rust_targets_for_abis(&[
1918            AndroidAbi::Arm64V8a,
1919            AndroidAbi::Arm64V8a,
1920            AndroidAbi::X86_64,
1921        ]);
1922        assert_eq!(
1923            required,
1924            vec![
1925                "aarch64-linux-android".to_string(),
1926                "x86_64-linux-android".to_string()
1927            ]
1928        );
1929    }
1930
1931    #[test]
1932    fn missing_android_targets_only_consider_requested_abis() {
1933        let required = required_android_rust_targets_for_abis(&[AndroidAbi::Arm64V8a]);
1934        let installed = vec![
1935            "aarch64-linux-android".to_string(),
1936            "armv7-linux-androideabi".to_string(),
1937            "x86_64-linux-android".to_string(),
1938        ];
1939        assert_eq!(
1940            missing_android_rust_targets(&installed, &required),
1941            [] as [String; 0]
1942        );
1943    }
1944
1945    #[test]
1946    fn missing_android_targets_report_only_requested_missing_entries() {
1947        let required =
1948            required_android_rust_targets_for_abis(&[AndroidAbi::Arm64V8a, AndroidAbi::X86]);
1949        let installed = vec!["aarch64-linux-android".to_string()];
1950        assert_eq!(
1951            missing_android_rust_targets(&installed, &required),
1952            vec!["i686-linux-android".to_string()]
1953        );
1954    }
1955
1956    #[test]
1957    fn select_installed_ndk_path_prefers_required_version_over_latest_directory() {
1958        let tempdir = tempfile::tempdir().unwrap();
1959        let ndk_dir = tempdir.path().join("ndk");
1960        std::fs::create_dir_all(ndk_dir.join("29.0.14206865")).unwrap();
1961        std::fs::create_dir_all(ndk_dir.join("30.0.14904198")).unwrap();
1962
1963        assert_eq!(
1964            select_installed_ndk_path(&ndk_dir, Some("29.0.14206865")),
1965            Some(ndk_dir.join("29.0.14206865"))
1966        );
1967    }
1968
1969    #[test]
1970    fn ndk_path_for_package_id_rejects_non_ndk_package_ids() {
1971        let error =
1972            ndk_path_for_package_id(Path::new("/tmp/android-sdk"), "platform-tools").unwrap_err();
1973        assert!(
1974            error
1975                .to_string()
1976                .contains("Invalid Android NDK package id `platform-tools`")
1977        );
1978    }
1979
1980    #[test]
1981    fn parse_kotlinc_version_output_extracts_version_token() {
1982        assert_eq!(
1983            parse_kotlinc_version_output("info: kotlinc-jvm 1.3-SNAPSHOT (JRE 21.0.10+7)"),
1984            Some("1.3-SNAPSHOT".to_string())
1985        );
1986    }
1987
1988    #[test]
1989    fn parse_kotlinc_version_output_ignores_jdk_warning_prefix() {
1990        let output = "OpenJDK 64-Bit Server VM warning: Options -Xverify:none and -noverify were deprecated in JDK 13 and will likely be removed in a future release.\ninfo: kotlinc-jvm 1.3-SNAPSHOT (JRE 21.0.10+7)";
1991        assert_eq!(
1992            parse_kotlinc_version_output(output),
1993            Some("1.3-SNAPSHOT".to_string())
1994        );
1995    }
1996
1997    #[test]
1998    fn kotlin_version_compatibility_uses_backend_minimum() {
1999        assert!(kotlin_version_is_compatible("2.0.21", "2.0.21"));
2000        assert!(kotlin_version_is_compatible("2.1.0", "2.0.21"));
2001        assert!(!kotlin_version_is_compatible("1.9.24", "2.0.21"));
2002    }
2003
2004    #[test]
2005    fn parse_sdkmanager_proxy_config_maps_http_proxy() {
2006        assert_eq!(
2007            parse_sdkmanager_proxy_config("http://host.docker.internal:7891").unwrap(),
2008            SdkManagerProxyConfig {
2009                proxy_type: SdkManagerProxyType::Http,
2010                host: "host.docker.internal".to_string(),
2011                port: 7891,
2012            }
2013        );
2014    }
2015
2016    #[test]
2017    fn parse_sdkmanager_proxy_config_maps_socks5h_proxy() {
2018        assert_eq!(
2019            parse_sdkmanager_proxy_config("socks5h://host.docker.internal:7890").unwrap(),
2020            SdkManagerProxyConfig {
2021                proxy_type: SdkManagerProxyType::Socks,
2022                host: "host.docker.internal".to_string(),
2023                port: 7890,
2024            }
2025        );
2026    }
2027}
2028
2029fn windows_jdk_candidates_from_root(root: &Path) -> Vec<PathBuf> {
2030    let Ok(entries) = std::fs::read_dir(root) else {
2031        return Vec::new();
2032    };
2033
2034    let mut candidates = entries
2035        .filter_map(std::result::Result::ok)
2036        .map(|entry| entry.path())
2037        .filter(|path| path.is_dir())
2038        .filter_map(|path| {
2039            let name = path.file_name()?.to_string_lossy().to_ascii_lowercase();
2040            if !name.starts_with("jdk") {
2041                return None;
2042            }
2043            let java_path = path.join("bin/java.exe");
2044            if java_path.exists() {
2045                Some(java_path)
2046            } else {
2047                None
2048            }
2049        })
2050        .collect::<Vec<_>>();
2051    candidates.sort();
2052    candidates
2053}
2054
2055fn detect_windows_jdk_java_path(host: &Host) -> Option<PathBuf> {
2056    let program_files = host.env_string("ProgramFiles")?;
2057    let roots = [
2058        PathBuf::from(&program_files).join("Microsoft"),
2059        PathBuf::from(&program_files).join("Eclipse Adoptium"),
2060        PathBuf::from(&program_files).join("Java"),
2061    ];
2062
2063    let mut matches = roots
2064        .iter()
2065        .flat_map(|root| windows_jdk_candidates_from_root(root))
2066        .collect::<Vec<_>>();
2067    matches.sort();
2068    matches.pop()
2069}
2070
2071async fn verify_android_platform_tools_executable(
2072    host: &Host,
2073    adb_path: &Path,
2074) -> Result<(), ToolchainError<AndroidPlatformToolsInstallation>> {
2075    let output = host.output(adb_path, ["version"]).await.map_err(|error| {
2076        ToolchainError::unfixable(
2077            format!(
2078                "Android Platform-Tools (`adb`) exists but failed to spawn on this host: {error}"
2079            ),
2080            format!(
2081                "Ensure the Android Platform-Tools binary at `{}` can start on this host, then retry `water doctor`.",
2082                adb_path.display()
2083            ),
2084        )
2085    })?;
2086
2087    if output.status.success() {
2088        return Ok(());
2089    }
2090
2091    let stderr = String::from_utf8_lossy(&output.stderr);
2092    let stdout = String::from_utf8_lossy(&output.stdout);
2093    let detail = if !stderr.trim().is_empty() {
2094        stderr.trim().to_owned()
2095    } else if !stdout.trim().is_empty() {
2096        stdout.trim().to_owned()
2097    } else {
2098        format!("exit status {}", output.status)
2099    };
2100    if needs_linux_x86_64_host_tools_compat(&detail) {
2101        return Err(ToolchainError::fixable(
2102            AndroidPlatformToolsInstallation::LinuxX86_64HostToolsCompat,
2103        ));
2104    }
2105
2106    let suggestion = if detail.contains("ld-linux-x86-64.so.2") {
2107        format!(
2108            "Install x86_64 userspace compatibility libraries for this Linux host, then retry `water doctor --fix`. Required packages on Debian/Ubuntu: {}.",
2109            ANDROID_LINUX_X86_64_HOST_TOOLS_COMPAT_PACKAGES.join(" ")
2110        )
2111    } else {
2112        format!(
2113            "Ensure the Android Platform-Tools binary at `{}` can execute on this host, then retry `water doctor`.",
2114            adb_path.display()
2115        )
2116    };
2117    Err(ToolchainError::unfixable(
2118        format!(
2119            "Android Platform-Tools (`adb`) exists but failed to execute on this host: {detail}"
2120        ),
2121        suggestion,
2122    ))
2123}
2124
2125/// An `aarch64-linux-android<api>-clang` wrapper from the first NDK prebuilt
2126/// host toolchain that ships one (its lowest API level, so the probe is
2127/// deterministic). Every API-level wrapper execs the same `clang`, so one
2128/// running proves the toolchain executes on this host. This check has no
2129/// resolved framework to read a floor from; the wrapper for the floor a build
2130/// targets is required on the build path in `platform.rs`.
2131fn ndk_host_clang_path(ndk_path: &Path) -> Option<PathBuf> {
2132    let wrapper_suffix = if cfg!(target_os = "windows") {
2133        "-clang.cmd"
2134    } else {
2135        "-clang"
2136    };
2137    let clang_wrapper = |bin_dir: &Path| {
2138        std::fs::read_dir(bin_dir)
2139            .ok()?
2140            .filter_map(Result::ok)
2141            .filter_map(|entry| {
2142                let api_level = entry
2143                    .file_name()
2144                    .to_str()?
2145                    .strip_prefix("aarch64-linux-android")?
2146                    .strip_suffix(wrapper_suffix)?
2147                    .parse::<u32>()
2148                    .ok()?;
2149                Some((api_level, entry.path()))
2150            })
2151            .min_by_key(|(api_level, _)| *api_level)
2152            .map(|(_, path)| path)
2153    };
2154
2155    let prebuilt_dir = ndk_path.join("toolchains/llvm/prebuilt");
2156    let entries = std::fs::read_dir(&prebuilt_dir).ok()?;
2157    let mut candidates = entries
2158        .filter_map(Result::ok)
2159        .map(|entry| entry.path())
2160        .filter(|path| path.is_dir())
2161        .collect::<Vec<_>>();
2162    candidates.sort();
2163
2164    candidates
2165        .iter()
2166        .find_map(|candidate| clang_wrapper(&candidate.join("bin")))
2167}
2168
2169async fn verify_ndk_host_toolchain_executable(
2170    host: &Host,
2171    ndk_path: &Path,
2172) -> Result<(), ToolchainError<AndroidNdkInstallation>> {
2173    let clang_path = ndk_host_clang_path(ndk_path).ok_or_else(|| {
2174        ToolchainError::unfixable(
2175            "Android NDK toolchain is incomplete (no `aarch64-linux-android*-clang` wrapper was found under toolchains/llvm/prebuilt).",
2176            android_ndk_install_suggestion(),
2177        )
2178    })?;
2179
2180    // Unique scratch source for the compile probe; the `NamedTempFile`
2181    // deletes itself on drop, including on the early-error paths below.
2182    let probe_file = smol::unblock(|| -> std::io::Result<tempfile::NamedTempFile> {
2183        use std::io::Write as _;
2184        let mut file = tempfile::Builder::new()
2185            .prefix("waterui-android-ndk-probe-")
2186            .suffix(".c")
2187            .tempfile()?;
2188        file.write_all(b"int main(void) { return 0; }\n")?;
2189        file.flush()?;
2190        Ok(file)
2191    })
2192    .await
2193    .map_err(|error| {
2194        ToolchainError::unfixable(
2195            format!("Failed to create the Android NDK probe source: {error}"),
2196            "Ensure the temporary directory is writable, then retry `water doctor`.",
2197        )
2198    })?;
2199    let probe_source = probe_file.path().to_path_buf();
2200
2201    let probe_output = if cfg!(target_os = "windows") {
2202        PathBuf::from("NUL")
2203    } else {
2204        PathBuf::from("/dev/null")
2205    };
2206    let result = host
2207        .output(
2208            &clang_path,
2209            [
2210                OsString::from("-x"),
2211                OsString::from("c"),
2212                OsString::from("-c"),
2213                probe_source.into_os_string(),
2214                OsString::from("-o"),
2215                probe_output.into_os_string(),
2216            ],
2217        )
2218        .await;
2219    let output = result.map_err(|error| {
2220        ToolchainError::unfixable(
2221            format!(
2222                "Android NDK toolchain exists but failed to spawn on this host: {error}"
2223            ),
2224            format!(
2225                "Ensure the Android NDK toolchain binary `{}` can start on this host, then retry packaging.",
2226                clang_path.display()
2227            ),
2228        )
2229    })?;
2230
2231    if output.status.success() {
2232        return Ok(());
2233    }
2234
2235    let stderr = String::from_utf8_lossy(&output.stderr);
2236    let stdout = String::from_utf8_lossy(&output.stdout);
2237    let detail = if !stderr.trim().is_empty() {
2238        stderr.trim().to_owned()
2239    } else if !stdout.trim().is_empty() {
2240        stdout.trim().to_owned()
2241    } else {
2242        format!("exit status {}", output.status)
2243    };
2244    if needs_linux_x86_64_host_tools_compat(&detail) {
2245        return Err(ToolchainError::fixable(
2246            AndroidNdkInstallation::LinuxX86_64HostToolsCompat,
2247        ));
2248    }
2249
2250    let suggestion = if detail.contains("ld-linux-x86-64.so.2") {
2251        format!(
2252            "Install x86_64 userspace compatibility libraries for this Linux host, then retry `water doctor --fix`. Required packages on Debian/Ubuntu: {}.",
2253            ANDROID_LINUX_X86_64_HOST_TOOLS_COMPAT_PACKAGES.join(" ")
2254        )
2255    } else {
2256        format!(
2257            "Ensure the Android NDK toolchain binaries under `{}` can execute on this host, then retry packaging.",
2258            clang_path.display()
2259        )
2260    };
2261    Err(ToolchainError::unfixable(
2262        format!("Android NDK toolchain exists but failed to execute on this host: {detail}"),
2263        suggestion,
2264    ))
2265}
2266
2267impl Java {
2268    /// Detect the path to the Java installation for Android development.
2269    ///
2270    /// Priority order:
2271    /// 1. Android Studio's bundled JBR (guaranteed compatible with AGP)
2272    /// 2. `JAVA_HOME` environment variable (may be incompatible)
2273    /// 3. Java from the host `PATH`
2274    pub async fn detect_path(host: &Host) -> Option<PathBuf> {
2275        if cfg!(target_os = "macos") {
2276            const ANDROID_STUDIO_JBRS: &[&str] = &[
2277                "Android Studio.app/Contents/jbr/Contents/Home/bin/java",
2278                "Android Studio Preview.app/Contents/jbr/Contents/Home/bin/java",
2279            ];
2280            for app_dir in host.app_dirs() {
2281                for relative in ANDROID_STUDIO_JBRS {
2282                    let java_path = app_dir.join(relative);
2283                    if java_path.exists() {
2284                        return Some(java_path);
2285                    }
2286                }
2287            }
2288        }
2289
2290        if cfg!(target_os = "linux")
2291            && let Some(home) = host.home_dir()
2292        {
2293            let paths = [
2294                home.join(".local/share/JetBrains/Toolbox/apps/android-studio/jbr/bin/java"),
2295                home.join("android-studio/jbr/bin/java"),
2296            ];
2297            for java_path in paths {
2298                if java_path.exists() {
2299                    return Some(java_path);
2300                }
2301            }
2302        }
2303
2304        if cfg!(target_os = "windows") {
2305            if let Some(program_files) = host.env_string("ProgramFiles") {
2306                let java_path =
2307                    PathBuf::from(&program_files).join("Android/Android Studio/jbr/bin/java.exe");
2308                if java_path.exists() {
2309                    return Some(java_path);
2310                }
2311            }
2312
2313            if let Some(java_path) = detect_windows_jdk_java_path(host) {
2314                return Some(java_path);
2315            }
2316        }
2317
2318        if let Some(home) = host.env_string("JAVA_HOME") {
2319            let java_path = PathBuf::from(home)
2320                .join("bin")
2321                .join(if cfg!(target_os = "windows") {
2322                    "java.exe"
2323                } else {
2324                    "java"
2325                });
2326            if java_path.exists() {
2327                return Some(java_path);
2328            }
2329        }
2330
2331        host.which("java").await.ok()
2332    }
2333
2334    /// Get the `JAVA_HOME` directory (parent of `bin/`) on `host`.
2335    pub async fn detect_home(host: &Host) -> Option<PathBuf> {
2336        let java_path = Self::detect_path(host).await?;
2337        java_path.parent()?.parent().map(PathBuf::from)
2338    }
2339}
2340
2341/// Java installation handler.
2342#[derive(Debug, Clone, Default)]
2343pub struct JavaInstallation;
2344
2345/// Errors that can occur when installing Java.
2346#[derive(Debug, thiserror::Error)]
2347pub enum FailToInstallJava {
2348    #[error("Homebrew not found. Install Homebrew first, then retry `water doctor --fix`.")]
2349    BrewNotFound,
2350    #[error(
2351        "winget is required for automatic Java installation on Windows. Install App Installer and retry."
2352    )]
2353    WingetNotFound,
2354    #[error("Failed to install Java via winget: {0}")]
2355    WingetInstallFailed(String),
2356    #[error(
2357        "No supported Linux package manager found (apt-get, dnf, pacman, zypper, apk). Install Java manually."
2358    )]
2359    UnsupportedPackageManager,
2360    #[error("Failed to install Java: {0}")]
2361    InstallFailed(#[from] CommandError),
2362    #[error(
2363        "Automatic Java installation is not supported on this host. Install a JDK manually and set `JAVA_HOME`."
2364    )]
2365    UnsupportedPlatform,
2366}
2367
2368impl Toolchain for Java {
2369    type Installation = JavaInstallation;
2370
2371    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
2372        if Self::detect_path(host).await.is_some() {
2373            Ok(())
2374        } else if cfg!(target_os = "windows") {
2375            if host.which("winget").await.is_ok() {
2376                Err(ToolchainError::fixable(JavaInstallation))
2377            } else {
2378                Err(ToolchainError::unfixable(
2379                    "Java runtime not found and winget is unavailable",
2380                    "Install Microsoft App Installer to provide winget, or install a JDK manually and set `JAVA_HOME`.",
2381                ))
2382            }
2383        } else if cfg!(target_os = "macos") {
2384            if host.which("brew").await.is_ok() {
2385                Err(ToolchainError::fixable(JavaInstallation))
2386            } else {
2387                Err(ToolchainError::unfixable(
2388                    "Java runtime not found and Homebrew is unavailable",
2389                    "Install Homebrew to enable automatic fixes, or install a JDK manually and set `JAVA_HOME`.",
2390                ))
2391            }
2392        } else if cfg!(target_os = "linux") {
2393            if has_supported_package_manager(host).await {
2394                Err(ToolchainError::fixable(JavaInstallation))
2395            } else {
2396                Err(ToolchainError::unfixable(
2397                    "Java runtime not found and no supported package manager was detected",
2398                    "Install a JDK manually and set `JAVA_HOME`, then retry.",
2399                ))
2400            }
2401        } else {
2402            Err(ToolchainError::unfixable(
2403                "Java runtime not found",
2404                "Install a JDK manually and set `JAVA_HOME`, then retry.",
2405            ))
2406        }
2407    }
2408}
2409
2410impl Installation for JavaInstallation {
2411    type Error = FailToInstallJava;
2412
2413    async fn install(&self, host: &Host) -> Result<(), Self::Error> {
2414        if cfg!(target_os = "windows") {
2415            ensure_package_installed(host, "Microsoft.OpenJDK.21")
2416                .await
2417                .map_err(map_winget_error_for_java)
2418        } else if cfg!(target_os = "macos") {
2419            let brew = Brew::default();
2420            brew.check(host)
2421                .await
2422                .map_err(|_| FailToInstallJava::BrewNotFound)?;
2423            brew.install_cask(host, "temurin")
2424                .await
2425                .map_err(FailToInstallJava::InstallFailed)
2426        } else if cfg!(target_os = "linux") {
2427            install_java_jdk(host)
2428                .await
2429                .map_err(map_linux_error_for_java)
2430        } else {
2431            Err(FailToInstallJava::UnsupportedPlatform)
2432        }
2433    }
2434}
2435
2436fn map_linux_error_for_java(error: LinuxPackageManagerError) -> FailToInstallJava {
2437    match error {
2438        LinuxPackageManagerError::UnsupportedPackageManager => {
2439            FailToInstallJava::UnsupportedPackageManager
2440        }
2441        LinuxPackageManagerError::Command(source) => FailToInstallJava::InstallFailed(source),
2442    }
2443}
2444
2445fn map_winget_error_for_java(error: WingetInstallError) -> FailToInstallJava {
2446    match error {
2447        WingetInstallError::WingetNotFound => FailToInstallJava::WingetNotFound,
2448        WingetInstallError::CommandFailed(err) => {
2449            FailToInstallJava::WingetInstallFailed(err.to_string())
2450        }
2451        WingetInstallError::NotInstalled { package_id } => {
2452            FailToInstallJava::WingetInstallFailed(format!(
2453                "Package `{package_id}` is still missing after winget install; verify winget sources and retry."
2454            ))
2455        }
2456    }
2457}
2458
2459impl Kotlin {
2460    /// Detect the path to the kotlinc compiler on `host`.
2461    pub async fn detect_path(host: &Host) -> Option<PathBuf> {
2462        let required_version = required_kotlin_version();
2463        let mut candidates = Vec::new();
2464
2465        if let Some(home) = host.env_string("KOTLIN_HOME")
2466            && let Some(kotlinc_path) = kotlin_executable_from_home(&PathBuf::from(&home))
2467        {
2468            candidates.push(kotlinc_path);
2469        }
2470
2471        if cfg!(target_os = "macos") {
2472            const ANDROID_STUDIO_KOTLINS: &[&str] = &[
2473                "Android Studio.app/Contents/plugins/Kotlin/kotlinc/bin/kotlinc",
2474                "Android Studio Preview.app/Contents/plugins/Kotlin/kotlinc/bin/kotlinc",
2475            ];
2476            for app_dir in host.app_dirs() {
2477                for relative in ANDROID_STUDIO_KOTLINS {
2478                    let kotlinc_path = app_dir.join(relative);
2479                    if kotlinc_path.exists() {
2480                        candidates.push(kotlinc_path);
2481                    }
2482                }
2483            }
2484        }
2485
2486        if cfg!(target_os = "linux")
2487            && let Some(home) = host.home_dir()
2488        {
2489            let paths = [
2490                home.join(
2491                    ".local/share/JetBrains/Toolbox/apps/android-studio/plugins/Kotlin/kotlinc/bin/kotlinc",
2492                ),
2493                home.join("android-studio/plugins/Kotlin/kotlinc/bin/kotlinc"),
2494            ];
2495            for kotlinc_path in paths {
2496                if kotlinc_path.exists() {
2497                    candidates.push(kotlinc_path);
2498                }
2499            }
2500        }
2501
2502        if cfg!(target_os = "windows")
2503            && let Some(program_files) = host.env_string("ProgramFiles")
2504        {
2505            let kotlinc_path = PathBuf::from(&program_files)
2506                .join("Android/Android Studio/plugins/Kotlin/kotlinc/bin/kotlinc.bat");
2507            if kotlinc_path.exists() {
2508                candidates.push(kotlinc_path);
2509            }
2510        }
2511
2512        if let Ok(managed_home) = managed_kotlin_home(host, required_version)
2513            && let Some(kotlinc_path) = kotlin_executable_from_home(&managed_home)
2514        {
2515            candidates.push(kotlinc_path);
2516        }
2517
2518        if let Ok(path) = host.which("kotlinc").await {
2519            candidates.push(path);
2520        }
2521
2522        candidates.dedup();
2523        for candidate in candidates {
2524            let Ok(installed_version) = kotlin_compiler_version(host, &candidate).await else {
2525                continue;
2526            };
2527            if kotlin_version_is_compatible(&installed_version, required_version) {
2528                return Some(candidate);
2529            }
2530        }
2531
2532        None
2533    }
2534}
2535
2536/// Kotlin installation handler.
2537#[derive(Debug)]
2538pub struct KotlinInstallation;
2539
2540/// Errors that can occur when installing Kotlin.
2541#[derive(Debug, thiserror::Error)]
2542pub enum FailToInstallKotlin {
2543    #[error("Failed to install Kotlin compiler: {0}")]
2544    InstallFailed(#[from] AndroidToolchainError),
2545    #[error("Kotlin compiler is still missing after installation.")]
2546    StillMissing,
2547}
2548
2549impl Toolchain for Kotlin {
2550    type Installation = KotlinInstallation;
2551
2552    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
2553        let kotlinc_path = Self::detect_path(host)
2554            .await
2555            .ok_or_else(|| ToolchainError::fixable(KotlinInstallation))?;
2556        // Only unix carries an execute bit, so elsewhere finding the file is
2557        // the whole check. Both arms are tail expressions rather than an early
2558        // `return` under one `cfg`, which would leave the other arm as dead
2559        // code on the platform that does compile it.
2560        #[cfg(unix)]
2561        {
2562            Self::reject_non_executable(kotlinc_path).await
2563        }
2564        #[cfg(not(unix))]
2565        {
2566            drop(kotlinc_path);
2567            Ok(())
2568        }
2569    }
2570}
2571
2572impl Kotlin {
2573    /// Rejects a `kotlinc` the current user cannot run.
2574    ///
2575    /// Only unix carries an execute bit, so elsewhere finding the file is the
2576    /// whole check — hence the two bodies rather than one with the permission
2577    /// half wrapped in `cfg`, which left the path unread on every other
2578    /// platform and tripped an unused-variable lint nobody was running.
2579    #[cfg(unix)]
2580    async fn reject_non_executable(
2581        kotlinc_path: PathBuf,
2582    ) -> Result<(), ToolchainError<KotlinInstallation>> {
2583        use std::os::unix::fs::PermissionsExt as _;
2584
2585        let Ok(metadata) = smol::unblock({
2586            let kotlinc_path = kotlinc_path.clone();
2587            move || std::fs::metadata(&kotlinc_path)
2588        })
2589        .await
2590        else {
2591            return Ok(());
2592        };
2593        if metadata.permissions().mode() & 0o111 == 0 {
2594            return Err(ToolchainError::unfixable(
2595                "Kotlin compiler (kotlinc) is not executable",
2596                format!(
2597                    "The kotlinc script at '{}' does not have execute permission. Fix it with: sudo chmod +x '{}'",
2598                    kotlinc_path.display(),
2599                    kotlinc_path.display()
2600                ),
2601            ));
2602        }
2603        Ok(())
2604    }
2605}
2606
2607impl Installation for KotlinInstallation {
2608    type Error = FailToInstallKotlin;
2609
2610    async fn install(&self, host: &Host) -> Result<(), Self::Error> {
2611        let required_version = required_kotlin_version();
2612        install_managed_kotlin_compiler(host, required_version)
2613            .await
2614            .map_err(FailToInstallKotlin::InstallFailed)?;
2615        if Kotlin::detect_path(host).await.is_some() {
2616            Ok(())
2617        } else {
2618            Err(FailToInstallKotlin::StillMissing)
2619        }
2620    }
2621}
2622
2623impl AndroidNdk {
2624    /// Detect the Android NDK path from `host` environment variables or standard locations.
2625    #[must_use]
2626    pub fn detect_path(host: &Host) -> Option<PathBuf> {
2627        if let Some(ndk_root) = host.env_string("ANDROID_NDK_ROOT") {
2628            let ndk_path = PathBuf::from(ndk_root);
2629            if ndk_path.exists() {
2630                return Some(ndk_path);
2631            }
2632        }
2633
2634        if let Some(ndk_home) = host.env_string("ANDROID_NDK_HOME") {
2635            let ndk_path = PathBuf::from(ndk_home);
2636            if ndk_path.exists() {
2637                return Some(ndk_path);
2638            }
2639        }
2640
2641        let sdk_path = AndroidSdk::detect_path(host)?;
2642        let ndk_dir = sdk_path.join("ndk");
2643        select_installed_ndk_path(&ndk_dir, Some(ndk_version::ANDROID_NDK_VERSION))
2644    }
2645}
2646
2647/// Android NDK installation handler.
2648#[derive(Debug, Clone, Copy, Default)]
2649pub enum AndroidNdkInstallation {
2650    /// Install the runtime-declared NDK package with `sdkmanager`.
2651    #[default]
2652    SdkPackage,
2653    /// Install `x86_64` userspace libraries needed by Google's Linux host tools on ARM Linux.
2654    LinuxX86_64HostToolsCompat,
2655}
2656
2657/// Errors that can occur when installing the Android NDK.
2658#[derive(Debug, thiserror::Error)]
2659pub enum FailToInstallAndroidNdk {
2660    #[error("Android SDK command-line tools (`sdkmanager`) not found.")]
2661    SdkManagerNotFound,
2662    #[error("Failed to install Android NDK via sdkmanager: {0}")]
2663    InstallFailed(#[from] AndroidToolchainError),
2664    #[error("Failed to install Android x86_64 host-tools compatibility packages: {0}")]
2665    HostToolsCompatFailed(#[from] LinuxPackageManagerError),
2666    /// Post-install NDK verification reported an unhealthy toolchain state.
2667    #[error("{0}")]
2668    VerificationFailed(#[from] ToolchainError<AndroidNdkInstallation>),
2669    #[error("Android NDK is still missing after installation.")]
2670    StillMissing,
2671    #[error("Android NDK is installed but incomplete (`toolchains/llvm/prebuilt` is missing).")]
2672    Incomplete,
2673}
2674
2675impl Toolchain for AndroidNdk {
2676    type Installation = AndroidNdkInstallation;
2677
2678    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
2679        if let Some(ndk_path) = Self::detect_path(host) {
2680            let llvm_dir = ndk_path.join("toolchains/llvm/prebuilt");
2681            if llvm_dir.exists() {
2682                return verify_ndk_host_toolchain_executable(host, &ndk_path).await;
2683            }
2684
2685            if AndroidSdk::sdkmanager_path(host).await.is_some() {
2686                return Err(ToolchainError::fixable(AndroidNdkInstallation::SdkPackage));
2687            }
2688
2689            return Err(ToolchainError::unfixable(
2690                "Android NDK is installed but incomplete",
2691                android_ndk_install_suggestion(),
2692            ));
2693        }
2694
2695        if AndroidSdk::sdkmanager_path(host).await.is_some() {
2696            Err(ToolchainError::fixable(AndroidNdkInstallation::SdkPackage))
2697        } else {
2698            Err(ToolchainError::unfixable(
2699                "Android NDK not found",
2700                format!(
2701                    "{} {}",
2702                    android_ndk_install_suggestion(),
2703                    android_cmdline_tools_suggestion()
2704                ),
2705            ))
2706        }
2707    }
2708}
2709
2710impl Installation for AndroidNdkInstallation {
2711    type Error = FailToInstallAndroidNdk;
2712
2713    async fn install(&self, host: &Host) -> Result<(), Self::Error> {
2714        if matches!(self, Self::LinuxX86_64HostToolsCompat) {
2715            return install_android_linux_x86_64_host_tools_compat(host)
2716                .await
2717                .map_err(FailToInstallAndroidNdk::HostToolsCompatFailed);
2718        }
2719
2720        if AndroidSdk::sdkmanager_path(host).await.is_none() {
2721            return Err(FailToInstallAndroidNdk::SdkManagerNotFound);
2722        }
2723
2724        let (_, sdk_root) = resolve_sdkmanager_and_root(host)
2725            .await
2726            .map_err(FailToInstallAndroidNdk::InstallFailed)?;
2727        let ndk_package = required_ndk_package_id(host)
2728            .await
2729            .map_err(FailToInstallAndroidNdk::InstallFailed)?;
2730        let ndk_path = ndk_path_for_package_id(&sdk_root, &ndk_package)
2731            .map_err(FailToInstallAndroidNdk::InstallFailed)?;
2732        if ndk_path.exists() && !ndk_layout_is_complete(&ndk_path) {
2733            remove_directory_if_exists(&ndk_path)
2734                .await
2735                .map_err(AndroidToolchainError::from)
2736                .map_err(FailToInstallAndroidNdk::InstallFailed)?;
2737        }
2738        install_android_sdk_package(host, &ndk_package)
2739            .await
2740            .map_err(FailToInstallAndroidNdk::InstallFailed)?;
2741
2742        if !ndk_path.exists() {
2743            return Err(FailToInstallAndroidNdk::StillMissing);
2744        }
2745
2746        if !ndk_layout_is_complete(&ndk_path) {
2747            return Err(FailToInstallAndroidNdk::Incomplete);
2748        }
2749
2750        verify_android_ndk_after_install(host, &ndk_path).await
2751    }
2752}
2753
2754async fn verify_android_ndk_after_install(
2755    host: &Host,
2756    ndk_path: &Path,
2757) -> Result<(), FailToInstallAndroidNdk> {
2758    match verify_ndk_host_toolchain_executable(host, ndk_path).await {
2759        Ok(()) => Ok(()),
2760        Err(ToolchainError::Fixable(AndroidNdkInstallation::LinuxX86_64HostToolsCompat)) => {
2761            install_android_linux_x86_64_host_tools_compat(host)
2762                .await
2763                .map_err(FailToInstallAndroidNdk::HostToolsCompatFailed)?;
2764            verify_ndk_host_toolchain_executable(host, ndk_path)
2765                .await
2766                .map_err(FailToInstallAndroidNdk::VerificationFailed)
2767        }
2768        Err(error) => Err(FailToInstallAndroidNdk::VerificationFailed(error)),
2769    }
2770}
2771
2772#[cfg(test)]
2773mod host_tests {
2774    use std::path::Path;
2775
2776    use super::{
2777        AndroidBuildTools, AndroidNdk, AndroidPlatformTools, AndroidRustTargets, AndroidSdk,
2778        AndroidSdkPlatforms, Java, Kotlin, latest_android_platform_package_id,
2779        parse_android_platform_api_level, parse_android_version_pair, required_kotlin_version,
2780    };
2781    use crate::toolchain::testing::TestMachine;
2782    use crate::toolchain::{Host, Toolchain, ToolchainError};
2783
2784    /// Host declaring `ANDROID_SDK_ROOT` at `sdk`.
2785    fn sdk_host(machine: &TestMachine, sdk: &Path) -> Host {
2786        machine.host([(
2787            String::from("ANDROID_SDK_ROOT"),
2788            sdk.as_os_str().to_os_string(),
2789        )])
2790    }
2791
2792    /// Machine with a staged SDK (`cmdline-tools/latest/bin/sdkmanager`) and a
2793    /// host declaring `ANDROID_SDK_ROOT` at it.
2794    fn sdk_machine() -> (TestMachine, Host) {
2795        let machine = TestMachine::new();
2796        let sdk = machine.install_android_sdk();
2797        let host = sdk_host(&machine, &sdk);
2798        (machine, host)
2799    }
2800
2801    // -- #633: minor-versioned platform package identifiers ----------------
2802
2803    #[test]
2804    fn platform_api_level_parser_accepts_minor_versioned_packages() {
2805        assert_eq!(
2806            parse_android_platform_api_level("platforms;android-37.0"),
2807            Some((37, 0)),
2808            "`platforms;android-37.0` must parse to API 37.0 (#633)"
2809        );
2810        assert_eq!(
2811            parse_android_platform_api_level("platforms;android-36"),
2812            Some((36, 0))
2813        );
2814        assert_eq!(
2815            parse_android_platform_api_level("platforms;android-Tiramisu"),
2816            None
2817        );
2818        assert_eq!(parse_android_platform_api_level("build-tools;37.0.0"), None);
2819    }
2820
2821    #[test]
2822    fn android_version_pair_orders_minor_within_major() {
2823        assert_eq!(parse_android_version_pair("37.0"), Some((37, 0)));
2824        assert_eq!(parse_android_version_pair("37.1"), Some((37, 1)));
2825        assert_eq!(parse_android_version_pair("36"), Some((36, 0)));
2826        assert_eq!(parse_android_version_pair("android-37"), None);
2827        assert_eq!(parse_android_version_pair(""), None);
2828        assert_eq!(parse_android_version_pair("36.1.2"), None);
2829        // android-36 < android-36.1 < android-37.0 < android-37.1
2830        assert!(parse_android_version_pair("36") < parse_android_version_pair("36.1"));
2831        assert!(parse_android_version_pair("36.1") < parse_android_version_pair("37.0"));
2832        assert!(parse_android_version_pair("37.0") < parse_android_version_pair("37.1"));
2833    }
2834
2835    #[test]
2836    fn sdkmanager_list_prefers_latest_platform_including_minor_versions() {
2837        let (machine, host) = sdk_machine();
2838        machine.install("java");
2839        machine.respond(
2840            "SDKMANAGER_LIST",
2841            include_str!("testdata/sdkmanager_list.txt"),
2842        );
2843        let package = smol::block_on(latest_android_platform_package_id(&host))
2844            .expect("sdkmanager --list transcript must yield a platform package");
2845        assert_eq!(
2846            package, "platforms;android-37.1",
2847            "android-37.1 outranks android-37.0 and android-36.1 (#633)"
2848        );
2849    }
2850
2851    #[test]
2852    fn android_jar_prefers_minor_versioned_platform_dir() {
2853        // #633: the platform-directory sort is keyed on the same
2854        // (major, minor) pair, so android-36.1 outranks android-36 and
2855        // android-37.1 outranks android-37.0 on disk too.
2856        let (machine, host) = sdk_machine();
2857        machine.install_android_platform("android-36");
2858        machine.install_android_platform("android-36.1");
2859        machine.install_android_platform("android-37.0");
2860        machine.install_android_platform("android-37.1");
2861        let jar = AndroidSdk::android_jar_path(&host).expect("a staged platform jar");
2862        assert_eq!(
2863            jar.parent().and_then(|dir| dir.file_name()),
2864            Some(std::ffi::OsStr::new("android-37.1")),
2865            "the highest (major, minor) platform dir wins: {jar:?}"
2866        );
2867
2868        let (machine36, host36) = sdk_machine();
2869        machine36.install_android_platform("android-36");
2870        machine36.install_android_platform("android-36.1");
2871        let jar = AndroidSdk::android_jar_path(&host36).expect("a staged platform jar");
2872        assert_eq!(
2873            jar.parent().and_then(|dir| dir.file_name()),
2874            Some(std::ffi::OsStr::new("android-36.1")),
2875            "android-36.1 outranks android-36: {jar:?}"
2876        );
2877    }
2878
2879    // -- AndroidSdk --------------------------------------------------------
2880
2881    #[test]
2882    fn sdk_detect_path_reads_declared_env() {
2883        let machine = TestMachine::new();
2884        let sdk = machine.install_android_sdk();
2885        let host = sdk_host(&machine, &sdk);
2886        assert_eq!(
2887            AndroidSdk::detect_path(&host).as_deref(),
2888            Some(sdk.as_path())
2889        );
2890    }
2891
2892    #[test]
2893    fn sdk_check_ok_when_sdkmanager_present() {
2894        let (_machine, host) = sdk_machine();
2895        smol::block_on(AndroidSdk.check(&host)).expect("a staged SDK with sdkmanager must be ok");
2896    }
2897
2898    #[test]
2899    fn sdk_check_fixable_when_sdkmanager_absent() {
2900        let machine = TestMachine::new();
2901        // A root that "looks like" an SDK (platform-tools marker) but has no
2902        // sdkmanager anywhere.
2903        let sdk = machine.dir("sdk/platform-tools");
2904        let host = sdk_host(&machine, sdk.parent().expect("sdk root"));
2905        let result = smol::block_on(AndroidSdk.check(&host));
2906        assert!(
2907            matches!(result, Err(ToolchainError::Fixable(_))),
2908            "an SDK root without sdkmanager must be fixable: {result:?}"
2909        );
2910    }
2911
2912    #[test]
2913    fn sdk_missing_classification_matches_platform_installer() {
2914        let machine = TestMachine::new();
2915        let host = machine.host(Vec::<(String, String)>::new());
2916        let result = smol::block_on(AndroidSdk.check(&host));
2917        #[cfg(target_os = "linux")]
2918        {
2919            // `~/Android/Sdk` under the scratch home counts as a configured
2920            // root, so Linux always plans the cmdline-tools install.
2921            assert!(
2922                matches!(result, Err(ToolchainError::Fixable(_))),
2923                "missing SDK on Linux must plan a cmdline-tools install: {result:?}"
2924            );
2925        }
2926        #[cfg(any(target_os = "macos", target_os = "windows"))]
2927        {
2928            assert!(
2929                matches!(result, Err(ToolchainError::Unfixable(_))),
2930                "missing SDK without brew/winget must be unfixable: {result:?}"
2931            );
2932            #[cfg(target_os = "macos")]
2933            machine.install("brew");
2934            #[cfg(target_os = "windows")]
2935            machine.install("winget");
2936            let host = machine.host(Vec::<(String, String)>::new());
2937            let result = smol::block_on(AndroidSdk.check(&host));
2938            assert!(
2939                matches!(result, Err(ToolchainError::Fixable(_))),
2940                "missing SDK with a platform installer must be fixable: {result:?}"
2941            );
2942        }
2943    }
2944
2945    // -- AndroidPlatformTools ----------------------------------------------
2946
2947    #[test]
2948    fn platform_tools_fixable_when_sdkmanager_can_install_it() {
2949        let (_machine, host) = sdk_machine();
2950        let result = smol::block_on(AndroidPlatformTools.check(&host));
2951        assert!(
2952            matches!(result, Err(ToolchainError::Fixable(_))),
2953            "missing adb with sdkmanager must be fixable: {result:?}"
2954        );
2955    }
2956
2957    #[test]
2958    fn platform_tools_unfixable_without_sdk() {
2959        let machine = TestMachine::new();
2960        let host = machine.host(Vec::<(String, String)>::new());
2961        let result = smol::block_on(AndroidPlatformTools.check(&host));
2962        assert!(
2963            matches!(result, Err(ToolchainError::Unfixable(_))),
2964            "no SDK and no sdkmanager must be unfixable: {result:?}"
2965        );
2966    }
2967
2968    #[test]
2969    #[cfg(unix)]
2970    fn platform_tools_ok_when_adb_runs() {
2971        let (machine, host) = sdk_machine();
2972        machine.install_adb();
2973        smol::block_on(AndroidPlatformTools.check(&host))
2974            .expect("a runnable adb must satisfy platform-tools");
2975    }
2976
2977    #[test]
2978    #[cfg(windows)]
2979    fn platform_tools_unfixable_when_adb_cannot_spawn() {
2980        // The staged adb.exe carries cmd text; CreateProcess cannot run it,
2981        // so the verify branch reports the executable-broken diagnostic.
2982        let (machine, host) = sdk_machine();
2983        machine.install_adb();
2984        let result = smol::block_on(AndroidPlatformTools.check(&host));
2985        assert!(
2986            matches!(result, Err(ToolchainError::Unfixable(_))),
2987            "a non-spawning adb must be unfixable: {result:?}"
2988        );
2989    }
2990
2991    // -- AndroidSdkPlatforms -------------------------------------------------
2992
2993    #[test]
2994    fn sdk_platforms_ok_with_android_jar() {
2995        let (machine, host) = sdk_machine();
2996        machine.install_android_platform("android-36");
2997        smol::block_on(AndroidSdkPlatforms.check(&host))
2998            .expect("an installed android.jar must satisfy the check");
2999    }
3000
3001    #[test]
3002    fn sdk_platforms_ok_with_minor_versioned_platform_dir() {
3003        // #633: `platforms/android-37.0` is a real layout on disk.
3004        let (machine, host) = sdk_machine();
3005        machine.install_android_platform("android-37.0");
3006        smol::block_on(AndroidSdkPlatforms.check(&host))
3007            .expect("android-37.0 platform dir must satisfy the check (#633)");
3008    }
3009
3010    #[test]
3011    fn sdk_platforms_fixable_when_sdkmanager_can_install() {
3012        let (_machine, host) = sdk_machine();
3013        let result = smol::block_on(AndroidSdkPlatforms.check(&host));
3014        assert!(
3015            matches!(result, Err(ToolchainError::Fixable(_))),
3016            "missing platforms with sdkmanager must be fixable: {result:?}"
3017        );
3018    }
3019
3020    #[test]
3021    fn sdk_platforms_unfixable_without_sdk() {
3022        let machine = TestMachine::new();
3023        let host = machine.host(Vec::<(String, String)>::new());
3024        let result = smol::block_on(AndroidSdkPlatforms.check(&host));
3025        assert!(
3026            matches!(result, Err(ToolchainError::Unfixable(_))),
3027            "no SDK and no sdkmanager must be unfixable: {result:?}"
3028        );
3029    }
3030
3031    // -- AndroidBuildTools ---------------------------------------------------
3032
3033    #[test]
3034    fn build_tools_ok_with_d8_jar() {
3035        let (machine, host) = sdk_machine();
3036        machine.install_android_build_tools("36.0.0");
3037        smol::block_on(AndroidBuildTools.check(&host))
3038            .expect("an installed d8.jar must satisfy the check");
3039    }
3040
3041    #[test]
3042    fn build_tools_fixable_when_sdkmanager_can_install() {
3043        let (_machine, host) = sdk_machine();
3044        let result = smol::block_on(AndroidBuildTools.check(&host));
3045        assert!(
3046            matches!(result, Err(ToolchainError::Fixable(_))),
3047            "missing build-tools with sdkmanager must be fixable: {result:?}"
3048        );
3049    }
3050
3051    // -- AndroidRustTargets --------------------------------------------------
3052
3053    #[test]
3054    fn rust_targets_unfixable_without_rustup() {
3055        let machine = TestMachine::new();
3056        let host = machine.host(Vec::<(String, String)>::new());
3057        let result = smol::block_on(AndroidRustTargets::default().check(&host));
3058        assert!(
3059            matches!(result, Err(ToolchainError::Unfixable(_))),
3060            "no rustup must be unfixable: {result:?}"
3061        );
3062    }
3063
3064    #[test]
3065    fn rust_targets_ok_when_all_installed() {
3066        let machine = TestMachine::new();
3067        machine.install("rustup");
3068        // `rustup target list --installed` emits one target per line.
3069        machine.respond(
3070            "RUSTUP_INSTALLED_TARGETS",
3071            &[
3072                "aarch64-linux-android",
3073                "armv7-linux-androideabi",
3074                "i686-linux-android",
3075                "x86_64-linux-android",
3076            ]
3077            .join("\n"),
3078        );
3079        let host = machine.host(Vec::<(String, String)>::new());
3080        smol::block_on(AndroidRustTargets::default().check(&host))
3081            .expect("all four Android targets installed must be ok");
3082    }
3083
3084    #[test]
3085    fn rust_targets_fixable_lists_missing_targets() {
3086        let machine = TestMachine::new();
3087        machine.install("rustup");
3088        let host = machine.host([(
3089            String::from("WATERUI_FAKE_RUSTUP_INSTALLED_TARGETS"),
3090            String::from("aarch64-linux-android"),
3091        )]);
3092        let result = smol::block_on(AndroidRustTargets::default().check(&host));
3093        assert!(
3094            matches!(result, Err(ToolchainError::Fixable(_))),
3095            "missing Android targets must be fixable: {result:?}"
3096        );
3097    }
3098
3099    // -- Java ----------------------------------------------------------------
3100
3101    #[test]
3102    fn java_ok_when_on_path() {
3103        let machine = TestMachine::new();
3104        machine.install("java");
3105        let host = machine.host(Vec::<(String, String)>::new());
3106        smol::block_on(Java.check(&host)).expect("java on PATH must be ok");
3107    }
3108
3109    #[test]
3110    fn java_missing_is_unfixable_without_installer() {
3111        let machine = TestMachine::new();
3112        let host = machine.host(Vec::<(String, String)>::new());
3113        let result = smol::block_on(Java.check(&host));
3114        assert!(
3115            matches!(result, Err(ToolchainError::Unfixable(_))),
3116            "no java and no installer must be unfixable: {result:?}"
3117        );
3118    }
3119
3120    #[test]
3121    fn java_missing_is_fixable_with_installer() {
3122        let machine = TestMachine::new();
3123        #[cfg(target_os = "macos")]
3124        machine.install("brew");
3125        #[cfg(target_os = "windows")]
3126        machine.install("winget");
3127        #[cfg(target_os = "linux")]
3128        machine.install("apt-get");
3129        let host = machine.host(Vec::<(String, String)>::new());
3130        let result = smol::block_on(Java.check(&host));
3131        assert!(
3132            matches!(result, Err(ToolchainError::Fixable(_))),
3133            "no java with a platform installer must be fixable: {result:?}"
3134        );
3135    }
3136
3137    // -- Kotlin --------------------------------------------------------------
3138
3139    #[test]
3140    fn kotlin_ok_when_compatible_kotlinc_on_path() {
3141        let machine = TestMachine::new();
3142        machine.install("kotlinc");
3143        let host = machine.host([(
3144            String::from("WATERUI_FAKE_KOTLINC_VERSION"),
3145            required_kotlin_version().to_string(),
3146        )]);
3147        smol::block_on(Kotlin.check(&host)).expect("a compatible kotlinc on PATH must be ok");
3148    }
3149
3150    #[test]
3151    fn kotlin_fixable_when_absent() {
3152        let machine = TestMachine::new();
3153        let host = machine.host(Vec::<(String, String)>::new());
3154        let result = smol::block_on(Kotlin.check(&host));
3155        assert!(
3156            matches!(result, Err(ToolchainError::Fixable(_))),
3157            "no kotlinc must be fixable via managed install: {result:?}"
3158        );
3159    }
3160
3161    #[test]
3162    fn kotlin_fixable_when_too_old() {
3163        let machine = TestMachine::new();
3164        machine.install("kotlinc");
3165        let host = machine.host([(
3166            String::from("WATERUI_FAKE_KOTLINC_VERSION"),
3167            String::from("1.0.0"),
3168        )]);
3169        let result = smol::block_on(Kotlin.check(&host));
3170        assert!(
3171            matches!(result, Err(ToolchainError::Fixable(_))),
3172            "an incompatible kotlinc must trigger the managed install fix: {result:?}"
3173        );
3174    }
3175
3176    // -- AndroidNdk ----------------------------------------------------------
3177
3178    #[test]
3179    fn ndk_fixable_when_sdkmanager_can_install() {
3180        let (_machine, host) = sdk_machine();
3181        let result = smol::block_on(AndroidNdk.check(&host));
3182        assert!(
3183            matches!(result, Err(ToolchainError::Fixable(_))),
3184            "missing NDK with sdkmanager must be fixable: {result:?}"
3185        );
3186    }
3187
3188    #[test]
3189    fn ndk_unfixable_without_sdk() {
3190        let machine = TestMachine::new();
3191        let host = machine.host(Vec::<(String, String)>::new());
3192        let result = smol::block_on(AndroidNdk.check(&host));
3193        assert!(
3194            matches!(result, Err(ToolchainError::Unfixable(_))),
3195            "no SDK and no sdkmanager must be unfixable: {result:?}"
3196        );
3197    }
3198
3199    #[test]
3200    #[cfg(unix)]
3201    fn ndk_ok_when_host_toolchain_runs() {
3202        let (machine, host) = sdk_machine();
3203        machine.install_android_ndk("29.0.14206865");
3204        smol::block_on(AndroidNdk.check(&host))
3205            .expect("a staged NDK whose clang runs must satisfy the check");
3206    }
3207}