diff --git a/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs b/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs
index 272a17b1..4d3e2842 100644
--- a/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs
+++ b/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs
@@ -40,5 +40,11 @@ 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";
}
}
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..589f89ff
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceInfo.cs
@@ -0,0 +1,84 @@
+// 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
+}
+
+///
+/// Represents the status of an Android device.
+///
+public enum AdbDeviceStatus
+{
+ Online,
+ Offline,
+ Unauthorized,
+ NoPermissions,
+ NotRunning,
+ Unknown
+}
+
+///
+/// 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/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/Models/EmulatorBootOptions.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs
new file mode 100644
index 00000000..8765df77
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs
@@ -0,0 +1,18 @@
+// 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
+{
+ ///
+ /// Options for booting an Android emulator.
+ ///
+ public class EmulatorBootOptions
+ {
+ public TimeSpan BootTimeout { get; set; } = TimeSpan.FromSeconds (300);
+ public string? AdditionalArgs { get; set; }
+ public bool ColdBoot { get; set; }
+ public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds (500);
+ }
+}
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs
new file mode 100644
index 00000000..59281792
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs
@@ -0,0 +1,15 @@
+// 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
+{
+ ///
+ /// Result of an emulator boot operation.
+ ///
+ public class EmulatorBootResult
+ {
+ public bool Success { get; set; }
+ public string? Serial { get; set; }
+ public string? ErrorMessage { get; set; }
+ }
+}
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs
index 28113d72..eb188e19 100644
--- a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs
+++ b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs
@@ -203,6 +203,73 @@ static string JoinArguments (string[] args)
}
#endif
+ ///
+ /// Throws when is non-zero.
+ /// Includes stderr/stdout context in the message when available.
+ ///
+ public 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 (!string.IsNullOrEmpty (stderr))
+ message += $" stderr:{Environment.NewLine}{stderr!.Trim ()}";
+ if (!string.IsNullOrEmpty (stdout))
+ message += $" stdout:{Environment.NewLine}{stdout!.Trim ()}";
+
+ throw new InvalidOperationException (message);
+ }
+
+ ///
+ /// Validates that is not null or empty.
+ /// Throws for null values and
+ /// for empty strings.
+ ///
+ public 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.
+ ///
+ public 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..aaa8f9e3
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs
@@ -0,0 +1,344 @@
+// 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;
+
+ // Matches known adb device states. Uses \s+ to handle both space and tab separators.
+ // Explicit state list prevents false positives from non-device lines.
+ static readonly Regex AdbDevicesRegex = new Regex (
+ @"^([^\s]+)\s+(device|offline|unauthorized|authorizing|no permissions|recovery|sideload|bootloader|connecting|host)\s*(.*)$",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ 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 virtual async Task> ListDevicesAsync (CancellationToken cancellationToken = default)
+ {
+ var adb = RequireAdb ();
+ using var stdout = new StringWriter ();
+ var psi = CreateAdbProcess (adb, "devices", "-l");
+ await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken).ConfigureAwait (false);
+
+ 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 {
+ // Silently ignore failures (emulator may not support this 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 {
+ await ProcessUtils.StartProcess (psi, null, null, cts.Token).ConfigureAwait (false);
+ } 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 ();
+ var psi = CreateAdbProcess (adb, "-s", serial, "emu", "kill");
+ await ProcessUtils.StartProcess (psi, null, null, cancellationToken).ConfigureAwait (false);
+ }
+
+ ///
+ /// Runs 'adb -s {serial} shell getprop {propertyName}' and returns the first non-empty line, or null.
+ ///
+ public virtual async Task GetShellPropertyAsync (string serial, string propertyName, CancellationToken cancellationToken = default)
+ {
+ var adb = RequireAdb ();
+ using var stdout = new StringWriter ();
+ var psi = CreateAdbProcess (adb, "-s", serial, "shell", "getprop", propertyName);
+ await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken).ConfigureAwait (false);
+
+ return FirstNonEmptyLine (stdout.ToString ());
+ }
+
+ ///
+ /// Runs 'adb -s {serial} shell {command}' and returns the first non-empty line, or null.
+ ///
+ public virtual async Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken = default)
+ {
+ var adb = RequireAdb ();
+ using var stdout = new StringWriter ();
+ var psi = CreateAdbProcess (adb, "-s", serial, "shell", command);
+ await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken).ConfigureAwait (false);
+
+ return FirstNonEmptyLine (stdout.ToString ());
+ }
+
+ static string? FirstNonEmptyLine (string output)
+ {
+ foreach (var line in output.Split ('\n')) {
+ var trimmed = line.Trim ();
+ if (!string.IsNullOrEmpty (trimmed))
+ return trimmed;
+ }
+ return null;
+ }
+
+ ///
+ /// 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..c5bb3af2
--- /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..9e37c8d2
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs
@@ -0,0 +1,226 @@
+// 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.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Xamarin.Android.Tools;
+
+///
+/// Runs Android Virtual Device Manager (avdmanager) commands.
+///
+public class AvdManagerRunner
+{
+ readonly Func getSdkPath;
+ readonly Func? getJdkPath;
+
+ public AvdManagerRunner (Func getSdkPath)
+ : this (getSdkPath, null)
+ {
+ }
+
+ public AvdManagerRunner (Func getSdkPath, Func? getJdkPath)
+ {
+ this.getSdkPath = getSdkPath ?? throw new ArgumentNullException (nameof (getSdkPath));
+ this.getJdkPath = getJdkPath;
+ }
+
+ public string? AvdManagerPath {
+ get {
+ var sdkPath = getSdkPath ();
+ if (string.IsNullOrEmpty (sdkPath))
+ return null;
+
+ var ext = OS.IsWindows ? ".bat" : "";
+ var cmdlineToolsDir = Path.Combine (sdkPath, "cmdline-tools");
+
+ if (Directory.Exists (cmdlineToolsDir)) {
+ // Versioned dirs sorted descending, then "latest" as fallback
+ var searchDirs = Directory.GetDirectories (cmdlineToolsDir)
+ .Select (Path.GetFileName)
+ .Where (n => n != "latest" && !string.IsNullOrEmpty (n))
+ .OrderByDescending (n => Version.TryParse (n, out var v) ? v : new Version (0, 0))
+ .Append ("latest");
+
+ foreach (var dir in searchDirs) {
+ var toolPath = Path.Combine (cmdlineToolsDir, dir!, "bin", "avdmanager" + ext);
+ if (File.Exists (toolPath))
+ return toolPath;
+ }
+ }
+
+ // Legacy fallback: tools/bin/avdmanager
+ var legacyPath = Path.Combine (sdkPath, "tools", "bin", "avdmanager" + ext);
+ return File.Exists (legacyPath) ? legacyPath : null;
+ }
+ }
+
+ public bool IsAvailable => !string.IsNullOrEmpty (AvdManagerPath);
+
+ string RequireAvdManagerPath ()
+ {
+ return AvdManagerPath ?? throw new InvalidOperationException ("AVD Manager not found.");
+ }
+
+ void ConfigureEnvironment (ProcessStartInfo psi)
+ {
+ AndroidEnvironmentHelper.ConfigureEnvironment (psi, getSdkPath (), getJdkPath?.Invoke ());
+ }
+
+ public async Task> ListAvdsAsync (CancellationToken cancellationToken = default)
+ {
+ var avdManagerPath = RequireAvdManagerPath ();
+
+ using var stdout = new StringWriter ();
+ using var stderr = new StringWriter ();
+ var psi = ProcessUtils.CreateProcessStartInfo (avdManagerPath, "list", "avd");
+ ConfigureEnvironment (psi);
+ var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken).ConfigureAwait (false);
+
+ if (exitCode != 0)
+ throw new InvalidOperationException ($"avdmanager list avd failed (exit code {exitCode}): {stderr.ToString ().Trim ()}");
+
+ return ParseAvdListOutput (stdout.ToString ());
+ }
+
+ public async Task CreateAvdAsync (string name, string systemImage, string? deviceProfile = null,
+ bool force = false, CancellationToken cancellationToken = default)
+ {
+ if (name is null)
+ throw new ArgumentNullException (nameof (name));
+ if (name.Length == 0)
+ throw new ArgumentException ("Value cannot be an empty string.", nameof (name));
+ if (systemImage is null)
+ throw new ArgumentNullException (nameof (systemImage));
+ if (systemImage.Length == 0)
+ throw new ArgumentException ("Value cannot be an empty string.", nameof (systemImage));
+
+ var avdManagerPath = RequireAvdManagerPath ();
+
+ // 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 (!string.IsNullOrEmpty (deviceProfile))
+ 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;
+ ConfigureEnvironment (psi);
+
+ // avdmanager prompts "Do you wish to create a custom hardware profile?" — answer "no"
+ var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken,
+ onStarted: p => {
+ try {
+ p.StandardInput.WriteLine ("no");
+ p.StandardInput.Close ();
+ } catch (IOException) {
+ // Process may have already exited
+ }
+ }).ConfigureAwait (false);
+
+ if (exitCode != 0) {
+ var errorOutput = stderr.ToString ().Trim ();
+ if (string.IsNullOrEmpty (errorOutput))
+ errorOutput = stdout.ToString ().Trim ();
+ throw new InvalidOperationException ($"Failed to create AVD '{name}': {errorOutput}");
+ }
+
+ // 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)
+ {
+ if (name is null)
+ throw new ArgumentNullException (nameof (name));
+ if (name.Length == 0)
+ throw new ArgumentException ("Value cannot be an empty string.", nameof (name));
+
+ var avdManagerPath = RequireAvdManagerPath ();
+
+ using var stderr = new StringWriter ();
+ var psi = ProcessUtils.CreateProcessStartInfo (avdManagerPath, "delete", "avd", "--name", name);
+ ConfigureEnvironment (psi);
+ var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken).ConfigureAwait (false);
+
+ if (exitCode != 0)
+ throw new InvalidOperationException ($"Failed to delete AVD '{name}': {stderr.ToString ().Trim ()}");
+ }
+
+ 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 ("ANDROID_AVD_HOME");
+ if (!string.IsNullOrEmpty (avdHome))
+ return avdHome;
+
+ // ANDROID_USER_HOME/avd is the next option
+ var userHome = Environment.GetEnvironmentVariable (EnvironmentVariableNames.AndroidUserHome);
+ if (!string.IsNullOrEmpty (userHome))
+ 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..837108f5
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs
@@ -0,0 +1,237 @@
+// 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.Linq;
+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, string? additionalArgs = null)
+ {
+ var emulatorPath = RequireEmulatorPath ();
+
+ var args = new List { "-avd", avdName };
+ if (coldBoot)
+ args.Add ("-no-snapshot-load");
+ if (!string.IsNullOrEmpty (additionalArgs))
+ args.Add (additionalArgs);
+
+ var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, args.ToArray ());
+ ConfigureEnvironment (psi);
+
+ // Redirect stdout/stderr so the emulator process doesn't inherit the
+ // caller's pipes. Without this, parent processes (e.g. VS Code spawn)
+ // never see the 'close' event because the emulator holds the pipes open.
+ psi.RedirectStandardOutput = true;
+ psi.RedirectStandardError = true;
+
+ 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;
+ }
+
+ ///
+ /// Boots an emulator and waits for it to be fully booted.
+ /// Ported from dotnet/android BootAndroidEmulator MSBuild task.
+ ///
+ public async Task BootAndWaitAsync (
+ string deviceOrAvdName,
+ AdbRunner adbRunner,
+ EmulatorBootOptions? options = null,
+ Action? logger = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace (deviceOrAvdName))
+ throw new ArgumentException ("Device or AVD name must not be empty.", nameof (deviceOrAvdName));
+ if (adbRunner == null)
+ throw new ArgumentNullException (nameof (adbRunner));
+
+ options = options ?? new EmulatorBootOptions ();
+ void Log (TraceLevel level, string message) => logger?.Invoke (level, message);
+
+ Log (TraceLevel.Info, $"Booting emulator for '{deviceOrAvdName}'...");
+
+ // Phase 1: Check if deviceOrAvdName is already an online ADB device by serial
+ var devices = await adbRunner.ListDevicesAsync (cancellationToken).ConfigureAwait (false);
+ var onlineDevice = devices.FirstOrDefault (d =>
+ d.Status == AdbDeviceStatus.Online &&
+ string.Equals (d.Serial, deviceOrAvdName, StringComparison.OrdinalIgnoreCase));
+
+ if (onlineDevice != null) {
+ Log (TraceLevel.Info, $"Device '{deviceOrAvdName}' is already online.");
+ return new EmulatorBootResult { Success = true, Serial = onlineDevice.Serial };
+ }
+
+ // Phase 2: Check if AVD is already running (possibly still booting)
+ var runningSerial = FindRunningAvdSerial (devices, deviceOrAvdName);
+ if (runningSerial != null) {
+ Log (TraceLevel.Info, $"AVD '{deviceOrAvdName}' is already running as '{runningSerial}', waiting for full boot...");
+ return await WaitForFullBootAsync (adbRunner, runningSerial, options, logger, cancellationToken).ConfigureAwait (false);
+ }
+
+ // Phase 3: Launch the emulator
+ if (EmulatorPath == null) {
+ return new EmulatorBootResult {
+ Success = false,
+ ErrorMessage = "Android Emulator not found. Ensure the Android SDK is installed and the emulator is available.",
+ };
+ }
+
+ Log (TraceLevel.Info, $"Launching AVD '{deviceOrAvdName}'...");
+ Process emulatorProcess;
+ try {
+ emulatorProcess = StartAvd (deviceOrAvdName, options.ColdBoot, options.AdditionalArgs);
+ } catch (Exception ex) {
+ return new EmulatorBootResult {
+ Success = false,
+ ErrorMessage = $"Failed to launch emulator: {ex.Message}",
+ };
+ }
+
+ // Poll for the new emulator serial to appear
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken);
+ timeoutCts.CancelAfter (options.BootTimeout);
+
+ try {
+ string? newSerial = null;
+ while (newSerial == null) {
+ timeoutCts.Token.ThrowIfCancellationRequested ();
+ await Task.Delay (options.PollInterval, timeoutCts.Token).ConfigureAwait (false);
+
+ devices = await adbRunner.ListDevicesAsync (timeoutCts.Token).ConfigureAwait (false);
+ newSerial = FindRunningAvdSerial (devices, deviceOrAvdName);
+ }
+
+ Log (TraceLevel.Info, $"Emulator appeared as '{newSerial}', waiting for full boot...");
+ return await WaitForFullBootAsync (adbRunner, newSerial, options, logger, timeoutCts.Token).ConfigureAwait (false);
+ } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) {
+ return new EmulatorBootResult {
+ Success = false,
+ ErrorMessage = $"Timed out waiting for emulator '{deviceOrAvdName}' to boot within {options.BootTimeout.TotalSeconds}s.",
+ };
+ }
+ }
+
+ static string? FindRunningAvdSerial (IReadOnlyList devices, string avdName)
+ {
+ foreach (var d in devices) {
+ if (d.Type == AdbDeviceType.Emulator &&
+ !string.IsNullOrEmpty (d.AvdName) &&
+ string.Equals (d.AvdName, avdName, StringComparison.OrdinalIgnoreCase)) {
+ return d.Serial;
+ }
+ }
+ return null;
+ }
+
+ async Task WaitForFullBootAsync (
+ AdbRunner adbRunner,
+ string serial,
+ EmulatorBootOptions options,
+ Action? logger,
+ CancellationToken cancellationToken)
+ {
+ void Log (TraceLevel level, string message) => logger?.Invoke (level, message);
+
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken);
+ timeoutCts.CancelAfter (options.BootTimeout);
+
+ try {
+ while (true) {
+ timeoutCts.Token.ThrowIfCancellationRequested ();
+
+ var bootCompleted = await adbRunner.GetShellPropertyAsync (serial, "sys.boot_completed", timeoutCts.Token).ConfigureAwait (false);
+ if (string.Equals (bootCompleted, "1", StringComparison.Ordinal)) {
+ var pmResult = await adbRunner.RunShellCommandAsync (serial, "pm path android", timeoutCts.Token).ConfigureAwait (false);
+ if (pmResult != null && pmResult.StartsWith ("package:", StringComparison.Ordinal)) {
+ Log (TraceLevel.Info, $"Emulator '{serial}' is fully booted.");
+ return new EmulatorBootResult { Success = true, Serial = serial };
+ }
+ }
+
+ await Task.Delay (options.PollInterval, timeoutCts.Token).ConfigureAwait (false);
+ }
+ } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) {
+ return new EmulatorBootResult {
+ Success = false,
+ Serial = serial,
+ ErrorMessage = $"Timed out waiting for emulator '{serial}' to fully boot within {options.BootTimeout.TotalSeconds}s.",
+ };
+ }
+ }
+}
+
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..26232154
--- /dev/null
+++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs
@@ -0,0 +1,199 @@
+// 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 AvdManagerPath_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 runner = new AvdManagerRunner (() => tempDir, null);
+ Assert.IsNotNull (runner.AvdManagerPath);
+ Assert.IsTrue (runner.AvdManagerPath!.Contains ("12.0"));
+ } finally {
+ Directory.Delete (tempDir, true);
+ }
+ }
+
+ [Test]
+ public void AvdManagerPath_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 runner = new AvdManagerRunner (() => tempDir, null);
+ Assert.IsNotNull (runner.AvdManagerPath);
+ Assert.IsTrue (runner.AvdManagerPath!.Contains ("12.0"));
+ } finally {
+ Directory.Delete (tempDir, true);
+ }
+ }
+
+ [Test]
+ public void AvdManagerPath_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 runner = new AvdManagerRunner (() => tempDir, null);
+ Assert.IsNotNull (runner.AvdManagerPath);
+ Assert.IsTrue (runner.AvdManagerPath!.Contains ("latest"));
+ } finally {
+ Directory.Delete (tempDir, true);
+ }
+ }
+
+ [Test]
+ public void AvdManagerPath_NullSdk_ReturnsNull ()
+ {
+ var runner = new AvdManagerRunner (() => null, null);
+ Assert.IsNull (runner.AvdManagerPath);
+ }
+
+ [Test]
+ public void AvdManagerPath_MissingSdk_ReturnsNull ()
+ {
+ var runner = new AvdManagerRunner (() => "/nonexistent/path", null);
+ Assert.IsNull (runner.AvdManagerPath);
+ }
+
+ [Test]
+ public void CreateAvdAsync_NullName_ThrowsArgumentNullException ()
+ {
+ var runner = new AvdManagerRunner (() => "/fake/sdk", null);
+ Assert.ThrowsAsync (() => runner.CreateAvdAsync (null!, "system-image"));
+ }
+
+ [Test]
+ public void CreateAvdAsync_EmptyName_ThrowsArgumentException ()
+ {
+ var runner = new AvdManagerRunner (() => "/fake/sdk", null);
+ Assert.ThrowsAsync (() => runner.CreateAvdAsync ("", "system-image"));
+ }
+
+ [Test]
+ public void CreateAvdAsync_EmptySystemImage_ThrowsArgumentException ()
+ {
+ var runner = new AvdManagerRunner (() => "/fake/sdk", null);
+ Assert.ThrowsAsync (() => runner.CreateAvdAsync ("test-avd", ""));
+ }
+
+ [Test]
+ public void DeleteAvdAsync_NullName_ThrowsArgumentNullException ()
+ {
+ var runner = new AvdManagerRunner (() => "/fake/sdk", null);
+ Assert.ThrowsAsync (() => runner.DeleteAvdAsync (null!));
+ }
+
+ [Test]
+ public void DeleteAvdAsync_EmptyName_ThrowsArgumentException ()
+ {
+ var runner = new AvdManagerRunner (() => "/fake/sdk", null);
+ 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..a951d12a
--- /dev/null
+++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs
@@ -0,0 +1,326 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+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);
+ }
+
+ // --- BootAndWaitAsync tests (ported from dotnet/android BootAndroidEmulatorTests) ---
+
+ [Test]
+ public async Task AlreadyOnlineDevice_PassesThrough ()
+ {
+ var devices = new List {
+ new AdbDeviceInfo {
+ Serial = "emulator-5554",
+ Type = AdbDeviceType.Emulator,
+ Status = AdbDeviceStatus.Online,
+ AvdName = "Pixel_7_API_35",
+ },
+ };
+
+ var mockAdb = new MockAdbRunner (devices);
+ var runner = new EmulatorRunner (() => null);
+
+ var result = await runner.BootAndWaitAsync ("emulator-5554", mockAdb);
+
+ Assert.IsTrue (result.Success);
+ Assert.AreEqual ("emulator-5554", result.Serial);
+ Assert.IsNull (result.ErrorMessage);
+ }
+
+ [Test]
+ public async Task AvdAlreadyRunning_WaitsForFullBoot ()
+ {
+ var devices = new List {
+ new AdbDeviceInfo {
+ Serial = "emulator-5554",
+ Type = AdbDeviceType.Emulator,
+ Status = AdbDeviceStatus.Online,
+ AvdName = "Pixel_7_API_35",
+ },
+ };
+
+ var mockAdb = new MockAdbRunner (devices);
+ mockAdb.ShellProperties ["sys.boot_completed"] = "1";
+ mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk";
+
+ var runner = new EmulatorRunner (() => null);
+ var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) };
+
+ var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options);
+
+ Assert.IsTrue (result.Success);
+ Assert.AreEqual ("emulator-5554", result.Serial);
+ }
+
+ [Test]
+ public async Task BootEmulator_AppearsAfterPolling ()
+ {
+ var devices = new List ();
+ var mockAdb = new MockAdbRunner (devices);
+ mockAdb.ShellProperties ["sys.boot_completed"] = "1";
+ mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk";
+
+ int pollCount = 0;
+ mockAdb.OnListDevices = () => {
+ pollCount++;
+ if (pollCount >= 2) {
+ devices.Add (new AdbDeviceInfo {
+ Serial = "emulator-5554",
+ Type = AdbDeviceType.Emulator,
+ Status = AdbDeviceStatus.Online,
+ AvdName = "Pixel_7_API_35",
+ });
+ }
+ };
+
+ var tempDir = CreateFakeEmulatorSdk ();
+ try {
+ var runner = new EmulatorRunner (() => tempDir);
+ var options = new EmulatorBootOptions {
+ BootTimeout = TimeSpan.FromSeconds (10),
+ PollInterval = TimeSpan.FromMilliseconds (50),
+ };
+
+ var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options);
+
+ Assert.IsTrue (result.Success);
+ Assert.AreEqual ("emulator-5554", result.Serial);
+ Assert.IsTrue (pollCount >= 2);
+ } finally {
+ Directory.Delete (tempDir, true);
+ }
+ }
+
+ [Test]
+ public async Task LaunchFailure_ReturnsError ()
+ {
+ var devices = new List ();
+ var mockAdb = new MockAdbRunner (devices);
+
+ // No emulator path → EmulatorPath returns null → error
+ var runner = new EmulatorRunner (() => null);
+ var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (2) };
+
+ var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options);
+
+ Assert.IsFalse (result.Success);
+ Assert.IsNotNull (result.ErrorMessage);
+ Assert.IsTrue (result.ErrorMessage!.Contains ("not found"), $"Unexpected error: {result.ErrorMessage}");
+ }
+
+ [Test]
+ public async Task BootTimeout_BootCompletedNeverReaches1 ()
+ {
+ var devices = new List {
+ new AdbDeviceInfo {
+ Serial = "emulator-5554",
+ Type = AdbDeviceType.Emulator,
+ Status = AdbDeviceStatus.Online,
+ AvdName = "Pixel_7_API_35",
+ },
+ };
+
+ var mockAdb = new MockAdbRunner (devices);
+ // boot_completed never returns "1"
+ mockAdb.ShellProperties ["sys.boot_completed"] = "0";
+
+ var runner = new EmulatorRunner (() => null);
+ var options = new EmulatorBootOptions {
+ BootTimeout = TimeSpan.FromMilliseconds (200),
+ PollInterval = TimeSpan.FromMilliseconds (50),
+ };
+
+ var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options);
+
+ Assert.IsFalse (result.Success);
+ Assert.IsNotNull (result.ErrorMessage);
+ Assert.IsTrue (result.ErrorMessage!.Contains ("Timed out"), $"Unexpected error: {result.ErrorMessage}");
+ }
+
+ [Test]
+ public async Task MultipleEmulators_FindsCorrectAvd ()
+ {
+ var devices = new List {
+ new AdbDeviceInfo {
+ Serial = "emulator-5554",
+ Type = AdbDeviceType.Emulator,
+ Status = AdbDeviceStatus.Online,
+ AvdName = "Pixel_5_API_30",
+ },
+ new AdbDeviceInfo {
+ Serial = "emulator-5556",
+ Type = AdbDeviceType.Emulator,
+ Status = AdbDeviceStatus.Online,
+ AvdName = "Pixel_7_API_35",
+ },
+ new AdbDeviceInfo {
+ Serial = "emulator-5558",
+ Type = AdbDeviceType.Emulator,
+ Status = AdbDeviceStatus.Online,
+ AvdName = "Nexus_5X_API_28",
+ },
+ };
+
+ var mockAdb = new MockAdbRunner (devices);
+ mockAdb.ShellProperties ["sys.boot_completed"] = "1";
+ mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk";
+
+ var runner = new EmulatorRunner (() => null);
+ var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) };
+
+ var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options);
+
+ Assert.IsTrue (result.Success);
+ Assert.AreEqual ("emulator-5556", result.Serial, "Should find the correct AVD among multiple emulators");
+ }
+
+ // --- Helpers ---
+
+ static string CreateFakeEmulatorSdk ()
+ {
+ var tempDir = Path.Combine (Path.GetTempPath (), $"emu-boot-test-{Path.GetRandomFileName ()}");
+ var emulatorDir = Path.Combine (tempDir, "emulator");
+ Directory.CreateDirectory (emulatorDir);
+
+ var emuName = OS.IsWindows ? "emulator.exe" : "emulator";
+ var emuPath = Path.Combine (emulatorDir, emuName);
+ // Create a fake emulator script that just exits
+ if (OS.IsWindows) {
+ File.WriteAllText (emuPath, "@echo off");
+ } else {
+ File.WriteAllText (emuPath, "#!/bin/sh\nsleep 60\n");
+ // Make executable
+ var psi = new ProcessStartInfo ("chmod", $"+x \"{emuPath}\"") { UseShellExecute = false };
+ Process.Start (psi)?.WaitForExit ();
+ }
+
+ return tempDir;
+ }
+
+ ///
+ /// Mock AdbRunner for testing BootAndWaitAsync without real adb commands.
+ ///
+ class MockAdbRunner : AdbRunner
+ {
+ readonly List devices;
+
+ public Dictionary ShellProperties { get; } = new Dictionary (StringComparer.OrdinalIgnoreCase);
+ public Dictionary ShellCommands { get; } = new Dictionary (StringComparer.OrdinalIgnoreCase);
+ public Action? OnListDevices { get; set; }
+
+ public MockAdbRunner (List devices)
+ : base (() => null)
+ {
+ this.devices = devices;
+ }
+
+ public override Task> ListDevicesAsync (CancellationToken cancellationToken = default)
+ {
+ OnListDevices?.Invoke ();
+ return Task.FromResult> (devices);
+ }
+
+ public override Task GetShellPropertyAsync (string serial, string propertyName, CancellationToken cancellationToken = default)
+ {
+ ShellProperties.TryGetValue (propertyName, out var value);
+ return Task.FromResult (value);
+ }
+
+ public override Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken = default)
+ {
+ ShellCommands.TryGetValue (command, out var value);
+ return Task.FromResult (value);
+ }
+ }
+}