diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/AvdInfo.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/AvdInfo.cs
new file mode 100644
index 00000000..d627cfc1
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/AvdInfo.cs
@@ -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; }
+}
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs
index 8527ccd5..5ffdb970 100644
--- a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs
+++ b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs
@@ -230,6 +230,58 @@ internal static void ThrowIfFailed (int exitCode, string command, StringWriter?
ThrowIfFailed (exitCode, command, stderr?.ToString (), stdout?.ToString ());
}
+ ///
+ /// Validates that a string parameter is not null or empty.
+ ///
+ 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);
+ }
+
+ ///
+ /// Searches for a cmdline-tools binary in the SDK, preferring higher versioned directories.
+ /// Falls back to "latest" and then legacy "tools/bin".
+ ///
+ /// Root path to the Android SDK.
+ /// Tool binary name without extension (e.g., "avdmanager").
+ /// File extension including the dot (e.g., ".bat") or empty string for no extension.
+ 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 FindExecutablesInPath (string executable)
{
var path = Environment.GetEnvironmentVariable (EnvironmentVariableNames.Path) ?? "";
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs
new file mode 100644
index 00000000..03064c92
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs
@@ -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;
+
+///
+/// Runs Android Virtual Device Manager (avdmanager) commands.
+///
+public class AvdManagerRunner
+{
+ readonly string avdManagerPath;
+ readonly IDictionary? environmentVariables;
+
+ ///
+ /// Creates a new AvdManagerRunner with the full path to the avdmanager executable.
+ ///
+ /// Full path to avdmanager (e.g., "/path/to/sdk/cmdline-tools/latest/bin/avdmanager").
+ /// Optional environment variables to pass to avdmanager processes.
+ public AvdManagerRunner (string avdManagerPath, IDictionary? 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> 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 CreateAvdAsync (string name, string systemImage, string? deviceProfile = null,
+ bool force = false, CancellationToken cancellationToken = default)
+ {
+ ProcessUtils.ValidateNotNullOrEmpty (name, nameof (name));
+ ProcessUtils.ValidateNotNullOrEmpty (systemImage, nameof (systemImage));
+
+ // 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 { "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 ParseAvdListOutput (string output)
+ {
+ var avds = new List ();
+ 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;
+ }
+
+ ///
+ /// Resolves the AVD root directory, respecting ANDROID_AVD_HOME and ANDROID_USER_HOME.
+ /// Checks instance first, then falls back to process environment.
+ ///
+ 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");
+ }
+
+ ///
+ /// Looks up an environment variable, checking instance first,
+ /// then falling back to the process environment.
+ ///
+ 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;
+ }
+}
+
diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs
new file mode 100644
index 00000000..fd3053ce
--- /dev/null
+++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs
@@ -0,0 +1,257 @@
+// 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 NUnit.Framework;
+
+namespace Xamarin.Android.Tools.Tests;
+
+[TestFixture]
+public class AvdManagerRunnerTests
+{
+ [Test]
+ public void ParseAvdListOutput_MultipleAvds ()
+ {
+ var output =
+ "Available Android Virtual Devices:\n" +
+ " Name: Pixel_7_API_35\n" +
+ " Device: pixel_7 (Google)\n" +
+ " Path: /Users/test/.android/avd/Pixel_7_API_35.avd\n" +
+ " Target: Google APIs (Google Inc.)\n" +
+ " Based on: Android 15 Tag/ABI: google_apis/arm64-v8a\n" +
+ "---------\n" +
+ " Name: MAUI_Emulator\n" +
+ " Device: pixel_6 (Google)\n" +
+ " Path: /Users/test/.android/avd/MAUI_Emulator.avd\n" +
+ " Target: Google APIs (Google Inc.)\n" +
+ " Based on: Android 14 Tag/ABI: google_apis/x86_64\n";
+
+ var avds = AvdManagerRunner.ParseAvdListOutput (output);
+
+ Assert.AreEqual (2, avds.Count);
+
+ Assert.AreEqual ("Pixel_7_API_35", avds [0].Name);
+ Assert.AreEqual ("pixel_7 (Google)", avds [0].DeviceProfile);
+ Assert.AreEqual ("/Users/test/.android/avd/Pixel_7_API_35.avd", avds [0].Path);
+
+ Assert.AreEqual ("MAUI_Emulator", avds [1].Name);
+ Assert.AreEqual ("pixel_6 (Google)", avds [1].DeviceProfile);
+ Assert.AreEqual ("/Users/test/.android/avd/MAUI_Emulator.avd", avds [1].Path);
+ }
+
+ [Test]
+ public void ParseAvdListOutput_WindowsNewlines ()
+ {
+ var output =
+ "Available Android Virtual Devices:\r\n" +
+ " Name: Test_AVD\r\n" +
+ " Device: Nexus 5X (Google)\r\n" +
+ " Path: C:\\Users\\test\\.android\\avd\\Test_AVD.avd\r\n" +
+ " Target: Google APIs (Google Inc.)\r\n";
+
+ var avds = AvdManagerRunner.ParseAvdListOutput (output);
+
+ Assert.AreEqual (1, avds.Count);
+ Assert.AreEqual ("Test_AVD", avds [0].Name);
+ Assert.AreEqual ("Nexus 5X (Google)", avds [0].DeviceProfile);
+ Assert.AreEqual ("C:\\Users\\test\\.android\\avd\\Test_AVD.avd", avds [0].Path);
+ }
+
+ [Test]
+ public void ParseAvdListOutput_EmptyOutput ()
+ {
+ var avds = AvdManagerRunner.ParseAvdListOutput ("");
+ Assert.AreEqual (0, avds.Count);
+ }
+
+ [Test]
+ public void ParseAvdListOutput_NoAvds ()
+ {
+ var output = "Available Android Virtual Devices:\n";
+ var avds = AvdManagerRunner.ParseAvdListOutput (output);
+ Assert.AreEqual (0, avds.Count);
+ }
+
+ [Test]
+ public void ParseAvdListOutput_SingleAvdNoDevice ()
+ {
+ var output =
+ " Name: Minimal_AVD\n" +
+ " Path: /home/user/.android/avd/Minimal_AVD.avd\n";
+
+ var avds = AvdManagerRunner.ParseAvdListOutput (output);
+
+ Assert.AreEqual (1, avds.Count);
+ Assert.AreEqual ("Minimal_AVD", avds [0].Name);
+ Assert.IsNull (avds [0].DeviceProfile);
+ Assert.AreEqual ("/home/user/.android/avd/Minimal_AVD.avd", avds [0].Path);
+ }
+
+ [Test]
+ public void ParseAvdListOutput_ReturnsIReadOnlyList ()
+ {
+ var avds = AvdManagerRunner.ParseAvdListOutput ("");
+ Assert.IsInstanceOf> (avds);
+ }
+
+ [Test]
+ public void FindCmdlineTool_FindsVersionedDir ()
+ {
+ var tempDir = Path.Combine (Path.GetTempPath (), $"avd-test-{Path.GetRandomFileName ()}");
+ var binDir = Path.Combine (tempDir, "cmdline-tools", "12.0", "bin");
+ Directory.CreateDirectory (binDir);
+
+ try {
+ var avdMgrName = OS.IsWindows ? "avdmanager.bat" : "avdmanager";
+ File.WriteAllText (Path.Combine (binDir, avdMgrName), "");
+
+ var path = ProcessUtils.FindCmdlineTool (tempDir, "avdmanager", OS.IsWindows ? ".bat" : "");
+ Assert.IsNotNull (path);
+ Assert.IsTrue (path!.Contains ("12.0"));
+ } finally {
+ Directory.Delete (tempDir, true);
+ }
+ }
+
+ [Test]
+ public void FindCmdlineTool_PrefersHigherVersion ()
+ {
+ var tempDir = Path.Combine (Path.GetTempPath (), $"avd-test-{Path.GetRandomFileName ()}");
+ var avdMgrName = OS.IsWindows ? "avdmanager.bat" : "avdmanager";
+
+ var binDir10 = Path.Combine (tempDir, "cmdline-tools", "10.0", "bin");
+ var binDir12 = Path.Combine (tempDir, "cmdline-tools", "12.0", "bin");
+ Directory.CreateDirectory (binDir10);
+ Directory.CreateDirectory (binDir12);
+ File.WriteAllText (Path.Combine (binDir10, avdMgrName), "");
+ File.WriteAllText (Path.Combine (binDir12, avdMgrName), "");
+
+ try {
+ var path = ProcessUtils.FindCmdlineTool (tempDir, "avdmanager", OS.IsWindows ? ".bat" : "");
+ Assert.IsNotNull (path);
+ Assert.IsTrue (path!.Contains ("12.0"));
+ } finally {
+ Directory.Delete (tempDir, true);
+ }
+ }
+
+ [Test]
+ public void FindCmdlineTool_HandlesPreReleaseVersionDir ()
+ {
+ var tempDir = Path.Combine (Path.GetTempPath (), $"avd-test-{Path.GetRandomFileName ()}");
+ var avdMgrName = OS.IsWindows ? "avdmanager.bat" : "avdmanager";
+
+ // Pre-release "13.0-rc1" should be preferred over "12.0"
+ var binDir12 = Path.Combine (tempDir, "cmdline-tools", "12.0", "bin");
+ var binDirRc = Path.Combine (tempDir, "cmdline-tools", "13.0-rc1", "bin");
+ Directory.CreateDirectory (binDir12);
+ Directory.CreateDirectory (binDirRc);
+ File.WriteAllText (Path.Combine (binDir12, avdMgrName), "");
+ File.WriteAllText (Path.Combine (binDirRc, avdMgrName), "");
+
+ try {
+ var path = ProcessUtils.FindCmdlineTool (tempDir, "avdmanager", OS.IsWindows ? ".bat" : "");
+ Assert.IsNotNull (path);
+ Assert.IsTrue (path!.Contains ("13.0-rc1"), $"Expected 13.0-rc1, got: {path}");
+ } finally {
+ Directory.Delete (tempDir, true);
+ }
+ }
+
+ [Test]
+ public void FindCmdlineTool_FallsBackToLatest ()
+ {
+ var tempDir = Path.Combine (Path.GetTempPath (), $"avd-test-{Path.GetRandomFileName ()}");
+ var binDir = Path.Combine (tempDir, "cmdline-tools", "latest", "bin");
+ Directory.CreateDirectory (binDir);
+
+ try {
+ var avdMgrName = OS.IsWindows ? "avdmanager.bat" : "avdmanager";
+ File.WriteAllText (Path.Combine (binDir, avdMgrName), "");
+
+ var path = ProcessUtils.FindCmdlineTool (tempDir, "avdmanager", OS.IsWindows ? ".bat" : "");
+ Assert.IsNotNull (path);
+ Assert.IsTrue (path!.Contains ("latest"));
+ } finally {
+ Directory.Delete (tempDir, true);
+ }
+ }
+
+ [Test]
+ public void FindCmdlineTool_MissingSdk_ReturnsNull ()
+ {
+ var path = ProcessUtils.FindCmdlineTool ("/nonexistent/path", "avdmanager", OS.IsWindows ? ".bat" : "");
+ Assert.IsNull (path);
+ }
+
+ [Test]
+ public void Constructor_NullPath_ThrowsArgumentException ()
+ {
+ Assert.Throws (() => new AvdManagerRunner (null!));
+ }
+
+ [Test]
+ public void Constructor_EmptyPath_ThrowsArgumentException ()
+ {
+ Assert.Throws (() => new AvdManagerRunner (""));
+ }
+
+ [Test]
+ public void Constructor_WhitespacePath_ThrowsArgumentException ()
+ {
+ Assert.Throws (() => new AvdManagerRunner (" "));
+ }
+
+ [Test]
+ public void Constructor_AcceptsEnvironmentVariables ()
+ {
+ var env = new Dictionary { { "ANDROID_HOME", "/test/sdk" } };
+ var runner = new AvdManagerRunner ("/fake/avdmanager", env);
+ Assert.IsNotNull (runner);
+ }
+
+ [Test]
+ public void CreateAvdAsync_NullName_ThrowsArgumentNullException ()
+ {
+ var runner = new AvdManagerRunner ("/fake/avdmanager");
+ Assert.ThrowsAsync (() => runner.CreateAvdAsync (null!, "system-image"));
+ }
+
+ [Test]
+ public void CreateAvdAsync_EmptyName_ThrowsArgumentException ()
+ {
+ var runner = new AvdManagerRunner ("/fake/avdmanager");
+ Assert.ThrowsAsync (() => runner.CreateAvdAsync ("", "system-image"));
+ }
+
+ [Test]
+ public void CreateAvdAsync_NullSystemImage_ThrowsArgumentNullException ()
+ {
+ var runner = new AvdManagerRunner ("/fake/avdmanager");
+ Assert.ThrowsAsync (() => runner.CreateAvdAsync ("test-avd", null!));
+ }
+
+ [Test]
+ public void CreateAvdAsync_EmptySystemImage_ThrowsArgumentException ()
+ {
+ var runner = new AvdManagerRunner ("/fake/avdmanager");
+ Assert.ThrowsAsync (() => runner.CreateAvdAsync ("test-avd", ""));
+ }
+
+ [Test]
+ public void DeleteAvdAsync_NullName_ThrowsArgumentNullException ()
+ {
+ var runner = new AvdManagerRunner ("/fake/avdmanager");
+ Assert.ThrowsAsync (() => runner.DeleteAvdAsync (null!));
+ }
+
+ [Test]
+ public void DeleteAvdAsync_EmptyName_ThrowsArgumentException ()
+ {
+ var runner = new AvdManagerRunner ("/fake/avdmanager");
+ Assert.ThrowsAsync (() => runner.DeleteAvdAsync (""));
+ }
+}