diff --git a/Directory.Build.targets b/Directory.Build.targets index aa7747c9fd6..2b96ce13a7f 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -2,6 +2,7 @@ + diff --git a/Documentation/docs-mobile/building-apps/build-properties.md b/Documentation/docs-mobile/building-apps/build-properties.md index a5a3acaf7a1..02a75139a1c 100644 --- a/Documentation/docs-mobile/building-apps/build-properties.md +++ b/Documentation/docs-mobile/building-apps/build-properties.md @@ -1819,3 +1819,21 @@ This MSBuild property replaces the Xamarin.Android. This is the same property used for [Blazor WASM][blazor]. [blazor]: /aspnet/core/blazor/host-and-deploy/webassembly/#ahead-of-time-aot-compilation + +## WaitForExit + +A boolean property that controls the behavior of `dotnet run` when launching +Android applications. + +When `$(WaitForExit)` not `false` (the default), `dotnet run` will: + +* Launch the Android application +* Stream `logcat` output filtered to the application's process +* Wait for the application to exit or for the user to press Ctrl+C +* 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. + +Introduced in .NET 11. diff --git a/Xamarin.Android.sln b/Xamarin.Android.sln index 412db775b43..d1edbe95c97 100644 --- a/Xamarin.Android.sln +++ b/Xamarin.Android.sln @@ -25,6 +25,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Xamarin.Android.Tools.Aidl" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Xamarin.Android.Build.Tasks", "src\Xamarin.Android.Build.Tasks\Xamarin.Android.Build.Tasks.csproj", "{3F1F2F50-AF1A-4A5A-BEDB-193372F068D7}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Android.Run", "src\Microsoft.Android.Run\Microsoft.Android.Run.csproj", "{A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Android.Sdk.ILLink", "src\Microsoft.Android.Sdk.ILLink\Microsoft.Android.Sdk.ILLink.csproj", "{71FE54FA-0BF5-48EF-ACAA-17557B28C9F4}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Xamarin.Android.Tools.Bytecode", "external\Java.Interop\src\Xamarin.Android.Tools.Bytecode\Xamarin.Android.Tools.Bytecode.csproj", "{B17475BC-45A2-47A3-B8FC-62F3A0959EE0}" @@ -169,6 +171,10 @@ Global {3F1F2F50-AF1A-4A5A-BEDB-193372F068D7}.Debug|AnyCPU.Build.0 = Debug|Any CPU {3F1F2F50-AF1A-4A5A-BEDB-193372F068D7}.Release|AnyCPU.ActiveCfg = Release|Any CPU {3F1F2F50-AF1A-4A5A-BEDB-193372F068D7}.Release|AnyCPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D}.Debug|AnyCPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D}.Debug|AnyCPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D}.Release|AnyCPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D}.Release|AnyCPU.Build.0 = Release|Any CPU {71FE54FA-0BF5-48EF-ACAA-17557B28C9F4}.Debug|AnyCPU.ActiveCfg = Debug|Any CPU {71FE54FA-0BF5-48EF-ACAA-17557B28C9F4}.Debug|AnyCPU.Build.0 = Debug|Any CPU {71FE54FA-0BF5-48EF-ACAA-17557B28C9F4}.Release|AnyCPU.ActiveCfg = Release|Any CPU diff --git a/build-tools/create-packs/Microsoft.Android.Sdk.proj b/build-tools/create-packs/Microsoft.Android.Sdk.proj index 62973139d88..60a574fb2f4 100644 --- a/build-tools/create-packs/Microsoft.Android.Sdk.proj +++ b/build-tools/create-packs/Microsoft.Android.Sdk.proj @@ -65,6 +65,9 @@ core workload SDK packs imported by WorkloadManifest.targets. + + + diff --git a/src/Microsoft.Android.Run/Microsoft.Android.Run.csproj b/src/Microsoft.Android.Run/Microsoft.Android.Run.csproj new file mode 100644 index 00000000000..4a0397bb4e7 --- /dev/null +++ b/src/Microsoft.Android.Run/Microsoft.Android.Run.csproj @@ -0,0 +1,24 @@ + + + + + false + $(MicrosoftAndroidSdkOutDir) + Exe + $(DotNetTargetFramework) + Microsoft.Android.Run + enable + enable + portable + + + + + + + + + + + + diff --git a/src/Microsoft.Android.Run/Program.cs b/src/Microsoft.Android.Run/Program.cs new file mode 100644 index 00000000000..b61ce341b2a --- /dev/null +++ b/src/Microsoft.Android.Run/Program.cs @@ -0,0 +1,382 @@ +using System.Diagnostics; +using Mono.Options; +using Xamarin.Android.Tools; + +const string Name = "Microsoft.Android.Run"; +const string VersionsFileName = "Microsoft.Android.versions.txt"; + +string? adbPath = null; +string? package = null; +string? activity = null; +bool verbose = false; +int? logcatPid = null; +Process? logcatProcess = null; +CancellationTokenSource cts = new (); +string? logcatArgs = null; + +try { + return Run (args); +} catch (Exception ex) { + Console.Error.WriteLine ($"Error: {ex.Message}"); + if (verbose) + Console.Error.WriteLine (ex.ToString ()); + return 1; +} + +int Run (string[] args) +{ + bool showHelp = false; + bool showVersion = false; + + var options = new OptionSet { + $"Usage: {Name} [OPTIONS]", + "", + "Launches an Android application, streams its logcat output, and provides", + "proper Ctrl+C handling to stop the app gracefully.", + "Options:", + { "a|adb=", + "Path to the {ADB} executable. If not specified, will attempt to locate " + + "the Android SDK automatically.", + v => adbPath = v }, + { "p|package=", + "The Android application {PACKAGE} name (e.g., com.example.myapp). Required.", + v => package = v }, + { "c|activity=", + "The {ACTIVITY} class name to launch. Required.", + v => activity = v }, + { "v|verbose", + "Enable verbose output for debugging.", + v => verbose = v != null }, + { "logcat-args=", + "Extra {ARGUMENTS} to pass to 'adb logcat' (e.g., 'monodroid-assembly:S' to silence a tag).", + v => logcatArgs = v }, + { "version", + "Show version information and exit.", + v => showVersion = v != null }, + { "h|help|?", + "Show this help message and exit.", + v => showHelp = v != null }, + }; + + try { + var remaining = options.Parse (args); + if (remaining.Count > 0) { + Console.Error.WriteLine ($"Error: Unexpected argument(s): {string.Join (" ", remaining)}"); + Console.Error.WriteLine ($"Try '{Name} --help' for more information."); + return 1; + } + } catch (OptionException e) { + Console.Error.WriteLine ($"Error: {e.Message}"); + Console.Error.WriteLine ($"Try '{Name} --help' for more information."); + return 1; + } + + if (showVersion) { + var (version, commit) = GetVersionInfo (); + if (!string.IsNullOrEmpty (version)) { + Console.WriteLine ($"{Name} {version}"); + if (!string.IsNullOrEmpty (commit)) + Console.WriteLine ($"Commit: {commit}"); + } else { + Console.WriteLine (Name); + } + return 0; + } + + if (showHelp) { + 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 (); + Console.WriteLine ("Press Ctrl+C while running to stop the Android application and exit."); + return 0; + } + + if (string.IsNullOrEmpty (package)) { + Console.Error.WriteLine ("Error: --package is required."); + Console.Error.WriteLine ($"Try '{Name} --help' for more information."); + return 1; + } + + if (string.IsNullOrEmpty (activity)) { + Console.Error.WriteLine ("Error: --activity is required."); + Console.Error.WriteLine ($"Try '{Name} --help' for more information."); + return 1; + } + + // Resolve adb path if not specified + if (string.IsNullOrEmpty (adbPath)) { + adbPath = FindAdbPath (); + if (string.IsNullOrEmpty (adbPath)) { + Console.Error.WriteLine ("Error: Could not locate adb. Please specify --adb."); + return 1; + } + } + + if (!File.Exists (adbPath)) { + Console.Error.WriteLine ($"Error: adb not found at '{adbPath}'."); + return 1; + } + + if (verbose) { + Console.WriteLine ($"Using adb: {adbPath}"); + Console.WriteLine ($"Package: {package}"); + if (!string.IsNullOrEmpty (activity)) + Console.WriteLine ($"Activity: {activity}"); + } + + // Set up Ctrl+C handler + Console.CancelKeyPress += OnCancelKeyPress; + + try { + return RunApp (); + } finally { + Console.CancelKeyPress -= OnCancelKeyPress; + cts.Dispose (); + } +} + +void OnCancelKeyPress (object? sender, ConsoleCancelEventArgs e) +{ + e.Cancel = true; // Prevent immediate exit + Console.WriteLine (); + Console.WriteLine ("Stopping application..."); + + cts.Cancel (); + + // Force-stop the app + StopApp (); + + // Kill logcat process if running + try { + if (logcatProcess != null && !logcatProcess.HasExited) { + logcatProcess.Kill (); + } + } catch (Exception ex) { + if (verbose) + Console.Error.WriteLine ($"Error killing logcat process: {ex.Message}"); + } +} + +int RunApp () +{ + // 1. Start the app + if (!StartApp ()) + return 1; + + // 2. Get the PID + logcatPid = GetAppPid (); + if (logcatPid == null) { + Console.Error.WriteLine ("Error: App started but could not retrieve PID. The app may have crashed."); + return 1; + } + + if (verbose) + Console.WriteLine ($"App PID: {logcatPid}"); + + // 3. Stream logcat + StartLogcat (); + + // 4. Wait for app to exit or Ctrl+C + WaitForAppExit (); + + return 0; +} + +bool StartApp () +{ + var cmdArgs = $"shell am start -S -W -n \"{package}/{activity}\""; + var (exitCode, output, error) = RunAdb (cmdArgs); + if (exitCode != 0) { + Console.Error.WriteLine ($"Error: Failed to start app: {error}"); + return false; + } + + if (verbose) + Console.WriteLine (output); + + return true; +} + +int? GetAppPid () +{ + var cmdArgs = $"shell pidof {package}"; + var (exitCode, output, error) = RunAdb (cmdArgs); + if (exitCode != 0 || string.IsNullOrWhiteSpace (output)) + return null; + + var pidStr = output.Trim ().Split (' ') [0]; // Take first PID if multiple + if (int.TryParse (pidStr, out int pid)) + return pid; + + return null; +} + +void StartLogcat () +{ + if (logcatPid == null) + return; + + var logcatArguments = $"logcat --pid={logcatPid}"; + if (!string.IsNullOrEmpty (logcatArgs)) + logcatArguments += $" {logcatArgs}"; + + if (verbose) + Console.WriteLine ($"Running: adb {logcatArguments}"); + + var locker = new Lock(); + var psi = new ProcessStartInfo { + FileName = adbPath, + Arguments = logcatArguments, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + logcatProcess = new Process { StartInfo = psi }; + + logcatProcess.OutputDataReceived += (s, e) => { + if (e.Data != null) + lock (locker) + Console.WriteLine (e.Data); + }; + + logcatProcess.ErrorDataReceived += (s, e) => { + if (e.Data != null) + lock (locker) + Console.Error.WriteLine (e.Data); + }; + + logcatProcess.Start (); + logcatProcess.BeginOutputReadLine (); + logcatProcess.BeginErrorReadLine (); +} + +void WaitForAppExit () +{ + while (!cts!.Token.IsCancellationRequested) { + // Check if app is still running + var pid = GetAppPid (); + if (pid == null || pid != logcatPid) { + if (verbose) + Console.WriteLine ("App has exited."); + break; + } + + // Also check if logcat process exited unexpectedly + if (logcatProcess != null && logcatProcess.HasExited) { + if (verbose) + Console.WriteLine ("Logcat process exited."); + break; + } + + Thread.Sleep (1000); + } + + // Clean up logcat process + try { + if (logcatProcess != null && !logcatProcess.HasExited) { + logcatProcess.Kill (); + logcatProcess.WaitForExit (1000); + } + } catch (Exception ex) { + if (verbose) + Console.Error.WriteLine ($"Error cleaning up logcat process: {ex.Message}"); + } +} + +void StopApp () +{ + if (string.IsNullOrEmpty (package) || string.IsNullOrEmpty (adbPath)) + return; + + RunAdb ($"shell am force-stop {package}"); +} + +string? FindAdbPath () +{ + try { + // Use AndroidSdkInfo to locate the SDK + var sdk = new AndroidSdkInfo ( + logger: verbose ? (level, msg) => Console.WriteLine ($"[{level}] {msg}") : null + ); + + if (!string.IsNullOrEmpty (sdk.AndroidSdkPath)) { + var adb = Path.Combine (sdk.AndroidSdkPath, "platform-tools", OperatingSystem.IsWindows () ? "adb.exe" : "adb"); + if (File.Exists (adb)) + return adb; + } + } catch (Exception ex) { + if (verbose) + Console.WriteLine ($"AndroidSdkInfo failed: {ex.Message}"); + } + + return null; +} + +(int ExitCode, string Output, string Error) RunAdb (string arguments) +{ + if (verbose) + Console.WriteLine ($"Running: adb {arguments}"); + + var psi = new ProcessStartInfo { + FileName = adbPath, + Arguments = arguments, + 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 { + // The tool is in: /tools/Microsoft.Android.Run.dll + // The versions file is in: /Microsoft.Android.versions.txt + var toolPath = typeof (OptionSet).Assembly.Location; + if (string.IsNullOrEmpty (toolPath)) + toolPath = Environment.ProcessPath; + + if (string.IsNullOrEmpty (toolPath)) + return (null, null); + + var toolDir = Path.GetDirectoryName (toolPath); + if (string.IsNullOrEmpty (toolDir)) + return (null, null); + + var sdkDir = Path.GetDirectoryName (toolDir); + if (string.IsNullOrEmpty (sdkDir)) + return (null, null); + + var versionsFile = Path.Combine (sdkDir, VersionsFileName); + if (!File.Exists (versionsFile)) + return (null, null); + + var lines = File.ReadAllLines (versionsFile); + string? commit = lines.Length > 0 ? lines [0].Trim () : null; + string? version = lines.Length > 1 ? lines [1].Trim () : null; + + return (version, commit); + } catch (Exception ex) { + if (verbose) + Console.Error.WriteLine ($"Error reading version info: {ex.Message}"); + return (null, null); + } +} 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 c3bd094ab59..bb7ec4c47a3 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 @@ -57,13 +57,24 @@ This file contains targets specific for Android application projects. - $(AdbToolExe) - adb.exe - adb - $([System.IO.Path]::Combine ('$(AdbToolPath)', '$(RunCommand)')) - $(AdbTarget) shell am start -S -n "$(_AndroidPackage)/$(AndroidLaunchActivity)" + <_AdbToolPath>$(AdbToolExe) + <_AdbToolPath Condition=" '$(_AdbToolPath)' == '' and $([MSBuild]::IsOSPlatform('windows')) ">adb.exe + <_AdbToolPath Condition=" '$(_AdbToolPath)' == '' and !$([MSBuild]::IsOSPlatform('windows')) ">adb + <_AdbToolPath>$([System.IO.Path]::Combine ('$(AdbToolPath)', '$(_AdbToolPath)')) $(MSBuildProjectDirectory) + + + <_AndroidRunPath Condition=" '$(_AndroidRunPath)' == '' ">$(MSBuildThisFileDirectory)..\tools\Microsoft.Android.Run.dll + <_AndroidRunLogcatArgs Condition=" '$(_AndroidRunLogcatArgs)' == '' ">monodroid-assembly:S + dotnet + exec "$(_AndroidRunPath)" --adb "$(_AdbToolPath)" --package "$(_AndroidPackage)" --activity "$(AndroidLaunchActivity)" --logcat-args "$(_AndroidRunLogcatArgs)" $(_AndroidRunExtraArgs) + + + + $(_AdbToolPath) + $(AdbTarget) shell am start -S -n "$(_AndroidPackage)/$(AndroidLaunchActivity)" + diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs index 9d38ac66cf5..7905edbbf4d 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; using System.Text; +using System.Threading; namespace Xamarin.ProjectTools { @@ -24,6 +26,37 @@ public DotNetCLI (string projectOrSolution) ProjectDirectory = Path.GetDirectoryName (projectOrSolution); } + /// + /// Creates and starts a `dotnet` process with the specified arguments. + /// + /// command arguments + /// A started Process instance. Caller is responsible for disposing. + protected Process ExecuteProcess (params string [] args) + { + var p = new Process (); + p.StartInfo.FileName = Path.Combine (TestEnvironment.DotNetPreviewDirectory, "dotnet"); + p.StartInfo.Arguments = string.Join (" ", args); + p.StartInfo.CreateNoWindow = true; + p.StartInfo.UseShellExecute = false; + p.StartInfo.RedirectStandardOutput = true; + p.StartInfo.RedirectStandardError = true; + p.StartInfo.SetEnvironmentVariable ("DOTNET_MULTILEVEL_LOOKUP", "0"); + p.StartInfo.SetEnvironmentVariable ("PATH", TestEnvironment.DotNetPreviewDirectory + Path.PathSeparator + Environment.GetEnvironmentVariable ("PATH")); + if (TestEnvironment.UseLocalBuildOutput) { + p.StartInfo.SetEnvironmentVariable ("DOTNETSDK_WORKLOAD_MANIFEST_ROOTS", TestEnvironment.WorkloadManifestOverridePath); + p.StartInfo.SetEnvironmentVariable ("DOTNETSDK_WORKLOAD_PACK_ROOTS", TestEnvironment.WorkloadPackOverridePath); + } + if (Directory.Exists (AndroidSdkPath)) { + p.StartInfo.SetEnvironmentVariable ("AndroidSdkDirectory", AndroidSdkPath.TrimEnd ('\\')); + } + if (Directory.Exists (JavaSdkPath)) { + p.StartInfo.SetEnvironmentVariable ("JavaSdkDirectory", JavaSdkPath.TrimEnd ('\\')); + } + + p.Start (); + return p; + } + /// /// Runs the `dotnet` tool with the specified arguments. /// @@ -36,36 +69,23 @@ protected bool Execute (params string [] args) ProcessLogFile = Path.Combine (ProjectDirectory, $"dotnet{DateTime.Now.ToString ("yyyyMMddHHmmssff")}-process.log"); } + var locker = new Lock (); var procOutput = new StringBuilder (); bool succeeded; - using (var p = new Process ()) { - p.StartInfo.FileName = Path.Combine (TestEnvironment.DotNetPreviewDirectory, "dotnet"); - p.StartInfo.Arguments = string.Join (" ", args); - p.StartInfo.CreateNoWindow = true; - p.StartInfo.UseShellExecute = false; - p.StartInfo.RedirectStandardOutput = true; - p.StartInfo.RedirectStandardError = true; - p.StartInfo.SetEnvironmentVariable ("DOTNET_MULTILEVEL_LOOKUP", "0"); - p.StartInfo.SetEnvironmentVariable ("PATH", TestEnvironment.DotNetPreviewDirectory + Path.PathSeparator + Environment.GetEnvironmentVariable ("PATH")); - if (TestEnvironment.UseLocalBuildOutput) { - p.StartInfo.SetEnvironmentVariable ("DOTNETSDK_WORKLOAD_MANIFEST_ROOTS", TestEnvironment.WorkloadManifestOverridePath); - p.StartInfo.SetEnvironmentVariable ("DOTNETSDK_WORKLOAD_PACK_ROOTS", TestEnvironment.WorkloadPackOverridePath); - } - + using (var p = ExecuteProcess (args)) { p.ErrorDataReceived += (sender, e) => { - if (e.Data != null) { - procOutput.AppendLine (e.Data); - } + if (e.Data != null) + lock (locker) + procOutput.AppendLine (e.Data); }; - p.ErrorDataReceived += (sender, e) => { - if (e.Data != null) { - procOutput.AppendLine (e.Data); - } + p.OutputDataReceived += (sender, e) => { + if (e.Data != null) + lock (locker) + procOutput.AppendLine (e.Data); }; procOutput.AppendLine ($"Running: {p.StartInfo.FileName} {p.StartInfo.Arguments}"); - p.Start (); p.BeginOutputReadLine (); p.BeginErrorReadLine (); bool completed = p.WaitForExit ((int) new TimeSpan (0, 15, 0).TotalMilliseconds); @@ -111,18 +131,38 @@ public bool Publish (string target = null, string runtimeIdentifier = null, stri return Execute (arguments.ToArray ()); } - public bool Run () + public bool Run (bool waitForExit = false) { string binlog = Path.Combine (Path.GetDirectoryName (projectOrSolution), "run.binlog"); var arguments = new List { "run", "--project", $"\"{projectOrSolution}\"", "--no-build", - $"/bl:\"{binlog}\"" + $"/bl:\"{binlog}\"", + $"/p:WaitForExit={waitForExit.ToString (CultureInfo.InvariantCulture)}" }; return Execute (arguments.ToArray ()); } + /// + /// Starts `dotnet run` and returns a running Process that can be monitored and killed. + /// + /// Whether to use Microsoft.Android.Run tool which waits for app exit and streams logcat. + /// A running Process instance. Caller is responsible for disposing. + public Process StartRun (bool waitForExit = true) + { + string binlog = Path.Combine (Path.GetDirectoryName (projectOrSolution), "run.binlog"); + var arguments = new List { + "run", + "--project", $"\"{projectOrSolution}\"", + "--no-build", + $"/bl:\"{binlog}\"", + $"/p:WaitForExit={waitForExit.ToString (CultureInfo.InvariantCulture)}" + }; + + return ExecuteProcess (arguments.ToArray ()); + } + public IEnumerable LastBuildOutput { get { if (!string.IsNullOrEmpty (BuildLogFile) && File.Exists (BuildLogFile)) { diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index b0a1594b404..7bfdd15f270 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Text; +using System.Threading; using System.Xml.Linq; using System.Xml.XPath; using Microsoft.VisualStudio.TestPlatform.Utilities; @@ -53,6 +54,73 @@ public void DotNetRun (bool isRelease, string typemapImplementation) Assert.IsTrue (didLaunch, "Activity should have started."); } + [Test] + public void DotNetRunWaitForExit () + { + const string logcatMessage = "DOTNET_RUN_TEST_MESSAGE_12345"; + var proj = new XamarinAndroidApplicationProject (); + + // Enable verbose output from Microsoft.Android.Run for debugging + proj.SetProperty ("_AndroidRunExtraArgs", "--verbose"); + + // Add a Console.WriteLine that will appear in logcat + proj.MainActivity = proj.DefaultMainActivity.Replace ( + "//${AFTER_ONCREATE}", + $"Console.WriteLine (\"{logcatMessage}\");"); + + 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"); + + // Start dotnet run with WaitForExit=true, which uses Microsoft.Android.Run + using var process = dotnet.StartRun (); + + var locker = new Lock (); + var output = new StringBuilder (); + var outputReceived = new ManualResetEventSlim (false); + bool foundMessage = false; + + process.OutputDataReceived += (sender, e) => { + if (e.Data != null) { + lock (locker) { + output.AppendLine (e.Data); + if (e.Data.Contains (logcatMessage)) { + foundMessage = true; + outputReceived.Set (); + } + } + } + }; + process.ErrorDataReceived += (sender, e) => { + if (e.Data != null) { + lock (locker) { + output.AppendLine ($"STDERR: {e.Data}"); + } + } + }; + + process.BeginOutputReadLine (); + process.BeginErrorReadLine (); + + // Wait for the expected message or timeout + bool messageFound = outputReceived.Wait (TimeSpan.FromSeconds (60)); + + // Kill the process (simulating Ctrl+C) + if (!process.HasExited) { + process.Kill (entireProcessTree: true); + process.WaitForExit (); + } + + // Write the output to a log file for debugging + string logPath = Path.Combine (Root, builder.ProjectDirectory, "dotnet-run-output.log"); + File.WriteAllText (logPath, output.ToString ()); + TestContext.AddTestAttachment (logPath); + + Assert.IsTrue (foundMessage, $"Expected message '{logcatMessage}' was not found in output. See {logPath} for details."); + } + [Test] [TestCase (true)] [TestCase (false)]