From 8a939395a464f796f2c79287757217ed986bfe29 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Mon, 1 Jun 2026 14:58:14 +0100 Subject: [PATCH] Add AVD skin enumeration API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AvdManagerRunner.ListAvdSkinsAsync(sdkPath, cancellationToken) which enumerates emulator skins discovered across the four SDK locations they can live in: * `/skins/` (top-level shared skins) * `/platforms//skins/` (per-platform built-in skins like HVGA, WVGA800, WXGA720 — the layout that ships with the SDK Platforms package, including the Visual Studio-installed Android SDK) * `/add-ons//skins/` (legacy add-on skins, e.g. older Google APIs add-ons) * `/system-images////skins/` (per-system-image skins shipped with recent Pixel images) Discovered skin names are deduplicated and sorted. `CancellationToken` is observed throughout enumeration including inside the per-directory loops and `AddSkinDirectories` helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 1 + .../netstandard2.0/PublicAPI.Unshipped.txt | 1 + .../Runners/AvdManagerRunner.cs | 100 +++++++++ .../AvdManagerRunnerTests.cs | 207 ++++++++++++++++++ 4 files changed, 309 insertions(+) diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 6ed1bba0..7ac41707 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -199,6 +199,7 @@ virtual Xamarin.Android.Tools.AdbRunner.ListReversePortsAsync(string! serial, Sy virtual Xamarin.Android.Tools.AdbRunner.RemoveAllReversePortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Xamarin.Android.Tools.AdbRunner.RemoveReversePortAsync(string! serial, Xamarin.Android.Tools.AdbPortSpec! remote, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Xamarin.Android.Tools.AdbRunner.ReversePortAsync(string! serial, Xamarin.Android.Tools.AdbPortSpec! remote, Xamarin.Android.Tools.AdbPortSpec! local, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.AvdManagerRunner.ListAvdSkinsAsync(string! sdkPath, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Xamarin.Android.Tools.AvdManagerRunner.ListDeviceProfilesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Xamarin.Android.Tools.AvdDeviceProfile Xamarin.Android.Tools.AvdDeviceProfile.AvdDeviceProfile(string! Id) -> void diff --git a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 6ed1bba0..7ac41707 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Xamarin.Android.Tools.AndroidSdk/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -199,6 +199,7 @@ virtual Xamarin.Android.Tools.AdbRunner.ListReversePortsAsync(string! serial, Sy virtual Xamarin.Android.Tools.AdbRunner.RemoveAllReversePortsAsync(string! serial, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Xamarin.Android.Tools.AdbRunner.RemoveReversePortAsync(string! serial, Xamarin.Android.Tools.AdbPortSpec! remote, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! virtual Xamarin.Android.Tools.AdbRunner.ReversePortAsync(string! serial, Xamarin.Android.Tools.AdbPortSpec! remote, Xamarin.Android.Tools.AdbPortSpec! local, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Xamarin.Android.Tools.AvdManagerRunner.ListAvdSkinsAsync(string! sdkPath, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Xamarin.Android.Tools.AvdManagerRunner.ListDeviceProfilesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Xamarin.Android.Tools.AvdDeviceProfile Xamarin.Android.Tools.AvdDeviceProfile.AvdDeviceProfile(string! Id) -> void diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs index cb0dcfda..2cf73b75 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs @@ -151,6 +151,106 @@ internal static IReadOnlyList ParseCompactDeviceListOutput (st return profiles; } + /// + /// Lists available AVD skins by scanning the SDK for built-in and downloaded skin definitions. + /// + /// + /// The following SDK locations are scanned and the discovered skin directory names are deduplicated: + /// + /// <sdk>/skins/ — top-level shared skins. + /// <sdk>/platforms/<api>/skins/ — per-platform built-in skins (e.g. HVGA, WVGA800, WXGA720). + /// <sdk>/add-ons/<addon>/skins/ — legacy SDK add-on skins (e.g. older Google APIs add-ons). + /// <sdk>/system-images/<api>/<tag>/<abi>/skins/ — per-system-image skins shipped with recent Pixel images. + /// + /// + /// Root path of the Android SDK. + /// Cancellation token observed throughout directory enumeration. + /// A sorted, deduplicated list of skin directory names discovered across the SDK. + public Task> ListAvdSkinsAsync (string sdkPath, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace (sdkPath)) + throw new ArgumentException ("SDK path must not be empty.", nameof (sdkPath)); + + // Skin enumeration is purely synchronous filesystem work, but it can walk a large SDK tree; + // offload to the thread pool so callers don't block, and to keep the runner's async surface consistent. + return Task.Run (() => EnumerateSkins (sdkPath, cancellationToken), cancellationToken); + } + + internal static IReadOnlyList EnumerateSkins (string sdkPath, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested (); + + // Skin directory names round-trip case-sensitively on Linux/macOS, so only collapse case on Windows. + var skins = new SortedSet (OS.IsWindows ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + + // Standalone skins: /skins// + AddSkinDirectories (skins, Path.Combine (sdkPath, "skins"), cancellationToken); + + // Per-platform built-in skins: /platforms//skins// + // This is where the SDK Platforms package ships skins (HVGA, WVGA800, WXGA720, ...). + AddNestedSkinDirectories (skins, Path.Combine (sdkPath, "platforms"), cancellationToken); + + // Legacy add-on skins: /add-ons//skins// + AddNestedSkinDirectories (skins, Path.Combine (sdkPath, "add-ons"), cancellationToken); + + // Per-system-image skins: /system-images////skins// + var systemImagesDir = Path.Combine (sdkPath, "system-images"); + if (Directory.Exists (systemImagesDir)) { + try { + foreach (var apiDir in Directory.EnumerateDirectories (systemImagesDir)) { + cancellationToken.ThrowIfCancellationRequested (); + try { + foreach (var tagDir in Directory.EnumerateDirectories (apiDir)) { + cancellationToken.ThrowIfCancellationRequested (); + foreach (var abiDir in Directory.EnumerateDirectories (tagDir)) { + cancellationToken.ThrowIfCancellationRequested (); + AddSkinDirectories (skins, Path.Combine (abiDir, "skins"), cancellationToken); + } + } + } catch (IOException) { + } catch (UnauthorizedAccessException) { + } + } + } catch (IOException) { + } catch (UnauthorizedAccessException) { + } + } + + return skins.ToList (); + } + + // Walk a single level of subdirectories (e.g. platforms/ or add-ons/) and + // pull skin names out of each child's "skins" subdirectory if it exists. + static void AddNestedSkinDirectories (SortedSet skins, string parentDir, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested (); + if (!Directory.Exists (parentDir)) + return; + try { + foreach (var childDir in Directory.EnumerateDirectories (parentDir)) { + cancellationToken.ThrowIfCancellationRequested (); + AddSkinDirectories (skins, Path.Combine (childDir, "skins"), cancellationToken); + } + } catch (IOException) { + } catch (UnauthorizedAccessException) { + } + } + + static void AddSkinDirectories (SortedSet skins, string directory, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested (); + if (!Directory.Exists (directory)) + return; + try { + foreach (var skinDir in Directory.EnumerateDirectories (directory)) { + cancellationToken.ThrowIfCancellationRequested (); + skins.Add (Path.GetFileName (skinDir)); + } + } catch (IOException) { + } catch (UnauthorizedAccessException) { + } + } + internal static IReadOnlyList ParseAvdListOutput (string output) { var avds = new List (); diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs index 187beedb..585d5cc2 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using NUnit.Framework; namespace Xamarin.Android.Tools.Tests; @@ -350,4 +351,210 @@ public void ParseCompactDeviceListOutput_ReturnsIReadOnlyList () var profiles = AvdManagerRunner.ParseCompactDeviceListOutput (""); Assert.IsInstanceOf> (profiles); } + + // --- EnumerateSkins tests --- + + [Test] + public void EnumerateSkins_FindsStandaloneSkins () + { + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-skin-test-{Path.GetRandomFileName ()}"); + try { + Directory.CreateDirectory (Path.Combine (sdkDir, "skins", "pixel_7_pro")); + Directory.CreateDirectory (Path.Combine (sdkDir, "skins", "nexus_5x")); + + var skins = AvdManagerRunner.EnumerateSkins (sdkDir); + + Assert.AreEqual (2, skins.Count); + Assert.That (skins, Contains.Item ("nexus_5x")); + Assert.That (skins, Contains.Item ("pixel_7_pro")); + } finally { + Directory.Delete (sdkDir, true); + } + } + + [Test] + public void EnumerateSkins_FindsSystemImageSkins () + { + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-skin-test-{Path.GetRandomFileName ()}"); + try { + var imgSkinsDir = Path.Combine (sdkDir, "system-images", "android-35", "google_apis", "x86_64", "skins"); + Directory.CreateDirectory (Path.Combine (imgSkinsDir, "pixel_tablet")); + + var skins = AvdManagerRunner.EnumerateSkins (sdkDir); + + Assert.AreEqual (1, skins.Count); + Assert.AreEqual ("pixel_tablet", skins [0]); + } finally { + Directory.Delete (sdkDir, true); + } + } + + [Test] + public void EnumerateSkins_DeduplicatesAndSorts () + { + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-skin-test-{Path.GetRandomFileName ()}"); + try { + Directory.CreateDirectory (Path.Combine (sdkDir, "skins", "pixel_7")); + var imgSkinsDir = Path.Combine (sdkDir, "system-images", "android-35", "google_apis", "x86_64", "skins"); + Directory.CreateDirectory (Path.Combine (imgSkinsDir, "pixel_7")); + Directory.CreateDirectory (Path.Combine (imgSkinsDir, "auto_skin")); + + var skins = AvdManagerRunner.EnumerateSkins (sdkDir); + + Assert.AreEqual (2, skins.Count); + Assert.AreEqual ("auto_skin", skins [0]); + Assert.AreEqual ("pixel_7", skins [1]); + } finally { + Directory.Delete (sdkDir, true); + } + } + + [Test] + public void EnumerateSkins_MissingSdkDir_ReturnsEmpty () + { + var skins = AvdManagerRunner.EnumerateSkins (Path.Combine (Path.GetTempPath (), "nonexistent-sdk-dir")); + Assert.AreEqual (0, skins.Count); + } + + [Test] + public void EnumerateSkins_FindsPlatformSkins () + { + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-skin-test-{Path.GetRandomFileName ()}"); + try { + Directory.CreateDirectory (Path.Combine (sdkDir, "platforms", "android-34", "skins", "HVGA")); + Directory.CreateDirectory (Path.Combine (sdkDir, "platforms", "android-34", "skins", "WVGA800")); + Directory.CreateDirectory (Path.Combine (sdkDir, "platforms", "android-35", "skins", "WXGA720")); + + var skins = AvdManagerRunner.EnumerateSkins (sdkDir); + + Assert.AreEqual (3, skins.Count); + Assert.AreEqual ("HVGA", skins [0]); + Assert.AreEqual ("WVGA800", skins [1]); + Assert.AreEqual ("WXGA720", skins [2]); + } finally { + Directory.Delete (sdkDir, true); + } + } + + [Test] + public void EnumerateSkins_FindsAddOnSkins () + { + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-skin-test-{Path.GetRandomFileName ()}"); + try { + Directory.CreateDirectory (Path.Combine (sdkDir, "add-ons", "addon-google_apis-google-24", "skins", "WSVGA")); + + var skins = AvdManagerRunner.EnumerateSkins (sdkDir); + + Assert.AreEqual (1, skins.Count); + Assert.AreEqual ("WSVGA", skins [0]); + } finally { + Directory.Delete (sdkDir, true); + } + } + + // Regression test for the Visual Studio-installed SDK layout reported on PR #326: + // `/system-images/...` exists with no `skins/` subfolder; skins live under + // `/platforms//skins/` and `/skins/`. + [Test] + public void EnumerateSkins_VsStyleLayout_FindsAll () + { + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-skin-test-{Path.GetRandomFileName ()}"); + try { + Directory.CreateDirectory (Path.Combine (sdkDir, "skins", "AndroidWearRound")); + Directory.CreateDirectory (Path.Combine (sdkDir, "platforms", "android-34", "skins", "HVGA")); + Directory.CreateDirectory (Path.Combine (sdkDir, "platforms", "android-34", "skins", "WVGA800")); + // system-images tree exists with no skins/ subdirectories (the VS layout case) + Directory.CreateDirectory (Path.Combine (sdkDir, "system-images", "android-34", "google_apis", "x86_64")); + + var skins = AvdManagerRunner.EnumerateSkins (sdkDir); + + Assert.AreEqual (3, skins.Count); + Assert.AreEqual ("AndroidWearRound", skins [0]); + Assert.AreEqual ("HVGA", skins [1]); + Assert.AreEqual ("WVGA800", skins [2]); + } finally { + Directory.Delete (sdkDir, true); + } + } + + [Test] + public void EnumerateSkins_CancelledToken_Throws () + { + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-skin-test-{Path.GetRandomFileName ()}"); + try { + Directory.CreateDirectory (Path.Combine (sdkDir, "skins", "pixel_7")); + using var cts = new CancellationTokenSource (); + cts.Cancel (); + + Assert.Throws (() => AvdManagerRunner.EnumerateSkins (sdkDir, cts.Token)); + } finally { + Directory.Delete (sdkDir, true); + } + } + + [Test] + public void EnumerateSkins_CancelledToken_EmptySdk_Throws () + { + // Regression: an SDK directory with no skin subtrees must still observe cancellation + // rather than silently returning an empty list. + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-skin-test-{Path.GetRandomFileName ()}"); + try { + Directory.CreateDirectory (sdkDir); + using var cts = new CancellationTokenSource (); + cts.Cancel (); + + Assert.Throws (() => AvdManagerRunner.EnumerateSkins (sdkDir, cts.Token)); + } finally { + Directory.Delete (sdkDir, true); + } + } + + [Test] + public void ListAvdSkinsAsync_ReturnsSkins () + { + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-skin-test-{Path.GetRandomFileName ()}"); + try { + Directory.CreateDirectory (Path.Combine (sdkDir, "skins", "pixel_7")); + Directory.CreateDirectory (Path.Combine (sdkDir, "platforms", "android-34", "skins", "HVGA")); + + var runner = new AvdManagerRunner ("/fake/avdmanager"); + var skins = runner.ListAvdSkinsAsync (sdkDir).GetAwaiter ().GetResult (); + + Assert.AreEqual (2, skins.Count); + Assert.AreEqual ("HVGA", skins [0]); + Assert.AreEqual ("pixel_7", skins [1]); + } finally { + Directory.Delete (sdkDir, true); + } + } + + [Test] + public void ListAvdSkinsAsync_CancelledToken_Throws () + { + var sdkDir = Path.Combine (Path.GetTempPath (), $"sdk-skin-test-{Path.GetRandomFileName ()}"); + try { + Directory.CreateDirectory (Path.Combine (sdkDir, "skins", "pixel_7")); + using var cts = new CancellationTokenSource (); + cts.Cancel (); + + var runner = new AvdManagerRunner ("/fake/avdmanager"); + Assert.CatchAsync (() => runner.ListAvdSkinsAsync (sdkDir, cts.Token)); + } finally { + Directory.Delete (sdkDir, true); + } + } + + [Test] + public void ListAvdSkinsAsync_NullSdkPath_ThrowsArgumentException () + { + var runner = new AvdManagerRunner ("/fake/avdmanager"); + Assert.Throws (() => runner.ListAvdSkinsAsync (null!)); + } + + [Test] + public void ListAvdSkinsAsync_EmptySdkPath_ThrowsArgumentException () + { + var runner = new AvdManagerRunner ("/fake/avdmanager"); + Assert.Throws (() => runner.ListAvdSkinsAsync ("")); + } }