From 78de2523137fb6537fe492659a1eda3af8f212e0 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 11 Sep 2026 18:21:15 +0200 Subject: [PATCH] [androidsdk] Atomically replace SDK packages Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../RemoveDirectoryBackups.cs | 36 ++++ .../ReplaceDirectory.cs | 84 ++++++++ .../RetryingDirectoryTask.cs | 103 ++++++++++ src/androidsdk/androidsdk.targets | 62 +++--- .../AndroidSdkTargetsTests.cs | 116 ++++++++++++ ...osoft.Android.Build.BaseTasks-Tests.csproj | 1 + .../ReplaceDirectoryTests.cs | 179 ++++++++++++++++++ .../Resources/AndroidSdkTargetsTest.proj | 22 +++ 8 files changed, 575 insertions(+), 28 deletions(-) create mode 100644 build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/RemoveDirectoryBackups.cs create mode 100644 build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/ReplaceDirectory.cs create mode 100644 build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/RetryingDirectoryTask.cs create mode 100644 tests/Microsoft.Android.Build.BaseTasks-Tests/AndroidSdkTargetsTests.cs create mode 100644 tests/Microsoft.Android.Build.BaseTasks-Tests/ReplaceDirectoryTests.cs create mode 100644 tests/Microsoft.Android.Build.BaseTasks-Tests/Resources/AndroidSdkTargetsTest.proj diff --git a/build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/RemoveDirectoryBackups.cs b/build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/RemoveDirectoryBackups.cs new file mode 100644 index 00000000000..0e687a7f387 --- /dev/null +++ b/build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/RemoveDirectoryBackups.cs @@ -0,0 +1,36 @@ +using System; +using System.IO; +using Microsoft.Build.Framework; + +namespace Xamarin.Android.Tools.BootstrapTasks +{ + public sealed class RemoveDirectoryBackups : RetryingDirectoryTask + { + [Required] + public ITaskItem [] Directories { get; set; } = []; + + public override bool Execute () + { + if (!ValidateRetryParameters ()) + return false; + + foreach (var item in Directories) { + var directory = NormalizeDirectoryPath (item.ItemSpec); + var parentDirectory = Path.GetDirectoryName (directory); + if (string.IsNullOrEmpty (parentDirectory) || !Directory.Exists (parentDirectory)) + continue; + + var directoryName = Path.GetFileName (directory); + foreach (var backupDirectory in Directory.GetDirectories (parentDirectory, $"{directoryName}.old-*", SearchOption.TopDirectoryOnly)) { + if (!TryDeleteDirectoryWithRetry (backupDirectory, out var deleteError)) { + Log.LogWarning ( + $"Could not remove old directory '{backupDirectory}' after {RetryCount + 1} attempts: " + + $"{deleteError.Message}{GetRemainingEntries (backupDirectory)}"); + } + } + } + + return !Log.HasLoggedErrors; + } + } +} diff --git a/build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/ReplaceDirectory.cs b/build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/ReplaceDirectory.cs new file mode 100644 index 00000000000..53e090f7e67 --- /dev/null +++ b/build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/ReplaceDirectory.cs @@ -0,0 +1,84 @@ +using System; +using System.IO; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Xamarin.Android.Tools.BootstrapTasks +{ + public class ReplaceDirectory : RetryingDirectoryTask + { + [Required] + public string SourceDirectory { get; set; } = ""; + + [Required] + public string DestinationDirectory { get; set; } = ""; + + [Required] + public string RequiredFile { get; set; } = ""; + + public override bool Execute () + { + if (!ValidateRetryParameters ()) + return false; + + var sourceDirectory = NormalizeDirectoryPath (SourceDirectory); + var destinationDirectory = NormalizeDirectoryPath (DestinationDirectory); + if (!Directory.Exists (sourceDirectory)) { + Log.LogError ($"Source directory '{sourceDirectory}' does not exist."); + return false; + } + if (!File.Exists (Path.Combine (sourceDirectory, RequiredFile))) { + Log.LogError ($"Source directory '{sourceDirectory}' does not contain required file '{RequiredFile}'."); + return false; + } + + var parentDirectory = Path.GetDirectoryName (destinationDirectory); + if (!string.IsNullOrEmpty (parentDirectory)) + Directory.CreateDirectory (parentDirectory); + + string backupDirectory = null; + try { + if (Directory.Exists (destinationDirectory)) { + var candidate = destinationDirectory + $".old-{Guid.NewGuid ():N}"; + MoveDirectoryWithRetry (destinationDirectory, candidate); + backupDirectory = candidate; + } + + try { + MoveDirectoryWithRetry (sourceDirectory, destinationDirectory); + } catch { + RestoreBackup (backupDirectory, destinationDirectory); + throw; + } + } catch (Exception e) { + Log.LogError ($"Failed to replace directory '{destinationDirectory}' with '{sourceDirectory}': {e.Message}"); + return false; + } + + if (backupDirectory != null && !TryDeleteDirectoryWithRetry (backupDirectory, out var deleteError)) { + Log.LogWarning ( + $"Installed '{destinationDirectory}', but could not remove old directory '{backupDirectory}' after {RetryCount + 1} attempts: " + + $"{deleteError.Message}{GetRemainingEntries (backupDirectory)}"); + } + + return !Log.HasLoggedErrors; + } + + void RestoreBackup (string backupDirectory, string destinationDirectory) + { + if (backupDirectory == null || !Directory.Exists (backupDirectory)) + return; + if (Directory.Exists (destinationDirectory)) { + Log.LogError ($"Cannot restore previous directory from '{backupDirectory}' because '{destinationDirectory}' exists."); + return; + } + try { + MoveDirectoryWithRetry (backupDirectory, destinationDirectory); + Log.LogMessage (MessageImportance.Normal, $"Restored previous directory from '{backupDirectory}'."); + } catch (Exception e) { + Log.LogError ($"Failed to restore previous directory from '{backupDirectory}': {e.Message}"); + } + } + + } +} diff --git a/build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/RetryingDirectoryTask.cs b/build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/RetryingDirectoryTask.cs new file mode 100644 index 00000000000..895004ef9ed --- /dev/null +++ b/build-tools/Xamarin.Android.Tools.BootstrapTasks/Xamarin.Android.Tools.BootstrapTasks/RetryingDirectoryTask.cs @@ -0,0 +1,103 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using Microsoft.Android.Build.Tasks; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Xamarin.Android.Tools.BootstrapTasks +{ + public abstract class RetryingDirectoryTask : Task + { + public int RetryCount { get; set; } = 10; + + public int RetryDelayMilliseconds { get; set; } = 200; + + protected bool ValidateRetryParameters () + { + if (RetryCount < 0) { + Log.LogError ($"{nameof (RetryCount)} must be greater than or equal to zero."); + return false; + } + if (RetryDelayMilliseconds < 0) { + Log.LogError ($"{nameof (RetryDelayMilliseconds)} must be greater than or equal to zero."); + return false; + } + return true; + } + + protected void MoveDirectoryWithRetry (string source, string destination) + { + for (int attempt = 0; ; attempt++) { + try { + MoveDirectory (source, destination); + return; + } catch (Exception e) when ((e is IOException || e is UnauthorizedAccessException) && attempt < RetryCount) { + Log.LogMessage ( + MessageImportance.Normal, + $"Could not move directory '{source}' to '{destination}' (attempt {attempt + 1} of {RetryCount + 1}): {e.Message} Retrying."); + Delay (RetryDelayMilliseconds * (attempt + 1)); + } + } + } + + protected bool TryDeleteDirectoryWithRetry (string directory, out Exception error) + { + error = null; + for (int attempt = 0; ; attempt++) { + try { + if (!Directory.Exists (directory)) + return true; + if (Path.DirectorySeparatorChar == '\\') + Files.SetDirectoryWriteable (directory); + DeleteDirectory (directory); + return true; + } catch (DirectoryNotFoundException) { + return true; + } catch (Exception e) when (e is IOException || e is UnauthorizedAccessException) { + error = e; + if (attempt >= RetryCount) + return false; + Log.LogMessage ( + MessageImportance.Normal, + $"Could not remove directory '{directory}' (attempt {attempt + 1} of {RetryCount + 1}): {e.Message} Retrying."); + Delay (RetryDelayMilliseconds * (attempt + 1)); + } + } + } + + protected static string GetRemainingEntries (string directory) + { + try { + var entries = Directory.EnumerateFileSystemEntries (directory, "*", SearchOption.AllDirectories) + .Take (10) + .Select (path => $"'{path}'") + .ToArray (); + return entries.Length == 0 ? "" : $" Remaining entries: {string.Join (", ", entries)}."; + } catch (Exception e) { + return $" Remaining entries could not be enumerated: {e.Message}"; + } + } + + protected static string NormalizeDirectoryPath (string directory) + { + return Path.TrimEndingDirectorySeparator (Path.GetFullPath (directory)); + } + + protected virtual void MoveDirectory (string source, string destination) + { + Directory.Move (source, destination); + } + + protected virtual void DeleteDirectory (string directory) + { + Directory.Delete (directory, recursive: true); + } + + protected virtual void Delay (int milliseconds) + { + Thread.Sleep (milliseconds); + } + } +} diff --git a/src/androidsdk/androidsdk.targets b/src/androidsdk/androidsdk.targets index b9974e08caf..b3028f3f328 100644 --- a/src/androidsdk/androidsdk.targets +++ b/src/androidsdk/androidsdk.targets @@ -48,6 +48,8 @@ + + - <_StagedTopDir Include="$([System.IO.Directory]::GetDirectories('$(_StagingDir)'))" /> - - <_GlobRoot Condition=" '$(_StripComponents)' == '1' ">@(_StagedTopDir) - <_GlobRoot Condition=" '$(_StripComponents)' == '0' ">$(_StagingDir) + + <_InstallSourceDir Condition=" '$(HostOS)' == 'Windows' Or '$(_StripComponents)' == '0' ">$(_StagingDir) + <_InstallSourceDir Condition=" '$(HostOS)' != 'Windows' And '$(_StripComponents)' == '1' ">@(_StagedTopDir) - - <_StagedFile Remove="@(_StagedFile)" /> - <_StagedFile Include="$(_GlobRoot)\**\*" /> - - - - + + + + + + + + + +