Skip to content
Open
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
11 changes: 11 additions & 0 deletions src/Xamarin.Android.Tools.AndroidSdk/Models/AvdInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Xamarin.Android.Tools;

public class AvdInfo
{
public string Name { get; set; } = string.Empty;
public string? DeviceProfile { get; set; }
public string? Path { get; set; }
}
52 changes: 52 additions & 0 deletions src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,58 @@ internal static void ThrowIfFailed (int exitCode, string command, StringWriter?
ThrowIfFailed (exitCode, command, stderr?.ToString (), stdout?.ToString ());
}

/// <summary>
/// Validates that a string parameter is not null or empty.
/// </summary>
internal static void ValidateNotNullOrEmpty (string? value, string paramName)
{
if (value is null)
throw new ArgumentNullException (paramName);
if (value.Length == 0)
throw new ArgumentException ("Value cannot be an empty string.", paramName);
}

/// <summary>
/// Searches for a cmdline-tools binary in the SDK, preferring higher versioned directories.
/// Falls back to "latest" and then legacy "tools/bin".
/// </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>
internal static string? FindCmdlineTool (string sdkPath, string toolName, string extension)
{
var cmdlineToolsDir = Path.Combine (sdkPath, "cmdline-tools");

if (Directory.Exists (cmdlineToolsDir)) {
var subdirs = new List<(string name, Version version)> ();
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 ('-');
if (dashIndex >= 0)
versionStr = name.Substring (0, dashIndex);
Version.TryParse (versionStr, out var v);
subdirs.Add ((name, v ?? new Version (0, 0)));
}
subdirs.Sort ((a, b) => b.version.CompareTo (a.version));

foreach (var (name, _) in subdirs) {
var toolPath = Path.Combine (cmdlineToolsDir, name, "bin", toolName + extension);
if (File.Exists (toolPath))
return toolPath;
}
var latestPath = Path.Combine (cmdlineToolsDir, "latest", "bin", toolName + extension);
if (File.Exists (latestPath))
return latestPath;
}

var legacyPath = Path.Combine (sdkPath, "tools", "bin", toolName + extension);
return File.Exists (legacyPath) ? legacyPath : null;
}

