diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 582d2a8f..e2765ace 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -49,6 +49,8 @@ When setting environment variables for SDK tools (e.g. `sdkmanager`, `avdmanager - **File-scoped namespaces**: all new files should use file-scoped namespaces (`namespace Foo;` instead of `namespace Foo { ... }`). - **Static `HttpClient`**: `HttpClient` instances must be `static` to avoid socket exhaustion. See [HttpClient guidelines](https://learn.microsoft.com/dotnet/fundamentals/networking/http/httpclient-guidelines#recommended-use). Do not create per-instance `HttpClient` fields or dispose them in `IDisposable`. - [Mono Coding Guidelines](http://www.mono-project.com/community/contributing/coding-guidelines/): tabs, K&R braces, `PascalCase` public members. +- **No null-forgiving operator (`!`)**: do not use the null-forgiving operator after null checks. Instead, use C# property patterns (e.g. `if (value is { Length: > 0 } v)`) which give the compiler proper non-null flow analysis on all target frameworks including `netstandard2.0`. +- **Prefer switch expressions**: use C# switch expressions over switch statements for simple value mappings (e.g. `return state switch { "x" => A, _ => B };`). Use switch statements only when the body has side effects or complex logic. - Nullable enabled in `AndroidSdk`. `NullableAttributes.cs` excluded on `net10.0+`. - Strong-named via `product.snk`. In the AndroidSdk project, tests use `InternalsVisibleTo` with full public key (`Properties/AssemblyInfo.cs`). - Assembly names support `$(VendorPrefix)`/`$(VendorSuffix)` for branding forks. diff --git a/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs b/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs index 272a17b1..1ea607f0 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs @@ -40,5 +40,17 @@ internal static class EnvironmentVariableNames /// Executable file extensions (Windows). /// public const string PathExt = "PATHEXT"; + + /// + /// Overrides the default location for Android user-specific data + /// (AVDs, preferences, etc.). Defaults to $HOME/.android. + /// + public const string AndroidUserHome = "ANDROID_USER_HOME"; + + /// + /// Overrides the AVD storage directory. Takes precedence over + /// /avd. + /// + public const string AndroidAvdHome = "ANDROID_AVD_HOME"; } } diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceInfo.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceInfo.cs new file mode 100644 index 00000000..a14941ee --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceInfo.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Xamarin.Android.Tools; + +/// +/// Represents an Android device or emulator from 'adb devices -l' output. +/// Mirrors the metadata produced by dotnet/android's GetAvailableAndroidDevices task. +/// +public class AdbDeviceInfo +{ + /// + /// Serial number of the device (e.g., "emulator-5554", "0A041FDD400327"). + /// For non-running emulators, this is the AVD name. + /// + public string Serial { get; set; } = string.Empty; + + /// + /// Human-friendly description of the device (e.g., "Pixel 7 API 35", "Pixel 6 Pro"). + /// + public string Description { get; set; } = string.Empty; + + /// + /// Device type: Device or Emulator. + /// + public AdbDeviceType Type { get; set; } + + /// + /// Device status: Online, Offline, Unauthorized, NoPermissions, NotRunning, Unknown. + /// + public AdbDeviceStatus Status { get; set; } + + /// + /// AVD name for emulators (e.g., "pixel_7_api_35"). Null for physical devices. + /// + public string? AvdName { get; set; } + + /// + /// Device model from adb properties (e.g., "Pixel_6_Pro"). + /// + public string? Model { get; set; } + + /// + /// Product name from adb properties (e.g., "raven"). + /// + public string? Product { get; set; } + + /// + /// Device code name from adb properties (e.g., "raven"). + /// + public string? Device { get; set; } + + /// + /// Transport ID from adb properties. + /// + public string? TransportId { get; set; } + + /// + /// Whether this device is an emulator. + /// + public bool IsEmulator => Type == AdbDeviceType.Emulator; +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceStatus.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceStatus.cs new file mode 100644 index 00000000..18b51a49 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceStatus.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Xamarin.Android.Tools; + +/// +/// Represents the status of an Android device. +/// +public enum AdbDeviceStatus +{ + Online, + Offline, + Unauthorized, + NoPermissions, + NotRunning, + Unknown +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceType.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceType.cs new file mode 100644 index 00000000..7662d1b3 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceType.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Xamarin.Android.Tools; + +/// +/// Represents the type of an Android device. +/// +public enum AdbDeviceType +{ + Device, + Emulator +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs index 28113d72..c8a64583 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs @@ -203,6 +203,33 @@ static string JoinArguments (string[] args) } #endif + /// + /// Throws when is non-zero. + /// Includes stderr/stdout context in the message when available. + /// + internal static void ThrowIfFailed (int exitCode, string command, string? stderr = null, string? stdout = null) + { + if (exitCode == 0) + return; + + var message = $"'{command}' failed with exit code {exitCode}."; + + if (!string.IsNullOrEmpty (stderr)) + message += $" stderr:{Environment.NewLine}{stderr!.Trim ()}"; + if (!string.IsNullOrEmpty (stdout)) + message += $" stdout:{Environment.NewLine}{stdout!.Trim ()}"; + + throw new InvalidOperationException (message); + } + + /// + /// Overload that accepts directly so callers don't need to call ToString(). + /// + internal static void ThrowIfFailed (int exitCode, string command, StringWriter? stderr = null, StringWriter? stdout = null) + { + ThrowIfFailed (exitCode, command, stderr?.ToString (), stdout?.ToString ()); + } + internal static IEnumerable FindExecutablesInPath (string executable) { var path = Environment.GetEnvironmentVariable (EnvironmentVariableNames.Path) ?? ""; diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs new file mode 100644 index 00000000..e9237608 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -0,0 +1,364 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Net.Sockets; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace Xamarin.Android.Tools; + +/// +/// Runs Android Debug Bridge (adb) commands. +/// Parsing logic ported from dotnet/android GetAvailableAndroidDevices task. +/// +public class AdbRunner +{ + readonly string adbPath; + readonly IDictionary? environmentVariables; + + // Pattern to match device lines: [key:value ...] + // Uses \s+ to handle both space and tab separators. + // Explicit state list prevents false positives from non-device lines. + static readonly Regex AdbDevicesRegex = new Regex ( + @"^([^\s]+)\s+(device|offline|unauthorized|authorizing|no permissions|recovery|sideload|bootloader|connecting|host)\s*(.*)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + static readonly Regex ApiRegex = new Regex (@"\bApi\b", RegexOptions.Compiled); + + /// + /// Creates a new AdbRunner with the full path to the adb executable. + /// + /// Full path to the adb executable (e.g., "/path/to/sdk/platform-tools/adb"). + /// Optional environment variables to pass to adb processes. + public AdbRunner (string adbPath, IDictionary? environmentVariables = null) + { + if (string.IsNullOrWhiteSpace (adbPath)) + throw new ArgumentException ("Path to adb must not be empty.", nameof (adbPath)); + this.adbPath = adbPath; + this.environmentVariables = environmentVariables; + } + + /// + /// Lists connected devices using 'adb devices -l'. + /// For emulators, queries the AVD name using 'adb -s <serial> emu avd name'. + /// + public async Task> ListDevicesAsync (CancellationToken cancellationToken = default) + { + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "devices", "-l"); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + + ProcessUtils.ThrowIfFailed (exitCode, "adb devices -l", stderr); + + var devices = ParseAdbDevicesOutput (stdout.ToString ().Split ('\n')); + + // For each emulator, try to get the AVD name + foreach (var device in devices) { + if (device.Type == AdbDeviceType.Emulator) { + device.AvdName = await GetEmulatorAvdNameAsync (device.Serial, cancellationToken).ConfigureAwait (false); + device.Description = BuildDeviceDescription (device); + } + } + + return devices; + } + + /// + /// Queries the emulator for its AVD name using 'adb -s <serial> emu avd name', + /// falling back to a direct emulator console TCP query if that fails. + /// Ported from dotnet/android GetAvailableAndroidDevices.GetEmulatorAvdName. + /// + internal async Task GetEmulatorAvdNameAsync (string serial, CancellationToken cancellationToken = default) + { + try { + using var stdout = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "emu", "avd", "name"); + await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken, environmentVariables).ConfigureAwait (false); + + foreach (var line in stdout.ToString ().Split ('\n')) { + var trimmed = line.Trim (); + if (!string.IsNullOrEmpty (trimmed) && + !string.Equals (trimmed, "OK", StringComparison.OrdinalIgnoreCase)) { + return trimmed; + } + } + } catch (OperationCanceledException) { + throw; + } catch (Exception ex) { + Trace.WriteLine ($"GetEmulatorAvdNameAsync adb query failed for '{serial}': {ex.Message}"); + } + + // Fallback: query the emulator console directly via TCP. + // Emulator serials follow the format "emulator-{port}" where port is the console port. + return await QueryAvdNameViaConsoleAsync (serial, cancellationToken).ConfigureAwait (false); + } + + /// + /// Queries AVD name by connecting to the emulator console port directly. + /// + static async Task QueryAvdNameViaConsoleAsync (string serial, CancellationToken cancellationToken) + { + const string EmulatorPrefix = "emulator-"; + if (!serial.StartsWith (EmulatorPrefix, StringComparison.OrdinalIgnoreCase)) + return null; + + if (!int.TryParse (serial.Substring (EmulatorPrefix.Length), out var port)) + return null; + + try { + using var client = new TcpClient (); + using var cts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); + cts.CancelAfter (TimeSpan.FromSeconds (3)); + + // ConnectAsync with CancellationToken not available on netstandard2.0; + // use Task.Run + token check instead + var connectTask = client.ConnectAsync ("127.0.0.1", port); + var completed = await Task.WhenAny (connectTask, Task.Delay (3000, cts.Token)).ConfigureAwait (false); + if (completed != connectTask) { + return null; + } + await connectTask.ConfigureAwait (false); // observe exceptions + + using var stream = client.GetStream (); + stream.ReadTimeout = 3000; + stream.WriteTimeout = 3000; + using var reader = new StreamReader (stream); + using var writer = new StreamWriter (stream) { AutoFlush = true }; + + // Read the welcome banner ("Android Console: ...") and "OK" + await reader.ReadLineAsync ().ConfigureAwait (false); + await reader.ReadLineAsync ().ConfigureAwait (false); + + // Send "avd name" command + await writer.WriteLineAsync ("avd name").ConfigureAwait (false); + + // Read AVD name + var name = await reader.ReadLineAsync ().ConfigureAwait (false); + if (!string.IsNullOrWhiteSpace (name) && + !string.Equals (name, "OK", StringComparison.OrdinalIgnoreCase)) + return name.Trim (); + } catch (OperationCanceledException) { + throw; + } catch (Exception ex) { + Trace.WriteLine ($"QueryAvdNameViaConsoleAsync failed for '{serial}': {ex.Message}"); + } + + return null; + } + + public async Task WaitForDeviceAsync (string? serial = null, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds (60); + + if (effectiveTimeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException (nameof (timeout), effectiveTimeout, "Timeout must be a positive value."); + + var args = string.IsNullOrEmpty (serial) + ? new [] { "wait-for-device" } + : new [] { "-s", serial, "wait-for-device" }; + + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, args); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); + cts.CancelAfter (effectiveTimeout); + + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + + try { + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cts.Token, environmentVariables).ConfigureAwait (false); + ProcessUtils.ThrowIfFailed (exitCode, "adb wait-for-device", stderr, stdout); + } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + throw new TimeoutException ($"Timed out waiting for device after {effectiveTimeout.TotalSeconds}s."); + } + } + + public async Task StopEmulatorAsync (string serial, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace (serial)) + throw new ArgumentException ("Serial must not be empty.", nameof (serial)); + + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "emu", "kill"); + using var stderr = new StringWriter (); + var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} emu kill", stderr); + } + + /// + /// Parses the output lines from 'adb devices -l'. + /// Accepts an to avoid allocating a joined string. + /// + public static List ParseAdbDevicesOutput (IEnumerable lines) + { + var devices = new List (); + + foreach (var line in lines) { + var trimmed = line.Trim (); + if (string.IsNullOrEmpty (trimmed) || + trimmed.IndexOf ("List of devices", StringComparison.OrdinalIgnoreCase) >= 0 || + trimmed.StartsWith ("*", StringComparison.Ordinal)) + continue; + + var match = AdbDevicesRegex.Match (trimmed); + if (!match.Success) + continue; + + var serial = match.Groups [1].Value.Trim (); + var state = match.Groups [2].Value.Trim (); + var properties = match.Groups [3].Value.Trim (); + + // Parse key:value pairs from the properties string + var propDict = new Dictionary (StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrEmpty (properties)) { + var pairs = properties.Split (new [] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var pair in pairs) { + var colonIndex = pair.IndexOf (':'); + if (colonIndex > 0 && colonIndex < pair.Length - 1) { + var key = pair.Substring (0, colonIndex); + var value = pair.Substring (colonIndex + 1); + propDict [key] = value; + } + } + } + + var deviceType = serial.StartsWith ("emulator-", StringComparison.OrdinalIgnoreCase) + ? AdbDeviceType.Emulator + : AdbDeviceType.Device; + + var device = new AdbDeviceInfo { + Serial = serial, + Type = deviceType, + Status = MapAdbStateToStatus (state), + }; + + if (propDict.TryGetValue ("model", out var model)) + device.Model = model; + if (propDict.TryGetValue ("product", out var product)) + device.Product = product; + if (propDict.TryGetValue ("device", out var deviceCodeName)) + device.Device = deviceCodeName; + if (propDict.TryGetValue ("transport_id", out var transportId)) + device.TransportId = transportId; + + // Build description (will be updated later if emulator AVD name is available) + device.Description = BuildDeviceDescription (device); + + devices.Add (device); + } + + return devices; + } + + /// + /// Maps adb device states to status values. + /// Ported from dotnet/android GetAvailableAndroidDevices.MapAdbStateToStatus. + /// + public static AdbDeviceStatus MapAdbStateToStatus (string adbState) => adbState.ToLowerInvariant () switch { + "device" => AdbDeviceStatus.Online, + "offline" => AdbDeviceStatus.Offline, + "unauthorized" => AdbDeviceStatus.Unauthorized, + "no permissions" => AdbDeviceStatus.NoPermissions, + _ => AdbDeviceStatus.Unknown, + }; + + /// + /// Builds a human-friendly description for a device. + /// Priority: AVD name (for emulators) > model > product > device > serial. + /// Ported from dotnet/android GetAvailableAndroidDevices.BuildDeviceDescription. + /// + public static string BuildDeviceDescription (AdbDeviceInfo device, Action? logger = null) + { + if (device.Type == AdbDeviceType.Emulator && device.AvdName is { Length: > 0 } avdName) { + logger?.Invoke (TraceLevel.Verbose, $"Emulator {device.Serial}, original AVD name: {avdName}"); + var formatted = FormatDisplayName (avdName); + logger?.Invoke (TraceLevel.Verbose, $"Emulator {device.Serial}, formatted AVD display name: {formatted}"); + return formatted; + } + + if (device.Model is { Length: > 0 } model) + return model.Replace ('_', ' '); + + if (device.Product is { Length: > 0 } product) + return product.Replace ('_', ' '); + + if (device.Device is { Length: > 0 } deviceName) + return deviceName.Replace ('_', ' '); + + return device.Serial; + } + + /// + /// Formats an AVD name into a user-friendly display name. + /// Replaces underscores with spaces, applies title case, and capitalizes "API". + /// Ported from dotnet/android GetAvailableAndroidDevices.FormatDisplayName. + /// + public static string FormatDisplayName (string avdName) + { + if (string.IsNullOrEmpty (avdName)) + return avdName ?? string.Empty; + + var textInfo = CultureInfo.InvariantCulture.TextInfo; + avdName = textInfo.ToTitleCase (avdName.Replace ('_', ' ').ToLowerInvariant ()); + + // Replace "Api" with "API" + avdName = ApiRegex.Replace (avdName, "API"); + return avdName; + } + + /// + /// Merges devices from adb with available emulators from 'emulator -list-avds'. + /// Running emulators are not duplicated. Non-running emulators are added with Status=NotRunning. + /// Ported from dotnet/android GetAvailableAndroidDevices.MergeDevicesAndEmulators. + /// + public static List MergeDevicesAndEmulators (IReadOnlyList adbDevices, IReadOnlyList availableEmulators, Action? logger = null) + { + var result = new List (adbDevices); + + // Build a set of AVD names that are already running + var runningAvdNames = new HashSet (StringComparer.OrdinalIgnoreCase); + foreach (var device in adbDevices) { + if (device.AvdName is { Length: > 0 } avdName) + runningAvdNames.Add (avdName); + } + + logger?.Invoke (TraceLevel.Verbose, $"Running emulators AVD names: {string.Join (", ", runningAvdNames)}"); + + // Add non-running emulators + foreach (var avdName in availableEmulators) { + if (runningAvdNames.Contains (avdName)) { + logger?.Invoke (TraceLevel.Verbose, $"Emulator '{avdName}' is already running, skipping"); + continue; + } + + var displayName = FormatDisplayName (avdName); + result.Add (new AdbDeviceInfo { + Serial = avdName, + Description = displayName + " (Not Running)", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.NotRunning, + AvdName = avdName, + }); + logger?.Invoke (TraceLevel.Verbose, $"Added non-running emulator: {avdName}"); + } + + // Sort: online devices first, then not-running emulators, alphabetically by description + result.Sort ((a, b) => { + var aNotRunning = a.Status == AdbDeviceStatus.NotRunning; + var bNotRunning = b.Status == AdbDeviceStatus.NotRunning; + + if (aNotRunning != bNotRunning) + return aNotRunning ? 1 : -1; + + return string.Compare (a.Description, b.Description, StringComparison.OrdinalIgnoreCase); + }); + + return result; + } +} + diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs new file mode 100644 index 00000000..51f6309d --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; + +namespace Xamarin.Android.Tools; + +/// +/// Helper for building environment variables for Android SDK tools. +/// Returns a dictionary that can be passed to . +/// +internal static class AndroidEnvironmentHelper +{ + /// + /// Builds environment variables needed to run Android SDK tools. + /// Pass the result to via the environmentVariables parameter. + /// + internal static Dictionary GetEnvironmentVariables (string? sdkPath, string? jdkPath) + { + var env = new Dictionary (); + + if (sdkPath is { Length: > 0 }) + env [EnvironmentVariableNames.AndroidHome] = sdkPath; + + if (jdkPath is { Length: > 0 }) { + env [EnvironmentVariableNames.JavaHome] = jdkPath; + var jdkBin = Path.Combine (jdkPath, "bin"); + var currentPath = Environment.GetEnvironmentVariable (EnvironmentVariableNames.Path) ?? ""; + env [EnvironmentVariableNames.Path] = string.IsNullOrEmpty (currentPath) ? jdkBin : jdkBin + Path.PathSeparator + currentPath; + } + + if (string.IsNullOrEmpty (Environment.GetEnvironmentVariable (EnvironmentVariableNames.AndroidUserHome))) + env [EnvironmentVariableNames.AndroidUserHome] = Path.Combine ( + Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), ".android"); + + return env; + } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Process.cs b/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Process.cs index 96d70ef8..d696d031 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Process.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/SdkManager.Process.cs @@ -93,18 +93,7 @@ async Task DownloadFileAsync (string url, string destinationPath, long expectedS Dictionary GetEnvironmentVariables () { - var env = new Dictionary { - ["ANDROID_USER_HOME"] = Path.Combine ( - Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), ".android") - }; - - if (!string.IsNullOrEmpty (AndroidSdkPath)) - env[EnvironmentVariableNames.AndroidHome] = AndroidSdkPath!; - - if (!string.IsNullOrEmpty (JavaSdkPath)) - env[EnvironmentVariableNames.JavaHome] = JavaSdkPath!; - - return env; + return AndroidEnvironmentHelper.GetEnvironmentVariables (AndroidSdkPath, JavaSdkPath); } } diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs new file mode 100644 index 00000000..e9e283f1 --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs @@ -0,0 +1,632 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using NUnit.Framework; + +namespace Xamarin.Android.Tools.Tests; + +/// +/// Tests for AdbRunner parsing, formatting, and merging logic. +/// Ported from dotnet/android GetAvailableAndroidDevicesTests. +/// +/// API consumer reference: +/// - ParseAdbDevicesOutput: used by dotnet/android GetAvailableAndroidDevices task +/// - BuildDeviceDescription: used by dotnet/android GetAvailableAndroidDevices task +/// - FormatDisplayName: used by dotnet/android GetAvailableAndroidDevices tests +/// - MergeDevicesAndEmulators: used by dotnet/android GetAvailableAndroidDevices task +/// - MapAdbStateToStatus: used internally by ParseAdbDevicesOutput, public for extensibility +/// - ListDevicesAsync: used by MAUI DevTools Adb provider (Providers/Android/Adb.cs) +/// - WaitForDeviceAsync: used by MAUI DevTools Adb provider +/// - StopEmulatorAsync: used by MAUI DevTools Adb provider +/// - GetEmulatorAvdNameAsync: internal, used by ListDevicesAsync only +/// +[TestFixture] +public class AdbRunnerTests +{ + // --- ParseAdbDevicesOutput tests --- + // Consumer: dotnet/android GetAvailableAndroidDevices.cs, MAUI DevTools (via ListDevicesAsync) + + [Test] + public void ParseAdbDevicesOutput_RealWorldData () + { + var output = + "List of devices attached\n" + + "0A041FDD400327 device product:redfin model:Pixel_5 device:redfin transport_id:2\n" + + "emulator-5554 device product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64xa transport_id:1\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); + + Assert.AreEqual (2, devices.Count); + + // Physical device + Assert.AreEqual ("0A041FDD400327", devices [0].Serial); + Assert.AreEqual (AdbDeviceType.Device, devices [0].Type); + Assert.AreEqual (AdbDeviceStatus.Online, devices [0].Status); + Assert.AreEqual ("Pixel 5", devices [0].Description); + Assert.AreEqual ("Pixel_5", devices [0].Model); + Assert.AreEqual ("redfin", devices [0].Product); + Assert.AreEqual ("redfin", devices [0].Device); + Assert.AreEqual ("2", devices [0].TransportId); + Assert.IsFalse (devices [0].IsEmulator); + + // Emulator + Assert.AreEqual ("emulator-5554", devices [1].Serial); + Assert.AreEqual (AdbDeviceType.Emulator, devices [1].Type); + Assert.AreEqual (AdbDeviceStatus.Online, devices [1].Status); + Assert.AreEqual ("sdk gphone64 x86 64", devices [1].Description); // model with underscores replaced + Assert.AreEqual ("sdk_gphone64_x86_64", devices [1].Model); + Assert.AreEqual ("1", devices [1].TransportId); + Assert.IsTrue (devices [1].IsEmulator); + } + + [Test] + public void ParseAdbDevicesOutput_EmptyOutput () + { + var output = "List of devices attached\n\n"; + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); + Assert.AreEqual (0, devices.Count); + } + + [Test] + public void ParseAdbDevicesOutput_SingleEmulator () + { + var output = + "List of devices attached\n" + + "emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); + + Assert.AreEqual (1, devices.Count); + Assert.AreEqual ("emulator-5554", devices [0].Serial); + Assert.AreEqual (AdbDeviceType.Emulator, devices [0].Type); + Assert.AreEqual (AdbDeviceStatus.Online, devices [0].Status); + Assert.AreEqual ("sdk_gphone64_arm64", devices [0].Model); + Assert.AreEqual ("1", devices [0].TransportId); + } + + [Test] + public void ParseAdbDevicesOutput_SinglePhysicalDevice () + { + var output = + "List of devices attached\n" + + "0A041FDD400327 device usb:1-1 product:raven model:Pixel_6_Pro device:raven transport_id:2\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); + + Assert.AreEqual (1, devices.Count); + Assert.AreEqual ("0A041FDD400327", devices [0].Serial); + Assert.AreEqual (AdbDeviceType.Device, devices [0].Type); + Assert.AreEqual (AdbDeviceStatus.Online, devices [0].Status); + Assert.AreEqual ("Pixel 6 Pro", devices [0].Description); + Assert.AreEqual ("Pixel_6_Pro", devices [0].Model); + Assert.AreEqual ("raven", devices [0].Product); + Assert.AreEqual ("2", devices [0].TransportId); + } + + [Test] + public void ParseAdbDevicesOutput_OfflineDevice () + { + var output = + "List of devices attached\n" + + "emulator-5554 offline product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); + + Assert.AreEqual (1, devices.Count); + Assert.AreEqual (AdbDeviceStatus.Offline, devices [0].Status); + } + + [Test] + public void ParseAdbDevicesOutput_UnauthorizedDevice () + { + var output = + "List of devices attached\n" + + "0A041FDD400327 unauthorized usb:1-1\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); + + Assert.AreEqual (1, devices.Count); + Assert.AreEqual ("0A041FDD400327", devices [0].Serial); + Assert.AreEqual (AdbDeviceStatus.Unauthorized, devices [0].Status); + Assert.AreEqual (AdbDeviceType.Device, devices [0].Type); + } + + [Test] + public void ParseAdbDevicesOutput_NoPermissionsDevice () + { + var output = + "List of devices attached\n" + + "???????????????? no permissions usb:1-1\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); + + Assert.AreEqual (1, devices.Count); + Assert.AreEqual ("????????????????", devices [0].Serial); + Assert.AreEqual (AdbDeviceStatus.NoPermissions, devices [0].Status); + } + + [Test] + public void ParseAdbDevicesOutput_DeviceWithMinimalMetadata () + { + var output = + "List of devices attached\n" + + "ABC123 device\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); + + Assert.AreEqual (1, devices.Count); + Assert.AreEqual ("ABC123", devices [0].Serial); + Assert.AreEqual (AdbDeviceType.Device, devices [0].Type); + Assert.AreEqual (AdbDeviceStatus.Online, devices [0].Status); + Assert.AreEqual ("ABC123", devices [0].Description, "Should fall back to serial"); + } + + [Test] + public void ParseAdbDevicesOutput_InvalidLines () + { + var output = + "List of devices attached\n" + + "\n" + + " \n" + + "Some random text\n" + + "* daemon not running; starting now at tcp:5037\n" + + "* daemon started successfully\n" + + "emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); + + Assert.AreEqual (1, devices.Count, "Should only return valid device lines"); + Assert.AreEqual ("emulator-5554", devices [0].Serial); + } + + [Test] + public void ParseAdbDevicesOutput_MixedDeviceStates () + { + var output = + "List of devices attached\n" + + "emulator-5554 device product:sdk_gphone64_arm64 model:Pixel_7 device:emu64a\n" + + "emulator-5556 offline\n" + + "0A041FDD400327 device usb:1-1 product:raven model:Pixel_6_Pro\n" + + "0B123456789ABC unauthorized usb:1-2\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); + + Assert.AreEqual (4, devices.Count); + Assert.AreEqual (AdbDeviceStatus.Online, devices [0].Status); + Assert.AreEqual (AdbDeviceStatus.Offline, devices [1].Status); + Assert.AreEqual (AdbDeviceStatus.Online, devices [2].Status); + Assert.AreEqual (AdbDeviceStatus.Unauthorized, devices [3].Status); + } + + [Test] + public void ParseAdbDevicesOutput_WindowsNewlines () + { + var output = + "List of devices attached\r\n" + + "emulator-5554 device transport_id:1\r\n" + + "\r\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); + + Assert.AreEqual (1, devices.Count); + Assert.AreEqual ("emulator-5554", devices [0].Serial); + Assert.IsTrue (devices [0].IsEmulator); + } + + [Test] + public void ParseAdbDevicesOutput_TabSeparator () + { + var output = + "List of devices attached\n" + + "emulator-5554\tdevice\n" + + "R5CR10YZQPJ\tdevice\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); + + Assert.AreEqual (2, devices.Count); + Assert.AreEqual ("emulator-5554", devices [0].Serial); + Assert.AreEqual ("R5CR10YZQPJ", devices [1].Serial); + } + + [Test] + public void ParseAdbDevicesOutput_IpPortDevice () + { + var output = + "List of devices attached\n" + + "192.168.1.100:5555 device product:sdk_gphone64_arm64 model:Remote_Device\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); + + Assert.AreEqual (1, devices.Count); + Assert.AreEqual ("192.168.1.100:5555", devices [0].Serial); + Assert.AreEqual (AdbDeviceType.Device, devices [0].Type, "IP devices should be Device"); + Assert.AreEqual ("Remote Device", devices [0].Description); + } + + [Test] + public void ParseAdbDevicesOutput_AdbDaemonStarting () + { + var output = + "* daemon not running; starting now at tcp:5037\n" + + "* daemon started successfully\n" + + "List of devices attached\n" + + "emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1\n" + + "0A041FDD400327 device usb:1-1 product:raven model:Pixel_6_Pro device:raven transport_id:2\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); + + Assert.AreEqual (2, devices.Count, "Should parse devices even with daemon startup messages"); + } + + // --- BuildDeviceDescription tests --- + // Consumer: dotnet/android GetAvailableAndroidDevices.cs + + [Test] + public void DescriptionPriorityOrder () + { + // Model has highest priority + var output1 = "List of devices attached\ndevice1 device product:product_name model:model_name device:device_name\n"; + var devices1 = AdbRunner.ParseAdbDevicesOutput (output1.Split ('\n')); + Assert.AreEqual ("model name", devices1 [0].Description, "Model should have highest priority"); + + // Product has second priority + var output2 = "List of devices attached\ndevice2 device product:product_name device:device_name\n"; + var devices2 = AdbRunner.ParseAdbDevicesOutput (output2.Split ('\n')); + Assert.AreEqual ("product name", devices2 [0].Description, "Product should have second priority"); + + // Device code name has third priority + var output3 = "List of devices attached\ndevice3 device device:device_name\n"; + var devices3 = AdbRunner.ParseAdbDevicesOutput (output3.Split ('\n')); + Assert.AreEqual ("device name", devices3 [0].Description, "Device should have third priority"); + } + + // --- FormatDisplayName tests --- + // Consumer: dotnet/android GetAvailableAndroidDevicesTests, BuildDeviceDescription (via AVD name formatting) + + [Test] + public void FormatDisplayName_ReplacesUnderscoresWithSpaces () + { + Assert.AreEqual ("Pixel 7 Pro", AdbRunner.FormatDisplayName ("pixel_7_pro")); + } + + [Test] + public void FormatDisplayName_AppliesTitleCase () + { + Assert.AreEqual ("Pixel 7 Pro", AdbRunner.FormatDisplayName ("pixel 7 pro")); + } + + [Test] + public void FormatDisplayName_ReplacesApiWithAPIUppercase () + { + Assert.AreEqual ("Pixel 5 API 34", AdbRunner.FormatDisplayName ("pixel_5_api_34")); + } + + [Test] + public void FormatDisplayName_HandlesMultipleApiOccurrences () + { + Assert.AreEqual ("Test API Device API 35", AdbRunner.FormatDisplayName ("test_api_device_api_35")); + } + + [Test] + public void FormatDisplayName_HandlesMixedCaseInput () + { + Assert.AreEqual ("Pixel 7 API 35", AdbRunner.FormatDisplayName ("PiXeL_7_API_35")); + } + + [Test] + public void FormatDisplayName_HandlesComplexNames () + { + Assert.AreEqual ("Pixel 9 Pro Xl API 36", AdbRunner.FormatDisplayName ("pixel_9_pro_xl_api_36")); + } + + [Test] + public void FormatDisplayName_PreservesNumbersAndSpecialChars () + { + Assert.AreEqual ("Pixel 7-Pro API 35", AdbRunner.FormatDisplayName ("pixel_7-pro_api_35")); + } + + [Test] + public void FormatDisplayName_HandlesEmptyString () + { + Assert.AreEqual ("", AdbRunner.FormatDisplayName ("")); + } + + [Test] + public void FormatDisplayName_HandlesSingleWord () + { + Assert.AreEqual ("Pixel", AdbRunner.FormatDisplayName ("pixel")); + } + + [Test] + public void FormatDisplayName_DoesNotReplaceApiInsideWords () + { + Assert.AreEqual ("Erapidevice", AdbRunner.FormatDisplayName ("erapidevice")); + } + + // --- MapAdbStateToStatus tests --- + // Consumer: ParseAdbDevicesOutput (internal mapping), public for custom consumers + + [Test] + public void MapAdbStateToStatus_AllStates () + { + Assert.AreEqual (AdbDeviceStatus.Online, AdbRunner.MapAdbStateToStatus ("device")); + Assert.AreEqual (AdbDeviceStatus.Offline, AdbRunner.MapAdbStateToStatus ("offline")); + Assert.AreEqual (AdbDeviceStatus.Unauthorized, AdbRunner.MapAdbStateToStatus ("unauthorized")); + Assert.AreEqual (AdbDeviceStatus.NoPermissions, AdbRunner.MapAdbStateToStatus ("no permissions")); + Assert.AreEqual (AdbDeviceStatus.Unknown, AdbRunner.MapAdbStateToStatus ("something-else")); + } + + // --- MergeDevicesAndEmulators tests --- + // Consumer: dotnet/android GetAvailableAndroidDevices.cs + + [Test] + public void MergeDevicesAndEmulators_NoEmulators_ReturnsAdbDevicesOnly () + { + var adbDevices = new List { + new AdbDeviceInfo { Serial = "0A041FDD400327", Description = "Pixel 5", Type = AdbDeviceType.Device, Status = AdbDeviceStatus.Online }, + }; + + var result = AdbRunner.MergeDevicesAndEmulators (adbDevices, new List ()); + + Assert.AreEqual (1, result.Count); + Assert.AreEqual ("0A041FDD400327", result [0].Serial); + } + + [Test] + public void MergeDevicesAndEmulators_NoRunningEmulators_AddsAllAvailable () + { + var adbDevices = new List { + new AdbDeviceInfo { Serial = "0A041FDD400327", Description = "Pixel 5", Type = AdbDeviceType.Device, Status = AdbDeviceStatus.Online }, + }; + var available = new List { "pixel_7_api_35", "pixel_9_api_36" }; + + var result = AdbRunner.MergeDevicesAndEmulators (adbDevices, available); + + Assert.AreEqual (3, result.Count); + + // Online first + Assert.AreEqual ("0A041FDD400327", result [0].Serial); + + // Non-running sorted alphabetically + Assert.AreEqual ("pixel_7_api_35", result [1].Serial); + Assert.AreEqual (AdbDeviceStatus.NotRunning, result [1].Status); + Assert.AreEqual ("pixel_7_api_35", result [1].AvdName); + Assert.AreEqual ("Pixel 7 API 35 (Not Running)", result [1].Description); + + Assert.AreEqual ("pixel_9_api_36", result [2].Serial); + Assert.AreEqual (AdbDeviceStatus.NotRunning, result [2].Status); + Assert.AreEqual ("Pixel 9 API 36 (Not Running)", result [2].Description); + } + + [Test] + public void MergeDevicesAndEmulators_RunningEmulator_NoDuplicate () + { + var adbDevices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", Description = "Pixel 7 API 35", + Type = AdbDeviceType.Emulator, Status = AdbDeviceStatus.Online, + AvdName = "pixel_7_api_35" + }, + }; + var available = new List { "pixel_7_api_35" }; + + var result = AdbRunner.MergeDevicesAndEmulators (adbDevices, available); + + Assert.AreEqual (1, result.Count, "Should not duplicate running emulator"); + Assert.AreEqual ("emulator-5554", result [0].Serial); + Assert.AreEqual (AdbDeviceStatus.Online, result [0].Status); + } + + [Test] + public void MergeDevicesAndEmulators_MixedRunningAndNotRunning () + { + var adbDevices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", Description = "Pixel 7 API 35", + Type = AdbDeviceType.Emulator, Status = AdbDeviceStatus.Online, + AvdName = "pixel_7_api_35" + }, + new AdbDeviceInfo { + Serial = "0A041FDD400327", Description = "Pixel 5", + Type = AdbDeviceType.Device, Status = AdbDeviceStatus.Online, + }, + }; + var available = new List { "pixel_7_api_35", "pixel_9_api_36", "nexus_5_api_30" }; + + var result = AdbRunner.MergeDevicesAndEmulators (adbDevices, available); + + Assert.AreEqual (4, result.Count); + + // Online devices first, sorted alphabetically + Assert.AreEqual ("0A041FDD400327", result [0].Serial); + Assert.AreEqual (AdbDeviceStatus.Online, result [0].Status); + + Assert.AreEqual ("emulator-5554", result [1].Serial); + Assert.AreEqual (AdbDeviceStatus.Online, result [1].Status); + + // Non-running emulators second, sorted alphabetically + Assert.AreEqual ("nexus_5_api_30", result [2].Serial); + Assert.AreEqual (AdbDeviceStatus.NotRunning, result [2].Status); + Assert.AreEqual ("Nexus 5 API 30 (Not Running)", result [2].Description); + + Assert.AreEqual ("pixel_9_api_36", result [3].Serial); + Assert.AreEqual (AdbDeviceStatus.NotRunning, result [3].Status); + } + + [Test] + public void MergeDevicesAndEmulators_CaseInsensitiveAvdNameMatching () + { + var adbDevices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", Description = "Pixel 7 API 35", + Type = AdbDeviceType.Emulator, Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35" + }, + }; + var available = new List { "pixel_7_api_35" }; // lowercase + + var result = AdbRunner.MergeDevicesAndEmulators (adbDevices, available); + + Assert.AreEqual (1, result.Count, "Should match AVD names case-insensitively"); + } + + [Test] + public void MergeDevicesAndEmulators_EmptyAdbDevices_ReturnsAllAvailable () + { + var result = AdbRunner.MergeDevicesAndEmulators (new List (), new List { "pixel_7_api_35", "pixel_9_api_36" }); + + Assert.AreEqual (2, result.Count); + Assert.AreEqual ("Pixel 7 API 35 (Not Running)", result [0].Description); + Assert.AreEqual ("Pixel 9 API 36 (Not Running)", result [1].Description); + } + + // --- AdbPath tests --- + // Consumer: MAUI DevTools Adb provider (AdbPath, IsAvailable properties) + + [Test] + public void Constructor_NullPath_ThrowsArgumentException () + { + Assert.Throws (() => new AdbRunner (null!)); + } + + [Test] + public void Constructor_EmptyPath_ThrowsArgumentException () + { + Assert.Throws (() => new AdbRunner ("")); + } + + [Test] + public void Constructor_WhitespacePath_ThrowsArgumentException () + { + Assert.Throws (() => new AdbRunner (" ")); + } + + [Test] + public void ParseAdbDevicesOutput_DeviceWithProductOnly () + { + var output = + "List of devices attached\n" + + "emulator-5554 device product:aosp_x86_64\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); + + Assert.AreEqual (1, devices.Count); + Assert.AreEqual ("aosp_x86_64", devices [0].Product); + Assert.AreEqual (AdbDeviceType.Emulator, devices [0].Type); + } + + [Test] + public void ParseAdbDevicesOutput_MultipleDevices () + { + var output = + "List of devices attached\n" + + "emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1\n" + + "emulator-5556 device product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64x transport_id:3\n" + + "0A041FDD400327 device usb:1-1 product:raven model:Pixel_6_Pro device:raven transport_id:2\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); + + Assert.AreEqual (3, devices.Count); + Assert.AreEqual ("emulator-5554", devices [0].Serial); + Assert.AreEqual (AdbDeviceType.Emulator, devices [0].Type); + Assert.AreEqual ("emulator-5556", devices [1].Serial); + Assert.AreEqual (AdbDeviceType.Emulator, devices [1].Type); + Assert.AreEqual ("0A041FDD400327", devices [2].Serial); + Assert.AreEqual (AdbDeviceType.Device, devices [2].Type); + Assert.AreEqual ("Pixel_6_Pro", devices [2].Model); + } + + [Test] + public void MergeDevicesAndEmulators_AllEmulatorsRunning_NoDuplicate () + { + var emulator1 = new AdbDeviceInfo { + Serial = "emulator-5554", Description = "Pixel 7 API 35", + Type = AdbDeviceType.Emulator, Status = AdbDeviceStatus.Online, + AvdName = "pixel_7_api_35", + }; + var emulator2 = new AdbDeviceInfo { + Serial = "emulator-5556", Description = "Pixel 9 API 36", + Type = AdbDeviceType.Emulator, Status = AdbDeviceStatus.Online, + AvdName = "pixel_9_api_36", + }; + + var result = AdbRunner.MergeDevicesAndEmulators ( + new List { emulator1, emulator2 }, + new List { "pixel_7_api_35", "pixel_9_api_36" }); + + Assert.AreEqual (2, result.Count, "Should not add duplicates when all emulators are running"); + Assert.IsTrue (result.TrueForAll (d => d.Status == AdbDeviceStatus.Online)); + } + + [Test] + public void MergeDevicesAndEmulators_NonRunningEmulatorHasFormattedDescription () + { + var result = AdbRunner.MergeDevicesAndEmulators ( + new List (), + new List { "pixel_7_pro_api_35" }); + + Assert.AreEqual (1, result.Count); + Assert.AreEqual ("Pixel 7 Pro API 35 (Not Running)", result [0].Description); + Assert.AreEqual (AdbDeviceStatus.NotRunning, result [0].Status); + } + + [Test] + public void ParseAdbDevicesOutput_RecoveryState () + { + var output = "List of devices attached\n" + + "0A041FDD400327 recovery product:redfin model:Pixel_5 device:redfin transport_id:2\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); + + Assert.AreEqual (1, devices.Count); + Assert.AreEqual ("0A041FDD400327", devices [0].Serial); + Assert.AreEqual (AdbDeviceStatus.Unknown, devices [0].Status); + } + + [Test] + public void ParseAdbDevicesOutput_SideloadState () + { + var output = "List of devices attached\n" + + "0A041FDD400327 sideload\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output.Split ('\n')); + + Assert.AreEqual (1, devices.Count); + Assert.AreEqual ("0A041FDD400327", devices [0].Serial); + Assert.AreEqual (AdbDeviceStatus.Unknown, devices [0].Status); + } + + [Test] + public void MapAdbStateToStatus_Recovery_ReturnsUnknown () + { + Assert.AreEqual (AdbDeviceStatus.Unknown, AdbRunner.MapAdbStateToStatus ("recovery")); + } + + [Test] + public void MapAdbStateToStatus_Sideload_ReturnsUnknown () + { + Assert.AreEqual (AdbDeviceStatus.Unknown, AdbRunner.MapAdbStateToStatus ("sideload")); + } + + // --- WaitForDeviceAsync tests --- + // Consumer: MAUI DevTools Adb provider (WaitForDeviceAsync) + + [Test] + public void WaitForDeviceAsync_NegativeTimeout_ThrowsArgumentOutOfRange () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.WaitForDeviceAsync (timeout: System.TimeSpan.FromSeconds (-1))); + } + + [Test] + public void WaitForDeviceAsync_ZeroTimeout_ThrowsArgumentOutOfRange () + { + var runner = new AdbRunner ("/fake/sdk/platform-tools/adb"); + Assert.ThrowsAsync ( + async () => await runner.WaitForDeviceAsync (timeout: System.TimeSpan.Zero)); + } +} diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/RunnerIntegrationTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/RunnerIntegrationTests.cs new file mode 100644 index 00000000..a057073e --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/RunnerIntegrationTests.cs @@ -0,0 +1,163 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +namespace Xamarin.Android.Tools.Tests; + +/// +/// Integration tests that verify AdbRunner works against real Android SDK tools. +/// +/// These tests only run on CI (TF_BUILD=True or CI=true) where hosted +/// images have JDK (JAVA_HOME) and Android SDK (ANDROID_HOME) pre-installed. +/// Tests are skipped on local developer machines. +/// +[TestFixture] +[Category ("Integration")] +public class RunnerIntegrationTests +{ + static string sdkPath; + static string jdkPath; + static string adbPath; + static SdkManager sdkManager; + static string bootstrappedSdkPath; + + static void Log (TraceLevel level, string message) + { + TestContext.Progress.WriteLine ($"[{level}] {message}"); + } + + static void RequireCi () + { + if (Environment.GetEnvironmentVariable ("TF_BUILD") is null && + Environment.GetEnvironmentVariable ("CI") is null) { + Assert.Ignore ("Integration tests only run on CI (TF_BUILD or CI env var must be set)."); + } + } + + /// + /// One-time setup: use pre-installed JDK/SDK on CI agents, bootstrap + /// cmdline-tools only if needed. Azure Pipelines hosted images have + /// JAVA_HOME and ANDROID_HOME already configured. + /// + [OneTimeSetUp] + public async Task OneTimeSetUp () + { + RequireCi (); + + // Use pre-installed JDK from JAVA_HOME (always available on CI agents) + jdkPath = Environment.GetEnvironmentVariable (EnvironmentVariableNames.JavaHome); + if (string.IsNullOrEmpty (jdkPath) || !Directory.Exists (jdkPath)) { + Assert.Ignore ("JAVA_HOME not set or invalid — cannot run integration tests."); + return; + } + TestContext.Progress.WriteLine ($"Using JDK from JAVA_HOME: {jdkPath}"); + + // Use pre-installed Android SDK from ANDROID_HOME (available on CI agents) + sdkPath = Environment.GetEnvironmentVariable (EnvironmentVariableNames.AndroidHome); + if (string.IsNullOrEmpty (sdkPath) || !Directory.Exists (sdkPath)) { + // Fall back to bootstrapping our own SDK + TestContext.Progress.WriteLine ("ANDROID_HOME not set — bootstrapping SDK..."); + try { + bootstrappedSdkPath = Path.Combine (Path.GetTempPath (), $"runner-integration-{Guid.NewGuid ():N}", "android-sdk"); + sdkManager = new SdkManager (Log); + sdkManager.JavaSdkPath = jdkPath; + using var cts = new CancellationTokenSource (TimeSpan.FromMinutes (10)); + await sdkManager.BootstrapAsync (bootstrappedSdkPath, cancellationToken: cts.Token); + sdkPath = bootstrappedSdkPath; + sdkManager.AndroidSdkPath = sdkPath; + + // Install platform-tools (provides adb) + await sdkManager.InstallAsync (new [] { "platform-tools" }, acceptLicenses: true, cancellationToken: cts.Token); + TestContext.Progress.WriteLine ($"SDK bootstrapped to: {sdkPath}"); + } + catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException || ex is InvalidOperationException) { + Assert.Ignore ($"SDK bootstrap failed: {ex.Message}"); + return; + } + } + else { + TestContext.Progress.WriteLine ($"Using SDK from ANDROID_HOME: {sdkPath}"); + sdkManager = new SdkManager (Log); + sdkManager.JavaSdkPath = jdkPath; + sdkManager.AndroidSdkPath = sdkPath; + } + + // Resolve the full path to adb for AdbRunner + var adbExe = OS.IsWindows ? "adb.exe" : "adb"; + adbPath = Path.Combine (sdkPath, "platform-tools", adbExe); + if (!File.Exists (adbPath)) + Assert.Ignore ($"adb not found at {adbPath}"); + } + + [OneTimeTearDown] + public void OneTimeTearDown () + { + sdkManager?.Dispose (); + + // Only clean up if we bootstrapped our own SDK + if (bootstrappedSdkPath != null) { + var basePath = Path.GetDirectoryName (bootstrappedSdkPath); + if (basePath != null && Directory.Exists (basePath)) { + try { + Directory.Delete (basePath, recursive: true); + } + catch { + // Best-effort cleanup on CI + } + } + } + } + + // ── AdbRunner integration ────────────────────────────────────── + + [Test] + public void AdbRunner_Constructor_AcceptsValidPath () + { + var runner = new AdbRunner (adbPath); + Assert.IsNotNull (runner); + } + + [Test] + public async Task AdbRunner_ListDevicesAsync_ReturnsWithoutError () + { + var runner = new AdbRunner (adbPath); + + // On CI there are no physical devices or emulators, but the command + // should succeed and return an empty (or non-null) list. + var devices = await runner.ListDevicesAsync (); + + Assert.IsNotNull (devices); + TestContext.Progress.WriteLine ($"ListDevicesAsync returned {devices.Count} device(s)"); + } + + [Test] + public void AdbRunner_WaitForDeviceAsync_TimesOut_WhenNoDevice () + { + var runner = new AdbRunner (adbPath); + var ex = Assert.ThrowsAsync (async () => + await runner.WaitForDeviceAsync (timeout: TimeSpan.FromSeconds (5))); + + Assert.IsNotNull (ex); + TestContext.Progress.WriteLine ($"WaitForDeviceAsync timed out as expected: {ex!.Message}"); + } + + // ── Cross-runner: verify tools exist ─────────────────────────── + + [Test] + public void AllRunners_ToolDiscovery_ConsistentWithSdk () + { + var runner = new AdbRunner (adbPath); + + // adb path should be under the SDK + Assert.IsTrue (File.Exists (adbPath), $"adb should exist at {adbPath}"); + StringAssert.StartsWith (sdkPath, adbPath); + } +}