From f5dc7d6075d580bfe5c97e1a84a359b6bc298fbb Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 28 Jan 2026 15:18:08 -0600 Subject: [PATCH 01/12] [dotnet run] `ComputeAvailableDevices` lists available emulators and auto-boot on deploy Fixes: https://github.com/dotnet/android/issues/10702 Context: IDEs (Visual Studio) handle emulator discovery and boot through the commercial `android-platform-support` repo. The key files are: - `src/Mono.AndroidTools/Internal/AvdWatcher.cs`: https://devdiv.visualstudio.com/DevDiv/_git/android-platform-support?path=/src/Mono.AndroidTools/Internal/AvdWatcher.cs Uses `FileSystemWatcher` on `~/.android/avd/*.ini` to discover AVDs (no `emulator -list-avds` call). Parses `.ini` files to load AVD metadata. - `src/Mono.AndroidTools/AndroidVirtualDeviceManager.cs`: https://devdiv.visualstudio.com/DevDiv/_git/android-platform-support?path=/src/Mono.AndroidTools/AndroidVirtualDeviceManager.cs Manages the full AVD lifecycle. `GetRunArguments()` constructs emulator launch arguments (`-avd`, `-netfast`, `-no-snapshot-load`, etc.). - `src/Mono.AndroidTools/AndroidVirtualDevice.cs`: https://devdiv.visualstudio.com/DevDiv/_git/android-platform-support?path=/src/Mono.AndroidTools/AndroidVirtualDevice.cs Loads AVD config from `.ini` files, reads `avd.ini.displayname` for friendly names, parses API level from target strings. - `src/Mono.AndroidTools/AndroidDeviceManager.cs`: https://devdiv.visualstudio.com/DevDiv/_git/android-platform-support?path=/src/Mono.AndroidTools/AndroidDeviceManager.cs Tracks connected devices via `adb track-devices` (persistent connection). This PR provides a simpler MSBuild-only equivalent for `dotnet run`: - `emulator -list-avds` instead of filesystem watchers for AVD discovery - `BootAndroidEmulator` task instead of IDE-level process management - Display names derived from AVD folder names (underscores to spaces, title case) instead of parsing `config.ini`/`avd.ini.displayname` Changes: - Extended `GetAvailableAndroidDevices` task to query `emulator -list-avds` and merge not-running emulators with `adb devices` output, sorted with online devices first, then offline emulators alphabetically. - Added `EmulatorToolPath`/`EmulatorToolExe`/`AdbToolExe` properties to `_ResolveMonoAndroidSdks` (ported `AdbToolPath` from `CreateProperty`). - Added `BootAndroidEmulator` task to boot a not-running AVD and wait for it to come online, resolving the ADB serial (e.g. `emulator-5554`). - Added `_EnsureDeviceBooted` target (`BeforeTargets=_GetPrimaryCpuAbi;_DeployApk;_DeployAppBundle`) so selecting a not-running emulator auto-boots it before deploy. - Updated `Resources.Designer.cs` by opening the resx in Visual Studio to regenerate it (the designer file was stale) and discarding whitespace changes. --- .github/copilot-instructions.md | 1 + .../Microsoft.Android.Sdk.Application.targets | 33 +- .../Properties/Resources.Designer.cs | 58 ++- .../Properties/Resources.resx | 16 + .../Tasks/BootAndroidEmulator.cs | 414 ++++++++++++++++++ .../Tasks/GetAvailableAndroidDevices.cs | 178 +++++++- .../Tasks/GetAvailableAndroidDevicesTests.cs | 220 +++++++++- .../Xamarin.Android.Common.targets | 12 +- 8 files changed, 885 insertions(+), 47 deletions(-) create mode 100644 src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 32b29dd094f..4e278b7fee5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -133,6 +133,7 @@ C# code uses tabs (not spaces) and Mono style (`.editorconfig`): - Space before `(` and `[`: `Foo ()`, `array [0]` - Use `""` not `string.Empty`, `[]` not `Array.Empty()` - Minimal diffs - don't leave random empty lines +- Do NOT use `#region` or `#endregion` ```csharp Foo (); diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets index d6efbf7b5ee..3e027ec9eb4 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets @@ -10,6 +10,7 @@ This file contains targets specific for Android application projects. + @@ -44,11 +45,41 @@ This file contains targets specific for Android application projects. Returns="@(Devices)"> + ToolPath="$(AdbToolPath)" + EmulatorToolExe="$(EmulatorToolExe)" + EmulatorToolPath="$(EmulatorToolPath)"> + + + + + + + + diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs index c301a8d6dd6..d936677798b 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Xamarin.Android.Tasks.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class Resources { @@ -108,7 +108,7 @@ public static string APT0004 { } /// - /// Looks up a localized string similar to Invalid file name: filenames cannot use java reserved words.. + /// Looks up a localized string similar to Invalid file name: Filenames cannot use Java reserved words.. /// public static string APT0005 { get { @@ -531,7 +531,7 @@ public static string XA0140 { } /// - /// Looks up a localized string similar to NuGet package '{0}' version '{1}' contains a shared library '{2}' which is not correctly aligned. See https://developer.android.com/guide/practices/page-sizes for more details. + /// Looks up a localized string similar to Android 16 will require 16 KB page sizes, shared library '{3}' does not have a 16 KB page size. Please inform the authors of the NuGet package '{0}' version '{1}' which contains '{2}'. See https://developer.android.com/guide/practices/page-sizes for more details.. /// public static string XA0141 { get { @@ -540,7 +540,7 @@ public static string XA0141 { } /// - /// Looks up a localized string similar to Command '{0}' failed.\n{1} + /// Looks up a localized string similar to Command '{0}' failed.\n{1}. /// public static string XA0142 { get { @@ -548,7 +548,34 @@ public static string XA0142 { } } - /// + /// + /// Looks up a localized string similar to Failed to launch the Android emulator for AVD '{0}': {1}. + /// + public static string XA0143 { + get { + return ResourceManager.GetString("XA0143", resourceCulture); + } + } + + /// + /// 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.. + /// + public static string XA0145 { + get { + return ResourceManager.GetString("XA0145", resourceCulture); + } + } + + /// /// Looks up a localized string similar to There was a problem parsing {0}. This is likely due to incomplete or invalid XML. Exception: {1}. /// public static string XA1000 { @@ -875,6 +902,15 @@ public static string XA1037 { } } + /// + /// Looks up a localized string similar to The '{0}' MSBuild property has an invalid value of '{1}'. A valid value is one of: {2}.. + /// + public static string XA1038 { + get { + return ResourceManager.GetString("XA1038", resourceCulture); + } + } + /// /// Looks up a localized string similar to The Android Support libraries are not supported in .NET 9 and later, please migrate to AndroidX. See https://aka.ms/net-android/androidx for more details.. /// @@ -885,7 +921,7 @@ public static string XA1039 { } /// - /// Looks up a localized string similar to The {0} runtime on Android is an experimental feature and not yet suitable for production use. File issues at: https://github.com/dotnet/android/issues + /// Looks up a localized string similar to The {0} runtime on Android is an experimental feature and not yet suitable for production use. File issues at: https://github.com/dotnet/android/issues. /// public static string XA1040 { get { @@ -894,7 +930,7 @@ public static string XA1040 { } /// - /// Looks up a localized string similar to The {0} . + /// Looks up a localized string similar to The MSBuild property '{0}' has an invalid value of '{1}'. The value is expected to be a directory path representing the relative location of your Assets or Resources.. /// public static string XA1041 { get { @@ -1047,7 +1083,7 @@ public static string XA4210 { } /// - /// Looks up a localized string similar to AndroidManifest.xml //uses-sdk/@android:targetSdkVersion '{0}' is less than $(TargetFrameworkVersion) '{1}'. Using API-{2} for ACW compilation.. + /// Looks up a localized string similar to AndroidManifest.xml //uses-sdk/@android:targetSdkVersion '{0}' is less than $(TargetPlatformVersion) '{1}'. Using API-{2} for ACW compilation.. /// public static string XA4211 { get { @@ -1565,7 +1601,7 @@ public static string XA4314 { } /// - /// Looks up a localized string similar to Ignoring {0}. Manifest does not have the required 'package' attribute on the manifest element. + /// Looks up a localized string similar to Ignoring `{0}`. Manifest does not have the required 'package' attribute on the manifest element.. /// public static string XA4315 { get { @@ -1740,7 +1776,7 @@ public static string XA8000 { } /// - /// Looks up a localized string similar to Executable 'gradlew' not found in project directory '{0}'. Please ensure the path to your Gradle project folder is correct, and that it contains Gradle Wrapper scripts.. + /// Looks up a localized string similar to Executable 'gradlew' not found in project directory '{0}'. Please ensure the path to your Gradle project folder is correct, and that it contains Gradle Wrapper scripts.. /// public static string XAGRDL1000 { get { @@ -1749,7 +1785,7 @@ public static string XAGRDL1000 { } /// - /// Looks up a localized string similar to dding reference to Gradle output: '{0}'. The '%(CreateAndroidLibrary)' metadata can be set to 'false' to opt out of this behavior.. + /// Looks up a localized string similar to Adding reference to Gradle output: '{0}'. The '%(CreateAndroidLibrary)' metadata can be set to 'false' to opt out of this behavior.. /// public static string XAGRDLRefLibraryOutputs { get { diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx index 78e0e959705..bb5e38b7164 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx @@ -1073,6 +1073,22 @@ To use a custom JDK path for a command line build, set the 'JavaSdkDirectory' MS Command '{0}' failed.\n{1} '{0}' is a failed command name (potentially with path) followed by all the arguments passed to it. {1} is the combined output on the standard error and standard output streams. + + 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. + The following is a literal name and should not be translated: BootTimeoutSeconds +{0} - The AVD name. +{1} - The timeout in seconds. + Executable 'gradlew' not found in project directory '{0}'. Please ensure the path to your Gradle project folder is correct, and that it contains Gradle Wrapper scripts. The following are literal names and should not be translated: gradlew, Gradle, Gradle Wrapper diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs new file mode 100644 index 00000000000..f2dc2858264 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -0,0 +1,414 @@ +#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; + +namespace Xamarin.Android.Tasks; + +/// +/// MSBuild task that ensures an Android device identified by $(Device) is online and ready. +/// +/// $(Device) may be either: +/// - An ADB serial for an already-running device/emulator (e.g. "emulator-5554", "0A041FDD400327") +/// - An AVD name for a not-yet-booted emulator (e.g. "Pixel_6_API_33") +/// +/// The task detects which case applies: +/// - If $(Device) is already an online ADB serial, it passes through unchanged. +/// - If $(Device) is an AVD name (not found as a serial in 'adb devices'), the task boots +/// the emulator and waits for it to become fully ready. +/// +/// On success, outputs the resolved ADB serial and AdbTarget for use by subsequent tasks. +/// +public class BootAndroidEmulator : AndroidTask +{ + const int DefaultBootTimeoutSeconds = 300; + const int PollIntervalMilliseconds = 3000; + + public override string TaskPrefix => "BAE0"; + + /// + /// The device identifier from 'dotnet run' device selection. May be an ADB serial + /// (e.g. "emulator-5554") for an already-running device, or an AVD name + /// (e.g. "Pixel_6_API_33") for a not-running emulator. + /// + [Required] + public string Device { get; set; } = ""; + + /// + /// Path to the emulator tool directory. + /// + [Required] + public string EmulatorToolPath { get; set; } = ""; + + /// + /// Filename of the emulator executable (e.g., "emulator" or "emulator.exe"). + /// + [Required] + public string EmulatorToolExe { get; set; } = ""; + + /// + /// Path to the adb tool directory. + /// + [Required] + public string AdbToolPath { get; set; } = ""; + + /// + /// Filename of the adb executable (e.g., "adb" or "adb.exe"). + /// + [Required] + public string AdbToolExe { get; set; } = ""; + + /// + /// Maximum time in seconds to wait for the emulator to fully boot. + /// Defaults to 300 seconds (5 minutes). + /// + public int BootTimeoutSeconds { get; set; } = DefaultBootTimeoutSeconds; + + /// + /// Optional additional arguments to pass to the emulator command line (e.g. "-no-snapshot-load -gpu auto"). + /// + public string? EmulatorExtraArguments { get; set; } + + /// + /// The resolved ADB serial of the device (e.g. "emulator-5554"). + /// For already-running devices this equals DeviceId; for booted emulators this is the new serial. + /// + [Output] + public string? ResolvedDevice { get; set; } + + /// + /// The ADB target argument for use by subsequent tasks (e.g. "-s emulator-5554"). + /// + [Output] + public string? AdbTarget { get; set; } + + public override bool RunTask () + { + var adbPath = Path.Combine (AdbToolPath, AdbToolExe); + + // 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 emulatorPath = Path.Combine (EmulatorToolPath, EmulatorToolExe); + + var avdName = Device; + + // 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); + } + + // 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; + } + + try { + var timeout = TimeSpan.FromSeconds (BootTimeoutSeconds); + var stopwatch = Stopwatch.StartNew (); + + // 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; + } + + ResolvedDevice = serial; + AdbTarget = $"-s {serial}"; + Log.LogMessage (MessageImportance.Normal, $"Emulator appeared as '{serial}'"); + + // 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; + } + } + + /// + /// Checks whether the given deviceId is currently listed as an online device in 'adb devices'. + /// + 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. + /// + 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'. + /// + 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 with -no-window (unless overridden by extra args). + /// + 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; + } + + if (stopwatch.Elapsed >= timeout) { + Log.LogCodedError ("XA0145", Properties.Resources.XA0145, avdName, BootTimeoutSeconds); + return false; + } + + Thread.Sleep (PollIntervalMilliseconds); + } + + // 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'. + /// + 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. + /// + 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/Tasks/GetAvailableAndroidDevices.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GetAvailableAndroidDevices.cs index 9475e66c8a7..d1da642cde0 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GetAvailableAndroidDevices.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GetAvailableAndroidDevices.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Text.RegularExpressions; using Microsoft.Android.Build.Tasks; using Microsoft.Build.Framework; @@ -11,7 +12,9 @@ namespace Xamarin.Android.Tasks; /// -/// MSBuild task that queries available Android devices and emulators using 'adb devices -l'. +/// MSBuild task that queries available Android devices and emulators using 'adb devices -l' +/// and 'emulator -list-avds'. Merges the results to provide a complete list of available +/// devices including emulators that are not currently running. /// Returns a list of devices with metadata for device selection in dotnet run. /// public class GetAvailableAndroidDevices : AndroidAdb @@ -29,6 +32,16 @@ enum DeviceType readonly List output = []; + /// + /// Path to the emulator tool directory. + /// + public string EmulatorToolPath { get; set; } = ""; + + /// + /// Filename of the emulator executable (e.g., "emulator" or "emulator.exe"). + /// + public string EmulatorToolExe { get; set; } = ""; + [Output] public ITaskItem [] Devices { get; set; } = []; @@ -51,14 +64,122 @@ public override bool RunTask () if (!base.RunTask ()) return false; - var devices = ParseAdbDevicesOutput (output); - Devices = devices.ToArray (); + // Parse devices from adb + var adbDevices = ParseAdbDevicesOutput (output); + Log.LogDebugMessage ($"Found {adbDevices.Count} device(s) from adb"); + + // Get available emulators from 'emulator -list-avds' + var availableEmulators = GetAvailableEmulators (); + Log.LogDebugMessage ($"Found {availableEmulators.Count} available emulator(s) from 'emulator -list-avds'"); - Log.LogDebugMessage ($"Found {Devices.Length} Android device(s)/emulator(s)"); + // Merge the lists + var mergedDevices = MergeDevicesAndEmulators (adbDevices, availableEmulators); + Devices = mergedDevices.ToArray (); + + Log.LogDebugMessage ($"Total {Devices.Length} Android device(s)/emulator(s) after merging"); return !Log.HasLoggedErrors; } + /// + /// Gets the list of available AVDs using 'emulator -list-avds'. + /// + protected virtual List GetAvailableEmulators () + { + var emulators = new List (); + + if (EmulatorToolPath.IsNullOrEmpty () || EmulatorToolExe.IsNullOrEmpty ()) { + Log.LogDebugMessage ("EmulatorToolPath or EmulatorToolExe not set, skipping emulator listing"); + return emulators; + } + + var emulatorPath = Path.Combine (EmulatorToolPath, EmulatorToolExe); + if (!File.Exists (emulatorPath)) { + Log.LogDebugMessage ($"Emulator tool not found at: {emulatorPath}"); + return emulators; + } + + try { + var exitCode = MonoAndroidHelper.RunProcess ( + emulatorPath, + "-list-avds", + Log, + onOutput: (sender, e) => { + if (!e.Data.IsNullOrWhiteSpace ()) { + var avdName = e.Data.Trim (); + emulators.Add (avdName); + Log.LogDebugMessage ($"Found available emulator: {avdName}"); + } + }, + logWarningOnFailure: false + ); + + if (exitCode != 0) { + Log.LogDebugMessage ($"'emulator -list-avds' returned exit code: {exitCode}"); + } + } catch (Exception ex) { + Log.LogDebugMessage ($"Failed to run 'emulator -list-avds': {ex.Message}"); + } + + return emulators; + } + + /// + /// Merges devices from adb with available emulators. + /// Running emulators (already in adb list) are not duplicated. + /// Non-running emulators are added with Status="NotRunning". + /// Results are sorted: online devices first, then not-running emulators, alphabetically by description within each group. + /// + internal List MergeDevicesAndEmulators (List adbDevices, List availableEmulators) + { + var result = new List (adbDevices); + + // Build a set of AVD names that are already running (from adb devices) + var runningAvdNames = new HashSet (StringComparer.OrdinalIgnoreCase); + foreach (var device in adbDevices) { + var avdName = device.GetMetadata ("AvdName"); + if (!avdName.IsNullOrEmpty ()) { + runningAvdNames.Add (avdName); + } + } + + Log.LogDebugMessage ($"Running emulators AVD names: {string.Join (", ", runningAvdNames)}"); + + // Add non-running emulators + foreach (var avdName in availableEmulators) { + if (runningAvdNames.Contains (avdName)) { + Log.LogDebugMessage ($"Emulator '{avdName}' is already running, skipping"); + continue; + } + + // Create item for non-running emulator + // Use the AVD name as the ItemSpec since there's no serial yet + var item = new TaskItem (avdName); + var displayName = FormatDisplayName (avdName, avdName); + item.SetMetadata ("Description", $"{displayName} (Not Running)"); + item.SetMetadata ("Type", DeviceType.Emulator.ToString ()); + item.SetMetadata ("Status", "NotRunning"); + item.SetMetadata ("AvdName", avdName); + + result.Add (item); + Log.LogDebugMessage ($"Added non-running emulator: {avdName}"); + } + + // Sort: online devices first, then not-running emulators, alphabetically by description within each group + result.Sort ((a, b) => { + var aNotRunning = string.Equals (a.GetMetadata ("Status"), "NotRunning", StringComparison.OrdinalIgnoreCase); + var bNotRunning = string.Equals (b.GetMetadata ("Status"), "NotRunning", StringComparison.OrdinalIgnoreCase); + + if (aNotRunning != bNotRunning) { + return aNotRunning ? 1 : -1; + } + + return string.Compare (a.GetMetadata ("Description"), b.GetMetadata ("Description"), StringComparison.OrdinalIgnoreCase); + }); + + return result; + } + /// /// Parses the output of 'adb devices -l' command. /// Example output: @@ -72,7 +193,7 @@ List ParseAdbDevicesOutput (List lines) foreach (var line in lines) { // Skip the header line "List of devices attached" - if (line.Contains ("List of devices") || string.IsNullOrWhiteSpace (line)) + if (line.Contains ("List of devices") || line.IsNullOrWhiteSpace ()) continue; var match = AdbDevicesRegex.Match (line); @@ -85,7 +206,7 @@ List ParseAdbDevicesOutput (List lines) // Parse key:value pairs from the properties string var propDict = new Dictionary (StringComparer.OrdinalIgnoreCase); - if (!string.IsNullOrWhiteSpace (properties)) { + if (!properties.IsNullOrWhiteSpace ()) { // Split by whitespace and parse key:value pairs var pairs = properties.Split ([' '], StringSplitOptions.RemoveEmptyEntries); foreach (var pair in pairs) { @@ -101,8 +222,14 @@ List ParseAdbDevicesOutput (List lines) // Determine device type: Emulator or Device var deviceType = serial.StartsWith ("emulator-", StringComparison.OrdinalIgnoreCase) ? DeviceType.Emulator : DeviceType.Device; + // For emulators, get the AVD name for duplicate detection + string? avdName = null; + if (deviceType == DeviceType.Emulator) { + avdName = GetEmulatorAvdName (serial); + } + // Build a friendly description - var description = BuildDeviceDescription (serial, propDict, deviceType); + var description = BuildDeviceDescription (serial, propDict, deviceType, avdName); // Map adb state to device status var status = MapAdbStateToStatus (state); @@ -113,6 +240,11 @@ List ParseAdbDevicesOutput (List lines) item.SetMetadata ("Type", deviceType.ToString ()); item.SetMetadata ("Status", status); + // Add AVD name for emulators (used for duplicate detection) + if (!avdName.IsNullOrEmpty ()) { + item.SetMetadata ("AvdName", avdName); + } + // Add optional metadata for additional information if (propDict.TryGetValue ("model", out var model)) item.SetMetadata ("Model", model); @@ -129,30 +261,28 @@ List ParseAdbDevicesOutput (List lines) return devices; } - string BuildDeviceDescription (string serial, Dictionary properties, DeviceType deviceType) + string BuildDeviceDescription (string serial, Dictionary properties, DeviceType deviceType, string? avdName) { // Try to build a human-friendly description // Priority: AVD name (for emulators) > model > product > device > serial // For emulators, try to get the AVD display name - if (deviceType == DeviceType.Emulator) { - var avdName = GetEmulatorAvdDisplayName (serial); - if (!string.IsNullOrEmpty (avdName)) - return avdName!; + if (deviceType == DeviceType.Emulator && !avdName.IsNullOrEmpty ()) { + return FormatDisplayName (serial, avdName!); } - if (properties.TryGetValue ("model", out var model) && !string.IsNullOrEmpty (model)) { + if (properties.TryGetValue ("model", out var model) && !model.IsNullOrEmpty ()) { // Clean up model name - replace underscores with spaces model = model.Replace ('_', ' '); return model; } - if (properties.TryGetValue ("product", out var product) && !string.IsNullOrEmpty (product)) { + if (properties.TryGetValue ("product", out var product) && !product.IsNullOrEmpty ()) { product = product.Replace ('_', ' '); return product; } - if (properties.TryGetValue ("device", out var device) && !string.IsNullOrEmpty (device)) { + if (properties.TryGetValue ("device", out var device) && !device.IsNullOrEmpty ()) { device = device.Replace ('_', ' '); return device; } @@ -174,13 +304,13 @@ static string MapAdbStateToStatus (string adbState) } /// - /// Queries the emulator for its AVD name using 'adb -s emu avd name' - /// and formats it as a friendly display name. + /// Queries the emulator for its AVD name using 'adb -s emu avd name'. + /// Returns the raw AVD name (not formatted). /// - protected virtual string? GetEmulatorAvdDisplayName (string serial) + protected virtual string? GetEmulatorAvdName (string serial) { try { - var adbPath = System.IO.Path.Combine (ToolPath, ToolExe); + var adbPath = Path.Combine (ToolPath, ToolExe); var outputLines = new List (); var exitCode = MonoAndroidHelper.RunProcess ( @@ -188,9 +318,8 @@ static string MapAdbStateToStatus (string adbState) $"-s {serial} emu avd name", Log, onOutput: (sender, e) => { - if (!string.IsNullOrEmpty (e.Data)) { + if (!e.Data.IsNullOrEmpty ()) { outputLines.Add (e.Data); - base.LogEventsFromTextOutput (e.Data, MessageImportance.Normal); } }, logWarningOnFailure: false @@ -199,12 +328,13 @@ static string MapAdbStateToStatus (string adbState) if (exitCode == 0 && outputLines.Count > 0) { var avdName = outputLines [0].Trim (); // Verify it's not the "OK" response - if (!string.IsNullOrEmpty (avdName) && !avdName.Equals ("OK", StringComparison.OrdinalIgnoreCase)) { - return FormatDisplayName (serial, avdName); + if (!avdName.IsNullOrEmpty () && !avdName.Equals ("OK", StringComparison.OrdinalIgnoreCase)) { + Log.LogDebugMessage ($"Emulator {serial} has AVD name: {avdName}"); + return avdName; } } } catch (Exception ex) { - Log.LogDebugMessage ($"Failed to get AVD display name for {serial}: {ex}"); + Log.LogDebugMessage ($"Failed to get AVD name for {serial}: {ex.Message}"); } return null; diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GetAvailableAndroidDevicesTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GetAvailableAndroidDevicesTests.cs index c1dd157648b..5dca488ebd7 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GetAvailableAndroidDevicesTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GetAvailableAndroidDevicesTests.cs @@ -1,4 +1,5 @@ using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; using NUnit.Framework; using System.Collections.Generic; using System.Reflection; @@ -25,17 +26,25 @@ public void Setup () /// class MockGetAvailableAndroidDevices : GetAvailableAndroidDevices { + // Real AVD names from 'emulator -list-avds' can have mixed case, underscores, and dashes readonly Dictionary AvdNames = new () { - { "emulator-5554", "Pixel 7 - API 35" }, - { "emulator-5556", "Pixel 9 Pro XL - API 36" } + { "emulator-5554", "pixel_7_-_api_35" }, + { "emulator-5556", "Pixel_9_Pro_XL" } }; - protected override string? GetEmulatorAvdDisplayName (string serial) + public List MockAvailableEmulators { get; set; } = []; + + protected override string? GetEmulatorAvdName (string serial) { if (AvdNames.TryGetValue (serial, out var name)) return name; return null; } + + protected override List GetAvailableEmulators () + { + return MockAvailableEmulators; + } } /// @@ -84,6 +93,7 @@ public void ParseRealWorldData () Assert.AreEqual ("Emulator", device2.GetMetadata ("Type"), "Type should be Emulator"); Assert.AreEqual ("Online", device2.GetMetadata ("Status"), "Status should be Online"); Assert.AreEqual ("Pixel 7 - API 35", device2.GetMetadata ("Description"), "Description should be replaced with AVD name"); + Assert.AreEqual ("pixel_7_-_api_35", device2.GetMetadata ("AvdName"), "AvdName metadata should be raw AVD name"); Assert.AreEqual ("sdk_gphone64_x86_64", device2.GetMetadata ("Model"), "Model metadata should be 'sdk_gphone64_x86_64'"); Assert.AreEqual ("sdk_gphone64_x86_64", device2.GetMetadata ("Product"), "Product metadata should be 'sdk_gphone64_x86_64'"); Assert.AreEqual ("emu64xa", device2.GetMetadata ("Device"), "Device metadata should be 'emu64xa'"); @@ -127,6 +137,7 @@ public void ParseSingleEmulator () Assert.AreEqual ("Emulator", device.GetMetadata ("Type"), "Type should be Emulator"); Assert.AreEqual ("Online", device.GetMetadata ("Status"), "Status should be Online"); Assert.AreEqual ("Pixel 7 - API 35", device.GetMetadata ("Description"), "Description should be replaced with AVD name"); + Assert.AreEqual ("pixel_7_-_api_35", device.GetMetadata ("AvdName"), "AvdName should be raw AVD name"); Assert.AreEqual ("sdk_gphone64_arm64", device.GetMetadata ("Model"), "Model metadata should be present"); Assert.AreEqual ("1", device.GetMetadata ("TransportId"), "TransportId should be present"); } @@ -182,7 +193,7 @@ public void ParseMultipleDevices () Assert.AreEqual ("emulator-5556", devices [1].ItemSpec); Assert.AreEqual ("Emulator", devices [1].GetMetadata ("Type")); Assert.AreEqual ("Online", devices [1].GetMetadata ("Status")); - Assert.AreEqual ("Pixel 9 Pro XL - API 36", devices [1].GetMetadata ("Description"), "Emulator should have AVD name"); + Assert.AreEqual ("Pixel 9 Pro XL", devices [1].GetMetadata ("Description"), "Emulator should have AVD name"); Assert.AreEqual ("0A041FDD400327", devices [2].ItemSpec); Assert.AreEqual ("Device", devices [2].GetMetadata ("Type")); @@ -341,7 +352,7 @@ public void ParseMixedDeviceStates () Assert.AreEqual ("Pixel 7 - API 35", devices [0].GetMetadata ("Description"), "Emulator should have AVD name"); Assert.AreEqual ("Offline", devices [1].GetMetadata ("Status")); - Assert.AreEqual ("Pixel 9 Pro XL - API 36", devices [1].GetMetadata ("Description"), "Offline emulator should still get AVD name"); + Assert.AreEqual ("Pixel 9 Pro XL", devices [1].GetMetadata ("Description"), "Offline emulator should still get AVD name"); Assert.AreEqual ("Online", devices [2].GetMetadata ("Status")); Assert.AreEqual ("Pixel 6 Pro", devices [2].GetMetadata ("Description")); @@ -556,5 +567,204 @@ public void FormatDisplayName_DoesNotReplaceApiInsideWords () Assert.AreEqual ("Erapidevice", result, "Should not replace 'api' when it's part of a larger word"); } + + [Test] + public void MergeDevicesAndEmulators_NoEmulators_ReturnsAdbDevicesOnly () + { + var task = new MockGetAvailableAndroidDevices { + BuildEngine = engine, + }; + + var adbDevices = new List { + CreateDeviceItem ("0A041FDD400327", "Pixel 5", "Device", "Online"), + }; + var availableEmulators = new List (); + + var result = task.MergeDevicesAndEmulators (adbDevices, availableEmulators); + + Assert.AreEqual (1, result.Count, "Should return only adb devices"); + Assert.AreEqual ("0A041FDD400327", result [0].ItemSpec); + } + + [Test] + public void MergeDevicesAndEmulators_NoRunningEmulators_AddsAllAvailableEmulators () + { + var task = new MockGetAvailableAndroidDevices { + BuildEngine = engine, + }; + + var adbDevices = new List { + CreateDeviceItem ("0A041FDD400327", "Pixel 5", "Device", "Online"), + }; + var availableEmulators = new List { "pixel_7_api_35", "pixel_9_api_36" }; + + var result = task.MergeDevicesAndEmulators (adbDevices, availableEmulators); + + Assert.AreEqual (3, result.Count, "Should return adb device + 2 available emulators"); + + // First item: physical device (online, sorted first) + Assert.AreEqual ("0A041FDD400327", result [0].ItemSpec); + + // Second item: non-running emulator (sorted alphabetically by description) + Assert.AreEqual ("pixel_7_api_35", result [1].ItemSpec, "Non-running emulator ItemSpec should be AVD name"); + Assert.AreEqual ("Emulator", result [1].GetMetadata ("Type")); + Assert.AreEqual ("NotRunning", result [1].GetMetadata ("Status")); + Assert.AreEqual ("pixel_7_api_35", result [1].GetMetadata ("AvdName")); + Assert.AreEqual ("Pixel 7 API 35 (Not Running)", result [1].GetMetadata ("Description")); + + // Third item: non-running emulator + Assert.AreEqual ("pixel_9_api_36", result [2].ItemSpec); + Assert.AreEqual ("NotRunning", result [2].GetMetadata ("Status")); + Assert.AreEqual ("Pixel 9 API 36 (Not Running)", result [2].GetMetadata ("Description")); + } + + [Test] + public void MergeDevicesAndEmulators_RunningEmulator_NoDuplicate () + { + var task = new MockGetAvailableAndroidDevices { + BuildEngine = engine, + }; + + // Emulator is running (has adb entry with AvdName metadata) + var runningEmulator = CreateDeviceItem ("emulator-5554", "Pixel 7 API 35", "Emulator", "Online"); + runningEmulator.SetMetadata ("AvdName", "pixel_7_api_35"); + + var adbDevices = new List { runningEmulator }; + var availableEmulators = new List { "pixel_7_api_35" }; + + var result = task.MergeDevicesAndEmulators (adbDevices, availableEmulators); + + Assert.AreEqual (1, result.Count, "Should not duplicate running emulator"); + Assert.AreEqual ("emulator-5554", result [0].ItemSpec, "Should keep the running emulator entry"); + Assert.AreEqual ("Online", result [0].GetMetadata ("Status")); + } + + [Test] + public void MergeDevicesAndEmulators_MixedRunningAndNotRunning () + { + var task = new MockGetAvailableAndroidDevices { + BuildEngine = engine, + }; + + // One emulator is running + var runningEmulator = CreateDeviceItem ("emulator-5554", "Pixel 7 API 35", "Emulator", "Online"); + runningEmulator.SetMetadata ("AvdName", "pixel_7_api_35"); + + var physicalDevice = CreateDeviceItem ("0A041FDD400327", "Pixel 5", "Device", "Online"); + + var adbDevices = new List { runningEmulator, physicalDevice }; + var availableEmulators = new List { "pixel_7_api_35", "pixel_9_api_36", "nexus_5_api_30" }; + + var result = task.MergeDevicesAndEmulators (adbDevices, availableEmulators); + + Assert.AreEqual (4, result.Count, "Should have: 1 running emulator + 1 device + 2 non-running emulators"); + + // Online devices come first, sorted alphabetically by description + Assert.AreEqual ("0A041FDD400327", result [0].ItemSpec); + Assert.AreEqual ("Online", result [0].GetMetadata ("Status")); + + Assert.AreEqual ("emulator-5554", result [1].ItemSpec); + Assert.AreEqual ("Online", result [1].GetMetadata ("Status")); + + // Non-running emulators come second, sorted alphabetically by description + Assert.AreEqual ("nexus_5_api_30", result [2].ItemSpec); + Assert.AreEqual ("NotRunning", result [2].GetMetadata ("Status")); + Assert.AreEqual ("Nexus 5 API 30 (Not Running)", result [2].GetMetadata ("Description")); + + Assert.AreEqual ("pixel_9_api_36", result [3].ItemSpec); + Assert.AreEqual ("NotRunning", result [3].GetMetadata ("Status")); + Assert.AreEqual ("Pixel 9 API 36 (Not Running)", result [3].GetMetadata ("Description")); + } + + [Test] + public void MergeDevicesAndEmulators_CaseInsensitiveAvdNameMatching () + { + var task = new MockGetAvailableAndroidDevices { + BuildEngine = engine, + }; + + // Running emulator with different case + var runningEmulator = CreateDeviceItem ("emulator-5554", "Pixel 7 API 35", "Emulator", "Online"); + runningEmulator.SetMetadata ("AvdName", "Pixel_7_API_35"); + + var adbDevices = new List { runningEmulator }; + var availableEmulators = new List { "pixel_7_api_35" }; // lowercase + + var result = task.MergeDevicesAndEmulators (adbDevices, availableEmulators); + + Assert.AreEqual (1, result.Count, "Should match AVD names case-insensitively"); + Assert.AreEqual ("emulator-5554", result [0].ItemSpec); + } + + [Test] + public void MergeDevicesAndEmulators_EmptyAdbDevices_ReturnsAllAvailableEmulators () + { + var task = new MockGetAvailableAndroidDevices { + BuildEngine = engine, + }; + + var adbDevices = new List (); + var availableEmulators = new List { "pixel_7_api_35", "pixel_9_api_36" }; + + var result = task.MergeDevicesAndEmulators (adbDevices, availableEmulators); + + Assert.AreEqual (2, result.Count, "Should return all available emulators"); + Assert.AreEqual ("pixel_7_api_35", result [0].ItemSpec); + Assert.AreEqual ("Pixel 7 API 35 (Not Running)", result [0].GetMetadata ("Description")); + Assert.AreEqual ("pixel_9_api_36", result [1].ItemSpec); + Assert.AreEqual ("Pixel 9 API 36 (Not Running)", result [1].GetMetadata ("Description")); + } + + [Test] + public void MergeDevicesAndEmulators_AllEmulatorsRunning_NoDuplicates () + { + var task = new MockGetAvailableAndroidDevices { + BuildEngine = engine, + }; + + var emulator1 = CreateDeviceItem ("emulator-5554", "Pixel 7 API 35", "Emulator", "Online"); + emulator1.SetMetadata ("AvdName", "pixel_7_api_35"); + + var emulator2 = CreateDeviceItem ("emulator-5556", "Pixel 9 API 36", "Emulator", "Online"); + emulator2.SetMetadata ("AvdName", "pixel_9_api_36"); + + var adbDevices = new List { emulator1, emulator2 }; + var availableEmulators = new List { "pixel_7_api_35", "pixel_9_api_36" }; + + var result = task.MergeDevicesAndEmulators (adbDevices, availableEmulators); + + Assert.AreEqual (2, result.Count, "Should not add duplicates when all emulators are running"); + Assert.AreEqual ("Pixel 7 API 35", result [0].GetMetadata ("Description"), "First should be alphabetically first"); + Assert.AreEqual ("Pixel 9 API 36", result [1].GetMetadata ("Description"), "Second should be alphabetically second"); + Assert.IsTrue (result.TrueForAll (d => d.GetMetadata ("Status") == "Online"), "All should be Online (running)"); + } + + [Test] + public void MergeDevicesAndEmulators_NonRunningEmulatorHasFormattedDescription () + { + var task = new MockGetAvailableAndroidDevices { + BuildEngine = engine, + }; + + var adbDevices = new List (); + var availableEmulators = new List { "pixel_7_pro_api_35" }; + + var result = task.MergeDevicesAndEmulators (adbDevices, availableEmulators); + + Assert.AreEqual (1, result.Count); + Assert.AreEqual ("Pixel 7 Pro API 35 (Not Running)", result [0].GetMetadata ("Description"), "Description should be formatted with (Not Running) suffix"); + } + + /// + /// Helper method to create a device ITaskItem for testing + /// + static ITaskItem CreateDeviceItem (string serial, string description, string type, string status) + { + var item = new TaskItem (serial); + item.SetMetadata ("Description", description); + item.SetMetadata ("Type", type); + item.SetMetadata ("Status", status); + return item; + } } } diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index 30e7b9d3813..20fa0c3e270 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -791,17 +791,17 @@ because xbuild doesn't support framework reference assemblies. /> - - - - $(MonoAndroidBinDirectory) <_DefaultLintToolPath>$(_AndroidSdkDirectory)\cmdline-tools\$(AndroidCommandLineToolsVersion)\bin $(_DefaultLintToolPath) + $(_AndroidPlatformToolsDirectory) + adb.exe + adb + $(_AndroidSdkDirectory)emulator\ + emulator.exe + emulator From 4d8a9079c4a796b8e59e8e8f4910b2c781303584 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Tue, 17 Feb 2026 15:22:19 -0600 Subject: [PATCH 02/12] Update src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs index f2dc2858264..12a768505be 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -29,7 +29,7 @@ public class BootAndroidEmulator : AndroidTask const int DefaultBootTimeoutSeconds = 300; const int PollIntervalMilliseconds = 3000; - public override string TaskPrefix => "BAE0"; + public override string TaskPrefix => "BAE"; /// /// The device identifier from 'dotnet run' device selection. May be an ADB serial From d90f95c4b6a54b1166628e03e9bd870d59296a18 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Tue, 17 Feb 2026 15:29:58 -0600 Subject: [PATCH 03/12] Unit tests for `BootAndroidEmulator` task. --- .../Tasks/BootAndroidEmulator.cs | 25 +- .../Tasks/BootAndroidEmulatorTests.cs | 227 ++++++++++++++++++ 2 files changed, 241 insertions(+), 11 deletions(-) create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs index 12a768505be..896e971dd72 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -161,7 +161,7 @@ public override bool RunTask () /// /// Checks whether the given deviceId is currently listed as an online device in 'adb devices'. /// - bool IsOnlineAdbDevice (string adbPath, string deviceId) + protected virtual bool IsOnlineAdbDevice (string adbPath, string deviceId) { bool found = false; @@ -187,7 +187,7 @@ bool IsOnlineAdbDevice (string adbPath, string deviceId) /// 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. /// - string? FindRunningEmulatorForAvd (string adbPath, string avdName) + protected virtual string? FindRunningEmulatorForAvd (string adbPath, string avdName) { var emulatorSerials = new List (); @@ -218,7 +218,7 @@ bool IsOnlineAdbDevice (string adbPath, string deviceId) /// /// Gets the AVD name from a running emulator via 'adb -s serial emu avd name'. /// - string? GetRunningAvdName (string adbPath, string serial) + protected virtual string? GetRunningAvdName (string adbPath, string serial) { string? avdName = null; try { @@ -250,7 +250,7 @@ bool IsOnlineAdbDevice (string adbPath, string deviceId) /// /// Launches the emulator process in the background with -no-window (unless overridden by extra args). /// - Process? LaunchEmulatorProcess (string emulatorPath, string avdName) + protected virtual Process? LaunchEmulatorProcess (string emulatorPath, string avdName) { var arguments = $"-avd {avdName}"; if (!EmulatorExtraArguments.IsNullOrEmpty ()) { @@ -341,14 +341,17 @@ bool WaitForFullBoot (string adbPath, string avdName, string serial) break; } - if (stopwatch.Elapsed >= timeout) { - Log.LogCodedError ("XA0145", Properties.Resources.XA0145, avdName, BootTimeoutSeconds); - return false; - } - 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"); @@ -367,7 +370,7 @@ bool WaitForFullBoot (string adbPath, string avdName, string serial) /// /// Gets a system property from the device via 'adb -s serial shell getprop property'. /// - string? GetShellProperty (string adbPath, string serial, string propertyName) + protected virtual string? GetShellProperty (string adbPath, string serial, string propertyName) { string? value = null; try { @@ -391,7 +394,7 @@ bool WaitForFullBoot (string adbPath, string avdName, string serial) /// /// Runs a shell command on the device and returns the first line of output. /// - string? RunShellCommand (string adbPath, string serial, string command) + protected virtual string? RunShellCommand (string adbPath, string serial, string command) { string? result = null; try { 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 new file mode 100644 index 00000000000..462bdfd0c31 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs @@ -0,0 +1,227 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using NUnit.Framework; +using Xamarin.Android.Tasks; + +namespace Xamarin.Android.Build.Tests; + +[TestFixture] +public class BootAndroidEmulatorTests : BaseTest +{ + List errors = []; + List warnings = []; + List messages = []; + MockBuildEngine engine = null!; + + [SetUp] + public void Setup () + { + engine = new MockBuildEngine (TestContext.Out, errors = [], warnings = [], messages = []); + } + + /// + /// Mock version of BootAndroidEmulator that overrides all process-dependent methods + /// so we can test the task logic without launching real emulators or adb. + /// + 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) + { + 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; + } + } + + MockBootAndroidEmulator CreateTask (string device = "Pixel_6_API_33") + { + return new MockBootAndroidEmulator { + BuildEngine = engine, + Device = device, + EmulatorToolPath = "/sdk/emulator/", + EmulatorToolExe = "emulator", + AdbToolPath = "/sdk/platform-tools/", + AdbToolExe = "adb", + BootTimeoutSeconds = 10, + }; + } + + [Test] + public void AlreadyOnlineDevice_PassesThrough () + { + var task = CreateTask ("emulator-5554"); + task.OnlineDevices = ["emulator-5554"]; + + Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.AreEqual ("emulator-5554", task.ResolvedDevice); + Assert.AreEqual ("-s emulator-5554", task.AdbTarget); + Assert.AreEqual (0, errors.Count, "Should have no errors"); + } + + [Test] + public void AlreadyOnlinePhysicalDevice_PassesThrough () + { + var task = CreateTask ("0A041FDD400327"); + task.OnlineDevices = ["0A041FDD400327"]; + + Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.AreEqual ("0A041FDD400327", task.ResolvedDevice); + Assert.AreEqual ("-s 0A041FDD400327", task.AdbTarget); + } + + [Test] + 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.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); + } + + [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.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); + } + + [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.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"); + } + + [Test] + public void BootTimeout_PmNeverResponds () + { + 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.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"); + } + + [Test] + 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.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); + } +} From cb41291821a79b1d978210ddf382162eb14ad1c0 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Tue, 17 Feb 2026 16:49:04 -0600 Subject: [PATCH 04/12] Stop overwriting $(Device), only update $(AdbTarget) --- .../targets/Microsoft.Android.Sdk.Application.targets | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets index 3e027ec9eb4..937cec89327 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets @@ -57,8 +57,8 @@ This file contains targets specific for Android application projects. _EnsureDeviceBooted Target that ensures the selected device is online and ready. If $(Device) refers to an - unbooted AVD, this target boots the emulator and updates $(Device) and $(AdbTarget) to - the resolved ADB serial (e.g. "emulator-5554"). + unbooted AVD, this target boots the emulator and updates $(AdbTarget) to the resolved + ADB serial (e.g. "-s emulator-5554"). Runs before _DeployApk/_DeployAppBundle (which use AdbTarget for adb commands) and _GetPrimaryCpuAbi (commercial builds) when $(Device) is set. *********************************************************************************************** @@ -75,7 +75,6 @@ This file contains targets specific for Android application projects. AdbToolPath="$(AdbToolPath)" BootTimeoutSeconds="$(BootTimeoutSeconds)" EmulatorExtraArguments="$(EmulatorExtraArguments)"> - From 8ad05085935b6aceb7a23afd56177993bdfbc5e6 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 18 Feb 2026 08:45:11 -0600 Subject: [PATCH 05/12] Remove DependsOnTargets to fix circular dependency with _ResolveMonoAndroidSdks --- .../targets/Microsoft.Android.Sdk.Application.targets | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets index 937cec89327..7ec56de59a5 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets @@ -64,7 +64,6 @@ This file contains targets specific for Android application projects. *********************************************************************************************** --> Date: Wed, 18 Feb 2026 13:14:28 -0600 Subject: [PATCH 06/12] Restore DependsOnTargets, remove _GetPrimaryCpuAbi from BeforeTargets to fix circular dependency --- .../targets/Microsoft.Android.Sdk.Application.targets | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets index 7ec56de59a5..f202dc5496f 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets @@ -59,12 +59,13 @@ This file contains targets specific for Android application projects. Target that ensures the selected device is online and ready. If $(Device) refers to an unbooted AVD, this target boots the emulator and updates $(AdbTarget) to the resolved ADB serial (e.g. "-s emulator-5554"). - Runs before _DeployApk/_DeployAppBundle (which use AdbTarget for adb commands) and - _GetPrimaryCpuAbi (commercial builds) when $(Device) is set. + Runs before _DeployApk/_DeployAppBundle (which use AdbTarget for adb commands) + when $(Device) is set. *********************************************************************************************** --> Date: Wed, 18 Feb 2026 13:29:57 -0600 Subject: [PATCH 07/12] Fix circular dependency: make tool paths non-required, resolve from AndroidSdkDirectory _EnsureDeviceBooted cannot use DependsOnTargets='_ResolveMonoAndroidSdks' because in commercial builds _GetPrimaryCpuAbi is in _ResolveMonoAndroidSdksDependsOn, creating a cycle. Instead, restore BeforeTargets='_GetPrimaryCpuAbi' so AdbTarget is correct, and let BootAndroidEmulator compute tool paths from AndroidSdkDirectory (which is available because _ResolveSdks runs earlier in the dependency chain). --- .../Microsoft.Android.Sdk.Application.targets | 13 ++++- .../Tasks/BootAndroidEmulator.cs | 56 +++++++++++++++---- .../Tasks/BootAndroidEmulatorTests.cs | 19 +++++++ 3 files changed, 75 insertions(+), 13 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets index f202dc5496f..474ab2cf53d 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets @@ -59,16 +59,23 @@ This file contains targets specific for Android application projects. Target that ensures the selected device is online and ready. If $(Device) refers to an unbooted AVD, this target boots the emulator and updates $(AdbTarget) to the resolved ADB serial (e.g. "-s emulator-5554"). - Runs before _DeployApk/_DeployAppBundle (which use AdbTarget for adb commands) + Runs before _GetPrimaryCpuAbi (in commercial builds) so $(AdbTarget) is correct, + and before _DeployApk/_DeployAppBundle (which use AdbTarget for adb commands) when $(Device) is set. + + Note: this target CANNOT use DependsOnTargets="_ResolveMonoAndroidSdks" because in commercial + builds _GetPrimaryCpuAbi is in $(_ResolveMonoAndroidSdksDependsOn), which would create a cycle: + _ResolveMonoAndroidSdks -> _GetPrimaryCpuAbi -> _EnsureDeviceBooted -> _ResolveMonoAndroidSdks + Instead, the task computes tool paths from $(AndroidSdkDirectory), which is available because + _ResolveSdks runs earlier in the dependency chain. *********************************************************************************************** --> + /// Path to the Android SDK directory (e.g., "/usr/local/lib/android/sdk/"). + /// Used to compute default tool paths when EmulatorToolPath/AdbToolPath are not set. + /// + public string? AndroidSdkDirectory { get; set; } + /// /// Path to the emulator tool directory. + /// Defaults to $(AndroidSdkDirectory)/emulator/ if not set. /// - [Required] - public string EmulatorToolPath { get; set; } = ""; + public string? EmulatorToolPath { get; set; } /// /// Filename of the emulator executable (e.g., "emulator" or "emulator.exe"). /// - [Required] - public string EmulatorToolExe { get; set; } = ""; + public string? EmulatorToolExe { get; set; } /// /// Path to the adb tool directory. + /// Defaults to $(AndroidSdkDirectory)/platform-tools/ if not set. /// - [Required] - public string AdbToolPath { get; set; } = ""; + public string? AdbToolPath { get; set; } /// /// Filename of the adb executable (e.g., "adb" or "adb.exe"). /// - [Required] - public string AdbToolExe { get; set; } = ""; + public string? AdbToolExe { get; set; } /// /// Maximum time in seconds to wait for the emulator to fully boot. @@ -89,7 +94,8 @@ public class BootAndroidEmulator : AndroidTask public override bool RunTask () { - var adbPath = Path.Combine (AdbToolPath, AdbToolExe); + ResolveToolPaths (); + var adbPath = Path.Combine (AdbToolPath!, AdbToolExe!); // Check if DeviceId is already a known online ADB serial if (IsOnlineAdbDevice (adbPath, Device)) { @@ -102,7 +108,7 @@ public override bool RunTask () // 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 emulatorPath = Path.Combine (EmulatorToolPath, EmulatorToolExe); + var emulatorPath = Path.Combine (EmulatorToolPath!, EmulatorToolExe!); var avdName = Device; @@ -158,6 +164,36 @@ public override bool RunTask () } } + /// + /// Resolves tool paths from AndroidSdkDirectory when not explicitly set. + /// + void ResolveToolPaths () + { + if (AdbToolExe.IsNullOrEmpty ()) { + AdbToolExe = OS.IsWindows ? "adb.exe" : "adb"; + } + + if (AdbToolPath.IsNullOrEmpty ()) { + if (!AndroidSdkDirectory.IsNullOrEmpty ()) { + AdbToolPath = Path.Combine (AndroidSdkDirectory, "platform-tools") + Path.DirectorySeparatorChar; + } else { + AdbToolPath = ""; + } + } + + if (EmulatorToolExe.IsNullOrEmpty ()) { + EmulatorToolExe = OS.IsWindows ? "emulator.exe" : "emulator"; + } + + if (EmulatorToolPath.IsNullOrEmpty ()) { + if (!AndroidSdkDirectory.IsNullOrEmpty ()) { + EmulatorToolPath = Path.Combine (AndroidSdkDirectory, "emulator") + Path.DirectorySeparatorChar; + } else { + EmulatorToolPath = ""; + } + } + } + /// /// Checks whether the given deviceId is currently listed as an online device in 'adb devices'. /// 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 462bdfd0c31..90a12b8cd4c 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 @@ -224,4 +224,23 @@ public void MultipleEmulators_FindsCorrectAvd () Assert.AreEqual ("emulator-5556", task.ResolvedDevice); Assert.AreEqual ("-s emulator-5556", task.AdbTarget); } + + [Test] + public void ToolPaths_ResolvedFromAndroidSdkDirectory () + { + var task = new MockBootAndroidEmulator { + BuildEngine = engine, + Device = "emulator-5554", + AndroidSdkDirectory = "/android/sdk", + BootTimeoutSeconds = 10, + }; + task.OnlineDevices = ["emulator-5554"]; + + Assert.IsTrue (task.RunTask (), "RunTask should succeed"); + Assert.AreEqual ("emulator-5554", task.ResolvedDevice); + // Verify tool paths were computed from AndroidSdkDirectory + var separator = System.IO.Path.DirectorySeparatorChar; + Assert.AreEqual ($"/android/sdk{separator}platform-tools{separator}", task.AdbToolPath); + Assert.AreEqual ($"/android/sdk{separator}emulator{separator}", task.EmulatorToolPath); + } } From 4b8ffdd6f4f8b9b52cce68bb4356db493c4a4f6d Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Mon, 23 Feb 2026 11:26:18 -0600 Subject: [PATCH 08/12] Update src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs index d2ba17ac73c..07056b90134 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -284,7 +284,8 @@ protected virtual bool IsOnlineAdbDevice (string adbPath, string deviceId) } /// - /// Launches the emulator process in the background with -no-window (unless overridden by extra args). + /// 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) { From d3fbb16906ccc469c34e9ec92f4c6537bb543f68 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Mon, 23 Feb 2026 11:26:29 -0600 Subject: [PATCH 09/12] Update src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs index 07056b90134..ac293b5ba73 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -289,7 +289,7 @@ protected virtual bool IsOnlineAdbDevice (string adbPath, string deviceId) /// protected virtual Process? LaunchEmulatorProcess (string emulatorPath, string avdName) { - var arguments = $"-avd {avdName}"; + var arguments = $"-avd \"{avdName}\""; if (!EmulatorExtraArguments.IsNullOrEmpty ()) { arguments += $" {EmulatorExtraArguments}"; } From d42c16ba1fd9961f396f7bff86c21c9f6a3f1998 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Tue, 24 Feb 2026 16:20:33 -0600 Subject: [PATCH 10/12] Refactor ResolveToolPaths into ResolveAdbPath/ResolveEmulatorPath Replace the void ResolveToolPaths() method that mutated nullable properties (requiring null-forgiving operators at call sites) with two methods that return non-null strings: ResolveAdbPath() and ResolveEmulatorPath(). Also update copilot-instructions.md to explicitly forbid the null-forgiving operator (!) in all C# code, not just nullable reference type opt-ins. --- .github/copilot-instructions.md | 1 + .../Tasks/BootAndroidEmulator.cs | 48 +++++++++---------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4e278b7fee5..d1c6d0a9ce3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -129,6 +129,7 @@ if (!UncompressedFileExtensions.IsNullOrWhiteSpace ()) { ## Formatting C# code uses tabs (not spaces) and Mono style (`.editorconfig`): +- **NEVER** use `!` (null-forgiving operator) in C# code. Always refactor to avoid it, e.g. by having helper methods return non-null types or by checking for null explicitly. - Preserve existing formatting and comments - Space before `(` and `[`: `Foo ()`, `array [0]` - Use `""` not `string.Empty`, `[]` not `Array.Empty()` diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs index ac293b5ba73..1e7cc07657c 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -94,8 +94,7 @@ public class BootAndroidEmulator : AndroidTask public override bool RunTask () { - ResolveToolPaths (); - var adbPath = Path.Combine (AdbToolPath!, AdbToolExe!); + var adbPath = ResolveAdbPath (); // Check if DeviceId is already a known online ADB serial if (IsOnlineAdbDevice (adbPath, Device)) { @@ -108,8 +107,7 @@ public override bool RunTask () // 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 emulatorPath = Path.Combine (EmulatorToolPath!, EmulatorToolExe!); - + var emulatorPath = ResolveEmulatorPath (); var avdName = Device; // Check if this AVD is already running (but perhaps still booting) @@ -165,33 +163,35 @@ public override bool RunTask () } /// - /// Resolves tool paths from AndroidSdkDirectory when not explicitly set. + /// Resolves the full path to the adb executable, using AdbToolPath/AdbToolExe + /// if set, otherwise computing defaults from AndroidSdkDirectory. /// - void ResolveToolPaths () + string ResolveAdbPath () { - if (AdbToolExe.IsNullOrEmpty ()) { - AdbToolExe = OS.IsWindows ? "adb.exe" : "adb"; - } + var exe = AdbToolExe.IsNullOrEmpty () ? (OS.IsWindows ? "adb.exe" : "adb") : AdbToolExe; + var dir = AdbToolPath; - if (AdbToolPath.IsNullOrEmpty ()) { - if (!AndroidSdkDirectory.IsNullOrEmpty ()) { - AdbToolPath = Path.Combine (AndroidSdkDirectory, "platform-tools") + Path.DirectorySeparatorChar; - } else { - AdbToolPath = ""; - } + if (dir.IsNullOrEmpty () && !AndroidSdkDirectory.IsNullOrEmpty ()) { + dir = Path.Combine (AndroidSdkDirectory, "platform-tools"); } - if (EmulatorToolExe.IsNullOrEmpty ()) { - EmulatorToolExe = OS.IsWindows ? "emulator.exe" : "emulator"; - } + return dir.IsNullOrEmpty () ? exe : Path.Combine (dir, exe); + } - if (EmulatorToolPath.IsNullOrEmpty ()) { - if (!AndroidSdkDirectory.IsNullOrEmpty ()) { - EmulatorToolPath = Path.Combine (AndroidSdkDirectory, "emulator") + Path.DirectorySeparatorChar; - } else { - EmulatorToolPath = ""; - } + /// + /// Resolves the full path to the emulator executable, using EmulatorToolPath/EmulatorToolExe + /// if set, otherwise computing defaults from AndroidSdkDirectory. + /// + string ResolveEmulatorPath () + { + var exe = EmulatorToolExe.IsNullOrEmpty () ? (OS.IsWindows ? "emulator.exe" : "emulator") : EmulatorToolExe; + var dir = EmulatorToolPath; + + if (dir.IsNullOrEmpty () && !AndroidSdkDirectory.IsNullOrEmpty ()) { + dir = Path.Combine (AndroidSdkDirectory, "emulator"); } + + return dir.IsNullOrEmpty () ? exe : Path.Combine (dir, exe); } /// From c84984e276e8631917e945e5111d850effe11e68 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Tue, 24 Feb 2026 16:32:01 -0600 Subject: [PATCH 11/12] Reduce poll interval from 3s to 500ms The polling loops (WaitForEmulatorOnline, WaitForFullBoot) each wasted an average of 1.5s after their condition became true. Each poll is a lightweight adb shell command that completes almost instantly, so 500ms polling adds negligible overhead while reducing average wasted time from ~4.5s to ~750ms across the three polling phases. --- src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs index 1e7cc07657c..bcfa0d656de 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -28,7 +28,7 @@ namespace Xamarin.Android.Tasks; public class BootAndroidEmulator : AndroidTask { const int DefaultBootTimeoutSeconds = 300; - const int PollIntervalMilliseconds = 3000; + const int PollIntervalMilliseconds = 500; public override string TaskPrefix => "BAE"; From 55c509dd172927a4799c48ad62157449bc5da674 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 25 Feb 2026 09:34:59 -0600 Subject: [PATCH 12/12] Fix ToolPaths_ResolvedFromAndroidSdkDirectory test ResolveAdbPath/ResolveEmulatorPath no longer mutate the AdbToolPath/EmulatorToolPath properties, so the test cannot assert on them. The test now just verifies the task succeeds without explicit tool paths set (proving the resolution logic works). --- .../Tasks/BootAndroidEmulatorTests.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 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 90a12b8cd4c..58ab1ae095e 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 @@ -236,11 +236,9 @@ public void ToolPaths_ResolvedFromAndroidSdkDirectory () }; 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); - // Verify tool paths were computed from AndroidSdkDirectory - var separator = System.IO.Path.DirectorySeparatorChar; - Assert.AreEqual ($"/android/sdk{separator}platform-tools{separator}", task.AdbToolPath); - Assert.AreEqual ($"/android/sdk{separator}emulator{separator}", task.EmulatorToolPath); } }