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");
+ }
+ }
+}