diff --git a/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/CommandLineToolsResolver.cs b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/CommandLineToolsResolver.cs new file mode 100644 index 00000000000..a66e9f3630b --- /dev/null +++ b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/CommandLineToolsResolver.cs @@ -0,0 +1,231 @@ +// 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; + +namespace Xamarin.Android.Tools; + +static class CommandLineToolsResolver +{ + const string PackageRevisionProperty = "Pkg.Revision"; + + public static CommandLineTool? Find ( + string sdkPath, + string toolName, + string extension, + Action? logger = null) + { + var cmdlineToolsDir = Path.Combine (sdkPath, "cmdline-tools"); + if (Directory.Exists (cmdlineToolsDir)) { + var candidates = FindCandidates (cmdlineToolsDir, toolName, extension, logger); + if (candidates.Count > 0) { + candidates.Sort (CompareCandidates); + var selected = candidates [0]; + logger?.Invoke ( + TraceLevel.Verbose, + $"Selected '{selected.Tool.Path}' from command-line tools revision '{selected.Tool.Revision ?? "unknown"}'."); + return selected.Tool; + } + } + + return null; + } + + internal static bool TryParseRevision (string? value, out ParsedRevision revision) + { + revision = default; + if (value is null) + return false; + + var text = value.Trim (); + if (text.Length == 0) + return false; + var suffixIndex = -1; + for (var i = 0; i < text.Length; i++) { + if (text [i] == '-' || char.IsWhiteSpace (text [i])) { + suffixIndex = i; + break; + } + } + + var versionText = suffixIndex >= 0 ? text.Substring (0, suffixIndex) : text; + var prerelease = suffixIndex >= 0 ? text.Substring (suffixIndex + 1).Trim () : null; + if (suffixIndex >= 0 && string.IsNullOrEmpty (prerelease)) + return false; + + if (!Version.TryParse (versionText, out var parsedVersion)) + return false; + + revision = new ParsedRevision (parsedVersion, prerelease); + return true; + } + + static List FindCandidates ( + string cmdlineToolsDir, + string toolName, + string extension, + Action? logger) + { + var candidates = new List (); + try { + foreach (var directory in Directory.GetDirectories (cmdlineToolsDir)) { + var directoryName = Path.GetFileName (directory); + if (string.IsNullOrEmpty (directoryName)) + continue; + + var toolPath = Path.Combine (directory, "bin", toolName + extension); + if (!File.Exists (toolPath)) + continue; + + string? revisionText = null; + var revision = default (ParsedRevision?); + var revisionFromSource = false; + + if (TryReadPackageRevision (directory, logger, out var packageRevision)) { + if (TryParseRevision (packageRevision, out var parsedRevision)) { + revisionText = packageRevision; + revision = parsedRevision; + revisionFromSource = true; + } else { + logger?.Invoke ( + TraceLevel.Warning, + $"Ignoring invalid {PackageRevisionProperty} in '{Path.Combine (directory, "source.properties")}': '{packageRevision}'."); + } + } + + if (revision is null && TryParseRevision (directoryName, out var directoryRevision)) { + revisionText = directoryName; + revision = directoryRevision; + } + + candidates.Add (new Candidate ( + directoryName, + new CommandLineTool (toolPath, revisionText), + revision, + revisionFromSource)); + } + } catch (IOException ex) { + logger?.Invoke (TraceLevel.Warning, $"Could not enumerate '{cmdlineToolsDir}': {ex.Message}"); + } catch (UnauthorizedAccessException ex) { + logger?.Invoke (TraceLevel.Warning, $"Could not enumerate '{cmdlineToolsDir}': {ex.Message}"); + } + + return candidates; + } + + static bool TryReadPackageRevision ( + string directory, + Action? logger, + out string? revision) + { + revision = null; + var sourceProperties = Path.Combine (directory, "source.properties"); + try { + return SourceProperties.TryGetProperty (sourceProperties, PackageRevisionProperty, out revision); + } catch (IOException ex) { + logger?.Invoke (TraceLevel.Warning, $"Could not read '{sourceProperties}': {ex.Message}"); + } catch (UnauthorizedAccessException ex) { + logger?.Invoke (TraceLevel.Warning, $"Could not read '{sourceProperties}': {ex.Message}"); + } + + return false; + } + + static int CompareCandidates (Candidate first, Candidate second) + { + if (first.Revision.HasValue != second.Revision.HasValue) + return first.Revision.HasValue ? -1 : 1; + + if (first.Revision is ParsedRevision firstRevision && second.Revision is ParsedRevision secondRevision) { + var revisionComparison = secondRevision.CompareTo (firstRevision); + if (revisionComparison != 0) + return revisionComparison; + } + + if (first.RevisionFromSource != second.RevisionFromSource) + return first.RevisionFromSource ? -1 : 1; + + var firstIsLatest = string.Equals (first.DirectoryName, "latest", StringComparison.Ordinal); + var secondIsLatest = string.Equals (second.DirectoryName, "latest", StringComparison.Ordinal); + if (firstIsLatest != secondIsLatest) + return firstIsLatest ? -1 : 1; + + return string.Compare (first.DirectoryName, second.DirectoryName, StringComparison.Ordinal); + } + + sealed class Candidate + { + public string DirectoryName { get; } + public CommandLineTool Tool { get; } + public ParsedRevision? Revision { get; } + public bool RevisionFromSource { get; } + + public Candidate ( + string directoryName, + CommandLineTool tool, + ParsedRevision? revision, + bool revisionFromSource) + { + DirectoryName = directoryName; + Tool = tool; + Revision = revision; + RevisionFromSource = revisionFromSource; + } + } + + internal readonly struct ParsedRevision : IComparable + { + public Version Version { get; } + public string? Prerelease { get; } + + public ParsedRevision (Version version, string? prerelease) + { + Version = version; + Prerelease = prerelease; + } + + public int CompareTo (ParsedRevision other) + { + var versionComparison = Version.CompareTo (other.Version); + if (versionComparison != 0) + return versionComparison; + + var isPrerelease = !string.IsNullOrEmpty (Prerelease); + var otherIsPrerelease = !string.IsNullOrEmpty (other.Prerelease); + if (isPrerelease != otherIsPrerelease) + return isPrerelease ? -1 : 1; + + return ComparePrerelease (Prerelease, other.Prerelease); + } + + static int ComparePrerelease (string? first, string? second) + { + if (first is null || second is null) + return string.Compare (first, second, StringComparison.OrdinalIgnoreCase); + + var firstNumberStart = FindTrailingNumberStart (first); + var secondNumberStart = FindTrailingNumberStart (second); + if (firstNumberStart < first.Length && secondNumberStart < second.Length) { + var firstLabel = first.Substring (0, firstNumberStart); + var secondLabel = second.Substring (0, secondNumberStart); + if (string.Equals (firstLabel, secondLabel, StringComparison.OrdinalIgnoreCase) && + long.TryParse (first.Substring (firstNumberStart), out var firstNumber) && + long.TryParse (second.Substring (secondNumberStart), out var secondNumber)) + return firstNumber.CompareTo (secondNumber); + } + + return string.Compare (first, second, StringComparison.OrdinalIgnoreCase); + } + + static int FindTrailingNumberStart (string value) + { + var index = value.Length; + while (index > 0 && char.IsDigit (value [index - 1])) + index--; + return index; + } + } +} diff --git a/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/CommandLineTool.cs b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/CommandLineTool.cs new file mode 100644 index 00000000000..9f1719731b9 --- /dev/null +++ b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/CommandLineTool.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Xamarin.Android.Tools; + +/// Information about an installed Android SDK command-line tool. +public sealed class CommandLineTool +{ + /// Gets the full path to the requested executable. + public string Path { get; } + + /// + /// Gets the installed command-line tools revision from source.properties, + /// or the versioned directory name when package metadata is unavailable. + /// + public string? Revision { get; } + + /// Creates information for a resolved Android SDK command-line tool. + /// The full path to the requested executable. + /// The installed command-line tools revision, when available. + /// is . + public CommandLineTool (string path, string? revision = null) + { + if (path is null) + throw new ArgumentNullException (nameof (path)); + + Path = path; + Revision = revision; + } +} diff --git a/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkBootstrapPhase.cs b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkBootstrapPhase.cs index d2a1f0195b0..e298d09b3fa 100644 --- a/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkBootstrapPhase.cs +++ b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkBootstrapPhase.cs @@ -17,5 +17,9 @@ public enum SdkBootstrapPhase /// Extracting the archive. Extracting, /// Bootstrap completed successfully. - Complete + Complete, + /// Checking Google's package catalog for a newer command-line tools revision. + CheckingForUpdates, + /// Installing the current command-line tools package. + Installing } diff --git a/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkBootstrapProgress.cs b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkBootstrapProgress.cs index 3fe59a4d4b5..b7ec53c57cd 100644 --- a/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkBootstrapProgress.cs +++ b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/Models/Sdk/SdkBootstrapProgress.cs @@ -3,5 +3,5 @@ namespace Xamarin.Android.Tools; -/// Progress information for SDK bootstrap operations. +/// Progress information for SDK bootstrap and command-line tools refresh operations. public record SdkBootstrapProgress (SdkBootstrapPhase Phase, int PercentComplete = -1, string Message = ""); diff --git a/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs index d2ab46f02db..b5bf8c1f8dc 100644 --- a/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs +++ b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs @@ -246,7 +246,8 @@ internal static void ThrowIfFailed (int exitCode, string command, StringWriter? /// /// Searches for a cmdline-tools binary in the SDK. - /// Prefers the "latest" symlink, then the highest versioned directory. + /// Selects the highest installed revision reported by source.properties, + /// with deterministic directory-name fallback when metadata is unavailable. /// /// Root path to the Android SDK. /// Tool binary name without extension (e.g., "avdmanager"). @@ -254,51 +255,11 @@ internal static void ThrowIfFailed (int exitCode, string command, StringWriter? /// Optional logger for diagnostic messages. internal static string? FindCmdlineTool (string sdkPath, string toolName, string extension, Action? logger = null) { - var cmdlineToolsDir = Path.Combine (sdkPath, "cmdline-tools"); - - if (Directory.Exists (cmdlineToolsDir)) { - // Prefer "latest" symlink first — it's the SDK's own recommended default - var latestPath = Path.Combine (cmdlineToolsDir, "latest", "bin", toolName + extension); - if (File.Exists (latestPath)) - return latestPath; - - try { - var subdirs = new List<(string name, Version version, bool isPreRelease)> (); - foreach (var dir in Directory.GetDirectories (cmdlineToolsDir)) { - var name = Path.GetFileName (dir); - if (string.IsNullOrEmpty (name) || name == "latest") - continue; - // Strip pre-release suffixes (e.g., "5.0-rc1" → "5.0") before parsing - var versionStr = name; - var dashIndex = name.IndexOf ('-'); - var isPreRelease = dashIndex >= 0; - if (isPreRelease) - versionStr = name.Substring (0, dashIndex); - Version.TryParse (versionStr, out var v); - subdirs.Add ((name, v ?? new Version (0, 0), isPreRelease)); - } - // Sort by version descending, then prefer stable (non-prerelease) over prerelease - subdirs.Sort ((a, b) => { - var cmp = b.version.CompareTo (a.version); - if (cmp != 0) return cmp; - if (a.isPreRelease != b.isPreRelease) - return a.isPreRelease ? 1 : -1; // stable first - return string.Compare (a.name, b.name, StringComparison.Ordinal); - }); - - foreach (var (name, _, _) in subdirs) { - var toolPath = Path.Combine (cmdlineToolsDir, name, "bin", toolName + extension); - if (File.Exists (toolPath)) - return toolPath; - } - } catch (IOException ex) { - logger?.Invoke (TraceLevel.Warning, $"FindCmdlineTool: IO error enumerating {cmdlineToolsDir}: {ex.Message}"); - } catch (UnauthorizedAccessException ex) { - logger?.Invoke (TraceLevel.Warning, $"FindCmdlineTool: Permission denied on {cmdlineToolsDir}: {ex.Message}"); - } - } - - return null; + return CommandLineToolsResolver.Find ( + sdkPath, + toolName, + extension, + logger: logger)?.Path; } internal static IEnumerable FindExecutablesInPath (string executable) diff --git a/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt index a9619272ca2..a64f09d9a2f 100644 --- a/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -38,6 +38,10 @@ Xamarin.Android.Tools.AdbRunner.WaitForDeviceAsync(string? serial = null, System Xamarin.Android.Tools.ChecksumType Xamarin.Android.Tools.ChecksumType.Sha1 = 0 -> Xamarin.Android.Tools.ChecksumType Xamarin.Android.Tools.ChecksumType.Sha256 = 1 -> Xamarin.Android.Tools.ChecksumType +Xamarin.Android.Tools.CommandLineTool +Xamarin.Android.Tools.CommandLineTool.CommandLineTool(string! path, string? revision = null) -> void +Xamarin.Android.Tools.CommandLineTool.Path.get -> string! +Xamarin.Android.Tools.CommandLineTool.Revision.get -> string? Xamarin.Android.Tools.JdkInstallPhase Xamarin.Android.Tools.JdkInstallPhase.Complete = 4 -> Xamarin.Android.Tools.JdkInstallPhase Xamarin.Android.Tools.JdkInstallPhase.Downloading = 0 -> Xamarin.Android.Tools.JdkInstallPhase @@ -69,9 +73,11 @@ Xamarin.Android.Tools.JdkVersionInfo.MajorVersion.get -> int Xamarin.Android.Tools.JdkVersionInfo.ResolvedUrl.get -> string? Xamarin.Android.Tools.JdkVersionInfo.Size.get -> long Xamarin.Android.Tools.SdkBootstrapPhase +Xamarin.Android.Tools.SdkBootstrapPhase.CheckingForUpdates = 5 -> Xamarin.Android.Tools.SdkBootstrapPhase Xamarin.Android.Tools.SdkBootstrapPhase.Complete = 4 -> Xamarin.Android.Tools.SdkBootstrapPhase Xamarin.Android.Tools.SdkBootstrapPhase.Downloading = 1 -> Xamarin.Android.Tools.SdkBootstrapPhase Xamarin.Android.Tools.SdkBootstrapPhase.Extracting = 3 -> Xamarin.Android.Tools.SdkBootstrapPhase +Xamarin.Android.Tools.SdkBootstrapPhase.Installing = 6 -> Xamarin.Android.Tools.SdkBootstrapPhase Xamarin.Android.Tools.SdkBootstrapPhase.ReadingManifest = 0 -> Xamarin.Android.Tools.SdkBootstrapPhase Xamarin.Android.Tools.SdkBootstrapPhase.Verifying = 2 -> Xamarin.Android.Tools.SdkBootstrapPhase Xamarin.Android.Tools.SdkBootstrapProgress @@ -98,6 +104,8 @@ Xamarin.Android.Tools.SdkManager.AndroidSdkPath.set -> void Xamarin.Android.Tools.SdkManager.AreLicensesAccepted() -> bool Xamarin.Android.Tools.SdkManager.BootstrapAsync(string! targetPath, System.IProgress? progress = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.SdkManager.Dispose() -> void +Xamarin.Android.Tools.SdkManager.EnsureLatestCommandLineToolsAsync(string! targetPath, System.IProgress? progress = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.SdkManager.FindSdkManager() -> Xamarin.Android.Tools.CommandLineTool? Xamarin.Android.Tools.SdkManager.FindSdkManagerPath() -> string? Xamarin.Android.Tools.SdkManager.GetPendingLicensesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Xamarin.Android.Tools.SdkManager.InstallAsync(System.Collections.Generic.IEnumerable! packages, bool acceptLicenses = true, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index a9619272ca2..a64f09d9a2f 100644 --- a/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -38,6 +38,10 @@ Xamarin.Android.Tools.AdbRunner.WaitForDeviceAsync(string? serial = null, System Xamarin.Android.Tools.ChecksumType Xamarin.Android.Tools.ChecksumType.Sha1 = 0 -> Xamarin.Android.Tools.ChecksumType Xamarin.Android.Tools.ChecksumType.Sha256 = 1 -> Xamarin.Android.Tools.ChecksumType +Xamarin.Android.Tools.CommandLineTool +Xamarin.Android.Tools.CommandLineTool.CommandLineTool(string! path, string? revision = null) -> void +Xamarin.Android.Tools.CommandLineTool.Path.get -> string! +Xamarin.Android.Tools.CommandLineTool.Revision.get -> string? Xamarin.Android.Tools.JdkInstallPhase Xamarin.Android.Tools.JdkInstallPhase.Complete = 4 -> Xamarin.Android.Tools.JdkInstallPhase Xamarin.Android.Tools.JdkInstallPhase.Downloading = 0 -> Xamarin.Android.Tools.JdkInstallPhase @@ -69,9 +73,11 @@ Xamarin.Android.Tools.JdkVersionInfo.MajorVersion.get -> int Xamarin.Android.Tools.JdkVersionInfo.ResolvedUrl.get -> string? Xamarin.Android.Tools.JdkVersionInfo.Size.get -> long Xamarin.Android.Tools.SdkBootstrapPhase +Xamarin.Android.Tools.SdkBootstrapPhase.CheckingForUpdates = 5 -> Xamarin.Android.Tools.SdkBootstrapPhase Xamarin.Android.Tools.SdkBootstrapPhase.Complete = 4 -> Xamarin.Android.Tools.SdkBootstrapPhase Xamarin.Android.Tools.SdkBootstrapPhase.Downloading = 1 -> Xamarin.Android.Tools.SdkBootstrapPhase Xamarin.Android.Tools.SdkBootstrapPhase.Extracting = 3 -> Xamarin.Android.Tools.SdkBootstrapPhase +Xamarin.Android.Tools.SdkBootstrapPhase.Installing = 6 -> Xamarin.Android.Tools.SdkBootstrapPhase Xamarin.Android.Tools.SdkBootstrapPhase.ReadingManifest = 0 -> Xamarin.Android.Tools.SdkBootstrapPhase Xamarin.Android.Tools.SdkBootstrapPhase.Verifying = 2 -> Xamarin.Android.Tools.SdkBootstrapPhase Xamarin.Android.Tools.SdkBootstrapProgress @@ -98,6 +104,8 @@ Xamarin.Android.Tools.SdkManager.AndroidSdkPath.set -> void Xamarin.Android.Tools.SdkManager.AreLicensesAccepted() -> bool Xamarin.Android.Tools.SdkManager.BootstrapAsync(string! targetPath, System.IProgress? progress = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.SdkManager.Dispose() -> void +Xamarin.Android.Tools.SdkManager.EnsureLatestCommandLineToolsAsync(string! targetPath, System.IProgress? progress = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.SdkManager.FindSdkManager() -> Xamarin.Android.Tools.CommandLineTool? Xamarin.Android.Tools.SdkManager.FindSdkManagerPath() -> string? Xamarin.Android.Tools.SdkManager.GetPendingLicensesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Xamarin.Android.Tools.SdkManager.InstallAsync(System.Collections.Generic.IEnumerable! packages, bool acceptLicenses = true, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.CommandLineTools.cs b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.CommandLineTools.cs new file mode 100644 index 00000000000..b9404165af0 --- /dev/null +++ b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.CommandLineTools.cs @@ -0,0 +1,162 @@ +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Xamarin.Android.Tools; + +public partial class SdkManager +{ + const string LatestCommandLineToolsPackage = "cmdline-tools;latest"; + + /// + /// Finds the installed sdkmanager from the highest command-line tools + /// revision reported by source.properties. + /// + /// + /// The selected executable and revision, or when no compatible + /// sdkmanager is installed. + /// + public CommandLineTool? FindSdkManager () + { + var sdkPath = AndroidSdkPath; + if (sdkPath is null || sdkPath.Length == 0) + return null; + + var extension = OS.IsWindows ? ".bat" : ""; + return CommandLineToolsResolver.Find ( + sdkPath, + "sdkmanager", + extension, + logger: logger); + } + + /// + /// Finds the installed sdkmanager executable path. + /// + /// The selected executable path, or when none is installed. + public string? FindSdkManagerPath () + { + return FindSdkManager ()?.Path; + } + + /// + /// Ensures the Android SDK contains the current Google cmdline-tools;latest package. + /// + /// The Android SDK root path. + /// Optional progress callback for bootstrap, catalog checking, and installation. + /// Cancellation token. + /// The selected sdkmanager executable and installed revision. + public Task EnsureLatestCommandLineToolsAsync ( + string targetPath, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed (); + if (string.IsNullOrEmpty (targetPath)) + throw new ArgumentNullException (nameof (targetPath)); + + progress ??= NullProgress; + return EnsureLatestCommandLineToolsAsync ( + targetPath, + progress, + cancellationToken, + BootstrapAsync, + ListAsync, + (packages, acceptLicenses, token) => InstallAsync (packages, acceptLicenses, token)); + } + + internal async Task EnsureLatestCommandLineToolsAsync ( + string targetPath, + IProgress progress, + CancellationToken cancellationToken, + Func?, CancellationToken, Task> bootstrapAsync, + Func Installed, IReadOnlyList Available)>> listAsync, + Func, bool, CancellationToken, Task> installAsync) + { + ThrowIfDisposed (); + cancellationToken.ThrowIfCancellationRequested (); + AndroidSdkPath = targetPath; + + var selected = FindSdkManager (); + if (selected is null) { + logger (System.Diagnostics.TraceLevel.Info, "No sdkmanager found; bootstrapping command-line tools."); + await bootstrapAsync ( + targetPath, + new BootstrapProgressForwarder (progress), + cancellationToken).ConfigureAwait (false); + AndroidSdkPath = targetPath; + selected = FindSdkManager (); + if (selected is null) + throw new InvalidOperationException ("Android SDK bootstrap completed without installing sdkmanager."); + } + + progress.Report (new SdkBootstrapProgress ( + SdkBootstrapPhase.CheckingForUpdates, + Message: "Checking the latest command-line tools revision...")); + var (installed, available) = await listAsync (cancellationToken).ConfigureAwait (false); + SdkPackage? latestPackage = null; + CommandLineToolsResolver.ParsedRevision? latestRevision = null; + foreach (var package in available.Concat (installed)) { + if (!string.Equals (package.Path, LatestCommandLineToolsPackage, StringComparison.Ordinal) || + !CommandLineToolsResolver.TryParseRevision (package.Version, out var packageRevision)) + continue; + if (latestRevision.HasValue && packageRevision.CompareTo (latestRevision.Value) <= 0) + continue; + + latestPackage = package; + latestRevision = packageRevision; + } + if (latestPackage is null || !latestRevision.HasValue) + throw new InvalidOperationException ("Could not determine the latest command-line tools revision from sdkmanager."); + var catalogRevision = latestRevision.Value; + + var shouldInstall = + !CommandLineToolsResolver.TryParseRevision (selected.Revision, out var selectedRevision) || + selectedRevision.CompareTo (catalogRevision) < 0; + + if (shouldInstall) { + progress.Report (new SdkBootstrapProgress ( + SdkBootstrapPhase.Installing, + Message: $"Installing {LatestCommandLineToolsPackage} {latestPackage.Version}...")); + await installAsync ([LatestCommandLineToolsPackage], true, cancellationToken).ConfigureAwait (false); + + selected = FindSdkManager (); + if (selected is null) + throw new InvalidOperationException ("The latest command-line tools package was installed, but sdkmanager could not be found."); + if (!CommandLineToolsResolver.TryParseRevision (selected.Revision, out selectedRevision)) + throw new InvalidOperationException ($"The installed sdkmanager revision '{selected.Revision ?? "unknown"}' could not be parsed."); + if (selectedRevision.CompareTo (catalogRevision) < 0) + throw new InvalidOperationException ( + $"The resolved command-line tools revision '{selected.Revision}' is older than the catalog revision '{latestPackage.Version}'."); + } else { + logger (System.Diagnostics.TraceLevel.Info, $"Command-line tools {selected.Revision} is already current."); + } + + progress.Report (new SdkBootstrapProgress ( + SdkBootstrapPhase.Complete, + 100, + $"Command-line tools {selected.Revision} are ready.")); + return selected; + } + + sealed class BootstrapProgressForwarder : IProgress + { + readonly IProgress progress; + + public BootstrapProgressForwarder (IProgress progress) + { + this.progress = progress; + } + + public void Report (SdkBootstrapProgress value) + { + if (value.Phase != SdkBootstrapPhase.Complete) + progress.Report (value); + } + } +} diff --git a/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Packages.cs b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Packages.cs index 0a548d985f1..b72fc6aed45 100644 --- a/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Packages.cs +++ b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Packages.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -13,38 +12,6 @@ namespace Xamarin.Android.Tools; public partial class SdkManager { - 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)) { - try { - // Versioned dirs sorted descending, then "latest" as fallback - var searchDirs = Directory.GetDirectories (cmdlineToolsDir) - .Select (Path.GetFileName) - .Where (n => n != "latest" && !string.IsNullOrEmpty (n)) - .OrderByDescending (n => Version.TryParse (n, out var v) ? v : new Version (0, 0)) - .Append ("latest"); - - foreach (var dir in searchDirs) { - var toolPath = Path.Combine (cmdlineToolsDir, dir!, "bin", "sdkmanager" + ext); - if (File.Exists (toolPath)) - return toolPath; - } - } catch (Exception ex) { - logger (TraceLevel.Verbose, $"Error enumerating cmdline-tools directories: {ex.Message}"); - } - } - - // Legacy fallback: tools/bin/sdkmanager - var legacyPath = Path.Combine (AndroidSdkPath, "tools", "bin", "sdkmanager" + ext); - return File.Exists (legacyPath) ? legacyPath : null; - } - public async Task<(IReadOnlyList Installed, IReadOnlyList Available)> ListAsync (CancellationToken cancellationToken = default) { var sdkManagerPath = RequireSdkManagerPath (); @@ -110,19 +77,23 @@ internal static (IReadOnlyList Installed, IReadOnlyList var installed = new List (); var available = new List (); List? target = null; + var parsingUpdates = false; foreach (var line in output.Split (new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)) { var trimmed = line.Trim (); - if (trimmed.Contains ("Installed packages:")) { target = installed; continue; } - if (trimmed.Contains ("Available Packages:")) { target = available; continue; } - if (trimmed.Contains ("Available Updates:")) { target = null; continue; } + if (trimmed.Contains ("Installed packages:")) { target = installed; parsingUpdates = false; continue; } + if (trimmed.Contains ("Available Packages:")) { target = available; parsingUpdates = false; continue; } + if (trimmed.Contains ("Available Updates:")) { target = available; parsingUpdates = true; continue; } if (target is null || trimmed.StartsWith ("Path", StringComparison.Ordinal) || trimmed.StartsWith ("---", StringComparison.Ordinal)) continue; var parts = trimmed.Split ('|'); - if (parts.Length < 2) + if (parsingUpdates && string.Equals (parts [0].Trim (), "ID", StringComparison.Ordinal)) + continue; + var versionIndex = parsingUpdates ? 2 : 1; + if (parts.Length <= versionIndex) continue; var path = parts[0].Trim (); @@ -131,8 +102,8 @@ internal static (IReadOnlyList Installed, IReadOnlyList target.Add (new SdkPackage ( path, - Version: parts[1].Trim (), - Description: parts.Length > 2 ? parts[2].Trim () : null, + Version: parts[versionIndex].Trim (), + Description: !parsingUpdates && parts.Length > 2 ? parts[2].Trim () : null, IsInstalled: target == installed )); } diff --git a/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/Sdks/AndroidSdkBase.cs b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/Sdks/AndroidSdkBase.cs index f05668f6e87..3a1e2b486a0 100644 --- a/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/Sdks/AndroidSdkBase.cs +++ b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/Sdks/AndroidSdkBase.cs @@ -13,8 +13,6 @@ abstract class AndroidSdkBase const int MinimumCompatibleNDKMajorVersion = 16; const int MaximumCompatibleNDKMajorVersion = 28; - static readonly char[] SourcePropertiesKeyValueSplit = new char[] { '=' }; - // Per https://developer.android.com/studio/command-line/variables#envar #pragma warning disable CS0618 // ANDROID_SDK_ROOT is obsolete but still needed for compat protected static readonly string[] AndroidSdkEnvVars = {EnvironmentVariableNames.AndroidHome, EnvironmentVariableNames.AndroidSdkRoot}; @@ -213,28 +211,17 @@ void LoadNDKVersion (string path) return; } - foreach (string line in File.ReadLines (propsFilePath)) { - string[] parts = line.Split (SourcePropertiesKeyValueSplit, 2, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length != 2) { - continue; - } - - if (String.Compare ("Pkg.Revision", parts[0].Trim (), StringComparison.Ordinal) != 0) { - continue; - } - - if (!Version.TryParse (parts[1].Trim (), out Version? ndkVer) || ndkVer == null || ndkInstances.ContainsKey (ndkVer)) { - continue; - } - - if (ndkVer.Major < MinimumCompatibleNDKMajorVersion || ndkVer.Major > MaximumCompatibleNDKMajorVersion) { - Logger (TraceLevel.Verbose, $"Skipping NDK in '{path}': version {ndkVer} is out of the accepted range (major version must be between {MinimumCompatibleNDKMajorVersion} and {MaximumCompatibleNDKMajorVersion}"); - continue; - } + if (!SourceProperties.TryGetProperty (propsFilePath, "Pkg.Revision", out var revision) || + !Version.TryParse (revision, out var ndkVer) || + ndkInstances.ContainsKey (ndkVer)) + return; - ndkInstances.Add (ndkVer, path); + if (ndkVer.Major < MinimumCompatibleNDKMajorVersion || ndkVer.Major > MaximumCompatibleNDKMajorVersion) { + Logger (TraceLevel.Verbose, $"Skipping NDK in '{path}': version {ndkVer} is out of the accepted range (major version must be between {MinimumCompatibleNDKMajorVersion} and {MaximumCompatibleNDKMajorVersion}"); return; } + + ndkInstances.Add (ndkVer, path); } } @@ -326,4 +313,3 @@ static string GetExecutablePath (string? dir, string exe) } } } - diff --git a/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/SourceProperties.cs b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/SourceProperties.cs new file mode 100644 index 00000000000..2c71b6c30d5 --- /dev/null +++ b/external/xamarin-android-tools/src/Xamarin.Android.Tools.AndroidSdk/SourceProperties.cs @@ -0,0 +1,32 @@ +// 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.IO; + +namespace Xamarin.Android.Tools; + +static class SourceProperties +{ + public static bool TryGetProperty (string filePath, string propertyName, out string? value) + { + value = null; + if (!File.Exists (filePath)) + return false; + + foreach (var line in File.ReadLines (filePath)) { + var separator = line.IndexOf ('='); + if (separator < 0) + continue; + + var name = line.Substring (0, separator).Trim (); + if (!string.Equals (name, propertyName, StringComparison.Ordinal)) + continue; + + value = line.Substring (separator + 1).Trim (); + return true; + } + + return false; + } +} diff --git a/external/xamarin-android-tools/tests/Xamarin.Android.Tools.AndroidSdk-Tests/CommandLineToolsResolverTests.cs b/external/xamarin-android-tools/tests/Xamarin.Android.Tools.AndroidSdk-Tests/CommandLineToolsResolverTests.cs new file mode 100644 index 00000000000..cb1d851a602 --- /dev/null +++ b/external/xamarin-android-tools/tests/Xamarin.Android.Tools.AndroidSdk-Tests/CommandLineToolsResolverTests.cs @@ -0,0 +1,420 @@ +// 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.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +namespace Xamarin.Android.Tools.Tests; + +[TestFixture] +public class CommandLineToolsResolverTests +{ + string sdkDirectory = ""; + + string SdkDirectory => sdkDirectory; + string ExecutableExtension => OS.IsWindows ? ".bat" : ""; + + [SetUp] + public void SetUp () + { + sdkDirectory = Path.Combine (Path.GetTempPath (), $"cmdline-tools-test-{Path.GetRandomFileName ()}"); + Directory.CreateDirectory (sdkDirectory); + } + + [TearDown] + public void TearDown () + { + if (Directory.Exists (sdkDirectory)) + Directory.Delete (sdkDirectory, recursive: true); + sdkDirectory = ""; + } + + [Test] + public void CommandLineTool_NullPath_Throws () + { + Assert.Throws (() => new CommandLineTool (null)); + } + + [Test] + public void FindCommandLineTool_LatestHasHigherPackageRevision_SelectsLatest () + { + CreateCommandLineTool ("19.0", "sdkmanager", "19.0"); + CreateCommandLineTool ("19.0", "avdmanager", "19.0"); + var expectedSdkManager = CreateCommandLineTool ("latest", "sdkmanager", "22.0"); + var expectedAvdManager = CreateCommandLineTool ("latest", "avdmanager", "22.0"); + + using var manager = CreateSdkManager (); + var selected = manager.FindSdkManager (); + + Assert.That (selected?.Path, Is.EqualTo (expectedSdkManager)); + Assert.That (selected?.Revision, Is.EqualTo ("22.0")); + Assert.That (manager.FindSdkManagerPath (), Is.EqualTo (expectedSdkManager)); + Assert.That ( + ProcessUtils.FindCmdlineTool (SdkDirectory, "avdmanager", ExecutableExtension), + Is.EqualTo (expectedAvdManager)); + } + + [Test] + public void FindCommandLineTool_NumericHasHigherPackageRevision_SelectsNumeric () + { + var expectedSdkManager = CreateCommandLineTool ("22.0", "sdkmanager", "22.0"); + var expectedAvdManager = CreateCommandLineTool ("22.0", "avdmanager", "22.0"); + CreateCommandLineTool ("latest", "sdkmanager", "19.0"); + CreateCommandLineTool ("latest", "avdmanager", "19.0"); + + using var manager = CreateSdkManager (); + var selected = manager.FindSdkManager (); + + Assert.That (selected?.Path, Is.EqualTo (expectedSdkManager)); + Assert.That (selected?.Revision, Is.EqualTo ("22.0")); + Assert.That ( + ProcessUtils.FindCmdlineTool (SdkDirectory, "avdmanager", ExecutableExtension), + Is.EqualTo (expectedAvdManager)); + } + + [Test] + public void FindCommandLineTool_DirectoryNamesDisagreeWithPackageRevision_UsesPackageRevision () + { + CreateCommandLineTool ("99.0", "sdkmanager", "19.0"); + CreateCommandLineTool ("99.0", "avdmanager", "19.0"); + var expectedSdkManager = CreateCommandLineTool ("1.0", "sdkmanager", "22.0"); + var expectedAvdManager = CreateCommandLineTool ("1.0", "avdmanager", "22.0"); + + using var manager = CreateSdkManager (); + + Assert.That (manager.FindSdkManagerPath (), Is.EqualTo (expectedSdkManager)); + Assert.That ( + ProcessUtils.FindCmdlineTool (SdkDirectory, "avdmanager", ExecutableExtension), + Is.EqualTo (expectedAvdManager)); + } + + [Test] + public void FindCommandLineTool_MissingAndMalformedPackageRevision_UsesDirectoryVersion () + { + var expectedSdkManager = CreateCommandLineTool ("22.0", "sdkmanager"); + var expectedAvdManager = CreateCommandLineTool ("22.0", "avdmanager"); + CreateCommandLineTool ("latest", "sdkmanager", "not-a-version"); + CreateCommandLineTool ("latest", "avdmanager", "not-a-version"); + + using var manager = CreateSdkManager (); + var selected = manager.FindSdkManager (); + + Assert.That (selected?.Path, Is.EqualTo (expectedSdkManager)); + Assert.That (selected?.Revision, Is.EqualTo ("22.0")); + Assert.That ( + ProcessUtils.FindCmdlineTool (SdkDirectory, "avdmanager", ExecutableExtension), + Is.EqualTo (expectedAvdManager)); + } + + [Test] + public void FindCommandLineTool_StableAndPrereleaseHaveSameCoreRevision_PrefersStable () + { + var expectedSdkManager = CreateCommandLineTool ("stable", "sdkmanager", "22.0"); + var expectedAvdManager = CreateCommandLineTool ("stable", "avdmanager", "22.0"); + CreateCommandLineTool ("preview", "sdkmanager", "22.0 rc1"); + CreateCommandLineTool ("preview", "avdmanager", "22.0 rc1"); + + using var manager = CreateSdkManager (); + + Assert.That (manager.FindSdkManagerPath (), Is.EqualTo (expectedSdkManager)); + Assert.That ( + ProcessUtils.FindCmdlineTool (SdkDirectory, "avdmanager", ExecutableExtension), + Is.EqualTo (expectedAvdManager)); + } + + [Test] + public void FindCommandLineTool_PrereleaseRevisionsHaveNumericSuffix_SelectsHighest () + { + CreateCommandLineTool ("rc2", "sdkmanager", "22.0 rc2"); + var expectedSdkManager = CreateCommandLineTool ("rc10", "sdkmanager", "22.0 rc10"); + + using var manager = CreateSdkManager (); + + Assert.That (manager.FindSdkManagerPath (), Is.EqualTo (expectedSdkManager)); + } + + [Test] + public async Task EnsureLatestCommandLineToolsAsync_MissingManager_BootstrapsAndInstallsLatest () + { + var bootstrapCalls = 0; + var installCalls = 0; + var progress = new ProgressCollector (); + using var manager = CreateSdkManager (); + + var selected = await manager.EnsureLatestCommandLineToolsAsync ( + SdkDirectory, + progress, + CancellationToken.None, + (targetPath, bootstrapProgress, cancellationToken) => { + cancellationToken.ThrowIfCancellationRequested (); + Assert.That (targetPath, Is.EqualTo (SdkDirectory)); + bootstrapCalls++; + bootstrapProgress?.Report (new SdkBootstrapProgress (SdkBootstrapPhase.ReadingManifest)); + bootstrapProgress?.Report (new SdkBootstrapProgress (SdkBootstrapPhase.Complete)); + CreateCommandLineTool ("19.0", "sdkmanager", "19.0"); + return Task.CompletedTask; + }, + _ => CreatePackageList ("22.0"), + (packages, acceptLicenses, cancellationToken) => { + cancellationToken.ThrowIfCancellationRequested (); + string[] expectedPackages = [LatestPackage]; + CollectionAssert.AreEqual (expectedPackages, packages.ToArray ()); + Assert.That (acceptLicenses, Is.True); + installCalls++; + CreateCommandLineTool ("latest", "sdkmanager", "22.0"); + return Task.CompletedTask; + }); + + Assert.That (selected.Revision, Is.EqualTo ("22.0")); + Assert.That (selected.Path, Does.Contain (Path.Combine ("latest", "bin"))); + Assert.That (bootstrapCalls, Is.EqualTo (1)); + Assert.That (installCalls, Is.EqualTo (1)); + Assert.That (progress.Phases, Does.Contain (SdkBootstrapPhase.ReadingManifest)); + Assert.That (progress.Phases, Does.Contain (SdkBootstrapPhase.CheckingForUpdates)); + Assert.That (progress.Phases, Does.Contain (SdkBootstrapPhase.Installing)); + Assert.That (progress.Phases, Does.Contain (SdkBootstrapPhase.Complete)); + Assert.That (progress.Phases.Count (phase => phase == SdkBootstrapPhase.Complete), Is.EqualTo (1)); + } + + [Test] + public async Task EnsureLatestCommandLineToolsAsync_StaleManager_InstallsLatestWithoutBootstrap () + { + CreateCommandLineTool ("19.0", "sdkmanager", "19.0"); + var bootstrapCalls = 0; + var installCalls = 0; + using var manager = CreateSdkManager (); + + var selected = await manager.EnsureLatestCommandLineToolsAsync ( + SdkDirectory, + new ProgressCollector (), + CancellationToken.None, + (_, _, _) => { + bootstrapCalls++; + return Task.CompletedTask; + }, + _ => CreatePackageList ("22.0"), + (_, _, _) => { + installCalls++; + CreateCommandLineTool ("latest", "sdkmanager", "22.0"); + return Task.CompletedTask; + }); + + Assert.That (selected.Revision, Is.EqualTo ("22.0")); + Assert.That (bootstrapCalls, Is.Zero); + Assert.That (installCalls, Is.EqualTo (1)); + } + + [Test] + public async Task EnsureLatestCommandLineToolsAsync_CurrentManager_DoesNotInstall () + { + CreateCommandLineTool ("latest", "sdkmanager", "22.0"); + var bootstrapCalls = 0; + var installCalls = 0; + var progress = new ProgressCollector (); + using var manager = CreateSdkManager (); + + var selected = await manager.EnsureLatestCommandLineToolsAsync ( + SdkDirectory, + progress, + CancellationToken.None, + (_, _, _) => { + bootstrapCalls++; + return Task.CompletedTask; + }, + _ => CreatePackageList ("22.0", isInstalled: true), + (_, _, _) => { + installCalls++; + return Task.CompletedTask; + }); + + Assert.That (selected.Revision, Is.EqualTo ("22.0")); + Assert.That (bootstrapCalls, Is.Zero); + Assert.That (installCalls, Is.Zero); + Assert.That (progress.Phases, Does.Not.Contain (SdkBootstrapPhase.Installing)); + } + + [Test] + public async Task EnsureLatestCommandLineToolsAsync_InstalledPackageHasAvailableUpdate_InstallsLatest () + { + CreateCommandLineTool ("latest", "sdkmanager", "19.0"); + var installCalls = 0; + using var manager = CreateSdkManager (); + + var selected = await manager.EnsureLatestCommandLineToolsAsync ( + SdkDirectory, + new ProgressCollector (), + CancellationToken.None, + (_, _, _) => Task.CompletedTask, + _ => { + IReadOnlyList installed = [new SdkPackage (LatestPackage, "19.0", IsInstalled: true)]; + IReadOnlyList available = [new SdkPackage (LatestPackage, "22.0")]; + return Task.FromResult ((installed, available)); + }, + (_, _, _) => { + installCalls++; + CreateCommandLineTool ("latest", "sdkmanager", "22.0"); + return Task.CompletedTask; + }); + + Assert.That (selected.Revision, Is.EqualTo ("22.0")); + Assert.That (installCalls, Is.EqualTo (1)); + } + + [Test] + public void EnsureLatestCommandLineToolsAsync_MissingCatalogPackage_Throws () + { + CreateCommandLineTool ("19.0", "sdkmanager", "19.0"); + using var manager = CreateSdkManager (); + + var exception = Assert.ThrowsAsync (() => manager.EnsureLatestCommandLineToolsAsync ( + SdkDirectory, + new ProgressCollector (), + CancellationToken.None, + (_, _, _) => Task.CompletedTask, + _ => { + IReadOnlyList installed = []; + IReadOnlyList available = []; + return Task.FromResult ((installed, available)); + }, + (_, _, _) => Task.CompletedTask)); + + Assert.That (exception?.Message, Does.Contain ("Could not determine the latest")); + } + + [Test] + public void EnsureLatestCommandLineToolsAsync_InstallDoesNotUpdateManager_Throws () + { + CreateCommandLineTool ("19.0", "sdkmanager", "19.0"); + var installCalls = 0; + using var manager = CreateSdkManager (); + + var exception = Assert.ThrowsAsync (() => manager.EnsureLatestCommandLineToolsAsync ( + SdkDirectory, + new ProgressCollector (), + CancellationToken.None, + (_, _, _) => Task.CompletedTask, + _ => CreatePackageList ("22.0"), + (_, _, _) => { + installCalls++; + return Task.CompletedTask; + })); + + Assert.That (exception?.Message, Does.Contain ("older than the catalog revision")); + Assert.That (installCalls, Is.EqualTo (1)); + } + + [Test] + public void EnsureLatestCommandLineToolsAsync_BootstrapDoesNotInstallManager_Throws () + { + using var manager = CreateSdkManager (); + + var exception = Assert.ThrowsAsync (() => manager.EnsureLatestCommandLineToolsAsync ( + SdkDirectory, + new ProgressCollector (), + CancellationToken.None, + (_, _, _) => Task.CompletedTask, + _ => CreatePackageList ("22.0"), + (_, _, _) => Task.CompletedTask)); + + Assert.That (exception?.Message, Does.Contain ("without installing sdkmanager")); + } + + [Test] + public void EnsureLatestCommandLineToolsAsync_Disposed_Throws () + { + using var manager = CreateSdkManager (); + manager.Dispose (); + + Assert.ThrowsAsync (() => manager.EnsureLatestCommandLineToolsAsync ( + SdkDirectory, + new ProgressCollector (), + CancellationToken.None, + (_, _, _) => Task.CompletedTask, + _ => CreatePackageList ("22.0"), + (_, _, _) => Task.CompletedTask)); + } + + [Test] + public void EnsureLatestCommandLineToolsAsync_Canceled_PropagatesCancellation () + { + using var manager = CreateSdkManager (); + using var cancellation = new CancellationTokenSource (); + cancellation.Cancel (); + + Assert.ThrowsAsync (() => manager.EnsureLatestCommandLineToolsAsync ( + SdkDirectory, + new ProgressCollector (), + cancellation.Token, + (_, _, _) => Task.CompletedTask, + _ => CreatePackageList ("22.0"), + (_, _, _) => Task.CompletedTask)); + } + + [Test] + public void EnsureLatestCommandLineToolsAsync_CanceledDuringInstall_PropagatesCancellation () + { + CreateCommandLineTool ("19.0", "sdkmanager", "19.0"); + using var manager = CreateSdkManager (); + using var cancellation = new CancellationTokenSource (); + + Assert.ThrowsAsync (() => manager.EnsureLatestCommandLineToolsAsync ( + SdkDirectory, + new ProgressCollector (), + cancellation.Token, + (_, _, _) => Task.CompletedTask, + _ => CreatePackageList ("22.0"), + (_, _, cancellationToken) => { + cancellation.Cancel (); + cancellationToken.ThrowIfCancellationRequested (); + return Task.CompletedTask; + })); + } + + const string LatestPackage = "cmdline-tools;latest"; + + SdkManager CreateSdkManager () + { + return new SdkManager { + AndroidSdkPath = SdkDirectory, + }; + } + + string CreateCommandLineTool (string directoryName, string toolName, string revision = null) + { + var commandLineToolsDirectory = Path.Combine (SdkDirectory, "cmdline-tools", directoryName); + var binDirectory = Path.Combine (commandLineToolsDirectory, "bin"); + Directory.CreateDirectory (binDirectory); + + var toolPath = Path.Combine (binDirectory, toolName + ExecutableExtension); + File.WriteAllText (toolPath, ""); + if (revision is not null) + File.WriteAllText (Path.Combine (commandLineToolsDirectory, "source.properties"), $"Pkg.Revision = {revision}"); + return toolPath; + } + + static Task<(IReadOnlyList Installed, IReadOnlyList Available)> CreatePackageList ( + string latestVersion, + bool isInstalled = false) + { + var package = new SdkPackage (LatestPackage, latestVersion, IsInstalled: isInstalled); + IReadOnlyList installed = isInstalled ? [package] : []; + IReadOnlyList available = isInstalled ? [] : [package]; + return Task.FromResult ((installed, available)); + } + + sealed class ProgressCollector : IProgress + { + public List Phases { get; } = []; + + public void Report (SdkBootstrapProgress value) + { + Phases.Add (value.Phase); + } + } +} diff --git a/external/xamarin-android-tools/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs b/external/xamarin-android-tools/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs index ab931c07a4f..b70c9cbf95b 100644 --- a/external/xamarin-android-tools/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs +++ b/external/xamarin-android-tools/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs @@ -149,7 +149,7 @@ public void ParseManifest_JdkElements_Parsed () } [Test] - public void ParseSdkManagerList_ParsesInstalledAndAvailable () + public void ParseSdkManagerList_ParsesInstalledAvailableAndUpdates () { var output = @"Installed packages: Path | Version | Description | Location @@ -166,14 +166,15 @@ public void ParseSdkManagerList_ParsesInstalledAndAvailable () 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 + ID | 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"); + Assert.AreEqual (4, available.Count, "Should have 3 available packages and 1 update"); var platformTools = installed.FirstOrDefault (p => p.Path == "platform-tools"); Assert.IsNotNull (platformTools); @@ -184,6 +185,11 @@ public void ParseSdkManagerList_ParsesInstalledAndAvailable () Assert.IsNotNull (buildTools36); Assert.AreEqual ("36.0.0", buildTools36!.Version); Assert.IsFalse (buildTools36.IsInstalled); + + var platformToolsUpdate = available.FirstOrDefault (p => p.Path == "platform-tools"); + Assert.IsNotNull (platformToolsUpdate); + Assert.AreEqual ("36.0.0", platformToolsUpdate!.Version); + Assert.IsFalse (platformToolsUpdate.IsInstalled); } [Test] @@ -261,28 +267,6 @@ public void FindSdkManagerPath_VersionedDir_Found () } } - [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 () {