diff --git a/Documentation/docs-mobile/TOC.yml b/Documentation/docs-mobile/TOC.yml
index 00676a2343f..52a705a10c7 100644
--- a/Documentation/docs-mobile/TOC.yml
+++ b/Documentation/docs-mobile/TOC.yml
@@ -266,6 +266,12 @@
href: messages/xa1037.md
- name: XA1038
href: messages/xa1038.md
+ - name: XA1042
+ href: messages/xa1042.md
+ - name: XA1043
+ href: messages/xa1043.md
+ - name: XA1048
+ href: messages/xa1048.md
- name: "XA2xxx: Linker"
items:
- name: "XA2xxx: Linker"
diff --git a/Documentation/docs-mobile/building-apps/build-properties.md b/Documentation/docs-mobile/building-apps/build-properties.md
index 013745c6ecc..275f184788a 100644
--- a/Documentation/docs-mobile/building-apps/build-properties.md
+++ b/Documentation/docs-mobile/building-apps/build-properties.md
@@ -688,9 +688,39 @@ A string property that specifies the Android
[instrumentation](https://developer.android.com/reference/android/app/Instrumentation)
runner class name to use when launching the application via `dotnet run`.
-When [`$(EnableMSTestRunner)`](#enablemstestrunner) is `true` and this property
-is not set, the instrumentation runner class name is automatically resolved from
-the generated `AndroidManifest.xml` in the intermediate output.
+When this property is not set, `dotnet run` resolves what to launch from the
+generated `AndroidManifest.xml` in the intermediate output:
+
+* If [`$(AndroidUseInstrumentation)`](#androiduseinstrumentation) is `true`, the
+ first `` element is used.
+* Otherwise the launchable `` is preferred, and the first
+ `` element is used when the app declares no launchable
+ activity. This makes it possible to `dotnet run` an app whose only entry point
+ is an `Android.App.Instrumentation` subclass, such as a
+ [BenchmarkDotNet](https://github.com/dotnet/BenchmarkDotNet) host.
+
+If the app declares neither, the build fails with
+[XA1043](../messages/xa1043.md). If `$(AndroidUseInstrumentation)` is `true` but
+the app declares no ``, the build fails with
+[XA1048](../messages/xa1048.md).
+
+When an instrumentation is launched, `dotnet run` runs
+`adb shell am instrument -w -r` and exits with a non-zero exit code if the
+instrumentation crashes or calls `Instrumentation.Finish()` with
+`Result.Canceled`. Setting [`$(WaitForExit)`](#waitforexit) to `false` drops the
+`-w`, so `dotnet run` returns as soon as the instrumentation is started and no
+results are reported.
+
+Any arguments after `--` are forwarded to the instrumentation as `am instrument`
+extras. Arguments of the form `KEY=VALUE` become `-e KEY VALUE`, and all
+remaining arguments are joined into a single `-e args "..."` extra:
+
+```dotnetcli
+dotnet run -- --filter *MyBenchmark*
+```
+
+is delivered to `Instrumentation.OnCreate(Bundle?)` as
+`arguments.GetString("args")`.
Introduced in .NET 11.
@@ -1253,6 +1283,24 @@ get a `XA1034` build error.
Added in .NET 8.
+## AndroidUseInstrumentation
+
+A boolean property that indicates the application is launched through its
+`` element rather than an ``, such as a test or
+[BenchmarkDotNet](https://github.com/dotnet/BenchmarkDotNet) host.
+
+When `true`, `dotnet run` resolves
+[`$(AndroidInstrumentation)`](#androidinstrumentation) from the generated
+`AndroidManifest.xml` and launches it with `adb shell am instrument`, even if
+the app also declares a launchable activity.
+
+The default value is `true` when
+[`$(EnableMSTestRunner)`](#enablemstestrunner) is `true`, and `false` otherwise.
+Note that an app with no launchable `` launches through its
+`` regardless of this property.
+
+Introduced in .NET 11.
+
## AndroidUseInterpreter
A boolean property that causes the `.apk` to contain the mono
@@ -1797,7 +1845,22 @@ When `$(WaitForExit)` not `false` (the default), `dotnet run` will:
* Force-stop the application when Ctrl+C is pressed
When `$(WaitForExit)` is `false`, `dotnet run` will simply launch the
-application using `adb shell am start` and return immediately without
-waiting for the application to exit or streaming any output.
+application and return immediately without waiting for the application to exit
+or streaming any output.
+
+This property also controls whether `adb shell am instrument` is passed `-w`
+when the application is launched through its
+[`$(AndroidInstrumentation)`](#androidinstrumentation):
+
+| `$(WaitForExit)` | Launching an `` | Launching an `` |
+| ---------------- | ---------------------------- | ----------------------------------- |
+| `true` (default) | `adb shell am start -S -W` | `adb shell am instrument -w -r` |
+| `false` | `adb shell am start -S` | `adb shell am instrument -r` |
+
+Because `adb shell am instrument` only reports results once the instrumentation
+completes, `$(WaitForExit)` must not be `false` if you need the instrumentation's
+output or a meaningful exit code. This matters for scenarios such as running
+tests or a [BenchmarkDotNet](https://github.com/dotnet/BenchmarkDotNet) host,
+which is why `dotnet test` always uses the default.
Introduced in .NET 11.
diff --git a/Documentation/docs-mobile/messages/index.md b/Documentation/docs-mobile/messages/index.md
index 3242e1850e5..da9d83c8d20 100644
--- a/Documentation/docs-mobile/messages/index.md
+++ b/Documentation/docs-mobile/messages/index.md
@@ -152,10 +152,13 @@ Either change the value in the AndroidManifest.xml to match the $(SupportedOSPla
+ [XA1039](xa1039.md): The Android Support libraries are not supported in .NET 9 and later, please migrate to AndroidX. See https://aka.ms/xamarin/androidx for more details.
+ [XA1040](xa1040.md): The NativeAOT runtime on Android is an experimental feature and not yet suitable for production use. File issues at: https://github.com/dotnet/android/issues
+ [XA1041](xa1041.md): The MSBuild property 'MonoAndroidAssetPrefix' has an invalid value of 'c:\Foo\Assets'. The value is expected to be a directory path representing the relative location of your Assets or Resources
++ [XA1042](xa1042.md): The <instrumentation> element in '{0}' is missing the android:name attribute.
++ [XA1043](xa1043.md): Could not determine what to launch: '{0}' does not contain a launchable <activity> or an <instrumentation> element.
+ [XA1044](xa1044.md): The MSBuild property '{0}' is not compatible with the {1} runtime. The build cannot continue while this property is enabled.
+ [XA1045](xa1045.md): Input file `{0}` does not start with ``.
+ [XA1046](xa1046.md): Attribute '{0}' in element '{1}' has value '{2}' that cannot be parsed as boolean; {3} line {4}.
+ [XA1047](xa1047.md): Required attribute '{0}' missing from element '{1}'; {2} line {3}.
++ [XA1048](xa1048.md): '{0}' does not contain an <instrumentation> element.
## XA2xxx: Linker
diff --git a/Documentation/docs-mobile/messages/xa1042.md b/Documentation/docs-mobile/messages/xa1042.md
new file mode 100644
index 00000000000..2e4c4656ee9
--- /dev/null
+++ b/Documentation/docs-mobile/messages/xa1042.md
@@ -0,0 +1,45 @@
+---
+title: .NET for Android error XA1042
+description: XA1042 error code
+ms.date: 07/29/2026
+f1_keywords:
+ - "XA1042"
+---
+
+# .NET for Android error XA1042
+
+## Example messages
+
+```dotnetcli
+error XA1042: The element in 'obj/Debug/net11.0-android/android/AndroidManifest.xml' is missing the android:name attribute.
+```
+
+## Issue
+
+`dotnet run` launches an app without a launchable `` through its
+`` element, but the `` element found in
+`AndroidManifest.xml` has no `android:name` attribute, so there is no class to
+launch.
+
+## Solution
+
+Give the `` element a name. If it is declared in your hand
+written `AndroidManifest.xml`, add the attribute:
+
+```xml
+
+```
+
+If the element is generated from an `Android.App.Instrumentation` subclass, set
+the `Name` property on the `[Instrumentation]` attribute:
+
+```csharp
+[Instrumentation (Name = "com.companyname.myapp.MyInstrumentation")]
+public class MyInstrumentation : Instrumentation
+{
+}
+```
+
+Alternatively, set the
+[`$(AndroidInstrumentation)`](../building-apps/build-properties.md#androidinstrumentation)
+MSBuild property to the class name to launch.
diff --git a/Documentation/docs-mobile/messages/xa1043.md b/Documentation/docs-mobile/messages/xa1043.md
new file mode 100644
index 00000000000..74983fe04eb
--- /dev/null
+++ b/Documentation/docs-mobile/messages/xa1043.md
@@ -0,0 +1,50 @@
+---
+title: .NET for Android error XA1043
+description: XA1043 error code
+ms.date: 07/29/2026
+f1_keywords:
+ - "XA1043"
+---
+
+# .NET for Android error XA1043
+
+## Example messages
+
+```dotnetcli
+error XA1043: Could not determine what to launch: 'obj/Debug/net11.0-android/android/AndroidManifest.xml' does not contain a launchable or an element. Set the $(AndroidLaunchActivity) or $(AndroidInstrumentation) MSBuild property to specify one.
+```
+
+## Issue
+
+`dotnet run` needs something to launch on the device. It looks for a launchable
+`` in `AndroidManifest.xml`, and falls back to an ``
+element for apps that have no user interface, such as a benchmark host. Neither
+was found.
+
+## Solution
+
+Declare an activity with a `MAIN`/`LAUNCHER` intent filter:
+
+```csharp
+[Activity (MainLauncher = true)]
+public class MainActivity : Activity
+{
+}
+```
+
+Or, for an app that has no activity, declare an instrumentation:
+
+```csharp
+[Instrumentation (Name = "com.companyname.myapp.MyInstrumentation")]
+public class MyInstrumentation : Instrumentation
+{
+ protected MyInstrumentation (IntPtr handle, JniHandleOwnership ownership)
+ : base (handle, ownership) { }
+}
+```
+
+You can also name the class to launch explicitly with the
+[`$(AndroidLaunchActivity)`](../building-apps/build-properties.md#androidlaunchactivity)
+or
+[`$(AndroidInstrumentation)`](../building-apps/build-properties.md#androidinstrumentation)
+MSBuild properties.
diff --git a/Documentation/docs-mobile/messages/xa1048.md b/Documentation/docs-mobile/messages/xa1048.md
new file mode 100644
index 00000000000..b1e5efb9f8d
--- /dev/null
+++ b/Documentation/docs-mobile/messages/xa1048.md
@@ -0,0 +1,46 @@
+---
+title: .NET for Android error XA1048
+description: XA1048 error code
+ms.date: 07/29/2026
+f1_keywords:
+ - "XA1048"
+---
+
+# .NET for Android error XA1048
+
+## Example messages
+
+```dotnetcli
+error XA1048: 'obj/Debug/net11.0-android/android/AndroidManifest.xml' does not contain an element. Add an Android.App.Instrumentation subclass to the application, or set the $(AndroidInstrumentation) MSBuild property to the name of one.
+```
+
+## Issue
+
+The application opted into launching through an
+[`Android.App.Instrumentation`](https://developer.android.com/reference/android/app/Instrumentation),
+either by setting
+[`$(AndroidUseInstrumentation)`](../building-apps/build-properties.md#androiduseinstrumentation)
+or
+[`$(EnableMSTestRunner)`](../building-apps/build-properties.md#enablemstestrunner)
+to `true`, but no `` element was found in `AndroidManifest.xml`.
+
+This differs from [XA1043](xa1043.md), which is reported when the application has
+neither a launchable `` nor an ``.
+
+## Solution
+
+Declare an instrumentation in the application:
+
+```csharp
+[Instrumentation (Name = "com.companyname.myapp.MyInstrumentation")]
+public class MyInstrumentation : Instrumentation
+{
+ protected MyInstrumentation (IntPtr handle, JniHandleOwnership ownership)
+ : base (handle, ownership) { }
+}
+```
+
+You can also name the class to launch explicitly with the
+[`$(AndroidInstrumentation)`](../building-apps/build-properties.md#androidinstrumentation)
+MSBuild property, or set `$(AndroidUseInstrumentation)` to `false` to launch the
+application's activity instead.
diff --git a/src/Microsoft.Android.Run/AdbHelper.cs b/src/Microsoft.Android.Run/AdbHelper.cs
index b34656a9563..0fa5a468ad6 100644
--- a/src/Microsoft.Android.Run/AdbHelper.cs
+++ b/src/Microsoft.Android.Run/AdbHelper.cs
@@ -16,6 +16,31 @@ public static ProcessStartInfo CreateStartInfo (string adbPath, string? adbTarge
};
}
+ ///
+ /// Builds a from a pre-split argument list.
+ /// quotes each element for us, so
+ /// values containing spaces or double quotes survive the trip through the
+ /// operating system's command line parser untouched.
+ ///
+ public static ProcessStartInfo CreateStartInfo (string adbPath, string? adbTarget, IEnumerable arguments)
+ {
+ var psi = new ProcessStartInfo {
+ FileName = adbPath,
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ };
+ if (!string.IsNullOrEmpty (adbTarget)) {
+ // `adbTarget` is an already formatted switch such as `-s emulator-5554`.
+ foreach (var part in adbTarget.Split (' ', StringSplitOptions.RemoveEmptyEntries))
+ psi.ArgumentList.Add (part);
+ }
+ foreach (var argument in arguments)
+ psi.ArgumentList.Add (argument);
+ return psi;
+ }
+
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);
diff --git a/src/Microsoft.Android.Run/Program.cs b/src/Microsoft.Android.Run/Program.cs
index 8d5ee3e7c48..e0366f92066 100644
--- a/src/Microsoft.Android.Run/Program.cs
+++ b/src/Microsoft.Android.Run/Program.cs
@@ -1,4 +1,5 @@
using System.Diagnostics;
+using System.Text;
using Microsoft.Testing.Extensions;
using Mono.Options;
using Xamarin.Android.Tools;
@@ -91,7 +92,7 @@ async Task RunAsync (string[] args)
return 1;
}
- if (remaining.Count > 0 && !isDotnetTestMode) {
+ if (remaining.Count > 0 && !isDotnetTestMode && string.IsNullOrEmpty (instrumentation)) {
Console.Error.WriteLine ($"Error: Unexpected argument(s): {string.Join (" ", remaining)}");
Console.Error.WriteLine ($"Try '{Name} --help' for more information.");
return 1;
@@ -115,8 +116,13 @@ async Task RunAsync (string[] args)
Console.WriteLine ("Examples:");
Console.WriteLine ($" {Name} -p com.example.myapp -c com.example.myapp.MainActivity");
Console.WriteLine ($" {Name} -p com.example.myapp -i com.example.myapp.TestInstrumentation");
+ Console.WriteLine ($" {Name} -p com.example.myapp -i com.example.myapp.Benchmarks --filter *MyBench*");
Console.WriteLine ($" {Name} --adb /path/to/adb -p com.example.myapp -c com.example.myapp.MainActivity");
Console.WriteLine ();
+ Console.WriteLine ("When --instrument is used, any unrecognized arguments are forwarded to");
+ Console.WriteLine ("'am instrument' as extras: KEY=VALUE becomes '-e KEY VALUE', and everything");
+ Console.WriteLine ("else is joined into a single '-e args \"...\"' extra.");
+ Console.WriteLine ();
Console.WriteLine ("Press Ctrl+C while running to stop the Android application and exit.");
return 0;
}
@@ -184,7 +190,7 @@ async Task RunAsync (string[] args)
return await RunDotnetTestAsync (remaining);
if (isInstrumentMode)
- return await RunInstrumentationAsync ();
+ return await RunInstrumentationAsync (remaining);
return await RunAppAsync ();
} finally {
@@ -215,41 +221,52 @@ void OnCancelKeyPress (object? sender, ConsoleCancelEventArgs e)
}
}
-async Task RunInstrumentationAsync ()
+async Task RunInstrumentationAsync (List instrumentationArgs)
{
- // Build the am instrument command
- var userArg = string.IsNullOrEmpty (deviceUserId) ? "" : $" --user {deviceUserId}";
- var cmdArgs = $"shell am instrument -w{userArg} {package}/{instrumentation}";
+ // '-w' waits for the run to complete; '-r' prints raw INSTRUMENTATION_STATUS
+ // blocks as they arrive instead of buffering everything until the end.
+ var cmdArgs = new List { "shell", "am", "instrument", "-w", "-r" };
+ if (!string.IsNullOrEmpty (deviceUserId)) {
+ cmdArgs.Add ("--user");
+ cmdArgs.Add (deviceUserId);
+ }
+ cmdArgs.AddRange (BuildInstrumentationExtras (instrumentationArgs));
+ cmdArgs.Add ($"{package}/{instrumentation}");
if (verbose)
- Console.WriteLine ($"Running instrumentation: adb {cmdArgs}");
+ Console.WriteLine ($"Running instrumentation: adb {string.Join (" ", cmdArgs)}");
// Run instrumentation with streaming output
var psi = AdbHelper.CreateStartInfo (adbPath, adbTarget, cmdArgs);
using var instrumentProcess = new Process { StartInfo = psi };
var locker = new Lock ();
+ var output = new StringBuilder ();
instrumentProcess.OutputDataReceived += (s, e) => {
if (e.Data != null)
- lock (locker)
+ lock (locker) {
+ output.AppendLine (e.Data);
Console.WriteLine (e.Data);
+ }
};
instrumentProcess.ErrorDataReceived += (s, e) => {
if (e.Data != null)
- lock (locker)
+ lock (locker) {
+ output.AppendLine (e.Data);
Console.Error.WriteLine (e.Data);
+ }
};
instrumentProcess.Start ();
instrumentProcess.BeginOutputReadLine ();
instrumentProcess.BeginErrorReadLine ();
- // Also start logcat in the background for additional debug output
- logcatPid = await GetAppPidAsync ();
- if (logcatPid != null)
- StartLogcat ();
+ // Also stream logcat in the background, which is where Console output from the
+ // app ends up. The app process does not exist yet when `am instrument` starts,
+ // so poll for it rather than giving up after a single `pidof`.
+ var logcatTask = StartLogcatWhenAppStartsAsync ();
// Wait for instrumentation to complete or Ctrl+C
try {
@@ -263,6 +280,8 @@ async Task RunInstrumentationAsync ()
return 1;
}
} finally {
+ cts.Cancel ();
+ await logcatTask;
// Clean up logcat
try {
if (logcatProcess != null && !logcatProcess.HasExited) {
@@ -281,9 +300,133 @@ async Task RunInstrumentationAsync ()
return 1;
}
+ // `am instrument` exits 0 even when the instrumentation crashes or reports
+ // failure, so inspect what it printed to decide the exit code. `WaitForExitAsync`
+ // has already drained both readers, but read under the same lock for clarity.
+ string capturedOutput;
+ lock (locker)
+ capturedOutput = output.ToString ();
+
+ var failure = GetInstrumentationFailure (capturedOutput);
+ if (failure != null) {
+ Console.Error.WriteLine ($"Error: {failure}");
+ return 1;
+ }
+
return 0;
}
+///
+/// Translates trailing `dotnet run -- ARGS` into `am instrument` extras.
+/// `KEY=VALUE` arguments become `-e KEY VALUE`; everything else is joined and
+/// passed as a single `-e args "..."` extra.
+///
+List BuildInstrumentationExtras (List instrumentationArgs)
+{
+ var result = new List ();
+ if (instrumentationArgs.Count == 0)
+ return result;
+
+ var positional = new List ();
+
+ foreach (var arg in instrumentationArgs) {
+ var eqIndex = arg.IndexOf ('=');
+ if (eqIndex > 0 && !arg.StartsWith ("-", StringComparison.Ordinal) && IsBundleKey (arg.AsSpan (0, eqIndex))) {
+ result.Add ("-e");
+ result.Add (arg.Substring (0, eqIndex));
+ result.Add (QuoteForDeviceShell (arg.Substring (eqIndex + 1)));
+ } else {
+ positional.Add (arg);
+ }
+ }
+
+ if (positional.Count > 0) {
+ result.Add ("-e");
+ result.Add ("args");
+ result.Add (QuoteForDeviceShell (string.Join (" ", positional)));
+ }
+
+ return result;
+
+ static bool IsBundleKey (ReadOnlySpan key)
+ {
+ foreach (var c in key) {
+ if (!char.IsLetterOrDigit (c) && c != '_' && c != '.')
+ return false;
+ }
+ return key.Length > 0;
+ }
+}
+
+///
+/// Wraps a value in single quotes so the shell on the device treats it as a
+/// single token. `adb shell` deliberately does not escape the arguments it
+/// forwards, it just joins them with spaces (like `ssh`), so quoting for the
+/// device shell is up to the caller. The surrounding quoting needed to survive
+/// the *local* command line is handled by .
+///
+static string QuoteForDeviceShell (string value) =>
+ "'" + value.Replace ("'", "'\\''") + "'";
+
+///
+/// Inspects `am instrument` output for signs that the instrumentation crashed or
+/// reported failure. Returns a human readable reason, or null on success.
+///
+static string? GetInstrumentationFailure (string output)
+{
+ if (output.Contains ("INSTRUMENTATION_FAILED", StringComparison.Ordinal))
+ return "The instrumentation failed to start. See the output above for details.";
+
+ string? shortMsg = null, longMsg = null;
+ int? code = null;
+ foreach (var rawLine in output.Split ('\n')) {
+ var line = rawLine.TrimEnd ('\r');
+ if (line.StartsWith ("INSTRUMENTATION_RESULT: shortMsg=", StringComparison.Ordinal))
+ shortMsg = line.Substring ("INSTRUMENTATION_RESULT: shortMsg=".Length).Trim ();
+ else if (line.StartsWith ("INSTRUMENTATION_RESULT: longMsg=", StringComparison.Ordinal))
+ longMsg = line.Substring ("INSTRUMENTATION_RESULT: longMsg=".Length).Trim ();
+ else if (line.StartsWith ("INSTRUMENTATION_CODE: ", StringComparison.Ordinal)) {
+ if (int.TryParse (line.Substring ("INSTRUMENTATION_CODE: ".Length).Trim (), out int parsed))
+ code = parsed;
+ }
+ }
+
+ if (longMsg != null || shortMsg != null)
+ return $"The application crashed: {longMsg ?? shortMsg}";
+
+ // Activity.RESULT_CANCELED (0) is what Instrumentation.Finish() reports on failure.
+ if (code == 0)
+ return "The instrumentation reported failure (INSTRUMENTATION_CODE: 0).";
+
+ if (code == null)
+ return "The instrumentation did not complete. It may have crashed before calling Finish().";
+
+ return null;
+}
+
+///
+/// Polls for the application process and starts streaming logcat once it appears.
+///
+async Task StartLogcatWhenAppStartsAsync ()
+{
+ try {
+ while (!cts.Token.IsCancellationRequested) {
+ var pid = await GetAppPidAsync ();
+ if (pid != null) {
+ logcatPid = pid;
+ StartLogcat ();
+ return;
+ }
+ await Task.Delay (250, cts.Token).ConfigureAwait (ConfigureAwaitOptions.SuppressThrowing);
+ }
+ } catch (OperationCanceledException) {
+ // The instrumentation finished (or was cancelled) before the app process was seen
+ } catch (Exception ex) {
+ if (verbose)
+ Console.Error.WriteLine ($"Error starting logcat: {ex.Message}");
+ }
+}
+
async Task RunDotnetTestAsync (List mtpArgs)
{
if (verbose)
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 dce71c7c33d..6c48af46f0a 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
@@ -101,13 +101,27 @@ This file contains targets specific for Android application projects.
+
+
+
+
+
@@ -129,7 +143,9 @@ This file contains targets specific for Android application projects.
<_AmStartUserArg Condition=" '$(AndroidDeviceUserId)' != '' "> --user $(AndroidDeviceUserId)
$(_AdbToolPath)
- $(AdbTarget) shell am start -S$(_AmStartUserArg) -n "$(_AndroidPackage)/$(AndroidLaunchActivity)"
+ $(AdbTarget) shell am start -S$(_AmStartUserArg) -n "$(_AndroidPackage)/$(AndroidLaunchActivity)"
+
+ $(AdbTarget) shell am instrument -r$(_AmStartUserArg) "$(_AndroidPackage)/$(AndroidInstrumentation)"
diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.DefaultProperties.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.DefaultProperties.targets
index 3701c41be58..27d8a47aae4 100644
--- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.DefaultProperties.targets
+++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.DefaultProperties.targets
@@ -6,6 +6,12 @@
Resources
Assets
Resource
+
+ true
+ false
true
true
true
diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs
index 1b0b941010a..a707010778b 100644
--- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs
+++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs
@@ -964,7 +964,7 @@ public static string XA1041 {
}
///
- /// Looks up a localized string similar to The <instrumentation> element in AndroidManifest.xml is missing the android:name attribute..
+ /// Looks up a localized string similar to The <instrumentation> element in '{0}' is missing the android:name attribute..
///
public static string XA1042 {
get {
@@ -973,7 +973,7 @@ public static string XA1042 {
}
///
- /// Looks up a localized string similar to No <instrumentation> element found in AndroidManifest.xml..
+ /// Looks up a localized string similar to Could not determine what to launch: '{0}' does not contain a launchable <activity> or an <instrumentation> element. Set the $(AndroidLaunchActivity) or $(AndroidInstrumentation) MSBuild property to specify one..
///
public static string XA1043 {
get {
@@ -1017,6 +1017,15 @@ public static string XA1047 {
}
}
+ ///
+ /// Looks up a localized string similar to '{0}' does not contain an <instrumentation> element. Add an Android.App.Instrumentation subclass to the application, or set the $(AndroidInstrumentation) MSBuild property to the name of one..
+ ///
+ public static string XA1048 {
+ get {
+ return ResourceManager.GetString("XA1048", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Use of AppDomain.CreateDomain() detected in assembly: {0}. .NET 6 and higher will only support a single AppDomain, so this API will no longer be available in .NET for Android once .NET 6 is released..
///
diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx
index 17d1695e2a1..63edb803fd0 100644
--- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx
+++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx
@@ -1072,6 +1072,18 @@ To use a custom JDK path for a command line build, set the 'JavaSdkDirectory' MS
The following are literal names and should not be translated: .NET.
{0} - The MSBuild property that has the incorrect value.
{1} - The current value of the property
+
+
+
+ The <instrumentation> element in '{0}' is missing the android:name attribute.
+ The following are literal names and should not be translated: instrumentation, android:name.
+{0} - The path to the AndroidManifest.xml file.
+
+
+
+ Could not determine what to launch: '{0}' does not contain a launchable <activity> or an <instrumentation> element. Set the $(AndroidLaunchActivity) or $(AndroidInstrumentation) MSBuild property to specify one.
+ The following are literal names and should not be translated: activity, instrumentation, MSBuild, AndroidLaunchActivity, AndroidInstrumentation.
+{0} - The path to the AndroidManifest.xml file.
@@ -1093,6 +1105,12 @@ To use a custom JDK path for a command line build, set the 'JavaSdkDirectory' MS
Required attribute '{0}' missing from element '{1}'; {2} line {3}.
{0} - attribute name; {1} - element name; {2} - file path; {3} - line number
+
+ '{0}' does not contain an <instrumentation> element. Add an Android.App.Instrumentation subclass to the application, or set the $(AndroidInstrumentation) MSBuild property to the name of one.
+ The following are literal names and should not be translated: instrumentation, Android.App.Instrumentation, MSBuild, AndroidInstrumentation.
+{0} - The path to the AndroidManifest.xml file.
+
+
Java dependency '{0}' is not satisfied.
The following are literal names and should not be translated: Java.
diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GetAndroidInstrumentationName.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GetAndroidInstrumentationName.cs
index b78583a0fc9..ff1cdff63cc 100644
--- a/src/Xamarin.Android.Build.Tasks/Tasks/GetAndroidInstrumentationName.cs
+++ b/src/Xamarin.Android.Build.Tasks/Tasks/GetAndroidInstrumentationName.cs
@@ -1,5 +1,4 @@
#nullable enable
-using System;
using Microsoft.Build.Framework;
using Xamarin.Android.Tools;
using Microsoft.Android.Build.Tasks;
@@ -17,6 +16,14 @@ public class GetAndroidInstrumentationName : AndroidTask
[Required]
public string ManifestFile { get; set; } = "";
+ ///
+ /// Set when the application was already found to have no launchable
+ /// <activity>, in which case a missing <instrumentation> element
+ /// means there is nothing at all to launch and XA1043 is reported.
+ /// Otherwise a missing <instrumentation> is reported as XA1048.
+ ///
+ public bool NoLaunchableActivity { get; set; }
+
[Output]
public string? InstrumentationName { get; set; }
@@ -24,13 +31,20 @@ public override bool RunTask ()
{
var manifest = AndroidAppManifest.Load (ManifestFile, MonoAndroidHelper.SupportedVersions);
var androidNs = AndroidAppManifest.AndroidXNamespace;
- var doc = manifest.Document;
- var instrumentation = doc?.Root?.Element ("instrumentation")
- ?? throw new InvalidOperationException ("No element found in AndroidManifest.xml.");
+ var instrumentation = manifest.Document?.Root?.Element ("instrumentation");
+ if (instrumentation == null) {
+ if (NoLaunchableActivity) {
+ Log.LogCodedError ("XA1043", Properties.Resources.XA1043, ManifestFile);
+ } else {
+ Log.LogCodedError ("XA1048", Properties.Resources.XA1048, ManifestFile);
+ }
+ return !Log.HasLoggedErrors;
+ }
+
InstrumentationName = instrumentation.Attribute (androidNs + "name")?.Value;
- if (string.IsNullOrEmpty (InstrumentationName))
- throw new InvalidOperationException ("The element in AndroidManifest.xml is missing the android:name attribute.");
+ if (InstrumentationName.IsNullOrEmpty ())
+ Log.LogCodedError ("XA1042", Properties.Resources.XA1042, ManifestFile);
return !Log.HasLoggedErrors;
}
diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GetAndroidInstrumentationNameTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GetAndroidInstrumentationNameTests.cs
new file mode 100644
index 00000000000..a9b6b66795a
--- /dev/null
+++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GetAndroidInstrumentationNameTests.cs
@@ -0,0 +1,108 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.Build.Framework;
+using NUnit.Framework;
+using Xamarin.Android.Tasks;
+
+namespace Xamarin.Android.Build.Tests
+{
+ [TestFixture]
+ public class GetAndroidInstrumentationNameTests : BaseTest
+ {
+ string temp;
+ string manifest;
+
+ [SetUp]
+ public void Setup ()
+ {
+ string tempDirectoryName = Path.Combine ("temp", TestName);
+ temp = Path.Combine (Root, tempDirectoryName);
+ Directory.CreateDirectory (temp);
+ manifest = Path.Combine (temp, "AndroidManifest.xml");
+
+ var references = CreateFauxReferencesDirectory (Path.Combine (tempDirectoryName, "references"), new [] {
+ new ApiInfo { Id = "28", Level = 28, Name = "Pie", FrameworkVersion = "v9.0", Stable = true },
+ });
+ MonoAndroidHelper.RefreshSupportedVersions (new [] {
+ Path.Combine (references, "MonoAndroid"),
+ });
+ }
+
+ [TearDown]
+ public void TearDown ()
+ {
+ Directory.Delete (temp, recursive: true);
+ }
+
+ void WriteManifest (string body)
+ {
+ File.WriteAllText (manifest,
+ $"""
+
+
+ {body}
+
+ """);
+ }
+
+ GetAndroidInstrumentationName CreateTask (List errors, bool noLaunchableActivity = false) =>
+ new GetAndroidInstrumentationName {
+ BuildEngine = new MockBuildEngine (TestContext.Out, errors),
+ ManifestFile = manifest,
+ NoLaunchableActivity = noLaunchableActivity,
+ };
+
+ [Test]
+ public void InstrumentationFound ()
+ {
+ WriteManifest (""" """);
+
+ var errors = new List ();
+ var task = CreateTask (errors);
+ Assert.IsTrue (task.Execute (), "Execute() should have succeeded.");
+ Assert.AreEqual (0, errors.Count, "There should be no errors.");
+ Assert.AreEqual ("com.mycompanyname.foo.MyInstrumentation", task.InstrumentationName);
+ }
+
+ [Test]
+ public void MissingInstrumentationIsXA1048 ()
+ {
+ // `$(AndroidUseInstrumentation)` opted in, but there is nothing to run.
+ WriteManifest ("");
+
+ var errors = new List ();
+ var task = CreateTask (errors);
+ Assert.IsFalse (task.Execute (), "Execute() should have failed.");
+ Assert.AreEqual (1, errors.Count, "There should be one error.");
+ Assert.AreEqual ("XA1048", errors [0].Code);
+ Assert.IsNull (task.InstrumentationName);
+ }
+
+ [Test]
+ public void MissingInstrumentationAndActivityIsXA1043 ()
+ {
+ // The fallback call site: no launchable `` was found either.
+ WriteManifest ("");
+
+ var errors = new List ();
+ var task = CreateTask (errors, noLaunchableActivity: true);
+ Assert.IsFalse (task.Execute (), "Execute() should have failed.");
+ Assert.AreEqual (1, errors.Count, "There should be one error.");
+ Assert.AreEqual ("XA1043", errors [0].Code);
+ Assert.IsNull (task.InstrumentationName);
+ }
+
+ [Test]
+ public void MissingAndroidNameIsXA1042 ()
+ {
+ WriteManifest (""" """);
+
+ var errors = new List ();
+ var task = CreateTask (errors);
+ Assert.IsFalse (task.Execute (), "Execute() should have failed.");
+ Assert.AreEqual (1, errors.Count, "There should be one error.");
+ Assert.AreEqual ("XA1042", errors [0].Code);
+ }
+ }
+}
diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs
index 85518d1fe99..e339ca33708 100644
--- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs
+++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs
@@ -2826,6 +2826,149 @@ public void DotNetNewAndroidTest (string mode, MSTestPackageChannel msTestPackag
}
}
+ ///
+ /// An app whose only entry point is an `Android.App.Instrumentation` subclass that
+ /// runs BenchmarkDotNet in-process. There is no ``, so `dotnet run` has to
+ /// resolve `$(AndroidInstrumentation)` from the generated `AndroidManifest.xml`.
+ ///
+ const string BenchmarkDotNetInstrumentationSource = """
+ using System;
+ using System.IO;
+ using BenchmarkDotNet.Attributes;
+ using BenchmarkDotNet.Columns;
+ using BenchmarkDotNet.Configs;
+ using BenchmarkDotNet.Jobs;
+ using BenchmarkDotNet.Loggers;
+ using BenchmarkDotNet.Running;
+ using BenchmarkDotNet.Toolchains.InProcess.NoEmit;
+
+ namespace ${ROOT_NAMESPACE}
+ {
+ public class SampleBenchmarks
+ {
+ [Benchmark]
+ public int Sum ()
+ {
+ int total = 0;
+ for (int i = 0; i < 1000; i++)
+ total += i;
+ return total;
+ }
+ }
+
+ [Instrumentation (Name = "${JAVA_PACKAGENAME}.BenchmarkInstrumentation")]
+ public class BenchmarkInstrumentation : Instrumentation
+ {
+ protected BenchmarkInstrumentation (IntPtr handle, Android.Runtime.JniHandleOwnership ownership)
+ : base (handle, ownership) { }
+
+ public override void OnCreate (Bundle? arguments)
+ {
+ base.OnCreate (arguments);
+ Console.WriteLine ($"BENCHMARK_ARGS args={arguments?.GetString ("args")} greeting={arguments?.GetString ("greeting")}");
+ Start ();
+ }
+
+ public override void OnStart ()
+ {
+ base.OnStart ();
+ var results = new Bundle ();
+ try {
+ var artifacts = Path.Combine (
+ Application.Context.GetExternalFilesDir (null)?.AbsolutePath ?? Path.GetTempPath (),
+ "BenchmarkDotNet.Artifacts");
+ // BenchmarkDotNet cannot spawn child processes on Android, so run in-process.
+ // Job.Dry keeps the run to a single iteration.
+ var config = ManualConfig.CreateEmpty ()
+ .AddJob (Job.Dry.WithToolchain (InProcessNoEmitToolchain.Instance))
+ .AddLogger (ConsoleLogger.Default)
+ .AddColumnProvider (DefaultColumnProviders.Instance)
+ .WithArtifactsPath (artifacts)
+ .WithOptions (ConfigOptions.DisableOptimizationsValidator);
+ var summary = BenchmarkRunner.Run (config);
+ Console.WriteLine ($"BENCHMARKS_COMPLETE reports={summary.Reports.Length}");
+ results.PutInt ("reports", summary.Reports.Length);
+ Finish (Result.Ok, results);
+ } catch (Exception ex) {
+ Console.WriteLine ($"BENCHMARKS_FAILED {ex}");
+ results.PutString ("error", ex.ToString ());
+ Finish (Result.Canceled, results);
+ }
+ }
+ }
+ }
+ """;
+
+ [Test]
+ public void DotNetRunBenchmarkDotNet ()
+ {
+ var proj = new XamarinAndroidApplicationProject {
+ ProjectName = nameof (DotNetRunBenchmarkDotNet),
+ RootNamespace = nameof (DotNetRunBenchmarkDotNet),
+ IsRelease = false,
+ };
+ proj.PackageReferences.Add (new Package { Id = "BenchmarkDotNet", Version = "0.15.8" });
+
+ // Enable verbose output from Microsoft.Android.Run for debugging
+ proj.SetProperty ("_AndroidRunExtraArgs", "--verbose");
+
+ // Replace MainActivity.cs, so the app declares an and no
+ proj.MainActivity = BenchmarkDotNetInstrumentationSource;
+
+ using var builder = CreateApkBuilder ();
+ builder.Save (proj);
+
+ var dotnet = new DotNetCLI (Path.Combine (Root, builder.ProjectDirectory, proj.ProjectFilePath));
+ Assert.IsTrue (dotnet.Build (), "`dotnet build` should succeed");
+
+ // Arguments after `--` are forwarded to `am instrument` as extras:
+ // `greeting=hello` becomes `-e greeting hello`, and `--custom-flag` is
+ // collected into a single `-e args "..."` extra.
+ using var process = dotnet.StartRun (waitForExit: true, parameters: new [] { "--", "greeting=hello", "--custom-flag" });
+
+ var locker = new Lock ();
+ var output = new StringBuilder ();
+
+ 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 ();
+
+ bool completed = process.WaitForExit ((int) TimeSpan.FromMinutes (10).TotalMilliseconds);
+ if (!completed) {
+ try { process.Kill (entireProcessTree: true); } catch { }
+ } else {
+ // Ensure async output events are fully drained
+ process.WaitForExit ();
+ }
+
+ string logPath = Path.Combine (Root, builder.ProjectDirectory, "dotnet-run-benchmarkdotnet.log");
+ File.WriteAllText (logPath, output.ToString ());
+ TestContext.AddTestAttachment (logPath);
+
+ Assert.IsTrue (completed, $"`dotnet run` did not complete in time. See {logPath} for details.");
+
+ var outputText = output.ToString ();
+
+ // `Console.WriteLine` from the app lands in logcat, which `dotnet run` streams
+ StringAssert.Contains ("BENCHMARK_ARGS args=--custom-flag greeting=hello", outputText,
+ $"The instrumentation should receive the arguments passed after `--`. See {logPath} for details.");
+ StringAssert.Contains ("BENCHMARKS_COMPLETE reports=1", outputText,
+ $"BenchmarkDotNet should have produced 1 report. See {logPath} for details.");
+ StringAssert.Contains ("INSTRUMENTATION_CODE: -1", outputText,
+ $"The instrumentation should have finished with Result.Ok. See {logPath} for details.");
+ Assert.AreEqual (0, process.ExitCode, $"`dotnet run` should succeed. See {logPath} for details.");
+ }
+
static int ParseInstrumentationResult (string output, string key)
{
// Parses lines like: INSTRUMENTATION_RESULT: passed=1