internal static IEnumerable<string> FindExecutablesInPath (string executable)
{
var path = Environment.GetEnvironmentVariable (EnvironmentVariableNames.Path) ?? "";
Expand Down
183 changes: 183 additions & 0 deletions src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// 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;

namespace Xamarin.Android.Tools;

/// <summary>
/// Runs Android Virtual Device Manager (avdmanager) commands.
/// </summary>
public class AvdManagerRunner
{
readonly string avdManagerPath;
readonly IDictionary<string, string>? environmentVariables;

/// <summary>
/// Creates a new AvdManagerRunner with the full path to the avdmanager executable.
/// </summary>
/// <param name="avdManagerPath">Full path to avdmanager (e.g., "/path/to/sdk/cmdline-tools/latest/bin/avdmanager").</param>
/// <param name="environmentVariables">Optional environment variables to pass to avdmanager processes.</param>
public AvdManagerRunner (string avdManagerPath, IDictionary<string, string>? environmentVariables = null)
{
if (string.IsNullOrWhiteSpace (avdManagerPath))
throw new ArgumentException ("Path to avdmanager must not be empty.", nameof (avdManagerPath));
this.avdManagerPath = avdManagerPath;
this.environmentVariables = environmentVariables;
}

public async Task<IReadOnlyList<AvdInfo>> ListAvdsAsync (CancellationToken cancellationToken = default)
{
using var stdout = new StringWriter ();
using var stderr = new StringWriter ();
var psi = ProcessUtils.CreateProcessStartInfo (avdManagerPath, "list", "avd");
var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);

ProcessUtils.ThrowIfFailed (exitCode, "avdmanager list avd", stderr);

return ParseAvdListOutput (stdout.ToString ());
}

public async Task<AvdInfo> CreateAvdAsync (string name, string systemImage, string? deviceProfile = null,
bool force = false, CancellationToken cancellationToken = default)
{
Comment on lines +46 to +48
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description/API surface says CreateAvdAsync returns Task (void), but the implementation returns Task (and returns an existing AVD when force=false). Either update the PR description/API docs or adjust the method signature/behavior so the public API matches what’s documented.

Copilot uses AI. Check for mistakes.
ProcessUtils.ValidateNotNullOrEmpty (name, nameof (name));
ProcessUtils.ValidateNotNullOrEmpty (systemImage, nameof (systemImage));
Comment on lines +49 to +50
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ValidateNotNullOrEmpty allows whitespace-only values, so CreateAvdAsync(" ", ...) will proceed and likely fail later with a less actionable avdmanager error. Since the constructor uses IsNullOrWhiteSpace, consider aligning these validations (either check whitespace here or rename the helper to reflect its behavior).

Suggested change
ProcessUtils.ValidateNotNullOrEmpty (name, nameof (name));
ProcessUtils.ValidateNotNullOrEmpty (systemImage, nameof (systemImage));
if (string.IsNullOrWhiteSpace (name))
throw new ArgumentException ("AVD name cannot be null, empty, or whitespace.", nameof (name));
if (string.IsNullOrWhiteSpace (systemImage))
throw new ArgumentException ("System image cannot be null, empty, or whitespace.", nameof (systemImage));

Copilot uses AI. Check for mistakes.

// Check if AVD already exists — return it instead of failing
if (!force) {
var existing = (await ListAvdsAsync (cancellationToken).ConfigureAwait (false))
.FirstOrDefault (a => string.Equals (a.Name, name, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
return existing;

// Detect orphaned AVD directory (folder exists without .ini registration).
var avdDir = Path.Combine (GetAvdRootDirectory (), $"{name}.avd");
if (Directory.Exists (avdDir))
force = true;
}

var args = new List<string> { "create", "avd", "-n", name, "-k", systemImage };
if (deviceProfile is { Length: > 0 })
args.AddRange (new [] { "-d", deviceProfile });
if (force)
args.Add ("--force");

using var stdout = new StringWriter ();
using var stderr = new StringWriter ();
var psi = ProcessUtils.CreateProcessStartInfo (avdManagerPath, args.ToArray ());
psi.RedirectStandardInput = true;

// avdmanager prompts "Do you wish to create a custom hardware profile?" — answer "no"
var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables,
onStarted: p => {
try {
p.StandardInput.WriteLine ("no");
p.StandardInput.Close ();
} catch (IOException) {
// Process may have already exited
}
}).ConfigureAwait (false);

ProcessUtils.ThrowIfFailed (exitCode, $"avdmanager create avd -n {name}", stderr, stdout);

// Re-list to get the actual path from avdmanager (respects ANDROID_USER_HOME/ANDROID_AVD_HOME)
var avds = await ListAvdsAsync (cancellationToken).ConfigureAwait (false);
var created = avds.FirstOrDefault (a => string.Equals (a.Name, name, StringComparison.OrdinalIgnoreCase));
if (created is not null)
return created;

// Fallback if re-list didn't find it
return new AvdInfo {
Name = name,
DeviceProfile = deviceProfile,
Path = Path.Combine (GetAvdRootDirectory (), $"{name}.avd"),
};
}

public async Task DeleteAvdAsync (string name, CancellationToken cancellationToken = default)
{
ProcessUtils.ValidateNotNullOrEmpty (name, nameof (name));

// Idempotent: if the AVD doesn't exist, treat as success
var avds = await ListAvdsAsync (cancellationToken).ConfigureAwait (false);
if (!avds.Any (a => string.Equals (a.Name, name, StringComparison.OrdinalIgnoreCase)))
return;

using var stderr = new StringWriter ();
var psi = ProcessUtils.CreateProcessStartInfo (avdManagerPath, "delete", "avd", "--name", name);
var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);

ProcessUtils.ThrowIfFailed (exitCode, $"avdmanager delete avd --name {name}", stderr);
}

internal static IReadOnlyList<AvdInfo> ParseAvdListOutput (string output)
{
var avds = new List<AvdInfo> ();
string? currentName = null, currentDevice = null, currentPath = null;

foreach (var line in output.Split ('\n')) {
var trimmed = line.Trim ();
if (trimmed.StartsWith ("Name:", StringComparison.OrdinalIgnoreCase)) {
if (currentName is not null)
avds.Add (new AvdInfo { Name = currentName, DeviceProfile = currentDevice, Path = currentPath });
currentName = trimmed.Substring (5).Trim ();
currentDevice = currentPath = null;
}
else if (trimmed.StartsWith ("Device:", StringComparison.OrdinalIgnoreCase))
currentDevice = trimmed.Substring (7).Trim ();
else if (trimmed.StartsWith ("Path:", StringComparison.OrdinalIgnoreCase))
currentPath = trimmed.Substring (5).Trim ();
}

if (currentName is not null)
avds.Add (new AvdInfo { Name = currentName, DeviceProfile = currentDevice, Path = currentPath });

return avds;
}

/// <summary>
/// Resolves the AVD root directory, respecting ANDROID_AVD_HOME and ANDROID_USER_HOME.
/// Checks instance <see cref="environmentVariables"/> first, then falls back to process environment.
/// </summary>
string GetAvdRootDirectory ()
{
// ANDROID_AVD_HOME takes highest priority
if (TryGetEnvironmentVariable (EnvironmentVariableNames.AndroidAvdHome, out var avdHome))
return avdHome;

// ANDROID_USER_HOME/avd is the next option
if (TryGetEnvironmentVariable (EnvironmentVariableNames.AndroidUserHome, out var userHome))
return Path.Combine (userHome, "avd");

// Default: ~/.android/avd
return Path.Combine (
Environment.GetFolderPath (Environment.SpecialFolder.UserProfile),
".android", "avd");
}

/// <summary>
/// Looks up an environment variable, checking instance <see cref="environmentVariables"/> first,
/// then falling back to the process environment.
/// </summary>
bool TryGetEnvironmentVariable (string name, out string value)
{
if (environmentVariables is not null && environmentVariables.TryGetValue (name, out var dictValue) && dictValue is { Length: > 0 }) {
value = dictValue;
return true;
}
var envValue = Environment.GetEnvironmentVariable (name);
if (envValue is { Length: > 0 }) {
value = envValue;
return true;
}
value = string.Empty;
return false;
}
}

Loading