diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 582d2a8f..e2765ace 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -49,6 +49,8 @@ When setting environment variables for SDK tools (e.g. `sdkmanager`, `avdmanager
- **File-scoped namespaces**: all new files should use file-scoped namespaces (`namespace Foo;` instead of `namespace Foo { ... }`).
- **Static `HttpClient`**: `HttpClient` instances must be `static` to avoid socket exhaustion. See [HttpClient guidelines](https://learn.microsoft.com/dotnet/fundamentals/networking/http/httpclient-guidelines#recommended-use). Do not create per-instance `HttpClient` fields or dispose them in `IDisposable`.
- [Mono Coding Guidelines](http://www.mono-project.com/community/contributing/coding-guidelines/): tabs, K&R braces, `PascalCase` public members.
+- **No null-forgiving operator (`!`)**: do not use the null-forgiving operator after null checks. Instead, use C# property patterns (e.g. `if (value is { Length: > 0 } v)`) which give the compiler proper non-null flow analysis on all target frameworks including `netstandard2.0`.
+- **Prefer switch expressions**: use C# switch expressions over switch statements for simple value mappings (e.g. `return state switch { "x" => A, _ => B };`). Use switch statements only when the body has side effects or complex logic.
- Nullable enabled in `AndroidSdk`. `NullableAttributes.cs` excluded on `net10.0+`.
- Strong-named via `product.snk`. In the AndroidSdk project, tests use `InternalsVisibleTo` with full public key (`Properties/AssemblyInfo.cs`).
- Assembly names support `$(VendorPrefix)`/`$(VendorSuffix)` for branding forks.
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs b/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs
index 272a17b1..1ea607f0 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. Takes precedence over
+ /// /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..18b51a49
--- /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..7662d1b3
--- /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/ProcessUtils.cs b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs
index 28113d72..c8a64583 100644
--- a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs
+++ b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs
@@ -203,6 +203,33 @@ 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 (!string.IsNullOrEmpty (stderr))
+ message += $" stderr:{Environment.NewLine}{stderr!.Trim ()}";
+ if (!string.IsNullOrEmpty (stdout))
+ message += $" stdout:{Environment.NewLine}{stdout!.Trim ()}";
+
+ throw new InvalidOperationException (message);
+ }
+
+ ///
+ /// Overload that accepts directly so callers don't need to call ToString().
+ ///
+ internal static void ThrowIfFailed (int exitCode, string command, StringWriter? stderr = null, StringWriter? stdout = null)
+ {
+ ThrowIfFailed (exitCode, command, stderr?.ToString (), stdout?.ToString ());
+ }
+
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..e9237608
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs
@@ -0,0 +1,364 @@
+// 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.Net.Sockets;
+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 string adbPath;
+ readonly IDictionary? environmentVariables;
+
+ // Pattern to match device lines: [key:value ...]
+ // 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);
+
+ ///
+ /// Creates a new AdbRunner with the full path to the adb executable.
+ ///
+ /// Full path to the adb executable (e.g., "/path/to/sdk/platform-tools/adb").
+ /// Optional environment variables to pass to adb processes.
+ public AdbRunner (string adbPath, IDictionary? environmentVariables = null)
+ {
+ if (string.IsNullOrWhiteSpace (adbPath))
+ throw new ArgumentException ("Path to adb must not be empty.", nameof (adbPath));
+ this.adbPath = adbPath;
+ this.environmentVariables = environmentVariables;
+ }
+
+ ///
+ /// 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)
+ {
+ using var stdout = new StringWriter ();
+ using var stderr = new StringWriter ();
+ var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "devices", "-l");
+ var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
+
+ ProcessUtils.ThrowIfFailed (exitCode, "adb devices -l", stderr);
+
+ var devices = ParseAdbDevicesOutput (stdout.ToString ().Split ('\n'));
+
+ // For each emulator, try to get the AVD name
+ foreach (var device in devices) {
+ if (device.Type == AdbDeviceType.Emulator) {
+ device.AvdName = await GetEmulatorAvdNameAsync (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',
+ /// falling back to a direct emulator console TCP query if that fails.
+ /// Ported from dotnet/android GetAvailableAndroidDevices.GetEmulatorAvdName.
+ ///
+ internal async Task GetEmulatorAvdNameAsync (string serial, CancellationToken cancellationToken = default)
+ {
+ try {
+ using var stdout = new StringWriter ();
+ var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "emu", "avd", "name");
+ await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken, environmentVariables).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 ex) {
+ Trace.WriteLine ($"GetEmulatorAvdNameAsync adb query failed for '{serial}': {ex.Message}");
+ }
+
+ // Fallback: query the emulator console directly via TCP.
+ // Emulator serials follow the format "emulator-{port}" where port is the console port.
+ return await QueryAvdNameViaConsoleAsync (serial, cancellationToken).ConfigureAwait (false);
+ }
+
+ ///
+ /// Queries AVD name by connecting to the emulator console port directly.
+ ///
+ static async Task QueryAvdNameViaConsoleAsync (string serial, CancellationToken cancellationToken)
+ {
+ const string EmulatorPrefix = "emulator-";
+ if (!serial.StartsWith (EmulatorPrefix, StringComparison.OrdinalIgnoreCase))
+ return null;
+
+ if (!int.TryParse (serial.Substring (EmulatorPrefix.Length), out var port))
+ return null;
+
+ try {
+ using var client = new TcpClient ();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken);
+ cts.CancelAfter (TimeSpan.FromSeconds (3));
+
+ // ConnectAsync with CancellationToken not available on netstandard2.0;
+ // use Task.Run + token check instead
+ var connectTask = client.ConnectAsync ("127.0.0.1", port);
+ var completed = await Task.WhenAny (connectTask, Task.Delay (3000, cts.Token)).ConfigureAwait (false);
+ if (completed != connectTask) {
+ return null;
+ }
+ await connectTask.ConfigureAwait (false); // observe exceptions
+
+ using var stream = client.GetStream ();
+ stream.ReadTimeout = 3000;
+ stream.WriteTimeout = 3000;
+ using var reader = new StreamReader (stream);
+ using var writer = new StreamWriter (stream) { AutoFlush = true };
+
+ // Read the welcome banner ("Android Console: ...") and "OK"
+ await reader.ReadLineAsync ().ConfigureAwait (false);
+ await reader.ReadLineAsync ().ConfigureAwait (false);
+
+ // Send "avd name" command
+ await writer.WriteLineAsync ("avd name").ConfigureAwait (false);
+
+ // Read AVD name
+ var name = await reader.ReadLineAsync ().ConfigureAwait (false);
+ if (!string.IsNullOrWhiteSpace (name) &&
+ !string.Equals (name, "OK", StringComparison.OrdinalIgnoreCase))
+ return name.Trim ();
+ } catch (OperationCanceledException) {
+ throw;
+ } catch (Exception ex) {
+ Trace.WriteLine ($"QueryAvdNameViaConsoleAsync failed for '{serial}': {ex.Message}");
+ }
+
+ return null;
+ }
+
+ public async Task WaitForDeviceAsync (string? serial = null, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
+ {
+ var effectiveTimeout = timeout ?? TimeSpan.FromSeconds (60);
+
+ if (effectiveTimeout <= TimeSpan.Zero)
+ throw new ArgumentOutOfRangeException (nameof (timeout), effectiveTimeout, "Timeout must be a positive value.");
+
+ var args = string.IsNullOrEmpty (serial)
+ ? new [] { "wait-for-device" }
+ : new [] { "-s", serial, "wait-for-device" };
+
+ var psi = ProcessUtils.CreateProcessStartInfo (adbPath, args);
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken);
+ cts.CancelAfter (effectiveTimeout);
+
+ using var stdout = new StringWriter ();
+ using var stderr = new StringWriter ();
+
+ try {
+ var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cts.Token, environmentVariables).ConfigureAwait (false);
+ ProcessUtils.ThrowIfFailed (exitCode, "adb wait-for-device", stderr, stdout);
+ } 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 psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "emu", "kill");
+ using var stderr = new StringWriter ();
+ var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false);
+ ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} emu kill", stderr);
+ }
+
+ ///
+ /// Parses the output lines from 'adb devices -l'.
+ /// Accepts an to avoid allocating a joined string.
+ ///
+ public static List ParseAdbDevicesOutput (IEnumerable lines)
+ {
+ var devices = new List ();
+
+ foreach (var line in lines) {
+ var trimmed = line.Trim ();
+ if (string.IsNullOrEmpty (trimmed) ||
+ trimmed.IndexOf ("List of devices", StringComparison.OrdinalIgnoreCase) >= 0 ||
+ trimmed.StartsWith ("*", StringComparison.Ordinal))
+ 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) => adbState.ToLowerInvariant () switch {
+ "device" => AdbDeviceStatus.Online,
+ "offline" => AdbDeviceStatus.Offline,
+ "unauthorized" => AdbDeviceStatus.Unauthorized,
+ "no permissions" => AdbDeviceStatus.NoPermissions,
+ _ => 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, Action? logger = null)
+ {
+ if (device.Type == AdbDeviceType.Emulator && device.AvdName is { Length: > 0 } avdName) {
+ logger?.Invoke (TraceLevel.Verbose, $"Emulator {device.Serial}, original AVD name: {avdName}");
+ var formatted = FormatDisplayName (avdName);
+ logger?.Invoke (TraceLevel.Verbose, $"Emulator {device.Serial}, formatted AVD display name: {formatted}");
+ return formatted;
+ }
+
+ if (device.Model is { Length: > 0 } model)
+ return model.Replace ('_', ' ');
+
+ if (device.Product is { Length: > 0 } product)
+ return product.Replace ('_', ' ');
+
+ if (device.Device is { Length: > 0 } deviceName)
+ return deviceName.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 ('_', ' ').ToLowerInvariant ());
+
+ // 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, Action? logger = null)
+ {
+ 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 (device.AvdName is { Length: > 0 } avdName)
+ runningAvdNames.Add (avdName);
+ }
+
+ logger?.Invoke (TraceLevel.Verbose, $"Running emulators AVD names: {string.Join (", ", runningAvdNames)}");
+
+ // Add non-running emulators
+ foreach (var avdName in availableEmulators) {
+ if (runningAvdNames.Contains (avdName)) {
+ logger?.Invoke (TraceLevel.Verbose, $"Emulator '{avdName}' is already running, skipping");
+ continue;
+ }
+
+ var displayName = FormatDisplayName (avdName);
+ result.Add (new AdbDeviceInfo {
+ Serial = avdName,
+ Description = displayName + " (Not Running)",
+ Type = AdbDeviceType.Emulator,
+ Status = AdbDeviceStatus.NotRunning,
+ AvdName = avdName,
+ });
+ logger?.Invoke (TraceLevel.Verbose, $"Added non-running emulator: {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..51f6309d
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs
@@ -0,0 +1,40 @@
+// 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;
+
+namespace Xamarin.Android.Tools;
+
+///
+/// Helper for building environment variables for Android SDK tools.
+/// Returns a dictionary that can be passed to .
+///
+internal static class AndroidEnvironmentHelper
+{
+ ///
+ /// Builds environment variables needed to run Android SDK tools.
+ /// Pass the result to via the environmentVariables parameter.
+ ///
+ internal static Dictionary GetEnvironmentVariables (string? sdkPath, string? jdkPath)
+ {
+ var env = new Dictionary ();
+
+ if (sdkPath is { Length: > 0 })
+ env [EnvironmentVariableNames.AndroidHome] = sdkPath;
+
+ if (jdkPath is { Length: > 0 }) {
+ env [EnvironmentVariableNames.JavaHome] = jdkPath;
+ var jdkBin = Path.Combine (jdkPath, "bin");
+ var currentPath = Environment.GetEnvironmentVariable (EnvironmentVariableNames.Path) ?? "";
+ env [EnvironmentVariableNames.Path] = string.IsNullOrEmpty (currentPath) ? jdkBin : jdkBin + Path.PathSeparator + currentPath;
+ }
+
+ if (string.IsNullOrEmpty (Environment.GetEnvironmentVariable (EnvironmentVariableNames.AndroidUserHome)))
+ env [EnvironmentVariableNames.AndroidUserHome] = Path.Combine (
+ Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), ".android");
+
+ return env;
+ }
+}
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Process.cs b/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Process.cs
index 96d70ef8..d696d031 100644
--- a/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Process.cs
+++ b/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Process.cs
@@ -93,18 +93,7 @@ async Task DownloadFileAsync (string url, string destinationPath, long expectedS
Dictionary GetEnvironmentVariables ()
{
- var env = new Dictionary {
- ["ANDROID_USER_HOME"] = Path.Combine (
- Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), ".android")
- };
-
- if (!string.IsNullOrEmpty (AndroidSdkPath))
- env[EnvironmentVariableNames.AndroidHome] = AndroidSdkPath!;
-
- if (!string.IsNullOrEmpty (JavaSdkPath))
- env[EnvironmentVariableNames.JavaHome] = JavaSdkPath!;
-
- return env;
+ return AndroidEnvironmentHelper.GetEnvironmentVariables (AndroidSdkPath, JavaSdkPath);
}
}
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..e9e283f1
--- /dev/null
+++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs
@@ -0,0 +1,632 @@
+// 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 NUnit.Framework;
+
+namespace Xamarin.Android.Tools.Tests;
+
+///
+/// Tests for AdbRunner parsing, formatting, and merging logic.
+/// Ported from dotnet/android GetAvailableAndroidDevicesTests.
+///
+/// API consumer reference:
+/// - ParseAdbDevicesOutput: used by dotnet/android GetAvailableAndroidDevices task
+/// - BuildDeviceDescription: used by dotnet/android GetAvailableAndroidDevices task
+/// - FormatDisplayName: used by dotnet/android GetAvailableAndroidDevices tests
+/// - MergeDevicesAndEmulators: used by dotnet/android GetAvailableAndroidDevices task
+/// - MapAdbStateToStatus: used internally by ParseAdbDevicesOutput, public for extensibility
+/// - ListDevicesAsync: used by MAUI DevTools Adb provider (Providers/Android/Adb.cs)
+/// - WaitForDeviceAsync: used by MAUI DevTools Adb provider
+/// - StopEmulatorAsync: used by MAUI DevTools Adb provider
+/// - GetEmulatorAvdNameAsync: internal, used by ListDevicesAsync only
+///
+[TestFixture]
+public class AdbRunnerTests
+{
+ // --- ParseAdbDevicesOutput tests ---
+ // Consumer: dotnet/android GetAvailableAndroidDevices.cs, MAUI DevTools (via ListDevicesAsync)
+
+ [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.Split ('\n'));
+
+ 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.Split ('\n'));
+ 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.Split ('\n'));
+
+ 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.Split ('\n'));
+
+ 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.Split ('\n'));
+
+ 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.Split ('\n'));
+
+ 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.Split ('\n'));
+
+ 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.Split ('\n'));
+
+ 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.Split ('\n'));
+
+ 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.Split ('\n'));
+
+ 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.Split ('\n'));
+
+ Assert.AreEqual (1, devices.Count);
+ Assert.AreEqual ("emulator-5554", devices [0].Serial);
+ Assert.IsTrue (devices [0].IsEmulator);
+ }
+
+ [Test]
+ public void ParseAdbDevicesOutput_TabSeparator ()
+ {
+ var output =
+ "List of devices attached\n" +
+ "emulator-5554\tdevice\n" +
+ "R5CR10YZQPJ\tdevice\n";
+
+ var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n'));
+
+ Assert.AreEqual (2, devices.Count);
+ Assert.AreEqual ("emulator-5554", devices [0].Serial);
+ Assert.AreEqual ("R5CR10YZQPJ", devices [1].Serial);
+ }
+
+ [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.Split ('\n'));
+
+ 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.Split ('\n'));
+
+ Assert.AreEqual (2, devices.Count, "Should parse devices even with daemon startup messages");
+ }
+
+ // --- BuildDeviceDescription tests ---
+ // Consumer: dotnet/android GetAvailableAndroidDevices.cs
+
+ [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.Split ('\n'));
+ 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.Split ('\n'));
+ 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.Split ('\n'));
+ Assert.AreEqual ("device name", devices3 [0].Description, "Device should have third priority");
+ }
+
+ // --- FormatDisplayName tests ---
+ // Consumer: dotnet/android GetAvailableAndroidDevicesTests, BuildDeviceDescription (via AVD name formatting)
+
+ [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 ---
+ // Consumer: ParseAdbDevicesOutput (internal mapping), public for custom consumers
+
+ [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 ---
+ // Consumer: dotnet/android GetAvailableAndroidDevices.cs
+
+ [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 ---
+ // Consumer: MAUI DevTools Adb provider (AdbPath, IsAvailable properties)
+
+ [Test]
+ public void Constructor_NullPath_ThrowsArgumentException ()
+ {
+ Assert.Throws (() => new AdbRunner (null!));
+ }
+
+ [Test]
+ public void Constructor_EmptyPath_ThrowsArgumentException ()
+ {
+ Assert.Throws (() => new AdbRunner (""));
+ }
+
+ [Test]
+ public void Constructor_WhitespacePath_ThrowsArgumentException ()
+ {
+ Assert.Throws (() => new AdbRunner (" "));
+ }
+
+ [Test]
+ public void ParseAdbDevicesOutput_DeviceWithProductOnly ()
+ {
+ var output =
+ "List of devices attached\n" +
+ "emulator-5554 device product:aosp_x86_64\n";
+
+ var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n'));
+
+ Assert.AreEqual (1, devices.Count);
+ Assert.AreEqual ("aosp_x86_64", devices [0].Product);
+ Assert.AreEqual (AdbDeviceType.Emulator, devices [0].Type);
+ }
+
+ [Test]
+ public void ParseAdbDevicesOutput_MultipleDevices ()
+ {
+ var output =
+ "List of devices attached\n" +
+ "emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1\n" +
+ "emulator-5556 device product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64x transport_id:3\n" +
+ "0A041FDD400327 device usb:1-1 product:raven model:Pixel_6_Pro device:raven transport_id:2\n";
+
+ var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n'));
+
+ Assert.AreEqual (3, devices.Count);
+ Assert.AreEqual ("emulator-5554", devices [0].Serial);
+ Assert.AreEqual (AdbDeviceType.Emulator, devices [0].Type);
+ Assert.AreEqual ("emulator-5556", devices [1].Serial);
+ Assert.AreEqual (AdbDeviceType.Emulator, devices [1].Type);
+ Assert.AreEqual ("0A041FDD400327", devices [2].Serial);
+ Assert.AreEqual (AdbDeviceType.Device, devices [2].Type);
+ Assert.AreEqual ("Pixel_6_Pro", devices [2].Model);
+ }
+
+ [Test]
+ public void MergeDevicesAndEmulators_AllEmulatorsRunning_NoDuplicate ()
+ {
+ var emulator1 = new AdbDeviceInfo {
+ Serial = "emulator-5554", Description = "Pixel 7 API 35",
+ Type = AdbDeviceType.Emulator, Status = AdbDeviceStatus.Online,
+ AvdName = "pixel_7_api_35",
+ };
+ var emulator2 = new AdbDeviceInfo {
+ Serial = "emulator-5556", Description = "Pixel 9 API 36",
+ Type = AdbDeviceType.Emulator, Status = AdbDeviceStatus.Online,
+ AvdName = "pixel_9_api_36",
+ };
+
+ var result = AdbRunner.MergeDevicesAndEmulators (
+ new List { emulator1, emulator2 },
+ new List { "pixel_7_api_35", "pixel_9_api_36" });
+
+ Assert.AreEqual (2, result.Count, "Should not add duplicates when all emulators are running");
+ Assert.IsTrue (result.TrueForAll (d => d.Status == AdbDeviceStatus.Online));
+ }
+
+ [Test]
+ public void MergeDevicesAndEmulators_NonRunningEmulatorHasFormattedDescription ()
+ {
+ var result = AdbRunner.MergeDevicesAndEmulators (
+ new List (),
+ new List { "pixel_7_pro_api_35" });
+
+ Assert.AreEqual (1, result.Count);
+ Assert.AreEqual ("Pixel 7 Pro API 35 (Not Running)", result [0].Description);
+ Assert.AreEqual (AdbDeviceStatus.NotRunning, result [0].Status);
+ }
+
+ [Test]
+ public void ParseAdbDevicesOutput_RecoveryState ()
+ {
+ var output = "List of devices attached\n" +
+ "0A041FDD400327 recovery product:redfin model:Pixel_5 device:redfin transport_id:2\n";
+
+ var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n'));
+
+ Assert.AreEqual (1, devices.Count);
+ Assert.AreEqual ("0A041FDD400327", devices [0].Serial);
+ Assert.AreEqual (AdbDeviceStatus.Unknown, devices [0].Status);
+ }
+
+ [Test]
+ public void ParseAdbDevicesOutput_SideloadState ()
+ {
+ var output = "List of devices attached\n" +
+ "0A041FDD400327 sideload\n";
+
+ var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n'));
+
+ Assert.AreEqual (1, devices.Count);
+ Assert.AreEqual ("0A041FDD400327", devices [0].Serial);
+ Assert.AreEqual (AdbDeviceStatus.Unknown, devices [0].Status);
+ }
+
+ [Test]
+ public void MapAdbStateToStatus_Recovery_ReturnsUnknown ()
+ {
+ Assert.AreEqual (AdbDeviceStatus.Unknown, AdbRunner.MapAdbStateToStatus ("recovery"));
+ }
+
+ [Test]
+ public void MapAdbStateToStatus_Sideload_ReturnsUnknown ()
+ {
+ Assert.AreEqual (AdbDeviceStatus.Unknown, AdbRunner.MapAdbStateToStatus ("sideload"));
+ }
+
+ // --- WaitForDeviceAsync tests ---
+ // Consumer: MAUI DevTools Adb provider (WaitForDeviceAsync)
+
+ [Test]
+ public void WaitForDeviceAsync_NegativeTimeout_ThrowsArgumentOutOfRange ()
+ {
+ var runner = new AdbRunner ("/fake/sdk/platform-tools/adb");
+ Assert.ThrowsAsync (
+ async () => await runner.WaitForDeviceAsync (timeout: System.TimeSpan.FromSeconds (-1)));
+ }
+
+ [Test]
+ public void WaitForDeviceAsync_ZeroTimeout_ThrowsArgumentOutOfRange ()
+ {
+ var runner = new AdbRunner ("/fake/sdk/platform-tools/adb");
+ Assert.ThrowsAsync (
+ async () => await runner.WaitForDeviceAsync (timeout: System.TimeSpan.Zero));
+ }
+}
diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/RunnerIntegrationTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/RunnerIntegrationTests.cs
new file mode 100644
index 00000000..a057073e
--- /dev/null
+++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/RunnerIntegrationTests.cs
@@ -0,0 +1,163 @@
+// 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;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+using NUnit.Framework;
+
+namespace Xamarin.Android.Tools.Tests;
+
+///
+/// Integration tests that verify AdbRunner works against real Android SDK tools.
+///
+/// These tests only run on CI (TF_BUILD=True or CI=true) where hosted
+/// images have JDK (JAVA_HOME) and Android SDK (ANDROID_HOME) pre-installed.
+/// Tests are skipped on local developer machines.
+///
+[TestFixture]
+[Category ("Integration")]
+public class RunnerIntegrationTests
+{
+ static string sdkPath;
+ static string jdkPath;
+ static string adbPath;
+ static SdkManager sdkManager;
+ static string bootstrappedSdkPath;
+
+ static void Log (TraceLevel level, string message)
+ {
+ TestContext.Progress.WriteLine ($"[{level}] {message}");
+ }
+
+ static void RequireCi ()
+ {
+ if (Environment.GetEnvironmentVariable ("TF_BUILD") is null &&
+ Environment.GetEnvironmentVariable ("CI") is null) {
+ Assert.Ignore ("Integration tests only run on CI (TF_BUILD or CI env var must be set).");
+ }
+ }
+
+ ///
+ /// One-time setup: use pre-installed JDK/SDK on CI agents, bootstrap
+ /// cmdline-tools only if needed. Azure Pipelines hosted images have
+ /// JAVA_HOME and ANDROID_HOME already configured.
+ ///
+ [OneTimeSetUp]
+ public async Task OneTimeSetUp ()
+ {
+ RequireCi ();
+
+ // Use pre-installed JDK from JAVA_HOME (always available on CI agents)
+ jdkPath = Environment.GetEnvironmentVariable (EnvironmentVariableNames.JavaHome);
+ if (string.IsNullOrEmpty (jdkPath) || !Directory.Exists (jdkPath)) {
+ Assert.Ignore ("JAVA_HOME not set or invalid — cannot run integration tests.");
+ return;
+ }
+ TestContext.Progress.WriteLine ($"Using JDK from JAVA_HOME: {jdkPath}");
+
+ // Use pre-installed Android SDK from ANDROID_HOME (available on CI agents)
+ sdkPath = Environment.GetEnvironmentVariable (EnvironmentVariableNames.AndroidHome);
+ if (string.IsNullOrEmpty (sdkPath) || !Directory.Exists (sdkPath)) {
+ // Fall back to bootstrapping our own SDK
+ TestContext.Progress.WriteLine ("ANDROID_HOME not set — bootstrapping SDK...");
+ try {
+ bootstrappedSdkPath = Path.Combine (Path.GetTempPath (), $"runner-integration-{Guid.NewGuid ():N}", "android-sdk");
+ sdkManager = new SdkManager (Log);
+ sdkManager.JavaSdkPath = jdkPath;
+ using var cts = new CancellationTokenSource (TimeSpan.FromMinutes (10));
+ await sdkManager.BootstrapAsync (bootstrappedSdkPath, cancellationToken: cts.Token);
+ sdkPath = bootstrappedSdkPath;
+ sdkManager.AndroidSdkPath = sdkPath;
+
+ // Install platform-tools (provides adb)
+ await sdkManager.InstallAsync (new [] { "platform-tools" }, acceptLicenses: true, cancellationToken: cts.Token);
+ TestContext.Progress.WriteLine ($"SDK bootstrapped to: {sdkPath}");
+ }
+ catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException || ex is InvalidOperationException) {
+ Assert.Ignore ($"SDK bootstrap failed: {ex.Message}");
+ return;
+ }
+ }
+ else {
+ TestContext.Progress.WriteLine ($"Using SDK from ANDROID_HOME: {sdkPath}");
+ sdkManager = new SdkManager (Log);
+ sdkManager.JavaSdkPath = jdkPath;
+ sdkManager.AndroidSdkPath = sdkPath;
+ }
+
+ // Resolve the full path to adb for AdbRunner
+ var adbExe = OS.IsWindows ? "adb.exe" : "adb";
+ adbPath = Path.Combine (sdkPath, "platform-tools", adbExe);
+ if (!File.Exists (adbPath))
+ Assert.Ignore ($"adb not found at {adbPath}");
+ }
+
+ [OneTimeTearDown]
+ public void OneTimeTearDown ()
+ {
+ sdkManager?.Dispose ();
+
+ // Only clean up if we bootstrapped our own SDK
+ if (bootstrappedSdkPath != null) {
+ var basePath = Path.GetDirectoryName (bootstrappedSdkPath);
+ if (basePath != null && Directory.Exists (basePath)) {
+ try {
+ Directory.Delete (basePath, recursive: true);
+ }
+ catch {
+ // Best-effort cleanup on CI
+ }
+ }
+ }
+ }
+
+ // ── AdbRunner integration ──────────────────────────────────────
+
+ [Test]
+ public void AdbRunner_Constructor_AcceptsValidPath ()
+ {
+ var runner = new AdbRunner (adbPath);
+ Assert.IsNotNull (runner);
+ }
+
+ [Test]
+ public async Task AdbRunner_ListDevicesAsync_ReturnsWithoutError ()
+ {
+ var runner = new AdbRunner (adbPath);
+
+ // On CI there are no physical devices or emulators, but the command
+ // should succeed and return an empty (or non-null) list.
+ var devices = await runner.ListDevicesAsync ();
+
+ Assert.IsNotNull (devices);
+ TestContext.Progress.WriteLine ($"ListDevicesAsync returned {devices.Count} device(s)");
+ }
+
+ [Test]
+ public void AdbRunner_WaitForDeviceAsync_TimesOut_WhenNoDevice ()
+ {
+ var runner = new AdbRunner (adbPath);
+ var ex = Assert.ThrowsAsync (async () =>
+ await runner.WaitForDeviceAsync (timeout: TimeSpan.FromSeconds (5)));
+
+ Assert.IsNotNull (ex);
+ TestContext.Progress.WriteLine ($"WaitForDeviceAsync timed out as expected: {ex!.Message}");
+ }
+
+ // ── Cross-runner: verify tools exist ───────────────────────────
+
+ [Test]
+ public void AllRunners_ToolDiscovery_ConsistentWithSdk ()
+ {
+ var runner = new AdbRunner (adbPath);
+
+ // adb path should be under the SDK
+ Assert.IsTrue (File.Exists (adbPath), $"adb should exist at {adbPath}");
+ StringAssert.StartsWith (sdkPath, adbPath);
+ }
+}