From 72786e080bc5df69f4ea7090607526c86439b169 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 10 Nov 2026 23:56:48 +0100 Subject: [PATCH 1/5] [FastDeploy] Move task to Microsoft.Android.Build.Tasks Move the `FastDeploy` task (all partial declarations and its `FastDeployJsonSerializerContext`) from `Xamarin.Android.Build.Debugging.Tasks.dll` (netstandard2.0) to `Microsoft.Android.Build.Tasks.dll` (net12.0). The task keeps its `Xamarin.Android.Tasks` namespace, so both `` and `` continue to resolve. * Register the task from `$(_MicrosoftAndroidBuildTasksAssembly)` using the existing Full/Core pattern: `TaskHostFactory` + `Runtime="NET"` on .NET Framework MSBuild, `Runtime="NET"` alone on .NET MSBuild. * Compile-link `AndroidHelper.cs` (still used by `GetPrimaryCpuAbi` and `DetectIfAppWasUninstalled`) into MABT as `internal`, and link the debugging `Resources.resx` files with their original manifest name so XA#### messages stay localized. * The manifest now uses the framework System.Text.Json; the `System.Text.Json` PackageReference in DebuggingTasks had no other consumer and is removed. * Make the implicit current-culture formatting explicit to satisfy CA1305, which MABT enforces; behavior is unchanged. * Move the FastDeploy unit tests (and `FastDeployTypeMapTests`) to `Microsoft.Android.Build.Tasks.Tests`. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Documentation/guides/FastDeploy.md | 2 +- .../Microsoft.Android.Build.Tasks.csproj | 9 + .../Tasks/FastDeploy.Adb.cs | 1 + .../Tasks/FastDeploy.Manifest.cs | 3 +- .../Tasks/FastDeploy.WarmState.cs | 1 + .../Tasks/FastDeploy.cs | 5 +- .../Tasks/FastDeployJsonSerializerContext.cs | 1 + .../FastDeployTests.FakeAdb.cs} | 2 +- .../FastDeployTests.cs | 573 ++++++++++++++++++ .../FastDeployTypeMapTests.cs | 0 .../Utilities/AndroidHelper.cs | 11 +- ...marin.Android.Build.Debugging.Tasks.csproj | 4 - .../Xamarin.Android.Common.Debugging.targets | 3 +- .../DebuggingTasksTests.cs | 556 ----------------- 14 files changed, 602 insertions(+), 569 deletions(-) rename src/{Xamarin.Android.Build.Debugging.Tasks => Microsoft.Android.Build.Tasks}/Tasks/FastDeploy.Adb.cs (99%) rename src/{Xamarin.Android.Build.Debugging.Tasks => Microsoft.Android.Build.Tasks}/Tasks/FastDeploy.Manifest.cs (99%) rename src/{Xamarin.Android.Build.Debugging.Tasks => Microsoft.Android.Build.Tasks}/Tasks/FastDeploy.WarmState.cs (99%) rename src/{Xamarin.Android.Build.Debugging.Tasks => Microsoft.Android.Build.Tasks}/Tasks/FastDeploy.cs (99%) rename src/{Xamarin.Android.Build.Debugging.Tasks => Microsoft.Android.Build.Tasks}/Tasks/FastDeployJsonSerializerContext.cs (94%) rename src/{Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/DebuggingTasksTests.FastDeploy.cs => Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.FakeAdb.cs} (99%) create mode 100644 src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.cs rename src/{Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks => Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests}/FastDeployTypeMapTests.cs (100%) diff --git a/Documentation/guides/FastDeploy.md b/Documentation/guides/FastDeploy.md index 15fd44a41a3..f210d8d5d97 100644 --- a/Documentation/guides/FastDeploy.md +++ b/Documentation/guides/FastDeploy.md @@ -9,7 +9,7 @@ inner-loop change only re-transfers the files that actually changed. This document describes how the [`FastDeploy`][task] MSBuild task works: the stages it runs, the `adb` commands it issues, and the properties that control it. -[task]: ../../src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.cs +[task]: ../../src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.cs ## MSBuild properties diff --git a/src/Microsoft.Android.Build.Tasks/Microsoft.Android.Build.Tasks.csproj b/src/Microsoft.Android.Build.Tasks/Microsoft.Android.Build.Tasks.csproj index 7a28da06c17..7d38f4a08f2 100644 --- a/src/Microsoft.Android.Build.Tasks/Microsoft.Android.Build.Tasks.csproj +++ b/src/Microsoft.Android.Build.Tasks/Microsoft.Android.Build.Tasks.csproj @@ -90,4 +90,13 @@ + + + + + + Xamarin.Android.Build.Debugging.Tasks.Properties.%(Filename) + + + diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.Adb.cs b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.Adb.cs similarity index 99% rename from src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.Adb.cs rename to src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.Adb.cs index 11701847baa..b96acac9ff4 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.Adb.cs +++ b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.Adb.cs @@ -1,3 +1,4 @@ +#nullable disable using System; using System.Collections.Generic; using System.Linq; diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.Manifest.cs b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.Manifest.cs similarity index 99% rename from src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.Manifest.cs rename to src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.Manifest.cs index 355275e1dbd..67e54786e47 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.Manifest.cs +++ b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.Manifest.cs @@ -1,3 +1,4 @@ +#nullable disable using System; using System.Collections.Generic; using System.IO; @@ -547,7 +548,7 @@ static string GetCanonicalManifestText (ManifestData manifest) builder.Append (entry.Key).Append ('\t') .Append (entry.Value.RelativePath).Append ('\t') .Append (entry.Value.Size).Append ('\t') - .AppendLine (entry.Value.LastWriteTimeUtcTicks.ToString ()); + .AppendLine (entry.Value.LastWriteTimeUtcTicks.ToString (System.Globalization.CultureInfo.CurrentCulture)); } return builder.ToString (); } diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.WarmState.cs b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.WarmState.cs similarity index 99% rename from src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.WarmState.cs rename to src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.WarmState.cs index 3848e09897f..a369e79969b 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.WarmState.cs +++ b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.WarmState.cs @@ -1,3 +1,4 @@ +#nullable disable using System; using System.IO; using System.Linq; diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.cs b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.cs similarity index 99% rename from src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.cs rename to src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.cs index d977b0bfcc2..ee58df180a2 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy.cs +++ b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.cs @@ -1,3 +1,4 @@ +#nullable disable using System; using System.Collections.Generic; using System.Diagnostics; @@ -527,8 +528,8 @@ byte [] CreateEnvironmentFileData (ITaskItem [] environments, out DateTime newes using (var stream = new MemoryStream ()) using (var binaryWriter = new BinaryWriter (stream, Encoding.ASCII)) { - binaryWriter.Write (Encoding.ASCII.GetBytes ("0x" + maxKeyLength.ToString ("X8") + '\0')); - binaryWriter.Write (Encoding.ASCII.GetBytes ("0x" + maxValueLength.ToString ("X8") + '\0')); + binaryWriter.Write (Encoding.ASCII.GetBytes ("0x" + maxKeyLength.ToString ("X8", System.Globalization.CultureInfo.CurrentCulture) + '\0')); + binaryWriter.Write (Encoding.ASCII.GetBytes ("0x" + maxValueLength.ToString ("X8", System.Globalization.CultureInfo.CurrentCulture) + '\0')); foreach (var kvp in data) { binaryWriter.Write (Encoding.ASCII.GetBytes (kvp.Key.PadRight (maxKeyLength, '\0'))); binaryWriter.Write (Encoding.ASCII.GetBytes (kvp.Value.PadRight (maxValueLength, '\0'))); diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeployJsonSerializerContext.cs b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeployJsonSerializerContext.cs similarity index 94% rename from src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeployJsonSerializerContext.cs rename to src/Microsoft.Android.Build.Tasks/Tasks/FastDeployJsonSerializerContext.cs index fb289b6e00d..7f5de039ccb 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeployJsonSerializerContext.cs +++ b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeployJsonSerializerContext.cs @@ -1,3 +1,4 @@ +#nullable disable using System.Collections.Generic; using System.Text.Json.Serialization; diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/DebuggingTasksTests.FastDeploy.cs b/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.FakeAdb.cs similarity index 99% rename from src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/DebuggingTasksTests.FastDeploy.cs rename to src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.FakeAdb.cs index 461b2696be6..9e880bd0828 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/DebuggingTasksTests.FastDeploy.cs +++ b/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.FakeAdb.cs @@ -3,7 +3,7 @@ namespace Xamarin.Android.Build.Tests { - public partial class DebuggingTasksTests + public partial class FastDeployTests { [Test] public void FastDeployRejectsFailedOverrideCommand ( diff --git a/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.cs b/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.cs new file mode 100644 index 00000000000..6c736b3a6fe --- /dev/null +++ b/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.cs @@ -0,0 +1,573 @@ +using Microsoft.Build.Framework; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Xamarin.Android.Tasks; +using TaskItem = Microsoft.Build.Utilities.TaskItem; + +namespace Xamarin.Android.Build.Tests +{ + [TestFixture, NonParallelizable] + public partial class FastDeployTests : BaseTest + { + [Test] + public void FastDeployParsesWarmStateProbe () + { + var state = FastDeploy.ParseWarmStateProbeOutput ( + """ + __XA_FD_REDIRECT__= + __XA_FD_RUN_AS_DISABLED__= + __XA_FD_REMOTE_HASH__=remote-hash + __XA_FD_PID__=123 456 + __XA_FD_PATH__=/data/user/0/com.example + __XA_FD_OVERRIDE_HASH__=override-hash + __XA_FD_RUN_AS_STATUS__=0 + __XA_FD_FORCE_STOP_STATUS__=0 + """); + + Assert.IsTrue (state.HasRequiredState); + Assert.AreEqual ("remote-hash", state.RemoteHash); + Assert.AreEqual ("override-hash", state.OverrideHash); + Assert.AreEqual ("/data/user/0/com.example", state.InternalPath); + Assert.AreEqual (123, state.ProcessId); + Assert.AreEqual (0, state.RunAsStatus); + Assert.AreEqual (0, state.ForceStopStatus); + } + + [Test] + public void FastDeployRejectsIncompleteWarmStateProbe () + { + var state = FastDeploy.ParseWarmStateProbeOutput ( + """ + __XA_FD_REDIRECT__= + __XA_FD_RUN_AS_DISABLED__=true + run-as: package not debuggable + """); + + Assert.IsFalse (state.HasRequiredState); + Assert.IsTrue (state.HasRunAsDisabled); + Assert.AreEqual ("true", state.RunAsDisabled); + } + + [Test] + public void FastDeployPlansOnlyNewStagingDirectories () + { + var previousFiles = new [] { + "arm64-v8a/App.dll", + "arm64-v8a/en-US/App.resources.dll", + }; + var currentFiles = new [] { + "arm64-v8a/App.dll", + "arm64-v8a/New.dll", + "arm64-v8a/en-US/App.resources.dll", + "arm64-v8a/fr/App.resources.dll", + }; + + HashSet files = FastDeploy.GetFilesRequiringStagingDirectories (currentFiles, previousFiles); + + CollectionAssert.AreEquivalent ( + new [] { "arm64-v8a/fr/App.resources.dll" }, + files); + } + + [Test] + public void FastDeployPlansAllStagingDirectoriesAfterReset () + { + var currentFiles = new [] { + "App.dll", + "arm64-v8a/App.dll", + "arm64-v8a/fr/App.resources.dll", + }; + + HashSet files = FastDeploy.GetFilesRequiringStagingDirectories (currentFiles, previousFiles: null); + + CollectionAssert.AreEquivalent (currentFiles, files); + } + + [TestCase ("adb: error: failed to copy: No such file or directory")] + [TestCase ("adb: error: target '/data/local/tmp/app/arm64-v8a' is not a directory")] + [TestCase ("remote couldn't create file: Is a directory")] + public void FastDeployDetectsInvalidRemoteFilesystem (string output) + { + Assert.IsTrue (FastDeploy.IsUnexpectedRemoteFilesystemError (output)); + } + + [Test] + public void FastDeployDoesNotResetForUnrelatedPushFailure () + { + Assert.IsFalse (FastDeploy.IsUnexpectedRemoteFilesystemError ("adb: error: device offline")); + } + + [TestCase ("adb: error: device offline", false)] + [TestCase ("adb: failed to install app.apk: cmd: Failure calling service package: Broken pipe (32)", true)] + [TestCase ("adb: failed to install app.apk: Broken pipe (32)", false)] + [TestCase ("cmd: Failure calling service package: Security exception", false)] + [TestCase ("Failure [INSTALL_FAILED_INSUFFICIENT_STORAGE]", false)] + public void FastDeployClassifiesOnlyKnownTransientInstallFailures (string output, bool expected) + { + Assert.AreEqual (expected, FastDeploy.IsTransientInstallFailure (output)); + } + + [Test] + public async Task FastDeployRetriesTransientInstallOnce () + { + var task = new TestFastDeploy ( + CreateAdbResult (1, "adb: failed to install app.apk: cmd: Failure calling service package: Broken pipe (32)"), + CreateAdbResult (0, "Success")); + + await task.InstallApkWithRetry ("app.apk", reinstall: false, testOnly: false, user: ""); + + Assert.AreEqual (2, task.InstallAttempts); + Assert.AreEqual (1, task.RecoveryAttempts); + } + + [Test] + public async Task FastDeployRetriesTransientInstallAfterUninstall () + { + var task = new TestFastDeploy ( + CreateAdbResult (1, "Failure [INSTALL_FAILED_ALREADY_EXISTS]"), + CreateAdbResult (1, "adb: failed to install app.apk: cmd: Failure calling service package: Broken pipe (32)"), + CreateAdbResult (0, "Success")); + + await task.InstallApkWithRetry ("app.apk", reinstall: false, testOnly: false, user: ""); + + Assert.AreEqual (3, task.InstallAttempts); + Assert.AreEqual (1, task.UninstallAttempts); + Assert.AreEqual (1, task.RecoveryAttempts); + } + + [Test] + public void FastDeployDoesNotRetryTransientInstallTwiceAcrossUninstall () + { + var task = new TestFastDeploy ( + CreateAdbResult (1, "first failure: cmd: Failure calling service package: Broken pipe (32)"), + CreateAdbResult (1, "Failure [INSTALL_FAILED_ALREADY_EXISTS]"), + CreateAdbResult (1, "third failure: cmd: Failure calling service package: Broken pipe (32)")); + + var exception = Assert.ThrowsAsync ( + async () => await task.InstallApkWithRetry ("app.apk", reinstall: false, testOnly: false, user: "")); + + Assert.IsNotNull (exception); + Assert.AreEqual ("ADB0010", exception.ErrorCode); + Assert.That (exception.Message, Does.Contain ("Install attempt 1")); + Assert.That (exception.Message, Does.Contain ("first failure")); + Assert.That (exception.Message, Does.Contain ("Install attempt 2")); + Assert.That (exception.Message, Does.Contain ("INSTALL_FAILED_ALREADY_EXISTS")); + Assert.That (exception.Message, Does.Contain ("Install attempt 3")); + Assert.That (exception.Message, Does.Contain ("third failure")); + Assert.That (exception.Message, Does.Not.Contain ("Install attempt 4")); + Assert.AreEqual (3, task.InstallAttempts); + Assert.AreEqual (1, task.UninstallAttempts); + Assert.AreEqual (1, task.RecoveryAttempts); + } + + [Test] + public void FastDeployDoesNotRetrySemanticInstallFailure () + { + var task = new TestFastDeploy ( + CreateAdbResult (1, "Failure [INSTALL_FAILED_INSUFFICIENT_STORAGE]")); + + var exception = Assert.ThrowsAsync ( + async () => await task.InstallApkWithRetry ("app.apk", reinstall: false, testOnly: false, user: "")); + + Assert.IsNotNull (exception); + Assert.AreEqual ("ADB0060", exception.ErrorCode); + Assert.AreEqual (1, task.InstallAttempts); + Assert.AreEqual (0, task.RecoveryAttempts); + } + + [Test] + public void FastDeployPreservesBothTransientInstallAttempts () + { + var task = new TestFastDeploy ( + CreateAdbResult (1, "first failure: cmd: Failure calling service package: Broken pipe (32)"), + CreateAdbResult (1, "second failure: cmd: Failure calling service package: Broken pipe (32)")); + + var exception = Assert.ThrowsAsync ( + async () => await task.InstallApkWithRetry ("app.apk", reinstall: false, testOnly: false, user: "")); + + Assert.IsNotNull (exception); + Assert.AreEqual ("ADB0010", exception.ErrorCode); + Assert.That (exception.Message, Does.Contain ("Install attempt 1")); + Assert.That (exception.Message, Does.Contain ("first failure")); + Assert.That (exception.Message, Does.Contain ("ADB transport recovery")); + Assert.That (exception.Message, Does.Contain ("Install attempt 2")); + Assert.That (exception.Message, Does.Contain ("second failure")); + Assert.AreEqual (2, task.InstallAttempts); + Assert.AreEqual (1, task.RecoveryAttempts); + } + + [Test] + public void FastDeployPreservesOriginalFailureWhenRecoveryFails () + { + var task = new TestFastDeploy ( + new InvalidOperationException ("package manager still unavailable"), + CreateAdbResult (1, "cmd: Failure calling service package: Broken pipe (32)")); + + var exception = Assert.ThrowsAsync ( + async () => await task.InstallApkWithRetry ("app.apk", reinstall: false, testOnly: false, user: "")); + + Assert.IsNotNull (exception); + Assert.That (exception.Message, Does.Contain ("Failure calling service package: Broken pipe (32)")); + Assert.That (exception.Message, Does.Contain ("package manager still unavailable")); + Assert.AreEqual (1, task.InstallAttempts); + Assert.AreEqual (1, task.RecoveryAttempts); + } + + // https://github.com/dotnet/android/issues/12878 + [TestCase (137, "Killed")] + [TestCase (1, "")] + [TestCase (1, "error: closed")] + [TestCase (1, "adb: error: device offline")] + [TestCase (1, "find: '/data/local/tmp/fastdeploy/com.example.app/0/lib': Permission denied")] + public void FastDeployRejectsFailedStagingFileListing (int exitCode, string output) + { + using var device = new FakeAdbDevice (exitCode, output); + + Assert.IsFalse (device.RunFastDeploy (), "A failed listing must fail the task."); + + Assert.AreEqual (1, device.Errors.Count); + Assert.AreEqual ("XA0129", device.Errors [0].Code); + Assert.That (device.Errors [0].Message, Does.Contain (FakeAdbDevice.StagingRoot)); + Assert.That (device.Commands.Last (), Does.Contain ($"find '{FakeAdbDevice.StagingRoot}'"), + "Nothing may be changed on the device after the staging listing failed."); + } + + [TestCase (1, "find: '/data/local/tmp/fastdeploy/com.example.app/0': No such file or directory")] + [TestCase (0, "")] + public void FastDeployTreatsMissingOrEmptyStagingDirectoryAsEmpty (int exitCode, string output) + { + using var device = new FakeAdbDevice (exitCode, output); + + Assert.IsTrue (device.RunFastDeploy ()); + + Assert.AreEqual (0, device.Errors.Count); + } + + [Test] + public void FastDeployCopiesFilesFromSuccessfulStagingFileListing () + { + using var device = new FakeAdbDevice ( + 0, $"{FakeAdbDevice.StagingRoot}/arm64-v8a/Foo.dll|3|1700000000", + $"find: '{FakeAdbDevice.OverrideRoot}': No such file or directory"); + + Assert.IsTrue (device.RunFastDeploy ()); + + Assert.AreEqual (0, device.Errors.Count); + Assert.That (device.Commands, Has.Some.Contain ($"cp -p {FakeAdbDevice.StagingRoot}/arm64-v8a/Foo.dll")); + } + + // https://github.com/dotnet/android/issues/13274 + [TestCase ("Copy", false)] + [TestCase ("Symlink", false)] + [TestCase ("Copy", true)] + public void FastDeployRejectsPartialFileListing (string transferMode, bool overrideListing) + { + string root = overrideListing ? FakeAdbDevice.OverrideRoot : FakeAdbDevice.StagingRoot; + string listing = $"{root}/arm64-v8a/Foo.dll|3|1700000000\nstat: '{root}/arm64-v8a/Bar.dll': No such file or directory"; + using var device = new FakeAdbDevice ( + overrideListing ? 0 : 1, + overrideListing ? $"{FakeAdbDevice.StagingRoot}/arm64-v8a/Foo.dll|3|1700000000" : listing, + overrideListing ? listing : $"{FakeAdbDevice.OverrideRoot}/.fastdeploy-manifest-hash|64|1700000000"); + if (transferMode == "Symlink") { + device.FailCommand ("ln -sf", "ln: Operation not permitted"); + } + + Assert.IsFalse (device.RunFastDeploy (symlink: transferMode == "Symlink"), "Partial listings must fail deployment."); + + Assert.AreEqual (1, device.Errors.Count); + Assert.AreEqual ("XA0129", device.Errors [0].Code); + Assert.That (device.Errors [0].Message, Does.Contain (root)); + Assert.That (device.Commands.Last (), Does.Contain ("-exec stat"), "No commands after a failed listing."); + Assert.That (device.Commands, Has.None.Contain ($"rm -rf {FakeAdbDevice.OverrideRoot}"), + "The override directory must not be cleared."); + Assert.That (device.Commands, Has.None.Contain ("printf %s"), "No success marker may be written."); + Assert.IsFalse (device.HasManifest); + } + + [Test] + public void FastDeployRejectsMissingRootWithValidEntries () + { + string root = FakeAdbDevice.StagingRoot; + using var device = new FakeAdbDevice (1, $"{root}/arm64-v8a/Foo.dll|3|1700000000\nfind: '{root}': No such file or directory"); + + Assert.IsFalse (device.RunFastDeploy ()); + Assert.AreEqual ("XA0129", device.Errors.Single ().Code); + Assert.That (device.Commands, Has.None.Contain ("printf %s")); + Assert.IsFalse (device.HasManifest); + } + + [Test] + public void FastDeployCopiesAfterClearingSymlinkState () + { + using var device = new FakeAdbDevice ( + 0, $"{FakeAdbDevice.StagingRoot}/arm64-v8a/Foo.dll|3|1700000000", + $"{FakeAdbDevice.OverrideRoot}/arm64-v8a/Foo.dll|3|1700000000\n{FakeAdbDevice.OverrideRoot}/.fastdeploy-manifest-hash|64|1700000000"); + + Assert.IsTrue (device.RunFastDeploy ()); + + var commands = device.Commands.ToList (); + int clearIndex = commands.FindIndex (c => c.Contains ($"rm -rf {FakeAdbDevice.OverrideRoot}")); + Assert.Greater (clearIndex, commands.FindLastIndex (c => c.Contains ("-exec stat")), + "Both listings must precede clearing the override directory."); + Assert.That (commands.Skip (clearIndex + 1), Has.Some.Contain ($"cp -p {FakeAdbDevice.StagingRoot}/arm64-v8a/Foo.dll"), + "Cleared files must be copied even when the previous listing matched staging."); + Assert.IsTrue (device.HasManifest); + } + + [Test] + public void FastDeployPreservesManifestAfterPartialFileListing () + { + using var device = new FakeAdbDevice (0, $"{FakeAdbDevice.StagingRoot}/arm64-v8a/Foo.dll|3|1700000000"); + Assert.IsTrue (device.RunFastDeploy ()); + string previousManifest = device.Manifest; + int previousCommandCount = device.Commands.Count; + device.TouchPayload (); + device.SetStagingListing (1, $"{FakeAdbDevice.StagingRoot}/arm64-v8a/Foo.dll|3|1700000000\nstat: '{FakeAdbDevice.StagingRoot}/arm64-v8a/Bar.dll': No such file or directory"); + + Assert.IsFalse (device.RunFastDeploy ()); + + Assert.AreEqual ("XA0129", device.Errors.Single ().Code); + Assert.AreEqual (previousManifest, device.Manifest, "A failed incremental deployment must not overwrite the successful host manifest."); + Assert.That (device.Commands.Skip (previousCommandCount), Has.None.Contain ("printf %s"), + "A failed incremental deployment must not update the remote success marker."); + } + + static FastDeploy.AdbCommandResult CreateAdbResult (int exitCode, string output) + { + return new FastDeploy.AdbCommandResult { + ExitCode = exitCode, + StandardOutput = output, + StandardError = "", + }; + } + + sealed class TestFastDeploy : FastDeploy + { + readonly Queue results; + readonly Exception recoveryException; + + public int InstallAttempts { get; private set; } + public int UninstallAttempts { get; private set; } + public int RecoveryAttempts { get; private set; } + + public TestFastDeploy (params AdbCommandResult [] results) + : this (recoveryException: null, results) + { + } + + public TestFastDeploy (Exception recoveryException, params AdbCommandResult [] results) + { + this.results = new Queue (results); + this.recoveryException = recoveryException; + BuildEngine = new MockBuildEngine (TestContext.Out); + PackageName = "com.example.app"; + } + + internal override Task RunInstallCommand (string apkFile, bool reinstall, bool testOnly, string user) + { + InstallAttempts++; + return Task.FromResult (results.Dequeue ()); + } + + internal override Task UninstallPackage (string packageName, bool preserveData, string user) + { + UninstallAttempts++; + return Task.CompletedTask; + } + + internal override Task WaitForInstallTransportRecovery () + { + RecoveryAttempts++; + return recoveryException == null ? Task.CompletedTask : Task.FromException (recoveryException); + } + } + + // Runs FastDeploy against a fake adb with configurable command results. + sealed class FakeAdbDevice : IDisposable + { + public const string PackageName = "com.example.app"; + public const string StagingRoot = "/data/local/tmp/fastdeploy/" + PackageName + "/0"; + public const string OverrideRoot = "files/.__override__"; + + const string AdbScript = """ + #!/bin/bash + dir="$(dirname "$0")" + printf '%s\n' "$*" >> "$dir/adb.log" + for fault in fault second-fault; do + if [ -f "$dir/$fault" ] && [[ "$*" == *"$(cat "$dir/$fault")"* ]]; then + cat "$dir/$fault.out" >&2 + exit "$(cat "$dir/$fault.exit")" + fi + done + case "$*" in + "devices -l") + echo "List of devices attached" + echo "emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1" + ;; + *"pwd"*"pidof"*) + cat "$dir/package.out" + exit "$(cat "$dir/package.exit")" + ;; + *"readlink -f ."*) + echo "/data/user/0/com.example.app" + ;; + *"shell find "*"-exec stat"*) + cat "$dir/staging-listing.out" + exit "$(cat "$dir/staging-listing.exit")" + ;; + *"run-as"*"find "*) + cat "$dir/override.out" + exit "$(cat "$dir/override.exit")" + ;; + *"remote="*"override="*) + hash="$(cat "$dir/hash" 2>/dev/null)" + echo "remote=$hash" + echo "override=$hash" + ;; + *"printf %s '"*) + if [[ "$*" =~ printf\ %s\ \'([0-9a-fA-F]+)\' ]]; then + printf '%s' "${BASH_REMATCH[1]}" > "$dir/hash" + fi + ;; + esac + exit 0 + """; + + readonly string directory = Path.Combine (Path.GetTempPath (), "fake-adb-" + Guid.NewGuid ().ToString ("N")); + readonly List errors = new List (); + readonly List files = []; + + public FakeAdbDevice () + : this (0, $"{StagingRoot}/arm64-v8a/Foo.dll|3|1700000000") + { + } + + public FakeAdbDevice (int stagingListingExitCode, string stagingListing, string overrideListing = "files/.__override__/stale.dll|1|1700000000") + { + if (OperatingSystem.IsWindows ()) { + Assert.Ignore ("The fake adb is a bash script, which is not supported on Windows."); + } else { + Directory.CreateDirectory (Path.Combine (directory, "obj")); + AddFile ("Foo.dll"); + WriteResult ("staging-listing", stagingListingExitCode, stagingListing); + WriteResult ("package", 0, "/data/user/0/com.example.app"); + WriteResult ("override", 0, overrideListing); + + var adb = Path.Combine (directory, "adb"); + File.WriteAllText (adb, AdbScript); + File.SetUnixFileMode (adb, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + } + + public IReadOnlyList Errors => errors; + + public bool HasManifest => Directory.GetFiles (Path.Combine (directory, "obj"), "manifest.json", SearchOption.AllDirectories).Length > 0; + + public string Manifest => File.ReadAllText (Directory.GetFiles (Path.Combine (directory, "obj"), "manifest.json", SearchOption.AllDirectories).Single ()); + + public void SetStagingListing (int exitCode, string output) + { + WriteResult ("staging-listing", exitCode, output); + } + + public void TouchPayload () + { + string path = Path.Combine (directory, "Foo.dll"); + File.SetLastWriteTimeUtc (path, File.GetLastWriteTimeUtc (path).AddMinutes (1)); + } + + // One entry per adb invocation, without the adb executable itself. + public IReadOnlyList Commands => File.ReadAllLines (Path.Combine (directory, "adb.log")); + + public void AddFile (string name) + { + string path = Path.Combine (directory, name); + File.WriteAllText (path, "foo"); + var item = new TaskItem (path); + item.SetMetadata ("TargetPath", "arm64-v8a/" + name); + files.Add (item); + File.WriteAllLines (Path.Combine (directory, "staging-listing.out"), + files.Select (file => $"{StagingRoot}/{file.GetMetadata ("TargetPath")}|3|1700000000")); + } + + public void FailCommand (string command, string output, bool second = false, int exitCode = 1) + { + string fault = second ? "second-fault" : "fault"; + File.WriteAllText (Path.Combine (directory, fault), command); + WriteResult (fault, exitCode, output); + } + + public void WriteResult (string name, int exitCode, string output) + { + File.WriteAllText (Path.Combine (directory, name + ".exit"), exitCode.ToString (System.Globalization.CultureInfo.InvariantCulture)); + File.WriteAllText (Path.Combine (directory, name + ".out"), output); + } + + public bool RunFastDeploy (bool symlink = false, bool embedded = false, bool install = false) + { + string apk = Path.Combine (directory, "app.apk"); + if (install) { + File.WriteAllText (apk, ""); + } + var task = new FastDeploy { + BuildEngine = new MockBuildEngine (TestContext.Out, errors), + AdbToolPath = directory, + AppFileTransferMode = symlink ? "Symlink" : "Copy", + EmbedAssembliesIntoApk = embedded, + FastDevFiles = files.ToArray (), + IntermediateOutputPath = Path.Combine (directory, "obj") + Path.DirectorySeparatorChar, + PackageName = PackageName, + PackageFile = install ? apk : null, + PrimaryCpuAbi = "arm64-v8a", + UploadFlagFile = Path.Combine (directory, "obj", "upload.flag"), + }; + return task.Execute (); + } + + public int RunGlobScript (string failingCommand) + { + string staging = Path.Combine (directory, "staging"); + string target = Path.Combine (directory, "override"); + Directory.CreateDirectory (staging); + Directory.CreateDirectory (target); + foreach (string name in new [] { "Bar.dll", "Foo.dll", "Zoo.dll" }) { + File.WriteAllText (Path.Combine (staging, name), "foo"); + File.WriteAllText (Path.Combine (target, name), "old"); + } + string executable = Path.Combine (directory, failingCommand); + File.WriteAllText (executable, $""" + #!/bin/sh + case "$*" in *Foo.dll*) exit 137;; esac + exec /bin/{failingCommand} "$@" + """); + if (!OperatingSystem.IsWindows ()) { + File.SetUnixFileMode (executable, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + string invocation = Commands.Single (c => c.Contains ("for f in")); + const string shellPrefix = "'sh' '-c' "; + string script = invocation.Substring (invocation.IndexOf (shellPrefix, StringComparison.Ordinal) + shellPrefix.Length) + .Replace (StagingRoot + "/arm64-v8a", staging) + .Replace ("files/.__override__/arm64-v8a", target); + var startInfo = new ProcessStartInfo ("/bin/sh") { UseShellExecute = false }; + startInfo.ArgumentList.Add ("-c"); + startInfo.ArgumentList.Add ("/bin/sh -c " + script); + startInfo.Environment ["PATH"] = directory + Path.PathSeparator + Environment.GetEnvironmentVariable ("PATH"); + using var process = Process.Start (startInfo); + Assert.IsNotNull (process); + process.WaitForExit (); + return process.ExitCode; + } + + public void Dispose () + { + Directory.Delete (directory, recursive: true); + } + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/FastDeployTypeMapTests.cs b/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTypeMapTests.cs similarity index 100% rename from src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/FastDeployTypeMapTests.cs rename to src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTypeMapTests.cs diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Utilities/AndroidHelper.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Utilities/AndroidHelper.cs index ec5faa70b81..3086a9008a6 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Utilities/AndroidHelper.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Utilities/AndroidHelper.cs @@ -1,3 +1,4 @@ +#nullable disable using System; using System.Collections.Generic; using System.IO; @@ -11,7 +12,11 @@ namespace Xamarin.Android.Tasks { +#if MICROSOFT_ANDROID_BUILD_TASKS + internal class AndroidHelper +#else public class AndroidHelper +#endif { const string DefaultErrorCode = "XA0010"; const RegisteredTaskObjectLifetime Lifetime = RegisteredTaskObjectLifetime.Build; @@ -109,16 +114,16 @@ static AdbDeviceInfo ParseTarget (string target, Action logMessage, Acti if (target != null && target.Length > 0 && !target.StartsWith ("-e", StringComparison.Ordinal) && !target.StartsWith ("-d", StringComparison.Ordinal) && !target.StartsWith ("-s", StringComparison.Ordinal)) { if (logErrors) - logError (DefaultErrorCode, string.Format (Resources.XA0010_AdbTarget, target)); + logError (DefaultErrorCode, string.Format (System.Globalization.CultureInfo.CurrentCulture, Resources.XA0010_AdbTarget, target)); } else { NoDeviceFound (target, key, logError, logErrors, engine4); } } catch (Exception ex) { RegisterDevice (engine4, key, null); if (logErrors) - logError (DefaultErrorCode, string.Format (Resources.XA0010_Adb, ex)); + logError (DefaultErrorCode, string.Format (System.Globalization.CultureInfo.CurrentCulture, Resources.XA0010_Adb, ex)); else - logMessage (string.Format (Resources.XA0010_Adb, ex)); + logMessage (string.Format (System.Globalization.CultureInfo.CurrentCulture, Resources.XA0010_Adb, ex)); } return null; } diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Build.Debugging.Tasks.csproj b/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Build.Debugging.Tasks.csproj index 136af0f4254..6e45a5a74ed 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Build.Debugging.Tasks.csproj +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Build.Debugging.Tasks.csproj @@ -71,8 +71,4 @@ - - - - diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Common.Debugging.targets b/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Common.Debugging.targets index e489a53803c..e2343ea025a 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Common.Debugging.targets +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Common.Debugging.targets @@ -20,7 +20,8 @@ Copyright (C) 2016 Xamarin. All rights reserved. - + + diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/DebuggingTasksTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/DebuggingTasksTests.cs index 4078643311f..c024c91bee0 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/DebuggingTasksTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/DebuggingTasksTests.cs @@ -198,562 +198,6 @@ public void BuilderDoesNotWaitForInheritedRedirectedOutput () Assert.IsTrue (errorDone.Task.Wait (TimeSpan.FromSeconds (10))); } - [Test] - public void FastDeployParsesWarmStateProbe () - { - var state = FastDeploy.ParseWarmStateProbeOutput ( - """ - __XA_FD_REDIRECT__= - __XA_FD_RUN_AS_DISABLED__= - __XA_FD_REMOTE_HASH__=remote-hash - __XA_FD_PID__=123 456 - __XA_FD_PATH__=/data/user/0/com.example - __XA_FD_OVERRIDE_HASH__=override-hash - __XA_FD_RUN_AS_STATUS__=0 - __XA_FD_FORCE_STOP_STATUS__=0 - """); - - Assert.IsTrue (state.HasRequiredState); - Assert.AreEqual ("remote-hash", state.RemoteHash); - Assert.AreEqual ("override-hash", state.OverrideHash); - Assert.AreEqual ("/data/user/0/com.example", state.InternalPath); - Assert.AreEqual (123, state.ProcessId); - Assert.AreEqual (0, state.RunAsStatus); - Assert.AreEqual (0, state.ForceStopStatus); - } - - [Test] - public void FastDeployRejectsIncompleteWarmStateProbe () - { - var state = FastDeploy.ParseWarmStateProbeOutput ( - """ - __XA_FD_REDIRECT__= - __XA_FD_RUN_AS_DISABLED__=true - run-as: package not debuggable - """); - - Assert.IsFalse (state.HasRequiredState); - Assert.IsTrue (state.HasRunAsDisabled); - Assert.AreEqual ("true", state.RunAsDisabled); - } - - [Test] - public void FastDeployPlansOnlyNewStagingDirectories () - { - var previousFiles = new [] { - "arm64-v8a/App.dll", - "arm64-v8a/en-US/App.resources.dll", - }; - var currentFiles = new [] { - "arm64-v8a/App.dll", - "arm64-v8a/New.dll", - "arm64-v8a/en-US/App.resources.dll", - "arm64-v8a/fr/App.resources.dll", - }; - - HashSet files = FastDeploy.GetFilesRequiringStagingDirectories (currentFiles, previousFiles); - - CollectionAssert.AreEquivalent ( - new [] { "arm64-v8a/fr/App.resources.dll" }, - files); - } - - [Test] - public void FastDeployPlansAllStagingDirectoriesAfterReset () - { - var currentFiles = new [] { - "App.dll", - "arm64-v8a/App.dll", - "arm64-v8a/fr/App.resources.dll", - }; - - HashSet files = FastDeploy.GetFilesRequiringStagingDirectories (currentFiles, previousFiles: null); - - CollectionAssert.AreEquivalent (currentFiles, files); - } - - [TestCase ("adb: error: failed to copy: No such file or directory")] - [TestCase ("adb: error: target '/data/local/tmp/app/arm64-v8a' is not a directory")] - [TestCase ("remote couldn't create file: Is a directory")] - public void FastDeployDetectsInvalidRemoteFilesystem (string output) - { - Assert.IsTrue (FastDeploy.IsUnexpectedRemoteFilesystemError (output)); - } - - [Test] - public void FastDeployDoesNotResetForUnrelatedPushFailure () - { - Assert.IsFalse (FastDeploy.IsUnexpectedRemoteFilesystemError ("adb: error: device offline")); - } - - [TestCase ("adb: error: device offline", false)] - [TestCase ("adb: failed to install app.apk: cmd: Failure calling service package: Broken pipe (32)", true)] - [TestCase ("adb: failed to install app.apk: Broken pipe (32)", false)] - [TestCase ("cmd: Failure calling service package: Security exception", false)] - [TestCase ("Failure [INSTALL_FAILED_INSUFFICIENT_STORAGE]", false)] - public void FastDeployClassifiesOnlyKnownTransientInstallFailures (string output, bool expected) - { - Assert.AreEqual (expected, FastDeploy.IsTransientInstallFailure (output)); - } - - [Test] - public async Task FastDeployRetriesTransientInstallOnce () - { - var task = new TestFastDeploy ( - CreateAdbResult (1, "adb: failed to install app.apk: cmd: Failure calling service package: Broken pipe (32)"), - CreateAdbResult (0, "Success")); - - await task.InstallApkWithRetry ("app.apk", reinstall: false, testOnly: false, user: ""); - - Assert.AreEqual (2, task.InstallAttempts); - Assert.AreEqual (1, task.RecoveryAttempts); - } - - [Test] - public async Task FastDeployRetriesTransientInstallAfterUninstall () - { - var task = new TestFastDeploy ( - CreateAdbResult (1, "Failure [INSTALL_FAILED_ALREADY_EXISTS]"), - CreateAdbResult (1, "adb: failed to install app.apk: cmd: Failure calling service package: Broken pipe (32)"), - CreateAdbResult (0, "Success")); - - await task.InstallApkWithRetry ("app.apk", reinstall: false, testOnly: false, user: ""); - - Assert.AreEqual (3, task.InstallAttempts); - Assert.AreEqual (1, task.UninstallAttempts); - Assert.AreEqual (1, task.RecoveryAttempts); - } - - [Test] - public void FastDeployDoesNotRetryTransientInstallTwiceAcrossUninstall () - { - var task = new TestFastDeploy ( - CreateAdbResult (1, "first failure: cmd: Failure calling service package: Broken pipe (32)"), - CreateAdbResult (1, "Failure [INSTALL_FAILED_ALREADY_EXISTS]"), - CreateAdbResult (1, "third failure: cmd: Failure calling service package: Broken pipe (32)")); - - var exception = Assert.ThrowsAsync ( - async () => await task.InstallApkWithRetry ("app.apk", reinstall: false, testOnly: false, user: "")); - - Assert.IsNotNull (exception); - Assert.AreEqual ("ADB0010", exception.ErrorCode); - Assert.That (exception.Message, Does.Contain ("Install attempt 1")); - Assert.That (exception.Message, Does.Contain ("first failure")); - Assert.That (exception.Message, Does.Contain ("Install attempt 2")); - Assert.That (exception.Message, Does.Contain ("INSTALL_FAILED_ALREADY_EXISTS")); - Assert.That (exception.Message, Does.Contain ("Install attempt 3")); - Assert.That (exception.Message, Does.Contain ("third failure")); - Assert.That (exception.Message, Does.Not.Contain ("Install attempt 4")); - Assert.AreEqual (3, task.InstallAttempts); - Assert.AreEqual (1, task.UninstallAttempts); - Assert.AreEqual (1, task.RecoveryAttempts); - } - - [Test] - public void FastDeployDoesNotRetrySemanticInstallFailure () - { - var task = new TestFastDeploy ( - CreateAdbResult (1, "Failure [INSTALL_FAILED_INSUFFICIENT_STORAGE]")); - - var exception = Assert.ThrowsAsync ( - async () => await task.InstallApkWithRetry ("app.apk", reinstall: false, testOnly: false, user: "")); - - Assert.IsNotNull (exception); - Assert.AreEqual ("ADB0060", exception.ErrorCode); - Assert.AreEqual (1, task.InstallAttempts); - Assert.AreEqual (0, task.RecoveryAttempts); - } - - [Test] - public void FastDeployPreservesBothTransientInstallAttempts () - { - var task = new TestFastDeploy ( - CreateAdbResult (1, "first failure: cmd: Failure calling service package: Broken pipe (32)"), - CreateAdbResult (1, "second failure: cmd: Failure calling service package: Broken pipe (32)")); - - var exception = Assert.ThrowsAsync ( - async () => await task.InstallApkWithRetry ("app.apk", reinstall: false, testOnly: false, user: "")); - - Assert.IsNotNull (exception); - Assert.AreEqual ("ADB0010", exception.ErrorCode); - Assert.That (exception.Message, Does.Contain ("Install attempt 1")); - Assert.That (exception.Message, Does.Contain ("first failure")); - Assert.That (exception.Message, Does.Contain ("ADB transport recovery")); - Assert.That (exception.Message, Does.Contain ("Install attempt 2")); - Assert.That (exception.Message, Does.Contain ("second failure")); - Assert.AreEqual (2, task.InstallAttempts); - Assert.AreEqual (1, task.RecoveryAttempts); - } - - [Test] - public void FastDeployPreservesOriginalFailureWhenRecoveryFails () - { - var task = new TestFastDeploy ( - new InvalidOperationException ("package manager still unavailable"), - CreateAdbResult (1, "cmd: Failure calling service package: Broken pipe (32)")); - - var exception = Assert.ThrowsAsync ( - async () => await task.InstallApkWithRetry ("app.apk", reinstall: false, testOnly: false, user: "")); - - Assert.IsNotNull (exception); - Assert.That (exception.Message, Does.Contain ("Failure calling service package: Broken pipe (32)")); - Assert.That (exception.Message, Does.Contain ("package manager still unavailable")); - Assert.AreEqual (1, task.InstallAttempts); - Assert.AreEqual (1, task.RecoveryAttempts); - } - - // https://github.com/dotnet/android/issues/12878 - [TestCase (137, "Killed")] - [TestCase (1, "")] - [TestCase (1, "error: closed")] - [TestCase (1, "adb: error: device offline")] - [TestCase (1, "find: '/data/local/tmp/fastdeploy/com.example.app/0/lib': Permission denied")] - public void FastDeployRejectsFailedStagingFileListing (int exitCode, string output) - { - using var device = new FakeAdbDevice (exitCode, output); - - Assert.IsFalse (device.RunFastDeploy (), "A failed listing must fail the task."); - - Assert.AreEqual (1, device.Errors.Count); - Assert.AreEqual ("XA0129", device.Errors [0].Code); - Assert.That (device.Errors [0].Message, Does.Contain (FakeAdbDevice.StagingRoot)); - Assert.That (device.Commands.Last (), Does.Contain ($"find '{FakeAdbDevice.StagingRoot}'"), - "Nothing may be changed on the device after the staging listing failed."); - } - - [TestCase (1, "find: '/data/local/tmp/fastdeploy/com.example.app/0': No such file or directory")] - [TestCase (0, "")] - public void FastDeployTreatsMissingOrEmptyStagingDirectoryAsEmpty (int exitCode, string output) - { - using var device = new FakeAdbDevice (exitCode, output); - - Assert.IsTrue (device.RunFastDeploy ()); - - Assert.AreEqual (0, device.Errors.Count); - } - - [Test] - public void FastDeployCopiesFilesFromSuccessfulStagingFileListing () - { - using var device = new FakeAdbDevice ( - 0, $"{FakeAdbDevice.StagingRoot}/arm64-v8a/Foo.dll|3|1700000000", - $"find: '{FakeAdbDevice.OverrideRoot}': No such file or directory"); - - Assert.IsTrue (device.RunFastDeploy ()); - - Assert.AreEqual (0, device.Errors.Count); - Assert.That (device.Commands, Has.Some.Contain ($"cp -p {FakeAdbDevice.StagingRoot}/arm64-v8a/Foo.dll")); - } - - // https://github.com/dotnet/android/issues/13274 - [TestCase ("Copy", false)] - [TestCase ("Symlink", false)] - [TestCase ("Copy", true)] - public void FastDeployRejectsPartialFileListing (string transferMode, bool overrideListing) - { - string root = overrideListing ? FakeAdbDevice.OverrideRoot : FakeAdbDevice.StagingRoot; - string listing = $"{root}/arm64-v8a/Foo.dll|3|1700000000\nstat: '{root}/arm64-v8a/Bar.dll': No such file or directory"; - using var device = new FakeAdbDevice ( - overrideListing ? 0 : 1, - overrideListing ? $"{FakeAdbDevice.StagingRoot}/arm64-v8a/Foo.dll|3|1700000000" : listing, - overrideListing ? listing : $"{FakeAdbDevice.OverrideRoot}/.fastdeploy-manifest-hash|64|1700000000"); - if (transferMode == "Symlink") { - device.FailCommand ("ln -sf", "ln: Operation not permitted"); - } - - Assert.IsFalse (device.RunFastDeploy (symlink: transferMode == "Symlink"), "Partial listings must fail deployment."); - - Assert.AreEqual (1, device.Errors.Count); - Assert.AreEqual ("XA0129", device.Errors [0].Code); - Assert.That (device.Errors [0].Message, Does.Contain (root)); - Assert.That (device.Commands.Last (), Does.Contain ("-exec stat"), "No commands after a failed listing."); - Assert.That (device.Commands, Has.None.Contain ($"rm -rf {FakeAdbDevice.OverrideRoot}"), - "The override directory must not be cleared."); - Assert.That (device.Commands, Has.None.Contain ("printf %s"), "No success marker may be written."); - Assert.IsFalse (device.HasManifest); - } - - [Test] - public void FastDeployRejectsMissingRootWithValidEntries () - { - string root = FakeAdbDevice.StagingRoot; - using var device = new FakeAdbDevice (1, $"{root}/arm64-v8a/Foo.dll|3|1700000000\nfind: '{root}': No such file or directory"); - - Assert.IsFalse (device.RunFastDeploy ()); - Assert.AreEqual ("XA0129", device.Errors.Single ().Code); - Assert.That (device.Commands, Has.None.Contain ("printf %s")); - Assert.IsFalse (device.HasManifest); - } - - [Test] - public void FastDeployCopiesAfterClearingSymlinkState () - { - using var device = new FakeAdbDevice ( - 0, $"{FakeAdbDevice.StagingRoot}/arm64-v8a/Foo.dll|3|1700000000", - $"{FakeAdbDevice.OverrideRoot}/arm64-v8a/Foo.dll|3|1700000000\n{FakeAdbDevice.OverrideRoot}/.fastdeploy-manifest-hash|64|1700000000"); - - Assert.IsTrue (device.RunFastDeploy ()); - - var commands = device.Commands.ToList (); - int clearIndex = commands.FindIndex (c => c.Contains ($"rm -rf {FakeAdbDevice.OverrideRoot}")); - Assert.Greater (clearIndex, commands.FindLastIndex (c => c.Contains ("-exec stat")), - "Both listings must precede clearing the override directory."); - Assert.That (commands.Skip (clearIndex + 1), Has.Some.Contain ($"cp -p {FakeAdbDevice.StagingRoot}/arm64-v8a/Foo.dll"), - "Cleared files must be copied even when the previous listing matched staging."); - Assert.IsTrue (device.HasManifest); - } - - [Test] - public void FastDeployPreservesManifestAfterPartialFileListing () - { - using var device = new FakeAdbDevice (0, $"{FakeAdbDevice.StagingRoot}/arm64-v8a/Foo.dll|3|1700000000"); - Assert.IsTrue (device.RunFastDeploy ()); - string previousManifest = device.Manifest; - int previousCommandCount = device.Commands.Count; - device.TouchPayload (); - device.SetStagingListing (1, $"{FakeAdbDevice.StagingRoot}/arm64-v8a/Foo.dll|3|1700000000\nstat: '{FakeAdbDevice.StagingRoot}/arm64-v8a/Bar.dll': No such file or directory"); - - Assert.IsFalse (device.RunFastDeploy ()); - - Assert.AreEqual ("XA0129", device.Errors.Single ().Code); - Assert.AreEqual (previousManifest, device.Manifest, "A failed incremental deployment must not overwrite the successful host manifest."); - Assert.That (device.Commands.Skip (previousCommandCount), Has.None.Contain ("printf %s"), - "A failed incremental deployment must not update the remote success marker."); - } - - static FastDeploy.AdbCommandResult CreateAdbResult (int exitCode, string output) - { - return new FastDeploy.AdbCommandResult { - ExitCode = exitCode, - StandardOutput = output, - StandardError = "", - }; - } - - sealed class TestFastDeploy : FastDeploy - { - readonly Queue results; - readonly Exception recoveryException; - - public int InstallAttempts { get; private set; } - public int UninstallAttempts { get; private set; } - public int RecoveryAttempts { get; private set; } - - public TestFastDeploy (params AdbCommandResult [] results) - : this (recoveryException: null, results) - { - } - - public TestFastDeploy (Exception recoveryException, params AdbCommandResult [] results) - { - this.results = new Queue (results); - this.recoveryException = recoveryException; - BuildEngine = new MockBuildEngine (TestContext.Out); - PackageName = "com.example.app"; - } - - internal override Task RunInstallCommand (string apkFile, bool reinstall, bool testOnly, string user) - { - InstallAttempts++; - return Task.FromResult (results.Dequeue ()); - } - - internal override Task UninstallPackage (string packageName, bool preserveData, string user) - { - UninstallAttempts++; - return Task.CompletedTask; - } - - internal override Task WaitForInstallTransportRecovery () - { - RecoveryAttempts++; - return recoveryException == null ? Task.CompletedTask : Task.FromException (recoveryException); - } - } - - // Runs FastDeploy against a fake adb with configurable command results. - sealed class FakeAdbDevice : IDisposable - { - public const string PackageName = "com.example.app"; - public const string StagingRoot = "/data/local/tmp/fastdeploy/" + PackageName + "/0"; - public const string OverrideRoot = "files/.__override__"; - - const string AdbScript = """ - #!/bin/bash - dir="$(dirname "$0")" - printf '%s\n' "$*" >> "$dir/adb.log" - for fault in fault second-fault; do - if [ -f "$dir/$fault" ] && [[ "$*" == *"$(cat "$dir/$fault")"* ]]; then - cat "$dir/$fault.out" >&2 - exit "$(cat "$dir/$fault.exit")" - fi - done - case "$*" in - "devices -l") - echo "List of devices attached" - echo "emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1" - ;; - *"pwd"*"pidof"*) - cat "$dir/package.out" - exit "$(cat "$dir/package.exit")" - ;; - *"readlink -f ."*) - echo "/data/user/0/com.example.app" - ;; - *"shell find "*"-exec stat"*) - cat "$dir/staging-listing.out" - exit "$(cat "$dir/staging-listing.exit")" - ;; - *"run-as"*"find "*) - cat "$dir/override.out" - exit "$(cat "$dir/override.exit")" - ;; - *"remote="*"override="*) - hash="$(cat "$dir/hash" 2>/dev/null)" - echo "remote=$hash" - echo "override=$hash" - ;; - *"printf %s '"*) - if [[ "$*" =~ printf\ %s\ \'([0-9a-fA-F]+)\' ]]; then - printf '%s' "${BASH_REMATCH[1]}" > "$dir/hash" - fi - ;; - esac - exit 0 - """; - - readonly string directory = Path.Combine (Path.GetTempPath (), "fake-adb-" + Guid.NewGuid ().ToString ("N")); - readonly List errors = new List (); - readonly List files = []; - - public FakeAdbDevice () - : this (0, $"{StagingRoot}/arm64-v8a/Foo.dll|3|1700000000") - { - } - - public FakeAdbDevice (int stagingListingExitCode, string stagingListing, string overrideListing = "files/.__override__/stale.dll|1|1700000000") - { - if (OperatingSystem.IsWindows ()) { - Assert.Ignore ("The fake adb is a bash script, which is not supported on Windows."); - } else { - Directory.CreateDirectory (Path.Combine (directory, "obj")); - AddFile ("Foo.dll"); - WriteResult ("staging-listing", stagingListingExitCode, stagingListing); - WriteResult ("package", 0, "/data/user/0/com.example.app"); - WriteResult ("override", 0, overrideListing); - - var adb = Path.Combine (directory, "adb"); - File.WriteAllText (adb, AdbScript); - File.SetUnixFileMode (adb, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); - } - } - - public IReadOnlyList Errors => errors; - - public bool HasManifest => Directory.GetFiles (Path.Combine (directory, "obj"), "manifest.json", SearchOption.AllDirectories).Length > 0; - - public string Manifest => File.ReadAllText (Directory.GetFiles (Path.Combine (directory, "obj"), "manifest.json", SearchOption.AllDirectories).Single ()); - - public void SetStagingListing (int exitCode, string output) - { - WriteResult ("staging-listing", exitCode, output); - } - - public void TouchPayload () - { - string path = Path.Combine (directory, "Foo.dll"); - File.SetLastWriteTimeUtc (path, File.GetLastWriteTimeUtc (path).AddMinutes (1)); - } - - // One entry per adb invocation, without the adb executable itself. - public IReadOnlyList Commands => File.ReadAllLines (Path.Combine (directory, "adb.log")); - - public void AddFile (string name) - { - string path = Path.Combine (directory, name); - File.WriteAllText (path, "foo"); - var item = new TaskItem (path); - item.SetMetadata ("TargetPath", "arm64-v8a/" + name); - files.Add (item); - File.WriteAllLines (Path.Combine (directory, "staging-listing.out"), - files.Select (file => $"{StagingRoot}/{file.GetMetadata ("TargetPath")}|3|1700000000")); - } - - public void FailCommand (string command, string output, bool second = false, int exitCode = 1) - { - string fault = second ? "second-fault" : "fault"; - File.WriteAllText (Path.Combine (directory, fault), command); - WriteResult (fault, exitCode, output); - } - - public void WriteResult (string name, int exitCode, string output) - { - File.WriteAllText (Path.Combine (directory, name + ".exit"), exitCode.ToString ()); - File.WriteAllText (Path.Combine (directory, name + ".out"), output); - } - - public bool RunFastDeploy (bool symlink = false, bool embedded = false, bool install = false) - { - string apk = Path.Combine (directory, "app.apk"); - if (install) { - File.WriteAllText (apk, ""); - } - var task = new FastDeploy { - BuildEngine = new MockBuildEngine (TestContext.Out, errors), - AdbToolPath = directory, - AppFileTransferMode = symlink ? "Symlink" : "Copy", - EmbedAssembliesIntoApk = embedded, - FastDevFiles = files.ToArray (), - IntermediateOutputPath = Path.Combine (directory, "obj") + Path.DirectorySeparatorChar, - PackageName = PackageName, - PackageFile = install ? apk : null, - PrimaryCpuAbi = "arm64-v8a", - UploadFlagFile = Path.Combine (directory, "obj", "upload.flag"), - }; - return task.Execute (); - } - - public int RunGlobScript (string failingCommand) - { - string staging = Path.Combine (directory, "staging"); - string target = Path.Combine (directory, "override"); - Directory.CreateDirectory (staging); - Directory.CreateDirectory (target); - foreach (string name in new [] { "Bar.dll", "Foo.dll", "Zoo.dll" }) { - File.WriteAllText (Path.Combine (staging, name), "foo"); - File.WriteAllText (Path.Combine (target, name), "old"); - } - string executable = Path.Combine (directory, failingCommand); - File.WriteAllText (executable, $""" - #!/bin/sh - case "$*" in *Foo.dll*) exit 137;; esac - exec /bin/{failingCommand} "$@" - """); - if (!OperatingSystem.IsWindows ()) { - File.SetUnixFileMode (executable, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); - } - - string invocation = Commands.Single (c => c.Contains ("for f in")); - const string shellPrefix = "'sh' '-c' "; - string script = invocation.Substring (invocation.IndexOf (shellPrefix, StringComparison.Ordinal) + shellPrefix.Length) - .Replace (StagingRoot + "/arm64-v8a", staging) - .Replace ("files/.__override__/arm64-v8a", target); - var startInfo = new ProcessStartInfo ("/bin/sh") { UseShellExecute = false }; - startInfo.ArgumentList.Add ("-c"); - startInfo.ArgumentList.Add ("/bin/sh -c " + script); - startInfo.Environment ["PATH"] = directory + Path.PathSeparator + Environment.GetEnvironmentVariable ("PATH"); - using var process = Process.Start (startInfo); - Assert.IsNotNull (process); - process.WaitForExit (); - return process.ExitCode; - } - - public void Dispose () - { - Directory.Delete (directory, recursive: true); - } - } - } } From e20c84273a245d6a7706c13eccf6ec1562ee3b27 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 11 Nov 2026 00:16:01 +0100 Subject: [PATCH 2/5] [FastDeploy] Share the selected adb device across task assemblies `AndroidHelper` caches the device selected by `GetPrimaryCpuAbi` with `RegisterTaskObjectAssemblyLocal`, whose key includes the Microsoft.Android.Build.BaseTasks path. FastDeploy now loads from `tools/net/` instead of `tools/`, so it missed that cache and ran an additional `adb devices -l` on every deploy. Also publish the selected serial (`""` for no device) under a framework-typed, non-assembly-local key, and read it only from the Microsoft.Android.Build.Tasks copy. FastDeploy only uses `AdbDeviceInfo.Serial`. `Microsoft.Android.Build.Tasks.csproj` did not define `MICROSOFT_ANDROID_BUILD_TASKS`, so the linked `AndroidHelper` was compiled as `public` there. Define it so the copy is `internal`. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.Android.Build.Tasks.csproj | 1 + .../FastDeployTests.DeviceCache.cs | 54 +++++++++++++++++++ .../Utilities/AndroidHelper.cs | 13 ++++- 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.DeviceCache.cs diff --git a/src/Microsoft.Android.Build.Tasks/Microsoft.Android.Build.Tasks.csproj b/src/Microsoft.Android.Build.Tasks/Microsoft.Android.Build.Tasks.csproj index 7d38f4a08f2..5f12bdd8a86 100644 --- a/src/Microsoft.Android.Build.Tasks/Microsoft.Android.Build.Tasks.csproj +++ b/src/Microsoft.Android.Build.Tasks/Microsoft.Android.Build.Tasks.csproj @@ -14,6 +14,7 @@ enable true preview + $(DefineConstants);MICROSOFT_ANDROID_BUILD_TASKS 2 diff --git a/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.DeviceCache.cs b/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.DeviceCache.cs new file mode 100644 index 00000000000..194f9b70968 --- /dev/null +++ b/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.DeviceCache.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.Build.Framework; +using NUnit.Framework; +using Xamarin.Android.Tasks; + +namespace Xamarin.Android.Build.Tests +{ + public partial class FastDeployTests + { + // Xamarin.Android.Build.Debugging.Tasks.dll (GetPrimaryCpuAbi) publishes the selected serial under this key. + static object SharedDeviceKey (string target, string adbPath) => + ("AndroidHelper_AndroidDeviceSerial", (object) Tuple.Create ("AndroidHelper_AndroidDevice", target, adbPath)); + + [Test] + public void FastDeployDoesNotExportAndroidHelper () + { + Assert.IsFalse (typeof (AndroidHelper).IsPublic); + } + + [Test] + public void FastDeployReusesDeviceSelectedByDebuggingTasks () + { + string adbToolPath = Path.Combine (Path.GetTempPath (), "missing-" + Guid.NewGuid ().ToString ("N")); + IBuildEngine4 engine = new MockBuildEngine (TestContext.Out); + engine.RegisterTaskObject (SharedDeviceKey ("-s emulator-5554", Path.Combine (adbToolPath, "adb")), "emulator-5554", RegisteredTaskObjectLifetime.Build, false); + var messages = new List (); + var errors = new List (); + + var device = AndroidHelper.ParseTarget ("-s emulator-5554", messages.Add, (code, message) => errors.Add (message), + logErrors: true, engine4: engine, adbToolPath: adbToolPath, adbToolExe: "adb"); + + Assert.AreEqual ("emulator-5554", device?.Serial); + Assert.IsEmpty (errors, "adb must not run when another task assembly already selected the device."); + CollectionAssert.Contains (messages, "Using cached value from RegisterTaskObject"); + } + + [Test] + public void FastDeployReusesMissingDeviceSelectedByDebuggingTasks () + { + string adbToolPath = Path.Combine (Path.GetTempPath (), "missing-" + Guid.NewGuid ().ToString ("N")); + IBuildEngine4 engine = new MockBuildEngine (TestContext.Out); + engine.RegisterTaskObject (SharedDeviceKey ("", Path.Combine (adbToolPath, "adb")), "", RegisteredTaskObjectLifetime.Build, false); + var errors = new List (); + + var device = AndroidHelper.ParseTarget ("", _ => { }, (code, message) => errors.Add ($"{code}: {message}"), + logErrors: true, engine4: engine, adbToolPath: adbToolPath, adbToolExe: "adb"); + + Assert.IsNull (device); + CollectionAssert.AreEqual (new [] { $"XA0010: {Xamarin.Android.Build.Debugging.Tasks.Properties.Resources.XA0010_NoDevice}" }, errors); + } + } +} diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Utilities/AndroidHelper.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Utilities/AndroidHelper.cs index 3086a9008a6..73748192760 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Utilities/AndroidHelper.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Utilities/AndroidHelper.cs @@ -28,8 +28,15 @@ static object GetKey (string target, string adbPath, TaskEnvironment taskEnviron return taskEnvironment == null ? key : (key, taskEnvironment.ProjectDirectory); } - static void RegisterDevice (IBuildEngine4 engine, object key, AdbDeviceInfo device) => + // Microsoft.Android.Build.Tasks.dll (FastDeploy) cannot read this assembly's assembly-local + // cache or AdbDeviceInfo type, so also share the selected serial ("" when there is no device). + static object GetSharedKey (object key) => ($"{nameof (AndroidHelper)}_AndroidDeviceSerial", key); + + static void RegisterDevice (IBuildEngine4 engine, object key, AdbDeviceInfo device) + { engine?.RegisterTaskObjectAssemblyLocal (key, device ?? NullDevice, Lifetime, allowEarlyCollection: false); + engine?.RegisterTaskObject (GetSharedKey (key), device?.Serial ?? "", Lifetime, allowEarlyCollection: false); + } public static AdbRunner CreateAdbRunner (string adbToolPath, string adbToolExe) { @@ -95,6 +102,10 @@ static AdbDeviceInfo ParseTarget (string target, Action logMessage, Acti var key = GetKey (target, adbPath, taskEnvironment); try { var device = engine4?.GetRegisteredTaskObjectAssemblyLocal (key, Lifetime); +#if MICROSOFT_ANDROID_BUILD_TASKS + if (device == null && engine4?.GetRegisteredTaskObject (GetSharedKey (key), Lifetime) is string serial) + device = serial.Length == 0 ? NullDevice : new AdbDeviceInfo { Serial = serial }; +#endif if (device != null) { logMessage ("Using cached value from RegisterTaskObject"); if (ReferenceEquals (device, NullDevice)) From 2c9ec5ac568a3b1ad8c9a26de8d5eac6297e1e52 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 11 Nov 2026 01:05:03 +0100 Subject: [PATCH 3/5] [FastDeploy] Cache adb devices as framework types Replace the assembly-local device cache plus serial side-channel with a single cache whose values are framework types, so the copies of `AndroidHelper` in Xamarin.Android.Build.Debugging.Tasks.dll and Microsoft.Android.Build.Tasks.dll share every `AdbDeviceInfo` field (an empty array means no device was found), as they did when both tasks lived in one assembly. Keep `AndroidHelper` public in both copies and drop the `MICROSOFT_ANDROID_BUILD_TASKS` define. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.Android.Build.Tasks.csproj | 1 - .../FastDeployTests.DeviceCache.cs | 33 ++++++++----- .../Utilities/AndroidHelper.cs | 46 ++++++++++--------- 3 files changed, 46 insertions(+), 34 deletions(-) diff --git a/src/Microsoft.Android.Build.Tasks/Microsoft.Android.Build.Tasks.csproj b/src/Microsoft.Android.Build.Tasks/Microsoft.Android.Build.Tasks.csproj index 5f12bdd8a86..7d38f4a08f2 100644 --- a/src/Microsoft.Android.Build.Tasks/Microsoft.Android.Build.Tasks.csproj +++ b/src/Microsoft.Android.Build.Tasks/Microsoft.Android.Build.Tasks.csproj @@ -14,7 +14,6 @@ enable true preview - $(DefineConstants);MICROSOFT_ANDROID_BUILD_TASKS 2 diff --git a/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.DeviceCache.cs b/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.DeviceCache.cs index 194f9b70968..52de8b9df9d 100644 --- a/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.DeviceCache.cs +++ b/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.DeviceCache.cs @@ -3,37 +3,46 @@ using System.IO; using Microsoft.Build.Framework; using NUnit.Framework; +using Microsoft.Android.Tasks; using Xamarin.Android.Tasks; +using Xamarin.Android.Tools; namespace Xamarin.Android.Build.Tests { public partial class FastDeployTests { - // Xamarin.Android.Build.Debugging.Tasks.dll (GetPrimaryCpuAbi) publishes the selected serial under this key. - static object SharedDeviceKey (string target, string adbPath) => - ("AndroidHelper_AndroidDeviceSerial", (object) Tuple.Create ("AndroidHelper_AndroidDevice", target, adbPath)); - - [Test] - public void FastDeployDoesNotExportAndroidHelper () - { - Assert.IsFalse (typeof (AndroidHelper).IsPublic); - } + // Xamarin.Android.Build.Debugging.Tasks.dll (GetPrimaryCpuAbi) caches the selected device under this key. + static object DeviceCacheKey (string target, string adbPath) => + Tuple.Create ("AndroidHelper_AndroidDevice", target, adbPath); [Test] public void FastDeployReusesDeviceSelectedByDebuggingTasks () { string adbToolPath = Path.Combine (Path.GetTempPath (), "missing-" + Guid.NewGuid ().ToString ("N")); IBuildEngine4 engine = new MockBuildEngine (TestContext.Out); - engine.RegisterTaskObject (SharedDeviceKey ("-s emulator-5554", Path.Combine (adbToolPath, "adb")), "emulator-5554", RegisteredTaskObjectLifetime.Build, false); + engine.RegisterTaskObject (DeviceCacheKey ("-s emulator-5554", Path.Combine (adbToolPath, "adb")), new object [] { + "emulator-5554", "sdk_gphone64_arm64", (int) AdbDeviceType.Emulator, (int) AdbDeviceStatus.Online, null, + "sdk_gphone64_arm64", "sdk_gphone64_arm64", "emu64a", "1", "emulator-5554 device transport_id:1", + }, RegisteredTaskObjectLifetime.Build, false); var messages = new List (); var errors = new List (); var device = AndroidHelper.ParseTarget ("-s emulator-5554", messages.Add, (code, message) => errors.Add (message), logErrors: true, engine4: engine, adbToolPath: adbToolPath, adbToolExe: "adb"); - Assert.AreEqual ("emulator-5554", device?.Serial); Assert.IsEmpty (errors, "adb must not run when another task assembly already selected the device."); CollectionAssert.Contains (messages, "Using cached value from RegisterTaskObject"); + Assert.IsNotNull (device); + Assert.AreEqual ("emulator-5554", device.Serial); + Assert.AreEqual ("sdk_gphone64_arm64", device.Description); + Assert.IsTrue (device.IsEmulator); + Assert.AreEqual (AdbDeviceStatus.Online, device.Status); + Assert.IsNull (device.AvdName); + Assert.AreEqual ("sdk_gphone64_arm64", device.Model); + Assert.AreEqual ("sdk_gphone64_arm64", device.Product); + Assert.AreEqual ("emu64a", device.Device); + Assert.AreEqual ("1", device.TransportId); + Assert.AreEqual ("emulator-5554 device transport_id:1", device.LongOutput); } [Test] @@ -41,7 +50,7 @@ public void FastDeployReusesMissingDeviceSelectedByDebuggingTasks () { string adbToolPath = Path.Combine (Path.GetTempPath (), "missing-" + Guid.NewGuid ().ToString ("N")); IBuildEngine4 engine = new MockBuildEngine (TestContext.Out); - engine.RegisterTaskObject (SharedDeviceKey ("", Path.Combine (adbToolPath, "adb")), "", RegisteredTaskObjectLifetime.Build, false); + engine.RegisterTaskObject (DeviceCacheKey ("", Path.Combine (adbToolPath, "adb")), new object [0], RegisteredTaskObjectLifetime.Build, false); var errors = new List (); var device = AndroidHelper.ParseTarget ("", _ => { }, (code, message) => errors.Add ($"{code}: {message}"), diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Utilities/AndroidHelper.cs b/src/Xamarin.Android.Build.Debugging.Tasks/Utilities/AndroidHelper.cs index 73748192760..8703a2fd209 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Utilities/AndroidHelper.cs +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Utilities/AndroidHelper.cs @@ -12,15 +12,10 @@ namespace Xamarin.Android.Tasks { -#if MICROSOFT_ANDROID_BUILD_TASKS - internal class AndroidHelper -#else public class AndroidHelper -#endif { const string DefaultErrorCode = "XA0010"; const RegisteredTaskObjectLifetime Lifetime = RegisteredTaskObjectLifetime.Build; - static readonly object NullDevice = new object (); static object GetKey (string target, string adbPath, TaskEnvironment taskEnvironment) { @@ -28,15 +23,27 @@ static object GetKey (string target, string adbPath, TaskEnvironment taskEnviron return taskEnvironment == null ? key : (key, taskEnvironment.ProjectDirectory); } - // Microsoft.Android.Build.Tasks.dll (FastDeploy) cannot read this assembly's assembly-local - // cache or AdbDeviceInfo type, so also share the selected serial ("" when there is no device). - static object GetSharedKey (object key) => ($"{nameof (AndroidHelper)}_AndroidDeviceSerial", key); + // This file is compiled into both Xamarin.Android.Build.Debugging.Tasks.dll and + // Microsoft.Android.Build.Tasks.dll (FastDeploy). Cache devices as framework types so both + // copies share them; an empty array means no device was found. + static void RegisterDevice (IBuildEngine4 engine, object key, AdbDeviceInfo device) => + engine?.RegisterTaskObject (key, device == null ? [] : new object [] { + device.Serial, device.Description, (int) device.Type, (int) device.Status, device.AvdName, + device.Model, device.Product, device.Device, device.TransportId, device.LongOutput, + }, Lifetime, allowEarlyCollection: false); - static void RegisterDevice (IBuildEngine4 engine, object key, AdbDeviceInfo device) - { - engine?.RegisterTaskObjectAssemblyLocal (key, device ?? NullDevice, Lifetime, allowEarlyCollection: false); - engine?.RegisterTaskObject (GetSharedKey (key), device?.Serial ?? "", Lifetime, allowEarlyCollection: false); - } + static AdbDeviceInfo ToDevice (object [] cached) => new AdbDeviceInfo { + Serial = (string) cached [0], + Description = (string) cached [1], + Type = (AdbDeviceType) (int) cached [2], + Status = (AdbDeviceStatus) (int) cached [3], + AvdName = (string) cached [4], + Model = (string) cached [5], + Product = (string) cached [6], + Device = (string) cached [7], + TransportId = (string) cached [8], + LongOutput = (string) cached [9], + }; public static AdbRunner CreateAdbRunner (string adbToolPath, string adbToolExe) { @@ -101,16 +108,13 @@ static AdbDeviceInfo ParseTarget (string target, Action logMessage, Acti GetAdbPath (adbToolPath, adbToolExe, taskEnvironment); var key = GetKey (target, adbPath, taskEnvironment); try { - var device = engine4?.GetRegisteredTaskObjectAssemblyLocal (key, Lifetime); -#if MICROSOFT_ANDROID_BUILD_TASKS - if (device == null && engine4?.GetRegisteredTaskObject (GetSharedKey (key), Lifetime) is string serial) - device = serial.Length == 0 ? NullDevice : new AdbDeviceInfo { Serial = serial }; -#endif - if (device != null) { + if (engine4?.GetRegisteredTaskObject (key, Lifetime) is object [] cached) { logMessage ("Using cached value from RegisterTaskObject"); - if (ReferenceEquals (device, NullDevice)) + if (cached.Length == 0) { NoDeviceFound (target, key, logError, logErrors, engine4); - return device as AdbDeviceInfo; + return null; + } + return ToDevice (cached); } var runner = taskEnvironment == null ? CreateAdbRunner (adbToolPath, adbToolExe) : CreateAdbRunner (adbToolPath, adbToolExe, taskEnvironment); From c9d8b805f25d30d89fe613a9ee7a684bd5b358b0 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 11 Nov 2026 01:05:03 +0100 Subject: [PATCH 4/5] [FastDeploy] Use the Microsoft.Android.Tasks namespace Move `FastDeploy` and `FastDeployJsonSerializerContext` into `Microsoft.Android.Tasks`, like the other tasks in Microsoft.Android.Build.Tasks.dll, and register the task as `Microsoft.Android.Tasks.FastDeploy`. `` keeps resolving; the old qualified `` name does not. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.Adb.cs | 2 +- .../Tasks/FastDeploy.Manifest.cs | 2 +- .../Tasks/FastDeploy.WarmState.cs | 2 +- src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.cs | 3 ++- .../Tasks/FastDeployJsonSerializerContext.cs | 2 +- .../Microsoft.Android.Build.Tasks.Tests/FastDeployTests.cs | 1 + .../FastDeployTypeMapTests.cs | 2 +- .../Xamarin.Android.Common.Debugging.targets | 4 ++-- 8 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.Adb.cs b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.Adb.cs index b96acac9ff4..0cbd22cfd18 100644 --- a/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.Adb.cs +++ b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.Adb.cs @@ -7,7 +7,7 @@ using Xamarin.Android.Tools; -namespace Xamarin.Android.Tasks +namespace Microsoft.Android.Tasks { public partial class FastDeploy { diff --git a/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.Manifest.cs b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.Manifest.cs index 67e54786e47..b7650e45148 100644 --- a/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.Manifest.cs +++ b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.Manifest.cs @@ -9,7 +9,7 @@ using System.Text.Json.Serialization; using System.Threading.Tasks; -namespace Xamarin.Android.Tasks +namespace Microsoft.Android.Tasks { public partial class FastDeploy { diff --git a/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.WarmState.cs b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.WarmState.cs index a369e79969b..99fee400e32 100644 --- a/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.WarmState.cs +++ b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.WarmState.cs @@ -6,7 +6,7 @@ using Xamarin.Android.Build.Debugging.Tasks.Properties; -namespace Xamarin.Android.Tasks +namespace Microsoft.Android.Tasks { public partial class FastDeploy { diff --git a/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.cs b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.cs index ee58df180a2..a6545debe78 100644 --- a/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.cs +++ b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeploy.cs @@ -11,9 +11,10 @@ using Microsoft.Android.Build.Tasks; using Microsoft.Build.Framework; using Xamarin.Android.Build.Debugging.Tasks.Properties; +using Xamarin.Android.Tasks; using Xamarin.Android.Tools; -namespace Xamarin.Android.Tasks +namespace Microsoft.Android.Tasks { public partial class FastDeploy : AsyncTask { diff --git a/src/Microsoft.Android.Build.Tasks/Tasks/FastDeployJsonSerializerContext.cs b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeployJsonSerializerContext.cs index 7f5de039ccb..dae06b1ad2f 100644 --- a/src/Microsoft.Android.Build.Tasks/Tasks/FastDeployJsonSerializerContext.cs +++ b/src/Microsoft.Android.Build.Tasks/Tasks/FastDeployJsonSerializerContext.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -namespace Xamarin.Android.Tasks +namespace Microsoft.Android.Tasks { [JsonSourceGenerationOptions (WriteIndented = true)] [JsonSerializable (typeof (FastDeploy.ManifestData))] diff --git a/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.cs b/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.cs index 6c736b3a6fe..7989789bd7e 100644 --- a/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.cs +++ b/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTests.cs @@ -6,6 +6,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; +using Microsoft.Android.Tasks; using Xamarin.Android.Tasks; using TaskItem = Microsoft.Build.Utilities.TaskItem; diff --git a/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTypeMapTests.cs b/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTypeMapTests.cs index f9de1b13da3..211e73e5c2a 100644 --- a/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTypeMapTests.cs +++ b/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/FastDeployTypeMapTests.cs @@ -4,7 +4,7 @@ using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using NUnit.Framework; -using Xamarin.Android.Tasks; +using Microsoft.Android.Tasks; namespace Xamarin.Android.Build.Tests { diff --git a/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Common.Debugging.targets b/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Common.Debugging.targets index e2343ea025a..4ae575b32e4 100644 --- a/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Common.Debugging.targets +++ b/src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Common.Debugging.targets @@ -20,8 +20,8 @@ Copyright (C) 2016 Xamarin. All rights reserved. - - + + From a68f4b7f9ce4b4adc007a4f58beb8f4f3d9ebb85 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 11 Nov 2026 07:19:46 +0100 Subject: [PATCH 5/5] [tests] Pass the MABT task assembly to the app detection fixture `AppDetectionTests` imports `Xamarin.Android.Common.Debugging.targets` without `Microsoft.Android.Sdk.targets`, so FastDeploy's `` evaluated `$(_MicrosoftAndroidBuildTasksAssembly)` to an empty string (MSB4022) and `DeviceSelectionSurvivesDeviceOrderChanges` failed before the detector ran. Set the property alongside `_XamarinAndroidBuildTasksAssembly`. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tests/Xamarin.Android.Build.Tests/Tasks/AppDetectionTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/AppDetectionTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/AppDetectionTests.cs index 3358de3b6a2..6191da324d1 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/AppDetectionTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/AppDetectionTests.cs @@ -57,6 +57,7 @@ public async Task DeviceSelectionSurvivesDeviceOrderChanges (bool multithreaded, new XElement ("AndroidLaunchActivity", "MainActivity"), new XElement ("_AndroidComputeRunArgumentsDependsOn", "_AndroidAdbToolPath"), new XElement ("_XamarinAndroidBuildTasksAssembly", Path.Combine (TestEnvironment.AndroidMSBuildDirectory, "Xamarin.Android.Build.Tasks.dll")), + new XElement ("_MicrosoftAndroidBuildTasksAssembly", Path.Combine (TestEnvironment.AndroidMSBuildDirectory, "net", "Microsoft.Android.Build.Tasks.dll")), new XElement ("AdbToolPath", Path.Combine (detection.Directory, "tools")), new XElement ("AdbToolExe", detection.Executable)), new XElement ("Import", new XAttribute ("Project",