diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs
new file mode 100644
index 00000000..8765df77
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootOptions.cs
@@ -0,0 +1,18 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+
+namespace Xamarin.Android.Tools
+{
+ ///
+ /// Options for booting an Android emulator.
+ ///
+ public class EmulatorBootOptions
+ {
+ public TimeSpan BootTimeout { get; set; } = TimeSpan.FromSeconds (300);
+ public string? AdditionalArgs { get; set; }
+ public bool ColdBoot { get; set; }
+ public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds (500);
+ }
+}
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs
new file mode 100644
index 00000000..59281792
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/EmulatorBootResult.cs
@@ -0,0 +1,15 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Xamarin.Android.Tools
+{
+ ///
+ /// Result of an emulator boot operation.
+ ///
+ public class EmulatorBootResult
+ {
+ public bool Success { get; set; }
+ public string? Serial { get; set; }
+ public string? ErrorMessage { get; set; }
+ }
+}
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs
index f321af6d..e743bf9c 100644
--- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AdbRunner.cs
@@ -47,7 +47,7 @@ public AdbRunner (string adbPath, IDictionary? environmentVariab
/// 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 +135,40 @@ 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>'.
+ ///
+ 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);
+ return exitCode == 0 ? FirstNonEmptyLine (stdout.ToString ()) : null;
+ }
+
+ ///
+ /// Runs a shell command on a device via 'adb -s <serial> shell <command>'.
+ ///
+ public virtual async Task RunShellCommandAsync (string serial, string command, CancellationToken cancellationToken = default)
+ {
+ 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);
+ return exitCode == 0 ? FirstNonEmptyLine (stdout.ToString ()) : 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/AndroidEnvironmentHelper.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs
index 51f6309d..3cf67207 100644
--- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AndroidEnvironmentHelper.cs
@@ -37,4 +37,15 @@ internal static Dictionary GetEnvironmentVariables (string? sdkP
return env;
}
+
+ ///
+ /// Applies Android SDK environment variables directly to a .
+ /// Used by runners that manage their own process lifecycle (e.g., EmulatorRunner).
+ ///
+ internal static void ConfigureEnvironment (System.Diagnostics.ProcessStartInfo psi, string? sdkPath, string? jdkPath)
+ {
+ var env = GetEnvironmentVariables (sdkPath, jdkPath);
+ foreach (var kvp in env)
+ psi.Environment [kvp.Key] = kvp.Value;
+ }
}
diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs
new file mode 100644
index 00000000..837108f5
--- /dev/null
+++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/EmulatorRunner.cs
@@ -0,0 +1,237 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Xamarin.Android.Tools;
+
+///
+/// Runs Android Emulator commands.
+///
+public class EmulatorRunner
+{
+ readonly Func getSdkPath;
+ readonly Func? getJdkPath;
+
+ public EmulatorRunner (Func getSdkPath)
+ : this (getSdkPath, null)
+ {
+ }
+
+ public EmulatorRunner (Func getSdkPath, Func? getJdkPath)
+ {
+ this.getSdkPath = getSdkPath ?? throw new ArgumentNullException (nameof (getSdkPath));
+ this.getJdkPath = getJdkPath;
+ }
+
+ public string? EmulatorPath {
+ get {
+ var sdkPath = getSdkPath ();
+ if (string.IsNullOrEmpty (sdkPath))
+ return null;
+
+ var ext = OS.IsWindows ? ".exe" : "";
+ var path = Path.Combine (sdkPath, "emulator", "emulator" + ext);
+
+ return File.Exists (path) ? path : null;
+ }
+ }
+
+ public bool IsAvailable => EmulatorPath is not null;
+
+ string RequireEmulatorPath ()
+ {
+ return EmulatorPath ?? throw new InvalidOperationException ("Android Emulator not found.");
+ }
+
+ void ConfigureEnvironment (ProcessStartInfo psi)
+ {
+ AndroidEnvironmentHelper.ConfigureEnvironment (psi, getSdkPath (), getJdkPath?.Invoke ());
+ }
+
+ public Process StartAvd (string avdName, bool coldBoot = false, string? additionalArgs = null)
+ {
+ var emulatorPath = RequireEmulatorPath ();
+
+ var args = new List { "-avd", avdName };
+ if (coldBoot)
+ args.Add ("-no-snapshot-load");
+ if (!string.IsNullOrEmpty (additionalArgs))
+ args.Add (additionalArgs);
+
+ var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, args.ToArray ());
+ ConfigureEnvironment (psi);
+
+ // Redirect stdout/stderr so the emulator process doesn't inherit the
+ // caller's pipes. Without this, parent processes (e.g. VS Code spawn)
+ // never see the 'close' event because the emulator holds the pipes open.
+ psi.RedirectStandardOutput = true;
+ psi.RedirectStandardError = true;
+
+ var process = new Process { StartInfo = psi };
+ process.Start ();
+
+ return process;
+ }
+
+ public async Task> ListAvdNamesAsync (CancellationToken cancellationToken = default)
+ {
+ var emulatorPath = RequireEmulatorPath ();
+
+ using var stdout = new StringWriter ();
+ var psi = ProcessUtils.CreateProcessStartInfo (emulatorPath, "-list-avds");
+ ConfigureEnvironment (psi);
+
+ await ProcessUtils.StartProcess (psi, stdout, null, cancellationToken).ConfigureAwait (false);
+
+ return ParseListAvdsOutput (stdout.ToString ());
+ }
+
+ internal static List ParseListAvdsOutput (string output)
+ {
+ var avds = new List ();
+ foreach (var line in output.Split ('\n')) {
+ var trimmed = line.Trim ();
+ if (!string.IsNullOrEmpty (trimmed))
+ avds.Add (trimmed);
+ }
+ return avds;
+ }
+
+ ///
+ /// Boots an emulator and waits for it to be fully booted.
+ /// Ported from dotnet/android BootAndroidEmulator MSBuild task.
+ ///
+ public async Task BootAndWaitAsync (
+ string deviceOrAvdName,
+ AdbRunner adbRunner,
+ EmulatorBootOptions? options = null,
+ Action? logger = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace (deviceOrAvdName))
+ throw new ArgumentException ("Device or AVD name must not be empty.", nameof (deviceOrAvdName));
+ if (adbRunner == null)
+ throw new ArgumentNullException (nameof (adbRunner));
+
+ options = options ?? new EmulatorBootOptions ();
+ void Log (TraceLevel level, string message) => logger?.Invoke (level, message);
+
+ Log (TraceLevel.Info, $"Booting emulator for '{deviceOrAvdName}'...");
+
+ // Phase 1: Check if deviceOrAvdName is already an online ADB device by serial
+ var devices = await adbRunner.ListDevicesAsync (cancellationToken).ConfigureAwait (false);
+ var onlineDevice = devices.FirstOrDefault (d =>
+ d.Status == AdbDeviceStatus.Online &&
+ string.Equals (d.Serial, deviceOrAvdName, StringComparison.OrdinalIgnoreCase));
+
+ if (onlineDevice != null) {
+ Log (TraceLevel.Info, $"Device '{deviceOrAvdName}' is already online.");
+ return new EmulatorBootResult { Success = true, Serial = onlineDevice.Serial };
+ }
+
+ // Phase 2: Check if AVD is already running (possibly still booting)
+ var runningSerial = FindRunningAvdSerial (devices, deviceOrAvdName);
+ if (runningSerial != null) {
+ Log (TraceLevel.Info, $"AVD '{deviceOrAvdName}' is already running as '{runningSerial}', waiting for full boot...");
+ return await WaitForFullBootAsync (adbRunner, runningSerial, options, logger, cancellationToken).ConfigureAwait (false);
+ }
+
+ // Phase 3: Launch the emulator
+ if (EmulatorPath == null) {
+ return new EmulatorBootResult {
+ Success = false,
+ ErrorMessage = "Android Emulator not found. Ensure the Android SDK is installed and the emulator is available.",
+ };
+ }
+
+ Log (TraceLevel.Info, $"Launching AVD '{deviceOrAvdName}'...");
+ Process emulatorProcess;
+ try {
+ emulatorProcess = StartAvd (deviceOrAvdName, options.ColdBoot, options.AdditionalArgs);
+ } catch (Exception ex) {
+ return new EmulatorBootResult {
+ Success = false,
+ ErrorMessage = $"Failed to launch emulator: {ex.Message}",
+ };
+ }
+
+ // Poll for the new emulator serial to appear
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken);
+ timeoutCts.CancelAfter (options.BootTimeout);
+
+ try {
+ string? newSerial = null;
+ while (newSerial == null) {
+ timeoutCts.Token.ThrowIfCancellationRequested ();
+ await Task.Delay (options.PollInterval, timeoutCts.Token).ConfigureAwait (false);
+
+ devices = await adbRunner.ListDevicesAsync (timeoutCts.Token).ConfigureAwait (false);
+ newSerial = FindRunningAvdSerial (devices, deviceOrAvdName);
+ }
+
+ Log (TraceLevel.Info, $"Emulator appeared as '{newSerial}', waiting for full boot...");
+ return await WaitForFullBootAsync (adbRunner, newSerial, options, logger, timeoutCts.Token).ConfigureAwait (false);
+ } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) {
+ return new EmulatorBootResult {
+ Success = false,
+ ErrorMessage = $"Timed out waiting for emulator '{deviceOrAvdName}' to boot within {options.BootTimeout.TotalSeconds}s.",
+ };
+ }
+ }
+
+ static string? FindRunningAvdSerial (IReadOnlyList devices, string avdName)
+ {
+ foreach (var d in devices) {
+ if (d.Type == AdbDeviceType.Emulator &&
+ !string.IsNullOrEmpty (d.AvdName) &&
+ string.Equals (d.AvdName, avdName, StringComparison.OrdinalIgnoreCase)) {
+ return d.Serial;
+ }
+ }
+ return null;
+ }
+
+ async Task WaitForFullBootAsync (
+ AdbRunner adbRunner,
+ string serial,
+ EmulatorBootOptions options,
+ Action? logger,
+ CancellationToken cancellationToken)
+ {
+ void Log (TraceLevel level, string message) => logger?.Invoke (level, message);
+
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource (cancellationToken);
+ timeoutCts.CancelAfter (options.BootTimeout);
+
+ try {
+ while (true) {
+ timeoutCts.Token.ThrowIfCancellationRequested ();
+
+ var bootCompleted = await adbRunner.GetShellPropertyAsync (serial, "sys.boot_completed", timeoutCts.Token).ConfigureAwait (false);
+ if (string.Equals (bootCompleted, "1", StringComparison.Ordinal)) {
+ var pmResult = await adbRunner.RunShellCommandAsync (serial, "pm path android", timeoutCts.Token).ConfigureAwait (false);
+ if (pmResult != null && pmResult.StartsWith ("package:", StringComparison.Ordinal)) {
+ Log (TraceLevel.Info, $"Emulator '{serial}' is fully booted.");
+ return new EmulatorBootResult { Success = true, Serial = serial };
+ }
+ }
+
+ await Task.Delay (options.PollInterval, timeoutCts.Token).ConfigureAwait (false);
+ }
+ } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) {
+ return new EmulatorBootResult {
+ Success = false,
+ Serial = serial,
+ ErrorMessage = $"Timed out waiting for emulator '{serial}' to fully boot within {options.BootTimeout.TotalSeconds}s.",
+ };
+ }
+ }
+}
+
diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs
new file mode 100644
index 00000000..490b18e7
--- /dev/null
+++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/EmulatorRunnerTests.cs
@@ -0,0 +1,326 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+
+namespace Xamarin.Android.Tools.Tests;
+
+[TestFixture]
+public class EmulatorRunnerTests
+{
+ [Test]
+ public void ParseListAvdsOutput_MultipleAvds ()
+ {
+ var output = "Pixel_7_API_35\nMAUI_Emulator\nNexus_5X\n";
+
+ var avds = EmulatorRunner.ParseListAvdsOutput (output);
+
+ Assert.AreEqual (3, avds.Count);
+ Assert.AreEqual ("Pixel_7_API_35", avds [0]);
+ Assert.AreEqual ("MAUI_Emulator", avds [1]);
+ Assert.AreEqual ("Nexus_5X", avds [2]);
+ }
+
+ [Test]
+ public void ParseListAvdsOutput_EmptyOutput ()
+ {
+ var avds = EmulatorRunner.ParseListAvdsOutput ("");
+ Assert.AreEqual (0, avds.Count);
+ }
+
+ [Test]
+ public void ParseListAvdsOutput_WindowsNewlines ()
+ {
+ var output = "Pixel_7_API_35\r\nMAUI_Emulator\r\n";
+
+ var avds = EmulatorRunner.ParseListAvdsOutput (output);
+
+ Assert.AreEqual (2, avds.Count);
+ Assert.AreEqual ("Pixel_7_API_35", avds [0]);
+ Assert.AreEqual ("MAUI_Emulator", avds [1]);
+ }
+
+ [Test]
+ public void ParseListAvdsOutput_BlankLines ()
+ {
+ var output = "\nPixel_7_API_35\n\n\nMAUI_Emulator\n\n";
+
+ var avds = EmulatorRunner.ParseListAvdsOutput (output);
+
+ Assert.AreEqual (2, avds.Count);
+ }
+
+ [Test]
+ public void EmulatorPath_FindsInSdk ()
+ {
+ var tempDir = Path.Combine (Path.GetTempPath (), $"emu-test-{Path.GetRandomFileName ()}");
+ var emulatorDir = Path.Combine (tempDir, "emulator");
+ Directory.CreateDirectory (emulatorDir);
+
+ try {
+ var emuName = OS.IsWindows ? "emulator.exe" : "emulator";
+ File.WriteAllText (Path.Combine (emulatorDir, emuName), "");
+
+ var runner = new EmulatorRunner (() => tempDir);
+
+ Assert.IsNotNull (runner.EmulatorPath);
+ Assert.IsTrue (runner.IsAvailable);
+ } finally {
+ Directory.Delete (tempDir, true);
+ }
+ }
+
+ [Test]
+ public void EmulatorPath_MissingSdk_ReturnsNull ()
+ {
+ var runner = new EmulatorRunner (() => "/nonexistent/path");
+ Assert.IsNull (runner.EmulatorPath);
+ Assert.IsFalse (runner.IsAvailable);
+ }
+
+ [Test]
+ public void EmulatorPath_NullSdk_ReturnsNull ()
+ {
+ var runner = new EmulatorRunner (() => null);
+ Assert.IsNull (runner.EmulatorPath);
+ Assert.IsFalse (runner.IsAvailable);
+ }
+
+ // --- BootAndWaitAsync tests (ported from dotnet/android BootAndroidEmulatorTests) ---
+
+ [Test]
+ public async Task AlreadyOnlineDevice_PassesThrough ()
+ {
+ var devices = new List {
+ new AdbDeviceInfo {
+ Serial = "emulator-5554",
+ Type = AdbDeviceType.Emulator,
+ Status = AdbDeviceStatus.Online,
+ AvdName = "Pixel_7_API_35",
+ },
+ };
+
+ var mockAdb = new MockAdbRunner (devices);
+ var runner = new EmulatorRunner (() => null);
+
+ var result = await runner.BootAndWaitAsync ("emulator-5554", mockAdb);
+
+ Assert.IsTrue (result.Success);
+ Assert.AreEqual ("emulator-5554", result.Serial);
+ Assert.IsNull (result.ErrorMessage);
+ }
+
+ [Test]
+ public async Task AvdAlreadyRunning_WaitsForFullBoot ()
+ {
+ var devices = new List {
+ new AdbDeviceInfo {
+ Serial = "emulator-5554",
+ Type = AdbDeviceType.Emulator,
+ Status = AdbDeviceStatus.Online,
+ AvdName = "Pixel_7_API_35",
+ },
+ };
+
+ var mockAdb = new MockAdbRunner (devices);
+ mockAdb.ShellProperties ["sys.boot_completed"] = "1";
+ mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk";
+
+ var runner = new EmulatorRunner (() => null);
+ var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) };
+
+ var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options);
+
+ Assert.IsTrue (result.Success);
+ Assert.AreEqual ("emulator-5554", result.Serial);
+ }
+
+ [Test]
+ public async Task BootEmulator_AppearsAfterPolling ()
+ {
+ var devices = new List ();
+ var mockAdb = new MockAdbRunner (devices);
+ mockAdb.ShellProperties ["sys.boot_completed"] = "1";
+ mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk";
+
+ int pollCount = 0;
+ mockAdb.OnListDevices = () => {
+ pollCount++;
+ if (pollCount >= 2) {
+ devices.Add (new AdbDeviceInfo {
+ Serial = "emulator-5554",
+ Type = AdbDeviceType.Emulator,
+ Status = AdbDeviceStatus.Online,
+ AvdName = "Pixel_7_API_35",
+ });
+ }
+ };
+
+ var tempDir = CreateFakeEmulatorSdk ();
+ try {
+ var runner = new EmulatorRunner (() => tempDir);
+ var options = new EmulatorBootOptions {
+ BootTimeout = TimeSpan.FromSeconds (10),
+ PollInterval = TimeSpan.FromMilliseconds (50),
+ };
+
+ var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options);
+
+ Assert.IsTrue (result.Success);
+ Assert.AreEqual ("emulator-5554", result.Serial);
+ Assert.IsTrue (pollCount >= 2);
+ } finally {
+ Directory.Delete (tempDir, true);
+ }
+ }
+
+ [Test]
+ public async Task LaunchFailure_ReturnsError ()
+ {
+ var devices = new List ();
+ var mockAdb = new MockAdbRunner (devices);
+
+ // No emulator path → EmulatorPath returns null → error
+ var runner = new EmulatorRunner (() => null);
+ var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (2) };
+
+ var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options);
+
+ Assert.IsFalse (result.Success);
+ Assert.IsNotNull (result.ErrorMessage);
+ Assert.IsTrue (result.ErrorMessage!.Contains ("not found"), $"Unexpected error: {result.ErrorMessage}");
+ }
+
+ [Test]
+ public async Task BootTimeout_BootCompletedNeverReaches1 ()
+ {
+ var devices = new List {
+ new AdbDeviceInfo {
+ Serial = "emulator-5554",
+ Type = AdbDeviceType.Emulator,
+ Status = AdbDeviceStatus.Online,
+ AvdName = "Pixel_7_API_35",
+ },
+ };
+
+ var mockAdb = new MockAdbRunner (devices);
+ // boot_completed never returns "1"
+ mockAdb.ShellProperties ["sys.boot_completed"] = "0";
+
+ var runner = new EmulatorRunner (() => null);
+ var options = new EmulatorBootOptions {
+ BootTimeout = TimeSpan.FromMilliseconds (200),
+ PollInterval = TimeSpan.FromMilliseconds (50),
+ };
+
+ var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options);
+
+ Assert.IsFalse (result.Success);
+ Assert.IsNotNull (result.ErrorMessage);
+ Assert.IsTrue (result.ErrorMessage!.Contains ("Timed out"), $"Unexpected error: {result.ErrorMessage}");
+ }
+
+ [Test]
+ public async Task MultipleEmulators_FindsCorrectAvd ()
+ {
+ var devices = new List {
+ new AdbDeviceInfo {
+ Serial = "emulator-5554",
+ Type = AdbDeviceType.Emulator,
+ Status = AdbDeviceStatus.Online,
+ AvdName = "Pixel_5_API_30",
+ },
+ new AdbDeviceInfo {
+ Serial = "emulator-5556",
+ Type = AdbDeviceType.Emulator,
+ Status = AdbDeviceStatus.Online,
+ AvdName = "Pixel_7_API_35",
+ },
+ new AdbDeviceInfo {
+ Serial = "emulator-5558",
+ Type = AdbDeviceType.Emulator,
+ Status = AdbDeviceStatus.Online,
+ AvdName = "Nexus_5X_API_28",
+ },
+ };
+
+ var mockAdb = new MockAdbRunner (devices);
+ mockAdb.ShellProperties ["sys.boot_completed"] = "1";
+ mockAdb.ShellCommands ["pm path android"] = "package:/system/framework/framework-res.apk";
+
+ var runner = new EmulatorRunner (() => null);
+ var options = new EmulatorBootOptions { BootTimeout = TimeSpan.FromSeconds (5), PollInterval = TimeSpan.FromMilliseconds (50) };
+
+ var result = await runner.BootAndWaitAsync ("Pixel_7_API_35", mockAdb, options);
+
+ Assert.IsTrue (result.Success);
+ Assert.AreEqual ("emulator-5556", result.Serial, "Should find the correct AVD among multiple emulators");
+ }
+
+ // --- Helpers ---
+
+ static string CreateFakeEmulatorSdk ()
+ {
+ var tempDir = Path.Combine (Path.GetTempPath (), $"emu-boot-test-{Path.GetRandomFileName ()}");
+ var emulatorDir = Path.Combine (tempDir, "emulator");
+ Directory.CreateDirectory (emulatorDir);
+
+ var emuName = OS.IsWindows ? "emulator.exe" : "emulator";
+ var emuPath = Path.Combine (emulatorDir, emuName);
+ // Create a fake emulator script that just exits
+ if (OS.IsWindows) {
+ File.WriteAllText (emuPath, "@echo off");
+ } else {
+ File.WriteAllText (emuPath, "#!/bin/sh\nsleep 60\n");
+ // Make executable
+ var psi = new ProcessStartInfo ("chmod", $"+x \"{emuPath}\"") { UseShellExecute = false };
+ Process.Start (psi)?.WaitForExit ();
+ }
+
+ return tempDir;
+ }
+
+ ///
+ /// Mock AdbRunner for testing BootAndWaitAsync without real adb commands.
+ ///
+ class MockAdbRunner : AdbRunner
+ {
+ readonly List devices;
+
+ public Dictionary ShellProperties { get; } = new Dictionary (StringComparer.OrdinalIgnoreCase);
+ public Dictionary ShellCommands { get; } = new Dictionary (StringComparer.OrdinalIgnoreCase);
+ public Action? OnListDevices { get; set; }
+
+ public MockAdbRunner (List devices)
+ : base ("/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 = default)
+ {
+ ShellCommands.TryGetValue (command, out var value);
+ return Task.FromResult (value);
+ }
+ }
+}