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);
+ }
+ }
+}