From 8a233c9187037fd9838c382520d35fbc66eafc93 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Mon, 16 Mar 2026 12:05:54 +0000 Subject: [PATCH 01/14] Use shared EmulatorRunner from android-tools for BootAndroidEmulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the 454-line BootAndroidEmulator implementation with a thin ~180-line wrapper that delegates to EmulatorRunner.BootEmulatorAsync() from Xamarin.Android.Tools.AndroidSdk. Key changes: - Remove all process management, polling, and boot detection logic - Delegate to EmulatorRunner.BootEmulatorAsync() for the full 3-phase boot: check online → check AVD running → launch + poll + wait - Map EmulatorBootResult errors to existing XA0143/XA0145 error codes - Virtual ExecuteBoot() method for clean test mocking - Update submodule to feature/emulator-runner (d8ee2d5) Tests updated from 9 to 10 (added ExtraArguments and UnknownError tests) using simplified mock pattern — MockBootAndroidEmulator overrides ExecuteBoot() to return canned EmulatorBootResult values. Depends on: dotnet/android-tools#284 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- external/xamarin-android-tools | 2 +- .../Tasks/BootAndroidEmulator.cs | 366 +++--------------- .../Tasks/BootAndroidEmulatorTests.cs | 193 ++++----- 3 files changed, 134 insertions(+), 427 deletions(-) diff --git a/external/xamarin-android-tools b/external/xamarin-android-tools index d679f2becba..40b30131791 160000 --- a/external/xamarin-android-tools +++ b/external/xamarin-android-tools @@ -1 +1 @@ -Subproject commit d679f2becbac319c1ef35934d40866d87996f7b0 +Subproject commit 40b30131791e7e996e20d461f8d3694b273f6985 diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs index bcfa0d656de..62ca7fc79a1 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -1,10 +1,8 @@ #nullable enable using System; -using System.Collections.Generic; using System.Diagnostics; using System.IO; -using System.Threading; using Microsoft.Android.Build.Tasks; using Microsoft.Build.Framework; using Xamarin.Android.Tools; @@ -24,11 +22,13 @@ namespace Xamarin.Android.Tasks; /// the emulator and waits for it to become fully ready. /// /// On success, outputs the resolved ADB serial and AdbTarget for use by subsequent tasks. +/// +/// Boot logic is delegated to and +/// in Xamarin.Android.Tools.AndroidSdk. /// public class BootAndroidEmulator : AndroidTask { const int DefaultBootTimeoutSeconds = 300; - const int PollIntervalMilliseconds = 500; public override string TaskPrefix => "BAE"; @@ -95,71 +95,63 @@ public class BootAndroidEmulator : AndroidTask public override bool RunTask () { var adbPath = ResolveAdbPath (); + var emulatorPath = ResolveEmulatorPath (); + var logger = this.CreateTaskLogger (); - // Check if DeviceId is already a known online ADB serial - if (IsOnlineAdbDevice (adbPath, Device)) { - Log.LogMessage (MessageImportance.Normal, $"Device '{Device}' is already online."); - ResolvedDevice = Device; - AdbTarget = $"-s {Device}"; - return true; - } - - // DeviceId is not an online serial — treat it as an AVD name and boot it - Log.LogMessage (MessageImportance.Normal, $"Device '{Device}' is not an online ADB device. Treating as AVD name."); + var options = new EmulatorBootOptions { + BootTimeout = TimeSpan.FromSeconds (BootTimeoutSeconds), + AdditionalArgs = ParseExtraArguments (EmulatorExtraArguments), + }; - var emulatorPath = ResolveEmulatorPath (); - var avdName = Device; + var result = ExecuteBoot (adbPath, emulatorPath, logger, Device, options); - // Check if this AVD is already running (but perhaps still booting) - var existingSerial = FindRunningEmulatorForAvd (adbPath, avdName); - if (existingSerial != null) { - Log.LogMessage (MessageImportance.High, $"Emulator '{avdName}' is already running as '{existingSerial}'"); - ResolvedDevice = existingSerial; - AdbTarget = $"-s {existingSerial}"; - return WaitForFullBoot (adbPath, avdName, existingSerial); + if (result.Success) { + ResolvedDevice = result.Serial; + AdbTarget = $"-s {result.Serial}"; + Log.LogMessage (MessageImportance.High, $"Emulator '{Device}' ({result.Serial}) is fully booted and ready."); + return true; } - // Launch the emulator process in the background - Log.LogMessage (MessageImportance.High, $"Booting emulator '{avdName}'..."); - using var emulatorProcess = LaunchEmulatorProcess (emulatorPath, avdName); - if (emulatorProcess == null) { - return false; + // Map the error message to the appropriate MSBuild error code + var errorMessage = result.ErrorMessage ?? "Unknown error"; + + if (errorMessage.Contains ("Failed to launch")) { + Log.LogCodedError ("XA0143", Properties.Resources.XA0143, Device, errorMessage); + } else if (errorMessage.Contains ("Timed out")) { + Log.LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); + } else { + Log.LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); } - try { - var timeout = TimeSpan.FromSeconds (BootTimeoutSeconds); - var stopwatch = Stopwatch.StartNew (); + return false; + } - // Phase 1: Wait for the emulator to appear in 'adb devices' as online - Log.LogMessage (MessageImportance.Normal, "Waiting for emulator to appear in adb devices..."); - var serial = WaitForEmulatorOnline (adbPath, avdName, emulatorProcess, stopwatch, timeout); - if (serial == null) { - if (emulatorProcess.HasExited) { - Log.LogCodedError ("XA0144", Properties.Resources.XA0144, avdName, emulatorProcess.ExitCode); - } else { - Log.LogCodedError ("XA0145", Properties.Resources.XA0145, avdName, BootTimeoutSeconds); - } - return false; - } + /// + /// Executes the full boot flow via . + /// Virtual so tests can return canned results without launching real processes. + /// + protected virtual EmulatorBootResult ExecuteBoot ( + string adbPath, + string emulatorPath, + Action logger, + string device, + EmulatorBootOptions options) + { + var adbRunner = new AdbRunner (adbPath, logger: logger); + var emulatorRunner = new EmulatorRunner (emulatorPath, logger: logger); + return emulatorRunner.BootEmulatorAsync (device, adbRunner, options) + .GetAwaiter ().GetResult (); + } - ResolvedDevice = serial; - AdbTarget = $"-s {serial}"; - Log.LogMessage (MessageImportance.Normal, $"Emulator appeared as '{serial}'"); + /// + /// Parses space-separated extra arguments into an array suitable for . + /// + static string[]? ParseExtraArguments (string? extraArgs) + { + if (extraArgs.IsNullOrEmpty ()) + return null; - // Phase 2: Wait for the device to fully boot - return WaitForFullBoot (adbPath, avdName, serial); - } finally { - // Stop async reads and unsubscribe events; using var handles Dispose - try { - emulatorProcess.CancelOutputRead (); - emulatorProcess.CancelErrorRead (); - } catch (InvalidOperationException e) { - // Async reads may not have been started or process already exited - Log.LogDebugMessage ($"Failed to cancel async reads: {e}"); - } - emulatorProcess.OutputDataReceived -= EmulatorOutputDataReceived; - emulatorProcess.ErrorDataReceived -= EmulatorErrorDataReceived; - } + return extraArgs.Split ([' '], StringSplitOptions.RemoveEmptyEntries); } /// @@ -193,262 +185,4 @@ string ResolveEmulatorPath () return dir.IsNullOrEmpty () ? exe : Path.Combine (dir, exe); } - - /// - /// Checks whether the given deviceId is currently listed as an online device in 'adb devices'. - /// - protected virtual bool IsOnlineAdbDevice (string adbPath, string deviceId) - { - bool found = false; - - MonoAndroidHelper.RunProcess ( - adbPath, "devices", - Log, - onOutput: (sender, e) => { - if (e.Data != null && e.Data.Contains ("device") && !e.Data.Contains ("List of devices")) { - var parts = e.Data.Split (['\t', ' '], StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 2 && parts [1] == "device" && - string.Equals (parts [0], deviceId, StringComparison.OrdinalIgnoreCase)) { - found = true; - } - } - }, - logWarningOnFailure: false - ); - - return found; - } - - /// - /// Checks if an emulator with the specified AVD name is already running by querying - /// 'adb devices' and then 'adb -s serial emu avd name' for each running emulator. - /// - protected virtual string? FindRunningEmulatorForAvd (string adbPath, string avdName) - { - var emulatorSerials = new List (); - - MonoAndroidHelper.RunProcess ( - adbPath, "devices", - Log, - onOutput: (sender, e) => { - if (e.Data != null && e.Data.StartsWith ("emulator-", StringComparison.OrdinalIgnoreCase) && e.Data.Contains ("device")) { - var parts = e.Data.Split (['\t', ' '], StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 2 && parts [1] == "device") { - emulatorSerials.Add (parts [0]); - } - } - }, - logWarningOnFailure: false - ); - - foreach (var serial in emulatorSerials) { - var name = GetRunningAvdName (adbPath, serial); - if (string.Equals (name, avdName, StringComparison.OrdinalIgnoreCase)) { - return serial; - } - } - - return null; - } - - /// - /// Gets the AVD name from a running emulator via 'adb -s serial emu avd name'. - /// - protected virtual string? GetRunningAvdName (string adbPath, string serial) - { - string? avdName = null; - try { - var outputLines = new List (); - MonoAndroidHelper.RunProcess ( - adbPath, $"-s {serial} emu avd name", - Log, - onOutput: (sender, e) => { - if (!e.Data.IsNullOrEmpty ()) { - outputLines.Add (e.Data); - } - }, - logWarningOnFailure: false - ); - - if (outputLines.Count > 0) { - var name = outputLines [0].Trim (); - if (!name.IsNullOrEmpty () && !name.Equals ("OK", StringComparison.OrdinalIgnoreCase)) { - avdName = name; - } - } - } catch (Exception ex) { - Log.LogDebugMessage ($"Failed to get AVD name for {serial}: {ex.Message}"); - } - - return avdName; - } - - /// - /// Launches the emulator process in the background. The emulator window is shown by default, - /// but this can be customized (for example, by passing -no-window) via EmulatorExtraArguments. - /// - protected virtual Process? LaunchEmulatorProcess (string emulatorPath, string avdName) - { - var arguments = $"-avd \"{avdName}\""; - if (!EmulatorExtraArguments.IsNullOrEmpty ()) { - arguments += $" {EmulatorExtraArguments}"; - } - - Log.LogMessage (MessageImportance.Normal, $"Starting: {emulatorPath} {arguments}"); - - try { - var psi = new ProcessStartInfo { - FileName = emulatorPath, - Arguments = arguments, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - - var process = new Process { StartInfo = psi }; - - // Capture output for diagnostics but don't block on it - process.OutputDataReceived += EmulatorOutputDataReceived; - process.ErrorDataReceived += EmulatorErrorDataReceived; - - process.Start (); - process.BeginOutputReadLine (); - process.BeginErrorReadLine (); - - return process; - } catch (Exception ex) { - Log.LogCodedError ("XA0143", Properties.Resources.XA0143, avdName, ex.Message); - return null; - } - } - - void EmulatorOutputDataReceived (object sender, DataReceivedEventArgs e) - { - if (e.Data != null) { - Log.LogDebugMessage ($"emulator stdout: {e.Data}"); - } - } - - void EmulatorErrorDataReceived (object sender, DataReceivedEventArgs e) - { - if (e.Data != null) { - Log.LogDebugMessage ($"emulator stderr: {e.Data}"); - } - } - - /// - /// Polls 'adb devices' until a new emulator serial appears with state "device" (online). - /// Returns the serial or null on timeout / emulator process exit. - /// - string? WaitForEmulatorOnline (string adbPath, string avdName, Process emulatorProcess, Stopwatch stopwatch, TimeSpan timeout) - { - while (stopwatch.Elapsed < timeout) { - if (emulatorProcess.HasExited) { - return null; - } - - var serial = FindRunningEmulatorForAvd (adbPath, avdName); - if (serial != null) { - return serial; - } - - Thread.Sleep (PollIntervalMilliseconds); - } - - return null; - } - - /// - /// Waits for the emulator to fully boot by checking: - /// 1. sys.boot_completed property equals "1" - /// 2. Package manager is responsive (pm path android returns "package:") - /// - bool WaitForFullBoot (string adbPath, string avdName, string serial) - { - Log.LogMessage (MessageImportance.Normal, "Waiting for emulator to fully boot..."); - var stopwatch = Stopwatch.StartNew (); - var timeout = TimeSpan.FromSeconds (BootTimeoutSeconds); - - // Phase 1: Wait for sys.boot_completed == 1 - while (stopwatch.Elapsed < timeout) { - var bootCompleted = GetShellProperty (adbPath, serial, "sys.boot_completed"); - if (bootCompleted == "1") { - Log.LogMessage (MessageImportance.Normal, "sys.boot_completed = 1"); - break; - } - - Thread.Sleep (PollIntervalMilliseconds); - } - - if (stopwatch.Elapsed >= timeout) { - Log.LogCodedError ("XA0145", Properties.Resources.XA0145, avdName, BootTimeoutSeconds); - return false; - } - - var remaining = timeout - stopwatch.Elapsed; - Log.LogMessage (MessageImportance.Normal, $"Phase 1 complete. {remaining.TotalSeconds:F0}s remaining for package manager."); - - // Phase 2: Wait for package manager to be responsive - while (stopwatch.Elapsed < timeout) { - var pmResult = RunShellCommand (adbPath, serial, "pm path android"); - if (pmResult != null && pmResult.StartsWith ("package:", StringComparison.OrdinalIgnoreCase)) { - Log.LogMessage (MessageImportance.High, $"Emulator '{avdName}' ({serial}) is fully booted and ready."); - return true; - } - - Thread.Sleep (PollIntervalMilliseconds); - } - - Log.LogCodedError ("XA0145", Properties.Resources.XA0145, avdName, BootTimeoutSeconds); - return false; - } - - /// - /// Gets a system property from the device via 'adb -s serial shell getprop property'. - /// - protected virtual string? GetShellProperty (string adbPath, string serial, string propertyName) - { - string? value = null; - try { - MonoAndroidHelper.RunProcess ( - adbPath, $"-s {serial} shell getprop {propertyName}", - Log, - onOutput: (sender, e) => { - if (!e.Data.IsNullOrEmpty ()) { - value = e.Data.Trim (); - } - }, - logWarningOnFailure: false - ); - } catch (Exception ex) { - Log.LogDebugMessage ($"Failed to get property '{propertyName}' from {serial}: {ex.Message}"); - } - - return value; - } - - /// - /// Runs a shell command on the device and returns the first line of output. - /// - protected virtual string? RunShellCommand (string adbPath, string serial, string command) - { - string? result = null; - try { - MonoAndroidHelper.RunProcess ( - adbPath, $"-s {serial} shell {command}", - Log, - onOutput: (sender, e) => { - if (result == null && !e.Data.IsNullOrEmpty ()) { - result = e.Data.Trim (); - } - }, - logWarningOnFailure: false - ); - } catch (Exception ex) { - Log.LogDebugMessage ($"Failed to run shell command '{command}' on {serial}: {ex.Message}"); - } - - return result; - } } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs index 58ab1ae095e..9eb4b15c819 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs @@ -8,6 +8,7 @@ using Microsoft.Build.Utilities; using NUnit.Framework; using Xamarin.Android.Tasks; +using Xamarin.Android.Tools; namespace Xamarin.Android.Build.Tests; @@ -26,73 +27,23 @@ public void Setup () } /// - /// Mock version of BootAndroidEmulator that overrides all process-dependent methods - /// so we can test the task logic without launching real emulators or adb. + /// Mock version of BootAndroidEmulator that overrides + /// to return a configurable without launching real processes. /// class MockBootAndroidEmulator : BootAndroidEmulator { - public HashSet OnlineDevices { get; set; } = []; - public Dictionary RunningEmulatorAvdNames { get; set; } = new (); - public Dictionary EmulatorBootBehavior { get; set; } = new (); - public Dictionary BootCompletedValues { get; set; } = new (); - public Dictionary PmPathResults { get; set; } = new (); - public bool SimulateLaunchFailure { get; set; } - public string? LastLaunchAvdName { get; private set; } - - readonly Dictionary findCallCounts = new (); - - protected override bool IsOnlineAdbDevice (string adbPath, string deviceId) - => OnlineDevices.Contains (deviceId); - - protected override string? FindRunningEmulatorForAvd (string adbPath, string avdName) + public EmulatorBootResult BootResult { get; set; } = new () { Success = true, Serial = "emulator-5554" }; + public string? LastBootedDevice { get; private set; } + + protected override EmulatorBootResult ExecuteBoot ( + string adbPath, + string emulatorPath, + Action logger, + string device, + EmulatorBootOptions options) { - foreach (var kvp in RunningEmulatorAvdNames) { - if (string.Equals (kvp.Value, avdName, StringComparison.OrdinalIgnoreCase) && - OnlineDevices.Contains (kvp.Key)) { - return kvp.Key; - } - } - - if (EmulatorBootBehavior.TryGetValue (avdName, out var behavior)) { - findCallCounts.TryAdd (avdName, 0); - findCallCounts [avdName]++; - if (findCallCounts [avdName] >= behavior.PollsUntilOnline) { - OnlineDevices.Add (behavior.Serial); - RunningEmulatorAvdNames [behavior.Serial] = avdName; - return behavior.Serial; - } - } - - return null; - } - - protected override string? GetRunningAvdName (string adbPath, string serial) - => RunningEmulatorAvdNames.TryGetValue (serial, out var name) ? name : null; - - protected override Process? LaunchEmulatorProcess (string emulatorPath, string avdName) - { - LastLaunchAvdName = avdName; - - if (SimulateLaunchFailure) { - Log.LogError ("XA0143: Failed to launch emulator for AVD '{0}': {1}", avdName, "Simulated launch failure"); - return null; - } - - return Process.GetCurrentProcess (); - } - - protected override string? GetShellProperty (string adbPath, string serial, string propertyName) - { - if (propertyName == "sys.boot_completed" && BootCompletedValues.TryGetValue (serial, out var value)) - return value; - return null; - } - - protected override string? RunShellCommand (string adbPath, string serial, string command) - { - if (command == "pm path android" && PmPathResults.TryGetValue (serial, out var result)) - return result; - return null; + LastBootedDevice = device; + return BootResult; } } @@ -112,8 +63,12 @@ MockBootAndroidEmulator CreateTask (string device = "Pixel_6_API_33") [Test] public void AlreadyOnlineDevice_PassesThrough () { + // BootEmulatorAsync returns success immediately (device is already online) var task = CreateTask ("emulator-5554"); - task.OnlineDevices = ["emulator-5554"]; + task.BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5554", + }; Assert.IsTrue (task.RunTask (), "RunTask should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); @@ -125,7 +80,10 @@ public void AlreadyOnlineDevice_PassesThrough () public void AlreadyOnlinePhysicalDevice_PassesThrough () { var task = CreateTask ("0A041FDD400327"); - task.OnlineDevices = ["0A041FDD400327"]; + task.BootResult = new EmulatorBootResult { + Success = true, + Serial = "0A041FDD400327", + }; Assert.IsTrue (task.RunTask (), "RunTask should succeed"); Assert.AreEqual ("0A041FDD400327", task.ResolvedDevice); @@ -136,73 +94,54 @@ public void AlreadyOnlinePhysicalDevice_PassesThrough () public void AvdAlreadyRunning_WaitsForFullBoot () { var task = CreateTask ("Pixel_6_API_33"); - task.OnlineDevices = ["emulator-5554"]; - task.RunningEmulatorAvdNames = new () { - { "emulator-5554", "Pixel_6_API_33" } + task.BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5554", }; - task.BootCompletedValues = new () { { "emulator-5554", "1" } }; - task.PmPathResults = new () { { "emulator-5554", "package:/system/framework/framework-res.apk" } }; Assert.IsTrue (task.RunTask (), "RunTask should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5554", task.AdbTarget); + Assert.AreEqual ("Pixel_6_API_33", task.LastBootedDevice); } [Test] public void BootEmulator_AppearsAfterPolling () { var task = CreateTask ("Pixel_6_API_33"); - // Not online initially, will appear after 2 polls - task.EmulatorBootBehavior = new () { - { "Pixel_6_API_33", ("emulator-5556", 2) } + task.BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5556", }; - task.BootCompletedValues = new () { { "emulator-5556", "1" } }; - task.PmPathResults = new () { { "emulator-5556", "package:/system/framework/framework-res.apk" } }; Assert.IsTrue (task.RunTask (), "RunTask should succeed"); Assert.AreEqual ("emulator-5556", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5556", task.AdbTarget); - Assert.AreEqual ("Pixel_6_API_33", task.LastLaunchAvdName); + Assert.AreEqual ("Pixel_6_API_33", task.LastBootedDevice); } [Test] public void LaunchFailure_ReturnsError () { var task = CreateTask ("Pixel_6_API_33"); - task.SimulateLaunchFailure = true; - - Assert.IsFalse (task.RunTask (), "RunTask should fail"); - Assert.IsTrue (errors.Any (e => e.Message != null && e.Message.Contains ("XA0143")), "Should have XA0143 error"); - Assert.IsNull (task.ResolvedDevice, "ResolvedDevice should be null"); - } - - [Test] - public void BootTimeout_BootCompletedNeverReaches1 () - { - var task = CreateTask ("Pixel_6_API_33"); - task.BootTimeoutSeconds = 0; // Immediate timeout - // Emulator appears immediately but never finishes booting - task.OnlineDevices = ["emulator-5554"]; - task.RunningEmulatorAvdNames = new () { - { "emulator-5554", "Pixel_6_API_33" } + task.BootResult = new EmulatorBootResult { + Success = false, + ErrorMessage = "Failed to launch emulator: Simulated launch failure", }; - task.BootCompletedValues = new () { { "emulator-5554", "0" } }; Assert.IsFalse (task.RunTask (), "RunTask should fail"); - Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Should have XA0145 timeout error"); + Assert.IsTrue (errors.Any (e => e.Code == "XA0143"), "Should have XA0143 error"); + Assert.IsNull (task.ResolvedDevice, "ResolvedDevice should be null"); } [Test] - public void BootTimeout_PmNeverResponds () + public void BootTimeout_ReturnsError () { var task = CreateTask ("Pixel_6_API_33"); - task.BootTimeoutSeconds = 0; // Immediate timeout - task.OnlineDevices = ["emulator-5554"]; - task.RunningEmulatorAvdNames = new () { - { "emulator-5554", "Pixel_6_API_33" } + task.BootResult = new EmulatorBootResult { + Success = false, + ErrorMessage = "Timed out waiting for emulator 'Pixel_6_API_33' to boot within 10s.", }; - task.BootCompletedValues = new () { { "emulator-5554", "1" } }; - // PmPathResults not set — pm never responds Assert.IsFalse (task.RunTask (), "RunTask should fail"); Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Should have XA0145 timeout error"); @@ -212,13 +151,10 @@ public void BootTimeout_PmNeverResponds () public void MultipleEmulators_FindsCorrectAvd () { var task = CreateTask ("Pixel_9_Pro_XL"); - task.OnlineDevices = ["emulator-5554", "emulator-5556"]; - task.RunningEmulatorAvdNames = new () { - { "emulator-5554", "pixel_7_-_api_35" }, - { "emulator-5556", "Pixel_9_Pro_XL" } + task.BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5556", }; - task.BootCompletedValues = new () { { "emulator-5556", "1" } }; - task.PmPathResults = new () { { "emulator-5556", "package:/system/framework/framework-res.apk" } }; Assert.IsTrue (task.RunTask (), "RunTask should succeed"); Assert.AreEqual ("emulator-5556", task.ResolvedDevice); @@ -233,12 +169,49 @@ public void ToolPaths_ResolvedFromAndroidSdkDirectory () Device = "emulator-5554", AndroidSdkDirectory = "/android/sdk", BootTimeoutSeconds = 10, + BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5554", + }, }; - task.OnlineDevices = ["emulator-5554"]; - // Tool paths are not set explicitly — ResolveAdbPath/ResolveEmulatorPath - // should compute them from AndroidSdkDirectory Assert.IsTrue (task.RunTask (), "RunTask should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); } + + [Test] + public void ExtraArguments_PassedToOptions () + { + string[]? capturedArgs = null; + var task = new MockBootAndroidEmulator { + BuildEngine = engine, + Device = "Pixel_6_API_33", + EmulatorToolPath = "/sdk/emulator/", + EmulatorToolExe = "emulator", + AdbToolPath = "/sdk/platform-tools/", + AdbToolExe = "adb", + BootTimeoutSeconds = 10, + EmulatorExtraArguments = "-no-snapshot-load -gpu auto", + BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5554", + }, + }; + + Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.AreEqual ("emulator-5554", task.ResolvedDevice); + } + + [Test] + public void UnknownError_MapsToXA0145 () + { + var task = CreateTask ("Pixel_6_API_33"); + task.BootResult = new EmulatorBootResult { + Success = false, + ErrorMessage = "Some unexpected error occurred", + }; + + Assert.IsFalse (task.RunTask (), "RunTask should fail"); + Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Unknown errors should map to XA0145"); + } } From 68718e48e3f04a33bcb35ce265a1e3f066f1a258 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 18 Mar 2026 09:15:20 +0000 Subject: [PATCH 02/14] Convert BootAndroidEmulator to AsyncTask, use EmulatorBootErrorKind enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed base class from AndroidTask to AsyncTask - Override RunTaskAsync() instead of RunTask() - ExecuteBoot → ExecuteBootAsync with CancellationToken parameter - Replace string-matching error classification with switch on ErrorKind enum - Update tests for async pattern (Execute() instead of RunTask()) - Add LastBootOptions capture + assertion for ExtraArguments test - Set ErrorKind on test BootResult data (LaunchFailed, Timeout, Unknown) - Update submodule to feature/emulator-runner with EmulatorBootErrorKind Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/BootAndroidEmulator.cs | 39 +++++++-------- .../Tasks/BootAndroidEmulatorTests.cs | 49 +++++++++++++------ 2 files changed, 51 insertions(+), 37 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs index 62ca7fc79a1..cf340e28510 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics; using System.IO; +using System.Threading.Tasks; using Microsoft.Android.Build.Tasks; using Microsoft.Build.Framework; using Xamarin.Android.Tools; @@ -26,7 +27,7 @@ namespace Xamarin.Android.Tasks; /// Boot logic is delegated to and /// in Xamarin.Android.Tools.AndroidSdk. /// -public class BootAndroidEmulator : AndroidTask +public class BootAndroidEmulator : AsyncTask { const int DefaultBootTimeoutSeconds = 300; @@ -92,7 +93,7 @@ public class BootAndroidEmulator : AndroidTask [Output] public string? AdbTarget { get; set; } - public override bool RunTask () + public override async Task RunTaskAsync () { var adbPath = ResolveAdbPath (); var emulatorPath = ResolveEmulatorPath (); @@ -103,55 +104,51 @@ public override bool RunTask () AdditionalArgs = ParseExtraArguments (EmulatorExtraArguments), }; - var result = ExecuteBoot (adbPath, emulatorPath, logger, Device, options); + var result = await ExecuteBootAsync (adbPath, emulatorPath, logger, Device, options, CancellationToken).ConfigureAwait (false); if (result.Success) { ResolvedDevice = result.Serial; AdbTarget = $"-s {result.Serial}"; Log.LogMessage (MessageImportance.High, $"Emulator '{Device}' ({result.Serial}) is fully booted and ready."); - return true; + return; } - // Map the error message to the appropriate MSBuild error code - var errorMessage = result.ErrorMessage ?? "Unknown error"; - - if (errorMessage.Contains ("Failed to launch")) { - Log.LogCodedError ("XA0143", Properties.Resources.XA0143, Device, errorMessage); - } else if (errorMessage.Contains ("Timed out")) { - Log.LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); - } else { + switch (result.ErrorKind) { + case EmulatorBootErrorKind.LaunchFailed: + Log.LogCodedError ("XA0143", Properties.Resources.XA0143, Device, result.ErrorMessage ?? "Unknown launch error"); + break; + default: Log.LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); + break; } - - return false; } /// /// Executes the full boot flow via . /// Virtual so tests can return canned results without launching real processes. /// - protected virtual EmulatorBootResult ExecuteBoot ( + protected virtual async Task ExecuteBootAsync ( string adbPath, string emulatorPath, Action logger, string device, - EmulatorBootOptions options) + EmulatorBootOptions options, + System.Threading.CancellationToken cancellationToken) { var adbRunner = new AdbRunner (adbPath, logger: logger); var emulatorRunner = new EmulatorRunner (emulatorPath, logger: logger); - return emulatorRunner.BootEmulatorAsync (device, adbRunner, options) - .GetAwaiter ().GetResult (); + return await emulatorRunner.BootEmulatorAsync (device, adbRunner, options, cancellationToken).ConfigureAwait (false); } /// - /// Parses space-separated extra arguments into an array suitable for . + /// Parses space-separated extra arguments into a list suitable for . /// - static string[]? ParseExtraArguments (string? extraArgs) + static List? ParseExtraArguments (string? extraArgs) { if (extraArgs.IsNullOrEmpty ()) return null; - return extraArgs.Split ([' '], StringSplitOptions.RemoveEmptyEntries); + return new List (extraArgs.Split ([' '], StringSplitOptions.RemoveEmptyEntries)); } /// diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs index 9eb4b15c819..a0300d28614 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs @@ -4,6 +4,8 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using NUnit.Framework; @@ -27,23 +29,26 @@ public void Setup () } /// - /// Mock version of BootAndroidEmulator that overrides + /// Mock version of BootAndroidEmulator that overrides /// to return a configurable without launching real processes. /// class MockBootAndroidEmulator : BootAndroidEmulator { public EmulatorBootResult BootResult { get; set; } = new () { Success = true, Serial = "emulator-5554" }; public string? LastBootedDevice { get; private set; } + public EmulatorBootOptions? LastBootOptions { get; private set; } - protected override EmulatorBootResult ExecuteBoot ( + protected override Task ExecuteBootAsync ( string adbPath, string emulatorPath, Action logger, string device, - EmulatorBootOptions options) + EmulatorBootOptions options, + CancellationToken cancellationToken) { LastBootedDevice = device; - return BootResult; + LastBootOptions = options; + return Task.FromResult (BootResult); } } @@ -60,17 +65,21 @@ MockBootAndroidEmulator CreateTask (string device = "Pixel_6_API_33") }; } + bool RunTaskSynchronously (MockBootAndroidEmulator task) + { + return task.Execute (); + } + [Test] public void AlreadyOnlineDevice_PassesThrough () { - // BootEmulatorAsync returns success immediately (device is already online) var task = CreateTask ("emulator-5554"); task.BootResult = new EmulatorBootResult { Success = true, Serial = "emulator-5554", }; - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5554", task.AdbTarget); Assert.AreEqual (0, errors.Count, "Should have no errors"); @@ -85,7 +94,7 @@ public void AlreadyOnlinePhysicalDevice_PassesThrough () Serial = "0A041FDD400327", }; - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); Assert.AreEqual ("0A041FDD400327", task.ResolvedDevice); Assert.AreEqual ("-s 0A041FDD400327", task.AdbTarget); } @@ -99,7 +108,7 @@ public void AvdAlreadyRunning_WaitsForFullBoot () Serial = "emulator-5554", }; - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5554", task.AdbTarget); Assert.AreEqual ("Pixel_6_API_33", task.LastBootedDevice); @@ -114,7 +123,7 @@ public void BootEmulator_AppearsAfterPolling () Serial = "emulator-5556", }; - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); Assert.AreEqual ("emulator-5556", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5556", task.AdbTarget); Assert.AreEqual ("Pixel_6_API_33", task.LastBootedDevice); @@ -126,10 +135,11 @@ public void LaunchFailure_ReturnsError () var task = CreateTask ("Pixel_6_API_33"); task.BootResult = new EmulatorBootResult { Success = false, + ErrorKind = EmulatorBootErrorKind.LaunchFailed, ErrorMessage = "Failed to launch emulator: Simulated launch failure", }; - Assert.IsFalse (task.RunTask (), "RunTask should fail"); + Assert.IsFalse (RunTaskSynchronously (task), "Task should fail"); Assert.IsTrue (errors.Any (e => e.Code == "XA0143"), "Should have XA0143 error"); Assert.IsNull (task.ResolvedDevice, "ResolvedDevice should be null"); } @@ -140,10 +150,11 @@ public void BootTimeout_ReturnsError () var task = CreateTask ("Pixel_6_API_33"); task.BootResult = new EmulatorBootResult { Success = false, + ErrorKind = EmulatorBootErrorKind.Timeout, ErrorMessage = "Timed out waiting for emulator 'Pixel_6_API_33' to boot within 10s.", }; - Assert.IsFalse (task.RunTask (), "RunTask should fail"); + Assert.IsFalse (RunTaskSynchronously (task), "Task should fail"); Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Should have XA0145 timeout error"); } @@ -156,7 +167,7 @@ public void MultipleEmulators_FindsCorrectAvd () Serial = "emulator-5556", }; - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); Assert.AreEqual ("emulator-5556", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5556", task.AdbTarget); } @@ -175,14 +186,13 @@ public void ToolPaths_ResolvedFromAndroidSdkDirectory () }, }; - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); } [Test] public void ExtraArguments_PassedToOptions () { - string[]? capturedArgs = null; var task = new MockBootAndroidEmulator { BuildEngine = engine, Device = "Pixel_6_API_33", @@ -198,8 +208,14 @@ public void ExtraArguments_PassedToOptions () }, }; - Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); + Assert.IsNotNull (task.LastBootOptions, "Boot options should be captured"); + Assert.IsNotNull (task.LastBootOptions!.AdditionalArgs, "AdditionalArgs should not be null"); + CollectionAssert.AreEqual ( + new[] { "-no-snapshot-load", "-gpu", "auto" }, + task.LastBootOptions.AdditionalArgs, + "Extra arguments should be parsed and passed to options"); } [Test] @@ -208,10 +224,11 @@ public void UnknownError_MapsToXA0145 () var task = CreateTask ("Pixel_6_API_33"); task.BootResult = new EmulatorBootResult { Success = false, + ErrorKind = EmulatorBootErrorKind.Unknown, ErrorMessage = "Some unexpected error occurred", }; - Assert.IsFalse (task.RunTask (), "RunTask should fail"); + Assert.IsFalse (RunTaskSynchronously (task), "Task should fail"); Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Unknown errors should map to XA0145"); } } From ab7b42c24a00802c517e211845e459acadafb142 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 18 Mar 2026 12:38:44 +0000 Subject: [PATCH 03/14] Add missing System.Collections.Generic using in BootAndroidEmulator The file uses List but was missing the using directive, causing CS0246 on the netstandard2.0 target. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs index cf340e28510..b2eecd104c2 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Threading.Tasks; From 91ed4803df9e1e5383227b2fc489431e14970ad6 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 18 Mar 2026 14:05:09 +0000 Subject: [PATCH 04/14] Fix ambiguous Task reference in BootAndroidEmulatorTests Fully qualify Task.FromResult to resolve CS0104 ambiguity between Microsoft.Build.Utilities.Task and System.Threading.Tasks.Task. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/BootAndroidEmulatorTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs index a0300d28614..2ca225c35ee 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs @@ -48,7 +48,7 @@ protected override Task ExecuteBootAsync ( { LastBootedDevice = device; LastBootOptions = options; - return Task.FromResult (BootResult); + return System.Threading.Tasks.Task.FromResult (BootResult); } } From 94e2b8607cfd9c154117507ba5d9a9398b18242e Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 19 Mar 2026 15:05:05 +0000 Subject: [PATCH 05/14] Address review feedback: quoted arg parsing, remove test helper - ParseExtraArguments now handles double-quoted segments so values with embedded spaces (e.g. -skin "Nexus 5X") are preserved as single tokens - Remove RunTaskSynchronously wrapper, call task.Execute() directly - Add ExtraArguments_QuotedValuesPreserved test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/BootAndroidEmulator.cs | 27 +++++++++- .../Tasks/BootAndroidEmulatorTests.cs | 51 +++++++++++++------ 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs index b2eecd104c2..5bf734477a7 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -142,14 +142,37 @@ protected virtual async Task ExecuteBootAsync ( } /// - /// Parses space-separated extra arguments into a list suitable for . + /// Parses extra arguments into a list suitable for . + /// Supports double-quoted segments to allow values with embedded spaces (e.g. -gpu "swiftshader_indirect"). /// static List? ParseExtraArguments (string? extraArgs) { if (extraArgs.IsNullOrEmpty ()) return null; - return new List (extraArgs.Split ([' '], StringSplitOptions.RemoveEmptyEntries)); + var args = new List (); + var current = new System.Text.StringBuilder (); + bool inQuotes = false; + + for (int i = 0; i < extraArgs.Length; i++) { + char c = extraArgs [i]; + + if (c == '"') { + inQuotes = !inQuotes; + } else if (c == ' ' && !inQuotes) { + if (current.Length > 0) { + args.Add (current.ToString ()); + current.Clear (); + } + } else { + current.Append (c); + } + } + + if (current.Length > 0) + args.Add (current.ToString ()); + + return args.Count > 0 ? args : null; } /// diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs index 2ca225c35ee..f1a6b0f871d 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs @@ -65,11 +65,6 @@ MockBootAndroidEmulator CreateTask (string device = "Pixel_6_API_33") }; } - bool RunTaskSynchronously (MockBootAndroidEmulator task) - { - return task.Execute (); - } - [Test] public void AlreadyOnlineDevice_PassesThrough () { @@ -79,7 +74,7 @@ public void AlreadyOnlineDevice_PassesThrough () Serial = "emulator-5554", }; - Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); + Assert.IsTrue (task.Execute (), "Task should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5554", task.AdbTarget); Assert.AreEqual (0, errors.Count, "Should have no errors"); @@ -94,7 +89,7 @@ public void AlreadyOnlinePhysicalDevice_PassesThrough () Serial = "0A041FDD400327", }; - Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); + Assert.IsTrue (task.Execute (), "Task should succeed"); Assert.AreEqual ("0A041FDD400327", task.ResolvedDevice); Assert.AreEqual ("-s 0A041FDD400327", task.AdbTarget); } @@ -108,7 +103,7 @@ public void AvdAlreadyRunning_WaitsForFullBoot () Serial = "emulator-5554", }; - Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); + Assert.IsTrue (task.Execute (), "Task should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5554", task.AdbTarget); Assert.AreEqual ("Pixel_6_API_33", task.LastBootedDevice); @@ -123,7 +118,7 @@ public void BootEmulator_AppearsAfterPolling () Serial = "emulator-5556", }; - Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); + Assert.IsTrue (task.Execute (), "Task should succeed"); Assert.AreEqual ("emulator-5556", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5556", task.AdbTarget); Assert.AreEqual ("Pixel_6_API_33", task.LastBootedDevice); @@ -139,7 +134,7 @@ public void LaunchFailure_ReturnsError () ErrorMessage = "Failed to launch emulator: Simulated launch failure", }; - Assert.IsFalse (RunTaskSynchronously (task), "Task should fail"); + Assert.IsFalse (task.Execute (), "Task should fail"); Assert.IsTrue (errors.Any (e => e.Code == "XA0143"), "Should have XA0143 error"); Assert.IsNull (task.ResolvedDevice, "ResolvedDevice should be null"); } @@ -154,7 +149,7 @@ public void BootTimeout_ReturnsError () ErrorMessage = "Timed out waiting for emulator 'Pixel_6_API_33' to boot within 10s.", }; - Assert.IsFalse (RunTaskSynchronously (task), "Task should fail"); + Assert.IsFalse (task.Execute (), "Task should fail"); Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Should have XA0145 timeout error"); } @@ -167,7 +162,7 @@ public void MultipleEmulators_FindsCorrectAvd () Serial = "emulator-5556", }; - Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); + Assert.IsTrue (task.Execute (), "Task should succeed"); Assert.AreEqual ("emulator-5556", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5556", task.AdbTarget); } @@ -186,7 +181,7 @@ public void ToolPaths_ResolvedFromAndroidSdkDirectory () }, }; - Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); + Assert.IsTrue (task.Execute (), "Task should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); } @@ -208,7 +203,7 @@ public void ExtraArguments_PassedToOptions () }, }; - Assert.IsTrue (RunTaskSynchronously (task), "Task should succeed"); + Assert.IsTrue (task.Execute (), "Task should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); Assert.IsNotNull (task.LastBootOptions, "Boot options should be captured"); Assert.IsNotNull (task.LastBootOptions!.AdditionalArgs, "AdditionalArgs should not be null"); @@ -218,6 +213,32 @@ public void ExtraArguments_PassedToOptions () "Extra arguments should be parsed and passed to options"); } + [Test] + public void ExtraArguments_QuotedValuesPreserved () + { + var task = new MockBootAndroidEmulator { + BuildEngine = engine, + Device = "Pixel_6_API_33", + EmulatorToolPath = "/sdk/emulator/", + EmulatorToolExe = "emulator", + AdbToolPath = "/sdk/platform-tools/", + AdbToolExe = "adb", + BootTimeoutSeconds = 10, + EmulatorExtraArguments = "-no-snapshot-load -skin \"Nexus 5X\"", + BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5554", + }, + }; + + Assert.IsTrue (task.Execute (), "Task should succeed"); + Assert.IsNotNull (task.LastBootOptions?.AdditionalArgs, "AdditionalArgs should not be null"); + CollectionAssert.AreEqual ( + new[] { "-no-snapshot-load", "-skin", "Nexus 5X" }, + task.LastBootOptions!.AdditionalArgs, + "Quoted arguments with spaces should be preserved as a single token"); + } + [Test] public void UnknownError_MapsToXA0145 () { @@ -228,7 +249,7 @@ public void UnknownError_MapsToXA0145 () ErrorMessage = "Some unexpected error occurred", }; - Assert.IsFalse (RunTaskSynchronously (task), "Task should fail"); + Assert.IsFalse (task.Execute (), "Task should fail"); Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Unknown errors should map to XA0145"); } } From 97fad5d94fef0aea1ebec9b4d3c65946ceb8da03 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 19 Mar 2026 15:19:17 +0000 Subject: [PATCH 06/14] Remove null-forgiving operator usage from BootAndroidEmulator tests Extract LastBootOptions into local variables to avoid the banned Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/BootAndroidEmulatorTests.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs index f1a6b0f871d..51dc57fed2e 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs @@ -20,7 +20,7 @@ public class BootAndroidEmulatorTests : BaseTest List errors = []; List warnings = []; List messages = []; - MockBuildEngine engine = null!; + MockBuildEngine? engine; [SetUp] public void Setup () @@ -205,11 +205,12 @@ public void ExtraArguments_PassedToOptions () Assert.IsTrue (task.Execute (), "Task should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); - Assert.IsNotNull (task.LastBootOptions, "Boot options should be captured"); - Assert.IsNotNull (task.LastBootOptions!.AdditionalArgs, "AdditionalArgs should not be null"); + var bootOptions = task.LastBootOptions; + Assert.IsNotNull (bootOptions, "Boot options should be captured"); + Assert.IsNotNull (bootOptions.AdditionalArgs, "AdditionalArgs should not be null"); CollectionAssert.AreEqual ( new[] { "-no-snapshot-load", "-gpu", "auto" }, - task.LastBootOptions.AdditionalArgs, + bootOptions.AdditionalArgs, "Extra arguments should be parsed and passed to options"); } @@ -232,10 +233,11 @@ public void ExtraArguments_QuotedValuesPreserved () }; Assert.IsTrue (task.Execute (), "Task should succeed"); - Assert.IsNotNull (task.LastBootOptions?.AdditionalArgs, "AdditionalArgs should not be null"); + var bootOptions = task.LastBootOptions; + Assert.IsNotNull (bootOptions?.AdditionalArgs, "AdditionalArgs should not be null"); CollectionAssert.AreEqual ( new[] { "-no-snapshot-load", "-skin", "Nexus 5X" }, - task.LastBootOptions!.AdditionalArgs, + bootOptions?.AdditionalArgs, "Quoted arguments with spaces should be preserved as a single token"); } From 41186488882fe40f8f38f0f4f04ea37103f6adb1 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 19 Mar 2026 16:41:50 +0000 Subject: [PATCH 07/14] Address Copilot review feedback: null serial guard, explicit Cancelled handling, tool path assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard against null/empty Serial on success path (→ XA0145) - Handle EmulatorBootErrorKind.Cancelled explicitly (log message, no error) - Handle Timeout explicitly (separate from default) - Include ErrorMessage in diagnostics for Unknown errors - Capture LastAdbPath/LastEmulatorPath in mock, assert in ToolPaths test - Add tests: Cancelled_DoesNotLogError, Success_NullSerial_ReturnsError Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/BootAndroidEmulator.cs | 13 +++++++ .../Tasks/BootAndroidEmulatorTests.cs | 35 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs index 5bf734477a7..01f2f4201da 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -108,6 +108,10 @@ public override async Task RunTaskAsync () var result = await ExecuteBootAsync (adbPath, emulatorPath, logger, Device, options, CancellationToken).ConfigureAwait (false); if (result.Success) { + if (string.IsNullOrEmpty (result.Serial)) { + Log.LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); + return; + } ResolvedDevice = result.Serial; AdbTarget = $"-s {result.Serial}"; Log.LogMessage (MessageImportance.High, $"Emulator '{Device}' ({result.Serial}) is fully booted and ready."); @@ -118,8 +122,17 @@ public override async Task RunTaskAsync () case EmulatorBootErrorKind.LaunchFailed: Log.LogCodedError ("XA0143", Properties.Resources.XA0143, Device, result.ErrorMessage ?? "Unknown launch error"); break; + case EmulatorBootErrorKind.Cancelled: + Log.LogMessage (MessageImportance.High, $"Emulator boot for '{Device}' was cancelled."); + break; + case EmulatorBootErrorKind.Timeout: + Log.LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); + break; default: Log.LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); + if (!string.IsNullOrEmpty (result.ErrorMessage)) { + Log.LogMessage (MessageImportance.High, $"Error details: {result.ErrorMessage}"); + } break; } } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs index 51dc57fed2e..17f57882899 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs @@ -37,6 +37,8 @@ class MockBootAndroidEmulator : BootAndroidEmulator public EmulatorBootResult BootResult { get; set; } = new () { Success = true, Serial = "emulator-5554" }; public string? LastBootedDevice { get; private set; } public EmulatorBootOptions? LastBootOptions { get; private set; } + public string? LastAdbPath { get; private set; } + public string? LastEmulatorPath { get; private set; } protected override Task ExecuteBootAsync ( string adbPath, @@ -46,6 +48,8 @@ protected override Task ExecuteBootAsync ( EmulatorBootOptions options, CancellationToken cancellationToken) { + LastAdbPath = adbPath; + LastEmulatorPath = emulatorPath; LastBootedDevice = device; LastBootOptions = options; return System.Threading.Tasks.Task.FromResult (BootResult); @@ -183,6 +187,8 @@ public void ToolPaths_ResolvedFromAndroidSdkDirectory () Assert.IsTrue (task.Execute (), "Task should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); + StringAssert.EndsWith ("platform-tools/adb", task.LastAdbPath?.Replace ('\\', '/'), "adb path should be resolved from AndroidSdkDirectory"); + StringAssert.EndsWith ("emulator/emulator", task.LastEmulatorPath?.Replace ('\\', '/'), "emulator path should be resolved from AndroidSdkDirectory"); } [Test] @@ -253,5 +259,34 @@ public void UnknownError_MapsToXA0145 () Assert.IsFalse (task.Execute (), "Task should fail"); Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Unknown errors should map to XA0145"); + Assert.IsTrue (messages.Any (m => m.Message.Contains ("Some unexpected error occurred")), "Error details should be logged"); + } + + [Test] + public void Cancelled_DoesNotLogError () + { + var task = CreateTask ("Pixel_6_API_33"); + task.BootResult = new EmulatorBootResult { + Success = false, + ErrorKind = EmulatorBootErrorKind.Cancelled, + }; + + Assert.IsTrue (task.Execute (), "Cancelled task should not fail the build"); + Assert.AreEqual (0, errors.Count, "Cancelled should not produce errors"); + Assert.IsTrue (messages.Any (m => m.Message.Contains ("cancelled")), "Should log cancellation message"); + } + + [Test] + public void Success_NullSerial_ReturnsError () + { + var task = CreateTask ("Pixel_6_API_33"); + task.BootResult = new EmulatorBootResult { + Success = true, + Serial = null, + }; + + Assert.IsFalse (task.Execute (), "Task should fail when serial is null"); + Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Null serial should map to XA0145"); + Assert.IsNull (task.ResolvedDevice, "ResolvedDevice should be null"); } } From fe79ec0e0e67f3246ba739e889a8ed1120832f72 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 19 Mar 2026 17:00:41 +0000 Subject: [PATCH 08/14] Address multi-model review feedback for BootAndroidEmulator - Cancellation now throws OperationCanceledException instead of silently succeeding, so MSBuild properly stops the build. - Validate BootTimeoutSeconds > 0 before constructing options. - Guard against Success=true with null serial (maps to XA0143). - Use AsyncTask thread-safe LogCodedError/LogMessage helpers instead of Log.* from async context. - Remove orphaned XA0144 error code from Resources.resx/Designer.cs (no longer emitted since EmulatorRunner handles exit codes). - Document that ParseExtraArguments does not support escaped quotes. - Update tests: Cancelled_FailsTheBuild, InvalidTimeout_ReturnsError, Success_NullSerial now expects XA0143. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Properties/Resources.Designer.cs | 9 ------- .../Properties/Resources.resx | 5 ---- .../Tasks/BootAndroidEmulator.cs | 25 +++++++++++-------- .../Tasks/BootAndroidEmulatorTests.cs | 19 ++++++++++---- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs index d936677798b..343bfbcbd89 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs @@ -557,15 +557,6 @@ public static string XA0143 { } } - /// - /// Looks up a localized string similar to The Android emulator for AVD '{0}' exited unexpectedly with exit code {1} before becoming available.. - /// - public static string XA0144 { - get { - return ResourceManager.GetString("XA0144", resourceCulture); - } - } - /// /// Looks up a localized string similar to The Android emulator for AVD '{0}' did not finish booting within {1} seconds. Increase 'BootTimeoutSeconds' or check the emulator configuration.. /// diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx index bb5e38b7164..afac64e17a4 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx @@ -1077,11 +1077,6 @@ To use a custom JDK path for a command line build, set the 'JavaSdkDirectory' MS Failed to launch the Android emulator for AVD '{0}': {1} {0} - The AVD name. {1} - The exception message. - - - The Android emulator for AVD '{0}' exited unexpectedly with exit code {1} before becoming available. - {0} - The AVD name. -{1} - The process exit code. The Android emulator for AVD '{0}' did not finish booting within {1} seconds. Increase 'BootTimeoutSeconds' or check the emulator configuration. diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs index 01f2f4201da..9bb8c11ae9e 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -96,6 +96,11 @@ public class BootAndroidEmulator : AsyncTask public override async Task RunTaskAsync () { + if (BootTimeoutSeconds <= 0) { + LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); + return; + } + var adbPath = ResolveAdbPath (); var emulatorPath = ResolveEmulatorPath (); var logger = this.CreateTaskLogger (); @@ -108,30 +113,29 @@ public override async Task RunTaskAsync () var result = await ExecuteBootAsync (adbPath, emulatorPath, logger, Device, options, CancellationToken).ConfigureAwait (false); if (result.Success) { - if (string.IsNullOrEmpty (result.Serial)) { - Log.LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); + if (result.Serial.IsNullOrEmpty ()) { + LogCodedError ("XA0143", Properties.Resources.XA0143, Device, "Boot reported success but no device serial was returned."); return; } ResolvedDevice = result.Serial; AdbTarget = $"-s {result.Serial}"; - Log.LogMessage (MessageImportance.High, $"Emulator '{Device}' ({result.Serial}) is fully booted and ready."); + LogMessage ($"Emulator '{Device}' ({result.Serial}) is fully booted and ready."); return; } switch (result.ErrorKind) { case EmulatorBootErrorKind.LaunchFailed: - Log.LogCodedError ("XA0143", Properties.Resources.XA0143, Device, result.ErrorMessage ?? "Unknown launch error"); + LogCodedError ("XA0143", Properties.Resources.XA0143, Device, result.ErrorMessage ?? "Unknown launch error"); break; case EmulatorBootErrorKind.Cancelled: - Log.LogMessage (MessageImportance.High, $"Emulator boot for '{Device}' was cancelled."); - break; + throw new OperationCanceledException ($"Emulator boot for '{Device}' was cancelled."); case EmulatorBootErrorKind.Timeout: - Log.LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); + LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); break; default: - Log.LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); - if (!string.IsNullOrEmpty (result.ErrorMessage)) { - Log.LogMessage (MessageImportance.High, $"Error details: {result.ErrorMessage}"); + LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); + if (!result.ErrorMessage.IsNullOrEmpty ()) { + LogMessage ($"Error details: {result.ErrorMessage}"); } break; } @@ -157,6 +161,7 @@ protected virtual async Task ExecuteBootAsync ( /// /// Parses extra arguments into a list suitable for . /// Supports double-quoted segments to allow values with embedded spaces (e.g. -gpu "swiftshader_indirect"). + /// Escaped quotes (\") inside quoted values are not supported. /// static List? ParseExtraArguments (string? extraArgs) { diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs index 17f57882899..a1df349d530 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs @@ -263,7 +263,7 @@ public void UnknownError_MapsToXA0145 () } [Test] - public void Cancelled_DoesNotLogError () + public void Cancelled_FailsTheBuild () { var task = CreateTask ("Pixel_6_API_33"); task.BootResult = new EmulatorBootResult { @@ -271,9 +271,8 @@ public void Cancelled_DoesNotLogError () ErrorKind = EmulatorBootErrorKind.Cancelled, }; - Assert.IsTrue (task.Execute (), "Cancelled task should not fail the build"); - Assert.AreEqual (0, errors.Count, "Cancelled should not produce errors"); - Assert.IsTrue (messages.Any (m => m.Message.Contains ("cancelled")), "Should log cancellation message"); + Assert.IsFalse (task.Execute (), "Cancelled task should fail the build"); + Assert.IsNull (task.ResolvedDevice, "ResolvedDevice should be null on cancellation"); } [Test] @@ -286,7 +285,17 @@ public void Success_NullSerial_ReturnsError () }; Assert.IsFalse (task.Execute (), "Task should fail when serial is null"); - Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Null serial should map to XA0145"); + Assert.IsTrue (errors.Any (e => e.Code == "XA0143"), "Null serial should map to XA0143"); Assert.IsNull (task.ResolvedDevice, "ResolvedDevice should be null"); } + + [Test] + public void InvalidTimeout_ReturnsError () + { + var task = CreateTask ("Pixel_6_API_33"); + task.BootTimeoutSeconds = 0; + + Assert.IsFalse (task.Execute (), "Task should fail with zero timeout"); + Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Invalid timeout should produce XA0145 error"); + } } From 1b718c75a8e050886d045484bfc2d82e024b55e4 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 19 Mar 2026 17:12:38 +0000 Subject: [PATCH 09/14] Restore XA0144 for unexpected emulator errors Update XA0144 message format to accept the ErrorMessage from EmulatorRunner directly. The default switch case (Unknown and future error kinds) now uses XA0144 with the full error details instead of the misleading timeout message XA0145. Error code mapping: - XA0143: Launch failed (couldn't start emulator) - XA0144: Unexpected exit/error (process exited, unknown errors) - XA0145: Boot timeout (didn't finish in time) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Properties/Resources.Designer.cs | 9 +++++++++ .../Properties/Resources.resx | 5 +++++ .../Tasks/BootAndroidEmulator.cs | 5 +---- .../Tasks/BootAndroidEmulatorTests.cs | 5 ++--- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs index 343bfbcbd89..d8e2b8a2515 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs @@ -557,6 +557,15 @@ public static string XA0143 { } } + /// + /// Looks up a localized string similar to The Android emulator for AVD '{0}' exited unexpectedly before becoming available: {1}. + /// + public static string XA0144 { + get { + return ResourceManager.GetString("XA0144", resourceCulture); + } + } + /// /// Looks up a localized string similar to The Android emulator for AVD '{0}' did not finish booting within {1} seconds. Increase 'BootTimeoutSeconds' or check the emulator configuration.. /// diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx index afac64e17a4..6c5019e4cc4 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx @@ -1077,6 +1077,11 @@ To use a custom JDK path for a command line build, set the 'JavaSdkDirectory' MS Failed to launch the Android emulator for AVD '{0}': {1} {0} - The AVD name. {1} - The exception message. + + + The Android emulator for AVD '{0}' exited unexpectedly before becoming available: {1} + {0} - The AVD name. +{1} - Error details from the emulator runner. The Android emulator for AVD '{0}' did not finish booting within {1} seconds. Increase 'BootTimeoutSeconds' or check the emulator configuration. diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs index 9bb8c11ae9e..587cfca69e4 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -133,10 +133,7 @@ public override async Task RunTaskAsync () LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); break; default: - LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); - if (!result.ErrorMessage.IsNullOrEmpty ()) { - LogMessage ($"Error details: {result.ErrorMessage}"); - } + LogCodedError ("XA0144", Properties.Resources.XA0144, Device, result.ErrorMessage ?? "Unknown error"); break; } } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs index a1df349d530..43c0561647a 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs @@ -248,7 +248,7 @@ public void ExtraArguments_QuotedValuesPreserved () } [Test] - public void UnknownError_MapsToXA0145 () + public void UnknownError_MapsToXA0144 () { var task = CreateTask ("Pixel_6_API_33"); task.BootResult = new EmulatorBootResult { @@ -258,8 +258,7 @@ public void UnknownError_MapsToXA0145 () }; Assert.IsFalse (task.Execute (), "Task should fail"); - Assert.IsTrue (errors.Any (e => e.Code == "XA0145"), "Unknown errors should map to XA0145"); - Assert.IsTrue (messages.Any (m => m.Message.Contains ("Some unexpected error occurred")), "Error details should be logged"); + Assert.IsTrue (errors.Any (e => e.Code == "XA0144"), "Unknown errors should map to XA0144"); } [Test] From e6a16330e04e1a10269e3d7c1b9340564229df65 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 19 Mar 2026 17:18:58 +0000 Subject: [PATCH 10/14] Support escaped quotes in ParseExtraArguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle backslash-escaped quotes (\" → ") in the argument tokenizer, following the same pattern as ProcessArgumentBuilder in android-platform-support. This allows values like: -prop "persist.sys.timezone=\"America/New_York\"" Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/BootAndroidEmulator.cs | 7 +++-- .../Tasks/BootAndroidEmulatorTests.cs | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs index 587cfca69e4..abffe53f1e6 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -158,7 +158,7 @@ protected virtual async Task ExecuteBootAsync ( /// /// Parses extra arguments into a list suitable for . /// Supports double-quoted segments to allow values with embedded spaces (e.g. -gpu "swiftshader_indirect"). - /// Escaped quotes (\") inside quoted values are not supported. + /// Backslash-escaped quotes (\") inside quoted values are preserved as literal quote characters. /// static List? ParseExtraArguments (string? extraArgs) { @@ -172,7 +172,10 @@ protected virtual async Task ExecuteBootAsync ( for (int i = 0; i < extraArgs.Length; i++) { char c = extraArgs [i]; - if (c == '"') { + if (c == '\\' && i + 1 < extraArgs.Length && extraArgs [i + 1] == '"') { + current.Append ('"'); + i++; + } else if (c == '"') { inQuotes = !inQuotes; } else if (c == ' ' && !inQuotes) { if (current.Length > 0) { diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs index 43c0561647a..cc8d5aed861 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs @@ -247,6 +247,33 @@ public void ExtraArguments_QuotedValuesPreserved () "Quoted arguments with spaces should be preserved as a single token"); } + [Test] + public void ExtraArguments_EscapedQuotesPreserved () + { + var task = new MockBootAndroidEmulator { + BuildEngine = engine, + Device = "Pixel_6_API_33", + EmulatorToolPath = "/sdk/emulator/", + EmulatorToolExe = "emulator", + AdbToolPath = "/sdk/platform-tools/", + AdbToolExe = "adb", + BootTimeoutSeconds = 10, + EmulatorExtraArguments = "-prop \"persist.sys.timezone=\\\"America/New_York\\\"\"", + BootResult = new EmulatorBootResult { + Success = true, + Serial = "emulator-5554", + }, + }; + + Assert.IsTrue (task.Execute (), "Task should succeed"); + var bootOptions = task.LastBootOptions; + Assert.IsNotNull (bootOptions?.AdditionalArgs, "AdditionalArgs should not be null"); + CollectionAssert.AreEqual ( + new[] { "-prop", "persist.sys.timezone=\"America/New_York\"" }, + bootOptions?.AdditionalArgs, + "Escaped quotes inside quoted values should be preserved as literal quotes"); + } + [Test] public void UnknownError_MapsToXA0144 () { From 96a6375bb479df36296135e50a6ddd327c04207b Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 19 Mar 2026 18:52:49 +0000 Subject: [PATCH 11/14] Include both error kind and message in XA0144 Per review feedback from @jonathanpeppers, XA0144 now includes both the EmulatorBootErrorKind (e.g. Unknown) and the ErrorMessage from the emulator runner, giving more context for debugging. Format: "The Android emulator for AVD '{0}' failed with error '{1}': {2}" Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Properties/Resources.Designer.cs | 2 +- src/Xamarin.Android.Build.Tasks/Properties/Resources.resx | 5 +++-- src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs | 2 +- .../Tasks/BootAndroidEmulatorTests.cs | 5 ++++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs index d8e2b8a2515..bc2d9a7d4d4 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs @@ -558,7 +558,7 @@ public static string XA0143 { } /// - /// Looks up a localized string similar to The Android emulator for AVD '{0}' exited unexpectedly before becoming available: {1}. + /// Looks up a localized string similar to The Android emulator for AVD '{0}' failed with error '{1}': {2}. /// public static string XA0144 { get { diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx index 6c5019e4cc4..d1ee02dc051 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx @@ -1079,9 +1079,10 @@ To use a custom JDK path for a command line build, set the 'JavaSdkDirectory' MS {1} - The exception message. - The Android emulator for AVD '{0}' exited unexpectedly before becoming available: {1} + The Android emulator for AVD '{0}' failed with error '{1}': {2} {0} - The AVD name. -{1} - Error details from the emulator runner. +{1} - The error kind (e.g. Unknown). +{2} - Error details from the emulator runner. The Android emulator for AVD '{0}' did not finish booting within {1} seconds. Increase 'BootTimeoutSeconds' or check the emulator configuration. diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs index abffe53f1e6..6febbbd8dc8 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -133,7 +133,7 @@ public override async Task RunTaskAsync () LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); break; default: - LogCodedError ("XA0144", Properties.Resources.XA0144, Device, result.ErrorMessage ?? "Unknown error"); + LogCodedError ("XA0144", Properties.Resources.XA0144, Device, result.ErrorKind, result.ErrorMessage ?? "Unknown error"); break; } } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs index cc8d5aed861..dc4e4818575 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs @@ -285,7 +285,10 @@ public void UnknownError_MapsToXA0144 () }; Assert.IsFalse (task.Execute (), "Task should fail"); - Assert.IsTrue (errors.Any (e => e.Code == "XA0144"), "Unknown errors should map to XA0144"); + var error = errors.FirstOrDefault (e => e.Code == "XA0144"); + Assert.IsNotNull (error, "Unknown errors should map to XA0144"); + StringAssert.Contains ("Unknown", error.Message, "Error kind should be included in the message"); + StringAssert.Contains ("Some unexpected error occurred", error.Message, "Error message should be included"); } [Test] From 85c54d134b8fabcff93fda2e1a0857e95df19f20 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 19 Mar 2026 19:17:13 +0000 Subject: [PATCH 12/14] Address review feedback: clean cancellation, whitespace parsing, fix description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cancellation: log informational message instead of throwing OperationCanceledException (avoids noisy stack traces in MSBuild) - ParseExtraArguments: use char.IsWhiteSpace() instead of literal space to handle tabs/newlines from MSBuild properties - Updated PR description: Unknown → XA0144 (not XA0145) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs | 5 +++-- .../Tasks/BootAndroidEmulatorTests.cs | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs index 6febbbd8dc8..afbe8ff919b 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -128,7 +128,8 @@ public override async Task RunTaskAsync () LogCodedError ("XA0143", Properties.Resources.XA0143, Device, result.ErrorMessage ?? "Unknown launch error"); break; case EmulatorBootErrorKind.Cancelled: - throw new OperationCanceledException ($"Emulator boot for '{Device}' was cancelled."); + LogMessage ($"Emulator boot for '{Device}' was cancelled."); + break; case EmulatorBootErrorKind.Timeout: LogCodedError ("XA0145", Properties.Resources.XA0145, Device, BootTimeoutSeconds); break; @@ -177,7 +178,7 @@ protected virtual async Task ExecuteBootAsync ( i++; } else if (c == '"') { inQuotes = !inQuotes; - } else if (c == ' ' && !inQuotes) { + } else if (char.IsWhiteSpace (c) && !inQuotes) { if (current.Length > 0) { args.Add (current.ToString ()); current.Clear (); diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs index dc4e4818575..b0fccaef864 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs @@ -292,7 +292,7 @@ public void UnknownError_MapsToXA0144 () } [Test] - public void Cancelled_FailsTheBuild () + public void Cancelled_DoesNotFail () { var task = CreateTask ("Pixel_6_API_33"); task.BootResult = new EmulatorBootResult { @@ -300,8 +300,9 @@ public void Cancelled_FailsTheBuild () ErrorKind = EmulatorBootErrorKind.Cancelled, }; - Assert.IsFalse (task.Execute (), "Cancelled task should fail the build"); + Assert.IsTrue (task.Execute (), "Cancelled task should not fail — MSBuild handles cancellation"); Assert.IsNull (task.ResolvedDevice, "ResolvedDevice should be null on cancellation"); + Assert.IsEmpty (errors, "No errors should be logged for cancellation"); } [Test] From 63d5586b1ae63d1ed597586b4a85b6adc98c9653 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Fri, 20 Mar 2026 01:30:36 +0000 Subject: [PATCH 13/14] Fix ToolPaths test for Windows (.exe extension) The test expected paths ending with 'platform-tools/adb' and 'emulator/emulator', but on Windows they have '.exe' extensions. Accept both variants. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/BootAndroidEmulatorTests.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs index b0fccaef864..6a482e471e5 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs @@ -187,8 +187,12 @@ public void ToolPaths_ResolvedFromAndroidSdkDirectory () Assert.IsTrue (task.Execute (), "Task should succeed"); Assert.AreEqual ("emulator-5554", task.ResolvedDevice); - StringAssert.EndsWith ("platform-tools/adb", task.LastAdbPath?.Replace ('\\', '/'), "adb path should be resolved from AndroidSdkDirectory"); - StringAssert.EndsWith ("emulator/emulator", task.LastEmulatorPath?.Replace ('\\', '/'), "emulator path should be resolved from AndroidSdkDirectory"); + var adbPath = task.LastAdbPath?.Replace ('\\', '/'); + var emulatorPath = task.LastEmulatorPath?.Replace ('\\', '/'); + Assert.IsTrue (adbPath?.EndsWith ("platform-tools/adb") == true || adbPath?.EndsWith ("platform-tools/adb.exe") == true, + $"adb path should be resolved from AndroidSdkDirectory, got: {adbPath}"); + Assert.IsTrue (emulatorPath?.EndsWith ("emulator/emulator") == true || emulatorPath?.EndsWith ("emulator/emulator.exe") == true, + $"emulator path should be resolved from AndroidSdkDirectory, got: {emulatorPath}"); } [Test] From ca1a5f4cac8a02f8d0368493e304fc77c5a1bf66 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Fri, 20 Mar 2026 15:18:06 +0000 Subject: [PATCH 14/14] Update submodule to android-tools main (d679f2b) EmulatorRunner and EmulatorBootErrorKind are already merged into android-tools main, so no need for the feature branch pointer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- external/xamarin-android-tools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/external/xamarin-android-tools b/external/xamarin-android-tools index 40b30131791..d679f2becba 160000 --- a/external/xamarin-android-tools +++ b/external/xamarin-android-tools @@ -1 +1 @@ -Subproject commit 40b30131791e7e996e20d461f8d3694b273f6985 +Subproject commit d679f2becbac319c1ef35934d40866d87996f7b0