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