From c53c7b0fb1694129eb10787a5bcdba28be9390cb Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Wed, 8 Apr 2026 17:38:12 +0100 Subject: [PATCH 1/2] Add AVD skin enumeration API Add AvdManagerRunner.ListAvdSkinsAsync() static method to enumerate available AVD skins from the SDK skins/ directory and system image skin directories. Returns a deduplicated, sorted list of skin names. Includes 6 unit tests with temp directory structures and PublicAPI entries for both net10.0 and netstandard2.0. Closes #322 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 | 48 ++++++++++++ .../AvdManagerRunnerTests.cs | 76 +++++++++++++++++++ 4 files changed, 126 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 61fc0e0b..31595a1b 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,3 +199,4 @@ 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! +static Xamarin.Android.Tools.AvdManagerRunner.ListAvdSkinsAsync(string! sdkPath, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! 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 61fc0e0b..31595a1b 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,3 +199,4 @@ 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! +static Xamarin.Android.Tools.AvdManagerRunner.ListAvdSkinsAsync(string! sdkPath, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs index b233ae38..3257bf0c 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs @@ -122,6 +122,54 @@ public async Task DeleteAvdAsync (string name, CancellationToken cancellationTok ProcessUtils.ThrowIfFailed (exitCode, $"avdmanager delete avd --name {name}", stderr); } + /// + /// Lists available AVD skins by scanning the SDK skins/ directory. + /// + /// Root path of the Android SDK. + /// Cancellation token. + /// Sorted list of unique skin directory names. + public static Task> ListAvdSkinsAsync (string sdkPath, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace (sdkPath)) + throw new ArgumentException ("SDK path must not be empty.", nameof (sdkPath)); + + cancellationToken.ThrowIfCancellationRequested (); + + return Task.FromResult (EnumerateSkins (sdkPath)); + } + + internal static IReadOnlyList EnumerateSkins (string sdkPath) + { + var skins = new SortedSet (StringComparer.OrdinalIgnoreCase); + + // Standalone skins: /skins// + var skinsDir = Path.Combine (sdkPath, "skins"); + AddSkinDirectories (skins, skinsDir); + + // System image skins: /system-images////skins// + var systemImagesDir = Path.Combine (sdkPath, "system-images"); + if (Directory.Exists (systemImagesDir)) { + foreach (var apiDir in Directory.EnumerateDirectories (systemImagesDir)) { + foreach (var tagDir in Directory.EnumerateDirectories (apiDir)) { + foreach (var abiDir in Directory.EnumerateDirectories (tagDir)) { + var imgSkinsDir = Path.Combine (abiDir, "skins"); + AddSkinDirectories (skins, imgSkinsDir); + } + } + } + } + + return skins.ToList (); + } + + static void AddSkinDirectories (SortedSet skins, string directory) + { + if (!Directory.Exists (directory)) + return; + foreach (var skinDir in Directory.EnumerateDirectories (directory)) + skins.Add (Path.GetFileName (skinDir)); + } + 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 6ae30de8..e60f9600 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs @@ -293,4 +293,80 @@ public void FindCmdlineTool_PrefersStableOverPreRelease () Directory.Delete (tempDir, true); } } + + // --- 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 ListAvdSkinsAsync_NullSdkPath_ThrowsArgumentException () + { + Assert.ThrowsAsync (() => AvdManagerRunner.ListAvdSkinsAsync (null!)); + } + + [Test] + public void ListAvdSkinsAsync_EmptySdkPath_ThrowsArgumentException () + { + Assert.ThrowsAsync (() => AvdManagerRunner.ListAvdSkinsAsync ("")); + } } From 0840ab24a36368b762a25f1430fa6a7c3098a5ef Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Thu, 9 Apr 2026 13:27:48 +0100 Subject: [PATCH 2/2] Fix ListAvdSkins: drop fake async, add IO exception handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename ListAvdSkinsAsync → ListAvdSkins (synchronous API that doesn't hide sync I/O behind Task.FromResult) - Check CancellationToken during directory enumeration loops - Catch IOException/UnauthorizedAccessException per subtree so one bad directory doesn't fail the entire listing - Fix doc comment to mention system-images scan - Update PublicAPI files and tests to match new signature Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 2 +- .../netstandard2.0/PublicAPI.Unshipped.txt | 2 +- .../Runners/AvdManagerRunner.cs | 40 ++++++++++++------- .../AvdManagerRunnerTests.cs | 8 ++-- 4 files changed, 32 insertions(+), 20 deletions(-) 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 31595a1b..f415a7b6 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,4 +199,4 @@ 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! -static Xamarin.Android.Tools.AvdManagerRunner.ListAvdSkinsAsync(string! sdkPath, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +static Xamarin.Android.Tools.AvdManagerRunner.ListAvdSkins(string! sdkPath, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IReadOnlyList! 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 31595a1b..f415a7b6 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,4 +199,4 @@ 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! -static Xamarin.Android.Tools.AvdManagerRunner.ListAvdSkinsAsync(string! sdkPath, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +static Xamarin.Android.Tools.AvdManagerRunner.ListAvdSkins(string! sdkPath, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IReadOnlyList! diff --git a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs index 3257bf0c..199e846d 100644 --- a/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs +++ b/src/Xamarin.Android.Tools.AndroidSdk/Runners/AvdManagerRunner.cs @@ -123,22 +123,21 @@ public async Task DeleteAvdAsync (string name, CancellationToken cancellationTok } /// - /// Lists available AVD skins by scanning the SDK skins/ directory. + /// Lists available AVD skins by scanning the SDK skins/ directory + /// and system-images/.../skins/ directories. /// /// Root path of the Android SDK. - /// Cancellation token. + /// Cancellation token checked during directory enumeration. /// Sorted list of unique skin directory names. - public static Task> ListAvdSkinsAsync (string sdkPath, CancellationToken cancellationToken = default) + public static IReadOnlyList ListAvdSkins (string sdkPath, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace (sdkPath)) throw new ArgumentException ("SDK path must not be empty.", nameof (sdkPath)); - cancellationToken.ThrowIfCancellationRequested (); - - return Task.FromResult (EnumerateSkins (sdkPath)); + return EnumerateSkins (sdkPath, cancellationToken); } - internal static IReadOnlyList EnumerateSkins (string sdkPath) + internal static IReadOnlyList EnumerateSkins (string sdkPath, CancellationToken cancellationToken = default) { var skins = new SortedSet (StringComparer.OrdinalIgnoreCase); @@ -149,13 +148,22 @@ internal static IReadOnlyList EnumerateSkins (string sdkPath) // System image skins: /system-images////skins// var systemImagesDir = Path.Combine (sdkPath, "system-images"); if (Directory.Exists (systemImagesDir)) { - foreach (var apiDir in Directory.EnumerateDirectories (systemImagesDir)) { - foreach (var tagDir in Directory.EnumerateDirectories (apiDir)) { - foreach (var abiDir in Directory.EnumerateDirectories (tagDir)) { - var imgSkinsDir = Path.Combine (abiDir, "skins"); - AddSkinDirectories (skins, imgSkinsDir); + try { + foreach (var apiDir in Directory.EnumerateDirectories (systemImagesDir)) { + cancellationToken.ThrowIfCancellationRequested (); + try { + foreach (var tagDir in Directory.EnumerateDirectories (apiDir)) { + foreach (var abiDir in Directory.EnumerateDirectories (tagDir)) { + var imgSkinsDir = Path.Combine (abiDir, "skins"); + AddSkinDirectories (skins, imgSkinsDir); + } + } + } catch (IOException) { + } catch (UnauthorizedAccessException) { } } + } catch (IOException) { + } catch (UnauthorizedAccessException) { } } @@ -166,8 +174,12 @@ static void AddSkinDirectories (SortedSet skins, string directory) { if (!Directory.Exists (directory)) return; - foreach (var skinDir in Directory.EnumerateDirectories (directory)) - skins.Add (Path.GetFileName (skinDir)); + try { + foreach (var skinDir in Directory.EnumerateDirectories (directory)) + skins.Add (Path.GetFileName (skinDir)); + } catch (IOException) { + } catch (UnauthorizedAccessException) { + } } internal static IReadOnlyList ParseAvdListOutput (string output) diff --git a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs index e60f9600..9ba211e5 100644 --- a/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs +++ b/tests/Xamarin.Android.Tools.AndroidSdk-Tests/AvdManagerRunnerTests.cs @@ -359,14 +359,14 @@ public void EnumerateSkins_MissingSdkDir_ReturnsEmpty () } [Test] - public void ListAvdSkinsAsync_NullSdkPath_ThrowsArgumentException () + public void ListAvdSkins_NullSdkPath_ThrowsArgumentException () { - Assert.ThrowsAsync (() => AvdManagerRunner.ListAvdSkinsAsync (null!)); + Assert.Throws (() => AvdManagerRunner.ListAvdSkins (null!)); } [Test] - public void ListAvdSkinsAsync_EmptySdkPath_ThrowsArgumentException () + public void ListAvdSkins_EmptySdkPath_ThrowsArgumentException () { - Assert.ThrowsAsync (() => AvdManagerRunner.ListAvdSkinsAsync ("")); + Assert.Throws (() => AvdManagerRunner.ListAvdSkins ("")); } }