diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Models/AvdInfo.cs b/src/Xamarin.Android.Tools.AndroidSdk/Models/AvdInfo.cs new file mode 100644 index 00000000..d627cfc1 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Models/AvdInfo.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Xamarin.Android.Tools; + +public class AvdInfo +{ + public string Name { get; set; } = string.Empty; + public string? DeviceProfile { get; set; } + public string? Path { get; set; } +} diff --git a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs index 8527ccd5..5ffdb970 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/ProcessUtils.cs @@ -230,6 +230,58 @@ internal static void ThrowIfFailed (int exitCode, string command, StringWriter? ThrowIfFailed (exitCode, command, stderr?.ToString (), stdout?.ToString ()); } + /// + /// Validates that a string parameter is not null or empty. + /// + internal static void ValidateNotNullOrEmpty (string? value, string paramName) + { + if (value is null) + throw new ArgumentNullException (paramName); + if (value.Length == 0) + throw new ArgumentException ("Value cannot be an empty string.", paramName); + } + + /// + /// Searches for a cmdline-tools binary in the SDK, preferring higher versioned directories. + /// Falls back to "latest" and then legacy "tools/bin". + /// + /// Root path to the Android SDK. + /// Tool binary name without extension (e.g., "avdmanager"). + /// File extension including the dot (e.g., ".bat") or empty string for no extension. + internal static string? FindCmdlineTool (string sdkPath, string toolName, string extension) + { + var cmdlineToolsDir = Path.Combine (sdkPath, "cmdline-tools"); + + if (Directory.Exists (cmdlineToolsDir)) { + var subdirs = new List<(string name, Version version)> (); + foreach (var dir in Directory.GetDirectories (cmdlineToolsDir)) { + var name = Path.GetFileName (dir); + if (string.IsNullOrEmpty (name) || name == "latest") + continue; + // Strip pre-release suffixes (e.g., "5.0-rc1" → "5.0") before parsing + var versionStr = name; + var dashIndex = name.IndexOf ('-'); + if (dashIndex >= 0) + versionStr = name.Substring (0, dashIndex); + Version.TryParse (versionStr, out var v); + subdirs.Add ((name, v ?? new Version (0, 0))); + } + subdirs.Sort ((a, b) => b.version.CompareTo (a.version)); + + foreach (var (name, _) in subdirs) { + var toolPath = Path.Combine (cmdlineToolsDir, name, "bin", toolName + extension); + if (File.Exists (toolPath)) + return toolPath; + } + var latestPath = Path.Combine (cmdlineToolsDir, "latest", "bin", toolName + extension); + if (File.Exists (latestPath)) + return latestPath; + } + + var legacyPath = Path.Combine (sdkPath, "tools", "bin", toolName + extension); + return File.Exists (legacyPath) ? legacyPath : null; + } + internal static IEnumerable FindExecutablesInPath (string executable) { var path = Environment.GetEnvironmentVariable (EnvironmentVariableNames.Path) ?? ""; diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs new file mode 100644 index 00000000..03064c92 --- /dev/null +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs @@ -0,0 +1,183 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Xamarin.Android.Tools; + +/// +/// Runs Android Virtual Device Manager (avdmanager) commands. +/// +public class AvdManagerRunner +{ + readonly string avdManagerPath; + readonly IDictionary? environmentVariables; + + /// + /// Creates a new AvdManagerRunner with the full path to the avdmanager executable. + /// + /// Full path to avdmanager (e.g., "/path/to/sdk/cmdline-tools/latest/bin/avdmanager"). + /// Optional environment variables to pass to avdmanager processes. + public AvdManagerRunner (string avdManagerPath, IDictionary? environmentVariables = null) + { + if (string.IsNullOrWhiteSpace (avdManagerPath)) + throw new ArgumentException ("Path to avdmanager must not be empty.", nameof (avdManagerPath)); + this.avdManagerPath = avdManagerPath; + this.environmentVariables = environmentVariables; + } + + public async Task> ListAvdsAsync (CancellationToken cancellationToken = default) + { + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (avdManagerPath, "list", "avd"); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + + ProcessUtils.ThrowIfFailed (exitCode, "avdmanager list avd", stderr); + + return ParseAvdListOutput (stdout.ToString ()); + } + + public async Task CreateAvdAsync (string name, string systemImage, string? deviceProfile = null, + bool force = false, CancellationToken cancellationToken = default) + { + ProcessUtils.ValidateNotNullOrEmpty (name, nameof (name)); + ProcessUtils.ValidateNotNullOrEmpty (systemImage, nameof (systemImage)); + + // Check if AVD already exists — return it instead of failing + if (!force) { + var existing = (await ListAvdsAsync (cancellationToken).ConfigureAwait (false)) + .FirstOrDefault (a => string.Equals (a.Name, name, StringComparison.OrdinalIgnoreCase)); + if (existing is not null) + return existing; + + // Detect orphaned AVD directory (folder exists without .ini registration). + var avdDir = Path.Combine (GetAvdRootDirectory (), $"{name}.avd"); + if (Directory.Exists (avdDir)) + force = true; + } + + var args = new List { "create", "avd", "-n", name, "-k", systemImage }; + if (deviceProfile is { Length: > 0 }) + args.AddRange (new [] { "-d", deviceProfile }); + if (force) + args.Add ("--force"); + + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (avdManagerPath, args.ToArray ()); + psi.RedirectStandardInput = true; + + // avdmanager prompts "Do you wish to create a custom hardware profile?" — answer "no" + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken, environmentVariables, + onStarted: p => { + try { + p.StandardInput.WriteLine ("no"); + p.StandardInput.Close (); + } catch (IOException) { + // Process may have already exited + } + }).ConfigureAwait (false); + + ProcessUtils.ThrowIfFailed (exitCode, $"avdmanager create avd -n {name}", stderr, stdout); + + // Re-list to get the actual path from avdmanager (respects ANDROID_USER_HOME/ANDROID_AVD_HOME) + var avds = await ListAvdsAsync (cancellationToken).ConfigureAwait (false); + var created = avds.FirstOrDefault (a => string.Equals (a.Name, name, StringComparison.OrdinalIgnoreCase)); + if (created is not null) + return created; + + // Fallback if re-list didn't find it + return new AvdInfo { + Name = name, + DeviceProfile = deviceProfile, + Path = Path.Combine (GetAvdRootDirectory (), $"{name}.avd"), + }; + } + + public async Task DeleteAvdAsync (string name, CancellationToken cancellationToken = default) + { + ProcessUtils.ValidateNotNullOrEmpty (name, nameof (name)); + + // Idempotent: if the AVD doesn't exist, treat as success + var avds = await ListAvdsAsync (cancellationToken).ConfigureAwait (false); + if (!avds.Any (a => string.Equals (a.Name, name, StringComparison.OrdinalIgnoreCase))) + return; + + using var stderr = new StringWriter (); + var psi = ProcessUtils.CreateProcessStartInfo (avdManagerPath, "delete", "avd", "--name", name); + var exitCode = await ProcessUtils.StartProcess (psi, null, stderr, cancellationToken, environmentVariables).ConfigureAwait (false); + + ProcessUtils.ThrowIfFailed (exitCode, $"avdmanager delete avd --name {name}", stderr); + } + + internal static IReadOnlyList ParseAvdListOutput (string output) + { + var avds = new List (); + string? currentName = null, currentDevice = null, currentPath = null; + + foreach (var line in output.Split ('\n')) { + var trimmed = line.Trim (); + if (trimmed.StartsWith ("Name:", StringComparison.OrdinalIgnoreCase)) { + if (currentName is not null) + avds.Add (new AvdInfo { Name = currentName, DeviceProfile = currentDevice, Path = currentPath }); + currentName = trimmed.Substring (5).Trim (); + currentDevice = currentPath = null; + } + else if (trimmed.StartsWith ("Device:", StringComparison.OrdinalIgnoreCase)) + currentDevice = trimmed.Substring (7).Trim (); + else if (trimmed.StartsWith ("Path:", StringComparison.OrdinalIgnoreCase)) + currentPath = trimmed.Substring (5).Trim (); + } + + if (currentName is not null) + avds.Add (new AvdInfo { Name = currentName, DeviceProfile = currentDevice, Path = currentPath }); + + return avds; + } + + /// + /// Resolves the AVD root directory, respecting ANDROID_AVD_HOME and ANDROID_USER_HOME. + /// Checks instance first, then falls back to process environment. + /// + string GetAvdRootDirectory () + { + // ANDROID_AVD_HOME takes highest priority + if (TryGetEnvironmentVariable (EnvironmentVariableNames.AndroidAvdHome, out var avdHome)) + return avdHome; + + // ANDROID_USER_HOME/avd is the next option + if (TryGetEnvironmentVariable (EnvironmentVariableNames.AndroidUserHome, out var userHome)) + return Path.Combine (userHome, "avd"); + + // Default: ~/.android/avd + return Path.Combine ( + Environment.GetFolderPath (Environment.SpecialFolder.UserProfile), + ".android", "avd"); + } + + /// + /// Looks up an environment variable, checking instance first, + /// then falling back to the process environment. + /// + bool TryGetEnvironmentVariable (string name, out string value) + { + if (environmentVariables is not null && environmentVariables.TryGetValue (name, out var dictValue) && dictValue is { Length: > 0 }) { + value = dictValue; + return true; + } + var envValue = Environment.GetEnvironmentVariable (name); + if (envValue is { Length: > 0 }) { + value = envValue; + return true; + } + value = string.Empty; + return false; + } +} + diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs new file mode 100644 index 00000000..fd3053ce --- /dev/null +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs @@ -0,0 +1,257 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NUnit.Framework; + +namespace Xamarin.Android.Tools.Tests; + +[TestFixture] +public class AvdManagerRunnerTests +{ + [Test] + public void ParseAvdListOutput_MultipleAvds () + { + var output = + "Available Android Virtual Devices:\n" + + " Name: Pixel_7_API_35\n" + + " Device: pixel_7 (Google)\n" + + " Path: /Users/test/.android/avd/Pixel_7_API_35.avd\n" + + " Target: Google APIs (Google Inc.)\n" + + " Based on: Android 15 Tag/ABI: google_apis/arm64-v8a\n" + + "---------\n" + + " Name: MAUI_Emulator\n" + + " Device: pixel_6 (Google)\n" + + " Path: /Users/test/.android/avd/MAUI_Emulator.avd\n" + + " Target: Google APIs (Google Inc.)\n" + + " Based on: Android 14 Tag/ABI: google_apis/x86_64\n"; + + var avds = AvdManagerRunner.ParseAvdListOutput (output); + + Assert.AreEqual (2, avds.Count); + + Assert.AreEqual ("Pixel_7_API_35", avds [0].Name); + Assert.AreEqual ("pixel_7 (Google)", avds [0].DeviceProfile); + Assert.AreEqual ("/Users/test/.android/avd/Pixel_7_API_35.avd", avds [0].Path); + + Assert.AreEqual ("MAUI_Emulator", avds [1].Name); + Assert.AreEqual ("pixel_6 (Google)", avds [1].DeviceProfile); + Assert.AreEqual ("/Users/test/.android/avd/MAUI_Emulator.avd", avds [1].Path); + } + + [Test] + public void ParseAvdListOutput_WindowsNewlines () + { + var output = + "Available Android Virtual Devices:\r\n" + + " Name: Test_AVD\r\n" + + " Device: Nexus 5X (Google)\r\n" + + " Path: C:\\Users\\test\\.android\\avd\\Test_AVD.avd\r\n" + + " Target: Google APIs (Google Inc.)\r\n"; + + var avds = AvdManagerRunner.ParseAvdListOutput (output); + + Assert.AreEqual (1, avds.Count); + Assert.AreEqual ("Test_AVD", avds [0].Name); + Assert.AreEqual ("Nexus 5X (Google)", avds [0].DeviceProfile); + Assert.AreEqual ("C:\\Users\\test\\.android\\avd\\Test_AVD.avd", avds [0].Path); + } + + [Test] + public void ParseAvdListOutput_EmptyOutput () + { + var avds = AvdManagerRunner.ParseAvdListOutput (""); + Assert.AreEqual (0, avds.Count); + } + + [Test] + public void ParseAvdListOutput_NoAvds () + { + var output = "Available Android Virtual Devices:\n"; + var avds = AvdManagerRunner.ParseAvdListOutput (output); + Assert.AreEqual (0, avds.Count); + } + + [Test] + public void ParseAvdListOutput_SingleAvdNoDevice () + { + var output = + " Name: Minimal_AVD\n" + + " Path: /home/user/.android/avd/Minimal_AVD.avd\n"; + + var avds = AvdManagerRunner.ParseAvdListOutput (output); + + Assert.AreEqual (1, avds.Count); + Assert.AreEqual ("Minimal_AVD", avds [0].Name); + Assert.IsNull (avds [0].DeviceProfile); + Assert.AreEqual ("/home/user/.android/avd/Minimal_AVD.avd", avds [0].Path); + } + + [Test] + public void ParseAvdListOutput_ReturnsIReadOnlyList () + { + var avds = AvdManagerRunner.ParseAvdListOutput (""); + Assert.IsInstanceOf> (avds); + } + + [Test] + public void FindCmdlineTool_FindsVersionedDir () + { + var tempDir = Path.Combine (Path.GetTempPath (), $"avd-test-{Path.GetRandomFileName ()}"); + var binDir = Path.Combine (tempDir, "cmdline-tools", "12.0", "bin"); + Directory.CreateDirectory (binDir); + + try { + var avdMgrName = OS.IsWindows ? "avdmanager.bat" : "avdmanager"; + File.WriteAllText (Path.Combine (binDir, avdMgrName), ""); + + var path = ProcessUtils.FindCmdlineTool (tempDir, "avdmanager", OS.IsWindows ? ".bat" : ""); + Assert.IsNotNull (path); + Assert.IsTrue (path!.Contains ("12.0")); + } finally { + Directory.Delete (tempDir, true); + } + } + + [Test] + public void FindCmdlineTool_PrefersHigherVersion () + { + var tempDir = Path.Combine (Path.GetTempPath (), $"avd-test-{Path.GetRandomFileName ()}"); + var avdMgrName = OS.IsWindows ? "avdmanager.bat" : "avdmanager"; + + var binDir10 = Path.Combine (tempDir, "cmdline-tools", "10.0", "bin"); + var binDir12 = Path.Combine (tempDir, "cmdline-tools", "12.0", "bin"); + Directory.CreateDirectory (binDir10); + Directory.CreateDirectory (binDir12); + File.WriteAllText (Path.Combine (binDir10, avdMgrName), ""); + File.WriteAllText (Path.Combine (binDir12, avdMgrName), ""); + + try { + var path = ProcessUtils.FindCmdlineTool (tempDir, "avdmanager", OS.IsWindows ? ".bat" : ""); + Assert.IsNotNull (path); + Assert.IsTrue (path!.Contains ("12.0")); + } finally { + Directory.Delete (tempDir, true); + } + } + + [Test] + public void FindCmdlineTool_HandlesPreReleaseVersionDir () + { + var tempDir = Path.Combine (Path.GetTempPath (), $"avd-test-{Path.GetRandomFileName ()}"); + var avdMgrName = OS.IsWindows ? "avdmanager.bat" : "avdmanager"; + + // Pre-release "13.0-rc1" should be preferred over "12.0" + var binDir12 = Path.Combine (tempDir, "cmdline-tools", "12.0", "bin"); + var binDirRc = Path.Combine (tempDir, "cmdline-tools", "13.0-rc1", "bin"); + Directory.CreateDirectory (binDir12); + Directory.CreateDirectory (binDirRc); + File.WriteAllText (Path.Combine (binDir12, avdMgrName), ""); + File.WriteAllText (Path.Combine (binDirRc, avdMgrName), ""); + + try { + var path = ProcessUtils.FindCmdlineTool (tempDir, "avdmanager", OS.IsWindows ? ".bat" : ""); + Assert.IsNotNull (path); + Assert.IsTrue (path!.Contains ("13.0-rc1"), $"Expected 13.0-rc1, got: {path}"); + } finally { + Directory.Delete (tempDir, true); + } + } + + [Test] + public void FindCmdlineTool_FallsBackToLatest () + { + var tempDir = Path.Combine (Path.GetTempPath (), $"avd-test-{Path.GetRandomFileName ()}"); + var binDir = Path.Combine (tempDir, "cmdline-tools", "latest", "bin"); + Directory.CreateDirectory (binDir); + + try { + var avdMgrName = OS.IsWindows ? "avdmanager.bat" : "avdmanager"; + File.WriteAllText (Path.Combine (binDir, avdMgrName), ""); + + var path = ProcessUtils.FindCmdlineTool (tempDir, "avdmanager", OS.IsWindows ? ".bat" : ""); + Assert.IsNotNull (path); + Assert.IsTrue (path!.Contains ("latest")); + } finally { + Directory.Delete (tempDir, true); + } + } + + [Test] + public void FindCmdlineTool_MissingSdk_ReturnsNull () + { + var path = ProcessUtils.FindCmdlineTool ("/nonexistent/path", "avdmanager", OS.IsWindows ? ".bat" : ""); + Assert.IsNull (path); + } + + [Test] + public void Constructor_NullPath_ThrowsArgumentException () + { + Assert.Throws (() => new AvdManagerRunner (null!)); + } + + [Test] + public void Constructor_EmptyPath_ThrowsArgumentException () + { + Assert.Throws (() => new AvdManagerRunner ("")); + } + + [Test] + public void Constructor_WhitespacePath_ThrowsArgumentException () + { + Assert.Throws (() => new AvdManagerRunner (" ")); + } + + [Test] + public void Constructor_AcceptsEnvironmentVariables () + { + var env = new Dictionary { { "ANDROID_HOME", "/test/sdk" } }; + var runner = new AvdManagerRunner ("/fake/avdmanager", env); + Assert.IsNotNull (runner); + } + + [Test] + public void CreateAvdAsync_NullName_ThrowsArgumentNullException () + { + var runner = new AvdManagerRunner ("/fake/avdmanager"); + Assert.ThrowsAsync (() => runner.CreateAvdAsync (null!, "system-image")); + } + + [Test] + public void CreateAvdAsync_EmptyName_ThrowsArgumentException () + { + var runner = new AvdManagerRunner ("/fake/avdmanager"); + Assert.ThrowsAsync (() => runner.CreateAvdAsync ("", "system-image")); + } + + [Test] + public void CreateAvdAsync_NullSystemImage_ThrowsArgumentNullException () + { + var runner = new AvdManagerRunner ("/fake/avdmanager"); + Assert.ThrowsAsync (() => runner.CreateAvdAsync ("test-avd", null!)); + } + + [Test] + public void CreateAvdAsync_EmptySystemImage_ThrowsArgumentException () + { + var runner = new AvdManagerRunner ("/fake/avdmanager"); + Assert.ThrowsAsync (() => runner.CreateAvdAsync ("test-avd", "")); + } + + [Test] + public void DeleteAvdAsync_NullName_ThrowsArgumentNullException () + { + var runner = new AvdManagerRunner ("/fake/avdmanager"); + Assert.ThrowsAsync (() => runner.DeleteAvdAsync (null!)); + } + + [Test] + public void DeleteAvdAsync_EmptyName_ThrowsArgumentException () + { + var runner = new AvdManagerRunner ("/fake/avdmanager"); + Assert.ThrowsAsync (() => runner.DeleteAvdAsync ("")); + } +}