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..698e1a0e --- /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; +using System.Collections.Generic; + +namespace Xamarin.Android.Tools; + +/// +/// Options for booting an Android emulator. +/// +public record EmulatorBootOptions +{ + public TimeSpan BootTimeout { get; init; } = TimeSpan.FromSeconds (300); + public List? AdditionalArgs { get; init; } + public bool ColdBoot { get; init; } + public TimeSpan PollInterval { get; init; } = 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..e73cb939 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs @@ -0,0 +1,41 @@ +// 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; + +/// +/// Classifies the reason an emulator boot operation failed. +/// +public enum EmulatorBootErrorKind +{ + /// No error — the boot succeeded. + None, + + /// The emulator process could not be launched (e.g., binary not found, AVD missing). + LaunchFailed, + + /// The emulator launched but did not finish booting within the allowed timeout. + Timeout, + + /// The boot was cancelled via . + Cancelled, + + /// An unexpected error occurred. + Unknown, +} + +/// +/// Result of an emulator boot operation. +/// +public record EmulatorBootResult +{ + public bool Success { get; init; } + public string? Serial { get; init; } + public string? ErrorMessage { get; init; } + + /// + /// Structured error classification. Consumers should switch on this value + /// instead of parsing strings. + /// + public EmulatorBootErrorKind ErrorKind { get; init; } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 03a3352e..9d51f08c 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1,5 +1,4 @@ #nullable enable -*REMOVED*static Xamarin.Android.Tools.ProcessUtils.StartProcess(System.Diagnostics.ProcessStartInfo! psi, System.IO.TextWriter? stdout, System.IO.TextWriter? stderr, System.Threading.CancellationToken cancellationToken, System.Action? onStarted = null) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AdbDeviceInfo Xamarin.Android.Tools.AdbDeviceInfo.AdbDeviceInfo() -> void Xamarin.Android.Tools.AdbDeviceInfo.AvdName.get -> string? @@ -32,8 +31,8 @@ Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbDeviceType.Device = 0 -> Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbDeviceType.Emulator = 1 -> Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbRunner -Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary? environmentVariables = null) -> void -Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void +virtual Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Xamarin.Android.Tools.AdbRunner.StopEmulatorAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AdbRunner.WaitForDeviceAsync(string? serial = null, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.ChecksumType @@ -145,3 +144,35 @@ Xamarin.Android.Tools.AvdManagerRunner.AvdManagerRunner(string! avdManagerPath, Xamarin.Android.Tools.AvdManagerRunner.GetOrCreateAvdAsync(string! name, string! systemImage, string? deviceProfile = null, bool force = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AvdManagerRunner.DeleteAvdAsync(string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AvdManagerRunner.ListAvdsAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual Xamarin.Android.Tools.AdbRunner.GetShellPropertyAsync(string! serial, string! propertyName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, string![]! args, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorBootOptions +Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.get -> System.Collections.Generic.List? +Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.init -> void +Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.get -> System.TimeSpan +Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.init -> void +Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.get -> bool +Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.init -> void +Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.get -> System.TimeSpan +Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.init -> void +Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.None = 0 -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.LaunchFailed = 1 -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.Timeout = 2 -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.Cancelled = 3 -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.Unknown = 4 -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootResult +Xamarin.Android.Tools.EmulatorBootResult.ErrorKind.get -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootResult.ErrorKind.init -> void +Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.get -> string? +Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.init -> void +Xamarin.Android.Tools.EmulatorBootResult.Serial.get -> string? +Xamarin.Android.Tools.EmulatorBootResult.Serial.init -> void +Xamarin.Android.Tools.EmulatorBootResult.Success.get -> bool +Xamarin.Android.Tools.EmulatorBootResult.Success.init -> void +Xamarin.Android.Tools.EmulatorRunner +Xamarin.Android.Tools.EmulatorRunner.BootEmulatorAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void +Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +Xamarin.Android.Tools.EmulatorRunner.LaunchEmulator(string! avdName, bool coldBoot = false, System.Collections.Generic.List? additionalArgs = null) -> System.Diagnostics.Process! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 03a3352e..9d51f08c 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1,5 +1,4 @@ #nullable enable -*REMOVED*static Xamarin.Android.Tools.ProcessUtils.StartProcess(System.Diagnostics.ProcessStartInfo! psi, System.IO.TextWriter? stdout, System.IO.TextWriter? stderr, System.Threading.CancellationToken cancellationToken, System.Action? onStarted = null) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AdbDeviceInfo Xamarin.Android.Tools.AdbDeviceInfo.AdbDeviceInfo() -> void Xamarin.Android.Tools.AdbDeviceInfo.AvdName.get -> string? @@ -32,8 +31,8 @@ Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbDeviceType.Device = 0 -> Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbDeviceType.Emulator = 1 -> Xamarin.Android.Tools.AdbDeviceType Xamarin.Android.Tools.AdbRunner -Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary? environmentVariables = null) -> void -Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +Xamarin.Android.Tools.AdbRunner.AdbRunner(string! adbPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void +virtual Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Xamarin.Android.Tools.AdbRunner.StopEmulatorAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AdbRunner.WaitForDeviceAsync(string? serial = null, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.ChecksumType @@ -145,3 +144,35 @@ Xamarin.Android.Tools.AvdManagerRunner.AvdManagerRunner(string! avdManagerPath, Xamarin.Android.Tools.AvdManagerRunner.GetOrCreateAvdAsync(string! name, string! systemImage, string? deviceProfile = null, bool force = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AvdManagerRunner.DeleteAvdAsync(string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Xamarin.Android.Tools.AvdManagerRunner.ListAvdsAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +virtual Xamarin.Android.Tools.AdbRunner.GetShellPropertyAsync(string! serial, string! propertyName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +virtual Xamarin.Android.Tools.AdbRunner.RunShellCommandAsync(string! serial, string! command, string![]! args, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorBootOptions +Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.get -> System.Collections.Generic.List? +Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.init -> void +Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.get -> System.TimeSpan +Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.init -> void +Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.get -> bool +Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.init -> void +Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.get -> System.TimeSpan +Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.init -> void +Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.None = 0 -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.LaunchFailed = 1 -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.Timeout = 2 -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.Cancelled = 3 -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootErrorKind.Unknown = 4 -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootResult +Xamarin.Android.Tools.EmulatorBootResult.ErrorKind.get -> Xamarin.Android.Tools.EmulatorBootErrorKind +Xamarin.Android.Tools.EmulatorBootResult.ErrorKind.init -> void +Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.get -> string? +Xamarin.Android.Tools.EmulatorBootResult.ErrorMessage.init -> void +Xamarin.Android.Tools.EmulatorBootResult.Serial.get -> string? +Xamarin.Android.Tools.EmulatorBootResult.Serial.init -> void +Xamarin.Android.Tools.EmulatorBootResult.Success.get -> bool +Xamarin.Android.Tools.EmulatorBootResult.Success.init -> void +Xamarin.Android.Tools.EmulatorRunner +Xamarin.Android.Tools.EmulatorRunner.BootEmulatorAsync(string! deviceOrAvdName, Xamarin.Android.Tools.AdbRunner! adbRunner, Xamarin.Android.Tools.EmulatorBootOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.EmulatorRunner.EmulatorRunner(string! emulatorPath, System.Collections.Generic.IDictionary? environmentVariables = null, System.Action? logger = null) -> void +Xamarin.Android.Tools.EmulatorRunner.ListAvdNamesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +Xamarin.Android.Tools.EmulatorRunner.LaunchEmulator(string! avdName, bool coldBoot = false, System.Collections.Generic.List? additionalArgs = null) -> System.Diagnostics.Process! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs index 24a62cec..0dd60476 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs @@ -21,6 +21,7 @@ public class AdbRunner { readonly string adbPath; readonly IDictionary? environmentVariables; + readonly Action logger; // Pattern to match device lines: [key:value ...] // Uses \s+ to match one or more whitespace characters (spaces or tabs) between fields. @@ -35,19 +36,21 @@ public class AdbRunner /// /// 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) + /// Optional logger callback for diagnostic messages. + public AdbRunner (string adbPath, IDictionary? environmentVariables = null, Action? logger = null) { if (string.IsNullOrWhiteSpace (adbPath)) throw new ArgumentException ("Path to adb must not be empty.", nameof (adbPath)); this.adbPath = adbPath; this.environmentVariables = environmentVariables; + this.logger = logger ?? RunnerDefaults.NullLogger; } /// /// 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) + public virtual async Task> ListDevicesAsync (CancellationToken cancellationToken = default) { using var stdout = new StringWriter (); using var stderr = new StringWriter (); @@ -70,12 +73,16 @@ public async Task> ListDevicesAsync (CancellationTo } /// - /// Queries the emulator for its AVD name using 'adb -s <serial> emu avd name'. - /// Returns null if the query fails or produces no output. - /// Ported from dotnet/android GetAvailableAndroidDevices.GetEmulatorAvdName. + /// Queries the emulator for its AVD name. + /// Tries adb -s <serial> emu avd name first (emulator console protocol), + /// then falls back to adb shell getprop ro.boot.qemu.avd_name which reads the + /// boot property set by the emulator kernel. The fallback is needed because + /// emu avd name can return empty output on some adb/emulator version + /// combinations (observed with adb v36). /// internal async Task GetEmulatorAvdNameAsync (string serial, CancellationToken cancellationToken = default) { + // Try 1: Console command (fast, works on most emulator versions) try { using var stdout = new StringWriter (); var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "emu", "avd", "name"); @@ -90,8 +97,19 @@ public async Task> ListDevicesAsync (CancellationTo } } catch (OperationCanceledException) { throw; - } catch (Exception ex) { - Trace.WriteLine ($"GetEmulatorAvdNameAsync adb query failed for '{serial}': {ex.Message}"); + } catch { + // Fall through to getprop fallback + } + + // Try 2: Shell property (fallback when 'adb emu avd name' returns empty on some adb/emulator versions) + try { + var avdName = await GetShellPropertyAsync (serial, "ro.boot.qemu.avd_name", cancellationToken).ConfigureAwait (false); + if (avdName is { Length: > 0 } name && !string.IsNullOrWhiteSpace (name)) + return name.Trim (); + } catch (OperationCanceledException) { + throw; + } catch { + // Both methods failed } return null; @@ -135,6 +153,92 @@ public async Task StopEmulatorAsync (string serial, CancellationToken cancellati ProcessUtils.ThrowIfFailed (exitCode, $"adb -s {serial} emu kill", stderr); } + /// + /// Gets a system property from a device via 'adb -s <serial> shell getprop <property>'. + /// Returns the property value (first non-empty line of stdout), or null on failure. + /// + public virtual async Task GetShellPropertyAsync (string serial, string propertyName, CancellationToken cancellationToken = default) + { + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "shell", "getprop", propertyName); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + if (exitCode != 0) { + var stderrText = stderr.ToString ().Trim (); + if (stderrText.Length > 0) + logger.Invoke (TraceLevel.Warning, $"adb shell getprop {propertyName} failed (exit {exitCode}): {stderrText}"); + return null; + } + return FirstNonEmptyLine (stdout.ToString ()); + } + + /// + /// Runs a shell command on a device via 'adb -s <serial> shell <command>'. + /// Returns the full stdout output trimmed, or null on failure. + /// + /// + /// The is passed as a single argument to adb shell, + /// which means the device's shell interprets it (shell expansion, pipes, semicolons are active). + /// Do not pass untrusted or user-supplied input without proper validation. + /// + public virtual async Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken) + { + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, "-s", serial, "shell", command); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + if (exitCode != 0) { + var stderrText = stderr.ToString ().Trim (); + if (stderrText.Length > 0) + logger.Invoke (TraceLevel.Warning, $"adb shell {command} failed (exit {exitCode}): {stderrText}"); + return null; + } + var output = stdout.ToString ().Trim (); + return output.Length > 0 ? output : null; + } + + /// + /// Runs a shell command on a device via adb -s <serial> shell <command> <args>. + /// Returns the full stdout output trimmed, or null on failure. + /// + /// + /// When adb shell receives the command and arguments as separate tokens, it uses + /// exec() directly on the device — bypassing the device's shell interpreter. + /// This avoids shell expansion, pipes, and injection risks, making it safer for dynamic input. + /// + public virtual async Task RunShellCommandAsync (string serial, string command, string[] args, CancellationToken cancellationToken = default) + { + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + // Build: adb -s shell ... + var allArgs = new string [3 + 1 + args.Length]; + allArgs [0] = "-s"; + allArgs [1] = serial; + allArgs [2] = "shell"; + allArgs [3] = command; + Array.Copy (args, 0, allArgs, 4, args.Length); + var psi = ProcessUtils.CreateProcessStartInfo (adbPath, allArgs); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + if (exitCode != 0) { + var stderrText = stderr.ToString ().Trim (); + if (stderrText.Length > 0) + logger.Invoke (TraceLevel.Warning, $"adb shell {command} failed (exit {exitCode}): {stderrText}"); + return null; + } + var output = stdout.ToString ().Trim (); + return output.Length > 0 ? output : null; + } + + internal static string? FirstNonEmptyLine (string output) + { + foreach (var line in output.Split ('\n')) { + var trimmed = line.Trim (); + if (trimmed.Length > 0) + return trimmed; + } + return null; + } + /// /// Parses the output lines from 'adb devices -l'. /// Accepts an to avoid allocating a joined string. @@ -219,10 +323,11 @@ public static IReadOnlyList ParseAdbDevicesOutput (IEnumerable public static string BuildDeviceDescription (AdbDeviceInfo device, Action? logger = null) { + logger ??= RunnerDefaults.NullLogger; if (device.Type == AdbDeviceType.Emulator && device.AvdName is { Length: > 0 } avdName) { - logger?.Invoke (TraceLevel.Verbose, $"Emulator {device.Serial}, original AVD name: {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}"); + logger.Invoke (TraceLevel.Verbose, $"Emulator {device.Serial}, formatted AVD display name: {formatted}"); return formatted; } @@ -264,6 +369,7 @@ public static string FormatDisplayName (string avdName) /// public static IReadOnlyList MergeDevicesAndEmulators (IReadOnlyList adbDevices, IReadOnlyList availableEmulators, Action? logger = null) { + logger ??= RunnerDefaults.NullLogger; var result = new List (adbDevices); // Build a set of AVD names that are already running @@ -273,12 +379,12 @@ public static IReadOnlyList MergeDevicesAndEmulators (IReadOnlyLi runningAvdNames.Add (avdName); } - logger?.Invoke (TraceLevel.Verbose, $"Running emulators AVD names: {string.Join (", ", runningAvdNames)}"); + 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"); + logger.Invoke (TraceLevel.Verbose, $"Emulator '{avdName}' is already running, skipping"); continue; } @@ -290,7 +396,7 @@ public static IReadOnlyList MergeDevicesAndEmulators (IReadOnlyLi Status = AdbDeviceStatus.NotRunning, AvdName = avdName, }); - logger?.Invoke (TraceLevel.Verbose, $"Added non-running emulator: {avdName}"); + logger.Invoke (TraceLevel.Verbose, $"Added non-running emulator: {avdName}"); } // Sort: online devices first, then not-running emulators, alphabetically by description diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs index d678991b..b233ae38 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs @@ -18,7 +18,7 @@ public class AvdManagerRunner { readonly string avdManagerPath; readonly IDictionary? environmentVariables; - readonly Action? logger; + readonly Action logger; /// /// Creates a new AvdManagerRunner with the full path to the avdmanager executable. @@ -32,7 +32,7 @@ public AvdManagerRunner (string avdManagerPath, IDictionary? env throw new ArgumentException ("Path to avdmanager must not be empty.", nameof (avdManagerPath)); this.avdManagerPath = avdManagerPath; this.environmentVariables = environmentVariables; - this.logger = logger; + this.logger = logger ?? RunnerDefaults.NullLogger; } public async Task> ListAvdsAsync (CancellationToken cancellationToken = default) @@ -40,7 +40,7 @@ public async Task> ListAvdsAsync (CancellationToken cance using var stdout = new StringWriter (); using var stderr = new StringWriter (); var psi = ProcessUtils.CreateProcessStartInfo (avdManagerPath, "list", "avd"); - logger?.Invoke (TraceLevel.Verbose, "Running: avdmanager list avd"); + logger.Invoke (TraceLevel.Verbose, "Running: avdmanager list avd"); var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); ProcessUtils.ThrowIfFailed (exitCode, "avdmanager list avd", stderr); @@ -65,7 +65,7 @@ public async Task GetOrCreateAvdAsync (string name, string systemImage, var existing = (await ListAvdsAsync (cancellationToken).ConfigureAwait (false)) .FirstOrDefault (a => string.Equals (a.Name, name, StringComparison.OrdinalIgnoreCase)); if (existing is not null) { - logger?.Invoke (TraceLevel.Verbose, $"AVD '{name}' already exists, returning existing"); + logger.Invoke (TraceLevel.Verbose, $"AVD '{name}' already exists, returning existing"); return existing; } } @@ -88,7 +88,7 @@ public async Task GetOrCreateAvdAsync (string name, string systemImage, p.StandardInput.WriteLine ("no"); p.StandardInput.Close (); } catch (IOException ex) { - logger?.Invoke (TraceLevel.Warning, $"Failed to write to avdmanager stdin: {ex.Message}"); + logger.Invoke (TraceLevel.Warning, $"Failed to write to avdmanager stdin: {ex.Message}"); } }).ConfigureAwait (false); @@ -111,7 +111,7 @@ public async Task DeleteAvdAsync (string name, CancellationToken cancellationTok // Idempotent: if the AVD doesn't exist, treat as success var avds = await ListAvdsAsync (cancellationToken).ConfigureAwait (false); if (!avds.Any (a => string.Equals (a.Name, name, StringComparison.OrdinalIgnoreCase))) { - logger?.Invoke (TraceLevel.Verbose, $"AVD '{name}' does not exist, nothing to delete"); + logger.Invoke (TraceLevel.Verbose, $"AVD '{name}' does not exist, nothing to delete"); return; } 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..d2ac7f58 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs @@ -0,0 +1,289 @@ +// 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 string emulatorPath; + readonly IDictionary? environmentVariables; + readonly Action logger; + + /// + /// Creates a new EmulatorRunner with the full path to the emulator executable. + /// + /// Full path to the emulator executable (e.g., "/path/to/sdk/emulator/emulator"). + /// Optional environment variables to pass to emulator processes. + /// Optional logger callback for diagnostic messages. + public EmulatorRunner (string emulatorPath, IDictionary? environmentVariables = null, Action? logger = null) + { + if (string.IsNullOrWhiteSpace (emulatorPath)) + throw new ArgumentException ("Path to emulator must not be empty.", nameof (emulatorPath)); + this.emulatorPath = emulatorPath; + this.environmentVariables = environmentVariables; + this.logger = logger ?? RunnerDefaults.NullLogger; + } + + /// + /// Launches an emulator process for the specified AVD and returns immediately. + /// The returned represents the running emulator — the caller + /// is responsible for managing its lifetime (e.g., killing it on shutdown). + /// This method does not wait for the emulator to finish booting. + /// To launch and wait until the device is fully booted, use instead. + /// + /// Name of the AVD to launch (as shown by emulator -list-avds). + /// When true, forces a cold boot by passing -no-snapshot-load. + /// Optional extra arguments to pass to the emulator command line. + /// The running the emulator. Stdout/stderr are redirected and forwarded to the logger. + public Process LaunchEmulator (string avdName, bool coldBoot = false, List? additionalArgs = null) + { + if (string.IsNullOrWhiteSpace (avdName)) + throw new ArgumentException ("AVD name must not be empty.", nameof (avdName)); + + var args = new List { "-avd", avdName }; + if (coldBoot) + args.Add ("-no-snapshot-load"); + if (additionalArgs != null) + args.AddRange (additionalArgs); + + var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, args.ToArray ()); + + if (environmentVariables != null) { + foreach (var kvp in environmentVariables) + psi.EnvironmentVariables[kvp.Key] = kvp.Value; + } + + // 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; + + logger.Invoke (TraceLevel.Verbose, $"Launching emulator AVD '{avdName}'"); + + var process = new Process { StartInfo = psi }; + + // Forward emulator output to the logger so crash messages (e.g. "HAX is + // not working", "image not found") are captured instead of silently lost. + process.OutputDataReceived += (_, e) => { + if (e.Data != null) + logger.Invoke (TraceLevel.Verbose, $"[emulator] {e.Data}"); + }; + process.ErrorDataReceived += (_, e) => { + if (e.Data != null) + logger.Invoke (TraceLevel.Warning, $"[emulator] {e.Data}"); + }; + + process.Start (); + + // Drain redirected streams asynchronously to prevent pipe buffer deadlocks + process.BeginOutputReadLine (); + process.BeginErrorReadLine (); + + return process; + } + + public async Task> ListAvdNamesAsync (CancellationToken cancellationToken = default) + { + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, "-list-avds"); + + logger.Invoke (TraceLevel.Verbose, "Running: emulator -list-avds"); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + ProcessUtils.ThrowIfFailed (exitCode, "emulator -list-avds", stderr); + + 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 for the specified AVD and waits until it is fully ready to accept commands. + /// + /// Unlike , which only spawns the emulator process, this method + /// handles the full lifecycle: it checks whether the device is already online, launches + /// the emulator if needed, then polls sys.boot_completed and pm path android + /// until the Android OS is fully booted and the package manager is responsive. + /// + /// Ported from the dotnet/android BootAndroidEmulator MSBuild task. + /// + /// + /// Either an ADB device serial (e.g., emulator-5554) to wait for, + /// or an AVD name (e.g., Pixel_7_API_35) to launch and boot. + /// + /// An used to query device status and boot properties. + /// Optional boot configuration (timeout, poll interval, cold boot, extra args). + /// Cancellation token to abort the operation. + /// + /// An indicating success or failure, including the device serial on success + /// or an error message on timeout/failure. + /// + public async Task BootEmulatorAsync ( + string deviceOrAvdName, + AdbRunner adbRunner, + EmulatorBootOptions? options = 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 ??= new EmulatorBootOptions (); + if (options.BootTimeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException (nameof (options), "BootTimeout must be positive."); + if (options.PollInterval <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException (nameof (options), "PollInterval must be positive."); + + logger.Invoke (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) { + logger.Invoke (TraceLevel.Info, $"Device '{deviceOrAvdName}' is already online."); + return new EmulatorBootResult { Success = true, Serial = onlineDevice.Serial, ErrorKind = EmulatorBootErrorKind.None }; + } + + // Single timeout CTS for the entire boot operation (covers Phase 2 and Phase 3). + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); + timeoutCts.CancelAfter (options.BootTimeout); + + // Phase 2: Check if AVD is already running (possibly still booting) + var runningSerial = FindRunningAvdSerial (devices, deviceOrAvdName); + if (runningSerial != null) { + logger.Invoke (TraceLevel.Info, $"AVD '{deviceOrAvdName}' is already running as '{runningSerial}', waiting for full boot..."); + try { + return await WaitForFullBootAsync (adbRunner, runningSerial, options, timeoutCts.Token).ConfigureAwait (false); + } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + return new EmulatorBootResult { + Success = false, + ErrorKind = EmulatorBootErrorKind.Timeout, + ErrorMessage = $"Timed out waiting for emulator '{deviceOrAvdName}' to boot within {options.BootTimeout.TotalSeconds}s.", + }; + } + } + + // Phase 3: Launch the emulator + logger.Invoke (TraceLevel.Info, $"Launching AVD '{deviceOrAvdName}'..."); + Process emulatorProcess; + try { + emulatorProcess = LaunchEmulator (deviceOrAvdName, options.ColdBoot, options.AdditionalArgs); + } catch (Exception ex) { + return new EmulatorBootResult { + Success = false, + ErrorKind = EmulatorBootErrorKind.LaunchFailed, + ErrorMessage = $"Failed to launch emulator: {ex.Message}", + }; + } + + // Poll for the new emulator serial to appear. + // If the boot times out or is cancelled, terminate the process we spawned + // to avoid leaving orphan emulator processes. + 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); + } + + logger.Invoke (TraceLevel.Info, $"Emulator appeared as '{newSerial}', waiting for full boot..."); + var result = await WaitForFullBootAsync (adbRunner, newSerial, options, timeoutCts.Token).ConfigureAwait (false); + + // Release the Process handle — the emulator process itself keeps running. + // We no longer need stdout/stderr forwarding since boot is complete. + emulatorProcess.Dispose (); + return result; + } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + TryKillProcess (emulatorProcess); + return new EmulatorBootResult { + Success = false, + ErrorKind = EmulatorBootErrorKind.Timeout, + ErrorMessage = $"Timed out waiting for emulator '{deviceOrAvdName}' to boot within {options.BootTimeout.TotalSeconds}s.", + }; + } catch { + TryKillProcess (emulatorProcess); + throw; + } + } + + 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; + } + + void TryKillProcess (Process process) + { + try { + process.Kill (); + } catch (Exception ex) { + // Best-effort: process may have already exited + logger.Invoke (TraceLevel.Verbose, $"Failed to stop emulator process: {ex.Message}"); + } finally { + process.Dispose (); + } + } + + async Task WaitForFullBootAsync ( + AdbRunner adbRunner, + string serial, + EmulatorBootOptions options, + CancellationToken cancellationToken) + { + // The caller is responsible for enforcing the overall boot timeout via + // cancellationToken (a linked CTS with CancelAfter). This method simply + // polls until boot completes or the token is cancelled. + while (!cancellationToken.IsCancellationRequested) { + cancellationToken.ThrowIfCancellationRequested (); + + var bootCompleted = await adbRunner.GetShellPropertyAsync (serial, "sys.boot_completed", cancellationToken).ConfigureAwait (false); + if (string.Equals (bootCompleted, "1", StringComparison.Ordinal)) { + var pmResult = await adbRunner.RunShellCommandAsync (serial, "pm path android", cancellationToken).ConfigureAwait (false); + if (pmResult != null && pmResult.StartsWith ("package:", StringComparison.Ordinal)) { + logger.Invoke (TraceLevel.Info, $"Emulator '{serial}' is fully booted."); + return new EmulatorBootResult { Success = true, Serial = serial, ErrorKind = EmulatorBootErrorKind.None }; + } + } + + await Task.Delay (options.PollInterval, cancellationToken).ConfigureAwait (false); + } + + cancellationToken.ThrowIfCancellationRequested (); + return new EmulatorBootResult { Success = false, ErrorKind = EmulatorBootErrorKind.Cancelled, ErrorMessage = "Boot cancelled." }; + } +} + diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/RunnerDefaults.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/RunnerDefaults.cs new file mode 100644 index 00000000..e71b2df1 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/RunnerDefaults.cs @@ -0,0 +1,19 @@ +// 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; + +namespace Xamarin.Android.Tools; + +/// +/// Shared defaults for Android SDK tool runners. +/// +static class RunnerDefaults +{ + /// + /// A no-op logger that discards all messages. Used as the default + /// when callers don't provide a logger callback. + /// + internal static readonly Action NullLogger = static (_, _) => { }; +} diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs index 1e9a232a..c9536cf9 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AdbRunnerTests.cs @@ -655,4 +655,63 @@ public void WaitForDeviceAsync_ZeroTimeout_ThrowsArgumentOutOfRange () Assert.ThrowsAsync ( async () => await runner.WaitForDeviceAsync (timeout: System.TimeSpan.Zero)); } + + // --- FirstNonEmptyLine tests --- + + [Test] + public void FirstNonEmptyLine_ReturnsFirstLine () + { + Assert.AreEqual ("hello", AdbRunner.FirstNonEmptyLine ("hello\nworld\n")); + } + + [Test] + public void FirstNonEmptyLine_SkipsBlankLines () + { + Assert.AreEqual ("hello", AdbRunner.FirstNonEmptyLine ("\n\nhello\nworld\n")); + } + + [Test] + public void FirstNonEmptyLine_TrimsWhitespace () + { + Assert.AreEqual ("hello", AdbRunner.FirstNonEmptyLine (" hello \n")); + } + + [Test] + public void FirstNonEmptyLine_HandlesWindowsNewlines () + { + Assert.AreEqual ("hello", AdbRunner.FirstNonEmptyLine ("\r\nhello\r\nworld\r\n")); + } + + [Test] + public void FirstNonEmptyLine_EmptyString_ReturnsNull () + { + Assert.IsNull (AdbRunner.FirstNonEmptyLine ("")); + } + + [Test] + public void FirstNonEmptyLine_OnlyNewlines_ReturnsNull () + { + Assert.IsNull (AdbRunner.FirstNonEmptyLine ("\n\n\n")); + } + + [Test] + public void FirstNonEmptyLine_SingleValue_ReturnsIt () + { + Assert.AreEqual ("1", AdbRunner.FirstNonEmptyLine ("1\n")); + } + + [Test] + public void FirstNonEmptyLine_TypicalGetpropOutput () + { + // adb shell getprop sys.boot_completed returns "1\n" or "1\r\n" + Assert.AreEqual ("1", AdbRunner.FirstNonEmptyLine ("1\r\n")); + } + + [Test] + public void FirstNonEmptyLine_PmPathOutput () + { + // adb shell pm path android returns "package:/system/framework/framework-res.apk\n" + var output = "package:/system/framework/framework-res.apk\n"; + Assert.AreEqual ("package:/system/framework/framework-res.apk", AdbRunner.FirstNonEmptyLine (output)); + } } 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..39fdca5b --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs @@ -0,0 +1,555 @@ +// 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 Constructor_ThrowsOnNullPath () + { + Assert.Throws (() => new EmulatorRunner (null!)); + } + + [Test] + public void Constructor_ThrowsOnEmptyPath () + { + Assert.Throws (() => new EmulatorRunner ("")); + } + + [Test] + public void Constructor_ThrowsOnWhitespacePath () + { + Assert.Throws (() => new EmulatorRunner (" ")); + } + + [Test] + public void LaunchEmulator_ThrowsOnNullAvdName () + { + var runner = new EmulatorRunner ("/fake/emulator"); + Assert.Throws (() => runner.LaunchEmulator (null!)); + } + + [Test] + public void LaunchEmulator_ThrowsOnEmptyAvdName () + { + var runner = new EmulatorRunner ("/fake/emulator"); + Assert.Throws (() => runner.LaunchEmulator ("")); + } + + [Test] + public void LaunchEmulator_ThrowsOnWhitespaceAvdName () + { + var runner = new EmulatorRunner ("/fake/emulator"); + Assert.Throws (() => runner.LaunchEmulator (" ")); + } + + // --- BootEmulatorAsync 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 ("/fake/emulator"); + + var result = await runner.BootEmulatorAsync ("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 ("/fake/emulator"); + var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; + + var result = await runner.BootEmulatorAsync ("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, emuPath) = CreateFakeEmulatorSdk (); + Process? emulatorProcess = null; + try { + var runner = new EmulatorRunner (emuPath); + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromSeconds (10), + PollInterval = TimeSpan.FromMilliseconds (50), + }; + + var result = await runner.BootEmulatorAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("emulator-5554", result.Serial); + Assert.IsTrue (pollCount >= 2); + } finally { + // Kill any emulator process spawned by the test + try { + emulatorProcess = FindEmulatorProcess (emuPath); + emulatorProcess?.Kill (); + emulatorProcess?.WaitForExit (1000); + } catch { } + Directory.Delete (tempDir, true); + } + } + + [Test] + public async Task LaunchFailure_ReturnsError () + { + var devices = new List (); + var mockAdb = new MockAdbRunner (devices); + + // Nonexistent path → LaunchAvd throws → error result + var runner = new EmulatorRunner ("/nonexistent/emulator"); + var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (2) }; + + var result = await runner.BootEmulatorAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsFalse (result.Success); + Assert.That (result.ErrorMessage, Does.Contain ("Failed to launch")); + } + + [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 ("/fake/emulator"); + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromMilliseconds (200), + PollInterval = TimeSpan.FromMilliseconds (50), + }; + + var result = await runner.BootEmulatorAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsFalse (result.Success); + Assert.That (result.ErrorMessage, Does.Contain ("Timed out")); + } + + [Test] + public void BootEmulatorAsync_InvalidBootTimeout_Throws () + { + var runner = new EmulatorRunner ("/fake/emulator"); + var mockAdb = new MockAdbRunner (new List ()); + var options = new EmulatorBootOptions { BootTimeout = TimeSpan.Zero }; + + Assert.ThrowsAsync (() => + runner.BootEmulatorAsync ("test", mockAdb, options)); + } + + [Test] + public void BootEmulatorAsync_InvalidPollInterval_Throws () + { + var runner = new EmulatorRunner ("/fake/emulator"); + var mockAdb = new MockAdbRunner (new List ()); + var options = new EmulatorBootOptions { PollInterval = TimeSpan.FromMilliseconds (-1) }; + + Assert.ThrowsAsync (() => + runner.BootEmulatorAsync ("test", mockAdb, options)); + } + + [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 ("/fake/emulator"); + var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) }; + + var result = await runner.BootEmulatorAsync ("Pixel_7_API_35", mockAdb, options); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("emulator-5556", result.Serial, "Should find the correct AVD among multiple emulators"); + } + + // --- Tests ported from dotnet/android BootAndroidEmulatorTests --- + + [Test] + public async Task AlreadyOnlinePhysicalDevice_PassesThrough () + { + // Physical devices have non-emulator serials (e.g., USB serial numbers). + // BootEmulatorAsync should recognise them as already-online devices and + // return immediately without attempting to launch an emulator. + var devices = new List { + new AdbDeviceInfo { + Serial = "0A041FDD400327", + Type = AdbDeviceType.Device, + Status = AdbDeviceStatus.Online, + }, + }; + + var mockAdb = new MockAdbRunner (devices); + var runner = new EmulatorRunner ("/fake/emulator"); + + var result = await runner.BootEmulatorAsync ("0A041FDD400327", mockAdb); + + Assert.IsTrue (result.Success); + Assert.AreEqual ("0A041FDD400327", result.Serial); + Assert.IsNull (result.ErrorMessage); + } + + [Test] + public async Task AdditionalArgs_PassedToLaunchEmulator () + { + // Verify that AdditionalArgs from EmulatorBootOptions are forwarded + // to the emulator process. We use a fake emulator script that logs + // its arguments so we can inspect them after the boot times out. + var (tempDir, emuPath) = CreateFakeEmulatorSdk (); + var argsLogPath = Path.Combine (tempDir, "args.log"); + + // Rewrite the fake emulator to log its arguments + if (OS.IsWindows) { + File.WriteAllText (emuPath, $"@echo off\r\necho %* > \"{argsLogPath}\"\r\nping -n 60 127.0.0.1 >nul\r\n"); + } else { + File.WriteAllText (emuPath, $"#!/bin/sh\necho \"$@\" > \"{argsLogPath}\"\nsleep 60\n"); + } + + try { + var devices = new List (); + var mockAdb = new MockAdbRunner (devices); + + var runner = new EmulatorRunner (emuPath); + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromMilliseconds (500), + PollInterval = TimeSpan.FromMilliseconds (50), + AdditionalArgs = new List { "-gpu", "auto", "-no-audio" }, + }; + + // Boot will time out (no device appears), but the emulator process + // should have been launched with the additional args. + var result = await runner.BootEmulatorAsync ("Test_AVD", mockAdb, options); + + Assert.IsFalse (result.Success, "Boot should time out"); + + // Give the script a moment to flush args.log + await Task.Delay (200); + + if (File.Exists (argsLogPath)) { + var logged = File.ReadAllText (argsLogPath); + Assert.That (logged, Does.Contain ("-gpu"), "Should contain -gpu arg"); + Assert.That (logged, Does.Contain ("auto"), "Should contain auto value"); + Assert.That (logged, Does.Contain ("-no-audio"), "Should contain -no-audio arg"); + Assert.That (logged, Does.Contain ("-avd"), "Should contain -avd flag"); + Assert.That (logged, Does.Contain ("Test_AVD"), "Should contain AVD name"); + } + } finally { + // Clean up any spawned processes + try { + foreach (var p in Process.GetProcessesByName ("sleep")) { + try { p.Kill (); p.WaitForExit (1000); } catch { } + } + } catch { } + Directory.Delete (tempDir, true); + } + } + + [Test] + public async Task CancellationToken_AbortsBoot () + { + // Verify that cancelling the token during the polling phase causes + // BootEmulatorAsync to return promptly rather than blocking for the + // full BootTimeout duration. We need a real fake emulator script so + // LaunchEmulator succeeds (starts the process), then cancel while polling. + var (tempDir, emuPath) = CreateFakeEmulatorSdk (); + + try { + var devices = new List (); + var mockAdb = new MockAdbRunner (devices); + + var runner = new EmulatorRunner (emuPath); + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromSeconds (30), + PollInterval = TimeSpan.FromMilliseconds (50), + }; + + using var cts = new CancellationTokenSource (); + cts.CancelAfter (TimeSpan.FromMilliseconds (300)); + + var sw = Stopwatch.StartNew (); + try { + await runner.BootEmulatorAsync ("Nonexistent_AVD", mockAdb, options, cts.Token); + Assert.Fail ("Should have thrown OperationCanceledException"); + } catch (OperationCanceledException) { + sw.Stop (); + // Should abort well before the 30s BootTimeout + Assert.That (sw.Elapsed.TotalSeconds, Is.LessThan (5), + "Cancellation should abort within a few seconds, not wait for full timeout"); + } + } finally { + try { + foreach (var p in Process.GetProcessesByName ("sleep")) { + try { p.Kill (); p.WaitForExit (1000); } catch { } + } + } catch { } + Directory.Delete (tempDir, true); + } + } + + [Test] + public async Task ColdBoot_PassesNoSnapshotLoad () + { + // Verify that ColdBoot = true causes -no-snapshot-load to be passed. + var (tempDir, emuPath) = CreateFakeEmulatorSdk (); + var argsLogPath = Path.Combine (tempDir, "args.log"); + + if (OS.IsWindows) { + File.WriteAllText (emuPath, $"@echo off\r\necho %* > \"{argsLogPath}\"\r\nping -n 60 127.0.0.1 >nul\r\n"); + } else { + File.WriteAllText (emuPath, $"#!/bin/sh\necho \"$@\" > \"{argsLogPath}\"\nsleep 60\n"); + } + + try { + var devices = new List (); + var mockAdb = new MockAdbRunner (devices); + + var runner = new EmulatorRunner (emuPath); + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromMilliseconds (500), + PollInterval = TimeSpan.FromMilliseconds (50), + ColdBoot = true, + }; + + var result = await runner.BootEmulatorAsync ("Test_AVD", mockAdb, options); + + Assert.IsFalse (result.Success, "Boot should time out"); + await Task.Delay (200); + + if (File.Exists (argsLogPath)) { + var logged = File.ReadAllText (argsLogPath); + Assert.That (logged, Does.Contain ("-no-snapshot-load"), "ColdBoot should pass -no-snapshot-load"); + } + } finally { + try { + foreach (var p in Process.GetProcessesByName ("sleep")) { + try { p.Kill (); p.WaitForExit (1000); } catch { } + } + } catch { } + Directory.Delete (tempDir, true); + } + } + + [Test] + public void BootEmulatorAsync_NullAdbRunner_Throws () + { + var runner = new EmulatorRunner ("/fake/emulator"); + + Assert.ThrowsAsync (() => + runner.BootEmulatorAsync ("test", null!)); + } + + [Test] + public void BootEmulatorAsync_EmptyDeviceName_Throws () + { + var runner = new EmulatorRunner ("/fake/emulator"); + var mockAdb = new MockAdbRunner (new List ()); + + Assert.ThrowsAsync (() => + runner.BootEmulatorAsync ("", mockAdb)); + } + + // --- Helpers --- + + static (string tempDir, string emulatorPath) 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.bat" : "emulator"; + var emuPath = Path.Combine (emulatorDir, emuName); + if (OS.IsWindows) { + File.WriteAllText (emuPath, "@echo off\r\nping -n 60 127.0.0.1 >nul\r\n"); + } else { + File.WriteAllText (emuPath, "#!/bin/sh\nsleep 60\n"); + var psi = ProcessUtils.CreateProcessStartInfo ("chmod", "+x", emuPath); + using var chmod = new Process { StartInfo = psi }; + chmod.Start (); + chmod.WaitForExit (); + } + + return (tempDir, emuPath); + } + + static Process? FindEmulatorProcess (string emuPath) + { + // Best-effort: find the process by matching the command line + try { + foreach (var p in Process.GetProcessesByName ("emulator")) { + return p; + } + foreach (var p in Process.GetProcessesByName ("sleep")) { + return p; + } + } catch { } + return null; + } + + /// + /// Mock AdbRunner for testing BootEmulatorAsync 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 ("/fake/adb") + { + 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) + { + ShellCommands.TryGetValue (command, out var value); + return Task.FromResult (value); + } + } +}