diff --git a/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs b/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs new file mode 100644 index 00000000..cca1c0b0 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.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 +{ + /// + /// Constants for environment variable names used by Android SDK tooling. + /// + public static class EnvironmentVariableNames + { + /// + /// The ANDROID_HOME environment variable specifying the Android SDK root directory. + /// This is the preferred variable for modern Android tooling. + /// + public const string AndroidHome = "ANDROID_HOME"; + + /// + /// The ANDROID_SDK_ROOT environment variable specifying the Android SDK root directory. + /// This is an older variable name, but still supported by many tools. + /// + public const string AndroidSdkRoot = "ANDROID_SDK_ROOT"; + + /// + /// The JAVA_HOME environment variable specifying the JDK installation directory. + /// + public const string JavaHome = "JAVA_HOME"; + + /// + /// The JI_JAVA_HOME environment variable for internal/override JDK path. + /// Takes precedence over JAVA_HOME when set. + /// + public const string JiJavaHome = "JI_JAVA_HOME"; + + /// + /// The PATH environment variable for executable search paths. + /// + public const string Path = "PATH"; + + /// + /// The PATHEXT environment variable for executable file extensions (Windows). + /// + public const string PathExt = "PATHEXT"; + } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/JdkInfo.cs b/src/Xamarin.Android.Tools.AndroidSdk/JdkInfo.cs index 4969874b..0da0880a 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/JdkInfo.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/JdkInfo.cs @@ -328,7 +328,7 @@ public static IEnumerable GetKnownSystemJdkInfos (Action GetKnownSystemJdkInfos (Action + /// Phases of the SDK bootstrap operation. + /// + public enum SdkBootstrapPhase + { + /// Reading the manifest feed. + ReadingManifest, + /// Downloading the command-line tools archive. + Downloading, + /// Verifying the downloaded archive checksum. + Verifying, + /// Extracting the archive. + Extracting, + /// Bootstrap completed successfully. + Complete + } +} + diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkBootstrapProgress.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkBootstrapProgress.cs new file mode 100644 index 00000000..1ee732cb --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkBootstrapProgress.cs @@ -0,0 +1,21 @@ +// 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 SDK bootstrap operations. + /// + public class SdkBootstrapProgress + { + /// Current phase of the bootstrap operation. + public SdkBootstrapPhase Phase { get; set; } + + /// Download progress percentage (0-100), or -1 if unknown. + public int PercentComplete { get; set; } = -1; + + /// Human-readable status message. + public string Message { get; set; } = ""; + } +} + diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkLicense.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkLicense.cs new file mode 100644 index 00000000..1f3752af --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkLicense.cs @@ -0,0 +1,21 @@ +// 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 an Android SDK license that may need to be accepted. + /// + public class SdkLicense + { + /// + /// Gets or sets the license identifier (e.g., "android-sdk-license", "android-sdk-preview-license"). + /// + public string Id { get; set; } = ""; + + /// + /// Gets or sets the full license text that should be presented to the user. + /// + public string Text { get; set; } = ""; + } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkManifestComponent.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkManifestComponent.cs new file mode 100644 index 00000000..2be65bf5 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkManifestComponent.cs @@ -0,0 +1,43 @@ +// 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 +{ + /// + /// Information about a component available in the SDK manifest feed. + /// Named SdkManifestComponent to avoid confusion with AndroidManifest.xml. + /// + public class SdkManifestComponent + { + /// Element name in the manifest (e.g. "cmdline-tools", "platform-tools"). + public string ElementName { get; set; } = ""; + + /// Component version/revision. + public string Revision { get; set; } = ""; + + /// SDK-style path (e.g. "cmdline-tools;19.0"). + public string? Path { get; set; } + + /// Filesystem destination path (e.g. "cmdline-tools/latest"). + public string? FilesystemPath { get; set; } + + /// Human-readable description. + public string? Description { get; set; } + + /// Download URL for the current platform. + public string? DownloadUrl { get; set; } + + /// Expected file size in bytes. + public long Size { get; set; } + + /// Checksum value (typically SHA-1). + public string? Checksum { get; set; } + + /// Checksum algorithm (e.g. "sha1"). + public string? ChecksumType { get; set; } + + /// Whether this component is marked obsolete. + public bool IsObsolete { get; set; } + } +} + diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkManifestSource.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkManifestSource.cs new file mode 100644 index 00000000..96fcc65b --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkManifestSource.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 +{ + /// + /// Specifies the manifest feed source for SDK component discovery. + /// + public enum SdkManifestSource + { + /// Use Xamarin/Microsoft manifest feed (default). + Xamarin, + /// Use Google's official Android SDK repository manifest. + Google + } +} + diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkPackage.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkPackage.cs new file mode 100644 index 00000000..934488bf --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkPackage.cs @@ -0,0 +1,24 @@ +// 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 +{ + /// + /// Information about an SDK package as reported by the sdkmanager CLI. + /// + public class SdkPackage + { + /// Package path (e.g. "platform-tools", "platforms;android-35"). + public string Path { get; set; } = ""; + + /// Installed or available version. + public string? Version { get; set; } + + /// Human-readable description. + public string? Description { get; set; } + + /// Whether this package is currently installed. + public bool IsInstalled { get; set; } + } +} + diff --git a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs index 00074a72..8095a3b3 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs @@ -13,7 +13,7 @@ public static class ProcessUtils static ProcessUtils () { - var pathExt = Environment.GetEnvironmentVariable ("PATHEXT"); + var pathExt = Environment.GetEnvironmentVariable (EnvironmentVariableNames.PathExt); var pathExts = pathExt?.Split (new char [] { Path.PathSeparator }, StringSplitOptions.RemoveEmptyEntries) ?? new string [0]; ExecutableFileExtensions = pathExts; @@ -161,7 +161,7 @@ internal static void Exec (ProcessStartInfo processStartInfo, DataReceivedEventH internal static IEnumerable FindExecutablesInPath (string executable) { - var path = Environment.GetEnvironmentVariable ("PATH") ?? ""; + var path = Environment.GetEnvironmentVariable (EnvironmentVariableNames.Path) ?? ""; var pathDirs = path.Split (new char[] { Path.PathSeparator }, StringSplitOptions.RemoveEmptyEntries); foreach (var dir in pathDirs) { diff --git a/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.cs b/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.cs new file mode 100644 index 00000000..91b15153 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.cs @@ -0,0 +1,1042 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +#if NET5_0_OR_GREATER +using System.Buffers; +#endif +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; + +namespace Xamarin.Android.Tools +{ + /// + /// Provides Android SDK bootstrap and management capabilities using the sdkmanager CLI. + /// + /// + /// + /// Downloads the Android command-line tools from the Xamarin Android manifest feed, + /// extracts them to cmdline-tools/<version>/, then uses the included sdkmanager + /// to install, uninstall, list, and update SDK packages. + /// + /// + /// The manifest feed URL defaults to https://aka.ms/AndroidManifestFeed/d18-0 + /// but can be configured via the property. + /// + /// + public class SdkManager : IDisposable + { + /// Default manifest feed URL (Xamarin/Microsoft). + public const string DefaultManifestFeedUrl = "https://aka.ms/AndroidManifestFeed/d18-0"; + + /// Google's official Android SDK repository manifest URL. + public const string GoogleManifestFeedUrl = "https://dl.google.com/android/repository/repository2-3.xml"; + + /// Buffer size for download operations (80 KB). + const int DownloadBufferSize = 81920; + + readonly HttpClient httpClient = new HttpClient (); + readonly Action logger; + bool disposed; + + /// + /// Gets or sets the manifest feed URL used to discover command-line tools. + /// Defaults to . + /// + public string ManifestFeedUrl { get; set; } = DefaultManifestFeedUrl; + + /// + /// Gets or sets the manifest source. Changing this property updates . + /// + public SdkManifestSource ManifestSource + { + get => ManifestFeedUrl == GoogleManifestFeedUrl ? SdkManifestSource.Google : SdkManifestSource.Xamarin; + set => ManifestFeedUrl = value == SdkManifestSource.Google ? GoogleManifestFeedUrl : DefaultManifestFeedUrl; + } + + /// + /// Gets or sets the Android SDK root path. Used to locate and invoke sdkmanager. + /// + public string? AndroidSdkPath { get; set; } + + /// + /// Gets or sets the Java SDK (JDK) home path. Set as JAVA_HOME when invoking sdkmanager. + /// + public string? JavaSdkPath { get; set; } + + /// + /// Creates a new instance. + /// + /// Optional logger callback. Defaults to . + public SdkManager (Action? logger = null) + { + this.logger = logger ?? AndroidSdkInfo.DefaultConsoleLogger; + } + + /// + /// Disposes the and its owned . + /// + public void Dispose () + { + if (disposed) + return; + disposed = true; + httpClient.Dispose (); + } + + void ThrowIfDisposed () + { + if (disposed) + throw new ObjectDisposedException (nameof (SdkManager)); + } + + // --- Manifest Parsing --- + + /// + /// Downloads and parses the Android manifest feed to discover available components. + /// + /// Cancellation token. + /// A list of manifest components available for the current platform. + public async Task> GetManifestComponentsAsync (CancellationToken cancellationToken = default) + { + ThrowIfDisposed (); + logger (TraceLevel.Info, $"Downloading manifest from {ManifestFeedUrl}..."); + // netstandard2.0 GetStringAsync has no CancellationToken overload; use GetAsync instead + using var response = await httpClient.GetAsync (ManifestFeedUrl, cancellationToken).ConfigureAwait (false); + response.EnsureSuccessStatusCode (); + var xml = await response.Content.ReadAsStringAsync ().ConfigureAwait (false); + return ParseManifest (xml); + } + + /// + /// Parses the Android manifest XML and returns components for the current platform. + /// Uses XmlReader for better performance than XDocument/XElement. + /// + internal IReadOnlyList ParseManifest (string xml) + { + var hostOs = GetManifestHostOs (); + var hostArch = GetManifestHostArch (); + var components = new List (); + + using var stringReader = new StringReader (xml); + using var reader = XmlReader.Create (stringReader, new XmlReaderSettings { IgnoreWhitespace = true }); + + while (reader.Read ()) { + if (reader.NodeType != XmlNodeType.Element) + continue; + + // Skip root element + if (reader.Depth == 0) + continue; + + var elementName = reader.LocalName; + var revision = reader.GetAttribute ("revision"); + if (string.IsNullOrEmpty (revision)) + continue; + + var component = new SdkManifestComponent { + ElementName = elementName, + Revision = revision!, + Path = reader.GetAttribute ("path"), + FilesystemPath = reader.GetAttribute ("filesystem-path"), + Description = reader.GetAttribute ("description"), + IsObsolete = string.Equals (reader.GetAttribute ("obsolete"), "True", StringComparison.OrdinalIgnoreCase), + }; + + // Read child elements to find matching URL + if (!reader.IsEmptyElement) { + var componentDepth = reader.Depth; + while (reader.Read () && reader.Depth > componentDepth) { + if (reader.NodeType == XmlNodeType.Element && reader.LocalName == "urls") { + var urlsDepth = reader.Depth; + while (reader.Read () && reader.Depth > urlsDepth) { + if (reader.NodeType == XmlNodeType.Element && reader.LocalName == "url") { + var urlHostOs = reader.GetAttribute ("host-os"); + var urlHostArch = reader.GetAttribute ("host-arch"); + + if (!MatchesPlatform (urlHostOs, hostOs)) + continue; + + if (!string.IsNullOrEmpty (urlHostArch) && !string.Equals (urlHostArch, hostArch, StringComparison.OrdinalIgnoreCase)) + continue; + + component.ChecksumType = reader.GetAttribute ("checksum-type"); + component.Checksum = reader.GetAttribute ("checksum"); + + var sizeStr = reader.GetAttribute ("size"); + if (long.TryParse (sizeStr, out var size)) + component.Size = size; + + // Read the URL text content + component.DownloadUrl = reader.ReadElementContentAsString ()?.Trim (); + break; + } + } + } + } + } + + if (!string.IsNullOrEmpty (component.DownloadUrl)) + components.Add (component); + } + + logger (TraceLevel.Verbose, $"Parsed {components.Count} components from manifest."); + return components.AsReadOnly (); + } + + static bool MatchesPlatform (string? urlHostOs, string hostOs) + { + if (string.IsNullOrEmpty (urlHostOs)) + return true; // No filter means any platform + return string.Equals (urlHostOs, hostOs, StringComparison.OrdinalIgnoreCase); + } + + static string GetManifestHostOs () + { + if (OS.IsWindows) return "windows"; + if (OS.IsMac) return "macosx"; + if (OS.IsLinux) return "linux"; + throw new PlatformNotSupportedException ($"Unsupported operating system for Android SDK manifest."); + } + + static string GetManifestHostArch () + { + var arch = RuntimeInformation.OSArchitecture; + switch (arch) { + case Architecture.Arm64: + return "aarch64"; + case Architecture.X64: + return "x64"; + case Architecture.X86: + return "x86"; + default: + throw new PlatformNotSupportedException ($"Unsupported architecture '{arch}' for Android SDK manifest."); + } + } + +/// + /// Downloads command-line tools from the manifest feed and extracts them to + /// <targetPath>/cmdline-tools/<version>/. + /// + /// The Android SDK root directory to bootstrap. + /// Optional progress reporter. + /// Cancellation token. + public async Task BootstrapAsync (string targetPath, IProgress? progress = null, CancellationToken cancellationToken = default) + { + ThrowIfDisposed (); + if (string.IsNullOrEmpty (targetPath)) + throw new ArgumentNullException (nameof (targetPath)); + + // Step 1: Read manifest + progress?.Report (new SdkBootstrapProgress { Phase = SdkBootstrapPhase.ReadingManifest, Message = "Reading manifest feed..." }); + logger (TraceLevel.Info, $"Reading manifest from {ManifestFeedUrl}..."); + + var components = await GetManifestComponentsAsync (cancellationToken).ConfigureAwait (false); + var cmdlineTools = components + .Where (c => string.Equals (c.ElementName, "cmdline-tools", StringComparison.OrdinalIgnoreCase) && !c.IsObsolete) + .OrderByDescending (c => { + if (Version.TryParse (c.Revision, out var v)) + return v; + return new Version (0, 0); + }) + .FirstOrDefault (); + + if (cmdlineTools is null || string.IsNullOrEmpty (cmdlineTools.DownloadUrl)) { + throw new InvalidOperationException ("Could not find command-line tools in the Android manifest feed."); + } + + logger (TraceLevel.Info, $"Found cmdline-tools {cmdlineTools.Revision}: {cmdlineTools.DownloadUrl}"); + + // Step 2: Download + var tempArchivePath = Path.Combine (Path.GetTempPath (), $"cmdline-tools-{Guid.NewGuid ()}.zip"); + try { + progress?.Report (new SdkBootstrapProgress { Phase = SdkBootstrapPhase.Downloading, Message = $"Downloading cmdline-tools {cmdlineTools.Revision}..." }); + await DownloadFileAsync (cmdlineTools.DownloadUrl!, tempArchivePath, cmdlineTools.Size, progress, cancellationToken).ConfigureAwait (false); + + // Step 3: Verify checksum + if (!string.IsNullOrEmpty (cmdlineTools.Checksum)) { + progress?.Report (new SdkBootstrapProgress { Phase = SdkBootstrapPhase.Verifying, Message = "Verifying checksum..." }); + var checksumValid = VerifyChecksum (tempArchivePath, cmdlineTools.Checksum!, cmdlineTools.ChecksumType); + if (!checksumValid) { + throw new InvalidOperationException ($"Checksum verification failed for cmdline-tools archive. Expected: {cmdlineTools.Checksum}"); + } + logger (TraceLevel.Info, "Checksum verification passed."); + } + else { + logger (TraceLevel.Warning, "No checksum available for cmdline-tools; skipping verification."); + } + + // Step 4: Extract to cmdline-tools// (use version number, not "latest" symlink) + progress?.Report (new SdkBootstrapProgress { Phase = SdkBootstrapPhase.Extracting, Message = "Extracting cmdline-tools..." }); + var cmdlineToolsDir = Path.Combine (targetPath, "cmdline-tools"); + var versionDir = Path.Combine (cmdlineToolsDir, cmdlineTools.Revision); + + Directory.CreateDirectory (cmdlineToolsDir); + + // Extract to temp dir first + var tempExtractDir = Path.Combine (Path.GetTempPath (), $"cmdline-tools-extract-{Guid.NewGuid ()}"); + try { + Directory.CreateDirectory (tempExtractDir); + + // Safe extraction to prevent zip slip (path traversal) attacks + var fullExtractRoot = Path.GetFullPath (tempExtractDir); + using (var archive = ZipFile.OpenRead (tempArchivePath)) { + foreach (var entry in archive.Entries) { + if (string.IsNullOrEmpty (entry.FullName)) + continue; + + var destinationPath = Path.GetFullPath ( + Path.Combine (fullExtractRoot, entry.FullName)); + + if (!destinationPath.StartsWith (fullExtractRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) && + !string.Equals (destinationPath, fullExtractRoot, StringComparison.OrdinalIgnoreCase)) { + throw new InvalidOperationException ($"Archive entry '{entry.FullName}' would extract outside target directory."); + } + + if (entry.FullName.EndsWith ("/", StringComparison.Ordinal) || entry.FullName.EndsWith ("\\", StringComparison.Ordinal)) { + Directory.CreateDirectory (destinationPath); + continue; + } + + var destDir = Path.GetDirectoryName (destinationPath); + if (!string.IsNullOrEmpty (destDir)) + Directory.CreateDirectory (destDir); + + entry.ExtractToFile (destinationPath, overwrite: true); + } + } + + // The zip contains a top-level "cmdline-tools" directory + var extractedDir = Path.Combine (tempExtractDir, "cmdline-tools"); + if (!Directory.Exists (extractedDir)) { + // Try to find the single top-level directory + var dirs = Directory.GetDirectories (tempExtractDir); + if (dirs.Length == 1) + extractedDir = dirs[0]; + else + extractedDir = tempExtractDir; + } + + // Move to latest, with rollback on failure and cross-device fallback + string? backupPath = null; + if (Directory.Exists (versionDir)) { + backupPath = versionDir + $".old-{Guid.NewGuid ():N}"; + Directory.Move (versionDir, backupPath); + } + + try { + try { + Directory.Move (extractedDir, versionDir); + } + catch (IOException) { + // Cross-device fallback: copy recursively then delete source + CopyDirectoryRecursive (extractedDir, versionDir); + Directory.Delete (extractedDir, recursive: true); + } + logger (TraceLevel.Info, $"Extracted cmdline-tools to '{versionDir}'."); + } + catch (Exception ex) { + logger (TraceLevel.Error, $"Failed to install cmdline-tools to '{versionDir}': {ex.Message}"); + // Attempt to restore previous installation from backup + if (!string.IsNullOrEmpty (backupPath) && Directory.Exists (backupPath)) { + try { + if (Directory.Exists (versionDir)) + Directory.Delete (versionDir, recursive: true); + Directory.Move (backupPath, versionDir); + logger (TraceLevel.Warning, "Restored previous cmdline-tools from backup."); + } + catch (Exception restoreEx) { + logger (TraceLevel.Error, $"Failed to restore backup: {restoreEx.Message}"); + } + } + throw; + } + finally { + if (!string.IsNullOrEmpty (backupPath) && Directory.Exists (backupPath)) { + try { Directory.Delete (backupPath, recursive: true); } + catch (Exception ex) { + logger (TraceLevel.Warning, $"Could not clean up old cmdline-tools at '{backupPath}': {ex.Message}"); + } + } + } + } + finally { + if (Directory.Exists (tempExtractDir)) { + try { Directory.Delete (tempExtractDir, recursive: true); } + catch (Exception ex) { logger (TraceLevel.Verbose, $"Failed to clean up temp extract dir: {ex.Message}"); } + } + } + + // Set executable permissions on Unix + if (!OS.IsWindows) { + SetExecutablePermissions (versionDir, logger); + } + + // Update AndroidSdkPath for subsequent sdkmanager calls + AndroidSdkPath = targetPath; + + progress?.Report (new SdkBootstrapProgress { Phase = SdkBootstrapPhase.Complete, PercentComplete = 100, Message = "Bootstrap complete." }); + logger (TraceLevel.Info, "Android SDK bootstrap complete."); + } + finally { + if (File.Exists (tempArchivePath)) { + try { File.Delete (tempArchivePath); } + catch (Exception ex) { logger (TraceLevel.Verbose, $"Failed to clean up temp archive: {ex.Message}"); } + } + } + } + + /// + /// Resolves the path to the sdkmanager executable within the Android SDK. + /// + /// The full path to sdkmanager, or null if not found. + public string? FindSdkManagerPath () + { + if (string.IsNullOrEmpty (AndroidSdkPath)) + return null; + + var ext = OS.IsWindows ? ".bat" : string.Empty; + var cmdlineToolsDir = Path.Combine (AndroidSdkPath, "cmdline-tools"); + + if (Directory.Exists (cmdlineToolsDir)) { + // Search versioned directories first (sorted descending), then "latest" for backward compatibility + var searchDirs = new List (); + + try { + var versionedDirs = Directory.GetDirectories (cmdlineToolsDir) + .Select (d => Path.GetFileName (d)) + .Where (n => n != "latest" && !string.IsNullOrEmpty (n)) + .OrderByDescending (n => { + if (Version.TryParse (n, out var v)) + return v; + return new Version (0, 0); + }) + .ToList (); + searchDirs.AddRange (versionedDirs); + } + catch (Exception ex) { + logger (TraceLevel.Verbose, $"Error enumerating cmdline-tools directories: {ex.Message}"); + } + + // Add "latest" at the end for backward compatibility with existing installations + searchDirs.Add ("latest"); + + foreach (var dir in searchDirs) { + var toolPath = Path.Combine (cmdlineToolsDir, dir, "bin", "sdkmanager" + ext); + if (File.Exists (toolPath)) + return toolPath; + } + } + + // Legacy fallback: tools/bin/sdkmanager + var legacyPath = Path.Combine (AndroidSdkPath, "tools", "bin", "sdkmanager" + ext); + if (File.Exists (legacyPath)) + return legacyPath; + + return null; + } + + /// + /// Lists installed and available SDK packages using sdkmanager --list. + /// + /// Cancellation token. + /// A tuple of (installed packages, available packages). + public async Task<(IReadOnlyList Installed, IReadOnlyList Available)> ListAsync (CancellationToken cancellationToken = default) + { + var sdkManagerPath = FindSdkManagerPath (); + if (sdkManagerPath is null) + throw new InvalidOperationException ("sdkmanager not found. Run BootstrapAsync first to install command-line tools."); + + logger (TraceLevel.Info, "Running sdkmanager --list..."); + var (exitCode, stdout, stderr) = await RunSdkManagerAsync (sdkManagerPath, "--list", cancellationToken: cancellationToken).ConfigureAwait (false); + + if (exitCode != 0) { + logger (TraceLevel.Error, $"sdkmanager --list failed (exit code {exitCode}): {stderr}"); + throw new InvalidOperationException ($"sdkmanager --list failed: {stderr}"); + } + + return ParseSdkManagerList (stdout); + } + + /// + /// Installs SDK packages using sdkmanager. + /// + /// Package paths to install (e.g. "platform-tools", "platforms;android-35"). + /// If true, automatically accepts licenses during installation. + /// Cancellation token. + public async Task InstallAsync (IEnumerable packages, bool acceptLicenses = true, CancellationToken cancellationToken = default) + { + if (packages is null || !packages.Any ()) + throw new ArgumentException ("At least one package must be specified.", nameof (packages)); + + var sdkManagerPath = FindSdkManagerPath (); + if (sdkManagerPath is null) + throw new InvalidOperationException ("sdkmanager not found. Run BootstrapAsync first."); + + var packageList = string.Join (" ", packages.Select (p => $"\"{p}\"")); + logger (TraceLevel.Info, $"Installing packages: {packageList}"); + + var (exitCode, stdout, stderr) = await RunSdkManagerAsync ( + sdkManagerPath, packageList, acceptLicenses, cancellationToken).ConfigureAwait (false); + + if (exitCode != 0) { + logger (TraceLevel.Error, $"Package installation failed (exit code {exitCode}): {stderr}"); + throw new InvalidOperationException ($"Failed to install packages: {stderr}"); + } + + logger (TraceLevel.Info, "Packages installed successfully."); + } + + /// + /// Uninstalls SDK packages using sdkmanager --uninstall. + /// + /// Package paths to uninstall. + /// Cancellation token. + public async Task UninstallAsync (IEnumerable packages, CancellationToken cancellationToken = default) + { + if (packages is null || !packages.Any ()) + throw new ArgumentException ("At least one package must be specified.", nameof (packages)); + + var sdkManagerPath = FindSdkManagerPath (); + if (sdkManagerPath is null) + throw new InvalidOperationException ("sdkmanager not found. Run BootstrapAsync first."); + + var packageList = string.Join (" ", packages.Select (p => $"\"{p}\"")); + logger (TraceLevel.Info, $"Uninstalling packages: {packageList}"); + + var (exitCode, stdout, stderr) = await RunSdkManagerAsync ( + sdkManagerPath, $"--uninstall {packageList}", cancellationToken: cancellationToken).ConfigureAwait (false); + + if (exitCode != 0) { + logger (TraceLevel.Error, $"Package uninstall failed (exit code {exitCode}): {stderr}"); + throw new InvalidOperationException ($"Failed to uninstall packages: {stderr}"); + } + + logger (TraceLevel.Info, "Packages uninstalled successfully."); + } + + /// + /// Updates all installed SDK packages using sdkmanager --update. + /// + /// Cancellation token. + public async Task UpdateAsync (CancellationToken cancellationToken = default) + { + var sdkManagerPath = FindSdkManagerPath (); + if (sdkManagerPath is null) + throw new InvalidOperationException ("sdkmanager not found. Run BootstrapAsync first."); + + logger (TraceLevel.Info, "Updating all installed packages..."); + var (exitCode, stdout, stderr) = await RunSdkManagerAsync ( + sdkManagerPath, "--update", acceptLicenses: true, cancellationToken: cancellationToken).ConfigureAwait (false); + + if (exitCode != 0) { + logger (TraceLevel.Error, $"Package update failed (exit code {exitCode}): {stderr}"); + throw new InvalidOperationException ($"Failed to update packages: {stderr}"); + } + + logger (TraceLevel.Info, "All packages updated successfully."); + } + + /// + /// Accepts all SDK licenses using sdkmanager --licenses. + /// + /// Cancellation token. + public async Task AcceptLicensesAsync (CancellationToken cancellationToken = default) + { + var sdkManagerPath = FindSdkManagerPath (); + if (sdkManagerPath is null) + throw new InvalidOperationException ("sdkmanager not found. Run BootstrapAsync first."); + + logger (TraceLevel.Info, "Accepting SDK licenses..."); + var (exitCode, stdout, stderr) = await RunSdkManagerAsync ( + sdkManagerPath, "--licenses", acceptLicenses: true, cancellationToken: cancellationToken).ConfigureAwait (false); + + // License acceptance may return non-zero when licenses are already accepted. + if (exitCode != 0) { + var output = (stdout ?? string.Empty) + Environment.NewLine + (stderr ?? string.Empty); + var outputLower = output.ToLowerInvariant (); + + // Tolerate known benign non-zero cases where licenses are already accepted. + if (outputLower.Contains ("licenses have already been accepted") || + outputLower.Contains ("all sdk package licenses accepted")) { + logger (TraceLevel.Info, $"License acceptance already completed (exit code {exitCode})."); + return; + } + + logger (TraceLevel.Error, $"License acceptance failed (exit code {exitCode}): {stderr}"); + throw new InvalidOperationException ($"Failed to accept SDK licenses: {stderr}"); + } + + logger (TraceLevel.Info, "License acceptance complete."); + } + + /// + /// Gets pending licenses that need to be accepted, along with their full text. + /// This allows IDEs and CLI tools to present licenses to the user before accepting. + /// + /// Cancellation token. + /// A list of pending licenses with their ID and full text content. + public async Task> GetPendingLicensesAsync (CancellationToken cancellationToken = default) + { + var sdkManagerPath = FindSdkManagerPath (); + if (sdkManagerPath is null) + throw new InvalidOperationException ("sdkmanager not found. Run BootstrapAsync first."); + + logger (TraceLevel.Verbose, "Checking for pending licenses..."); + + // Run --licenses without auto-accept to get the license text + var psi = new ProcessStartInfo { + FileName = sdkManagerPath, + Arguments = "--licenses", + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + }; + + ConfigureEnvironment (psi); + + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + + // Send 'n' to decline all licenses so we just get the text + Action onStarted = process => { + Task.Run (async () => { + try { + await Task.Delay (500, cancellationToken).ConfigureAwait (false); + while (!process.HasExited && !cancellationToken.IsCancellationRequested) { + process.StandardInput.WriteLine ("n"); + await Task.Delay (200, cancellationToken).ConfigureAwait (false); + } + } + catch (Exception ex) { + // Process may have exited - expected behavior when process completes + logger (TraceLevel.Verbose, $"License check loop ended: {ex.GetType ().Name}"); + } + }, cancellationToken); + }; + + try { + await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, onStarted).ConfigureAwait (false); + } + catch (OperationCanceledException) { + throw; + } + catch { + // sdkmanager may exit with non-zero when declining licenses - that's expected + } + + return ParseLicenseOutput (stdout.ToString ()); + } + + /// + /// Accepts specific licenses by ID. + /// + /// The license IDs to accept (e.g., "android-sdk-license"). + /// Cancellation token. + public async Task AcceptLicensesAsync (IEnumerable licenseIds, CancellationToken cancellationToken = default) + { + if (licenseIds is null || !licenseIds.Any ()) + return; + + var sdkManagerPath = FindSdkManagerPath (); + if (sdkManagerPath is null) + throw new InvalidOperationException ("sdkmanager not found. Run BootstrapAsync first."); + + // Accept licenses by writing the hash to the licenses directory + var licensesDir = Path.Combine (AndroidSdkPath!, "licenses"); + Directory.CreateDirectory (licensesDir); + + // Get pending licenses to find their hashes + var pendingLicenses = await GetPendingLicensesAsync (cancellationToken).ConfigureAwait (false); + var licenseIdSet = new HashSet (licenseIds, StringComparer.OrdinalIgnoreCase); + + foreach (var license in pendingLicenses) { + if (licenseIdSet.Contains (license.Id)) { + var licensePath = Path.Combine (licensesDir, license.Id); + // Compute hash of license text and write it + var hash = ComputeLicenseHash (license.Text); + File.WriteAllText (licensePath, $"\n{hash}"); + logger (TraceLevel.Info, $"Accepted license: {license.Id}"); + } + } + } + + /// + /// Parses the output of sdkmanager --licenses to extract license information. + /// + internal static IReadOnlyList ParseLicenseOutput (string output) + { + var licenses = new List (); + var lines = output.Split (new[] { '\n' }, StringSplitOptions.None); + + string? currentLicenseId = null; + var currentLicenseText = new StringBuilder (); + bool inLicenseText = false; + + foreach (var rawLine in lines) { + var line = rawLine.TrimEnd ('\r'); + + // License header: "License android-sdk-license:" + if (line.StartsWith ("License ", StringComparison.OrdinalIgnoreCase) && line.TrimEnd ().EndsWith (":")) { + // Save previous license if any + if (currentLicenseId is not null && currentLicenseText.Length > 0) { + licenses.Add (new SdkLicense { + Id = currentLicenseId, + Text = currentLicenseText.ToString ().Trim () + }); + } + + var trimmedLine = line.TrimEnd (); + currentLicenseId = trimmedLine.Substring (8, trimmedLine.Length - 9).Trim (); + currentLicenseText.Clear (); + inLicenseText = true; + continue; + } + + // End of license text when we see the accept prompt + if (line.Contains ("Accept?") || line.Contains ("(y/N)")) { + if (currentLicenseId is not null && currentLicenseText.Length > 0) { + licenses.Add (new SdkLicense { + Id = currentLicenseId, + Text = currentLicenseText.ToString ().Trim () + }); + } + currentLicenseId = null; + currentLicenseText.Clear (); + inLicenseText = false; + continue; + } + + // Accumulate license text + if (inLicenseText && currentLicenseId is not null) { + // Skip separator lines + if (!line.TrimStart ().StartsWith ("-------", StringComparison.Ordinal)) { + currentLicenseText.AppendLine (line); + } + } + } + + // Add last license if not yet added + if (currentLicenseId is not null && currentLicenseText.Length > 0) { + licenses.Add (new SdkLicense { + Id = currentLicenseId, + Text = currentLicenseText.ToString ().Trim () + }); + } + + return licenses.AsReadOnly (); + } + + static string ComputeLicenseHash (string licenseText) + { + // Android SDK uses SHA-1 hash of the license text + using var sha1 = SHA1.Create (); + var bytes = Encoding.UTF8.GetBytes (licenseText.Replace ("\r\n", "\n").Trim ()); + var hash = sha1.ComputeHash (bytes); + return BitConverter.ToString (hash).Replace ("-", "").ToLowerInvariant (); + } + + /// + /// Checks whether SDK licenses have been accepted by checking the licenses directory. + /// + /// true if at least one license file exists; otherwise false. + public bool AreLicensesAccepted () + { + if (string.IsNullOrEmpty (AndroidSdkPath)) + return false; + + var licensesPath = Path.Combine (AndroidSdkPath, "licenses"); + if (!Directory.Exists (licensesPath)) + return false; + + return Directory.GetFiles (licensesPath).Length > 0; + } + + /// + /// Parses sdkmanager --list output into installed and available packages. + /// + internal static (IReadOnlyList Installed, IReadOnlyList Available) ParseSdkManagerList (string output) + { + var installed = new List (); + var available = new List (); + string? currentSection = null; + + var lines = output.Split (new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var line in lines) { + var trimmed = line.Trim (); + + if (trimmed.IndexOf ("Installed packages:", StringComparison.Ordinal) >= 0) { + currentSection = "installed"; + continue; + } + if (trimmed.IndexOf ("Available Packages:", StringComparison.Ordinal) >= 0) { + currentSection = "available"; + continue; + } + if (trimmed.IndexOf ("Available Updates:", StringComparison.Ordinal) >= 0) { + currentSection = null; + continue; + } + + if (currentSection is null || string.IsNullOrWhiteSpace (trimmed)) + continue; + + // Skip header and separator lines + if (trimmed.StartsWith ("Path", StringComparison.Ordinal) || trimmed.StartsWith ("---", StringComparison.Ordinal)) + continue; + + var parts = trimmed.Split (new[] { '|' }); + if (parts.Length < 2) + continue; + + var pkg = new SdkPackage { + Path = parts[0].Trim (), + Version = parts.Length > 1 ? parts[1].Trim () : null, + Description = parts.Length > 2 ? parts[2].Trim () : null, + IsInstalled = currentSection == "installed" + }; + + if (string.IsNullOrEmpty (pkg.Path)) + continue; + + if (currentSection == "installed") + installed.Add (pkg); + else + available.Add (pkg); + } + + return (installed.AsReadOnly (), available.AsReadOnly ()); + } + + async Task<(int ExitCode, string Stdout, string Stderr)> RunSdkManagerAsync ( + string sdkManagerPath, string arguments, bool acceptLicenses = false, CancellationToken cancellationToken = default) + { + var psi = new ProcessStartInfo { + FileName = sdkManagerPath, + Arguments = arguments, + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = acceptLicenses, + }; + + ConfigureEnvironment (psi); + + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + + Action? onStarted = null; + if (acceptLicenses) { + onStarted = process => { + // Feed "y\n" continuously for license prompts + Task.Run (async () => { + try { + while (!process.HasExited && !cancellationToken.IsCancellationRequested) { + process.StandardInput.WriteLine ("y"); + await Task.Delay (500, cancellationToken).ConfigureAwait (false); + } + } + catch (Exception ex) { + // Process may have exited or cancellation requested - expected behavior + logger (TraceLevel.Verbose, $"Auto-accept loop ended: {ex.GetType ().Name}"); + } + }, cancellationToken); + }; + } + + logger (TraceLevel.Verbose, $"Running: {sdkManagerPath} {arguments}"); + int exitCode; + try { + exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, onStarted).ConfigureAwait (false); + } + catch (OperationCanceledException) { + throw; + } + catch (Exception ex) { + logger (TraceLevel.Error, $"Failed to run sdkmanager: {ex.Message}"); + logger (TraceLevel.Verbose, ex.ToString ()); + throw; + } + + var stdoutStr = stdout.ToString (); + var stderrStr = stderr.ToString (); + + if (exitCode != 0) { + logger (TraceLevel.Warning, $"sdkmanager exited with code {exitCode}"); + logger (TraceLevel.Verbose, $"stdout: {stdoutStr}"); + logger (TraceLevel.Verbose, $"stderr: {stderrStr}"); + } + + return (exitCode, stdoutStr, stderrStr); + } + + async Task DownloadFileAsync (string url, string destinationPath, long expectedSize, IProgress? progress, CancellationToken cancellationToken) + { + logger (TraceLevel.Info, $"Downloading {url}..."); + + using var response = await httpClient.GetAsync (url, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait (false); + response.EnsureSuccessStatusCode (); + + var totalSize = response.Content.Headers.ContentLength ?? expectedSize; + + // In netstandard2.0, ReadAsStreamAsync() has no CancellationToken overload. + // Register to dispose the response on cancellation so the stream read will abort. + cancellationToken.ThrowIfCancellationRequested (); + using var registration = cancellationToken.Register (() => response.Dispose ()); + using var stream = await response.Content.ReadAsStreamAsync ().ConfigureAwait (false); + using var fileStream = File.Create (destinationPath); + +#if NET5_0_OR_GREATER + // Use ArrayPool for buffer to reduce allocations (requires System.Buffers) + var buffer = ArrayPool.Shared.Rent (DownloadBufferSize); + try { +#else + var buffer = new byte[DownloadBufferSize]; + { +#endif + long totalRead = 0; + int bytesRead; + + while ((bytesRead = await stream.ReadAsync (buffer, 0, buffer.Length, cancellationToken).ConfigureAwait (false)) > 0) { + await fileStream.WriteAsync (buffer, 0, bytesRead, cancellationToken).ConfigureAwait (false); + totalRead += bytesRead; + + if (totalSize > 0 && progress is not null) { + var percent = (int) ((totalRead * 100) / totalSize); + progress.Report (new SdkBootstrapProgress { + Phase = SdkBootstrapPhase.Downloading, + PercentComplete = percent, + Message = $"Downloading... {percent}% ({totalRead / (1024 * 1024)}MB / {totalSize / (1024 * 1024)}MB)" + }); + } + } + + logger (TraceLevel.Info, $"Downloaded {totalRead} bytes to {destinationPath}."); + } +#if NET5_0_OR_GREATER + finally { + ArrayPool.Shared.Return (buffer); + } +#endif + } + + bool VerifyChecksum (string filePath, string expectedChecksum, string? checksumType) + { + // Validate checksumType - only SHA1 is currently supported + var type = checksumType ?? "sha1"; + if (!string.Equals (type, "sha1", StringComparison.OrdinalIgnoreCase)) { + throw new NotSupportedException ($"Unsupported checksum type: '{checksumType}'. Only 'sha1' is currently supported."); + } + + logger (TraceLevel.Verbose, $"Verifying {type} checksum for {filePath}..."); + + using var stream = File.OpenRead (filePath); + using var hasher = SHA1.Create (); + var hashBytes = hasher.ComputeHash (stream); + var actualChecksum = BitConverter.ToString (hashBytes).Replace ("-", ""); + + var match = string.Equals (actualChecksum, expectedChecksum, StringComparison.OrdinalIgnoreCase); + if (!match) { + logger (TraceLevel.Error, $"Checksum mismatch: expected={expectedChecksum}, actual={actualChecksum}"); + } + return match; + } + + static void SetExecutablePermissions (string directory, Action logger) + { + // Make sdkmanager and other binaries executable on Unix + var binDir = Path.Combine (directory, "bin"); + if (Directory.Exists (binDir)) { + foreach (var file in Directory.GetFiles (binDir)) { + // Use p/invoke chmod for efficiency (avoid spawning processes) + if (!Chmod (file, 0x1ED)) { // 0755 octal = 0x1ED + // Fallback to chmod process if p/invoke fails + try { + var psi = new ProcessStartInfo ("chmod", $"+x \"{file}\"") { + CreateNoWindow = true, + UseShellExecute = false, + }; + var process = Process.Start (psi); + process?.WaitForExit (); + if (process is null || process.ExitCode != 0) { + throw new InvalidOperationException ($"chmod failed for '{file}' with exit code {process?.ExitCode ?? -1}"); + } + } + catch (Exception ex) { + // Let the exception propagate - sdkmanager won't work without executable permissions + logger (TraceLevel.Error, $"Failed to set executable permission on '{file}': {ex.Message}"); + throw new InvalidOperationException ($"Failed to set executable permissions on '{file}'. The sdkmanager will not be usable.", ex); + } + } + } + } + } + + /// + /// Sets file permissions on Unix using libc chmod. + /// + /// File path. + /// Permission mode (e.g., 0x1ED for 0755 octal). + /// True if successful, false otherwise. + static bool Chmod (string path, int mode) + { + try { + return chmod (path, mode) == 0; + } + catch { + // p/invoke failed (e.g., not on Unix) - caller will use fallback + return false; + } + } + + [DllImport ("libc", SetLastError = true)] + static extern int chmod (string pathname, int mode); + + /// + /// Configures environment variables on a ProcessStartInfo for Android SDK tools. + /// + void ConfigureEnvironment (ProcessStartInfo psi) + { + if (!string.IsNullOrEmpty (AndroidSdkPath)) { + psi.EnvironmentVariables[EnvironmentVariableNames.AndroidHome] = AndroidSdkPath; + // Note: ANDROID_SDK_ROOT is deprecated per https://developer.android.com/tools/variables#envar + // Only set ANDROID_HOME + } + if (!string.IsNullOrEmpty (JavaSdkPath)) { + psi.EnvironmentVariables[EnvironmentVariableNames.JavaHome] = JavaSdkPath; + } + } + + static void CopyDirectoryRecursive (string sourceDir, string destinationDir) + { + if (!Directory.Exists (destinationDir)) + Directory.CreateDirectory (destinationDir); + + foreach (var file in Directory.GetFiles (sourceDir)) { + var destFile = Path.Combine (destinationDir, Path.GetFileName (file)); + File.Copy (file, destFile, overwrite: true); + } + + foreach (var subDir in Directory.GetDirectories (sourceDir)) { + var destSubDir = Path.Combine (destinationDir, Path.GetFileName (subDir)); + CopyDirectoryRecursive (subDir, destSubDir); + } + } + +} +} + diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs new file mode 100644 index 00000000..97b638a1 --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs @@ -0,0 +1,623 @@ +// 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 SdkManagerTests + { + SdkManager manager; + + [SetUp] + public void SetUp () + { + manager = new SdkManager (logger: (level, message) => { + TestContext.WriteLine ($"[{level}] {message}"); + }); + } + + [TearDown] + public void TearDown () + { + manager?.Dispose (); + manager = null; + } + + + [Test] + public void ParseManifest_CmdlineTools_ReturnsComponents () + { + var xml = @" + + + + https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip + https://dl.google.com/android/repository/commandlinetools-mac-13114758_latest.zip + https://dl.google.com/android/repository/commandlinetools-win-13114758_latest.zip + + + + + https://dl.google.com/android/repository/platform-tools_r36.0.0-linux.zip + https://dl.google.com/android/repository/platform-tools_r36.0.0-darwin.zip + https://dl.google.com/android/repository/platform-tools_r36.0.0-win.zip + + +"; + + var components = manager.ParseManifest (xml); + + Assert.IsNotNull (components); + Assert.IsTrue (components.Count >= 2, $"Expected at least 2 components, got {components.Count}"); + + var cmdline = components.FirstOrDefault (c => c.ElementName == "cmdline-tools"); + Assert.IsNotNull (cmdline, "Should find cmdline-tools component"); + Assert.AreEqual ("19.0", cmdline!.Revision); + Assert.IsNotEmpty (cmdline.DownloadUrl!); + Assert.IsNotEmpty (cmdline.Checksum!); + Assert.AreEqual ("sha1", cmdline.ChecksumType); + Assert.Greater (cmdline.Size, 0); + + var platformTools = components.FirstOrDefault (c => c.ElementName == "platform-tools"); + Assert.IsNotNull (platformTools, "Should find platform-tools component"); + Assert.AreEqual ("36.0.0", platformTools!.Revision); + } + + [Test] + public void ParseManifest_ObsoleteComponents_AreIncluded () + { + var xml = @" + + + + https://example.com/old.zip + https://example.com/old-mac.zip + https://example.com/old-win.zip + + +"; + + var components = manager.ParseManifest (xml); + var obsolete = components.FirstOrDefault (c => c.Revision == "9.0"); + Assert.IsNotNull (obsolete); + Assert.IsTrue (obsolete!.IsObsolete); + } + + [Test] + public void ParseManifest_EmptyXml_ReturnsEmpty () + { + var xml = @""; + var components = manager.ParseManifest (xml); + Assert.IsNotNull (components); + Assert.AreEqual (0, components.Count); + } + + [Test] + public void ParseManifest_MultipleVersions_AllReturned () + { + var xml = @" + + + + https://example.com/19.zip + https://example.com/19-mac.zip + https://example.com/19-win.zip + + + + + https://example.com/17.zip + https://example.com/17-mac.zip + https://example.com/17-win.zip + + +"; + + var components = manager.ParseManifest (xml); + var cmdlineTools = components.Where (c => c.ElementName == "cmdline-tools").ToList (); + Assert.AreEqual (2, cmdlineTools.Count, "Should find both cmdline-tools versions"); + } + + [Test] + public void ParseManifest_JdkElements_Parsed () + { + var xml = @" + + + + https://aka.ms/download-jdk/microsoft-jdk-21.0.9-windows-x64.zip + https://aka.ms/download-jdk/microsoft-jdk-21.0.9-macOS-x64.tar.gz + https://aka.ms/download-jdk/microsoft-jdk-21.0.9-macOS-aarch64.tar.gz + https://aka.ms/download-jdk/microsoft-jdk-21.0.9-linux-x64.tar.gz + + +"; + + var components = manager.ParseManifest (xml); + var jdk = components.FirstOrDefault (c => c.ElementName == "jdk"); + Assert.IsNotNull (jdk, "Should find jdk component"); + Assert.AreEqual ("21.0.9", jdk!.Revision); + Assert.IsNotEmpty (jdk.DownloadUrl!); + } + + + + + + [Test] + public void ParseSdkManagerList_ParsesInstalledAndAvailable () + { + var output = @"Installed packages: + Path | Version | Description | Location + ------- | ------- | ------- | ------- + build-tools;35.0.0 | 35.0.0 | Android SDK Build-Tools 35 | build-tools/35.0.0 + emulator | 35.3.10 | Android Emulator | emulator + platform-tools | 36.0.0 | Android SDK Platform-Tools | platform-tools + +Available Packages: + Path | Version | Description + ------- | ------- | ------- + build-tools;36.0.0 | 36.0.0 | Android SDK Build-Tools 36 + platforms;android-35 | 5 | Android SDK Platform 35 + system-images;android-35;google_apis;arm64-v8a | 14 | Google APIs ARM 64 v8a System Image + +Available Updates: + Path | Installed | Available + platform-tools | 35.0.2 | 36.0.0 +"; + + var (installed, available) = SdkManager.ParseSdkManagerList (output); + + Assert.AreEqual (3, installed.Count, "Should have 3 installed packages"); + Assert.AreEqual (3, available.Count, "Should have 3 available packages"); + + var platformTools = installed.FirstOrDefault (p => p.Path == "platform-tools"); + Assert.IsNotNull (platformTools); + Assert.AreEqual ("36.0.0", platformTools!.Version); + Assert.IsTrue (platformTools.IsInstalled); + + var buildTools36 = available.FirstOrDefault (p => p.Path == "build-tools;36.0.0"); + Assert.IsNotNull (buildTools36); + Assert.AreEqual ("36.0.0", buildTools36!.Version); + Assert.IsFalse (buildTools36.IsInstalled); + } + + [Test] + public void ParseSdkManagerList_EmptyOutput_ReturnsEmpty () + { + var (installed, available) = SdkManager.ParseSdkManagerList (""); + Assert.AreEqual (0, installed.Count); + Assert.AreEqual (0, available.Count); + } + + [Test] + public void ParseSdkManagerList_OnlyInstalledSection () + { + var output = @"Installed packages: + Path | Version | Description + ------- | ------- | ------- + platform-tools | 36.0.0 | Android SDK Platform-Tools +"; + + var (installed, available) = SdkManager.ParseSdkManagerList (output); + Assert.AreEqual (1, installed.Count); + Assert.AreEqual (0, available.Count); + Assert.AreEqual ("platform-tools", installed[0].Path); + } + + + + + + [Test] + public void FindSdkManagerPath_NullSdkPath_ReturnsNull () + { + manager.AndroidSdkPath = null; + Assert.IsNull (manager.FindSdkManagerPath ()); + } + + [Test] + public void FindSdkManagerPath_CmdlineToolsLatest_Found () + { + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-test-{Guid.NewGuid ()}"); + try { + var binDir = Path.Combine (sdkDir, "cmdline-tools", "latest", "bin"); + Directory.CreateDirectory (binDir); + + var sdkManagerName = OS.IsWindows ? "sdkmanager.bat" : "sdkmanager"; + File.WriteAllText (Path.Combine (binDir, sdkManagerName), "#!/bin/sh\necho test"); + + manager.AndroidSdkPath = sdkDir; + var result = manager.FindSdkManagerPath (); + + Assert.IsNotNull (result, "Should find sdkmanager in cmdline-tools/latest/bin"); + Assert.That (result, Does.Contain ("sdkmanager")); + } + finally { + if (Directory.Exists (sdkDir)) + Directory.Delete (sdkDir, recursive: true); + } + } + + [Test] + public void FindSdkManagerPath_VersionedDir_Found () + { + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-test-{Guid.NewGuid ()}"); + try { + var binDir = Path.Combine (sdkDir, "cmdline-tools", "12.0", "bin"); + Directory.CreateDirectory (binDir); + + var sdkManagerName = OS.IsWindows ? "sdkmanager.bat" : "sdkmanager"; + File.WriteAllText (Path.Combine (binDir, sdkManagerName), "#!/bin/sh\necho test"); + + manager.AndroidSdkPath = sdkDir; + var result = manager.FindSdkManagerPath (); + + Assert.IsNotNull (result, "Should find sdkmanager in versioned dir"); + } + finally { + if (Directory.Exists (sdkDir)) + Directory.Delete (sdkDir, recursive: true); + } + } + + [Test] + public void FindSdkManagerPath_LegacyToolsDir_Found () + { + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-test-{Guid.NewGuid ()}"); + try { + var binDir = Path.Combine (sdkDir, "tools", "bin"); + Directory.CreateDirectory (binDir); + + var sdkManagerName = OS.IsWindows ? "sdkmanager.bat" : "sdkmanager"; + File.WriteAllText (Path.Combine (binDir, sdkManagerName), "#!/bin/sh\necho test"); + + manager.AndroidSdkPath = sdkDir; + var result = manager.FindSdkManagerPath (); + + Assert.IsNotNull (result, "Should find sdkmanager in legacy tools/bin"); + } + finally { + if (Directory.Exists (sdkDir)) + Directory.Delete (sdkDir, recursive: true); + } + } + + [Test] + public void FindSdkManagerPath_NoSdkManager_ReturnsNull () + { + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-test-{Guid.NewGuid ()}"); + try { + Directory.CreateDirectory (sdkDir); + manager.AndroidSdkPath = sdkDir; + Assert.IsNull (manager.FindSdkManagerPath ()); + } + finally { + if (Directory.Exists (sdkDir)) + Directory.Delete (sdkDir, recursive: true); + } + } + + + + + + [Test] + public void DefaultManifestFeedUrl_IsSet () + { + Assert.AreEqual ("https://aka.ms/AndroidManifestFeed/d18-0", SdkManager.DefaultManifestFeedUrl); + Assert.AreEqual (SdkManager.DefaultManifestFeedUrl, manager.ManifestFeedUrl); + } + + [Test] + public void ManifestFeedUrl_IsConfigurable () + { + manager.ManifestFeedUrl = "https://example.com/manifest.xml"; + Assert.AreEqual ("https://example.com/manifest.xml", manager.ManifestFeedUrl); + } + + [Test] + public void Constructor_DefaultLogger_DoesNotThrow () + { + var defaultManager = new SdkManager (); + Assert.IsNotNull (defaultManager); + } + + // --- AreLicensesAccepted --- + + [Test] + public void AreLicensesAccepted_NullSdkPath_ReturnsFalse () + { + manager.AndroidSdkPath = null; + Assert.IsFalse (manager.AreLicensesAccepted ()); + } + + [Test] + public void AreLicensesAccepted_NoLicensesDir_ReturnsFalse () + { + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-test-{Guid.NewGuid ()}"); + try { + Directory.CreateDirectory (sdkDir); + manager.AndroidSdkPath = sdkDir; + Assert.IsFalse (manager.AreLicensesAccepted ()); + } + finally { + if (Directory.Exists (sdkDir)) + Directory.Delete (sdkDir, recursive: true); + } + } + + [Test] + public void AreLicensesAccepted_WithLicenseFiles_ReturnsTrue () + { + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-test-{Guid.NewGuid ()}"); + try { + var licensesDir = Path.Combine (sdkDir, "licenses"); + Directory.CreateDirectory (licensesDir); + File.WriteAllText (Path.Combine (licensesDir, "android-sdk-license"), "abc123"); + + manager.AndroidSdkPath = sdkDir; + Assert.IsTrue (manager.AreLicensesAccepted ()); + } + finally { + if (Directory.Exists (sdkDir)) + Directory.Delete (sdkDir, recursive: true); + } + } + + + + + + [Test] + public async Task GetManifestComponentsAsync_ReturnsComponents () + { + IReadOnlyList components; + try { + components = await manager.GetManifestComponentsAsync (); + } + catch (Exception ex) when (ex is System.Net.Http.HttpRequestException || ex is TaskCanceledException) { + Assert.Ignore ($"Network unavailable: {ex.Message}"); + return; + } + + Assert.IsNotNull (components); + if (components.Count == 0) { + Assert.Ignore ("No components returned."); + return; + } + + // Should find cmdline-tools + var cmdline = components.FirstOrDefault (c => c.ElementName == "cmdline-tools"); + Assert.IsNotNull (cmdline, "Manifest should contain cmdline-tools"); + Assert.IsNotEmpty (cmdline!.DownloadUrl!); + Assert.IsNotEmpty (cmdline.Checksum!); + + // Should find platform-tools + var platformTools = components.FirstOrDefault (c => c.ElementName == "platform-tools"); + Assert.IsNotNull (platformTools, "Manifest should contain platform-tools"); + } + + [Test] + public async Task BootstrapAsync_NullPath_Throws () + { + Assert.ThrowsAsync ( + async () => await manager.BootstrapAsync (null!)); + } + + [Test] + public void InstallAsync_NoSdkManager_Throws () + { + manager.AndroidSdkPath = Path.Combine (Path.GetTempPath (), "nonexistent"); + Assert.ThrowsAsync ( + async () => await manager.InstallAsync (new[] { "platform-tools" })); + } + + [Test] + public void InstallAsync_EmptyPackages_Throws () + { + Assert.ThrowsAsync ( + async () => await manager.InstallAsync (new string[0])); + } + + [Test] + public void InstallAsync_NullPackages_Throws () + { + Assert.ThrowsAsync ( + async () => await manager.InstallAsync (null!)); + } + + [Test] + public void UninstallAsync_NoSdkManager_Throws () + { + manager.AndroidSdkPath = Path.Combine (Path.GetTempPath (), "nonexistent"); + Assert.ThrowsAsync ( + async () => await manager.UninstallAsync (new[] { "platform-tools" })); + } + + [Test] + public void UninstallAsync_EmptyPackages_Throws () + { + Assert.ThrowsAsync ( + async () => await manager.UninstallAsync (new string[0])); + } + + [Test] + public void ListAsync_NoSdkManager_Throws () + { + manager.AndroidSdkPath = Path.Combine (Path.GetTempPath (), "nonexistent"); + Assert.ThrowsAsync ( + async () => await manager.ListAsync ()); + } + + [Test] + public void UpdateAsync_NoSdkManager_Throws () + { + manager.AndroidSdkPath = Path.Combine (Path.GetTempPath (), "nonexistent"); + Assert.ThrowsAsync ( + async () => await manager.UpdateAsync ()); + } + + [Test] + public void AcceptLicensesAsync_NoSdkManager_Throws () + { + manager.AndroidSdkPath = Path.Combine (Path.GetTempPath (), "nonexistent"); + Assert.ThrowsAsync ( + async () => await manager.AcceptLicensesAsync ()); + } + + [Test] + public async Task AcceptLicensesAsync_ExitCodeZero_Succeeds () + { + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-test-{Guid.NewGuid ()}"); + try { + CreateFakeSdkManager (sdkDir, exitCode: 0, stdout: "All SDK package licenses accepted."); + manager.AndroidSdkPath = sdkDir; + // Should complete without throwing + await manager.AcceptLicensesAsync (); + } + finally { + if (Directory.Exists (sdkDir)) + Directory.Delete (sdkDir, recursive: true); + } + } + + [Test] + public async Task AcceptLicensesAsync_NonZeroWithAllLicensesAcceptedInStdout_Succeeds () + { + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-test-{Guid.NewGuid ()}"); + try { + CreateFakeSdkManager (sdkDir, exitCode: 1, stdout: "All SDK package licenses accepted."); + manager.AndroidSdkPath = sdkDir; + // Known benign phrase: should not throw + await manager.AcceptLicensesAsync (); + } + finally { + if (Directory.Exists (sdkDir)) + Directory.Delete (sdkDir, recursive: true); + } + } + + [Test] + public async Task AcceptLicensesAsync_NonZeroWithLicensesAlreadyAcceptedInStdout_Succeeds () + { + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-test-{Guid.NewGuid ()}"); + try { + CreateFakeSdkManager (sdkDir, exitCode: 1, stdout: "Licenses have already been accepted."); + manager.AndroidSdkPath = sdkDir; + // Known benign phrase: should not throw + await manager.AcceptLicensesAsync (); + } + finally { + if (Directory.Exists (sdkDir)) + Directory.Delete (sdkDir, recursive: true); + } + } + + [Test] + public void AcceptLicensesAsync_NonZeroWithUnknownError_Throws () + { + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-test-{Guid.NewGuid ()}"); + try { + CreateFakeSdkManager (sdkDir, exitCode: 1, stdout: "Error: JAVA_HOME is not set."); + manager.AndroidSdkPath = sdkDir; + var ex = Assert.ThrowsAsync ( + async () => await manager.AcceptLicensesAsync ()); + Assert.That (ex!.Message, Does.Contain ("Failed to accept SDK licenses")); + } + finally { + if (Directory.Exists (sdkDir)) + Directory.Delete (sdkDir, recursive: true); + } + } + + static void CreateFakeSdkManager (string sdkDir, int exitCode, string stdout) + { + var binDir = Path.Combine (sdkDir, "cmdline-tools", "latest", "bin"); + Directory.CreateDirectory (binDir); + + if (OS.IsWindows) { + var script = Path.Combine (binDir, "sdkmanager.bat"); + File.WriteAllText (script, $"@echo off\r\necho {stdout}\r\nexit /b {exitCode}\r\n"); + } else { + var script = Path.Combine (binDir, "sdkmanager"); + File.WriteAllText (script, $"#!/bin/sh\necho \"{stdout}\"\nexit {exitCode}\n"); + var chmod = new ProcessStartInfo { + FileName = "chmod", + Arguments = $"+x \"{script}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + using var p = Process.Start (chmod)!; + p.WaitForExit (); + } + } + + // --- License Parsing --- + + [Test] + public void ParseLicenseOutput_SingleLicense_Parsed () + { + var output = @" +License android-sdk-license: +--------------------------------------- +Terms and Conditions + +This is the license text. + +--------------------------------------- +Accept? (y/N): "; + + var licenses = SdkManager.ParseLicenseOutput (output); + + Assert.AreEqual (1, licenses.Count, "Should parse one license"); + Assert.AreEqual ("android-sdk-license", licenses[0].Id); + Assert.That (licenses[0].Text, Does.Contain ("Terms and Conditions")); + Assert.That (licenses[0].Text, Does.Contain ("This is the license text")); + } + + [Test] + public void ParseLicenseOutput_MultipleLicenses_Parsed () + { + var output = @" +License android-sdk-license: +--------------------------------------- +SDK License Text +--------------------------------------- +Accept? (y/N): n +License android-sdk-preview-license: +--------------------------------------- +Preview License Text +--------------------------------------- +Accept? (y/N): "; + + var licenses = SdkManager.ParseLicenseOutput (output); + + Assert.AreEqual (2, licenses.Count, "Should parse two licenses"); + Assert.AreEqual ("android-sdk-license", licenses[0].Id); + Assert.AreEqual ("android-sdk-preview-license", licenses[1].Id); + } + + [Test] + public void ParseLicenseOutput_NoLicenses_ReturnsEmpty () + { + var output = "All SDK package licenses accepted."; + + var licenses = SdkManager.ParseLicenseOutput (output); + + Assert.AreEqual (0, licenses.Count, "Should return empty list"); + } + } +}