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
@@ -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<TraceLevel, string>? 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<Candidate> FindCandidates (
string cmdlineToolsDir,
string toolName,
string extension,
Action<TraceLevel, string>? logger)
{
var candidates = new List<Candidate> ();
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<TraceLevel, string>? 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<ParsedRevision>
{
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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Information about an installed Android SDK command-line tool.</summary>
public sealed class CommandLineTool
{
/// <summary>Gets the full path to the requested executable.</summary>
public string Path { get; }

/// <summary>
/// Gets the installed command-line tools revision from <c>source.properties</c>,
/// or the versioned directory name when package metadata is unavailable.
/// </summary>
public string? Revision { get; }

/// <summary>Creates information for a resolved Android SDK command-line tool.</summary>
/// <param name="path">The full path to the requested executable.</param>
/// <param name="revision">The installed command-line tools revision, when available.</param>
/// <exception cref="ArgumentNullException"><paramref name="path"/> is <see langword="null"/>.</exception>
public CommandLineTool (string path, string? revision = null)
{
if (path is null)
throw new ArgumentNullException (nameof (path));

Path = path;
Revision = revision;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,9 @@ public enum SdkBootstrapPhase
/// <summary>Extracting the archive.</summary>
Extracting,
/// <summary>Bootstrap completed successfully.</summary>
Complete
Complete,
/// <summary>Checking Google's package catalog for a newer command-line tools revision.</summary>
CheckingForUpdates,
/// <summary>Installing the current command-line tools package.</summary>
Installing
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@

namespace Xamarin.Android.Tools;

/// <summary>Progress information for SDK bootstrap operations.</summary>
/// <summary>Progress information for SDK bootstrap and command-line tools refresh operations.</summary>
public record SdkBootstrapProgress (SdkBootstrapPhase Phase, int PercentComplete = -1, string Message = "");
Original file line number Diff line number Diff line change
Expand Up @@ -246,59 +246,20 @@ internal static void ThrowIfFailed (int exitCode, string command, StringWriter?

/// <summary>
/// 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.
/// </summary>
/// <param name="sdkPath">Root path to the Android SDK.</param>
/// <param name="toolName">Tool binary name without extension (e.g., "avdmanager").</param>
/// <param name="extension">File extension including the dot (e.g., ".bat") or empty string for no extension.</param>
/// <param name="logger">Optional logger for diagnostic messages.</param>
internal static string? FindCmdlineTool (string sdkPath, string toolName, string extension, Action<TraceLevel, string>? 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<string> FindExecutablesInPath (string executable)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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<Xamarin.Android.Tools.SdkBootstrapProgress!>? 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<Xamarin.Android.Tools.SdkBootstrapProgress!>? progress = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Xamarin.Android.Tools.CommandLineTool!>!
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<System.Collections.Generic.IReadOnlyList<Xamarin.Android.Tools.SdkLicense!>!>!
Xamarin.Android.Tools.SdkManager.InstallAsync(System.Collections.Generic.IEnumerable<string!>! packages, bool acceptLicenses = true, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Expand Down
Loading
Loading