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