From 46cf4d28ba06b51a3c2c54cf044ded54e9de0b5b Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 4 Mar 2026 10:58:36 +0000 Subject: [PATCH 01/11] Add AdbRunner for adb CLI operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port adb device parsing, formatting, and merging logic from dotnet/android GetAvailableAndroidDevices MSBuild task into a shared AdbRunner class. New files: - AdbRunner.cs — ListDevicesAsync, WaitForDeviceAsync, StopEmulatorAsync, ParseAdbDevicesOutput, BuildDeviceDescription, FormatDisplayName, MapAdbStateToStatus, MergeDevicesAndEmulators - AdbDeviceInfo/AdbDeviceType/AdbDeviceStatus — device models - AndroidEnvironmentHelper — shared env var builder for all runners - ProcessUtils.ThrowIfFailed — shared exit code validation Modified files: - EnvironmentVariableNames — add ANDROID_USER_HOME, ANDROID_AVD_HOME - SdkManager.Process.cs — deduplicate env var logic via AndroidEnvironmentHelper Tests: - 43 unit tests (parsing, formatting, merging, path discovery, timeout) - 5 integration tests (CI-only, real SDK tools) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../EnvironmentVariableNames.cs | 12 + .../Models/AdbDeviceInfo.cs | 62 ++ .../Models/AdbDeviceStatus.cs | 17 + .../Models/AdbDeviceType.cs | 13 + .../ProcessUtils.cs | 19 + .../Runners/AdbRunner.cs | 331 +++++++++ .../Runners/AndroidEnvironmentHelper.cs | 40 ++ .../SdkManager.Process.cs | 13 +- .../AdbRunnerTests.cs | 627 ++++++++++++++++++ .../RunnerIntegrationTests.cs | 163 +++++ 10 files changed, 1285 insertions(+), 12 deletions(-) create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceInfo.cs create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceStatus.cs create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceType.cs create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs create mode 100644 src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs create mode 100644 tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs create mode 100644 tests/Xamarin.Android.Tools.AndroidSdk-Tests/RunnerIntegrationTests.cs 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..b4030846 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs @@ -203,6 +203,25 @@ 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); + } + 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..8a5af3a0 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -0,0 +1,331 @@ +// 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; + readonly Func? getJdkPath; + + // Pattern to match device lines: [key:value ...] + // Requires 2+ spaces between serial and state (adb pads serials). + // Matches known multi-word states (e.g. "no permissions") and any single-word state + // so unknown states (recovery, sideload, etc.) are not silently dropped. + static readonly Regex AdbDevicesRegex = new Regex ( + @"^([^\s]+)\s{2,}(no permissions|\S+)\s*(.*)$", RegexOptions.Compiled); + static readonly Regex ApiRegex = new Regex (@"\bApi\b", RegexOptions.Compiled); + + public AdbRunner (Func getSdkPath) + : this (getSdkPath, null) + { + } + + public AdbRunner (Func getSdkPath, Func? getJdkPath) + { + this.getSdkPath = getSdkPath ?? throw new ArgumentNullException (nameof (getSdkPath)); + this.getJdkPath = getJdkPath; + } + + 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."); + } + + IDictionary GetEnvironmentVariables () + { + return AndroidEnvironmentHelper.GetEnvironmentVariables (getSdkPath (), getJdkPath?.Invoke ()); + } + + /// + /// Lists connected devices using 'adb devices -l'. + /// For emulators, queries the AVD name using 'adb -s <serial> emu avd name'. + /// + public async Task> ListDevicesAsync (CancellationToken cancellationToken = default) + { + var adb = RequireAdb (); + var envVars = GetEnvironmentVariables (); + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (adb, "devices", "-l"); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, envVars).ConfigureAwait (false); + + ProcessUtils.ThrowIfFailed (exitCode, "adb devices -l", stderr.ToString ()); + + var devices = ParseAdbDevicesOutput (stdout.ToString ()); + + // For each emulator, try to get the AVD name + foreach (var device in devices) { + if (device.Type == AdbDeviceType.Emulator) { + device.AvdName = await GetEmulatorAvdNameAsync (adb, device.Serial, cancellationToken).ConfigureAwait (false); + device.Description = BuildDeviceDescription (device); + } + } + + return devices; + } + + /// + /// Queries the emulator for its AVD name using 'adb -s <serial> emu avd name'. + /// Ported from dotnet/android GetAvailableAndroidDevices.GetEmulatorAvdName. + /// + internal async Task GetEmulatorAvdNameAsync (string adbPath, string serial, CancellationToken cancellationToken = default) + { + try { + var envVars = GetEnvironmentVariables (); + using var stdout = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "emu", "avd", "name"); + await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken, envVars).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 effectiveTimeout = timeout ?? TimeSpan.FromSeconds (60); + + if (effectiveTimeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException (nameof (timeout), effectiveTimeout, "Timeout must be a positive value."); + + var adb = RequireAdb (); + var envVars = GetEnvironmentVariables (); + + var args = string.IsNullOrEmpty (serial) + ? new [] { "wait-for-device" } + : new [] { "-s", serial, "wait-for-device" }; + + var psi = ProcessUtils.CreateProcessStartInfo (adb, 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, envVars).ConfigureAwait (false); + ProcessUtils.ThrowIfFailed (exitCode, "adb wait-for-device", stderr.ToString (), stdout.ToString ()); + } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + throw new TimeoutException ($"Timed out waiting for device after {effectiveTimeout.TotalSeconds}s."); + } + } + + public async Task StopEmulatorAsync (string serial, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace (serial)) + throw new ArgumentException ("Serial must not be empty.", nameof (serial)); + + var adb = RequireAdb (); + var envVars = GetEnvironmentVariables (); + var psi = ProcessUtils.CreateProcessStartInfo (adb, "-s", serial, "emu", "kill"); + await ProcessUtils.StartProcess (psi, null, null, cancellationToken, envVars).ConfigureAwait (false); + } + + /// + /// 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 || + 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) + { + 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 ('_', ' ').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) + { + 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..17994396 --- /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 (!string.IsNullOrEmpty (sdkPath)) + env [EnvironmentVariableNames.AndroidHome] = sdkPath; + + if (!string.IsNullOrEmpty (jdkPath)) { + 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..64a71489 --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs @@ -0,0 +1,627 @@ +// 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, 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); + + 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"); + } + + // --- 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); + 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 --- + // 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 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; + } + + [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); + + 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); + + 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); + + 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); + + 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"); + Assert.ThrowsAsync ( + async () => await runner.WaitForDeviceAsync (timeout: System.TimeSpan.FromSeconds (-1))); + } + + [Test] + public void WaitForDeviceAsync_ZeroTimeout_ThrowsArgumentOutOfRange () + { + var runner = new AdbRunner (() => "/fake/sdk"); + 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..c5c213cb --- /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. +/// When the pre-installed SDK lacks cmdline-tools, the tests bootstrap them. +/// Tests are skipped on local developer machines. +/// +[TestFixture] +[Category ("Integration")] +public class RunnerIntegrationTests +{ + static string sdkPath; + static string jdkPath; + 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; + } + } + + [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_IsAvailable_WithSdk () + { + var runner = new AdbRunner (() => sdkPath); + + Assert.IsTrue (runner.IsAvailable, "AdbRunner should find adb in SDK"); + Assert.IsNotNull (runner.AdbPath); + Assert.IsTrue (File.Exists (runner.AdbPath), $"adb binary should exist at {runner.AdbPath}"); + } + + [Test] + public async Task AdbRunner_ListDevicesAsync_ReturnsWithoutError () + { + var runner = new AdbRunner (() => sdkPath); + + // 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 (() => sdkPath); + + // With no devices connected, wait-for-device should time out + 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 adb = new AdbRunner (() => sdkPath); + + Assert.IsTrue (adb.IsAvailable, "adb should be available"); + + // adb path should be under the SDK + StringAssert.StartsWith (sdkPath, adb.AdbPath!); + } +} From bda41068beaee1bcc4eb9d4dd953249c8ec7a21b Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 4 Mar 2026 12:11:37 +0000 Subject: [PATCH 02/11] Address review: exit code check, internal visibility, doc fix - StopEmulatorAsync now captures stderr and calls ThrowIfFailed for consistency with ListDevicesAsync/WaitForDeviceAsync. - ThrowIfFailed changed from public to internal since it is only used within the library. - Remove inaccurate cmdline-tools bootstrap claim from integration test doc comment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs | 2 +- src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs | 4 +++- .../RunnerIntegrationTests.cs | 1 - 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs index b4030846..b5c5f48a 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs @@ -207,7 +207,7 @@ static string JoinArguments (string[] args) /// 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) + internal static void ThrowIfFailed (int exitCode, string command, string? stderr = null, string? stdout = null) { if (exitCode == 0) return; diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index 8a5af3a0..8cb03ec0 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -160,7 +160,9 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati var adb = RequireAdb (); var envVars = GetEnvironmentVariables (); var psi = ProcessUtils.CreateProcessStartInfo (adb, "-s", serial, "emu", "kill"); - await ProcessUtils.StartProcess (psi, null, null, cancellationToken, envVars).ConfigureAwait (false); + using var stderr = new StringWriter (); + var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, envVars).ConfigureAwait (false); + ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} emu kill", stderr.ToString ()); } /// diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/RunnerIntegrationTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/RunnerIntegrationTests.cs index c5c213cb..e1e6759b 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/RunnerIntegrationTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/RunnerIntegrationTests.cs @@ -17,7 +17,6 @@ namespace Xamarin.Android.Tools.Tests; /// /// 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. -/// When the pre-installed SDK lacks cmdline-tools, the tests bootstrap them. /// Tests are skipped on local developer machines. /// [TestFixture] From 669121a3354bcb1632d50fca7dd36f380bd57a42 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 4 Mar 2026 12:24:54 +0000 Subject: [PATCH 03/11] Add IEnumerable overload for ParseAdbDevicesOutput Avoids allocating a joined string when callers already have individual lines (e.g., MSBuild LogEventsFromTextOutput). The existing string overload now delegates to the new one. Addresses review feedback from @jonathanpeppers on dotnet/android#10880. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runners/AdbRunner.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index 8cb03ec0..51ad3e60 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -170,10 +170,19 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati /// Ported from dotnet/android GetAvailableAndroidDevices.ParseAdbDevicesOutput. /// public static List ParseAdbDevicesOutput (string output) + { + return ParseAdbDevicesOutput (output.Split ('\n')); + } + + /// + /// Parses individual lines from 'adb devices -l' output. + /// Accepts an to avoid allocating a joined string. + /// + public static List ParseAdbDevicesOutput (IEnumerable lines) { var devices = new List (); - foreach (var line in output.Split ('\n')) { + foreach (var line in lines) { var trimmed = line.Trim (); if (string.IsNullOrEmpty (trimmed) || trimmed.IndexOf ("List of devices", StringComparison.OrdinalIgnoreCase) >= 0 || From c14219883d48dd0fd4f65f623224f5a144e59e6c Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 4 Mar 2026 12:33:26 +0000 Subject: [PATCH 04/11] Add optional logger callback to BuildDeviceDescription and MergeDevicesAndEmulators Accepts Action to route debug messages through the caller's logging infrastructure (e.g., MSBuild TaskLoggingHelper). Restores log messages lost when logic moved from dotnet/android to android-tools: AVD name formatting, running emulator detection, and non-running emulator additions. Follows the existing CreateTaskLogger pattern used by JdkInstaller. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runners/AdbRunner.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index 51ad3e60..fd31bbd0 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -259,10 +259,14 @@ public static AdbDeviceStatus MapAdbStateToStatus (string adbState) /// Priority: AVD name (for emulators) > model > product > device > serial. /// Ported from dotnet/android GetAvailableAndroidDevices.BuildDeviceDescription. /// - public static string BuildDeviceDescription (AdbDeviceInfo device) + public static string BuildDeviceDescription (AdbDeviceInfo device, Action? logger = null) { - if (device.Type == AdbDeviceType.Emulator && !string.IsNullOrEmpty (device.AvdName)) - return FormatDisplayName (device.AvdName!); + if (device.Type == AdbDeviceType.Emulator && !string.IsNullOrEmpty (device.AvdName)) { + logger?.Invoke (TraceLevel.Verbose, $"Emulator {device.Serial}, original AVD name: {device.AvdName}"); + var formatted = FormatDisplayName (device.AvdName!); + logger?.Invoke (TraceLevel.Verbose, $"Emulator {device.Serial}, formatted AVD display name: {formatted}"); + return formatted; + } if (!string.IsNullOrEmpty (device.Model)) return device.Model!.Replace ('_', ' '); @@ -299,7 +303,7 @@ public static string FormatDisplayName (string avdName) /// 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) + public static List MergeDevicesAndEmulators (IReadOnlyList adbDevices, IReadOnlyList availableEmulators, Action? logger = null) { var result = new List (adbDevices); @@ -310,10 +314,14 @@ public static List MergeDevicesAndEmulators (IReadOnlyList MergeDevicesAndEmulators (IReadOnlyList Date: Wed, 4 Mar 2026 17:10:48 +0000 Subject: [PATCH 05/11] Fix nullable reference type warnings for dotnet/android compatibility dotnet/android compiles the submodule as netstandard2.0 with WarningsAsErrors=Nullable. In netstandard2.0, string.IsNullOrEmpty lacks [NotNullWhen(false)], so the compiler doesn't narrow string? to string after null checks. Add null-forgiving operators where the preceding guard guarantees non-null. Fixes: CS8601 in AndroidEnvironmentHelper.cs (sdkPath, jdkPath) Fixes: CS8620 in AdbRunner.cs (serial in string[] array literal) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs | 2 +- .../Runners/AndroidEnvironmentHelper.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index fd31bbd0..f2bd9646 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -134,7 +134,7 @@ public async Task WaitForDeviceAsync (string? serial = null, TimeSpan? timeout = var args = string.IsNullOrEmpty (serial) ? new [] { "wait-for-device" } - : new [] { "-s", serial, "wait-for-device" }; + : new [] { "-s", serial!, "wait-for-device" }; var psi = ProcessUtils.CreateProcessStartInfo (adb, args); diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs index 17994396..2cfffae4 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs @@ -22,11 +22,11 @@ internal static Dictionary GetEnvironmentVariables (string? sdkP var env = new Dictionary (); if (!string.IsNullOrEmpty (sdkPath)) - env [EnvironmentVariableNames.AndroidHome] = sdkPath; + env [EnvironmentVariableNames.AndroidHome] = sdkPath!; if (!string.IsNullOrEmpty (jdkPath)) { - env [EnvironmentVariableNames.JavaHome] = jdkPath; - var jdkBin = Path.Combine (jdkPath, "bin"); + 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; } From 9c9b8853d839d4e85a6115d94e61f1c6232bfef9 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 4 Mar 2026 17:36:25 +0000 Subject: [PATCH 06/11] Fix regex to use explicit adb states and support tab separators - Replace \s{2,} with \s+ to handle tab-separated adb output - Use explicit state list (device|offline|unauthorized|etc.) instead of \S+ to prevent false positives from non-device lines - Add ParseAdbDevicesOutput_TabSeparator test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runners/AdbRunner.cs | 7 ++++--- .../AdbRunnerTests.cs | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index f2bd9646..7d16e241 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -24,10 +24,11 @@ public class AdbRunner // Pattern to match device lines: [key:value ...] // Requires 2+ spaces between serial and state (adb pads serials). - // Matches known multi-word states (e.g. "no permissions") and any single-word state - // so unknown states (recovery, sideload, etc.) are not silently dropped. + // 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{2,}(no permissions|\S+)\s*(.*)$", RegexOptions.Compiled); + @"^([^\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) diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs index 64a71489..575cce8c 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs @@ -215,6 +215,21 @@ public void ParseAdbDevicesOutput_WindowsNewlines () 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); + + Assert.AreEqual (2, devices.Count); + Assert.AreEqual ("emulator-5554", devices [0].Serial); + Assert.AreEqual ("R5CR10YZQPJ", devices [1].Serial); + } + [Test] public void ParseAdbDevicesOutput_IpPortDevice () { From 7801c18acef681a2ffac99e392b41e85d5690c37 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 4 Mar 2026 20:10:05 +0000 Subject: [PATCH 07/11] Log exception in GetEmulatorAvdNameAsync bare catch block Address review feedback: replace bare catch with catch(Exception ex) and log via Trace.WriteLine for debuggability. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index 7d16e241..e9b0cd58 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -116,8 +116,8 @@ public async Task> ListDevicesAsync (CancellationTo } } catch (OperationCanceledException) { throw; - } catch { - // Silently ignore failures (emulator may not support this command) + } catch (Exception ex) { + Trace.WriteLine ($"GetEmulatorAvdNameAsync failed for '{serial}': {ex.Message}"); } return null; From 763d9a4cb79c0011a1b3bbf9ae99ad1e1de79055 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 4 Mar 2026 20:20:46 +0000 Subject: [PATCH 08/11] Add emulator console fallback for AVD name resolution When 'adb emu avd name' fails (common on macOS), fall back to querying the emulator console directly via TCP on the console port extracted from the serial (emulator-XXXX -> port XXXX). This fixes duplicate device entries when running emulators can't be matched with their AVD definitions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runners/AdbRunner.cs | 61 ++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index e9b0cd58..c3847291 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -7,6 +7,7 @@ using System.Globalization; using System.IO; using System.Linq; +using System.Net.Sockets; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -96,7 +97,8 @@ public async Task> ListDevicesAsync (CancellationTo } /// - /// Queries the emulator for its AVD name using 'adb -s <serial> emu avd name'. + /// 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 adbPath, string serial, CancellationToken cancellationToken = default) @@ -117,7 +119,62 @@ public async Task> ListDevicesAsync (CancellationTo } catch (OperationCanceledException) { throw; } catch (Exception ex) { - Trace.WriteLine ($"GetEmulatorAvdNameAsync failed for '{serial}': {ex.Message}"); + 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; From b68bea9fc7749703ee78df72a6792b76410431ec Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 5 Mar 2026 12:17:54 +0000 Subject: [PATCH 09/11] Simplify AdbRunner constructor: require full adb path Address review feedback (threads 41-43): replace Func getSdkPath constructor with string adbPath that takes the full path to the adb executable. Remove AdbPath property, IsAvailable property, RequireAdb(), PATH discovery fallback, and getSdkPath/getJdkPath fields. Callers are now responsible for resolving the adb path before constructing. Environment variables can optionally be passed via the constructor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runners/AdbRunner.cs | 79 ++++++------------- .../AdbRunnerTests.cs | 34 +++----- .../RunnerIntegrationTests.cs | 29 +++---- 3 files changed, 50 insertions(+), 92 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index c3847291..c239558c 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -6,7 +6,6 @@ using System.Diagnostics; using System.Globalization; using System.IO; -using System.Linq; using System.Net.Sockets; using System.Text.RegularExpressions; using System.Threading; @@ -20,52 +19,28 @@ namespace Xamarin.Android.Tools; /// public class AdbRunner { - readonly Func getSdkPath; - readonly Func? getJdkPath; + readonly string adbPath; + readonly IDictionary? environmentVariables; // Pattern to match device lines: [key:value ...] - // Requires 2+ spaces between serial and state (adb pads serials). - // Matches known adb device states. Uses \s+ to handle both space and tab separators. + // 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, null) - { - } - - public AdbRunner (Func getSdkPath, Func? getJdkPath) - { - this.getSdkPath = getSdkPath ?? throw new ArgumentNullException (nameof (getSdkPath)); - this.getJdkPath = getJdkPath; - } - - 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."); - } - - IDictionary GetEnvironmentVariables () + /// + /// 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) { - return AndroidEnvironmentHelper.GetEnvironmentVariables (getSdkPath (), getJdkPath?.Invoke ()); + if (string.IsNullOrWhiteSpace (adbPath)) + throw new ArgumentException ("Path to adb must not be empty.", nameof (adbPath)); + this.adbPath = adbPath; + this.environmentVariables = environmentVariables; } /// @@ -74,12 +49,10 @@ IDictionary GetEnvironmentVariables () /// public async Task> ListDevicesAsync (CancellationToken cancellationToken = default) { - var adb = RequireAdb (); - var envVars = GetEnvironmentVariables (); using var stdout = new StringWriter (); using var stderr = new StringWriter (); - var psi = ProcessUtils.CreateProcessStartInfo (adb, "devices", "-l"); - var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, envVars).ConfigureAwait (false); + 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.ToString ()); @@ -88,7 +61,7 @@ public async Task> ListDevicesAsync (CancellationTo // 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.AvdName = await GetEmulatorAvdNameAsync (device.Serial, cancellationToken).ConfigureAwait (false); device.Description = BuildDeviceDescription (device); } } @@ -101,13 +74,12 @@ public async Task> ListDevicesAsync (CancellationTo /// falling back to a direct emulator console TCP query if that fails. /// Ported from dotnet/android GetAvailableAndroidDevices.GetEmulatorAvdName. /// - internal async Task GetEmulatorAvdNameAsync (string adbPath, string serial, CancellationToken cancellationToken = default) + internal async Task GetEmulatorAvdNameAsync (string serial, CancellationToken cancellationToken = default) { try { - var envVars = GetEnvironmentVariables (); using var stdout = new StringWriter (); var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "emu", "avd", "name"); - await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken, envVars).ConfigureAwait (false); + await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken, environmentVariables).ConfigureAwait (false); foreach (var line in stdout.ToString ().Split ('\n')) { var trimmed = line.Trim (); @@ -187,14 +159,11 @@ public async Task WaitForDeviceAsync (string? serial = null, TimeSpan? timeout = if (effectiveTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException (nameof (timeout), effectiveTimeout, "Timeout must be a positive value."); - var adb = RequireAdb (); - var envVars = GetEnvironmentVariables (); - var args = string.IsNullOrEmpty (serial) ? new [] { "wait-for-device" } - : new [] { "-s", serial!, "wait-for-device" }; + : new [] { "-s", serial, "wait-for-device" }; - var psi = ProcessUtils.CreateProcessStartInfo (adb, args); + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, args); using var cts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); cts.CancelAfter (effectiveTimeout); @@ -203,7 +172,7 @@ public async Task WaitForDeviceAsync (string? serial = null, TimeSpan? timeout = using var stderr = new StringWriter (); try { - var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cts.Token, envVars).ConfigureAwait (false); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cts.Token, environmentVariables).ConfigureAwait (false); ProcessUtils.ThrowIfFailed (exitCode, "adb wait-for-device", stderr.ToString (), stdout.ToString ()); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { throw new TimeoutException ($"Timed out waiting for device after {effectiveTimeout.TotalSeconds}s."); @@ -215,11 +184,9 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati if (string.IsNullOrWhiteSpace (serial)) throw new ArgumentException ("Serial must not be empty.", nameof (serial)); - var adb = RequireAdb (); - var envVars = GetEnvironmentVariables (); - var psi = ProcessUtils.CreateProcessStartInfo (adb, "-s", serial, "emu", "kill"); + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "emu", "kill"); using var stderr = new StringWriter (); - var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, envVars).ConfigureAwait (false); + var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} emu kill", stderr.ToString ()); } diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs index 575cce8c..21a73d98 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs @@ -1,6 +1,7 @@ // 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; @@ -486,32 +487,21 @@ public void MergeDevicesAndEmulators_EmptyAdbDevices_ReturnsAllAvailable () // Consumer: MAUI DevTools Adb provider (AdbPath, IsAvailable properties) [Test] - public void AdbPath_FindsInSdk () + public void Constructor_NullPath_ThrowsArgumentException () { - 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.Throws (() => new AdbRunner (null!)); + } - Assert.IsNotNull (runner.AdbPath); - Assert.IsTrue (runner.IsAvailable); - Assert.IsTrue (runner.AdbPath!.Contains ("platform-tools")); - } finally { - Directory.Delete (tempDir, true); - } + [Test] + public void Constructor_EmptyPath_ThrowsArgumentException () + { + Assert.Throws (() => new AdbRunner ("")); } [Test] - public void AdbPath_NullSdkPath_StillSearchesPath () + public void Constructor_WhitespacePath_ThrowsArgumentException () { - var runner = new AdbRunner (() => null); - // Should not throw — falls back to PATH search - _ = runner.AdbPath; + Assert.Throws (() => new AdbRunner (" ")); } [Test] @@ -627,7 +617,7 @@ public void MapAdbStateToStatus_Sideload_ReturnsUnknown () [Test] public void WaitForDeviceAsync_NegativeTimeout_ThrowsArgumentOutOfRange () { - var runner = new AdbRunner (() => "/fake/sdk"); + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); Assert.ThrowsAsync ( async () => await runner.WaitForDeviceAsync (timeout: System.TimeSpan.FromSeconds (-1))); } @@ -635,7 +625,7 @@ public void WaitForDeviceAsync_NegativeTimeout_ThrowsArgumentOutOfRange () [Test] public void WaitForDeviceAsync_ZeroTimeout_ThrowsArgumentOutOfRange () { - var runner = new AdbRunner (() => "/fake/sdk"); + 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 index e1e6759b..a057073e 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/RunnerIntegrationTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/RunnerIntegrationTests.cs @@ -25,6 +25,7 @@ public class RunnerIntegrationTests { static string sdkPath; static string jdkPath; + static string adbPath; static SdkManager sdkManager; static string bootstrappedSdkPath; @@ -88,6 +89,12 @@ public async Task OneTimeSetUp () 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] @@ -112,19 +119,16 @@ public void OneTimeTearDown () // ── AdbRunner integration ────────────────────────────────────── [Test] - public void AdbRunner_IsAvailable_WithSdk () + public void AdbRunner_Constructor_AcceptsValidPath () { - var runner = new AdbRunner (() => sdkPath); - - Assert.IsTrue (runner.IsAvailable, "AdbRunner should find adb in SDK"); - Assert.IsNotNull (runner.AdbPath); - Assert.IsTrue (File.Exists (runner.AdbPath), $"adb binary should exist at {runner.AdbPath}"); + var runner = new AdbRunner (adbPath); + Assert.IsNotNull (runner); } [Test] public async Task AdbRunner_ListDevicesAsync_ReturnsWithoutError () { - var runner = new AdbRunner (() => sdkPath); + 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. @@ -137,9 +141,7 @@ public async Task AdbRunner_ListDevicesAsync_ReturnsWithoutError () [Test] public void AdbRunner_WaitForDeviceAsync_TimesOut_WhenNoDevice () { - var runner = new AdbRunner (() => sdkPath); - - // With no devices connected, wait-for-device should time out + var runner = new AdbRunner (adbPath); var ex = Assert.ThrowsAsync (async () => await runner.WaitForDeviceAsync (timeout: TimeSpan.FromSeconds (5))); @@ -152,11 +154,10 @@ public void AdbRunner_WaitForDeviceAsync_TimesOut_WhenNoDevice () [Test] public void AllRunners_ToolDiscovery_ConsistentWithSdk () { - var adb = new AdbRunner (() => sdkPath); - - Assert.IsTrue (adb.IsAvailable, "adb should be available"); + var runner = new AdbRunner (adbPath); // adb path should be under the SDK - StringAssert.StartsWith (sdkPath, adb.AdbPath!); + Assert.IsTrue (File.Exists (adbPath), $"adb should exist at {adbPath}"); + StringAssert.StartsWith (sdkPath, adbPath); } } From 1c79b8b5f75c02d045b79677baae4fe5113945ca Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 5 Mar 2026 12:23:51 +0000 Subject: [PATCH 10/11] AdbRunner: add ThrowIfFailed StringWriter overload, remove string ParseAdbDevicesOutput Thread 46: Add ProcessUtils.ThrowIfFailed(int, string, StringWriter?, StringWriter?) overload that delegates to the string version. Update AdbRunner callers to pass StringWriter directly instead of calling .ToString() at each call site. Thread 47: Remove ParseAdbDevicesOutput(string) overload. Callers now split the string themselves and pass IEnumerable directly. This removes the dual-signature confusion and aligns with dotnet/android's usage pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ProcessUtils.cs | 8 ++++ .../Runners/AdbRunner.cs | 19 +++------ .../AdbRunnerTests.cs | 42 +++++++++---------- 3 files changed, 34 insertions(+), 35 deletions(-) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs index b5c5f48a..c8a64583 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs @@ -222,6 +222,14 @@ internal static void ThrowIfFailed (int exitCode, string command, string? stderr 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 index c239558c..8faa68fe 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -54,9 +54,9 @@ public async Task> ListDevicesAsync (CancellationTo 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.ToString ()); + ProcessUtils.ThrowIfFailed (exitCode, "adb devices -l", stderr); - var devices = ParseAdbDevicesOutput (stdout.ToString ()); + var devices = ParseAdbDevicesOutput (stdout.ToString ().Split ('\n')); // For each emulator, try to get the AVD name foreach (var device in devices) { @@ -173,7 +173,7 @@ public async Task WaitForDeviceAsync (string? serial = null, TimeSpan? timeout = try { var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cts.Token, environmentVariables).ConfigureAwait (false); - ProcessUtils.ThrowIfFailed (exitCode, "adb wait-for-device", stderr.ToString (), stdout.ToString ()); + 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."); } @@ -187,20 +187,11 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati 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.ToString ()); + ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} emu kill", stderr); } /// - /// Parses the output of 'adb devices -l'. - /// Ported from dotnet/android GetAvailableAndroidDevices.ParseAdbDevicesOutput. - /// - public static List ParseAdbDevicesOutput (string output) - { - return ParseAdbDevicesOutput (output.Split ('\n')); - } - - /// - /// Parses individual lines from 'adb devices -l' output. + /// Parses the output lines from 'adb devices -l'. /// Accepts an to avoid allocating a joined string. /// public static List ParseAdbDevicesOutput (IEnumerable lines) diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs index 21a73d98..e9e283f1 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs @@ -37,7 +37,7 @@ public void ParseAdbDevicesOutput_RealWorldData () "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); + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); Assert.AreEqual (2, devices.Count); @@ -66,7 +66,7 @@ public void ParseAdbDevicesOutput_RealWorldData () public void ParseAdbDevicesOutput_EmptyOutput () { var output = "List of devices attached\n\n"; - var devices = AdbRunner.ParseAdbDevicesOutput (output); + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); Assert.AreEqual (0, devices.Count); } @@ -77,7 +77,7 @@ public void ParseAdbDevicesOutput_SingleEmulator () "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); + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); Assert.AreEqual (1, devices.Count); Assert.AreEqual ("emulator-5554", devices [0].Serial); @@ -94,7 +94,7 @@ public void ParseAdbDevicesOutput_SinglePhysicalDevice () "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); + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); Assert.AreEqual (1, devices.Count); Assert.AreEqual ("0A041FDD400327", devices [0].Serial); @@ -113,7 +113,7 @@ public void ParseAdbDevicesOutput_OfflineDevice () "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); + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); Assert.AreEqual (1, devices.Count); Assert.AreEqual (AdbDeviceStatus.Offline, devices [0].Status); @@ -126,7 +126,7 @@ public void ParseAdbDevicesOutput_UnauthorizedDevice () "List of devices attached\n" + "0A041FDD400327 unauthorized usb:1-1\n"; - var devices = AdbRunner.ParseAdbDevicesOutput (output); + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); Assert.AreEqual (1, devices.Count); Assert.AreEqual ("0A041FDD400327", devices [0].Serial); @@ -141,7 +141,7 @@ public void ParseAdbDevicesOutput_NoPermissionsDevice () "List of devices attached\n" + "???????????????? no permissions usb:1-1\n"; - var devices = AdbRunner.ParseAdbDevicesOutput (output); + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); Assert.AreEqual (1, devices.Count); Assert.AreEqual ("????????????????", devices [0].Serial); @@ -155,7 +155,7 @@ public void ParseAdbDevicesOutput_DeviceWithMinimalMetadata () "List of devices attached\n" + "ABC123 device\n"; - var devices = AdbRunner.ParseAdbDevicesOutput (output); + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); Assert.AreEqual (1, devices.Count); Assert.AreEqual ("ABC123", devices [0].Serial); @@ -176,7 +176,7 @@ public void ParseAdbDevicesOutput_InvalidLines () "* 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); + 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); @@ -192,7 +192,7 @@ public void ParseAdbDevicesOutput_MixedDeviceStates () "0A041FDD400327 device usb:1-1 product:raven model:Pixel_6_Pro\n" + "0B123456789ABC unauthorized usb:1-2\n"; - var devices = AdbRunner.ParseAdbDevicesOutput (output); + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); Assert.AreEqual (4, devices.Count); Assert.AreEqual (AdbDeviceStatus.Online, devices [0].Status); @@ -209,7 +209,7 @@ public void ParseAdbDevicesOutput_WindowsNewlines () "emulator-5554 device transport_id:1\r\n" + "\r\n"; - var devices = AdbRunner.ParseAdbDevicesOutput (output); + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); Assert.AreEqual (1, devices.Count); Assert.AreEqual ("emulator-5554", devices [0].Serial); @@ -224,7 +224,7 @@ public void ParseAdbDevicesOutput_TabSeparator () "emulator-5554\tdevice\n" + "R5CR10YZQPJ\tdevice\n"; - var devices = AdbRunner.ParseAdbDevicesOutput (output); + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); Assert.AreEqual (2, devices.Count); Assert.AreEqual ("emulator-5554", devices [0].Serial); @@ -238,7 +238,7 @@ public void ParseAdbDevicesOutput_IpPortDevice () "List of devices attached\n" + "192.168.1.100:5555 device product:sdk_gphone64_arm64 model:Remote_Device\n"; - var devices = AdbRunner.ParseAdbDevicesOutput (output); + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); Assert.AreEqual (1, devices.Count); Assert.AreEqual ("192.168.1.100:5555", devices [0].Serial); @@ -256,7 +256,7 @@ public void ParseAdbDevicesOutput_AdbDaemonStarting () "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); + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); Assert.AreEqual (2, devices.Count, "Should parse devices even with daemon startup messages"); } @@ -269,17 +269,17 @@ 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); + 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); + 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); + var devices3 = AdbRunner.ParseAdbDevicesOutput (output3.Split ('\n')); Assert.AreEqual ("device name", devices3 [0].Description, "Device should have third priority"); } @@ -511,7 +511,7 @@ public void ParseAdbDevicesOutput_DeviceWithProductOnly () "List of devices attached\n" + "emulator-5554 device product:aosp_x86_64\n"; - var devices = AdbRunner.ParseAdbDevicesOutput (output); + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); Assert.AreEqual (1, devices.Count); Assert.AreEqual ("aosp_x86_64", devices [0].Product); @@ -527,7 +527,7 @@ public void ParseAdbDevicesOutput_MultipleDevices () "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); + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); Assert.AreEqual (3, devices.Count); Assert.AreEqual ("emulator-5554", devices [0].Serial); @@ -579,7 +579,7 @@ 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); + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); Assert.AreEqual (1, devices.Count); Assert.AreEqual ("0A041FDD400327", devices [0].Serial); @@ -592,7 +592,7 @@ public void ParseAdbDevicesOutput_SideloadState () var output = "List of devices attached\n" + "0A041FDD400327 sideload\n"; - var devices = AdbRunner.ParseAdbDevicesOutput (output); + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); Assert.AreEqual (1, devices.Count); Assert.AreEqual ("0A041FDD400327", devices [0].Serial); From 3cadb587f2b04737284b2a38e4ff751b1cbe10e1 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 5 Mar 2026 12:41:38 +0000 Subject: [PATCH 11/11] Replace null-forgiving operators with pattern matching, use switch expression patterns that give the compiler proper non-null flow on netstandard2.0. - Convert MapAdbStateToStatus from switch statement to switch expression. - Update copilot-instructions.md with both guidelines for future PRs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 2 + .../Runners/AdbRunner.cs | 39 +++++++++---------- .../Runners/AndroidEnvironmentHelper.cs | 10 ++--- 3 files changed, 25 insertions(+), 26 deletions(-) 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/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index 8faa68fe..e9237608 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -259,16 +259,13 @@ public static List ParseAdbDevicesOutput (IEnumerable lin /// 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; - } - } + 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. @@ -277,21 +274,21 @@ public static AdbDeviceStatus MapAdbStateToStatus (string adbState) /// public static string BuildDeviceDescription (AdbDeviceInfo device, Action? logger = null) { - if (device.Type == AdbDeviceType.Emulator && !string.IsNullOrEmpty (device.AvdName)) { - logger?.Invoke (TraceLevel.Verbose, $"Emulator {device.Serial}, original AVD name: {device.AvdName}"); - var formatted = FormatDisplayName (device.AvdName!); + 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 (!string.IsNullOrEmpty (device.Model)) - return device.Model!.Replace ('_', ' '); + if (device.Model is { Length: > 0 } model) + return model.Replace ('_', ' '); - if (!string.IsNullOrEmpty (device.Product)) - return device.Product!.Replace ('_', ' '); + if (device.Product is { Length: > 0 } product) + return product.Replace ('_', ' '); - if (!string.IsNullOrEmpty (device.Device)) - return device.Device!.Replace ('_', ' '); + if (device.Device is { Length: > 0 } deviceName) + return deviceName.Replace ('_', ' '); return device.Serial; } @@ -326,8 +323,8 @@ public static List MergeDevicesAndEmulators (IReadOnlyList (StringComparer.OrdinalIgnoreCase); foreach (var device in adbDevices) { - if (!string.IsNullOrEmpty (device.AvdName)) - runningAvdNames.Add (device.AvdName!); + if (device.AvdName is { Length: > 0 } avdName) + runningAvdNames.Add (avdName); } logger?.Invoke (TraceLevel.Verbose, $"Running emulators AVD names: {string.Join (", ", runningAvdNames)}"); diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs index 2cfffae4..51f6309d 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs @@ -21,12 +21,12 @@ internal static Dictionary GetEnvironmentVariables (string? sdkP { var env = new Dictionary (); - if (!string.IsNullOrEmpty (sdkPath)) - env [EnvironmentVariableNames.AndroidHome] = sdkPath!; + if (sdkPath is { Length: > 0 }) + env [EnvironmentVariableNames.AndroidHome] = sdkPath; - if (!string.IsNullOrEmpty (jdkPath)) { - env [EnvironmentVariableNames.JavaHome] = jdkPath!; - var jdkBin = Path.Combine (jdkPath!, "bin"); + 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; }