From 0bc684b93eba8ce8c6aa98177d7cac1a900c104a Mon Sep 17 00:00:00 2001 From: Mauro Agnoletti Date: Thu, 20 Aug 2026 14:19:09 -0400 Subject: [PATCH 1/4] [Xamarin.Android.Tools.AndroidSdk] Parse the new Android CLI `sdkmanager --list` format ## Issue `sdkmanager` is deprecated in cmdline-tools 23+ and replaced by the new "Android CLI", whose `--list` output has a different shape than the classic tool: * a multi-line deprecation banner precedes the sections; * packages are printed as whitespace-aligned columns instead of the classic `path | version | description` pipe-delimited table; and * the section header is lowercase `Available packages:` (the classic tool emitted `Available Packages:`). `ParseSdkManagerList` only understood the pipe-delimited table. Against the new tool it split each package row on `|`, got a single column, decided the row had too few fields, and skipped it -- so *every* package line was dropped and the returned installed-package list came back empty even though packages were installed. The lowercase `Available packages:` header was additionally missed because the header comparison was case-sensitive. ## Impact on the .NET MAUI VS Code extension This surfaced as a user-visible bug in the .NET MAUI extension for VS Code, which is how it was found. To decide whether the Android emulator/AVD prerequisites are satisfied, the extension asks the MAUI CLI to check installed Android SDK components, and the CLI uses this library to parse `sdkmanager --list`. With the empty parse result the extension concluded that no system images were installed and showed a false "AVD missing components" warning in the Android language status item, offering an "Install" action. Running that check reported success, yet the warning never cleared -- because the underlying `--list` parse kept returning nothing on the new Android CLI. Users on cmdline-tools 23+ hit this even with a fully configured SDK. ## Fix Both changes are backward-compatible with the classic tool: * when a package row contains no `|`, split it on runs of two-or-more spaces (the new whitespace-aligned columns), keeping the pipe split for classic output; and * match the section headers case-insensitively so `Available packages:` is recognized regardless of casing. Added an NUnit regression test built from real Android CLI output (deprecation banner + lowercase header + whitespace columns, including a system-image id with an internal `/` and a multi-word "16 KB Page Size ..." description) asserting that both installed and available packages parse correctly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 05e54498-1ff0-4f5c-8d8d-0edcf8526596 --- .../SdkManager.Packages.cs | 20 +++++-- .../SdkManagerTests.cs | 54 +++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Packages.cs b/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Packages.cs index b72fc6aed45..acfa0bcd2d2 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; @@ -82,14 +83,25 @@ 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). + if (trimmed.IndexOf ("Installed packages:", StringComparison.OrdinalIgnoreCase) >= 0) { target = installed; parsingUpdates = false; continue; } + if (trimmed.IndexOf ("Available Packages:", StringComparison.OrdinalIgnoreCase) >= 0) { target = available; parsingUpdates = false; continue; } + if (trimmed.IndexOf ("Available Updates:", StringComparison.OrdinalIgnoreCase) >= 0) { target = available; parsingUpdates = true; 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 ('|') + : Regex.Split (trimmed, @"\s{2,}"); 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/SdkManagerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs index b70c9cbf95b..a97640118f2 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs @@ -215,6 +215,60 @@ 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.FirstOrDefault (p => p.Path == "build-tools/36.0.0"); + Assert.IsNotNull (buildTools, "build-tools/36.0.0 should be parsed"); + 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.FirstOrDefault (p => p.Path == "system-images/android-37.0/google_apis_ps16k/arm64-v8a"); + Assert.IsNotNull (sysImg, "16 KB page-size system image should be parsed"); + 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.FirstOrDefault (p => p.Path == "system-images/android-36.1/google_apis/arm64-v8a"); + Assert.IsNotNull (sysImg361); + 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.FirstOrDefault (p => p.Path == "add-ons/addon-google_apis-google-10"); + Assert.IsNotNull (googleApis); + Assert.AreEqual ("2.0.0", googleApis!.Version); + Assert.IsFalse (googleApis.IsInstalled); + } + [Test] public void FindSdkManagerPath_NullSdkPath_ReturnsNull () { From 685974999538880db154f9a9d42fbe6d785b8c8e Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 20 Aug 2026 16:48:00 -0500 Subject: [PATCH 2/4] Address sdkmanager parser review feedback Reuse parser metadata and the whitespace separator regex, and avoid new null-forgiving operators in the regression test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8a292f53-56d7-4219-9179-21db5b2fc4f1 --- .../SdkManager.Packages.cs | 24 +++++++++++++++---- .../SdkManagerTests.cs | 20 +++++++--------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Packages.cs b/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Packages.cs index acfa0bcd2d2..d0be29f240c 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Packages.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Packages.cs @@ -13,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 (); @@ -86,9 +93,18 @@ internal static (IReadOnlyList Installed, IReadOnlyList // 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). - if (trimmed.IndexOf ("Installed packages:", StringComparison.OrdinalIgnoreCase) >= 0) { target = installed; parsingUpdates = false; continue; } - if (trimmed.IndexOf ("Available Packages:", StringComparison.OrdinalIgnoreCase) >= 0) { target = available; parsingUpdates = false; continue; } - if (trimmed.IndexOf ("Available Updates:", StringComparison.OrdinalIgnoreCase) >= 0) { target = available; parsingUpdates = true; continue; } + 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; @@ -101,7 +117,7 @@ internal static (IReadOnlyList Installed, IReadOnlyList // (pipe split yields a single column), producing an empty installed list. var parts = trimmed.IndexOf ('|') >= 0 ? trimmed.Split ('|') - : Regex.Split (trimmed, @"\s{2,}"); + : 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/SdkManagerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs index a97640118f2..d45700e4c40 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs @@ -244,28 +244,24 @@ emulator 37.1.11 Android Emu Assert.AreEqual (7, installed.Count, "Should parse all 7 whitespace-aligned installed packages"); - var buildTools = installed.FirstOrDefault (p => p.Path == "build-tools/36.0.0"); - Assert.IsNotNull (buildTools, "build-tools/36.0.0 should be parsed"); - Assert.AreEqual ("36.0.0", buildTools!.Version); + 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.FirstOrDefault (p => p.Path == "system-images/android-37.0/google_apis_ps16k/arm64-v8a"); - Assert.IsNotNull (sysImg, "16 KB page-size system image should be parsed"); - Assert.AreEqual ("6.0.0", sysImg!.Version); + 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.FirstOrDefault (p => p.Path == "system-images/android-36.1/google_apis/arm64-v8a"); - Assert.IsNotNull (sysImg361); - Assert.AreEqual ("4.0.0", sysImg361!.Version); + 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.FirstOrDefault (p => p.Path == "add-ons/addon-google_apis-google-10"); - Assert.IsNotNull (googleApis); - Assert.AreEqual ("2.0.0", googleApis!.Version); + 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); } From d4fdacc2bb74779257588eb15f205a5ce26c9e1d Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 20 Aug 2026 16:49:37 -0500 Subject: [PATCH 3/4] Expand sdkmanager parser coverage Cover whitespace-formatted updates and case-insensitive matching for every package section header. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8a292f53-56d7-4219-9179-21db5b2fc4f1 --- .../SdkManagerTests.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs index d45700e4c40..0e2e628eb7a 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs @@ -265,6 +265,47 @@ emulator 37.1.11 Android Emu 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 () { From bec64ae07d38ed030a789957c7760983048f5369 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 21 Aug 2026 08:09:32 -0500 Subject: [PATCH 4/4] Handle Android CLI command-line tools ID Recognize both sdkmanager's semicolon package ID and Android CLI's slash package ID when resolving the latest command-line tools package. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8a292f53-56d7-4219-9179-21db5b2fc4f1 --- .../SdkManager.CommandLineTools.cs | 9 +++++- .../CommandLineToolsResolverTests.cs | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) 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/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 () {