diff --git a/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.CommandLineTools.cs b/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.CommandLineTools.cs index c6e6086077d..a25fb3aac32 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.CommandLineTools.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.CommandLineTools.cs @@ -12,6 +12,7 @@ namespace Xamarin.Android.Tools; public partial class SdkManager { const string LatestCommandLineToolsPackage = "cmdline-tools;latest"; + const string LatestCommandLineToolsAndroidCliPackage = "cmdline-tools/latest"; /// /// Finds sdkmanager in cmdline-tools/latest/bin, or falls back to @@ -102,7 +103,7 @@ await bootstrapAsync ( SdkPackage? latestPackage = null; CommandLineToolsResolver.ParsedRevision? latestRevision = null; foreach (var package in available.Concat (installed)) { - if (!string.Equals (package.Path, LatestCommandLineToolsPackage, StringComparison.Ordinal) || + if (!IsLatestCommandLineToolsPackage (package.Path) || !CommandLineToolsResolver.TryParseRevision (package.Version, out var packageRevision)) continue; if (latestRevision.HasValue && packageRevision.CompareTo (latestRevision.Value) <= 0) @@ -144,6 +145,12 @@ await bootstrapAsync ( return selected; } + static bool IsLatestCommandLineToolsPackage (string path) + { + return string.Equals (path, LatestCommandLineToolsPackage, StringComparison.Ordinal) || + string.Equals (path, LatestCommandLineToolsAndroidCliPackage, StringComparison.Ordinal); + } + sealed class BootstrapProgressForwarder : IProgress { readonly IProgress progress; diff --git a/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Packages.cs b/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Packages.cs index b72fc6aed45..d0be29f240c 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Packages.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Packages.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -12,6 +13,13 @@ namespace Xamarin.Android.Tools; public partial class SdkManager { + static readonly (string Header, bool IsInstalled, bool IsUpdate) [] PackageSections = { + ("Installed packages:", true, false), + ("Available Packages:", false, false), + ("Available Updates:", false, true), + }; + static readonly Regex WhitespaceColumnSeparator = new Regex (@"\s{2,}", RegexOptions.Compiled | RegexOptions.CultureInvariant); + public async Task<(IReadOnlyList Installed, IReadOnlyList Available)> ListAsync (CancellationToken cancellationToken = default) { var sdkManagerPath = RequireSdkManagerPath (); @@ -82,14 +90,34 @@ internal static (IReadOnlyList Installed, IReadOnlyList foreach (var line in output.Split (new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)) { var trimmed = line.Trim (); - 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; } + // Match section headers case-insensitively: the classic sdkmanager emits + // "Available Packages:" (capital P) while the newer Android CLI replacement + // (cmdline-tools 23+) emits "Available packages:" (lowercase p). + var foundSection = false; + foreach (var section in PackageSections) { + if (trimmed.IndexOf (section.Header, StringComparison.OrdinalIgnoreCase) < 0) + continue; + + target = section.IsInstalled ? installed : available; + parsingUpdates = section.IsUpdate; + foundSection = true; + break; + } + if (foundSection) + continue; if (target is null || trimmed.StartsWith ("Path", StringComparison.Ordinal) || trimmed.StartsWith ("---", StringComparison.Ordinal)) continue; - var parts = trimmed.Split ('|'); + // The classic sdkmanager prints a pipe-delimited table + // ("path | version | description | location"). sdkmanager is deprecated in + // cmdline-tools 23+ and replaced by the "Android CLI", which prints + // whitespace-aligned columns with no pipes. Fall back to splitting on runs of + // 2+ spaces when no pipe is present; otherwise every package row is skipped + // (pipe split yields a single column), producing an empty installed list. + var parts = trimmed.IndexOf ('|') >= 0 + ? trimmed.Split ('|') + : WhitespaceColumnSeparator.Split (trimmed); if (parsingUpdates && string.Equals (parts [0].Trim (), "ID", StringComparison.Ordinal)) continue; var versionIndex = parsingUpdates ? 2 : 1; diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/CommandLineToolsResolverTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/CommandLineToolsResolverTests.cs index 0ffb29e9ebd..74f77831985 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/CommandLineToolsResolverTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/CommandLineToolsResolverTests.cs @@ -297,6 +297,34 @@ public async Task EnsureLatestCommandLineToolsAsync_InstalledPackageHasAvailable Assert.That (installCalls, Is.EqualTo (1)); } + [TestCase (@"Available Packages: + Path | Version | Description + cmdline-tools;latest | 22.0 | Android SDK Command-line Tools (latest) +")] + [TestCase (@"Available packages: + cmdline-tools/latest 22.0 Android SDK Command-line Tools (latest) +")] + public async Task EnsureLatestCommandLineToolsAsync_ParsedPackageFormats_RecognizesLatest (string output) + { + CreateCommandLineTool ("latest", "sdkmanager", "22.0"); + var installCalls = 0; + using var manager = CreateSdkManager (); + + var selected = await manager.EnsureLatestCommandLineToolsAsync ( + SdkDirectory, + new ProgressCollector (), + CancellationToken.None, + (_, _, _) => Task.CompletedTask, + _ => Task.FromResult (SdkManager.ParseSdkManagerList (output)), + (_, _, _) => { + installCalls++; + return Task.CompletedTask; + }); + + Assert.That (selected.Revision, Is.EqualTo ("22.0")); + Assert.That (installCalls, Is.Zero); + } + [Test] public void EnsureLatestCommandLineToolsAsync_MissingCatalogPackage_Throws () { diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs index b70c9cbf95b..0e2e628eb7a 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs @@ -215,6 +215,97 @@ public void ParseSdkManagerList_OnlyInstalledSection () Assert.AreEqual ("platform-tools", installed[0].Path); } + [Test] + public void ParseSdkManagerList_AndroidCliWhitespaceFormat_ParsesInstalledAndAvailable () + { + // sdkmanager is deprecated in cmdline-tools 23+ and replaced by the "Android CLI", + // which prints a 3-line deprecation banner followed by whitespace-aligned columns + // (no pipes) under a lowercase "Available packages:" header. Regression test: the old + // pipe-only parser skipped every row here, returning an empty installed list, which the + // MAUI extension surfaced as a false "AVD missing components" warning. + var output = @"WARNING: The SDK Manager CLI tool (sdkmanager) is deprecated. Android CLI will be used instead. +The 'android' binary can also be found in the cmdline-tools directory, and 'android sdk' is the replacement for 'sdkmanager'. +To learn more about the Android CLI and how to use it, see the documentation (https://d.android.com/tools/agents/android-cli) + +Installed packages: + build-tools/36.0.0 36.0.0 Android SDK Build-Tools 36 + cmdline-tools/latest 23.0.0 Android SDK Command-line Tools (latest) + emulator 37.1.11 Android Emulator + platform-tools 37.0.1 Android SDK Platform-Tools + platforms/android-35 2.0.0 Android SDK Platform 35 + system-images/android-36.1/google_apis/arm64-v8a 4.0.0 Google APIs ARM 64 v8a System Image + system-images/android-37.0/google_apis_ps16k/arm64-v8a 6.0.0 16 KB Page Size Google APIs ARM 64 v8a System Image +Available packages: + add-ons/addon-google_apis-google-10 2.0.0 Google APIs + platforms/android-36 2.0.0 Android SDK Platform 36 +"; + + var (installed, available) = SdkManager.ParseSdkManagerList (output); + + Assert.AreEqual (7, installed.Count, "Should parse all 7 whitespace-aligned installed packages"); + + var buildTools = installed.Single (p => p.Path == "build-tools/36.0.0"); + Assert.AreEqual ("36.0.0", buildTools.Version); + Assert.AreEqual ("Android SDK Build-Tools 36", buildTools.Description); + Assert.IsTrue (buildTools.IsInstalled); + + // System-image ids carry internal '/' separators and multi-word descriptions with + // single spaces (e.g. "16 KB Page Size ...") that must survive the 2+-space column split. + var sysImg = installed.Single (p => p.Path == "system-images/android-37.0/google_apis_ps16k/arm64-v8a"); + Assert.AreEqual ("6.0.0", sysImg.Version); + Assert.AreEqual ("16 KB Page Size Google APIs ARM 64 v8a System Image", sysImg.Description); + + var sysImg361 = installed.Single (p => p.Path == "system-images/android-36.1/google_apis/arm64-v8a"); + Assert.AreEqual ("4.0.0", sysImg361.Version); + + // Available-section parsing requires the case-insensitive "Available packages:" match. + Assert.AreEqual (2, available.Count, "Should parse available packages under the lowercase header"); + var googleApis = available.Single (p => p.Path == "add-ons/addon-google_apis-google-10"); + Assert.AreEqual ("2.0.0", googleApis.Version); + Assert.IsFalse (googleApis.IsInstalled); + } + + [Test] + public void ParseSdkManagerList_AndroidCliWhitespaceFormat_ParsesUpdates () + { + var output = @"Installed packages: + platform-tools 37.0.1 Android SDK Platform-Tools +Available Updates: + ID Installed Available + platform-tools 37.0.1 37.0.2 +"; + + var (installed, available) = SdkManager.ParseSdkManagerList (output); + + Assert.AreEqual (1, installed.Count); + Assert.AreEqual ("37.0.1", installed.Single ().Version); + + var update = available.Single (); + Assert.AreEqual ("platform-tools", update.Path); + Assert.AreEqual ("37.0.2", update.Version); + Assert.IsNull (update.Description); + Assert.IsFalse (update.IsInstalled); + } + + [Test] + public void ParseSdkManagerList_SectionHeadersAreCaseInsensitive () + { + var output = @"INSTALLED PACKAGES: + platform-tools | 37.0.1 | Android SDK Platform-Tools +available packages: + platforms;android-37 | 1 | Android SDK Platform 37 +AVAILABLE UPDATES: + platform-tools | 37.0.1 | 37.0.2 +"; + + var (installed, available) = SdkManager.ParseSdkManagerList (output); + + Assert.AreEqual ("platform-tools", installed.Single ().Path); + Assert.AreEqual (2, available.Count); + Assert.AreEqual ("1", available.Single (p => p.Path == "platforms;android-37").Version); + Assert.AreEqual ("37.0.2", available.Single (p => p.Path == "platform-tools").Version); + } + [Test] public void FindSdkManagerPath_NullSdkPath_ReturnsNull () {