diff --git a/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs b/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs index 272a17b1..4d3e2842 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/EnvironmentVariableNames.cs @@ -40,5 +40,11 @@ internal static class EnvironmentVariableNames /// Executable file extensions (Windows). /// public const string PathExt = "PATHEXT"; + + /// + /// Overrides the default location for Android user-specific data + /// (AVDs, preferences, etc.). Defaults to $HOME/.android. + /// + public const string AndroidUserHome = "ANDROID_USER_HOME"; } } diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceInfo.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceInfo.cs new file mode 100644 index 00000000..589f89ff --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/AdbDeviceInfo.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Xamarin.Android.Tools; + +/// +/// Represents the type of an Android device. +/// +public enum AdbDeviceType +{ + Device, + Emulator +} + +/// +/// Represents the status of an Android device. +/// +public enum AdbDeviceStatus +{ + Online, + Offline, + Unauthorized, + NoPermissions, + NotRunning, + Unknown +} + +/// +/// Represents an Android device or emulator from 'adb devices -l' output. +/// Mirrors the metadata produced by dotnet/android's GetAvailableAndroidDevices task. +/// +public class AdbDeviceInfo +{ + /// + /// Serial number of the device (e.g., "emulator-5554", "0A041FDD400327"). + /// For non-running emulators, this is the AVD name. + /// + public string Serial { get; set; } = string.Empty; + + /// + /// Human-friendly description of the device (e.g., "Pixel 7 API 35", "Pixel 6 Pro"). + /// + public string Description { get; set; } = string.Empty; + + /// + /// Device type: Device or Emulator. + /// + public AdbDeviceType Type { get; set; } + + /// + /// Device status: Online, Offline, Unauthorized, NoPermissions, NotRunning, Unknown. + /// + public AdbDeviceStatus Status { get; set; } + + /// + /// AVD name for emulators (e.g., "pixel_7_api_35"). Null for physical devices. + /// + public string? AvdName { get; set; } + + /// + /// Device model from adb properties (e.g., "Pixel_6_Pro"). + /// + public string? Model { get; set; } + + /// + /// Product name from adb properties (e.g., "raven"). + /// + public string? Product { get; set; } + + /// + /// Device code name from adb properties (e.g., "raven"). + /// + public string? Device { get; set; } + + /// + /// Transport ID from adb properties. + /// + public string? TransportId { get; set; } + + /// + /// Whether this device is an emulator. + /// + public bool IsEmulator => Type == AdbDeviceType.Emulator; +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/AvdInfo.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/AvdInfo.cs new file mode 100644 index 00000000..d627cfc1 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/AvdInfo.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Xamarin.Android.Tools; + +public class AvdInfo +{ + public string Name { get; set; } = string.Empty; + public string? DeviceProfile { get; set; } + public string? Path { get; set; } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs new file mode 100644 index 00000000..8765df77 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Xamarin.Android.Tools +{ + /// + /// Options for booting an Android emulator. + /// + public class EmulatorBootOptions + { + public TimeSpan BootTimeout { get; set; } = TimeSpan.FromSeconds (300); + public string? AdditionalArgs { get; set; } + public bool ColdBoot { get; set; } + public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds (500); + } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs new file mode 100644 index 00000000..59281792 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Xamarin.Android.Tools +{ + /// + /// Result of an emulator boot operation. + /// + public class EmulatorBootResult + { + public bool Success { get; set; } + public string? Serial { get; set; } + public string? ErrorMessage { get; set; } + } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs index 28113d72..eb188e19 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs @@ -203,6 +203,73 @@ static string JoinArguments (string[] args) } #endif + /// + /// Throws when is non-zero. + /// Includes stderr/stdout context in the message when available. + /// + public static void ThrowIfFailed (int exitCode, string command, string? stderr = null, string? stdout = null) + { + if (exitCode == 0) + return; + + var message = $"'{command}' failed with exit code {exitCode}."; + + if (!string.IsNullOrEmpty (stderr)) + message += $" stderr:{Environment.NewLine}{stderr!.Trim ()}"; + if (!string.IsNullOrEmpty (stdout)) + message += $" stdout:{Environment.NewLine}{stdout!.Trim ()}"; + + throw new InvalidOperationException (message); + } + + /// + /// Validates that is not null or empty. + /// Throws for null values and + /// for empty strings. + /// + public static void ValidateNotNullOrEmpty (string? value, string paramName) + { + if (value is null) + throw new ArgumentNullException (paramName); + if (value.Length == 0) + throw new ArgumentException ("Value cannot be an empty string.", paramName); + } + + /// + /// Searches versioned cmdline-tools directories (descending) and "latest" for a specific tool binary. + /// Falls back to the legacy tools/bin path. Returns null if not found. + /// + public static string? FindCmdlineTool (string sdkPath, string toolName, string extension) + { + var cmdlineToolsDir = Path.Combine (sdkPath, "cmdline-tools"); + + if (Directory.Exists (cmdlineToolsDir)) { + var subdirs = new List<(string name, Version? version)> (); + foreach (var dir in Directory.GetDirectories (cmdlineToolsDir)) { + var name = Path.GetFileName (dir); + if (string.IsNullOrEmpty (name) || name == "latest") + continue; + Version.TryParse (name, out var v); + subdirs.Add ((name, v ?? new Version (0, 0))); + } + subdirs.Sort ((a, b) => b.version!.CompareTo (a.version)); + + // Check versioned directories first (highest version first), then "latest" + foreach (var (name, _) in subdirs) { + var toolPath = Path.Combine (cmdlineToolsDir, name, "bin", toolName + extension); + if (File.Exists (toolPath)) + return toolPath; + } + var latestPath = Path.Combine (cmdlineToolsDir, "latest", "bin", toolName + extension); + if (File.Exists (latestPath)) + return latestPath; + } + + // Legacy fallback: tools/bin/ + var legacyPath = Path.Combine (sdkPath, "tools", "bin", toolName + extension); + return File.Exists (legacyPath) ? legacyPath : null; + } + internal static IEnumerable FindExecutablesInPath (string executable) { var path = Environment.GetEnvironmentVariable (EnvironmentVariableNames.Path) ?? ""; diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs new file mode 100644 index 00000000..aaa8f9e3 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -0,0 +1,344 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace Xamarin.Android.Tools; + +/// +/// Runs Android Debug Bridge (adb) commands. +/// Parsing logic ported from dotnet/android GetAvailableAndroidDevices task. +/// +public class AdbRunner +{ + readonly Func getSdkPath; + + // Matches known adb device states. Uses \s+ to handle both space and tab separators. + // Explicit state list prevents false positives from non-device lines. + static readonly Regex AdbDevicesRegex = new Regex ( + @"^([^\s]+)\s+(device|offline|unauthorized|authorizing|no permissions|recovery|sideload|bootloader|connecting|host)\s*(.*)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + static readonly Regex ApiRegex = new Regex (@"\bApi\b", RegexOptions.Compiled); + + public AdbRunner (Func getSdkPath) + { + this.getSdkPath = getSdkPath ?? throw new ArgumentNullException (nameof (getSdkPath)); + } + + public string? AdbPath { + get { + var sdkPath = getSdkPath (); + if (!string.IsNullOrEmpty (sdkPath)) { + var ext = OS.IsWindows ? ".exe" : ""; + var sdkAdb = Path.Combine (sdkPath, "platform-tools", "adb" + ext); + if (File.Exists (sdkAdb)) + return sdkAdb; + } + return ProcessUtils.FindExecutablesInPath ("adb").FirstOrDefault (); + } + } + + public bool IsAvailable => AdbPath is not null; + + string RequireAdb () + { + return AdbPath ?? throw new InvalidOperationException ("ADB not found."); + } + + ProcessStartInfo CreateAdbProcess (string adbPath, params string [] args) + { + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, args); + AndroidEnvironmentHelper.ConfigureEnvironment (psi, getSdkPath (), null); + return psi; + } + + /// + /// Lists connected devices using 'adb devices -l'. + /// For emulators, queries the AVD name using 'adb -s <serial> emu avd name'. + /// + public virtual async Task> ListDevicesAsync (CancellationToken cancellationToken = default) + { + var adb = RequireAdb (); + using var stdout = new StringWriter (); + var psi = CreateAdbProcess (adb, "devices", "-l"); + await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken).ConfigureAwait (false); + + var devices = ParseAdbDevicesOutput (stdout.ToString ()); + + // For each emulator, try to get the AVD name + foreach (var device in devices) { + if (device.Type == AdbDeviceType.Emulator) { + device.AvdName = await GetEmulatorAvdNameAsync (adb, device.Serial, cancellationToken).ConfigureAwait (false); + device.Description = BuildDeviceDescription (device); + } + } + + return devices; + } + + /// + /// Queries the emulator for its AVD name using 'adb -s <serial> emu avd name'. + /// Ported from dotnet/android GetAvailableAndroidDevices.GetEmulatorAvdName. + /// + public async Task GetEmulatorAvdNameAsync (string adbPath, string serial, CancellationToken cancellationToken = default) + { + try { + using var stdout = new StringWriter (); + var psi = CreateAdbProcess (adbPath, "-s", serial, "emu", "avd", "name"); + await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken).ConfigureAwait (false); + + foreach (var line in stdout.ToString ().Split ('\n')) { + var trimmed = line.Trim (); + if (!string.IsNullOrEmpty (trimmed) && + !string.Equals (trimmed, "OK", StringComparison.OrdinalIgnoreCase)) { + return trimmed; + } + } + } catch (OperationCanceledException) { + throw; + } catch { + // Silently ignore failures (emulator may not support this command) + } + + return null; + } + + public async Task WaitForDeviceAsync (string? serial = null, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + var adb = RequireAdb (); + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds (60); + + var args = string.IsNullOrEmpty (serial) + ? new [] { "wait-for-device" } + : new [] { "-s", serial!, "wait-for-device" }; + + var psi = CreateAdbProcess (adb, args); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); + cts.CancelAfter (effectiveTimeout); + + try { + await ProcessUtils.StartProcess (psi, null, null, cts.Token).ConfigureAwait (false); + } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + throw new TimeoutException ($"Timed out waiting for device after {effectiveTimeout.TotalSeconds}s."); + } + } + + public async Task StopEmulatorAsync (string serial, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace (serial)) + throw new ArgumentException ("Serial must not be empty.", nameof (serial)); + + var adb = RequireAdb (); + var psi = CreateAdbProcess (adb, "-s", serial, "emu", "kill"); + await ProcessUtils.StartProcess (psi, null, null, cancellationToken).ConfigureAwait (false); + } + + /// + /// Runs 'adb -s {serial} shell getprop {propertyName}' and returns the first non-empty line, or null. + /// + public virtual async Task GetShellPropertyAsync (string serial, string propertyName, CancellationToken cancellationToken = default) + { + var adb = RequireAdb (); + using var stdout = new StringWriter (); + var psi = CreateAdbProcess (adb, "-s", serial, "shell", "getprop", propertyName); + await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken).ConfigureAwait (false); + + return FirstNonEmptyLine (stdout.ToString ()); + } + + /// + /// Runs 'adb -s {serial} shell {command}' and returns the first non-empty line, or null. + /// + public virtual async Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken = default) + { + var adb = RequireAdb (); + using var stdout = new StringWriter (); + var psi = CreateAdbProcess (adb, "-s", serial, "shell", command); + await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken).ConfigureAwait (false); + + return FirstNonEmptyLine (stdout.ToString ()); + } + + static string? FirstNonEmptyLine (string output) + { + foreach (var line in output.Split ('\n')) { + var trimmed = line.Trim (); + if (!string.IsNullOrEmpty (trimmed)) + return trimmed; + } + return null; + } + + /// + /// Parses the output of 'adb devices -l'. + /// Ported from dotnet/android GetAvailableAndroidDevices.ParseAdbDevicesOutput. + /// + public static List ParseAdbDevicesOutput (string output) + { + var devices = new List (); + + foreach (var line in output.Split ('\n')) { + var trimmed = line.Trim (); + if (string.IsNullOrEmpty (trimmed) || trimmed.IndexOf ("List of devices", StringComparison.OrdinalIgnoreCase) >= 0) + continue; + + var match = AdbDevicesRegex.Match (trimmed); + if (!match.Success) + continue; + + var serial = match.Groups [1].Value.Trim (); + var state = match.Groups [2].Value.Trim (); + var properties = match.Groups [3].Value.Trim (); + + // Parse key:value pairs from the properties string + var propDict = new Dictionary (StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrEmpty (properties)) { + var pairs = properties.Split (new [] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var pair in pairs) { + var colonIndex = pair.IndexOf (':'); + if (colonIndex > 0 && colonIndex < pair.Length - 1) { + var key = pair.Substring (0, colonIndex); + var value = pair.Substring (colonIndex + 1); + propDict [key] = value; + } + } + } + + var deviceType = serial.StartsWith ("emulator-", StringComparison.OrdinalIgnoreCase) + ? AdbDeviceType.Emulator + : AdbDeviceType.Device; + + var device = new AdbDeviceInfo { + Serial = serial, + Type = deviceType, + Status = MapAdbStateToStatus (state), + }; + + if (propDict.TryGetValue ("model", out var model)) + device.Model = model; + if (propDict.TryGetValue ("product", out var product)) + device.Product = product; + if (propDict.TryGetValue ("device", out var deviceCodeName)) + device.Device = deviceCodeName; + if (propDict.TryGetValue ("transport_id", out var transportId)) + device.TransportId = transportId; + + // Build description (will be updated later if emulator AVD name is available) + device.Description = BuildDeviceDescription (device); + + devices.Add (device); + } + + return devices; + } + + /// + /// Maps adb device states to status values. + /// Ported from dotnet/android GetAvailableAndroidDevices.MapAdbStateToStatus. + /// + public static AdbDeviceStatus MapAdbStateToStatus (string adbState) + { + switch (adbState.ToLowerInvariant ()) { + case "device": return AdbDeviceStatus.Online; + case "offline": return AdbDeviceStatus.Offline; + case "unauthorized": return AdbDeviceStatus.Unauthorized; + case "no permissions": return AdbDeviceStatus.NoPermissions; + default: return AdbDeviceStatus.Unknown; + } + } + + /// + /// Builds a human-friendly description for a device. + /// Priority: AVD name (for emulators) > model > product > device > serial. + /// Ported from dotnet/android GetAvailableAndroidDevices.BuildDeviceDescription. + /// + public static string BuildDeviceDescription (AdbDeviceInfo device) + { + if (device.Type == AdbDeviceType.Emulator && !string.IsNullOrEmpty (device.AvdName)) + return FormatDisplayName (device.AvdName!); + + if (!string.IsNullOrEmpty (device.Model)) + return device.Model!.Replace ('_', ' '); + + if (!string.IsNullOrEmpty (device.Product)) + return device.Product!.Replace ('_', ' '); + + if (!string.IsNullOrEmpty (device.Device)) + return device.Device!.Replace ('_', ' '); + + return device.Serial; + } + + /// + /// Formats an AVD name into a user-friendly display name. + /// Replaces underscores with spaces, applies title case, and capitalizes "API". + /// Ported from dotnet/android GetAvailableAndroidDevices.FormatDisplayName. + /// + public static string FormatDisplayName (string avdName) + { + if (string.IsNullOrEmpty (avdName)) + return avdName ?? string.Empty; + + var textInfo = CultureInfo.InvariantCulture.TextInfo; + avdName = textInfo.ToTitleCase (avdName.Replace ('_', ' ')); + + // Replace "Api" with "API" + avdName = ApiRegex.Replace (avdName, "API"); + return avdName; + } + + /// + /// Merges devices from adb with available emulators from 'emulator -list-avds'. + /// Running emulators are not duplicated. Non-running emulators are added with Status=NotRunning. + /// Ported from dotnet/android GetAvailableAndroidDevices.MergeDevicesAndEmulators. + /// + public static List MergeDevicesAndEmulators (IReadOnlyList adbDevices, IReadOnlyList availableEmulators) + { + var result = new List (adbDevices); + + // Build a set of AVD names that are already running + var runningAvdNames = new HashSet (StringComparer.OrdinalIgnoreCase); + foreach (var device in adbDevices) { + if (!string.IsNullOrEmpty (device.AvdName)) + runningAvdNames.Add (device.AvdName!); + } + + // Add non-running emulators + foreach (var avdName in availableEmulators) { + if (runningAvdNames.Contains (avdName)) + continue; + + var displayName = FormatDisplayName (avdName); + result.Add (new AdbDeviceInfo { + Serial = avdName, + Description = displayName + " (Not Running)", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.NotRunning, + AvdName = avdName, + }); + } + + // Sort: online devices first, then not-running emulators, alphabetically by description + result.Sort ((a, b) => { + var aNotRunning = a.Status == AdbDeviceStatus.NotRunning; + var bNotRunning = b.Status == AdbDeviceStatus.NotRunning; + + if (aNotRunning != bNotRunning) + return aNotRunning ? 1 : -1; + + return string.Compare (a.Description, b.Description, StringComparison.OrdinalIgnoreCase); + }); + + return result; + } +} + diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs new file mode 100644 index 00000000..c5bb3af2 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using System.IO; + +namespace Xamarin.Android.Tools; + +/// +/// Helper for setting up environment variables for Android SDK tools. +/// +internal static class AndroidEnvironmentHelper +{ + /// + /// Configures environment variables on a ProcessStartInfo for running Android SDK tools. + /// + internal static void ConfigureEnvironment (ProcessStartInfo psi, string? sdkPath, string? jdkPath) + { + if (!string.IsNullOrEmpty (sdkPath)) + psi.EnvironmentVariables [EnvironmentVariableNames.AndroidHome] = sdkPath; + + if (!string.IsNullOrEmpty (jdkPath)) { + psi.EnvironmentVariables [EnvironmentVariableNames.JavaHome] = jdkPath; + var jdkBin = Path.Combine (jdkPath!, "bin"); + var currentPath = psi.EnvironmentVariables [EnvironmentVariableNames.Path] ?? ""; + psi.EnvironmentVariables [EnvironmentVariableNames.Path] = string.IsNullOrEmpty (currentPath) ? jdkBin : jdkBin + Path.PathSeparator + currentPath; + } + + // Set ANDROID_USER_HOME for consistent AVD location across tools (matches SdkManager behavior) + if (!psi.EnvironmentVariables.ContainsKey (EnvironmentVariableNames.AndroidUserHome)) { + psi.EnvironmentVariables [EnvironmentVariableNames.AndroidUserHome] = Path.Combine ( + Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), ".android"); + } + } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs new file mode 100644 index 00000000..9e37c8d2 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs @@ -0,0 +1,226 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Xamarin.Android.Tools; + +/// +/// Runs Android Virtual Device Manager (avdmanager) commands. +/// +public class AvdManagerRunner +{ + readonly Func getSdkPath; + readonly Func? getJdkPath; + + public AvdManagerRunner (Func getSdkPath) + : this (getSdkPath, null) + { + } + + public AvdManagerRunner (Func getSdkPath, Func? getJdkPath) + { + this.getSdkPath = getSdkPath ?? throw new ArgumentNullException (nameof (getSdkPath)); + this.getJdkPath = getJdkPath; + } + + public string? AvdManagerPath { + get { + var sdkPath = getSdkPath (); + if (string.IsNullOrEmpty (sdkPath)) + return null; + + var ext = OS.IsWindows ? ".bat" : ""; + var cmdlineToolsDir = Path.Combine (sdkPath, "cmdline-tools"); + + if (Directory.Exists (cmdlineToolsDir)) { + // Versioned dirs sorted descending, then "latest" as fallback + var searchDirs = Directory.GetDirectories (cmdlineToolsDir) + .Select (Path.GetFileName) + .Where (n => n != "latest" && !string.IsNullOrEmpty (n)) + .OrderByDescending (n => Version.TryParse (n, out var v) ? v : new Version (0, 0)) + .Append ("latest"); + + foreach (var dir in searchDirs) { + var toolPath = Path.Combine (cmdlineToolsDir, dir!, "bin", "avdmanager" + ext); + if (File.Exists (toolPath)) + return toolPath; + } + } + + // Legacy fallback: tools/bin/avdmanager + var legacyPath = Path.Combine (sdkPath, "tools", "bin", "avdmanager" + ext); + return File.Exists (legacyPath) ? legacyPath : null; + } + } + + public bool IsAvailable => !string.IsNullOrEmpty (AvdManagerPath); + + string RequireAvdManagerPath () + { + return AvdManagerPath ?? throw new InvalidOperationException ("AVD Manager not found."); + } + + void ConfigureEnvironment (ProcessStartInfo psi) + { + AndroidEnvironmentHelper.ConfigureEnvironment (psi, getSdkPath (), getJdkPath?.Invoke ()); + } + + public async Task> ListAvdsAsync (CancellationToken cancellationToken = default) + { + var avdManagerPath = RequireAvdManagerPath (); + + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (avdManagerPath, "list", "avd"); + ConfigureEnvironment (psi); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken).ConfigureAwait (false); + + if (exitCode != 0) + throw new InvalidOperationException ($"avdmanager list avd failed (exit code {exitCode}): {stderr.ToString ().Trim ()}"); + + return ParseAvdListOutput (stdout.ToString ()); + } + + public async Task CreateAvdAsync (string name, string systemImage, string? deviceProfile = null, + bool force = false, CancellationToken cancellationToken = default) + { + if (name is null) + throw new ArgumentNullException (nameof (name)); + if (name.Length == 0) + throw new ArgumentException ("Value cannot be an empty string.", nameof (name)); + if (systemImage is null) + throw new ArgumentNullException (nameof (systemImage)); + if (systemImage.Length == 0) + throw new ArgumentException ("Value cannot be an empty string.", nameof (systemImage)); + + var avdManagerPath = RequireAvdManagerPath (); + + // Check if AVD already exists — return it instead of failing + if (!force) { + var existing = (await ListAvdsAsync (cancellationToken).ConfigureAwait (false)) + .FirstOrDefault (a => string.Equals (a.Name, name, StringComparison.OrdinalIgnoreCase)); + if (existing is not null) + return existing; + } + + // Detect orphaned AVD directory (folder exists without .ini registration). + var avdDir = Path.Combine (GetAvdRootDirectory (), $"{name}.avd"); + if (Directory.Exists (avdDir)) + force = true; + + var args = new List { "create", "avd", "-n", name, "-k", systemImage }; + if (!string.IsNullOrEmpty (deviceProfile)) + args.AddRange (new [] { "-d", deviceProfile }); + if (force) + args.Add ("--force"); + + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (avdManagerPath, args.ToArray ()); + psi.RedirectStandardInput = true; + ConfigureEnvironment (psi); + + // avdmanager prompts "Do you wish to create a custom hardware profile?" — answer "no" + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, + onStarted: p => { + try { + p.StandardInput.WriteLine ("no"); + p.StandardInput.Close (); + } catch (IOException) { + // Process may have already exited + } + }).ConfigureAwait (false); + + if (exitCode != 0) { + var errorOutput = stderr.ToString ().Trim (); + if (string.IsNullOrEmpty (errorOutput)) + errorOutput = stdout.ToString ().Trim (); + throw new InvalidOperationException ($"Failed to create AVD '{name}': {errorOutput}"); + } + + // Re-list to get the actual path from avdmanager (respects ANDROID_USER_HOME/ANDROID_AVD_HOME) + var avds = await ListAvdsAsync (cancellationToken).ConfigureAwait (false); + var created = avds.FirstOrDefault (a => string.Equals (a.Name, name, StringComparison.OrdinalIgnoreCase)); + if (created is not null) + return created; + + // Fallback if re-list didn't find it + return new AvdInfo { + Name = name, + DeviceProfile = deviceProfile, + Path = Path.Combine (GetAvdRootDirectory (), $"{name}.avd"), + }; + } + + public async Task DeleteAvdAsync (string name, CancellationToken cancellationToken = default) + { + if (name is null) + throw new ArgumentNullException (nameof (name)); + if (name.Length == 0) + throw new ArgumentException ("Value cannot be an empty string.", nameof (name)); + + var avdManagerPath = RequireAvdManagerPath (); + + using var stderr = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (avdManagerPath, "delete", "avd", "--name", name); + ConfigureEnvironment (psi); + var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken).ConfigureAwait (false); + + if (exitCode != 0) + throw new InvalidOperationException ($"Failed to delete AVD '{name}': {stderr.ToString ().Trim ()}"); + } + + internal static List ParseAvdListOutput (string output) + { + var avds = new List (); + string? currentName = null, currentDevice = null, currentPath = null; + + foreach (var line in output.Split ('\n')) { + var trimmed = line.Trim (); + if (trimmed.StartsWith ("Name:", StringComparison.OrdinalIgnoreCase)) { + if (currentName is not null) + avds.Add (new AvdInfo { Name = currentName, DeviceProfile = currentDevice, Path = currentPath }); + currentName = trimmed.Substring (5).Trim (); + currentDevice = currentPath = null; + } + else if (trimmed.StartsWith ("Device:", StringComparison.OrdinalIgnoreCase)) + currentDevice = trimmed.Substring (7).Trim (); + else if (trimmed.StartsWith ("Path:", StringComparison.OrdinalIgnoreCase)) + currentPath = trimmed.Substring (5).Trim (); + } + + if (currentName is not null) + avds.Add (new AvdInfo { Name = currentName, DeviceProfile = currentDevice, Path = currentPath }); + + return avds; + } + + /// + /// Resolves the AVD root directory, respecting ANDROID_AVD_HOME and ANDROID_USER_HOME. + /// + static string GetAvdRootDirectory () + { + // ANDROID_AVD_HOME takes highest priority + var avdHome = Environment.GetEnvironmentVariable ("ANDROID_AVD_HOME"); + if (!string.IsNullOrEmpty (avdHome)) + return avdHome; + + // ANDROID_USER_HOME/avd is the next option + var userHome = Environment.GetEnvironmentVariable (EnvironmentVariableNames.AndroidUserHome); + if (!string.IsNullOrEmpty (userHome)) + return Path.Combine (userHome, "avd"); + + // Default: ~/.android/avd + return Path.Combine ( + Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), + ".android", "avd"); + } +} + diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs new file mode 100644 index 00000000..837108f5 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -0,0 +1,237 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Xamarin.Android.Tools; + +/// +/// Runs Android Emulator commands. +/// +public class EmulatorRunner +{ + readonly Func getSdkPath; + readonly Func? getJdkPath; + + public EmulatorRunner (Func getSdkPath) + : this (getSdkPath, null) + { + } + + public EmulatorRunner (Func getSdkPath, Func? getJdkPath) + { + this.getSdkPath = getSdkPath ?? throw new ArgumentNullException (nameof (getSdkPath)); + this.getJdkPath = getJdkPath; + } + + public string? EmulatorPath { + get { + var sdkPath = getSdkPath (); + if (string.IsNullOrEmpty (sdkPath)) + return null; + + var ext = OS.IsWindows ? ".exe" : ""; + var path = Path.Combine (sdkPath, "emulator", "emulator" + ext); + + return File.Exists (path) ? path : null; + } + } + + public bool IsAvailable => EmulatorPath is not null; + + string RequireEmulatorPath () + { + return EmulatorPath ?? throw new InvalidOperationException ("Android Emulator not found."); + } + + void ConfigureEnvironment (ProcessStartInfo psi) + { + AndroidEnvironmentHelper.ConfigureEnvironment (psi, getSdkPath (), getJdkPath?.Invoke ()); + } + + public Process StartAvd (string avdName, bool coldBoot = false, string? additionalArgs = null) + { + var emulatorPath = RequireEmulatorPath (); + + var args = new List { "-avd", avdName }; + if (coldBoot) + args.Add ("-no-snapshot-load"); + if (!string.IsNullOrEmpty (additionalArgs)) + args.Add (additionalArgs); + + var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, args.ToArray ()); + ConfigureEnvironment (psi); + + // Redirect stdout/stderr so the emulator process doesn't inherit the + // caller's pipes. Without this, parent processes (e.g. VS Code spawn) + // never see the 'close' event because the emulator holds the pipes open. + psi.RedirectStandardOutput = true; + psi.RedirectStandardError = true; + + var process = new Process { StartInfo = psi }; + process.Start (); + + return process; + } + + public async Task> ListAvdNamesAsync (CancellationToken cancellationToken = default) + { + var emulatorPath = RequireEmulatorPath (); + + using var stdout = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, "-list-avds"); + ConfigureEnvironment (psi); + + await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken).ConfigureAwait (false); + + return ParseListAvdsOutput (stdout.ToString ()); + } + + internal static List ParseListAvdsOutput (string output) + { + var avds = new List (); + foreach (var line in output.Split ('\n')) { + var trimmed = line.Trim (); + if (!string.IsNullOrEmpty (trimmed)) + avds.Add (trimmed); + } + return avds; + } + + /// + /// Boots an emulator and waits for it to be fully booted. + /// Ported from dotnet/android BootAndroidEmulator MSBuild task. + /// + public async Task BootAndWaitAsync ( + string deviceOrAvdName, + AdbRunner adbRunner, + EmulatorBootOptions? options = null, + Action? logger = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace (deviceOrAvdName)) + throw new ArgumentException ("Device or AVD name must not be empty.", nameof (deviceOrAvdName)); + if (adbRunner == null) + throw new ArgumentNullException (nameof (adbRunner)); + + options = options ?? new EmulatorBootOptions (); + void Log (TraceLevel level, string message) => logger?.Invoke (level, message); + + Log (TraceLevel.Info, $"Booting emulator for '{deviceOrAvdName}'..."); + + // Phase 1: Check if deviceOrAvdName is already an online ADB device by serial + var devices = await adbRunner.ListDevicesAsync (cancellationToken).ConfigureAwait (false); + var onlineDevice = devices.FirstOrDefault (d => + d.Status == AdbDeviceStatus.Online && + string.Equals (d.Serial, deviceOrAvdName, StringComparison.OrdinalIgnoreCase)); + + if (onlineDevice != null) { + Log (TraceLevel.Info, $"Device '{deviceOrAvdName}' is already online."); + return new EmulatorBootResult { Success = true, Serial = onlineDevice.Serial }; + } + + // Phase 2: Check if AVD is already running (possibly still booting) + var runningSerial = FindRunningAvdSerial (devices, deviceOrAvdName); + if (runningSerial != null) { + Log (TraceLevel.Info, $"AVD '{deviceOrAvdName}' is already running as '{runningSerial}', waiting for full boot..."); + return await WaitForFullBootAsync (adbRunner, runningSerial, options, logger, cancellationToken).ConfigureAwait (false); + } + + // Phase 3: Launch the emulator + if (EmulatorPath == null) { + return new EmulatorBootResult { + Success = false, + ErrorMessage = "Android Emulator not found. Ensure the Android SDK is installed and the emulator is available.", + }; + } + + Log (TraceLevel.Info, $"Launching AVD '{deviceOrAvdName}'..."); + Process emulatorProcess; + try { + emulatorProcess = StartAvd (deviceOrAvdName, options.ColdBoot, options.AdditionalArgs); + } catch (Exception ex) { + return new EmulatorBootResult { + Success = false, + ErrorMessage = $"Failed to launch emulator: {ex.Message}", + }; + } + + // Poll for the new emulator serial to appear + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); + timeoutCts.CancelAfter (options.BootTimeout); + + try { + string? newSerial = null; + while (newSerial == null) { + timeoutCts.Token.ThrowIfCancellationRequested (); + await Task.Delay (options.PollInterval, timeoutCts.Token).ConfigureAwait (false); + + devices = await adbRunner.ListDevicesAsync (timeoutCts.Token).ConfigureAwait (false); + newSerial = FindRunningAvdSerial (devices, deviceOrAvdName); + } + + Log (TraceLevel.Info, $"Emulator appeared as '{newSerial}', waiting for full boot..."); + return await WaitForFullBootAsync (adbRunner, newSerial, options, logger, timeoutCts.Token).ConfigureAwait (false); + } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + return new EmulatorBootResult { + Success = false, + ErrorMessage = $"Timed out waiting for emulator '{deviceOrAvdName}' to boot within {options.BootTimeout.TotalSeconds}s.", + }; + } + } + + static string? FindRunningAvdSerial (IReadOnlyList devices, string avdName) + { + foreach (var d in devices) { + if (d.Type == AdbDeviceType.Emulator && + !string.IsNullOrEmpty (d.AvdName) && + string.Equals (d.AvdName, avdName, StringComparison.OrdinalIgnoreCase)) { + return d.Serial; + } + } + return null; + } + + async Task WaitForFullBootAsync ( + AdbRunner adbRunner, + string serial, + EmulatorBootOptions options, + Action? logger, + CancellationToken cancellationToken) + { + void Log (TraceLevel level, string message) => logger?.Invoke (level, message); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); + timeoutCts.CancelAfter (options.BootTimeout); + + try { + while (true) { + timeoutCts.Token.ThrowIfCancellationRequested (); + + var bootCompleted = await adbRunner.GetShellPropertyAsync (serial, "sys.boot_completed", timeoutCts.Token).ConfigureAwait (false); + if (string.Equals (bootCompleted, "1", StringComparison.Ordinal)) { + var pmResult = await adbRunner.RunShellCommandAsync (serial, "pm path android", timeoutCts.Token).ConfigureAwait (false); + if (pmResult != null && pmResult.StartsWith ("package:", StringComparison.Ordinal)) { + Log (TraceLevel.Info, $"Emulator '{serial}' is fully booted."); + return new EmulatorBootResult { Success = true, Serial = serial }; + } + } + + await Task.Delay (options.PollInterval, timeoutCts.Token).ConfigureAwait (false); + } + } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + return new EmulatorBootResult { + Success = false, + Serial = serial, + ErrorMessage = $"Timed out waiting for emulator '{serial}' to fully boot within {options.BootTimeout.TotalSeconds}s.", + }; + } + } +} + diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs new file mode 100644 index 00000000..e09d9687 --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs @@ -0,0 +1,479 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; +using NUnit.Framework; + +namespace Xamarin.Android.Tools.Tests; + +/// +/// Tests for AdbRunner parsing logic, ported from dotnet/android GetAvailableAndroidDevicesTests. +/// +[TestFixture] +public class AdbRunnerTests +{ + [Test] + public void ParseAdbDevicesOutput_RealWorldData () + { + var output = + "List of devices attached\n" + + "0A041FDD400327 device product:redfin model:Pixel_5 device:redfin transport_id:2\n" + + "emulator-5554 device product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64xa transport_id:1\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output); + + Assert.AreEqual (2, devices.Count); + + // Physical device + Assert.AreEqual ("0A041FDD400327", devices [0].Serial); + Assert.AreEqual (AdbDeviceType.Device, devices [0].Type); + Assert.AreEqual (AdbDeviceStatus.Online, devices [0].Status); + Assert.AreEqual ("Pixel 5", devices [0].Description); + Assert.AreEqual ("Pixel_5", devices [0].Model); + Assert.AreEqual ("redfin", devices [0].Product); + Assert.AreEqual ("redfin", devices [0].Device); + Assert.AreEqual ("2", devices [0].TransportId); + Assert.IsFalse (devices [0].IsEmulator); + + // Emulator + Assert.AreEqual ("emulator-5554", devices [1].Serial); + Assert.AreEqual (AdbDeviceType.Emulator, devices [1].Type); + Assert.AreEqual (AdbDeviceStatus.Online, devices [1].Status); + Assert.AreEqual ("sdk gphone64 x86 64", devices [1].Description); // model with underscores replaced + Assert.AreEqual ("sdk_gphone64_x86_64", devices [1].Model); + Assert.AreEqual ("1", devices [1].TransportId); + Assert.IsTrue (devices [1].IsEmulator); + } + + [Test] + public void ParseAdbDevicesOutput_EmptyOutput () + { + var output = "List of devices attached\n\n"; + var devices = AdbRunner.ParseAdbDevicesOutput (output); + Assert.AreEqual (0, devices.Count); + } + + [Test] + public void ParseAdbDevicesOutput_SingleEmulator () + { + var output = + "List of devices attached\n" + + "emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output); + + Assert.AreEqual (1, devices.Count); + Assert.AreEqual ("emulator-5554", devices [0].Serial); + Assert.AreEqual (AdbDeviceType.Emulator, devices [0].Type); + Assert.AreEqual (AdbDeviceStatus.Online, devices [0].Status); + Assert.AreEqual ("sdk_gphone64_arm64", devices [0].Model); + Assert.AreEqual ("1", devices [0].TransportId); + } + + [Test] + public void ParseAdbDevicesOutput_SinglePhysicalDevice () + { + var output = + "List of devices attached\n" + + "0A041FDD400327 device usb:1-1 product:raven model:Pixel_6_Pro device:raven transport_id:2\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output); + + Assert.AreEqual (1, devices.Count); + Assert.AreEqual ("0A041FDD400327", devices [0].Serial); + Assert.AreEqual (AdbDeviceType.Device, devices [0].Type); + Assert.AreEqual (AdbDeviceStatus.Online, devices [0].Status); + Assert.AreEqual ("Pixel 6 Pro", devices [0].Description); + Assert.AreEqual ("Pixel_6_Pro", devices [0].Model); + Assert.AreEqual ("raven", devices [0].Product); + Assert.AreEqual ("2", devices [0].TransportId); + } + + [Test] + public void ParseAdbDevicesOutput_OfflineDevice () + { + var output = + "List of devices attached\n" + + "emulator-5554 offline product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output); + + Assert.AreEqual (1, devices.Count); + Assert.AreEqual (AdbDeviceStatus.Offline, devices [0].Status); + } + + [Test] + public void ParseAdbDevicesOutput_UnauthorizedDevice () + { + var output = + "List of devices attached\n" + + "0A041FDD400327 unauthorized usb:1-1\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output); + + Assert.AreEqual (1, devices.Count); + Assert.AreEqual ("0A041FDD400327", devices [0].Serial); + Assert.AreEqual (AdbDeviceStatus.Unauthorized, devices [0].Status); + Assert.AreEqual (AdbDeviceType.Device, devices [0].Type); + } + + [Test] + public void ParseAdbDevicesOutput_NoPermissionsDevice () + { + var output = + "List of devices attached\n" + + "???????????????? no permissions usb:1-1\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output); + + Assert.AreEqual (1, devices.Count); + Assert.AreEqual ("????????????????", devices [0].Serial); + Assert.AreEqual (AdbDeviceStatus.NoPermissions, devices [0].Status); + } + + [Test] + public void ParseAdbDevicesOutput_DeviceWithMinimalMetadata () + { + var output = + "List of devices attached\n" + + "ABC123 device\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output); + + Assert.AreEqual (1, devices.Count); + Assert.AreEqual ("ABC123", devices [0].Serial); + Assert.AreEqual (AdbDeviceType.Device, devices [0].Type); + Assert.AreEqual (AdbDeviceStatus.Online, devices [0].Status); + Assert.AreEqual ("ABC123", devices [0].Description, "Should fall back to serial"); + } + + [Test] + public void ParseAdbDevicesOutput_InvalidLines () + { + var output = + "List of devices attached\n" + + "\n" + + " \n" + + "Some random text\n" + + "* daemon not running; starting now at tcp:5037\n" + + "* daemon started successfully\n" + + "emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output); + + Assert.AreEqual (1, devices.Count, "Should only return valid device lines"); + Assert.AreEqual ("emulator-5554", devices [0].Serial); + } + + [Test] + public void ParseAdbDevicesOutput_MixedDeviceStates () + { + var output = + "List of devices attached\n" + + "emulator-5554 device product:sdk_gphone64_arm64 model:Pixel_7 device:emu64a\n" + + "emulator-5556 offline\n" + + "0A041FDD400327 device usb:1-1 product:raven model:Pixel_6_Pro\n" + + "0B123456789ABC unauthorized usb:1-2\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output); + + Assert.AreEqual (4, devices.Count); + Assert.AreEqual (AdbDeviceStatus.Online, devices [0].Status); + Assert.AreEqual (AdbDeviceStatus.Offline, devices [1].Status); + Assert.AreEqual (AdbDeviceStatus.Online, devices [2].Status); + Assert.AreEqual (AdbDeviceStatus.Unauthorized, devices [3].Status); + } + + [Test] + public void ParseAdbDevicesOutput_WindowsNewlines () + { + var output = + "List of devices attached\r\n" + + "emulator-5554 device transport_id:1\r\n" + + "\r\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output); + + Assert.AreEqual (1, devices.Count); + Assert.AreEqual ("emulator-5554", devices [0].Serial); + Assert.IsTrue (devices [0].IsEmulator); + } + + [Test] + public void ParseAdbDevicesOutput_IpPortDevice () + { + var output = + "List of devices attached\n" + + "192.168.1.100:5555 device product:sdk_gphone64_arm64 model:Remote_Device\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output); + + Assert.AreEqual (1, devices.Count); + Assert.AreEqual ("192.168.1.100:5555", devices [0].Serial); + Assert.AreEqual (AdbDeviceType.Device, devices [0].Type, "IP devices should be Device"); + Assert.AreEqual ("Remote Device", devices [0].Description); + } + + [Test] + public void ParseAdbDevicesOutput_AdbDaemonStarting () + { + var output = + "* daemon not running; starting now at tcp:5037\n" + + "* daemon started successfully\n" + + "List of devices attached\n" + + "emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1\n" + + "0A041FDD400327 device usb:1-1 product:raven model:Pixel_6_Pro device:raven transport_id:2\n"; + + var devices = AdbRunner.ParseAdbDevicesOutput (output); + + Assert.AreEqual (2, devices.Count, "Should parse devices even with daemon startup messages"); + } + + [Test] + public void DescriptionPriorityOrder () + { + // Model has highest priority + var output1 = "List of devices attached\ndevice1 device product:product_name model:model_name device:device_name\n"; + var devices1 = AdbRunner.ParseAdbDevicesOutput (output1); + Assert.AreEqual ("model name", devices1 [0].Description, "Model should have highest priority"); + + // Product has second priority + var output2 = "List of devices attached\ndevice2 device product:product_name device:device_name\n"; + var devices2 = AdbRunner.ParseAdbDevicesOutput (output2); + Assert.AreEqual ("product name", devices2 [0].Description, "Product should have second priority"); + + // Device code name has third priority + var output3 = "List of devices attached\ndevice3 device device:device_name\n"; + var devices3 = AdbRunner.ParseAdbDevicesOutput (output3); + Assert.AreEqual ("device name", devices3 [0].Description, "Device should have third priority"); + } + + // --- FormatDisplayName tests (ported from dotnet/android) --- + + [Test] + public void FormatDisplayName_ReplacesUnderscoresWithSpaces () + { + Assert.AreEqual ("Pixel 7 Pro", AdbRunner.FormatDisplayName ("pixel_7_pro")); + } + + [Test] + public void FormatDisplayName_AppliesTitleCase () + { + Assert.AreEqual ("Pixel 7 Pro", AdbRunner.FormatDisplayName ("pixel 7 pro")); + } + + [Test] + public void FormatDisplayName_ReplacesApiWithAPIUppercase () + { + Assert.AreEqual ("Pixel 5 API 34", AdbRunner.FormatDisplayName ("pixel_5_api_34")); + } + + [Test] + public void FormatDisplayName_HandlesMultipleApiOccurrences () + { + Assert.AreEqual ("Test API Device API 35", AdbRunner.FormatDisplayName ("test_api_device_api_35")); + } + + [Test] + public void FormatDisplayName_HandlesMixedCaseInput () + { + Assert.AreEqual ("Pixel 7 API 35", AdbRunner.FormatDisplayName ("PiXeL_7_API_35")); + } + + [Test] + public void FormatDisplayName_HandlesComplexNames () + { + Assert.AreEqual ("Pixel 9 Pro Xl API 36", AdbRunner.FormatDisplayName ("pixel_9_pro_xl_api_36")); + } + + [Test] + public void FormatDisplayName_PreservesNumbersAndSpecialChars () + { + Assert.AreEqual ("Pixel 7-Pro API 35", AdbRunner.FormatDisplayName ("pixel_7-pro_api_35")); + } + + [Test] + public void FormatDisplayName_HandlesEmptyString () + { + Assert.AreEqual ("", AdbRunner.FormatDisplayName ("")); + } + + [Test] + public void FormatDisplayName_HandlesSingleWord () + { + Assert.AreEqual ("Pixel", AdbRunner.FormatDisplayName ("pixel")); + } + + [Test] + public void FormatDisplayName_DoesNotReplaceApiInsideWords () + { + Assert.AreEqual ("Erapidevice", AdbRunner.FormatDisplayName ("erapidevice")); + } + + // --- MapAdbStateToStatus tests --- + + [Test] + public void MapAdbStateToStatus_AllStates () + { + Assert.AreEqual (AdbDeviceStatus.Online, AdbRunner.MapAdbStateToStatus ("device")); + Assert.AreEqual (AdbDeviceStatus.Offline, AdbRunner.MapAdbStateToStatus ("offline")); + Assert.AreEqual (AdbDeviceStatus.Unauthorized, AdbRunner.MapAdbStateToStatus ("unauthorized")); + Assert.AreEqual (AdbDeviceStatus.NoPermissions, AdbRunner.MapAdbStateToStatus ("no permissions")); + Assert.AreEqual (AdbDeviceStatus.Unknown, AdbRunner.MapAdbStateToStatus ("something-else")); + } + + // --- MergeDevicesAndEmulators tests (ported from dotnet/android) --- + + [Test] + public void MergeDevicesAndEmulators_NoEmulators_ReturnsAdbDevicesOnly () + { + var adbDevices = new List { + new AdbDeviceInfo { Serial = "0A041FDD400327", Description = "Pixel 5", Type = AdbDeviceType.Device, Status = AdbDeviceStatus.Online }, + }; + + var result = AdbRunner.MergeDevicesAndEmulators (adbDevices, new List ()); + + Assert.AreEqual (1, result.Count); + Assert.AreEqual ("0A041FDD400327", result [0].Serial); + } + + [Test] + public void MergeDevicesAndEmulators_NoRunningEmulators_AddsAllAvailable () + { + var adbDevices = new List { + new AdbDeviceInfo { Serial = "0A041FDD400327", Description = "Pixel 5", Type = AdbDeviceType.Device, Status = AdbDeviceStatus.Online }, + }; + var available = new List { "pixel_7_api_35", "pixel_9_api_36" }; + + var result = AdbRunner.MergeDevicesAndEmulators (adbDevices, available); + + Assert.AreEqual (3, result.Count); + + // Online first + Assert.AreEqual ("0A041FDD400327", result [0].Serial); + + // Non-running sorted alphabetically + Assert.AreEqual ("pixel_7_api_35", result [1].Serial); + Assert.AreEqual (AdbDeviceStatus.NotRunning, result [1].Status); + Assert.AreEqual ("pixel_7_api_35", result [1].AvdName); + Assert.AreEqual ("Pixel 7 API 35 (Not Running)", result [1].Description); + + Assert.AreEqual ("pixel_9_api_36", result [2].Serial); + Assert.AreEqual (AdbDeviceStatus.NotRunning, result [2].Status); + Assert.AreEqual ("Pixel 9 API 36 (Not Running)", result [2].Description); + } + + [Test] + public void MergeDevicesAndEmulators_RunningEmulator_NoDuplicate () + { + var adbDevices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", Description = "Pixel 7 API 35", + Type = AdbDeviceType.Emulator, Status = AdbDeviceStatus.Online, + AvdName = "pixel_7_api_35" + }, + }; + var available = new List { "pixel_7_api_35" }; + + var result = AdbRunner.MergeDevicesAndEmulators (adbDevices, available); + + Assert.AreEqual (1, result.Count, "Should not duplicate running emulator"); + Assert.AreEqual ("emulator-5554", result [0].Serial); + Assert.AreEqual (AdbDeviceStatus.Online, result [0].Status); + } + + [Test] + public void MergeDevicesAndEmulators_MixedRunningAndNotRunning () + { + var adbDevices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", Description = "Pixel 7 API 35", + Type = AdbDeviceType.Emulator, Status = AdbDeviceStatus.Online, + AvdName = "pixel_7_api_35" + }, + new AdbDeviceInfo { + Serial = "0A041FDD400327", Description = "Pixel 5", + Type = AdbDeviceType.Device, Status = AdbDeviceStatus.Online, + }, + }; + var available = new List { "pixel_7_api_35", "pixel_9_api_36", "nexus_5_api_30" }; + + var result = AdbRunner.MergeDevicesAndEmulators (adbDevices, available); + + Assert.AreEqual (4, result.Count); + + // Online devices first, sorted alphabetically + Assert.AreEqual ("0A041FDD400327", result [0].Serial); + Assert.AreEqual (AdbDeviceStatus.Online, result [0].Status); + + Assert.AreEqual ("emulator-5554", result [1].Serial); + Assert.AreEqual (AdbDeviceStatus.Online, result [1].Status); + + // Non-running emulators second, sorted alphabetically + Assert.AreEqual ("nexus_5_api_30", result [2].Serial); + Assert.AreEqual (AdbDeviceStatus.NotRunning, result [2].Status); + Assert.AreEqual ("Nexus 5 API 30 (Not Running)", result [2].Description); + + Assert.AreEqual ("pixel_9_api_36", result [3].Serial); + Assert.AreEqual (AdbDeviceStatus.NotRunning, result [3].Status); + } + + [Test] + public void MergeDevicesAndEmulators_CaseInsensitiveAvdNameMatching () + { + var adbDevices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", Description = "Pixel 7 API 35", + Type = AdbDeviceType.Emulator, Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35" + }, + }; + var available = new List { "pixel_7_api_35" }; // lowercase + + var result = AdbRunner.MergeDevicesAndEmulators (adbDevices, available); + + Assert.AreEqual (1, result.Count, "Should match AVD names case-insensitively"); + } + + [Test] + public void MergeDevicesAndEmulators_EmptyAdbDevices_ReturnsAllAvailable () + { + var result = AdbRunner.MergeDevicesAndEmulators (new List (), new List { "pixel_7_api_35", "pixel_9_api_36" }); + + Assert.AreEqual (2, result.Count); + Assert.AreEqual ("Pixel 7 API 35 (Not Running)", result [0].Description); + Assert.AreEqual ("Pixel 9 API 36 (Not Running)", result [1].Description); + } + + // --- AdbPath tests --- + + [Test] + public void AdbPath_FindsInSdk () + { + var tempDir = Path.Combine (Path.GetTempPath (), $"adb-test-{Path.GetRandomFileName ()}"); + var platformTools = Path.Combine (tempDir, "platform-tools"); + Directory.CreateDirectory (platformTools); + + try { + var adbName = OS.IsWindows ? "adb.exe" : "adb"; + File.WriteAllText (Path.Combine (platformTools, adbName), ""); + + var runner = new AdbRunner (() => tempDir); + + Assert.IsNotNull (runner.AdbPath); + Assert.IsTrue (runner.IsAvailable); + Assert.IsTrue (runner.AdbPath!.Contains ("platform-tools")); + } finally { + Directory.Delete (tempDir, true); + } + } + + [Test] + public void AdbPath_NullSdkPath_StillSearchesPath () + { + var runner = new AdbRunner (() => null); + // Should not throw — falls back to PATH search + _ = runner.AdbPath; + } +} diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs new file mode 100644 index 00000000..26232154 --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs @@ -0,0 +1,199 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using NUnit.Framework; + +namespace Xamarin.Android.Tools.Tests; + +[TestFixture] +public class AvdManagerRunnerTests +{ + [Test] + public void ParseAvdListOutput_MultipleAvds () + { + var output = + "Available Android Virtual Devices:\n" + + " Name: Pixel_7_API_35\n" + + " Device: pixel_7 (Google)\n" + + " Path: /Users/test/.android/avd/Pixel_7_API_35.avd\n" + + " Target: Google APIs (Google Inc.)\n" + + " Based on: Android 15 Tag/ABI: google_apis/arm64-v8a\n" + + "---------\n" + + " Name: MAUI_Emulator\n" + + " Device: pixel_6 (Google)\n" + + " Path: /Users/test/.android/avd/MAUI_Emulator.avd\n" + + " Target: Google APIs (Google Inc.)\n" + + " Based on: Android 14 Tag/ABI: google_apis/x86_64\n"; + + var avds = AvdManagerRunner.ParseAvdListOutput (output); + + Assert.AreEqual (2, avds.Count); + + Assert.AreEqual ("Pixel_7_API_35", avds [0].Name); + Assert.AreEqual ("pixel_7 (Google)", avds [0].DeviceProfile); + Assert.AreEqual ("/Users/test/.android/avd/Pixel_7_API_35.avd", avds [0].Path); + + Assert.AreEqual ("MAUI_Emulator", avds [1].Name); + Assert.AreEqual ("pixel_6 (Google)", avds [1].DeviceProfile); + Assert.AreEqual ("/Users/test/.android/avd/MAUI_Emulator.avd", avds [1].Path); + } + + [Test] + public void ParseAvdListOutput_WindowsNewlines () + { + var output = + "Available Android Virtual Devices:\r\n" + + " Name: Test_AVD\r\n" + + " Device: Nexus 5X (Google)\r\n" + + " Path: C:\\Users\\test\\.android\\avd\\Test_AVD.avd\r\n" + + " Target: Google APIs (Google Inc.)\r\n"; + + var avds = AvdManagerRunner.ParseAvdListOutput (output); + + Assert.AreEqual (1, avds.Count); + Assert.AreEqual ("Test_AVD", avds [0].Name); + Assert.AreEqual ("Nexus 5X (Google)", avds [0].DeviceProfile); + Assert.AreEqual ("C:\\Users\\test\\.android\\avd\\Test_AVD.avd", avds [0].Path); + } + + [Test] + public void ParseAvdListOutput_EmptyOutput () + { + var avds = AvdManagerRunner.ParseAvdListOutput (""); + Assert.AreEqual (0, avds.Count); + } + + [Test] + public void ParseAvdListOutput_NoAvds () + { + var output = "Available Android Virtual Devices:\n"; + var avds = AvdManagerRunner.ParseAvdListOutput (output); + Assert.AreEqual (0, avds.Count); + } + + [Test] + public void ParseAvdListOutput_SingleAvdNoDevice () + { + var output = + " Name: Minimal_AVD\n" + + " Path: /home/user/.android/avd/Minimal_AVD.avd\n"; + + var avds = AvdManagerRunner.ParseAvdListOutput (output); + + Assert.AreEqual (1, avds.Count); + Assert.AreEqual ("Minimal_AVD", avds [0].Name); + Assert.IsNull (avds [0].DeviceProfile); + Assert.AreEqual ("/home/user/.android/avd/Minimal_AVD.avd", avds [0].Path); + } + + [Test] + public void AvdManagerPath_FindsVersionedDir () + { + var tempDir = Path.Combine (Path.GetTempPath (), $"avd-test-{Path.GetRandomFileName ()}"); + var binDir = Path.Combine (tempDir, "cmdline-tools", "12.0", "bin"); + Directory.CreateDirectory (binDir); + + try { + var avdMgrName = OS.IsWindows ? "avdmanager.bat" : "avdmanager"; + File.WriteAllText (Path.Combine (binDir, avdMgrName), ""); + + var runner = new AvdManagerRunner (() => tempDir, null); + Assert.IsNotNull (runner.AvdManagerPath); + Assert.IsTrue (runner.AvdManagerPath!.Contains ("12.0")); + } finally { + Directory.Delete (tempDir, true); + } + } + + [Test] + public void AvdManagerPath_PrefersHigherVersion () + { + var tempDir = Path.Combine (Path.GetTempPath (), $"avd-test-{Path.GetRandomFileName ()}"); + var avdMgrName = OS.IsWindows ? "avdmanager.bat" : "avdmanager"; + + var binDir10 = Path.Combine (tempDir, "cmdline-tools", "10.0", "bin"); + var binDir12 = Path.Combine (tempDir, "cmdline-tools", "12.0", "bin"); + Directory.CreateDirectory (binDir10); + Directory.CreateDirectory (binDir12); + File.WriteAllText (Path.Combine (binDir10, avdMgrName), ""); + File.WriteAllText (Path.Combine (binDir12, avdMgrName), ""); + + try { + var runner = new AvdManagerRunner (() => tempDir, null); + Assert.IsNotNull (runner.AvdManagerPath); + Assert.IsTrue (runner.AvdManagerPath!.Contains ("12.0")); + } finally { + Directory.Delete (tempDir, true); + } + } + + [Test] + public void AvdManagerPath_FallsBackToLatest () + { + var tempDir = Path.Combine (Path.GetTempPath (), $"avd-test-{Path.GetRandomFileName ()}"); + var binDir = Path.Combine (tempDir, "cmdline-tools", "latest", "bin"); + Directory.CreateDirectory (binDir); + + try { + var avdMgrName = OS.IsWindows ? "avdmanager.bat" : "avdmanager"; + File.WriteAllText (Path.Combine (binDir, avdMgrName), ""); + + var runner = new AvdManagerRunner (() => tempDir, null); + Assert.IsNotNull (runner.AvdManagerPath); + Assert.IsTrue (runner.AvdManagerPath!.Contains ("latest")); + } finally { + Directory.Delete (tempDir, true); + } + } + + [Test] + public void AvdManagerPath_NullSdk_ReturnsNull () + { + var runner = new AvdManagerRunner (() => null, null); + Assert.IsNull (runner.AvdManagerPath); + } + + [Test] + public void AvdManagerPath_MissingSdk_ReturnsNull () + { + var runner = new AvdManagerRunner (() => "/nonexistent/path", null); + Assert.IsNull (runner.AvdManagerPath); + } + + [Test] + public void CreateAvdAsync_NullName_ThrowsArgumentNullException () + { + var runner = new AvdManagerRunner (() => "/fake/sdk", null); + Assert.ThrowsAsync (() => runner.CreateAvdAsync (null!, "system-image")); + } + + [Test] + public void CreateAvdAsync_EmptyName_ThrowsArgumentException () + { + var runner = new AvdManagerRunner (() => "/fake/sdk", null); + Assert.ThrowsAsync (() => runner.CreateAvdAsync ("", "system-image")); + } + + [Test] + public void CreateAvdAsync_EmptySystemImage_ThrowsArgumentException () + { + var runner = new AvdManagerRunner (() => "/fake/sdk", null); + Assert.ThrowsAsync (() => runner.CreateAvdAsync ("test-avd", "")); + } + + [Test] + public void DeleteAvdAsync_NullName_ThrowsArgumentNullException () + { + var runner = new AvdManagerRunner (() => "/fake/sdk", null); + Assert.ThrowsAsync (() => runner.DeleteAvdAsync (null!)); + } + + [Test] + public void DeleteAvdAsync_EmptyName_ThrowsArgumentException () + { + var runner = new AvdManagerRunner (() => "/fake/sdk", null); + Assert.ThrowsAsync (() => runner.DeleteAvdAsync ("")); + } +} diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs new file mode 100644 index 00000000..a951d12a --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs @@ -0,0 +1,326 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace Xamarin.Android.Tools.Tests; + +[TestFixture] +public class EmulatorRunnerTests +{ + [Test] + public void ParseListAvdsOutput_MultipleAvds () + { + var output = "Pixel_7_API_35\nMAUI_Emulator\nNexus_5X\n"; + + var avds = EmulatorRunner.ParseListAvdsOutput (output); + + Assert.AreEqual (3, avds.Count); + Assert.AreEqual ("Pixel_7_API_35", avds [0]); + Assert.AreEqual ("MAUI_Emulator", avds [1]); + Assert.AreEqual ("Nexus_5X", avds [2]); + } + + [Test] + public void ParseListAvdsOutput_EmptyOutput () + { + var avds = EmulatorRunner.ParseListAvdsOutput (""); + Assert.AreEqual (0, avds.Count); + } + + [Test] + public void ParseListAvdsOutput_WindowsNewlines () + { + var output = "Pixel_7_API_35\r\nMAUI_Emulator\r\n"; + + var avds = EmulatorRunner.ParseListAvdsOutput (output); + + Assert.AreEqual (2, avds.Count); + Assert.AreEqual ("Pixel_7_API_35", avds [0]); + Assert.AreEqual ("MAUI_Emulator", avds [1]); + } + + [Test] + public void ParseListAvdsOutput_BlankLines () + { + var output = "\nPixel_7_API_35\n\n\nMAUI_Emulator\n\n"; + + var avds = EmulatorRunner.ParseListAvdsOutput (output); + + Assert.AreEqual (2, avds.Count); + } + + [Test] + public void EmulatorPath_FindsInSdk () + { + var tempDir = Path.Combine (Path.GetTempPath (), $"emu-test-{Path.GetRandomFileName ()}"); + var emulatorDir = Path.Combine (tempDir, "emulator"); + Directory.CreateDirectory (emulatorDir); + + try { + var emuName = OS.IsWindows ? "emulator.exe" : "emulator"; + File.WriteAllText (Path.Combine (emulatorDir, emuName), ""); + + var runner = new EmulatorRunner (() => tempDir); + + Assert.IsNotNull (runner.EmulatorPath); + Assert.IsTrue (runner.IsAvailable); + } finally { + Directory.Delete (tempDir, true); + } + } + + [Test] + public void EmulatorPath_MissingSdk_ReturnsNull () + { + var runner = new EmulatorRunner (() => "/nonexistent/path"); + Assert.IsNull (runner.EmulatorPath); + Assert.IsFalse (runner.IsAvailable); + } + + [Test] + public void EmulatorPath_NullSdk_ReturnsNull () + { + var runner = new EmulatorRunner (() => null); + Assert.IsNull (runner.EmulatorPath); + Assert.IsFalse (runner.IsAvailable); + } + + // --- BootAndWaitAsync tests (ported from dotnet/android BootAndroidEmulatorTests) --- + + [Test] + public async Task AlreadyOnlineDevice_PassesThrough () + { + var devices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }, + }; + + var mockAdb = new MockAdbRunner (devices); + var runner = new EmulatorRunner (() => null); + + var result = await runner.BootAndWaitAsync ("emulator-5554", mockAdb); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("emulator-5554", result.Serial); + Assert.IsNull (result.ErrorMessage); + } + + [Test] + public async Task AvdAlreadyRunning_WaitsForFullBoot () + { + var devices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }, + }; + + var mockAdb = new MockAdbRunner (devices); + mockAdb.ShellProperties ["sys.boot_completed"] = "1"; + mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk"; + + var runner = new EmulatorRunner (() => null); + var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("emulator-5554", result.Serial); + } + + [Test] + public async Task BootEmulator_AppearsAfterPolling () + { + var devices = new List (); + var mockAdb = new MockAdbRunner (devices); + mockAdb.ShellProperties ["sys.boot_completed"] = "1"; + mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk"; + + int pollCount = 0; + mockAdb.OnListDevices = () => { + pollCount++; + if (pollCount >= 2) { + devices.Add (new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }); + } + }; + + var tempDir = CreateFakeEmulatorSdk (); + try { + var runner = new EmulatorRunner (() => tempDir); + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromSeconds (10), + PollInterval = TimeSpan.FromMilliseconds (50), + }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("emulator-5554", result.Serial); + Assert.IsTrue (pollCount >= 2); + } finally { + Directory.Delete (tempDir, true); + } + } + + [Test] + public async Task LaunchFailure_ReturnsError () + { + var devices = new List (); + var mockAdb = new MockAdbRunner (devices); + + // No emulator path → EmulatorPath returns null → error + var runner = new EmulatorRunner (() => null); + var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (2) }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsFalse (result.Success); + Assert.IsNotNull (result.ErrorMessage); + Assert.IsTrue (result.ErrorMessage!.Contains ("not found"), $"Unexpected error: {result.ErrorMessage}"); + } + + [Test] + public async Task BootTimeout_BootCompletedNeverReaches1 () + { + var devices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }, + }; + + var mockAdb = new MockAdbRunner (devices); + // boot_completed never returns "1" + mockAdb.ShellProperties ["sys.boot_completed"] = "0"; + + var runner = new EmulatorRunner (() => null); + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromMilliseconds (200), + PollInterval = TimeSpan.FromMilliseconds (50), + }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsFalse (result.Success); + Assert.IsNotNull (result.ErrorMessage); + Assert.IsTrue (result.ErrorMessage!.Contains ("Timed out"), $"Unexpected error: {result.ErrorMessage}"); + } + + [Test] + public async Task MultipleEmulators_FindsCorrectAvd () + { + var devices = new List { + new AdbDeviceInfo { + Serial = "emulator-5554", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_5_API_30", + }, + new AdbDeviceInfo { + Serial = "emulator-5556", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Pixel_7_API_35", + }, + new AdbDeviceInfo { + Serial = "emulator-5558", + Type = AdbDeviceType.Emulator, + Status = AdbDeviceStatus.Online, + AvdName = "Nexus_5X_API_28", + }, + }; + + var mockAdb = new MockAdbRunner (devices); + mockAdb.ShellProperties ["sys.boot_completed"] = "1"; + mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk"; + + var runner = new EmulatorRunner (() => null); + var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; + + var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("emulator-5556", result.Serial, "Should find the correct AVD among multiple emulators"); + } + + // --- Helpers --- + + static string CreateFakeEmulatorSdk () + { + var tempDir = Path.Combine (Path.GetTempPath (), $"emu-boot-test-{Path.GetRandomFileName ()}"); + var emulatorDir = Path.Combine (tempDir, "emulator"); + Directory.CreateDirectory (emulatorDir); + + var emuName = OS.IsWindows ? "emulator.exe" : "emulator"; + var emuPath = Path.Combine (emulatorDir, emuName); + // Create a fake emulator script that just exits + if (OS.IsWindows) { + File.WriteAllText (emuPath, "@echo off"); + } else { + File.WriteAllText (emuPath, "#!/bin/sh\nsleep 60\n"); + // Make executable + var psi = new ProcessStartInfo ("chmod", $"+x \"{emuPath}\"") { UseShellExecute = false }; + Process.Start (psi)?.WaitForExit (); + } + + return tempDir; + } + + /// + /// Mock AdbRunner for testing BootAndWaitAsync without real adb commands. + /// + class MockAdbRunner : AdbRunner + { + readonly List devices; + + public Dictionary ShellProperties { get; } = new Dictionary (StringComparer.OrdinalIgnoreCase); + public Dictionary ShellCommands { get; } = new Dictionary (StringComparer.OrdinalIgnoreCase); + public Action? OnListDevices { get; set; } + + public MockAdbRunner (List devices) + : base (() => null) + { + this.devices = devices; + } + + public override Task> ListDevicesAsync (CancellationToken cancellationToken = default) + { + OnListDevices?.Invoke (); + return Task.FromResult> (devices); + } + + public override Task GetShellPropertyAsync (string serial, string propertyName, CancellationToken cancellationToken = default) + { + ShellProperties.TryGetValue (propertyName, out var value); + return Task.FromResult (value); + } + + public override Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken = default) + { + ShellCommands.TryGetValue (command, out var value); + return Task.FromResult (value); + } + } +}