diff --git a/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs b/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs
index 272a17b1..81ff0325 100644
--- a/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs
+++ b/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs
@@ -40,5 +40,17 @@ internal static class EnvironmentVariableNames
/// Executable file extensions (Windows).
///
public const string PathExt = "PATHEXT";
+
+ ///
+ /// Overrides the default location for Android user-specific data
+ /// (AVDs, preferences, etc.). Defaults to $HOME/.android.
+ ///
+ public const string AndroidUserHome = "ANDROID_USER_HOME";
+
+ ///
+ /// Overrides the AVD storage directory.
+ /// Defaults to $ANDROID_USER_HOME/avd or $HOME/.android/avd.
+ ///
+ public const string AndroidAvdHome = "ANDROID_AVD_HOME";
}
}
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceInfo.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceInfo.cs
new file mode 100644
index 00000000..a14941ee
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceInfo.cs
@@ -0,0 +1,62 @@
+// 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;
+
+///
+/// Represents an Android device or emulator from 'adb devices -l' output.
+/// Mirrors the metadata produced by dotnet/android's GetAvailableAndroidDevices task.
+///
+public class AdbDeviceInfo
+{
+ ///
+ /// Serial number of the device (e.g., "emulator-5554", "0A041FDD400327").
+ /// For non-running emulators, this is the AVD name.
+ ///
+ public string Serial { get; set; } = string.Empty;
+
+ ///
+ /// Human-friendly description of the device (e.g., "Pixel 7 API 35", "Pixel 6 Pro").
+ ///
+ public string Description { get; set; } = string.Empty;
+
+ ///
+ /// Device type: Device or Emulator.
+ ///
+ public AdbDeviceType Type { get; set; }
+
+ ///
+ /// Device status: Online, Offline, Unauthorized, NoPermissions, NotRunning, Unknown.
+ ///
+ public AdbDeviceStatus Status { get; set; }
+
+ ///
+ /// AVD name for emulators (e.g., "pixel_7_api_35"). Null for physical devices.
+ ///
+ public string? AvdName { get; set; }
+
+ ///
+ /// Device model from adb properties (e.g., "Pixel_6_Pro").
+ ///
+ public string? Model { get; set; }
+
+ ///
+ /// Product name from adb properties (e.g., "raven").
+ ///
+ public string? Product { get; set; }
+
+ ///
+ /// Device code name from adb properties (e.g., "raven").
+ ///
+ public string? Device { get; set; }
+
+ ///
+ /// Transport ID from adb properties.
+ ///
+ public string? TransportId { get; set; }
+
+ ///
+ /// Whether this device is an emulator.
+ ///
+ public bool IsEmulator => Type == AdbDeviceType.Emulator;
+}
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceStatus.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceStatus.cs
new file mode 100644
index 00000000..bfff7733
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceStatus.cs
@@ -0,0 +1,17 @@
+// 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;
+
+///
+/// Represents the status of an Android device.
+///
+public enum AdbDeviceStatus
+{
+ Online,
+ Offline,
+ Unauthorized,
+ NoPermissions,
+ NotRunning,
+ Unknown
+}
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceType.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceType.cs
new file mode 100644
index 00000000..db5b22e9
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceType.cs
@@ -0,0 +1,13 @@
+// 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;
+
+///
+/// Represents the type of an Android device.
+///
+public enum AdbDeviceType
+{
+ Device,
+ Emulator
+}
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 28113d72..18b04a38 100644
--- a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs
+++ b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs
@@ -203,6 +203,78 @@ static string JoinArguments (string[] args)
}
#endif
+ ///
+ /// Throws when is non-zero.
+ /// Includes stderr/stdout context in the message when available.
+ ///
+ internal static void ThrowIfFailed (int exitCode, string command, string? stderr = null, string? stdout = null)
+ {
+ if (exitCode == 0)
+ return;
+
+ var message = $"'{command}' failed with exit code {exitCode}.";
+
+ if (stderr is { Length: > 0 })
+ message += $" stderr:{Environment.NewLine}{stderr.Trim ()}";
+ if (stdout is { Length: > 0 })
+ message += $" stdout:{Environment.NewLine}{stdout.Trim ()}";
+
+ throw new InvalidOperationException (message);
+ }
+
+ internal static void ThrowIfFailed (int exitCode, string command, StringWriter? stderr, StringWriter? stdout = null)
+ {
+ ThrowIfFailed (exitCode, command, stderr?.ToString (), stdout?.ToString ());
+ }
+
+ ///
+ /// Validates that is not null or empty.
+ /// Throws for null values and
+ /// for empty strings.
+ ///
+ 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 versioned cmdline-tools directories (descending) and "latest" for a specific tool binary.
+ /// Falls back to the legacy tools/bin path. Returns null if not found.
+ ///
+ 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;
+ Version.TryParse (name, out var v);
+ subdirs.Add ((name, v ?? new Version (0, 0)));
+ }
+ subdirs.Sort ((a, b) => b.version!.CompareTo (a.version));
+
+ // Check versioned directories first (highest version first), then "latest"
+ 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;
+ }
+
+ // Legacy fallback: tools/bin/
+ 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/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs
new file mode 100644
index 00000000..4bccce8e
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs
@@ -0,0 +1,315 @@
+// 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.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Xamarin.Android.Tools;
+
+///
+/// Runs Android Debug Bridge (adb) commands.
+/// Parsing logic ported from dotnet/android GetAvailableAndroidDevices task.
+///
+public class AdbRunner
+{
+ readonly Func getSdkPath;
+
+ // Pattern to match device lines: [key:value ...]
+ // Ported from dotnet/android GetAvailableAndroidDevices.AdbDevicesRegex
+ static readonly Regex AdbDevicesRegex = new Regex (
+ @"^([^\s]+)\s+(device|offline|unauthorized|no permissions)\s*(.*)$", RegexOptions.Compiled);
+ static readonly Regex ApiRegex = new Regex (@"\bApi\b", RegexOptions.Compiled);
+
+ public AdbRunner (Func getSdkPath)
+ {
+ this.getSdkPath = getSdkPath ?? throw new ArgumentNullException (nameof (getSdkPath));
+ }
+
+ public string? AdbPath {
+ get {
+ var sdkPath = getSdkPath ();
+ if (!string.IsNullOrEmpty (sdkPath)) {
+ var ext = OS.IsWindows ? ".exe" : "";
+ var sdkAdb = Path.Combine (sdkPath, "platform-tools", "adb" + ext);
+ if (File.Exists (sdkAdb))
+ return sdkAdb;
+ }
+ return ProcessUtils.FindExecutablesInPath ("adb").FirstOrDefault ();
+ }
+ }
+
+ public bool IsAvailable => AdbPath is not null;
+
+ string RequireAdb ()
+ {
+ return AdbPath ?? throw new InvalidOperationException ("ADB not found.");
+ }
+
+ ProcessStartInfo CreateAdbProcess (string adbPath, params string [] args)
+ {
+ var psi = ProcessUtils.CreateProcessStartInfo (adbPath, args);
+ AndroidEnvironmentHelper.ConfigureEnvironment (psi, getSdkPath (), null);
+ return psi;
+ }
+
+ ///
+ /// Lists connected devices using 'adb devices -l'.
+ /// For emulators, queries the AVD name using 'adb -s <serial> emu avd name'.
+ ///
+ public async Task> ListDevicesAsync (CancellationToken cancellationToken = default)
+ {
+ var adb = RequireAdb ();
+ using var stdout = new StringWriter ();
+ using var stderr = new StringWriter ();
+ var psi = CreateAdbProcess (adb, "devices", "-l");
+ var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken).ConfigureAwait (false);
+
+ ProcessUtils.ThrowIfFailed (exitCode, "adb devices -l", stderr.ToString ());
+
+ var devices = ParseAdbDevicesOutput (stdout.ToString ());
+
+ // For each emulator, try to get the AVD name
+ foreach (var device in devices) {
+ if (device.Type == AdbDeviceType.Emulator) {
+ device.AvdName = await GetEmulatorAvdNameAsync (adb, device.Serial, cancellationToken).ConfigureAwait (false);
+ device.Description = BuildDeviceDescription (device);
+ }
+ }
+
+ return devices;
+ }
+
+ ///
+ /// Queries the emulator for its AVD name using 'adb -s <serial> emu avd name'.
+ /// Ported from dotnet/android GetAvailableAndroidDevices.GetEmulatorAvdName.
+ ///
+ public async Task GetEmulatorAvdNameAsync (string adbPath, string serial, CancellationToken cancellationToken = default)
+ {
+ try {
+ using var stdout = new StringWriter ();
+ var psi = CreateAdbProcess (adbPath, "-s", serial, "emu", "avd", "name");
+ await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken).ConfigureAwait (false);
+
+ foreach (var line in stdout.ToString ().Split ('\n')) {
+ var trimmed = line.Trim ();
+ if (!string.IsNullOrEmpty (trimmed) &&
+ !string.Equals (trimmed, "OK", StringComparison.OrdinalIgnoreCase)) {
+ return trimmed;
+ }
+ }
+ } catch (OperationCanceledException) {
+ throw;
+ } catch (Exception) {
+ // Expected: emulator may not support 'emu avd name' command
+ }
+
+ return null;
+ }
+
+ public async Task WaitForDeviceAsync (string? serial = null, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
+ {
+ var adb = RequireAdb ();
+ var effectiveTimeout = timeout ?? TimeSpan.FromSeconds (60);
+
+ var args = string.IsNullOrEmpty (serial)
+ ? new [] { "wait-for-device" }
+ : new [] { "-s", serial, "wait-for-device" };
+
+ var psi = CreateAdbProcess (adb, args);
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken);
+ cts.CancelAfter (effectiveTimeout);
+
+ try {
+ using var stderr = new StringWriter ();
+ var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cts.Token).ConfigureAwait (false);
+ ProcessUtils.ThrowIfFailed (exitCode, "adb wait-for-device", stderr.ToString ());
+ } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) {
+ throw new TimeoutException ($"Timed out waiting for device after {effectiveTimeout.TotalSeconds}s.");
+ }
+ }
+
+ public async Task StopEmulatorAsync (string serial, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace (serial))
+ throw new ArgumentException ("Serial must not be empty.", nameof (serial));
+
+ var adb = RequireAdb ();
+ using var stderr = new StringWriter ();
+ var psi = CreateAdbProcess (adb, "-s", serial, "emu", "kill");
+ var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken).ConfigureAwait (false);
+
+ ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} emu kill", stderr.ToString ());
+ }
+
+ ///
+ /// Parses the output of 'adb devices -l'.
+ /// Ported from dotnet/android GetAvailableAndroidDevices.ParseAdbDevicesOutput.
+ ///
+ public static List ParseAdbDevicesOutput (string output)
+ {
+ var devices = new List ();
+
+ foreach (var line in output.Split ('\n')) {
+ var trimmed = line.Trim ();
+ if (string.IsNullOrEmpty (trimmed) || trimmed.IndexOf ("List of devices", StringComparison.OrdinalIgnoreCase) >= 0)
+ continue;
+
+ var match = AdbDevicesRegex.Match (trimmed);
+ if (!match.Success)
+ continue;
+
+ var serial = match.Groups [1].Value.Trim ();
+ var state = match.Groups [2].Value.Trim ();
+ var properties = match.Groups [3].Value.Trim ();
+
+ // Parse key:value pairs from the properties string
+ var propDict = new Dictionary (StringComparer.OrdinalIgnoreCase);
+ if (!string.IsNullOrEmpty (properties)) {
+ var pairs = properties.Split (new [] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
+ foreach (var pair in pairs) {
+ var colonIndex = pair.IndexOf (':');
+ if (colonIndex > 0 && colonIndex < pair.Length - 1) {
+ var key = pair.Substring (0, colonIndex);
+ var value = pair.Substring (colonIndex + 1);
+ propDict [key] = value;
+ }
+ }
+ }
+
+ var deviceType = serial.StartsWith ("emulator-", StringComparison.OrdinalIgnoreCase)
+ ? AdbDeviceType.Emulator
+ : AdbDeviceType.Device;
+
+ var device = new AdbDeviceInfo {
+ Serial = serial,
+ Type = deviceType,
+ Status = MapAdbStateToStatus (state),
+ };
+
+ if (propDict.TryGetValue ("model", out var model))
+ device.Model = model;
+ if (propDict.TryGetValue ("product", out var product))
+ device.Product = product;
+ if (propDict.TryGetValue ("device", out var deviceCodeName))
+ device.Device = deviceCodeName;
+ if (propDict.TryGetValue ("transport_id", out var transportId))
+ device.TransportId = transportId;
+
+ // Build description (will be updated later if emulator AVD name is available)
+ device.Description = BuildDeviceDescription (device);
+
+ devices.Add (device);
+ }
+
+ return devices;
+ }
+
+ ///
+ /// Maps adb device states to status values.
+ /// Ported from dotnet/android GetAvailableAndroidDevices.MapAdbStateToStatus.
+ ///
+ public static AdbDeviceStatus MapAdbStateToStatus (string adbState)
+ {
+ switch (adbState.ToLowerInvariant ()) {
+ case "device": return AdbDeviceStatus.Online;
+ case "offline": return AdbDeviceStatus.Offline;
+ case "unauthorized": return AdbDeviceStatus.Unauthorized;
+ case "no permissions": return AdbDeviceStatus.NoPermissions;
+ default: return AdbDeviceStatus.Unknown;
+ }
+ }
+
+ ///
+ /// Builds a human-friendly description for a device.
+ /// Priority: AVD name (for emulators) > model > product > device > serial.
+ /// Ported from dotnet/android GetAvailableAndroidDevices.BuildDeviceDescription.
+ ///
+ public static string BuildDeviceDescription (AdbDeviceInfo device)
+ {
+ if (device.Type == AdbDeviceType.Emulator && !string.IsNullOrEmpty (device.AvdName))
+ return FormatDisplayName (device.AvdName!);
+
+ if (!string.IsNullOrEmpty (device.Model))
+ return device.Model!.Replace ('_', ' ');
+
+ if (!string.IsNullOrEmpty (device.Product))
+ return device.Product!.Replace ('_', ' ');
+
+ if (!string.IsNullOrEmpty (device.Device))
+ return device.Device!.Replace ('_', ' ');
+
+ return device.Serial;
+ }
+
+ ///
+ /// Formats an AVD name into a user-friendly display name.
+ /// Replaces underscores with spaces, applies title case, and capitalizes "API".
+ /// Ported from dotnet/android GetAvailableAndroidDevices.FormatDisplayName.
+ ///
+ public static string FormatDisplayName (string avdName)
+ {
+ if (string.IsNullOrEmpty (avdName))
+ return avdName ?? string.Empty;
+
+ var textInfo = CultureInfo.InvariantCulture.TextInfo;
+ avdName = textInfo.ToTitleCase (avdName.Replace ('_', ' '));
+
+ // Replace "Api" with "API"
+ avdName = ApiRegex.Replace (avdName, "API");
+ return avdName;
+ }
+
+ ///
+ /// Merges devices from adb with available emulators from 'emulator -list-avds'.
+ /// Running emulators are not duplicated. Non-running emulators are added with Status=NotRunning.
+ /// Ported from dotnet/android GetAvailableAndroidDevices.MergeDevicesAndEmulators.
+ ///
+ public static List MergeDevicesAndEmulators (IReadOnlyList adbDevices, IReadOnlyList availableEmulators)
+ {
+ var result = new List (adbDevices);
+
+ // Build a set of AVD names that are already running
+ var runningAvdNames = new HashSet (StringComparer.OrdinalIgnoreCase);
+ foreach (var device in adbDevices) {
+ if (!string.IsNullOrEmpty (device.AvdName))
+ runningAvdNames.Add (device.AvdName!);
+ }
+
+ // Add non-running emulators
+ foreach (var avdName in availableEmulators) {
+ if (runningAvdNames.Contains (avdName))
+ continue;
+
+ var displayName = FormatDisplayName (avdName);
+ result.Add (new AdbDeviceInfo {
+ Serial = avdName,
+ Description = displayName + " (Not Running)",
+ Type = AdbDeviceType.Emulator,
+ Status = AdbDeviceStatus.NotRunning,
+ AvdName = avdName,
+ });
+ }
+
+ // Sort: online devices first, then not-running emulators, alphabetically by description
+ result.Sort ((a, b) => {
+ var aNotRunning = a.Status == AdbDeviceStatus.NotRunning;
+ var bNotRunning = b.Status == AdbDeviceStatus.NotRunning;
+
+ if (aNotRunning != bNotRunning)
+ return aNotRunning ? 1 : -1;
+
+ return string.Compare (a.Description, b.Description, StringComparison.OrdinalIgnoreCase);
+ });
+
+ return result;
+ }
+}
+
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs
new file mode 100644
index 00000000..25690c52
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs
@@ -0,0 +1,36 @@
+// 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.Diagnostics;
+using System.IO;
+
+namespace Xamarin.Android.Tools;
+
+///
+/// Helper for setting up environment variables for Android SDK tools.
+///
+internal static class AndroidEnvironmentHelper
+{
+ ///
+ /// Configures environment variables on a ProcessStartInfo for running Android SDK tools.
+ ///
+ internal static void ConfigureEnvironment (ProcessStartInfo psi, string? sdkPath, string? jdkPath)
+ {
+ if (!string.IsNullOrEmpty (sdkPath))
+ psi.EnvironmentVariables [EnvironmentVariableNames.AndroidHome] = sdkPath;
+
+ if (!string.IsNullOrEmpty (jdkPath)) {
+ psi.EnvironmentVariables [EnvironmentVariableNames.JavaHome] = jdkPath;
+ var jdkBin = Path.Combine (jdkPath, "bin");
+ var currentPath = psi.EnvironmentVariables [EnvironmentVariableNames.Path] ?? "";
+ psi.EnvironmentVariables [EnvironmentVariableNames.Path] = string.IsNullOrEmpty (currentPath) ? jdkBin : jdkBin + Path.PathSeparator + currentPath;
+ }
+
+ // Set ANDROID_USER_HOME for consistent AVD location across tools (matches SdkManager behavior)
+ if (!psi.EnvironmentVariables.ContainsKey (EnvironmentVariableNames.AndroidUserHome)) {
+ psi.EnvironmentVariables [EnvironmentVariableNames.AndroidUserHome] = Path.Combine (
+ Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), ".android");
+ }
+ }
+}
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..126d5f13
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs
@@ -0,0 +1,160 @@
+// 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));
+
+ 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 List 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.
+ ///
+ static string GetAvdRootDirectory ()
+ {
+ // ANDROID_AVD_HOME takes highest priority
+ var avdHome = Environment.GetEnvironmentVariable (EnvironmentVariableNames.AndroidAvdHome);
+ if (avdHome is { Length: > 0 })
+ return avdHome;
+
+ // ANDROID_USER_HOME/avd is the next option
+ var userHome = Environment.GetEnvironmentVariable (EnvironmentVariableNames.AndroidUserHome);
+ if (userHome is { Length: > 0 })
+ return Path.Combine (userHome, "avd");
+
+ // Default: ~/.android/avd
+ return Path.Combine (
+ Environment.GetFolderPath (Environment.SpecialFolder.UserProfile),
+ ".android", "avd");
+ }
+}
+
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs
new file mode 100644
index 00000000..2ebb29fe
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs
@@ -0,0 +1,106 @@
+// 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;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Xamarin.Android.Tools;
+
+///
+/// Runs Android Emulator commands.
+///
+public class EmulatorRunner
+{
+ readonly Func getSdkPath;
+ readonly Func? getJdkPath;
+
+ public EmulatorRunner (Func getSdkPath)
+ : this (getSdkPath, null)
+ {
+ }
+
+ public EmulatorRunner (Func getSdkPath, Func? getJdkPath)
+ {
+ this.getSdkPath = getSdkPath ?? throw new ArgumentNullException (nameof (getSdkPath));
+ this.getJdkPath = getJdkPath;
+ }
+
+ public string? EmulatorPath {
+ get {
+ var sdkPath = getSdkPath ();
+ if (string.IsNullOrEmpty (sdkPath))
+ return null;
+
+ var ext = OS.IsWindows ? ".exe" : "";
+ var path = Path.Combine (sdkPath, "emulator", "emulator" + ext);
+
+ return File.Exists (path) ? path : null;
+ }
+ }
+
+ public bool IsAvailable => EmulatorPath is not null;
+
+ string RequireEmulatorPath ()
+ {
+ return EmulatorPath ?? throw new InvalidOperationException ("Android Emulator not found.");
+ }
+
+ void ConfigureEnvironment (ProcessStartInfo psi)
+ {
+ AndroidEnvironmentHelper.ConfigureEnvironment (psi, getSdkPath (), getJdkPath?.Invoke ());
+ }
+
+ public Process StartAvd (string avdName, bool coldBoot = false, IEnumerable? additionalArgs = null)
+ {
+ var emulatorPath = RequireEmulatorPath ();
+
+ var args = new List { "-avd", avdName };
+ if (coldBoot)
+ args.Add ("-no-snapshot-load");
+ if (additionalArgs != null)
+ args.AddRange (additionalArgs);
+
+ var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, args.ToArray ());
+ ConfigureEnvironment (psi);
+
+ // Don't redirect stdout/stderr for this long-running background process.
+ // UseShellExecute=false (set by CreateProcessStartInfo) already prevents
+ // pipe inheritance without needing redirect+drain.
+ psi.RedirectStandardOutput = false;
+ psi.RedirectStandardError = false;
+
+ var process = new Process { StartInfo = psi };
+ process.Start ();
+
+ return process;
+ }
+
+ public async Task> ListAvdNamesAsync (CancellationToken cancellationToken = default)
+ {
+ var emulatorPath = RequireEmulatorPath ();
+
+ using var stdout = new StringWriter ();
+ var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, "-list-avds");
+ ConfigureEnvironment (psi);
+
+ await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken).ConfigureAwait (false);
+
+ return ParseListAvdsOutput (stdout.ToString ());
+ }
+
+ internal static List ParseListAvdsOutput (string output)
+ {
+ var avds = new List ();
+ foreach (var line in output.Split ('\n')) {
+ var trimmed = line.Trim ();
+ if (!string.IsNullOrEmpty (trimmed))
+ avds.Add (trimmed);
+ }
+ return avds;
+ }
+}
+
diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs
new file mode 100644
index 00000000..e09d9687
--- /dev/null
+++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs
@@ -0,0 +1,479 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using System.IO;
+using NUnit.Framework;
+
+namespace Xamarin.Android.Tools.Tests;
+
+///
+/// Tests for AdbRunner parsing logic, ported from dotnet/android GetAvailableAndroidDevicesTests.
+///
+[TestFixture]
+public class AdbRunnerTests
+{
+ [Test]
+ public void ParseAdbDevicesOutput_RealWorldData ()
+ {
+ var output =
+ "List of devices attached\n" +
+ "0A041FDD400327 device product:redfin model:Pixel_5 device:redfin transport_id:2\n" +
+ "emulator-5554 device product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64xa transport_id:1\n";
+
+ var devices = AdbRunner.ParseAdbDevicesOutput (output);
+
+ Assert.AreEqual (2, devices.Count);
+
+ // Physical device
+ Assert.AreEqual ("0A041FDD400327", devices [0].Serial);
+ Assert.AreEqual (AdbDeviceType.Device, devices [0].Type);
+ Assert.AreEqual (AdbDeviceStatus.Online, devices [0].Status);
+ Assert.AreEqual ("Pixel 5", devices [0].Description);
+ Assert.AreEqual ("Pixel_5", devices [0].Model);
+ Assert.AreEqual ("redfin", devices [0].Product);
+ Assert.AreEqual ("redfin", devices [0].Device);
+ Assert.AreEqual ("2", devices [0].TransportId);
+ Assert.IsFalse (devices [0].IsEmulator);
+
+ // Emulator
+ Assert.AreEqual ("emulator-5554", devices [1].Serial);
+ Assert.AreEqual (AdbDeviceType.Emulator, devices [1].Type);
+ Assert.AreEqual (AdbDeviceStatus.Online, devices [1].Status);
+ Assert.AreEqual ("sdk gphone64 x86 64", devices [1].Description); // model with underscores replaced
+ Assert.AreEqual ("sdk_gphone64_x86_64", devices [1].Model);
+ Assert.AreEqual ("1", devices [1].TransportId);
+ Assert.IsTrue (devices [1].IsEmulator);
+ }
+
+ [Test]
+ public void ParseAdbDevicesOutput_EmptyOutput ()
+ {
+ var output = "List of devices attached\n\n";
+ var devices = AdbRunner.ParseAdbDevicesOutput (output);
+ Assert.AreEqual (0, devices.Count);
+ }
+
+ [Test]
+ public void ParseAdbDevicesOutput_SingleEmulator ()
+ {
+ var output =
+ "List of devices attached\n" +
+ "emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1\n";
+
+ var devices = AdbRunner.ParseAdbDevicesOutput (output);
+
+ Assert.AreEqual (1, devices.Count);
+ Assert.AreEqual ("emulator-5554", devices [0].Serial);
+ Assert.AreEqual (AdbDeviceType.Emulator, devices [0].Type);
+ Assert.AreEqual (AdbDeviceStatus.Online, devices [0].Status);
+ Assert.AreEqual ("sdk_gphone64_arm64", devices [0].Model);
+ Assert.AreEqual ("1", devices [0].TransportId);
+ }
+
+ [Test]
+ public void ParseAdbDevicesOutput_SinglePhysicalDevice ()
+ {
+ var output =
+ "List of devices attached\n" +
+ "0A041FDD400327 device usb:1-1 product:raven model:Pixel_6_Pro device:raven transport_id:2\n";
+
+ var devices = AdbRunner.ParseAdbDevicesOutput (output);
+
+ Assert.AreEqual (1, devices.Count);
+ Assert.AreEqual ("0A041FDD400327", devices [0].Serial);
+ Assert.AreEqual (AdbDeviceType.Device, devices [0].Type);
+ Assert.AreEqual (AdbDeviceStatus.Online, devices [0].Status);
+ Assert.AreEqual ("Pixel 6 Pro", devices [0].Description);
+ Assert.AreEqual ("Pixel_6_Pro", devices [0].Model);
+ Assert.AreEqual ("raven", devices [0].Product);
+ Assert.AreEqual ("2", devices [0].TransportId);
+ }
+
+ [Test]
+ public void ParseAdbDevicesOutput_OfflineDevice ()
+ {
+ var output =
+ "List of devices attached\n" +
+ "emulator-5554 offline product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1\n";
+
+ var devices = AdbRunner.ParseAdbDevicesOutput (output);
+
+ Assert.AreEqual (1, devices.Count);
+ Assert.AreEqual (AdbDeviceStatus.Offline, devices [0].Status);
+ }
+
+ [Test]
+ public void ParseAdbDevicesOutput_UnauthorizedDevice ()
+ {
+ var output =
+ "List of devices attached\n" +
+ "0A041FDD400327 unauthorized usb:1-1\n";
+
+ var devices = AdbRunner.ParseAdbDevicesOutput (output);
+
+ Assert.AreEqual (1, devices.Count);
+ Assert.AreEqual ("0A041FDD400327", devices [0].Serial);
+ Assert.AreEqual (AdbDeviceStatus.Unauthorized, devices [0].Status);
+ Assert.AreEqual (AdbDeviceType.Device, devices [0].Type);
+ }
+
+ [Test]
+ public void ParseAdbDevicesOutput_NoPermissionsDevice ()
+ {
+ var output =
+ "List of devices attached\n" +
+ "???????????????? no permissions usb:1-1\n";
+
+ var devices = AdbRunner.ParseAdbDevicesOutput (output);
+
+ Assert.AreEqual (1, devices.Count);
+ Assert.AreEqual ("????????????????", devices [0].Serial);
+ Assert.AreEqual (AdbDeviceStatus.NoPermissions, devices [0].Status);
+ }
+
+ [Test]
+ public void ParseAdbDevicesOutput_DeviceWithMinimalMetadata ()
+ {
+ var output =
+ "List of devices attached\n" +
+ "ABC123 device\n";
+
+ var devices = AdbRunner.ParseAdbDevicesOutput (output);
+
+ Assert.AreEqual (1, devices.Count);
+ Assert.AreEqual ("ABC123", devices [0].Serial);
+ Assert.AreEqual (AdbDeviceType.Device, devices [0].Type);
+ Assert.AreEqual (AdbDeviceStatus.Online, devices [0].Status);
+ Assert.AreEqual ("ABC123", devices [0].Description, "Should fall back to serial");
+ }
+
+ [Test]
+ public void ParseAdbDevicesOutput_InvalidLines ()
+ {
+ var output =
+ "List of devices attached\n" +
+ "\n" +
+ " \n" +
+ "Some random text\n" +
+ "* daemon not running; starting now at tcp:5037\n" +
+ "* daemon started successfully\n" +
+ "emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1\n";
+
+ var devices = AdbRunner.ParseAdbDevicesOutput (output);
+
+ Assert.AreEqual (1, devices.Count, "Should only return valid device lines");
+ Assert.AreEqual ("emulator-5554", devices [0].Serial);
+ }
+
+ [Test]
+ public void ParseAdbDevicesOutput_MixedDeviceStates ()
+ {
+ var output =
+ "List of devices attached\n" +
+ "emulator-5554 device product:sdk_gphone64_arm64 model:Pixel_7 device:emu64a\n" +
+ "emulator-5556 offline\n" +
+ "0A041FDD400327 device usb:1-1 product:raven model:Pixel_6_Pro\n" +
+ "0B123456789ABC unauthorized usb:1-2\n";
+
+ var devices = AdbRunner.ParseAdbDevicesOutput (output);
+
+ Assert.AreEqual (4, devices.Count);
+ Assert.AreEqual (AdbDeviceStatus.Online, devices [0].Status);
+ Assert.AreEqual (AdbDeviceStatus.Offline, devices [1].Status);
+ Assert.AreEqual (AdbDeviceStatus.Online, devices [2].Status);
+ Assert.AreEqual (AdbDeviceStatus.Unauthorized, devices [3].Status);
+ }
+
+ [Test]
+ public void ParseAdbDevicesOutput_WindowsNewlines ()
+ {
+ var output =
+ "List of devices attached\r\n" +
+ "emulator-5554 device transport_id:1\r\n" +
+ "\r\n";
+
+ var devices = AdbRunner.ParseAdbDevicesOutput (output);
+
+ Assert.AreEqual (1, devices.Count);
+ Assert.AreEqual ("emulator-5554", devices [0].Serial);
+ Assert.IsTrue (devices [0].IsEmulator);
+ }
+
+ [Test]
+ public void ParseAdbDevicesOutput_IpPortDevice ()
+ {
+ var output =
+ "List of devices attached\n" +
+ "192.168.1.100:5555 device product:sdk_gphone64_arm64 model:Remote_Device\n";
+
+ var devices = AdbRunner.ParseAdbDevicesOutput (output);
+
+ Assert.AreEqual (1, devices.Count);
+ Assert.AreEqual ("192.168.1.100:5555", devices [0].Serial);
+ Assert.AreEqual (AdbDeviceType.Device, devices [0].Type, "IP devices should be Device");
+ Assert.AreEqual ("Remote Device", devices [0].Description);
+ }
+
+ [Test]
+ public void ParseAdbDevicesOutput_AdbDaemonStarting ()
+ {
+ var output =
+ "* daemon not running; starting now at tcp:5037\n" +
+ "* daemon started successfully\n" +
+ "List of devices attached\n" +
+ "emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1\n" +
+ "0A041FDD400327 device usb:1-1 product:raven model:Pixel_6_Pro device:raven transport_id:2\n";
+
+ var devices = AdbRunner.ParseAdbDevicesOutput (output);
+
+ Assert.AreEqual (2, devices.Count, "Should parse devices even with daemon startup messages");
+ }
+
+ [Test]
+ public void DescriptionPriorityOrder ()
+ {
+ // Model has highest priority
+ var output1 = "List of devices attached\ndevice1 device product:product_name model:model_name device:device_name\n";
+ var devices1 = AdbRunner.ParseAdbDevicesOutput (output1);
+ Assert.AreEqual ("model name", devices1 [0].Description, "Model should have highest priority");
+
+ // Product has second priority
+ var output2 = "List of devices attached\ndevice2 device product:product_name device:device_name\n";
+ var devices2 = AdbRunner.ParseAdbDevicesOutput (output2);
+ Assert.AreEqual ("product name", devices2 [0].Description, "Product should have second priority");
+
+ // Device code name has third priority
+ var output3 = "List of devices attached\ndevice3 device device:device_name\n";
+ var devices3 = AdbRunner.ParseAdbDevicesOutput (output3);
+ Assert.AreEqual ("device name", devices3 [0].Description, "Device should have third priority");
+ }
+
+ // --- FormatDisplayName tests (ported from dotnet/android) ---
+
+ [Test]
+ public void FormatDisplayName_ReplacesUnderscoresWithSpaces ()
+ {
+ Assert.AreEqual ("Pixel 7 Pro", AdbRunner.FormatDisplayName ("pixel_7_pro"));
+ }
+
+ [Test]
+ public void FormatDisplayName_AppliesTitleCase ()
+ {
+ Assert.AreEqual ("Pixel 7 Pro", AdbRunner.FormatDisplayName ("pixel 7 pro"));
+ }
+
+ [Test]
+ public void FormatDisplayName_ReplacesApiWithAPIUppercase ()
+ {
+ Assert.AreEqual ("Pixel 5 API 34", AdbRunner.FormatDisplayName ("pixel_5_api_34"));
+ }
+
+ [Test]
+ public void FormatDisplayName_HandlesMultipleApiOccurrences ()
+ {
+ Assert.AreEqual ("Test API Device API 35", AdbRunner.FormatDisplayName ("test_api_device_api_35"));
+ }
+
+ [Test]
+ public void FormatDisplayName_HandlesMixedCaseInput ()
+ {
+ Assert.AreEqual ("Pixel 7 API 35", AdbRunner.FormatDisplayName ("PiXeL_7_API_35"));
+ }
+
+ [Test]
+ public void FormatDisplayName_HandlesComplexNames ()
+ {
+ Assert.AreEqual ("Pixel 9 Pro Xl API 36", AdbRunner.FormatDisplayName ("pixel_9_pro_xl_api_36"));
+ }
+
+ [Test]
+ public void FormatDisplayName_PreservesNumbersAndSpecialChars ()
+ {
+ Assert.AreEqual ("Pixel 7-Pro API 35", AdbRunner.FormatDisplayName ("pixel_7-pro_api_35"));
+ }
+
+ [Test]
+ public void FormatDisplayName_HandlesEmptyString ()
+ {
+ Assert.AreEqual ("", AdbRunner.FormatDisplayName (""));
+ }
+
+ [Test]
+ public void FormatDisplayName_HandlesSingleWord ()
+ {
+ Assert.AreEqual ("Pixel", AdbRunner.FormatDisplayName ("pixel"));
+ }
+
+ [Test]
+ public void FormatDisplayName_DoesNotReplaceApiInsideWords ()
+ {
+ Assert.AreEqual ("Erapidevice", AdbRunner.FormatDisplayName ("erapidevice"));
+ }
+
+ // --- MapAdbStateToStatus tests ---
+
+ [Test]
+ public void MapAdbStateToStatus_AllStates ()
+ {
+ Assert.AreEqual (AdbDeviceStatus.Online, AdbRunner.MapAdbStateToStatus ("device"));
+ Assert.AreEqual (AdbDeviceStatus.Offline, AdbRunner.MapAdbStateToStatus ("offline"));
+ Assert.AreEqual (AdbDeviceStatus.Unauthorized, AdbRunner.MapAdbStateToStatus ("unauthorized"));
+ Assert.AreEqual (AdbDeviceStatus.NoPermissions, AdbRunner.MapAdbStateToStatus ("no permissions"));
+ Assert.AreEqual (AdbDeviceStatus.Unknown, AdbRunner.MapAdbStateToStatus ("something-else"));
+ }
+
+ // --- MergeDevicesAndEmulators tests (ported from dotnet/android) ---
+
+ [Test]
+ public void MergeDevicesAndEmulators_NoEmulators_ReturnsAdbDevicesOnly ()
+ {
+ var adbDevices = new List {
+ new AdbDeviceInfo { Serial = "0A041FDD400327", Description = "Pixel 5", Type = AdbDeviceType.Device, Status = AdbDeviceStatus.Online },
+ };
+
+ var result = AdbRunner.MergeDevicesAndEmulators (adbDevices, new List ());
+
+ Assert.AreEqual (1, result.Count);
+ Assert.AreEqual ("0A041FDD400327", result [0].Serial);
+ }
+
+ [Test]
+ public void MergeDevicesAndEmulators_NoRunningEmulators_AddsAllAvailable ()
+ {
+ var adbDevices = new List {
+ new AdbDeviceInfo { Serial = "0A041FDD400327", Description = "Pixel 5", Type = AdbDeviceType.Device, Status = AdbDeviceStatus.Online },
+ };
+ var available = new List { "pixel_7_api_35", "pixel_9_api_36" };
+
+ var result = AdbRunner.MergeDevicesAndEmulators (adbDevices, available);
+
+ Assert.AreEqual (3, result.Count);
+
+ // Online first
+ Assert.AreEqual ("0A041FDD400327", result [0].Serial);
+
+ // Non-running sorted alphabetically
+ Assert.AreEqual ("pixel_7_api_35", result [1].Serial);
+ Assert.AreEqual (AdbDeviceStatus.NotRunning, result [1].Status);
+ Assert.AreEqual ("pixel_7_api_35", result [1].AvdName);
+ Assert.AreEqual ("Pixel 7 API 35 (Not Running)", result [1].Description);
+
+ Assert.AreEqual ("pixel_9_api_36", result [2].Serial);
+ Assert.AreEqual (AdbDeviceStatus.NotRunning, result [2].Status);
+ Assert.AreEqual ("Pixel 9 API 36 (Not Running)", result [2].Description);
+ }
+
+ [Test]
+ public void MergeDevicesAndEmulators_RunningEmulator_NoDuplicate ()
+ {
+ var adbDevices = new List {
+ new AdbDeviceInfo {
+ Serial = "emulator-5554", Description = "Pixel 7 API 35",
+ Type = AdbDeviceType.Emulator, Status = AdbDeviceStatus.Online,
+ AvdName = "pixel_7_api_35"
+ },
+ };
+ var available = new List { "pixel_7_api_35" };
+
+ var result = AdbRunner.MergeDevicesAndEmulators (adbDevices, available);
+
+ Assert.AreEqual (1, result.Count, "Should not duplicate running emulator");
+ Assert.AreEqual ("emulator-5554", result [0].Serial);
+ Assert.AreEqual (AdbDeviceStatus.Online, result [0].Status);
+ }
+
+ [Test]
+ public void MergeDevicesAndEmulators_MixedRunningAndNotRunning ()
+ {
+ var adbDevices = new List {
+ new AdbDeviceInfo {
+ Serial = "emulator-5554", Description = "Pixel 7 API 35",
+ Type = AdbDeviceType.Emulator, Status = AdbDeviceStatus.Online,
+ AvdName = "pixel_7_api_35"
+ },
+ new AdbDeviceInfo {
+ Serial = "0A041FDD400327", Description = "Pixel 5",
+ Type = AdbDeviceType.Device, Status = AdbDeviceStatus.Online,
+ },
+ };
+ var available = new List { "pixel_7_api_35", "pixel_9_api_36", "nexus_5_api_30" };
+
+ var result = AdbRunner.MergeDevicesAndEmulators (adbDevices, available);
+
+ Assert.AreEqual (4, result.Count);
+
+ // Online devices first, sorted alphabetically
+ Assert.AreEqual ("0A041FDD400327", result [0].Serial);
+ Assert.AreEqual (AdbDeviceStatus.Online, result [0].Status);
+
+ Assert.AreEqual ("emulator-5554", result [1].Serial);
+ Assert.AreEqual (AdbDeviceStatus.Online, result [1].Status);
+
+ // Non-running emulators second, sorted alphabetically
+ Assert.AreEqual ("nexus_5_api_30", result [2].Serial);
+ Assert.AreEqual (AdbDeviceStatus.NotRunning, result [2].Status);
+ Assert.AreEqual ("Nexus 5 API 30 (Not Running)", result [2].Description);
+
+ Assert.AreEqual ("pixel_9_api_36", result [3].Serial);
+ Assert.AreEqual (AdbDeviceStatus.NotRunning, result [3].Status);
+ }
+
+ [Test]
+ public void MergeDevicesAndEmulators_CaseInsensitiveAvdNameMatching ()
+ {
+ var adbDevices = new List {
+ new AdbDeviceInfo {
+ Serial = "emulator-5554", Description = "Pixel 7 API 35",
+ Type = AdbDeviceType.Emulator, Status = AdbDeviceStatus.Online,
+ AvdName = "Pixel_7_API_35"
+ },
+ };
+ var available = new List { "pixel_7_api_35" }; // lowercase
+
+ var result = AdbRunner.MergeDevicesAndEmulators (adbDevices, available);
+
+ Assert.AreEqual (1, result.Count, "Should match AVD names case-insensitively");
+ }
+
+ [Test]
+ public void MergeDevicesAndEmulators_EmptyAdbDevices_ReturnsAllAvailable ()
+ {
+ var result = AdbRunner.MergeDevicesAndEmulators (new List (), new List { "pixel_7_api_35", "pixel_9_api_36" });
+
+ Assert.AreEqual (2, result.Count);
+ Assert.AreEqual ("Pixel 7 API 35 (Not Running)", result [0].Description);
+ Assert.AreEqual ("Pixel 9 API 36 (Not Running)", result [1].Description);
+ }
+
+ // --- AdbPath tests ---
+
+ [Test]
+ public void AdbPath_FindsInSdk ()
+ {
+ var tempDir = Path.Combine (Path.GetTempPath (), $"adb-test-{Path.GetRandomFileName ()}");
+ var platformTools = Path.Combine (tempDir, "platform-tools");
+ Directory.CreateDirectory (platformTools);
+
+ try {
+ var adbName = OS.IsWindows ? "adb.exe" : "adb";
+ File.WriteAllText (Path.Combine (platformTools, adbName), "");
+
+ var runner = new AdbRunner (() => tempDir);
+
+ Assert.IsNotNull (runner.AdbPath);
+ Assert.IsTrue (runner.IsAvailable);
+ Assert.IsTrue (runner.AdbPath!.Contains ("platform-tools"));
+ } finally {
+ Directory.Delete (tempDir, true);
+ }
+ }
+
+ [Test]
+ public void AdbPath_NullSdkPath_StillSearchesPath ()
+ {
+ var runner = new AdbRunner (() => null);
+ // Should not throw — falls back to PATH search
+ _ = runner.AdbPath;
+ }
+}
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..993b6fa0
--- /dev/null
+++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs
@@ -0,0 +1,204 @@
+// 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.IO;
+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 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_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 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_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 (""));
+ }
+}
diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs
new file mode 100644
index 00000000..80011c04
--- /dev/null
+++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs
@@ -0,0 +1,89 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.IO;
+using NUnit.Framework;
+
+namespace Xamarin.Android.Tools.Tests;
+
+[TestFixture]
+public class EmulatorRunnerTests
+{
+ [Test]
+ public void ParseListAvdsOutput_MultipleAvds ()
+ {
+ var output = "Pixel_7_API_35\nMAUI_Emulator\nNexus_5X\n";
+
+ var avds = EmulatorRunner.ParseListAvdsOutput (output);
+
+ Assert.AreEqual (3, avds.Count);
+ Assert.AreEqual ("Pixel_7_API_35", avds [0]);
+ Assert.AreEqual ("MAUI_Emulator", avds [1]);
+ Assert.AreEqual ("Nexus_5X", avds [2]);
+ }
+
+ [Test]
+ public void ParseListAvdsOutput_EmptyOutput ()
+ {
+ var avds = EmulatorRunner.ParseListAvdsOutput ("");
+ Assert.AreEqual (0, avds.Count);
+ }
+
+ [Test]
+ public void ParseListAvdsOutput_WindowsNewlines ()
+ {
+ var output = "Pixel_7_API_35\r\nMAUI_Emulator\r\n";
+
+ var avds = EmulatorRunner.ParseListAvdsOutput (output);
+
+ Assert.AreEqual (2, avds.Count);
+ Assert.AreEqual ("Pixel_7_API_35", avds [0]);
+ Assert.AreEqual ("MAUI_Emulator", avds [1]);
+ }
+
+ [Test]
+ public void ParseListAvdsOutput_BlankLines ()
+ {
+ var output = "\nPixel_7_API_35\n\n\nMAUI_Emulator\n\n";
+
+ var avds = EmulatorRunner.ParseListAvdsOutput (output);
+
+ Assert.AreEqual (2, avds.Count);
+ }
+
+ [Test]
+ public void EmulatorPath_FindsInSdk ()
+ {
+ var tempDir = Path.Combine (Path.GetTempPath (), $"emu-test-{Path.GetRandomFileName ()}");
+ var emulatorDir = Path.Combine (tempDir, "emulator");
+ Directory.CreateDirectory (emulatorDir);
+
+ try {
+ var emuName = OS.IsWindows ? "emulator.exe" : "emulator";
+ File.WriteAllText (Path.Combine (emulatorDir, emuName), "");
+
+ var runner = new EmulatorRunner (() => tempDir);
+
+ Assert.IsNotNull (runner.EmulatorPath);
+ Assert.IsTrue (runner.IsAvailable);
+ } finally {
+ Directory.Delete (tempDir, true);
+ }
+ }
+
+ [Test]
+ public void EmulatorPath_MissingSdk_ReturnsNull ()
+ {
+ var runner = new EmulatorRunner (() => "/nonexistent/path");
+ Assert.IsNull (runner.EmulatorPath);
+ Assert.IsFalse (runner.IsAvailable);
+ }
+
+ [Test]
+ public void EmulatorPath_NullSdk_ReturnsNull ()
+ {
+ var runner = new EmulatorRunner (() => null);
+ Assert.IsNull (runner.EmulatorPath);
+ Assert.IsFalse (runner.IsAvailable);
+ }
+}