From 728114f985cd97674d4073f759e871644299de23 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 26 Feb 2026 12:53:45 -0600 Subject: [PATCH 1/5] [dotnet test] Add `androidtest` project template and `dotnet run` instrumentation support Context: https://github.com/dotnet/android/issues/10683 Add a new `androidtest` .NET project template that creates an Android test project using MSTest with Microsoft.Testing.Platform. The template includes a TestInstrumentation class that runs tests via `am instrument` and reports results (passed/failed/skipped) back through Android's instrumentation protocol. Add `--instrument` option to Microsoft.Android.Run so `dotnet run` can launch test projects via `am instrument -w` instead of `am start`. Fix an issue where NuGet packages like MSTest add assemblies as both properly-resolved publish assets (with DestinationSubPath metadata) and as None items (without DestinationSubPath) that flow into `_SourceItemsToCopyToPublishDirectory`. When NuGet conflict resolution arbitrarily picks the None-based copy, the assembly loses its DestinationSubPath and TargetPath metadata, causing it to either be missing from the APK or deployed to the wrong directory on device. The fix conditionally removes only items without DestinationSubPath, then re-adds any assemblies that were completely stripped with a cleared TargetPath so that downstream targets (ProcessAssemblies, FastDeploy) can set the correct per-architecture paths. Changes: - New `androidtest` template under src/Microsoft.Android.Templates/ - New GetAndroidInstrumentationName MSBuild task to resolve the instrumentation runner class from AndroidManifest.xml - Updated Microsoft.Android.Sdk.Application.targets to pass `--instrument` when EnableMSTestRunner is true - Added AdbHelper class to Microsoft.Android.Run for shared adb process management - Added `--instrument` CLI option to Microsoft.Android.Run/Program.cs - Fixed _ComputeFilesToPublishForRuntimeIdentifiers to preserve properly-resolved publish assets and recover missing assemblies - Added DotNetNewAndroidTest device integration test TODO: - In a future PR, add support for `dotnet test` end-to-end. --- src/Microsoft.Android.Run/AdbHelper.cs | 37 +++++ src/Microsoft.Android.Run/Program.cs | 142 ++++++++++++------ .../.template.config/template.json | 34 +++++ .../androidtest/AndroidManifest.xml | 6 + .../androidtest/AndroidTest1.csproj | 27 ++++ .../androidtest/Test1.cs | 22 +++ .../androidtest/TestInstrumentation.cs | 104 +++++++++++++ .../androidtest/global.json | 5 + .../Microsoft.Android.Sdk.Application.targets | 12 +- ...oft.Android.Sdk.AssemblyResolution.targets | 19 ++- .../Tasks/GetAndroidInstrumentationName.cs | 40 +++++ .../Tests/InstallAndRunTests.cs | 76 ++++++++++ 12 files changed, 474 insertions(+), 50 deletions(-) create mode 100644 src/Microsoft.Android.Run/AdbHelper.cs create mode 100644 src/Microsoft.Android.Templates/androidtest/.template.config/template.json create mode 100644 src/Microsoft.Android.Templates/androidtest/AndroidManifest.xml create mode 100644 src/Microsoft.Android.Templates/androidtest/AndroidTest1.csproj create mode 100644 src/Microsoft.Android.Templates/androidtest/Test1.cs create mode 100644 src/Microsoft.Android.Templates/androidtest/TestInstrumentation.cs create mode 100644 src/Microsoft.Android.Templates/androidtest/global.json create mode 100644 src/Xamarin.Android.Build.Tasks/Tasks/GetAndroidInstrumentationName.cs diff --git a/src/Microsoft.Android.Run/AdbHelper.cs b/src/Microsoft.Android.Run/AdbHelper.cs new file mode 100644 index 00000000000..f2606c525c3 --- /dev/null +++ b/src/Microsoft.Android.Run/AdbHelper.cs @@ -0,0 +1,37 @@ +using System.Diagnostics; + +static class AdbHelper +{ + public static ProcessStartInfo CreateStartInfo (string adbPath, string? adbTarget, string arguments) + { + var fullArguments = string.IsNullOrEmpty (adbTarget) ? arguments : $"{adbTarget} {arguments}"; + return new ProcessStartInfo { + FileName = adbPath, + Arguments = fullArguments, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + } + + public static (int ExitCode, string Output, string Error) Run (string adbPath, string? adbTarget, string arguments, bool verbose = false) + { + var psi = CreateStartInfo (adbPath, adbTarget, arguments); + + if (verbose) + Console.WriteLine ($"Running: adb {psi.Arguments}"); + + using var process = Process.Start (psi); + if (process == null) + return (-1, "", "Failed to start process"); + + // Read both streams asynchronously to avoid potential deadlock + var outputTask = process.StandardOutput.ReadToEndAsync (); + var errorTask = process.StandardError.ReadToEndAsync (); + + process.WaitForExit (); + + return (process.ExitCode, outputTask.Result, errorTask.Result); + } +} diff --git a/src/Microsoft.Android.Run/Program.cs b/src/Microsoft.Android.Run/Program.cs index 6eda1c6020b..a4b25647e9c 100644 --- a/src/Microsoft.Android.Run/Program.cs +++ b/src/Microsoft.Android.Run/Program.cs @@ -10,6 +10,7 @@ string? package = null; string? activity = null; string? deviceUserId = null; +string? instrumentation = null; bool verbose = false; int? logcatPid = null; Process? logcatProcess = null; @@ -47,11 +48,15 @@ int Run (string[] args) "The Android application {PACKAGE} name (e.g., com.example.myapp). Required.", v => package = v }, { "c|activity=", - "The {ACTIVITY} class name to launch. Required.", + "The {ACTIVITY} class name to launch. Required unless --instrument is used.", v => activity = v }, { "user=", "The Android device {USER_ID} to launch the activity under (e.g., 10 for a work profile).", v => deviceUserId = v }, + { "i|instrument=", + "The instrumentation {RUNNER} class name (e.g., com.example.myapp.TestInstrumentation). " + + "When specified, runs 'am instrument' instead of 'am start'.", + v => instrumentation = v }, { "v|verbose", "Enable verbose output for debugging.", v => verbose = v != null }, @@ -95,9 +100,9 @@ int Run (string[] args) options.WriteOptionDescriptions (Console.Out); Console.WriteLine (); Console.WriteLine ("Examples:"); - Console.WriteLine ($" {Name} -p com.example.myapp"); Console.WriteLine ($" {Name} -p com.example.myapp -c com.example.myapp.MainActivity"); - Console.WriteLine ($" {Name} --adb /path/to/adb -p com.example.myapp"); + Console.WriteLine ($" {Name} -p com.example.myapp -i com.example.myapp.TestInstrumentation"); + Console.WriteLine ($" {Name} --adb /path/to/adb -p com.example.myapp -c com.example.myapp.MainActivity"); Console.WriteLine (); Console.WriteLine ("Press Ctrl+C while running to stop the Android application and exit."); return 0; @@ -109,8 +114,16 @@ int Run (string[] args) return 1; } - if (string.IsNullOrEmpty (activity)) { - Console.Error.WriteLine ("Error: --activity is required."); + bool isInstrumentMode = !string.IsNullOrEmpty (instrumentation); + + if (!isInstrumentMode && string.IsNullOrEmpty (activity)) { + Console.Error.WriteLine ("Error: --activity or --instrument is required."); + Console.Error.WriteLine ($"Try '{Name} --help' for more information."); + return 1; + } + + if (isInstrumentMode && !string.IsNullOrEmpty (activity)) { + Console.Error.WriteLine ("Error: --activity and --instrument cannot be used together."); Console.Error.WriteLine ($"Try '{Name} --help' for more information."); return 1; } @@ -129,6 +142,8 @@ int Run (string[] args) return 1; } + Debug.Assert (adbPath != null, "adbPath should be non-null after validation"); + if (verbose) { Console.WriteLine ($"Using adb: {adbPath}"); if (!string.IsNullOrEmpty (adbTarget)) @@ -136,12 +151,17 @@ int Run (string[] args) Console.WriteLine ($"Package: {package}"); if (!string.IsNullOrEmpty (activity)) Console.WriteLine ($"Activity: {activity}"); + if (isInstrumentMode) + Console.WriteLine ($"Instrumentation runner: {instrumentation}"); } // Set up Ctrl+C handler Console.CancelKeyPress += OnCancelKeyPress; try { + if (isInstrumentMode) + return RunInstrumentation (); + return RunApp (); } finally { Console.CancelKeyPress -= OnCancelKeyPress; @@ -171,6 +191,71 @@ void OnCancelKeyPress (object? sender, ConsoleCancelEventArgs e) } } +int RunInstrumentation () +{ + // Build the am instrument command + var cmdArgs = $"shell am instrument -w {package}/{instrumentation}"; + + if (verbose) + Console.WriteLine ($"Running instrumentation: adb {cmdArgs}"); + + // Run instrumentation with streaming output + var psi = AdbHelper.CreateStartInfo (adbPath, adbTarget, cmdArgs); + using var instrumentProcess = new Process { StartInfo = psi }; + + var locker = new Lock (); + + instrumentProcess.OutputDataReceived += (s, e) => { + if (e.Data != null) + lock (locker) + Console.WriteLine (e.Data); + }; + + instrumentProcess.ErrorDataReceived += (s, e) => { + if (e.Data != null) + lock (locker) + Console.Error.WriteLine (e.Data); + }; + + instrumentProcess.Start (); + instrumentProcess.BeginOutputReadLine (); + instrumentProcess.BeginErrorReadLine (); + + // Also start logcat in the background for additional debug output + logcatPid = GetAppPid (); + if (logcatPid != null) + StartLogcat (); + + // Wait for instrumentation to complete or Ctrl+C + try { + while (!instrumentProcess.HasExited && !cts.Token.IsCancellationRequested) + Thread.Sleep (250); + + if (cts.Token.IsCancellationRequested) { + try { instrumentProcess.Kill (); } catch { } + return 1; + } + + instrumentProcess.WaitForExit (); + } finally { + // Clean up logcat + try { + if (logcatProcess != null && !logcatProcess.HasExited) { + logcatProcess.Kill (); + logcatProcess.WaitForExit (1000); + } + } catch { } + } + + // Check exit status + if (instrumentProcess.ExitCode != 0) { + Console.Error.WriteLine ($"Error: adb instrument exited with code {instrumentProcess.ExitCode}"); + return 1; + } + + return 0; +} + int RunApp () { // 1. Start the app @@ -200,7 +285,7 @@ bool StartApp () { var userArg = string.IsNullOrEmpty (deviceUserId) ? "" : $" --user {deviceUserId}"; var cmdArgs = $"shell am start -S -W{userArg} -n \"{package}/{activity}\""; - var (exitCode, output, error) = RunAdb (cmdArgs); + var (exitCode, output, error) = AdbHelper.Run (adbPath, adbTarget, cmdArgs, verbose); if (exitCode != 0) { Console.Error.WriteLine ($"Error: Failed to start app: {error}"); return false; @@ -215,7 +300,7 @@ bool StartApp () int? GetAppPid () { var cmdArgs = $"shell pidof {package}"; - var (exitCode, output, error) = RunAdb (cmdArgs); + var (exitCode, output, error) = AdbHelper.Run (adbPath, adbTarget, cmdArgs, verbose); if (exitCode != 0 || string.IsNullOrWhiteSpace (output)) return null; @@ -235,20 +320,12 @@ void StartLogcat () if (!string.IsNullOrEmpty (logcatArgs)) logcatArguments += $" {logcatArgs}"; - var fullArguments = string.IsNullOrEmpty (adbTarget) ? logcatArguments : $"{adbTarget} {logcatArguments}"; + var psi = AdbHelper.CreateStartInfo (adbPath, adbTarget, logcatArguments); if (verbose) - Console.WriteLine ($"Running: adb {fullArguments}"); + Console.WriteLine ($"Running: adb {psi.Arguments}"); var locker = new Lock(); - var psi = new ProcessStartInfo { - FileName = adbPath, - Arguments = fullArguments, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; logcatProcess = new Process { StartInfo = psi }; @@ -308,7 +385,7 @@ void StopApp () return; var userArg = string.IsNullOrEmpty (deviceUserId) ? "" : $" --user {deviceUserId}"; - RunAdb ($"shell am force-stop{userArg} {package}"); + AdbHelper.Run (adbPath, adbTarget, $"shell am force-stop{userArg} {package}", verbose); } string? FindAdbPath () @@ -332,35 +409,6 @@ void StopApp () return null; } -(int ExitCode, string Output, string Error) RunAdb (string arguments) -{ - var fullArguments = string.IsNullOrEmpty (adbTarget) ? arguments : $"{adbTarget} {arguments}"; - - if (verbose) - Console.WriteLine ($"Running: adb {fullArguments}"); - - var psi = new ProcessStartInfo { - FileName = adbPath, - Arguments = fullArguments, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - - using var process = Process.Start (psi); - if (process == null) - return (-1, "", "Failed to start process"); - - // Read both streams asynchronously to avoid potential deadlock - var outputTask = process.StandardOutput.ReadToEndAsync (); - var errorTask = process.StandardError.ReadToEndAsync (); - - process.WaitForExit (); - - return (process.ExitCode, outputTask.Result, errorTask.Result); -} - (string? Version, string? Commit) GetVersionInfo () { try { diff --git a/src/Microsoft.Android.Templates/androidtest/.template.config/template.json b/src/Microsoft.Android.Templates/androidtest/.template.config/template.json new file mode 100644 index 00000000000..413ec8d1d8a --- /dev/null +++ b/src/Microsoft.Android.Templates/androidtest/.template.config/template.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Microsoft", + "classifications": [ "Android", "Mobile", "Test" ], + "identity": "Microsoft.Android.AndroidTest", + "name": "Android Test Project", + "description": "A project for creating a .NET for Android test project using MSTest", + "shortName": "androidtest", + "tags": { + "language": "C#", + "type": "project" + }, + "sourceName": "AndroidTest1", + "preferNameDirectory": true, + "primaryOutputs": [ + { "path": "AndroidTest1.csproj" } + ], + "symbols": { + "packageName": { + "type": "parameter", + "description": "Overrides the package name in the AndroidManifest.xml", + "datatype": "string", + "replaces": "com.companyname.AndroidTest1" + }, + "supportedOSVersion": { + "type": "parameter", + "description": "Overrides $(SupportedOSPlatformVersion) in the project", + "datatype": "string", + "replaces": "SUPPORTED_OS_PLATFORM_VERSION", + "defaultValue": "24" + } + }, + "defaultName": "AndroidTest1" +} diff --git a/src/Microsoft.Android.Templates/androidtest/AndroidManifest.xml b/src/Microsoft.Android.Templates/androidtest/AndroidManifest.xml new file mode 100644 index 00000000000..c2166bb216e --- /dev/null +++ b/src/Microsoft.Android.Templates/androidtest/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Microsoft.Android.Templates/androidtest/AndroidTest1.csproj b/src/Microsoft.Android.Templates/androidtest/AndroidTest1.csproj new file mode 100644 index 00000000000..1e03e352219 --- /dev/null +++ b/src/Microsoft.Android.Templates/androidtest/AndroidTest1.csproj @@ -0,0 +1,27 @@ + + + net11.0-android + SUPPORTED_OS_PLATFORM_VERSION + AndroidTest1 + Exe + enable + enable + true + com.companyname.AndroidTest1 + 1 + 1.0 + + full + + + + + + + + + + diff --git a/src/Microsoft.Android.Templates/androidtest/Test1.cs b/src/Microsoft.Android.Templates/androidtest/Test1.cs new file mode 100644 index 00000000000..97ca260a405 --- /dev/null +++ b/src/Microsoft.Android.Templates/androidtest/Test1.cs @@ -0,0 +1,22 @@ +namespace AndroidTest1; + +[TestClass] +public sealed class Test1 +{ + [TestMethod] + public void TestMethod1() + { + } + + [TestMethod] + public void TestMethod2() + { + Assert.Fail("This test is expected to fail"); + } + + [TestMethod] + public void TestMethod3() + { + Assert.Inconclusive("This test is expected to be skipped"); + } +} diff --git a/src/Microsoft.Android.Templates/androidtest/TestInstrumentation.cs b/src/Microsoft.Android.Templates/androidtest/TestInstrumentation.cs new file mode 100644 index 00000000000..a50eb7424f7 --- /dev/null +++ b/src/Microsoft.Android.Templates/androidtest/TestInstrumentation.cs @@ -0,0 +1,104 @@ +using Android.Runtime; +using Microsoft.Testing.Extensions; +using Microsoft.Testing.Platform.Builder; +using Microsoft.Testing.Platform.Extensions; +using Microsoft.Testing.Platform.Extensions.Messages; + +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] + +namespace AndroidTest1; + +[Instrumentation(Name = "com.companyname.AndroidTest1.TestInstrumentation")] +public class TestInstrumentation : Instrumentation +{ + protected TestInstrumentation(IntPtr handle, JniHandleOwnership ownership) + : base(handle, ownership) { } + + public override void OnCreate(Bundle? arguments) + { + base.OnCreate(arguments); + Start(); + } + + public override void OnStart() + { + base.OnStart(); + + Task.Run(async () => + { + var consumer = new ResultConsumer(this); + var bundle = new Bundle(); + try + { + var writeablePath = Application.Context.GetExternalFilesDir(null)?.AbsolutePath ?? Path.GetTempPath(); + var resultsPath = Path.Combine(writeablePath, "TestResults"); + var builder = await TestApplication.CreateBuilderAsync([ + "--results-directory", resultsPath, + "--report-trx" + ]); + builder.AddMSTest(() => [GetType().Assembly]); + builder.AddTrxReportProvider(); + builder.TestHost.AddDataConsumer(_ => consumer); + + using ITestApplication app = await builder.BuildAsync(); + await app.RunAsync(); + + bundle.PutInt("passed", consumer.Passed); + bundle.PutInt("failed", consumer.Failed); + bundle.PutInt("skipped", consumer.Skipped); + bundle.PutString("resultsPath", consumer.TrxReportPath); + Finish(Result.Ok, bundle); + } + catch (Exception ex) + { + bundle.PutString("error", ex.ToString()); + Finish(Result.Canceled, bundle); + } + }); + } + + class ResultConsumer(Instrumentation instrumentation) : IDataConsumer + { + public int Passed, Failed, Skipped; + public string? TrxReportPath; + + public string Uid => nameof(ResultConsumer); + public string DisplayName => nameof(ResultConsumer); + public string Description => ""; + public string Version => "1.0"; + public Task IsEnabledAsync() => Task.FromResult(true); + + public Type[] DataTypesConsumed => [typeof(TestNodeUpdateMessage), typeof(SessionFileArtifact)]; + + public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) + { + if (value is SessionFileArtifact artifact) + { + TrxReportPath = artifact.FileInfo.FullName; + } + else if (value is TestNodeUpdateMessage { TestNode: var node }) + { + var state = node.Properties.SingleOrDefault(); + string? outcome = state switch + { + PassedTestNodeStateProperty => "passed", + FailedTestNodeStateProperty or ErrorTestNodeStateProperty + or TimeoutTestNodeStateProperty or CancelledTestNodeStateProperty => "failed", + SkippedTestNodeStateProperty => "skipped", + _ => null + }; + if (outcome is null) + return Task.CompletedTask; + + _ = outcome switch { "passed" => Passed++, "failed" => Failed++, _ => Skipped++ }; + + var id = node.Properties.SingleOrDefault(); + var b = new Bundle(); + b.PutString("test", id is not null ? $"{id.Namespace}.{id.TypeName}.{id.MethodName}" : node.DisplayName); + b.PutString("outcome", outcome); + instrumentation.SendStatus(0, b); + } + return Task.CompletedTask; + } + } +} diff --git a/src/Microsoft.Android.Templates/androidtest/global.json b/src/Microsoft.Android.Templates/androidtest/global.json new file mode 100644 index 00000000000..3140116df39 --- /dev/null +++ b/src/Microsoft.Android.Templates/androidtest/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets index a4c9d65f801..80029893456 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets @@ -9,6 +9,7 @@ This file contains targets specific for Android application projects. + @@ -90,10 +91,15 @@ This file contains targets specific for Android application projects. BeforeTargets="ComputeRunArguments" DependsOnTargets="$(_AndroidComputeRunArgumentsDependsOn)"> + + + <_AdbToolPath>$(AdbToolExe) <_AdbToolPath Condition=" '$(_AdbToolPath)' == '' and $([MSBuild]::IsOSPlatform('windows')) ">adb.exe @@ -107,8 +113,10 @@ This file contains targets specific for Android application projects. <_AndroidRunLogcatArgs Condition=" '$(_AndroidRunLogcatArgs)' == '' ">monodroid-assembly:S <_AndroidRunAdbTargetArg Condition=" '$(AdbTarget)' != '' ">--adb-target "$(AdbTarget)" <_AndroidRunUserArg Condition=" '$(AndroidDeviceUserId)' != '' ">--user "$(AndroidDeviceUserId)" + <_AndroidRunInstrumentArg Condition=" '$(AndroidInstrumentation)' != '' ">--instrument "$(AndroidInstrumentation)" + <_AndroidRunActivityArg Condition=" '$(AndroidInstrumentation)' == '' ">--activity "$(AndroidLaunchActivity)" dotnet - exec "$(_AndroidRunPath)" --adb "$(_AdbToolPath)" $(_AndroidRunAdbTargetArg) --package "$(_AndroidPackage)" --activity "$(AndroidLaunchActivity)" --logcat-args "$(_AndroidRunLogcatArgs)" $(_AndroidRunUserArg) $(_AndroidRunExtraArgs) + exec "$(_AndroidRunPath)" --adb "$(_AdbToolPath)" $(_AndroidRunAdbTargetArg) --package "$(_AndroidPackage)" $(_AndroidRunActivityArg) $(_AndroidRunInstrumentArg) --logcat-args "$(_AndroidRunLogcatArgs)" $(_AndroidRunUserArg) $(_AndroidRunExtraArgs) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets index 6b627177c5e..04c6c74543a 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets @@ -40,7 +40,24 @@ _ResolveAssemblies MSBuild target. DependsOnTargets="BuildOnlySettings;_FixupIntermediateAssembly;ResolveReferences;ComputeFilesToPublish;$(_RunAotMaybe)" Returns="@(ResolvedFileToPublish)"> - + + + <_MissingAssemblies Include="@(_SourceItemsToCopyToPublishDirectory)" + Condition=" '%(Extension)' == '.dll' or '%(Extension)' == '.pdb' " /> + <_MissingAssemblies Remove="@(ResolvedFileToPublish)" /> + + <_MissingAssemblies Remove="@(_MissingAssemblies)" /> + /// Finds the first <instrumentation> element in an AndroidManifest.xml + /// and returns its android:name attribute value. + /// + public class GetAndroidInstrumentationName : AndroidTask + { + public override string TaskPrefix => "GAIN"; + + [Required] + public string ManifestFile { get; set; } = ""; + + [Output] + public string? InstrumentationName { get; set; } + + public override bool RunTask () + { + using var reader = XmlReader.Create (ManifestFile); + while (reader.Read ()) { + if (reader.NodeType == XmlNodeType.Element && reader.LocalName == "instrumentation") { + InstrumentationName = reader.GetAttribute ("name", ManifestDocument.AndroidXmlNamespace.ToString ()); + if (InstrumentationName.IsNullOrEmpty ()) { + Log.LogError ("The element is missing the android:name attribute."); + return false; + } + return !Log.HasLoggedErrors; + } + } + + Log.LogError ("No element found in AndroidManifest.xml."); + return false; + } + } +} diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index dc66f41e443..43d6fbbfbd8 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -1727,5 +1727,81 @@ public void StartAndroidActivityRespectsAndroidDeviceUserId () StringAssertEx.ContainsRegex (@"am start.*--user 0", builder.LastBuildOutput, "The 'am start' command should contain '--user 0' when AndroidDeviceUserId is set."); } + + [Test] + public void DotNetNewAndroidTest () + { + var templateName = TestName; + var projectDirectory = Path.Combine (Root, "temp", templateName); + if (Directory.Exists (projectDirectory)) + Directory.Delete (projectDirectory, true); + + TestOutputDirectories [TestContext.CurrentContext.Test.ID] = projectDirectory; + var dotnet = new DotNetCLI (Path.Combine (projectDirectory, $"{templateName}.csproj")); + Assert.IsTrue (dotnet.New ("androidtest"), "`dotnet new androidtest` should succeed"); + + // Build and assert 0 warnings + Assert.IsTrue (dotnet.Build (), "`dotnet build` should succeed"); + dotnet.AssertHasNoWarnings (); + + // Run instrumentation via `dotnet run` and capture output + using var process = dotnet.StartRun (waitForExit: true); + + var locker = new Lock (); + var output = new StringBuilder (); + var processExited = new ManualResetEventSlim (false); + + process.OutputDataReceived += (sender, e) => { + if (e.Data != null) + lock (locker) + output.AppendLine (e.Data); + }; + process.ErrorDataReceived += (sender, e) => { + if (e.Data != null) + lock (locker) + output.AppendLine ($"STDERR: {e.Data}"); + }; + + process.BeginOutputReadLine (); + process.BeginErrorReadLine (); + + // Wait for the process to complete (instrumentation should finish on its own) + bool completed = process.WaitForExit ((int) TimeSpan.FromMinutes (5).TotalMilliseconds); + if (!completed) { + try { process.Kill (entireProcessTree: true); } catch { } + } + + // Write the output to a log file for debugging + string logPath = Path.Combine (projectDirectory, "dotnet-run-output.log"); + File.WriteAllText (logPath, output.ToString ()); + TestContext.AddTestAttachment (logPath); + + Assert.IsTrue (completed, $"`dotnet run` did not complete in time. See {logPath} for details."); + + // Parse INSTRUMENTATION_RESULT lines from output + var outputText = output.ToString (); + int passed = ParseInstrumentationResult (outputText, "passed"); + int failed = ParseInstrumentationResult (outputText, "failed"); + int skipped = ParseInstrumentationResult (outputText, "skipped"); + + Assert.AreEqual (1, passed, $"Expected 1 passed test, got {passed}. See {logPath} for details."); + Assert.AreEqual (1, failed, $"Expected 1 failed test, got {failed}. See {logPath} for details."); + Assert.AreEqual (1, skipped, $"Expected 1 skipped test, got {skipped}. See {logPath} for details."); + } + + static int ParseInstrumentationResult (string output, string key) + { + // Parses lines like: INSTRUMENTATION_RESULT: passed=1 + var prefix = $"INSTRUMENTATION_RESULT: {key}="; + foreach (var rawLine in output.Split ('\n')) { + var line = rawLine.Trim (); + if (line.StartsWith (prefix, StringComparison.Ordinal)) { + var valueStr = line.Substring (prefix.Length).Trim (); + if (int.TryParse (valueStr, out int value)) + return value; + } + } + return -1; + } } } From f121c7cf55cae16d8414f39feae381116c378be0 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 6 Mar 2026 16:28:26 -0600 Subject: [PATCH 2/5] `--user` --- src/Microsoft.Android.Run/Program.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.Android.Run/Program.cs b/src/Microsoft.Android.Run/Program.cs index a4b25647e9c..e33dbe8e610 100644 --- a/src/Microsoft.Android.Run/Program.cs +++ b/src/Microsoft.Android.Run/Program.cs @@ -194,7 +194,8 @@ void OnCancelKeyPress (object? sender, ConsoleCancelEventArgs e) int RunInstrumentation () { // Build the am instrument command - var cmdArgs = $"shell am instrument -w {package}/{instrumentation}"; + var userArg = string.IsNullOrEmpty (deviceUserId) ? "" : $" --user {deviceUserId}"; + var cmdArgs = $"shell am instrument -w{userArg} {package}/{instrumentation}"; if (verbose) Console.WriteLine ($"Running instrumentation: adb {cmdArgs}"); From c7eb96bcebda025e45e5806fcc0ffc14e81e49a3 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 6 Mar 2026 16:36:17 -0600 Subject: [PATCH 3/5] Use `ProcessUtils` and async --- src/Microsoft.Android.Run/AdbHelper.cs | 17 ++++------ src/Microsoft.Android.Run/Program.cs | 44 +++++++++++++------------- 2 files changed, 28 insertions(+), 33 deletions(-) diff --git a/src/Microsoft.Android.Run/AdbHelper.cs b/src/Microsoft.Android.Run/AdbHelper.cs index f2606c525c3..2bfe4e9644b 100644 --- a/src/Microsoft.Android.Run/AdbHelper.cs +++ b/src/Microsoft.Android.Run/AdbHelper.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using Xamarin.Android.Tools; static class AdbHelper { @@ -15,23 +16,17 @@ public static ProcessStartInfo CreateStartInfo (string adbPath, string? adbTarge }; } - public static (int ExitCode, string Output, string Error) Run (string adbPath, string? adbTarget, string arguments, bool verbose = false) + public static async Task<(int ExitCode, string Output, string Error)> RunAsync (string adbPath, string? adbTarget, string arguments, bool verbose = false) { var psi = CreateStartInfo (adbPath, adbTarget, arguments); if (verbose) Console.WriteLine ($"Running: adb {psi.Arguments}"); - using var process = Process.Start (psi); - if (process == null) - return (-1, "", "Failed to start process"); + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, CancellationToken.None); - // Read both streams asynchronously to avoid potential deadlock - var outputTask = process.StandardOutput.ReadToEndAsync (); - var errorTask = process.StandardError.ReadToEndAsync (); - - process.WaitForExit (); - - return (process.ExitCode, outputTask.Result, errorTask.Result); + return (exitCode, stdout.ToString (), stderr.ToString ()); } } diff --git a/src/Microsoft.Android.Run/Program.cs b/src/Microsoft.Android.Run/Program.cs index e33dbe8e610..718837ca3f9 100644 --- a/src/Microsoft.Android.Run/Program.cs +++ b/src/Microsoft.Android.Run/Program.cs @@ -18,7 +18,7 @@ string? logcatArgs = null; try { - return Run (args); + return await RunAsync (args); } catch (Exception ex) { Console.Error.WriteLine ($"Error: {ex.Message}"); if (verbose) @@ -26,7 +26,7 @@ return 1; } -int Run (string[] args) +async Task RunAsync (string[] args) { bool showHelp = false; bool showVersion = false; @@ -160,9 +160,9 @@ int Run (string[] args) try { if (isInstrumentMode) - return RunInstrumentation (); + return await RunInstrumentationAsync (); - return RunApp (); + return await RunAppAsync (); } finally { Console.CancelKeyPress -= OnCancelKeyPress; cts.Dispose (); @@ -177,8 +177,8 @@ void OnCancelKeyPress (object? sender, ConsoleCancelEventArgs e) cts.Cancel (); - // Force-stop the app - StopApp (); + // Force-stop the app (fire-and-forget in cancel handler) + _ = StopAppAsync (); // Kill logcat process if running try { @@ -191,7 +191,7 @@ void OnCancelKeyPress (object? sender, ConsoleCancelEventArgs e) } } -int RunInstrumentation () +async Task RunInstrumentationAsync () { // Build the am instrument command var userArg = string.IsNullOrEmpty (deviceUserId) ? "" : $" --user {deviceUserId}"; @@ -223,14 +223,14 @@ int RunInstrumentation () instrumentProcess.BeginErrorReadLine (); // Also start logcat in the background for additional debug output - logcatPid = GetAppPid (); + logcatPid = await GetAppPidAsync (); if (logcatPid != null) StartLogcat (); // Wait for instrumentation to complete or Ctrl+C try { while (!instrumentProcess.HasExited && !cts.Token.IsCancellationRequested) - Thread.Sleep (250); + await Task.Delay (250); if (cts.Token.IsCancellationRequested) { try { instrumentProcess.Kill (); } catch { } @@ -257,14 +257,14 @@ int RunInstrumentation () return 0; } -int RunApp () +async Task RunAppAsync () { // 1. Start the app - if (!StartApp ()) + if (!await StartAppAsync ()) return 1; // 2. Get the PID - logcatPid = GetAppPid (); + logcatPid = await GetAppPidAsync (); if (logcatPid == null) { Console.Error.WriteLine ("Error: App started but could not retrieve PID. The app may have crashed."); return 1; @@ -277,16 +277,16 @@ int RunApp () StartLogcat (); // 4. Wait for app to exit or Ctrl+C - WaitForAppExit (); + await WaitForAppExitAsync (); return 0; } -bool StartApp () +async Task StartAppAsync () { var userArg = string.IsNullOrEmpty (deviceUserId) ? "" : $" --user {deviceUserId}"; var cmdArgs = $"shell am start -S -W{userArg} -n \"{package}/{activity}\""; - var (exitCode, output, error) = AdbHelper.Run (adbPath, adbTarget, cmdArgs, verbose); + var (exitCode, output, error) = await AdbHelper.RunAsync (adbPath, adbTarget, cmdArgs, verbose); if (exitCode != 0) { Console.Error.WriteLine ($"Error: Failed to start app: {error}"); return false; @@ -298,10 +298,10 @@ bool StartApp () return true; } -int? GetAppPid () +async Task GetAppPidAsync () { var cmdArgs = $"shell pidof {package}"; - var (exitCode, output, error) = AdbHelper.Run (adbPath, adbTarget, cmdArgs, verbose); + var (exitCode, output, error) = await AdbHelper.RunAsync (adbPath, adbTarget, cmdArgs, verbose); if (exitCode != 0 || string.IsNullOrWhiteSpace (output)) return null; @@ -347,11 +347,11 @@ void StartLogcat () logcatProcess.BeginErrorReadLine (); } -void WaitForAppExit () +async Task WaitForAppExitAsync () { while (!cts!.Token.IsCancellationRequested) { // Check if app is still running - var pid = GetAppPid (); + var pid = await GetAppPidAsync (); if (pid == null || pid != logcatPid) { if (verbose) Console.WriteLine ("App has exited."); @@ -365,7 +365,7 @@ void WaitForAppExit () break; } - Thread.Sleep (1000); + await Task.Delay (1000); } // Clean up logcat process @@ -380,13 +380,13 @@ void WaitForAppExit () } } -void StopApp () +async Task StopAppAsync () { if (string.IsNullOrEmpty (package) || string.IsNullOrEmpty (adbPath)) return; var userArg = string.IsNullOrEmpty (deviceUserId) ? "" : $" --user {deviceUserId}"; - AdbHelper.Run (adbPath, adbTarget, $"shell am force-stop{userArg} {package}", verbose); + await AdbHelper.RunAsync (adbPath, adbTarget, $"shell am force-stop{userArg} {package}", verbose); } string? FindAdbPath () From 1afca66104d01b2dade4c93cb38380dd29196d21 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 6 Mar 2026 16:38:00 -0600 Subject: [PATCH 4/5] Pass in CancellationToken --- src/Microsoft.Android.Run/AdbHelper.cs | 4 ++-- src/Microsoft.Android.Run/Program.cs | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Microsoft.Android.Run/AdbHelper.cs b/src/Microsoft.Android.Run/AdbHelper.cs index 2bfe4e9644b..b34656a9563 100644 --- a/src/Microsoft.Android.Run/AdbHelper.cs +++ b/src/Microsoft.Android.Run/AdbHelper.cs @@ -16,7 +16,7 @@ public static ProcessStartInfo CreateStartInfo (string adbPath, string? adbTarge }; } - public static async Task<(int ExitCode, string Output, string Error)> RunAsync (string adbPath, string? adbTarget, string arguments, bool verbose = false) + public static async Task<(int ExitCode, string Output, string Error)> RunAsync (string adbPath, string? adbTarget, string arguments, CancellationToken cancellationToken, bool verbose = false) { var psi = CreateStartInfo (adbPath, adbTarget, arguments); @@ -25,7 +25,7 @@ public static ProcessStartInfo CreateStartInfo (string adbPath, string? adbTarge using var stdout = new StringWriter (); using var stderr = new StringWriter (); - var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, CancellationToken.None); + var exitCode = await ProcessUtils.StartProcess (psi, stdout, stderr, cancellationToken); return (exitCode, stdout.ToString (), stderr.ToString ()); } diff --git a/src/Microsoft.Android.Run/Program.cs b/src/Microsoft.Android.Run/Program.cs index 718837ca3f9..55b0b679a4e 100644 --- a/src/Microsoft.Android.Run/Program.cs +++ b/src/Microsoft.Android.Run/Program.cs @@ -230,7 +230,7 @@ async Task RunInstrumentationAsync () // Wait for instrumentation to complete or Ctrl+C try { while (!instrumentProcess.HasExited && !cts.Token.IsCancellationRequested) - await Task.Delay (250); + await Task.Delay (250, cts.Token).ConfigureAwait (ConfigureAwaitOptions.SuppressThrowing); if (cts.Token.IsCancellationRequested) { try { instrumentProcess.Kill (); } catch { } @@ -286,7 +286,7 @@ async Task StartAppAsync () { var userArg = string.IsNullOrEmpty (deviceUserId) ? "" : $" --user {deviceUserId}"; var cmdArgs = $"shell am start -S -W{userArg} -n \"{package}/{activity}\""; - var (exitCode, output, error) = await AdbHelper.RunAsync (adbPath, adbTarget, cmdArgs, verbose); + var (exitCode, output, error) = await AdbHelper.RunAsync (adbPath, adbTarget, cmdArgs, cts.Token, verbose); if (exitCode != 0) { Console.Error.WriteLine ($"Error: Failed to start app: {error}"); return false; @@ -301,7 +301,7 @@ async Task StartAppAsync () async Task GetAppPidAsync () { var cmdArgs = $"shell pidof {package}"; - var (exitCode, output, error) = await AdbHelper.RunAsync (adbPath, adbTarget, cmdArgs, verbose); + var (exitCode, output, error) = await AdbHelper.RunAsync (adbPath, adbTarget, cmdArgs, cts.Token, verbose); if (exitCode != 0 || string.IsNullOrWhiteSpace (output)) return null; @@ -365,7 +365,7 @@ async Task WaitForAppExitAsync () break; } - await Task.Delay (1000); + await Task.Delay (1000, cts.Token).ConfigureAwait (ConfigureAwaitOptions.SuppressThrowing); } // Clean up logcat process @@ -386,7 +386,7 @@ async Task StopAppAsync () return; var userArg = string.IsNullOrEmpty (deviceUserId) ? "" : $" --user {deviceUserId}"; - await AdbHelper.RunAsync (adbPath, adbTarget, $"shell am force-stop{userArg} {package}", verbose); + await AdbHelper.RunAsync (adbPath, adbTarget, $"shell am force-stop{userArg} {package}", CancellationToken.None, verbose); } string? FindAdbPath () From 2a62e8cf36aa0d73e82bb9de4dd9134d074945fb Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Mon, 9 Mar 2026 15:00:15 -0500 Subject: [PATCH 5/5] [xabt] Handle native PE files in HasMonoAndroidReference Check pe.HasMetadata before calling GetMetadataReader() to avoid InvalidOperationException when processing native DLLs (e.g. from BenchmarkDotNet/TraceEvent NuGet packages) that don't have .NET metadata. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Xamarin.Android.Build.Tasks/Utilities/MonoAndroidHelper.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/MonoAndroidHelper.cs b/src/Xamarin.Android.Build.Tasks/Utilities/MonoAndroidHelper.cs index 4163bcb6276..028cf0d1f2f 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/MonoAndroidHelper.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/MonoAndroidHelper.cs @@ -353,6 +353,9 @@ public static bool HasMonoAndroidReference (ITaskItem assembly) return true; using var pe = new PEReader (File.OpenRead (assembly.ItemSpec)); + if (!pe.HasMetadata) { + return false; // this is a native Windows .dll, not a .NET assembly + } var reader = pe.GetMetadataReader (); return HasMonoAndroidReference (reader); }