diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 32b29dd094f..d1c6d0a9ce3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -129,10 +129,12 @@ 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()` - 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..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 @@ -10,6 +10,7 @@ This file contains targets specific for Android application projects. + @@ -44,11 +45,47 @@ 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..bcfa0d656de --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tasks/BootAndroidEmulator.cs @@ -0,0 +1,454 @@ +#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; + +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 = 500; + + public override string TaskPrefix => "BAE"; + + /// + /// 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 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. + /// + public string? EmulatorToolPath { get; set; } + + /// + /// Filename of the emulator executable (e.g., "emulator" or "emulator.exe"). + /// + public string? EmulatorToolExe { get; set; } + + /// + /// Path to the adb tool directory. + /// Defaults to $(AndroidSdkDirectory)/platform-tools/ if not set. + /// + public string? AdbToolPath { get; set; } + + /// + /// Filename of the adb executable (e.g., "adb" or "adb.exe"). + /// + 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 = ResolveAdbPath (); + + // 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 = ResolveEmulatorPath (); + 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; + } + } + + /// + /// Resolves the full path to the adb executable, using AdbToolPath/AdbToolExe + /// if set, otherwise computing defaults from AndroidSdkDirectory. + /// + string ResolveAdbPath () + { + var exe = AdbToolExe.IsNullOrEmpty () ? (OS.IsWindows ? "adb.exe" : "adb") : AdbToolExe; + var dir = AdbToolPath; + + if (dir.IsNullOrEmpty () && !AndroidSdkDirectory.IsNullOrEmpty ()) { + dir = Path.Combine (AndroidSdkDirectory, "platform-tools"); + } + + return dir.IsNullOrEmpty () ? exe : Path.Combine (dir, exe); + } + + /// + /// 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); + } + + /// + /// 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/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/BootAndroidEmulatorTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs new file mode 100644 index 00000000000..58ab1ae095e --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/BootAndroidEmulatorTests.cs @@ -0,0 +1,244 @@ +#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); + } + + [Test] + public void ToolPaths_ResolvedFromAndroidSdkDirectory () + { + var task = new MockBootAndroidEmulator { + BuildEngine = engine, + Device = "emulator-5554", + AndroidSdkDirectory = "/android/sdk", + BootTimeoutSeconds = 10, + }; + 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); + } +} 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