Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ namespace Xamarin.Android.Tools;
public partial class SdkManager
{
const string LatestCommandLineToolsPackage = "cmdline-tools;latest";
const string LatestCommandLineToolsAndroidCliPackage = "cmdline-tools/latest";

/// <summary>
/// Finds <c>sdkmanager</c> in <c>cmdline-tools/latest/bin</c>, or falls back to
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<SdkBootstrapProgress>
{
readonly IProgress<SdkBootstrapProgress> progress;
Expand Down
36 changes: 32 additions & 4 deletions src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Packages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;

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<SdkPackage> Installed, IReadOnlyList<SdkPackage> Available)> ListAsync (CancellationToken cancellationToken = default)
{
var sdkManagerPath = RequireSdkManagerPath ();
Expand Down Expand Up @@ -82,14 +90,34 @@ internal static (IReadOnlyList<SdkPackage> Installed, IReadOnlyList<SdkPackage>
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);
Comment thread
jonathanpeppers marked this conversation as resolved.
if (parsingUpdates && string.Equals (parts [0].Trim (), "ID", StringComparison.Ordinal))
continue;
var versionIndex = parsingUpdates ? 2 : 1;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ()
{
Expand Down
91 changes: 91 additions & 0 deletions tests/Xamarin.Android.Tools.AndroidSdk-Tests/SdkManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ()
{
Expand Down
Loading