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