diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index f972e466..5a936e49 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -24,8 +24,21 @@ dotnet test tests/Microsoft.Android.Build.BaseTasks-Tests/Microsoft.Android.Buil
Output: `bin\$(Configuration)\` (redistributables), `bin\Test$(Configuration)\` (tests). `$(DotNetTargetFrameworkVersion)` = `10.0` in `Directory.Build.props`. Versioning: `nuget.version` has `major.minor`; patch = git commit count since file changed.
+## Android Environment Variables
+
+Per the [official Android docs](https://developer.android.com/tools/variables#envar):
+
+- **`ANDROID_HOME`** — the canonical variable for the Android SDK root path. Use this everywhere.
+- **`ANDROID_SDK_ROOT`** — **deprecated**. Do not introduce new usages. Existing code may still read it for backward compatibility but always prefer `ANDROID_HOME`.
+- **`ANDROID_USER_HOME`** — user-level config/AVD storage (defaults to `~/.android`).
+- **`ANDROID_EMULATOR_HOME`** — emulator config (defaults to `$ANDROID_USER_HOME`).
+- **`ANDROID_AVD_HOME`** — AVD data (defaults to `$ANDROID_USER_HOME/avd`).
+
+When setting environment variables for SDK tools (e.g. `sdkmanager`, `avdmanager`), set `ANDROID_HOME`. The `EnvironmentVariableNames` class in this repo defines the constants.
+
## Conventions
+- **One type per file**: each public class, struct, enum, or interface must be in its own `.cs` file named after the type (e.g. `JdkVersionInfo` → `JdkVersionInfo.cs`). Do not combine multiple top-level types in a single file.
- [Mono Coding Guidelines](http://www.mono-project.com/community/contributing/coding-guidelines/): tabs, K&R braces, `PascalCase` public members.
- Nullable enabled in `AndroidSdk`. `NullableAttributes.cs` excluded on `net10.0+`.
- Strong-named via `product.snk`. In the AndroidSdk project, tests use `InternalsVisibleTo` with full public key (`Properties/AssemblyInfo.cs`).
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/DownloadUtils.cs b/src/Xamarin.Android.Tools.AndroidSdk/DownloadUtils.cs
new file mode 100644
index 00000000..64c4469d
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/DownloadUtils.cs
@@ -0,0 +1,131 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.IO.Compression;
+using System.Net.Http;
+using System.Security.Cryptography;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Xamarin.Android.Tools
+{
+ ///
+ /// Shared helpers for downloading files, verifying checksums, and extracting archives.
+ ///
+ static class DownloadUtils
+ {
+ /// Downloads a file from the given URL with optional progress reporting.
+ public static async Task DownloadFileAsync (HttpClient client, string url, string destinationPath, long expectedSize, IProgress<(double percent, string message)>? progress, CancellationToken cancellationToken)
+ {
+ using var response = await client.GetAsync (url, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait (false);
+ response.EnsureSuccessStatusCode ();
+
+ var totalBytes = response.Content.Headers.ContentLength ?? expectedSize;
+
+ using var contentStream = await response.Content.ReadAsStreamAsync ().ConfigureAwait (false);
+
+ var dirPath = Path.GetDirectoryName (destinationPath);
+ if (!string.IsNullOrEmpty (dirPath))
+ Directory.CreateDirectory (dirPath);
+
+ using var fileStream = new FileStream (destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true);
+
+ var buffer = new byte [81920];
+ long totalRead = 0;
+ int bytesRead;
+
+ while ((bytesRead = await contentStream.ReadAsync (buffer, 0, buffer.Length, cancellationToken).ConfigureAwait (false)) > 0) {
+ await fileStream.WriteAsync (buffer, 0, bytesRead, cancellationToken).ConfigureAwait (false);
+ totalRead += bytesRead;
+
+ if (progress is not null && totalBytes > 0) {
+ var pct = (double) totalRead / totalBytes * 100;
+ progress.Report ((pct, $"Downloaded {totalRead / (1024 * 1024)} MB / {totalBytes / (1024 * 1024)} MB"));
+ }
+ }
+ }
+
+ /// Verifies a file's SHA-256 hash against an expected value.
+ public static void VerifyChecksum (string filePath, string expectedChecksum)
+ {
+ using var sha256 = SHA256.Create ();
+ using var stream = File.OpenRead (filePath);
+
+ var hash = sha256.ComputeHash (stream);
+ var actual = BitConverter.ToString (hash).Replace ("-", "").ToLowerInvariant ();
+
+ if (!string.Equals (actual, expectedChecksum, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException ($"Checksum verification failed. Expected: {expectedChecksum}, Actual: {actual}");
+ }
+
+ /// Extracts a ZIP archive with Zip Slip protection.
+ public static void ExtractZipSafe (string archivePath, string destinationPath, CancellationToken cancellationToken)
+ {
+ using var archive = ZipFile.OpenRead (archivePath);
+ var fullExtractRoot = Path.GetFullPath (destinationPath);
+
+ foreach (var entry in archive.Entries) {
+ cancellationToken.ThrowIfCancellationRequested ();
+
+ if (string.IsNullOrEmpty (entry.Name))
+ continue;
+
+ var destinationFile = Path.GetFullPath (Path.Combine (fullExtractRoot, entry.FullName));
+
+ // Zip Slip protection
+ if (!destinationFile.StartsWith (fullExtractRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) &&
+ destinationFile != fullExtractRoot) {
+ throw new InvalidOperationException ($"Archive entry '{entry.FullName}' would extract outside target directory.");
+ }
+
+ var entryDir = Path.GetDirectoryName (destinationFile);
+ if (!string.IsNullOrEmpty (entryDir))
+ Directory.CreateDirectory (entryDir);
+
+ entry.ExtractToFile (destinationFile, overwrite: true);
+ }
+ }
+
+ /// Extracts a tar.gz archive using the system tar command.
+ public static async Task ExtractTarGzAsync (string archivePath, string destinationPath, Action logger, CancellationToken cancellationToken)
+ {
+ var psi = new ProcessStartInfo {
+ FileName = "/usr/bin/tar",
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+#if NET5_0_OR_GREATER
+ psi.ArgumentList.Add ("-xzf");
+ psi.ArgumentList.Add (archivePath);
+ psi.ArgumentList.Add ("-C");
+ psi.ArgumentList.Add (destinationPath);
+#else
+ psi.Arguments = $"-xzf \"{archivePath}\" -C \"{destinationPath}\"";
+#endif
+
+ var stdout = new StringWriter ();
+ var stderr = new StringWriter ();
+ var exitCode = await ProcessUtils.StartProcess (psi, stdout: stdout, stderr: stderr, cancellationToken).ConfigureAwait (false);
+
+ if (exitCode != 0) {
+ var errorOutput = stderr.ToString ();
+ logger (TraceLevel.Error, $"tar extraction failed (exit code {exitCode}): {errorOutput}");
+ throw new IOException ($"Failed to extract archive '{archivePath}': {errorOutput}");
+ }
+ }
+
+ /// Parses "hash filename" or just "hash" from .sha256sum.txt content.
+ public static string? ParseChecksumFile (string content)
+ {
+ if (string.IsNullOrWhiteSpace (content))
+ return null;
+
+ var line = content.Trim ().Split ('\n') [0].Trim ();
+ var parts = line.Split (new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
+ return parts.Length > 0 ? parts [0] : null;
+ }
+ }
+}
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/IsExternalInit.cs b/src/Xamarin.Android.Tools.AndroidSdk/IsExternalInit.cs
new file mode 100644
index 00000000..4f2414e6
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/IsExternalInit.cs
@@ -0,0 +1,8 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace System.Runtime.CompilerServices
+{
+ // Polyfill for netstandard2.0 to support C# 9+ records and init-only properties.
+ internal static class IsExternalInit { }
+}
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs b/src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs
new file mode 100644
index 00000000..07618c23
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/JdkInstaller.cs
@@ -0,0 +1,536 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Runtime.InteropServices;
+#if NET5_0_OR_GREATER
+using System.Security.Principal;
+#endif
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Xamarin.Android.Tools
+{
+ ///
+ /// Provides JDK installation capabilities using the Microsoft Build of OpenJDK.
+ /// Downloads from https://aka.ms/download-jdk with SHA-256 verification.
+ /// See https://www.microsoft.com/openjdk for more information.
+ ///
+ public class JdkInstaller : IDisposable
+ {
+ const string DownloadUrlBase = "https://aka.ms/download-jdk";
+
+ /// Gets the recommended JDK major version for .NET Android development.
+ public static int RecommendedMajorVersion => 21;
+
+ /// Gets the supported JDK major versions available for installation.
+ public static IReadOnlyList SupportedVersions { get; } = [ RecommendedMajorVersion ];
+
+ readonly HttpClient httpClient = new();
+ readonly Action logger;
+
+ public JdkInstaller (Action? logger = null)
+ {
+ this.logger = logger ?? AndroidSdkInfo.DefaultConsoleLogger;
+ }
+
+ public void Dispose () => httpClient.Dispose ();
+
+ /// Discovers available Microsoft OpenJDK versions for the current platform.
+ public async Task> DiscoverAsync (CancellationToken cancellationToken = default)
+ {
+ var results = new List ();
+
+ foreach (var version in SupportedVersions) {
+ cancellationToken.ThrowIfCancellationRequested ();
+ try {
+ var info = BuildVersionInfo (version);
+
+ // Verify the download URL is valid with a HEAD request
+ using var request = new HttpRequestMessage (HttpMethod.Head, info.DownloadUrl);
+ using var response = await httpClient.SendAsync (request, cancellationToken).ConfigureAwait (false);
+
+ if (!response.IsSuccessStatusCode) {
+ logger (TraceLevel.Warning, $"JDK {version} not available: HEAD {info.DownloadUrl} returned {(int) response.StatusCode}");
+ continue;
+ }
+
+ if (response.Content.Headers.ContentLength.HasValue)
+ info.Size = response.Content.Headers.ContentLength.Value;
+
+ if (response.RequestMessage?.RequestUri is not null)
+ info.ResolvedUrl = response.RequestMessage.RequestUri.ToString ();
+
+ info.Checksum = await FetchChecksumAsync (info.ChecksumUrl, $"JDK {version}", cancellationToken).ConfigureAwait (false);
+
+ results.Add (info);
+ logger (TraceLevel.Info, $"Discovered {info.DisplayName} (size={info.Size})");
+ }
+ catch (Exception ex) {
+ logger (TraceLevel.Warning, $"Failed to discover JDK {version}: {ex.Message}");
+ logger (TraceLevel.Verbose, ex.ToString ());
+ }
+ }
+
+ return results.AsReadOnly ();
+ }
+
+ /// Downloads and installs a Microsoft OpenJDK to the target path.
+ public async Task InstallAsync (int majorVersion, string targetPath, IProgress? progress = null, CancellationToken cancellationToken = default)
+ {
+ if (!SupportedVersions.Contains (majorVersion))
+ throw new ArgumentException ($"JDK version {majorVersion} is not supported. Supported versions: {string.Join (", ", SupportedVersions)}", nameof (majorVersion));
+
+ if (string.IsNullOrEmpty (targetPath))
+ throw new ArgumentNullException (nameof (targetPath));
+
+ // When elevated and a platform installer is available, use it and let the installer handle paths
+ if (IsElevated () && GetInstallerExtension () is not null) {
+ logger (TraceLevel.Info, "Running elevated — using platform installer (.msi/.pkg).");
+ await InstallWithPlatformInstallerAsync (majorVersion, progress, cancellationToken).ConfigureAwait (false);
+ return;
+ }
+
+ if (!IsTargetPathWritable (targetPath)) {
+ logger (TraceLevel.Error, $"Target path '{targetPath}' is not writable or is in a restricted location.");
+ throw new ArgumentException ($"Target path '{targetPath}' is not writable or is in a restricted location.", nameof (targetPath));
+ }
+
+ var versionInfo = BuildVersionInfo (majorVersion);
+
+ // Fetch checksum - required for supply-chain integrity
+ var checksum = await FetchChecksumAsync (versionInfo.ChecksumUrl, $"JDK {majorVersion}", cancellationToken).ConfigureAwait (false);
+ if (string.IsNullOrEmpty (checksum))
+ throw new InvalidOperationException ($"Failed to fetch SHA-256 checksum for JDK {majorVersion}. Cannot verify download integrity.");
+ versionInfo.Checksum = checksum;
+
+ var tempArchivePath = Path.Combine (Path.GetTempPath (), $"microsoft-jdk-{majorVersion}-{Guid.NewGuid ()}{GetArchiveExtension ()}");
+
+ try {
+ // Download
+ logger (TraceLevel.Info, $"Downloading Microsoft OpenJDK {majorVersion} from {versionInfo.DownloadUrl}");
+ progress?.Report (new JdkInstallProgress (JdkInstallPhase.Downloading, 0, $"Downloading Microsoft OpenJDK {majorVersion}..."));
+ await DownloadUtils.DownloadFileAsync (httpClient, versionInfo.DownloadUrl, tempArchivePath, versionInfo.Size,
+ progress is null ? null : new Progress<(double pct, string msg)> (p => progress.Report (new JdkInstallProgress (JdkInstallPhase.Downloading, p.pct, p.msg))),
+ cancellationToken).ConfigureAwait (false);
+ logger (TraceLevel.Info, $"Download complete: {tempArchivePath}");
+
+ // Verify checksum
+ progress?.Report (new JdkInstallProgress (JdkInstallPhase.Verifying, 0, "Verifying SHA-256 checksum..."));
+ DownloadUtils.VerifyChecksum (tempArchivePath, versionInfo.Checksum!);
+ logger (TraceLevel.Info, "Checksum verified.");
+ progress?.Report (new JdkInstallProgress (JdkInstallPhase.Verifying, 100, "Checksum verified."));
+
+ // Extract
+ logger (TraceLevel.Info, $"Extracting JDK to {targetPath}");
+ progress?.Report (new JdkInstallProgress (JdkInstallPhase.Extracting, 0, "Extracting JDK..."));
+ await ExtractArchiveAsync (tempArchivePath, targetPath, cancellationToken).ConfigureAwait (false);
+ logger (TraceLevel.Info, "Extraction complete.");
+ progress?.Report (new JdkInstallProgress (JdkInstallPhase.Extracting, 100, "Extraction complete."));
+
+ // Validate
+ progress?.Report (new JdkInstallProgress (JdkInstallPhase.Validating, 0, "Validating installation..."));
+ if (!IsValid (targetPath)) {
+ logger (TraceLevel.Error, $"JDK installation at '{targetPath}' failed validation.");
+ TryDeleteDirectory (targetPath, "invalid installation");
+ throw new InvalidOperationException ($"JDK installation at '{targetPath}' failed validation. The extracted files may be corrupted.");
+ }
+ logger (TraceLevel.Info, $"Microsoft OpenJDK {majorVersion} installed successfully at {targetPath}");
+ progress?.Report (new JdkInstallProgress (JdkInstallPhase.Validating, 100, "Validation complete."));
+
+ progress?.Report (new JdkInstallProgress (JdkInstallPhase.Complete, 100, $"Microsoft OpenJDK {majorVersion} installed successfully."));
+ }
+ catch (Exception ex) when (ex is not ArgumentException and not ArgumentNullException) {
+ logger (TraceLevel.Error, $"JDK installation failed: {ex.Message}");
+ logger (TraceLevel.Verbose, ex.ToString ());
+ throw;
+ }
+ finally {
+ TryDeleteFile (tempArchivePath);
+ }
+ }
+
+ /// Validates whether the path contains a valid JDK installation.
+ public bool IsValid (string jdkPath)
+ {
+ if (string.IsNullOrEmpty (jdkPath) || !Directory.Exists (jdkPath))
+ return false;
+
+ try {
+ var jdk = new JdkInfo (jdkPath, logger: logger);
+ return jdk.Version is not null;
+ }
+ catch (Exception ex) {
+ logger (TraceLevel.Warning, $"JDK validation failed for '{jdkPath}': {ex.Message}");
+ logger (TraceLevel.Verbose, ex.ToString ());
+ return false;
+ }
+ }
+
+ async Task InstallWithPlatformInstallerAsync (int majorVersion, IProgress? progress, CancellationToken cancellationToken)
+ {
+ var installerExt = GetInstallerExtension ()!;
+ var info = BuildVersionInfo (majorVersion, installerExt);
+
+ // Fetch checksum before download for supply-chain integrity
+ var checksum = await FetchChecksumAsync (info.ChecksumUrl, $"JDK {majorVersion} installer", cancellationToken).ConfigureAwait (false);
+ info.Checksum = checksum;
+
+ var tempInstallerPath = Path.Combine (Path.GetTempPath (), $"microsoft-jdk-{majorVersion}-{Guid.NewGuid ()}{installerExt}");
+
+ try {
+ // Download installer
+ logger (TraceLevel.Info, $"Downloading installer from {info.DownloadUrl}");
+ progress?.Report (new JdkInstallProgress (JdkInstallPhase.Downloading, 0, $"Downloading Microsoft OpenJDK {majorVersion} installer..."));
+ await DownloadUtils.DownloadFileAsync (httpClient, info.DownloadUrl, tempInstallerPath, info.Size,
+ progress is null ? null : new Progress<(double pct, string msg)> (p => progress.Report (new JdkInstallProgress (JdkInstallPhase.Downloading, p.pct, p.msg))),
+ cancellationToken).ConfigureAwait (false);
+
+ // Verify checksum
+ if (!string.IsNullOrEmpty (info.Checksum)) {
+ progress?.Report (new JdkInstallProgress (JdkInstallPhase.Verifying, 0, "Verifying SHA-256 checksum..."));
+ DownloadUtils.VerifyChecksum (tempInstallerPath, info.Checksum!);
+ progress?.Report (new JdkInstallProgress (JdkInstallPhase.Verifying, 100, "Checksum verified."));
+ }
+
+ // Run the installer silently
+ progress?.Report (new JdkInstallProgress (JdkInstallPhase.Extracting, 0, "Running platform installer..."));
+ await RunPlatformInstallerAsync (tempInstallerPath, cancellationToken).ConfigureAwait (false);
+
+ logger (TraceLevel.Info, $"Microsoft OpenJDK {majorVersion} installed successfully via platform installer.");
+ progress?.Report (new JdkInstallProgress (JdkInstallPhase.Complete, 100, $"Microsoft OpenJDK {majorVersion} installed successfully."));
+ }
+ finally {
+ TryDeleteFile (tempInstallerPath);
+ }
+ }
+
+ async Task RunPlatformInstallerAsync (string installerPath, CancellationToken cancellationToken)
+ {
+ ProcessStartInfo psi;
+ if (OS.IsWindows) {
+ psi = new ProcessStartInfo {
+ FileName = "msiexec",
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+#if NET5_0_OR_GREATER
+ psi.ArgumentList.Add ("/i");
+ psi.ArgumentList.Add (installerPath);
+ psi.ArgumentList.Add ("/quiet");
+ psi.ArgumentList.Add ("/norestart");
+#else
+ psi.Arguments = $"/i \"{installerPath}\" /quiet /norestart";
+#endif
+ }
+ else {
+ psi = new ProcessStartInfo {
+ FileName = "/usr/sbin/installer",
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+#if NET5_0_OR_GREATER
+ psi.ArgumentList.Add ("-pkg");
+ psi.ArgumentList.Add (installerPath);
+ psi.ArgumentList.Add ("-target");
+ psi.ArgumentList.Add ("/");
+#else
+ psi.Arguments = $"-pkg \"{installerPath}\" -target /";
+#endif
+ }
+
+ var stdout = new StringWriter ();
+ var stderr = new StringWriter ();
+ var exitCode = await ProcessUtils.StartProcess (psi, stdout: stdout, stderr: stderr, cancellationToken).ConfigureAwait (false);
+
+ // On Windows, msiexec can return non-zero exit codes that still indicate success,
+ // such as 3010 (success, reboot required) or 1641 (success, restart initiated).
+ // Treat these as success while logging that a reboot is required.
+ var rebootRequired = OS.IsWindows && (exitCode == 3010 || exitCode == 1641);
+
+ if (exitCode != 0 && !rebootRequired) {
+ var errorOutput = stderr.ToString ();
+ logger (TraceLevel.Error, $"Installer failed (exit code {exitCode}): {errorOutput}");
+ throw new InvalidOperationException ($"Platform installer failed with exit code {exitCode}: {errorOutput}");
+ }
+
+ if (rebootRequired) {
+ logger (TraceLevel.Warning, $"Installer completed successfully but a reboot is required (exit code {exitCode}).");
+ }
+ }
+
+ /// Checks if the target path is writable and not in a restricted location.
+ public bool IsTargetPathWritable (string targetPath)
+ {
+ if (string.IsNullOrEmpty (targetPath))
+ return false;
+
+ // Normalize the path to prevent path traversal bypasses (e.g., "C:\Program Files\..\Users")
+ string normalizedPath;
+ try {
+ normalizedPath = Path.GetFullPath (targetPath);
+ }
+ catch {
+ normalizedPath = targetPath;
+ }
+
+ if (OS.IsWindows) {
+ var programFiles = Environment.GetFolderPath (Environment.SpecialFolder.ProgramFiles);
+ var programFilesX86 = Environment.GetFolderPath (Environment.SpecialFolder.ProgramFilesX86);
+ if (IsUnderDirectory (normalizedPath, programFiles) || IsUnderDirectory (normalizedPath, programFilesX86)) {
+ logger (TraceLevel.Warning, $"Target path '{targetPath}' is in Program Files which typically requires elevation.");
+ return false;
+ }
+ }
+
+ // Test writability on the nearest existing ancestor directory without creating the target
+ try {
+ var testDir = normalizedPath;
+ while (!string.IsNullOrEmpty (testDir) && !Directory.Exists (testDir))
+ testDir = Path.GetDirectoryName (testDir);
+
+ if (string.IsNullOrEmpty (testDir))
+ return false;
+
+ var testFile = Path.Combine (testDir, $".write-test-{Guid.NewGuid ()}");
+ using (File.Create (testFile, 1, FileOptions.DeleteOnClose)) { }
+ return true;
+ }
+ catch (Exception ex) {
+ logger (TraceLevel.Warning, $"Target path '{targetPath}' is not writable: {ex.Message}");
+ return false;
+ }
+ }
+
+ /// Removes a JDK installation at the specified path.
+ public bool Remove (string jdkPath)
+ {
+ if (string.IsNullOrEmpty (jdkPath) || !Directory.Exists (jdkPath))
+ return false;
+
+ try {
+ Directory.Delete (jdkPath, recursive: true);
+ logger (TraceLevel.Info, $"Removed JDK at '{jdkPath}'.");
+ return true;
+ }
+ catch (Exception ex) {
+ logger (TraceLevel.Error, $"Failed to remove JDK at '{jdkPath}': {ex.Message}");
+ logger (TraceLevel.Verbose, ex.ToString ());
+ return false;
+ }
+ }
+
+ static JdkVersionInfo BuildVersionInfo (int majorVersion, string? extensionOverride = null)
+ {
+ var os = GetMicrosoftOpenJDKOSName ();
+ var arch = GetArchitectureName ();
+ var ext = extensionOverride ?? GetArchiveExtension ();
+
+ var filename = $"microsoft-jdk-{majorVersion}-{os}-{arch}{ext}";
+ var downloadUrl = $"{DownloadUrlBase}/{filename}";
+ var checksumUrl = $"{downloadUrl}.sha256sum.txt";
+ var displayName = extensionOverride is not null
+ ? $"Microsoft OpenJDK {majorVersion} ({ext})"
+ : $"Microsoft OpenJDK {majorVersion}";
+
+ return new JdkVersionInfo (
+ majorVersion: majorVersion,
+ displayName: displayName,
+ downloadUrl: downloadUrl,
+ checksumUrl: checksumUrl
+ );
+ }
+
+ async Task ExtractArchiveAsync (string archivePath, string targetPath, CancellationToken cancellationToken)
+ {
+ var targetParent = Path.GetDirectoryName (targetPath);
+ if (string.IsNullOrEmpty (targetParent))
+ targetParent = Path.GetTempPath ();
+ else
+ Directory.CreateDirectory (targetParent);
+ var tempExtractPath = Path.Combine (targetParent, $".jdk-extract-{Guid.NewGuid ()}");
+
+ try {
+ Directory.CreateDirectory (tempExtractPath);
+
+ if (OS.IsWindows)
+ DownloadUtils.ExtractZipSafe (archivePath, tempExtractPath, cancellationToken);
+ else
+ await DownloadUtils.ExtractTarGzAsync (archivePath, tempExtractPath, logger, cancellationToken).ConfigureAwait (false);
+
+ // Find the actual JDK root (archives contain a single top-level directory)
+ var extractedDirs = Directory.GetDirectories (tempExtractPath);
+ var jdkRoot = extractedDirs.Length == 1 ? extractedDirs [0] : tempExtractPath;
+
+ // On macOS, the JDK is inside Contents/Home
+ if (OS.IsMac) {
+ var contentsHome = Path.Combine (jdkRoot, "Contents", "Home");
+ if (Directory.Exists (contentsHome))
+ jdkRoot = contentsHome;
+ }
+
+ MoveWithRollback (jdkRoot, targetPath);
+ }
+ finally {
+ TryDeleteDirectory (tempExtractPath, "temp extract directory");
+ }
+ }
+
+ void MoveWithRollback (string sourcePath, string targetPath)
+ {
+ string? backupPath = null;
+ if (Directory.Exists (targetPath)) {
+ backupPath = targetPath + $".old-{Guid.NewGuid ():N}";
+ Directory.Move (targetPath, backupPath);
+ }
+
+ var parentDir = Path.GetDirectoryName (targetPath);
+ if (!string.IsNullOrEmpty (parentDir))
+ Directory.CreateDirectory (parentDir);
+
+ try {
+ Directory.Move (sourcePath, targetPath);
+
+ // Only delete backup after successful move
+ if (backupPath is not null)
+ TryDeleteDirectory (backupPath, "old JDK backup");
+ }
+ catch (Exception ex) {
+ logger (TraceLevel.Error, $"Failed to move to '{targetPath}': {ex.Message}");
+ if (backupPath is not null && Directory.Exists (backupPath)) {
+ try {
+ if (Directory.Exists (targetPath))
+ Directory.Delete (targetPath, recursive: true);
+ Directory.Move (backupPath, targetPath);
+ logger (TraceLevel.Warning, $"Restored previous JDK from backup '{backupPath}'.");
+ }
+ catch (Exception restoreEx) {
+ logger (TraceLevel.Error, $"Failed to restore from backup: {restoreEx.Message}");
+ }
+ }
+ throw;
+ }
+ }
+
+ async Task FetchChecksumAsync (string checksumUrl, string label, CancellationToken cancellationToken)
+ {
+ try {
+ using var response = await httpClient.GetAsync (checksumUrl, cancellationToken).ConfigureAwait (false);
+ response.EnsureSuccessStatusCode ();
+#if NET5_0_OR_GREATER
+ var content = await response.Content.ReadAsStringAsync (cancellationToken).ConfigureAwait (false);
+#else
+ var content = await response.Content.ReadAsStringAsync ().ConfigureAwait (false);
+#endif
+ var checksum = DownloadUtils.ParseChecksumFile (content);
+ logger (TraceLevel.Verbose, $"{label}: checksum={checksum}");
+ return checksum;
+ }
+ catch (Exception ex) {
+ logger (TraceLevel.Warning, $"Could not fetch checksum for {label}: {ex.Message}");
+ return null;
+ }
+ }
+
+ void TryDeleteFile (string path)
+ {
+ if (!File.Exists (path))
+ return;
+ try { File.Delete (path); }
+ catch (Exception ex) { logger (TraceLevel.Warning, $"Could not delete '{path}': {ex.Message}"); }
+ }
+
+ void TryDeleteDirectory (string path, string label)
+ {
+ if (!Directory.Exists (path))
+ return;
+ try { Directory.Delete (path, recursive: true); }
+ catch (Exception ex) { logger (TraceLevel.Warning, $"Could not clean up {label} at '{path}': {ex.Message}"); }
+ }
+
+ static string GetMicrosoftOpenJDKOSName ()
+ {
+ if (OS.IsMac) return "macOS";
+ if (OS.IsWindows) return "windows";
+ if (OS.IsLinux) return "linux";
+ throw new PlatformNotSupportedException ("Unsupported platform");
+ }
+
+ static string GetArchitectureName ()
+ {
+ return RuntimeInformation.OSArchitecture switch {
+ Architecture.X64 => "x64",
+ Architecture.Arm64 => "aarch64",
+ _ => throw new PlatformNotSupportedException ($"Unsupported architecture: {RuntimeInformation.OSArchitecture}"),
+ };
+ }
+
+ static string GetArchiveExtension ()
+ {
+ return OS.IsWindows ? ".zip" : ".tar.gz";
+ }
+
+ // Returns .msi (Windows), .pkg (macOS), or null (Linux)
+ static string? GetInstallerExtension ()
+ {
+ if (OS.IsWindows) return ".msi";
+ if (OS.IsMac) return ".pkg";
+ return null;
+ }
+
+ /// Checks if running as Administrator (Windows) or root (macOS/Linux).
+ public static bool IsElevated ()
+ {
+ if (OS.IsWindows) {
+#if NET5_0_OR_GREATER
+#pragma warning disable CA1416 // Guarded by OS.IsWindows runtime check above
+ return IsElevatedWindows ();
+#pragma warning restore CA1416
+#else
+ return false;
+#endif
+ }
+ // Unix: geteuid() == 0 means root
+ return IsElevatedUnix ();
+ }
+
+#if NET5_0_OR_GREATER
+ [System.Runtime.Versioning.SupportedOSPlatform ("windows")]
+ static bool IsElevatedWindows ()
+ {
+ using var identity = WindowsIdentity.GetCurrent ();
+ var principal = new WindowsPrincipal (identity);
+ return principal.IsInRole (WindowsBuiltInRole.Administrator);
+ }
+#endif
+
+ // Separate method so geteuid P/Invoke is never JIT-compiled on Windows
+ static bool IsElevatedUnix ()
+ {
+#if NET5_0_OR_GREATER
+ if (!OperatingSystem.IsWindows ())
+ return geteuid () == 0;
+ return false;
+#else
+ return geteuid () == 0;
+#endif
+ }
+
+ static bool IsUnderDirectory (string path, string directory)
+ {
+ if (string.IsNullOrEmpty (directory) || string.IsNullOrEmpty (path))
+ return false;
+ if (path.Equals (directory, StringComparison.OrdinalIgnoreCase))
+ return true;
+ return path.StartsWith (directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [DllImport ("libc", SetLastError = true)]
+ static extern uint geteuid ();
+ }
+}
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkInstallPhase.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkInstallPhase.cs
new file mode 100644
index 00000000..fff2af61
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkInstallPhase.cs
@@ -0,0 +1,17 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Xamarin.Android.Tools
+{
+ ///
+ /// Phases of JDK installation.
+ ///
+ public enum JdkInstallPhase
+ {
+ Downloading,
+ Verifying,
+ Extracting,
+ Validating,
+ Complete
+ }
+}
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkInstallProgress.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkInstallProgress.cs
new file mode 100644
index 00000000..16a3e016
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkInstallProgress.cs
@@ -0,0 +1,10 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Xamarin.Android.Tools
+{
+ ///
+ /// Progress information for JDK installation.
+ ///
+ public record JdkInstallProgress (JdkInstallPhase Phase, double PercentComplete, string? Message = null);
+}
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkVersionInfo.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkVersionInfo.cs
new file mode 100644
index 00000000..0003cc7e
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/Jdk/JdkVersionInfo.cs
@@ -0,0 +1,44 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Xamarin.Android.Tools
+{
+ ///
+ /// Represents information about an available JDK version from Microsoft OpenJDK.
+ ///
+ public class JdkVersionInfo
+ {
+ /// Major version number (e.g. 17, 21).
+ public int MajorVersion { get; }
+
+ /// Display name for the version (e.g. "Microsoft OpenJDK 17").
+ public string DisplayName { get; }
+
+ /// Download URL for the current platform.
+ public string DownloadUrl { get; }
+
+ /// URL for the SHA-256 checksum file.
+ public string ChecksumUrl { get; }
+
+ /// Expected file size in bytes, or 0 if unknown.
+ public long Size { get; internal set; }
+
+ /// SHA-256 checksum for download verification, if fetched.
+ public string? Checksum { get; internal set; }
+
+ /// The actual download URL after following redirects (reveals the specific version).
+ public string? ResolvedUrl { get; internal set; }
+
+ public JdkVersionInfo (int majorVersion, string displayName, string downloadUrl, string checksumUrl, long size = 0, string? checksum = null)
+ {
+ MajorVersion = majorVersion;
+ DisplayName = displayName;
+ DownloadUrl = downloadUrl;
+ ChecksumUrl = checksumUrl;
+ Size = size;
+ Checksum = checksum;
+ }
+
+ public override string ToString () => DisplayName;
+ }
+}
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Xamarin.Android.Tools.AndroidSdk.csproj b/src/Xamarin.Android.Tools.AndroidSdk/Xamarin.Android.Tools.AndroidSdk.csproj
index 4fb13f81..5944520a 100644
--- a/src/Xamarin.Android.Tools.AndroidSdk/Xamarin.Android.Tools.AndroidSdk.csproj
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Xamarin.Android.Tools.AndroidSdk.csproj
@@ -27,8 +27,9 @@
true
-
-
+
+
+
diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/JdkInstallerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/JdkInstallerTests.cs
new file mode 100644
index 00000000..b312074e
--- /dev/null
+++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/JdkInstallerTests.cs
@@ -0,0 +1,263 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using NUnit.Framework;
+
+namespace Xamarin.Android.Tools.Tests
+{
+ [TestFixture]
+ public class JdkInstallerTests
+ {
+ JdkInstaller installer;
+
+ [SetUp]
+ public void SetUp ()
+ {
+ installer = new JdkInstaller (logger: (level, message) => {
+ TestContext.WriteLine ($"[{level}] {message}");
+ });
+ }
+
+ [TearDown]
+ public void TearDown ()
+ {
+ installer?.Dispose ();
+ installer = null!;
+ }
+
+ [Test]
+ public void IsValid_NullPath_ReturnsFalse ()
+ {
+ Assert.IsFalse (installer.IsValid (null!));
+ }
+
+ [Test]
+ public void IsValid_EmptyPath_ReturnsFalse ()
+ {
+ Assert.IsFalse (installer.IsValid (""));
+ }
+
+ [Test]
+ public void IsValid_NonExistentPath_ReturnsFalse ()
+ {
+ Assert.IsFalse (installer.IsValid (Path.Combine (Path.GetTempPath (), Guid.NewGuid ().ToString ())));
+ }
+
+ [Test]
+ public void IsValid_EmptyDirectory_ReturnsFalse ()
+ {
+ var dir = Path.Combine (Path.GetTempPath (), Guid.NewGuid ().ToString ());
+ Directory.CreateDirectory (dir);
+ try {
+ Assert.IsFalse (installer.IsValid (dir));
+ }
+ finally {
+ Directory.Delete (dir, recursive: true);
+ }
+ }
+
+ [Test]
+ public void IsValid_FauxJdk_ReturnsTrue ()
+ {
+ var dir = Path.Combine (Path.GetTempPath (), $"jdk-test-{Guid.NewGuid ()}");
+ try {
+ JdkInfoTests.CreateFauxJdk (dir, releaseVersion: "17.0.1", releaseBuildNumber: "1", javaVersion: "17.0.1-1");
+ Assert.IsTrue (installer.IsValid (dir));
+ }
+ finally {
+ if (Directory.Exists (dir))
+ Directory.Delete (dir, recursive: true);
+ }
+ }
+
+ [Test]
+ public void IsValid_SystemJdk_ReturnsTrue ()
+ {
+ // Find first known JDK on the system
+ var jdk = JdkInfo.GetKnownSystemJdkInfos ().FirstOrDefault ();
+ if (jdk is null) {
+ Assert.Ignore ("No system JDK found to validate.");
+ return;
+ }
+ Assert.IsTrue (installer.IsValid (jdk.HomePath));
+ }
+
+ [Test]
+ public async Task DiscoverAsync_ReturnsVersions ()
+ {
+ IReadOnlyList versions;
+ try {
+ versions = await installer.DiscoverAsync ();
+ }
+ catch (Exception ex) when (ex is System.Net.Http.HttpRequestException || ex is TaskCanceledException) {
+ Assert.Ignore ($"Network unavailable: {ex.Message}");
+ return;
+ }
+
+ // We should get at least one version (if network is available)
+ Assert.IsNotNull (versions);
+ if (versions.Count == 0) {
+ Assert.Ignore ("No versions returned (network may be restricted).");
+ return;
+ }
+
+ // Verify structure of returned info
+ foreach (var v in versions) {
+ Assert.Greater (v.MajorVersion, 0, "MajorVersion should be positive");
+ Assert.IsNotEmpty (v.DisplayName, "DisplayName should not be empty");
+ Assert.IsNotEmpty (v.DownloadUrl, "DownloadUrl should not be empty");
+ Assert.That (v.DownloadUrl, Does.Contain ("aka.ms/download-jdk"), "DownloadUrl should use Microsoft OpenJDK");
+ }
+ }
+
+ [Test]
+ public async Task DiscoverAsync_ContainsExpectedMajorVersions ()
+ {
+ IReadOnlyList versions;
+ try {
+ versions = await installer.DiscoverAsync ();
+ }
+ catch (Exception ex) when (ex is System.Net.Http.HttpRequestException || ex is TaskCanceledException) {
+ Assert.Ignore ($"Network unavailable: {ex.Message}");
+ return;
+ }
+
+ if (versions.Count == 0) {
+ Assert.Ignore ("No versions returned.");
+ return;
+ }
+
+ var majorVersions = versions.Select (v => v.MajorVersion).Distinct ().ToList ();
+ Assert.That (majorVersions, Does.Contain (21), "Should contain JDK 21");
+ }
+
+ [Test]
+ public async Task DiscoverAsync_CancellationToken_Cancels ()
+ {
+ using var cts = new CancellationTokenSource ();
+ cts.Cancel ();
+
+ Assert.ThrowsAsync (
+ async () => await installer.DiscoverAsync (cts.Token));
+ }
+
+ [Test]
+ public void InstallAsync_InvalidVersion_Throws ()
+ {
+ Assert.ThrowsAsync (
+ async () => await installer.InstallAsync (8, Path.GetTempPath ()));
+ }
+
+ [Test]
+ public void InstallAsync_NullPath_Throws ()
+ {
+ Assert.ThrowsAsync (
+ async () => await installer.InstallAsync (21, null!));
+ }
+
+ [Test]
+ public async Task InstallAsync_ReportsProgress ()
+ {
+ // This test actually downloads and installs a JDK, so it may be slow.
+ // Skip if running in CI or if network is unavailable.
+ if (Environment.GetEnvironmentVariable ("CI") is not null ||
+ Environment.GetEnvironmentVariable ("TF_BUILD") is not null) {
+ Assert.Ignore ("Skipping download test in CI environment.");
+ return;
+ }
+
+ var targetPath = Path.Combine (Path.GetTempPath (), $"jdk-install-test-{Guid.NewGuid ()}");
+ var reportedPhases = new List ();
+ var progress = new Progress (p => {
+ reportedPhases.Add (p.Phase);
+ });
+
+ try {
+ using var cts = new CancellationTokenSource (TimeSpan.FromMinutes (10));
+ await installer.InstallAsync (21, targetPath, progress, cts.Token);
+
+ // Verify installation
+ Assert.IsTrue (installer.IsValid (targetPath), "Installed JDK should be valid");
+ Assert.IsTrue (reportedPhases.Contains (JdkInstallPhase.Downloading), "Should report Downloading phase");
+ Assert.IsTrue (reportedPhases.Contains (JdkInstallPhase.Extracting), "Should report Extracting phase");
+ Assert.IsTrue (reportedPhases.Contains (JdkInstallPhase.Complete), "Should report Complete phase");
+
+ // Verify we can create a JdkInfo from it
+ var jdkInfo = new JdkInfo (targetPath);
+ Assert.IsNotNull (jdkInfo.Version);
+ Assert.AreEqual (21, jdkInfo.Version!.Major);
+ }
+ catch (Exception ex) when (ex is System.Net.Http.HttpRequestException || ex is TaskCanceledException) {
+ Assert.Ignore ($"Network unavailable: {ex.Message}");
+ }
+ finally {
+ if (Directory.Exists (targetPath))
+ Directory.Delete (targetPath, recursive: true);
+ }
+ }
+
+ [Test]
+ public void Constructor_DefaultLogger_DoesNotThrow ()
+ {
+ using var defaultInstaller = new JdkInstaller ();
+ Assert.IsNotNull (defaultInstaller);
+ }
+
+ [Test]
+ public void RecommendedMajorVersion_Is21 ()
+ {
+ Assert.AreEqual (21, JdkInstaller.RecommendedMajorVersion);
+ }
+
+ [Test]
+ public void SupportedVersions_ContainsExpected ()
+ {
+ Assert.That (JdkInstaller.SupportedVersions, Does.Contain (21));
+ }
+
+ [Test]
+ public void IsTargetPathWritable_TempDir_ReturnsTrue ()
+ {
+ var dir = Path.Combine (Path.GetTempPath (), $"jdk-write-test-{Guid.NewGuid ()}");
+ try {
+ Assert.IsTrue (installer.IsTargetPathWritable (dir));
+ }
+ finally {
+ if (Directory.Exists (dir))
+ Directory.Delete (dir, recursive: true);
+ }
+ }
+
+ [Test]
+ public void IsTargetPathWritable_NullOrEmpty_ReturnsFalse ()
+ {
+ Assert.IsFalse (installer.IsTargetPathWritable (null!));
+ Assert.IsFalse (installer.IsTargetPathWritable (""));
+ }
+
+ [Test]
+ public void Remove_NonExistentPath_ReturnsFalse ()
+ {
+ Assert.IsFalse (installer.Remove (Path.Combine (Path.GetTempPath (), Guid.NewGuid ().ToString ())));
+ }
+
+ [Test]
+ public void Remove_ExistingDirectory_RemovesIt ()
+ {
+ var dir = Path.Combine (Path.GetTempPath (), $"jdk-remove-test-{Guid.NewGuid ()}");
+ Directory.CreateDirectory (dir);
+ File.WriteAllText (Path.Combine (dir, "test.txt"), "test");
+
+ Assert.IsTrue (installer.Remove (dir));
+ Assert.IsFalse (Directory.Exists (dir));
+ }
+ }
+}