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..7c011fde
--- /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 class EmulatorBootOptions
+{
+ public TimeSpan BootTimeout { get; set; } = TimeSpan.FromSeconds (300);
+ public IEnumerable? AdditionalArgs { get; set; }
+ public bool ColdBoot { get; set; }
+ public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds (500);
+}
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs
new file mode 100644
index 00000000..4316f8b6
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs
@@ -0,0 +1,14 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Xamarin.Android.Tools;
+
+///
+/// Result of an emulator boot operation.
+///
+public record EmulatorBootResult
+{
+ public bool Success { get; init; }
+ public string? Serial { get; init; }
+ public string? ErrorMessage { 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..484b7243 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
@@ -32,8 +32,9 @@ 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
+*REMOVED*Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+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 +146,28 @@ 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.IEnumerable?
+Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.set -> void
+Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.get -> System.TimeSpan
+Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.set -> void
+Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.get -> bool
+Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.set -> void
+Xamarin.Android.Tools.EmulatorBootOptions.EmulatorBootOptions() -> void
+Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.get -> System.TimeSpan
+Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.set -> void
+Xamarin.Android.Tools.EmulatorBootResult
+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.IEnumerable? 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..484b7243 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
@@ -32,8 +32,9 @@ 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
+*REMOVED*Xamarin.Android.Tools.AdbRunner.ListDevicesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+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 +146,28 @@ 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.IEnumerable?
+Xamarin.Android.Tools.EmulatorBootOptions.AdditionalArgs.set -> void
+Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.get -> System.TimeSpan
+Xamarin.Android.Tools.EmulatorBootOptions.BootTimeout.set -> void
+Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.get -> bool
+Xamarin.Android.Tools.EmulatorBootOptions.ColdBoot.set -> void
+Xamarin.Android.Tools.EmulatorBootOptions.EmulatorBootOptions() -> void
+Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.get -> System.TimeSpan
+Xamarin.Android.Tools.EmulatorBootOptions.PollInterval.set -> void
+Xamarin.Android.Tools.EmulatorBootResult
+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.IEnumerable? 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..5437ce8f 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;
}
///
/// 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 ();
@@ -135,6 +138,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.
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..83f67ea5
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs
@@ -0,0 +1,291 @@
+// 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;
+ }
+
+ ///
+ /// 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, IEnumerable? 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.");
+
+ void Log (TraceLevel level, string message) => logger?.Invoke (level, message);
+
+ Log (TraceLevel.Info, $"Booting emulator for '{deviceOrAvdName}'...");
+
+ // Phase 1: Check if deviceOrAvdName is already an online ADB device by serial
+ var devices = await adbRunner.ListDevicesAsync (cancellationToken).ConfigureAwait (false);
+ var onlineDevice = devices.FirstOrDefault (d =>
+ d.Status == AdbDeviceStatus.Online &&
+ string.Equals (d.Serial, deviceOrAvdName, StringComparison.OrdinalIgnoreCase));
+
+ if (onlineDevice != null) {
+ Log (TraceLevel.Info, $"Device '{deviceOrAvdName}' is already online.");
+ return new EmulatorBootResult { Success = true, Serial = onlineDevice.Serial };
+ }
+
+ // 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) {
+ Log (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,
+ ErrorMessage = $"Timed out waiting for emulator '{deviceOrAvdName}' to boot within {options.BootTimeout.TotalSeconds}s.",
+ };
+ }
+ }
+
+ // Phase 3: Launch the emulator
+ Log (TraceLevel.Info, $"Launching AVD '{deviceOrAvdName}'...");
+ Process emulatorProcess;
+ try {
+ emulatorProcess = LaunchEmulator (deviceOrAvdName, options.ColdBoot, options.AdditionalArgs);
+ } catch (Exception ex) {
+ return new EmulatorBootResult {
+ Success = false,
+ ErrorMessage = $"Failed to launch emulator: {ex.Message}",
+ };
+ }
+
+ // Poll for the new emulator serial to appear.
+ // 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);
+ }
+
+ Log (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,
+ 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 {
+#if NET5_0_OR_GREATER
+ process.Kill (entireProcessTree: true);
+#else
+ process.Kill ();
+#endif
+ } 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)
+ {
+ void Log (TraceLevel level, string message) => logger?.Invoke (level, message);
+
+ // 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 (true) {
+ 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)) {
+ Log (TraceLevel.Info, $"Emulator '{serial}' is fully booted.");
+ return new EmulatorBootResult { Success = true, Serial = serial };
+ }
+ }
+
+ await Task.Delay (options.PollInterval, cancellationToken).ConfigureAwait (false);
+ }
+ }
+}
+
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..f095a872
--- /dev/null
+++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs
@@ -0,0 +1,370 @@
+// 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");
+ }
+
+ // --- 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);
+ }
+ }
+}