From d10c1560a45a0023764bbc779bc88f5c935dfcfb Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 25 Feb 2026 14:13:45 -0600 Subject: [PATCH 1/3] [xabt] support `@(RuntimeEnvironmentVariable)` items (#10770) Context: https://github.com/dotnet/sdk/commit/bd5d3af9804ba358bf3f2e1be209418bcc949d16 `dotnet run` now passes in `dotnet run -e FOO=BAR` as `@(RuntimeEnvironmentVariable)` MSBuild items. To opt in to this new feature, we need to add: As well as update the `_GenerateEnvironmentFiles` MSBuild target: <_GeneratedAndroidEnvironment Include="@(RuntimeEnvironmentVariable->'%(Identity)=%(Value)')" /> I added a new test to verify we have the env vars on-device at runtime. Note that I tested this in combination with a local .NET SDK build: * https://github.com/dotnet/android/pull/10769 We won't be able to merge this until we have a .NET SDK here that includes the above commit. Merging with nightly .NET 10.0.3xx SDK. --- .../docs-mobile/building-apps/build-items.md | 26 ++++++++++++ ...ft.Android.Sdk.ProjectCapabilities.targets | 1 + .../Xamarin.ProjectTools/Common/DotNetCLI.cs | 20 +++++----- .../Xamarin.Android.Common.targets | 2 + .../Tests/InstallAndRunTests.cs | 40 ++++++++++++++++++- 5 files changed, 77 insertions(+), 12 deletions(-) diff --git a/Documentation/docs-mobile/building-apps/build-items.md b/Documentation/docs-mobile/building-apps/build-items.md index 02b7f053adc..a978ac0e318 100644 --- a/Documentation/docs-mobile/building-apps/build-items.md +++ b/Documentation/docs-mobile/building-apps/build-items.md @@ -581,3 +581,29 @@ this build action, see These files are ignored unless the [`$(EnableProguard)`](/xamarin/android/deploy-test/building-apps/build-properties#enableproguard) MSBuild property is `True`. + +## RuntimeEnvironmentVariable + +`@(RuntimeEnvironmentVariable)` items allow environment variables to be +passed to the Android application at runtime via `dotnet run -e`. For example: + +```sh +dotnet run -e DOTNET_RUN_FOO=TestValue123 -e DOTNET_RUN_BAR=AnotherValue456 +``` + +These items are automatically populated by the .NET SDK when using +`dotnet run -e NAME=VALUE` and are included in the generated +environment file during the build. Each item's `%(Identity)` is the +variable name and `%(Value)` is the variable value. + +```xml + + + +``` + +This feature is only available for Android application projects and +requires a .NET SDK that supports the +`RuntimeEnvironmentVariableSupport` project capability. + +This build item was introduced in .NET 10.0.300 SDK and .NET 11. diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.ProjectCapabilities.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.ProjectCapabilities.targets index a8b04059e23..fef1c290dfb 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.ProjectCapabilities.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.ProjectCapabilities.targets @@ -17,6 +17,7 @@ Docs about @(ProjectCapability): + 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 eec0361c108..de8fe033cf7 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 @@ -136,20 +136,20 @@ public bool Publish (string target = null, string runtimeIdentifier = null, stri return Execute (arguments.ToArray ()); } - public bool Run (bool waitForExit = false, string [] parameters = null) + public bool Run (bool waitForExit = false, bool noBuild = true, string [] parameters = null) { 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)}" }; + if (noBuild) { + arguments.Add ("--no-build"); + } + arguments.Add ($"/bl:\"{binlog}\""); + arguments.Add ($"/p:WaitForExit={waitForExit.ToString (CultureInfo.InvariantCulture)}"); if (parameters != null) { - foreach (var parameter in parameters) { - arguments.Add ($"/p:{parameter}"); - } + arguments.AddRange (parameters); } return Execute (arguments.ToArray ()); } @@ -158,7 +158,7 @@ public bool Run (bool waitForExit = false, string [] parameters = null) /// 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. - /// Optional MSBuild properties to pass (e.g., "Device=emulator-5554"). + /// Additional arguments to pass to `dotnet run`. /// A running Process instance. Caller is responsible for disposing. public Process StartRun (bool waitForExit = true, string [] parameters = null) { @@ -171,9 +171,7 @@ public Process StartRun (bool waitForExit = true, string [] parameters = null) $"/p:WaitForExit={waitForExit.ToString (CultureInfo.InvariantCulture)}" }; if (parameters != null) { - foreach (var parameter in parameters) { - arguments.Add ($"/p:{parameter}"); - } + arguments.AddRange (parameters); } return ExecuteProcess (arguments.ToArray ()); diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index 9d27230fa99..e2cd6eeca42 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -1591,6 +1591,8 @@ because xbuild doesn't support framework reference assemblies. <_GeneratedAndroidEnvironment Include="mono.enable_assembly_preload=0" Condition=" '$(AndroidEnablePreloadAssemblies)' != 'True' " /> <_GeneratedAndroidEnvironment Include="DOTNET_MODIFIABLE_ASSEMBLIES=Debug" Condition=" '$(AndroidIncludeDebugSymbols)' == 'true' and '$(AndroidUseInterpreter)' == 'true' " /> <_GeneratedAndroidEnvironment Include="DOTNET_DiagnosticPorts=$(DiagnosticConfiguration)" Condition=" '$(DiagnosticConfiguration)' != '' " /> + + <_GeneratedAndroidEnvironment Include="@(RuntimeEnvironmentVariable->'%(Identity)=%(Value)')" /> Date: Mon, 9 Mar 2026 16:46:41 -0500 Subject: [PATCH 2/3] [xabt] `dotnet watch` support, based on env vars (#10778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: https://github.com/dotnet/sdk/issues/52492 Context: https://github.com/dotnet/sdk/pull/52581 `dotnet-watch` now runs Android applications via: dotnet watch 🚀 [helloandroid (net10.0-android)] Launched 'D:\src\xamarin-android\bin\Debug\dotnet\dotnet.exe' with arguments 'run --no-build -e DOTNET_WATCH=1 -e DOTNET_WATCH_ITERATION=1 -e DOTNET_MODIFIABLE_ASSEMBLIES=debug -e DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT=ws://localhost:9000 -e DOTNET_STARTUP_HOOKS=D:\src\xamarin-android\bin\Debug\dotnet\sdk\10.0.300-dev\DotnetTools\dotnet-watch\10.0.300-dev\tools\net10.0\any\hotreload\net10.0\Microsoft.Extensions.DotNetDeltaApplier.dll -bl': process id 3356 And so the pieces on Android for this to work are: ~~ Startup Hook Assembly ~~ Parse out the value: <_AndroidHotReloadAgentAssemblyPath>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_STARTUP_HOOKS')->'%(Value)'->Exists()) And verify this assembly is included in the app: Then, for Android, we need to patch up `$DOTNET_STARTUP_HOOKS` to be just the assembly name, not the full path: <_AndroidHotReloadAgentAssemblyName>$([System.IO.Path]::GetFileNameWithoutExtension('$(_AndroidHotReloadAgentAssemblyPath)')) ... ~~ Port Forwarding ~~ A new `_AndroidConfigureAdbReverse` target runs after deploying apps, that does: adb reverse tcp:9000 tcp:9000 I parsed the value out of: <_AndroidWebSocketEndpoint>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT')->'%(Value)') <_AndroidWebSocketPort>$([System.Text.RegularExpressions.Regex]::Match('$(_AndroidWebSocketEndpoint)', ':(\d+)').Groups[1].Value) ~~ Prevent Startup Hooks in Microsoft.Android.Run ~~ When I was implementing this, I keep seeing *two* clients connect to `dotnet-watch` and I was pulling my hair to figure out why! Then I realized that `Microsoft.Android.Run` was also getting `$DOTNET_STARTUP_HOOKS`, and so we had a desktop process + mobile process both trying to connect! Easiest fix, is to disable startup hook support in `Microsoft.Android.Run`. I reviewed the code in `dotnet run`, and it doesn't seem correct to try to clear the env vars. ~~ Conclusion ~~ With these changes, everything is working! dotnet watch 🔥 C# and Razor changes applied in 23ms. --- .github/copilot-instructions.md | 15 ++ .../Microsoft.Android.Run.csproj | 1 + .../Microsoft.Android.Sdk.After.targets | 1 + .../Microsoft.Android.Sdk.Application.targets | 14 +- ...oft.Android.Sdk.AssemblyResolution.targets | 3 +- .../Microsoft.Android.Sdk.BuildOrder.targets | 5 + .../Microsoft.Android.Sdk.HotReload.targets | 84 ++++++++++++ ...ft.Android.Sdk.ProjectCapabilities.targets | 1 + .../Xamarin.ProjectTools/Common/DotNetCLI.cs | 28 +++- .../Common/ProjectBuilder.cs | 13 +- .../Xamarin.Android.Common.targets | 2 +- .../Tests/InstallAndRunTests.cs | 128 ++++++++++++++++++ 12 files changed, 284 insertions(+), 11 deletions(-) create mode 100644 src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.HotReload.targets diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3937d5be13c..dc057a1e03e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -164,6 +164,21 @@ try { } ``` +## Testing + +**Modifying project files in tests:** Never use `File.WriteAllText()` directly to update project source files. Instead, use the `Xamarin.ProjectTools` infrastructure: + +```csharp +// 1. Update the in-memory content +proj.MainActivity = proj.MainActivity.Replace ("old text", "new text"); +// 2. Bump the timestamp so UpdateProjectFiles knows it changed +proj.Touch ("MainActivity.cs"); +// 3. Write to disk (doNotCleanupOnUpdate preserves other files, saveProject: false skips .csproj regeneration) +builder.Save (proj, doNotCleanupOnUpdate: true, saveProject: false); +``` + +This pattern ensures proper encoding, timestamps, and file attributes are handled correctly. The `Touch` + `Save` pattern is used throughout the test suite for incremental builds and file modifications. + ## Error Patterns - **MSBuild Errors:** `XA####` (errors), `XA####` (warnings), `APT####` (Android tools) - **Logging:** Use `Log.LogError`, `Log.LogWarning` with error codes and context diff --git a/src/Microsoft.Android.Run/Microsoft.Android.Run.csproj b/src/Microsoft.Android.Run/Microsoft.Android.Run.csproj index de8fb6bf5c8..4d83d44e3c2 100644 --- a/src/Microsoft.Android.Run/Microsoft.Android.Run.csproj +++ b/src/Microsoft.Android.Run/Microsoft.Android.Run.csproj @@ -11,6 +11,7 @@ enable portable Major + false diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.After.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.After.targets index 06f9355341e..bfaf48a1bec 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.After.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.After.targets @@ -32,4 +32,5 @@ This file is imported *after* the Microsoft.NET.Sdk/Sdk.targets. + diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Application.targets index a4c9d65f801..6ce8c27ba5e 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 @@ -26,6 +26,7 @@ This file contains targets specific for Android application projects. <_AndroidComputeRunArgumentsDependsOn Condition=" '$(_AndroidComputeRunArgumentsDependsOn)' == '' "> _ResolveMonoAndroidSdks; _GetAndroidPackageName; + _AndroidAdbToolPath; @@ -86,6 +87,15 @@ This file contains targets specific for Android application projects. + + + <_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)')) + + + @@ -95,10 +105,6 @@ This file contains targets specific for Android application projects. - <_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) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets index ed73ea268e4..9d90d3661f7 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets @@ -84,7 +84,7 @@ _ResolveAssemblies MSBuild target. - + <_RIDs Include="$(RuntimeIdentifier)" Condition=" '$(RuntimeIdentifiers)' == '' " /> <_RIDs Include="$(RuntimeIdentifiers)" Condition=" '$(RuntimeIdentifiers)' != '' " /> @@ -130,6 +130,7 @@ _ResolveAssemblies MSBuild target. + diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.BuildOrder.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.BuildOrder.targets index 039bc54f78a..9bb66ab4073 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.BuildOrder.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.BuildOrder.targets @@ -99,6 +99,7 @@ properties that determine build ordering. SignAndroidPackage; _DeployApk; _DeployAppBundle; + _AndroidConfigureAdbReverse; AndroidPrepareForBuild; @@ -127,12 +128,16 @@ properties that determine build ordering. $(_MinimalSignAndroidPackageDependsOn); + _GenerateEnvironmentFiles; _Upload; + _AndroidConfigureAdbReverse; $(_MinimalSignAndroidPackageDependsOn); + _GenerateEnvironmentFiles; _DeployApk; _DeployAppBundle; + _AndroidConfigureAdbReverse; diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.HotReload.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.HotReload.targets new file mode 100644 index 00000000000..0e887dcf6c6 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.HotReload.targets @@ -0,0 +1,84 @@ + + + + + + + + + + <_AndroidHotReloadAgentAssemblyPath>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_STARTUP_HOOKS')->'%(Value)'->Exists()) + + + + + <_AndroidHotReloadAgentAssemblyName>$([System.IO.Path]::GetFileNameWithoutExtension('$(_AndroidHotReloadAgentAssemblyPath)')) + + + + + + + + + + + + + + + + + + <_AndroidWebSocketEndpoint>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT')->'%(Value)') + + + + + <_AndroidWebSocketPort>$([System.UriBuilder]::new('$(_AndroidWebSocketEndpoint)').Port) + + <_AndroidWebSocketPort Condition=" '$(_AndroidWebSocketPort)' == '-1' "> + + + + + + + + diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.ProjectCapabilities.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.ProjectCapabilities.targets index fef1c290dfb..502c2460fe7 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.ProjectCapabilities.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.ProjectCapabilities.targets @@ -17,6 +17,7 @@ Docs about @(ProjectCapability): + 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 de8fe033cf7..9f4ebc9a1e0 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 @@ -32,7 +32,7 @@ public DotNetCLI (string projectOrSolution) /// /// command arguments /// A started Process instance. Caller is responsible for disposing. - protected Process ExecuteProcess (params string [] args) + protected Process ExecuteProcess (string [] args, string workingDirectory = null) { var p = new Process (); p.StartInfo.FileName = Path.Combine (TestEnvironment.DotNetPreviewDirectory, "dotnet"); @@ -41,6 +41,9 @@ protected Process ExecuteProcess (params string [] args) p.StartInfo.UseShellExecute = false; p.StartInfo.RedirectStandardOutput = true; p.StartInfo.RedirectStandardError = true; + if (!string.IsNullOrEmpty (workingDirectory)) { + p.StartInfo.WorkingDirectory = workingDirectory; + } p.StartInfo.SetEnvironmentVariable ("DOTNET_MULTILEVEL_LOOKUP", "0"); // Workaround for dotnet/msbuild#13175: the MSBuild app host needs DOTNET_HOST_PATH // to bootstrap the .NET runtime when spawning TaskHostFactory task hosts (e.g. ILLink). @@ -177,6 +180,29 @@ public Process StartRun (bool waitForExit = true, string [] parameters = null) return ExecuteProcess (arguments.ToArray ()); } + /// + /// Starts `dotnet watch` and returns a running Process that can be monitored and killed. + /// This is used for hot reload testing where dotnet-watch builds, deploys, and watches for file changes. + /// + /// Additional arguments to pass to `dotnet watch`. + /// A running Process instance. Caller is responsible for disposing. + public Process StartWatch (string [] parameters = null) + { + var arguments = new List { + "watch", + "--project", $"\"{projectOrSolution}\"", + "--non-interactive", + "--verbose", + "--verbosity", "diag", + "-bl", + }; + if (parameters != null) { + arguments.AddRange (parameters); + } + + return ExecuteProcess (arguments.ToArray (), workingDirectory: ProjectDirectory); + } + public IEnumerable LastBuildOutput { get { if (!string.IsNullOrEmpty (BuildLogFile) && File.Exists (BuildLogFile)) { diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/ProjectBuilder.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/ProjectBuilder.cs index f0345a776e2..db607aa6475 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/ProjectBuilder.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/ProjectBuilder.cs @@ -74,9 +74,14 @@ protected override void Dispose (bool disposing) Cleanup (); } - bool built_before; bool last_build_result; + /// + /// Indicates whether the project has been built at least once. + /// Set to true after Build(), or manually when using external build tools (e.g. DotNetCLI.StartWatch). + /// + public bool BuiltBefore { get; set; } + /// /// Gets the build output from the last build operation. /// @@ -97,7 +102,7 @@ public void Save (XamarinProject project, bool doNotCleanupOnUpdate = false, boo { var files = project.Save (saveProject); - if (!built_before) { + if (!BuiltBefore) { if (project.ShouldPopulate) { if (Directory.Exists (ProjectDirectory)) { FileSystemUtils.SetDirectoryWriteable (ProjectDirectory); @@ -130,7 +135,7 @@ public bool Build (XamarinProject project, bool doNotCleanupOnUpdate = false, st Output = project.CreateBuildOutput (this); bool result = BuildInternal (Path.Combine (ProjectDirectory, project.ProjectFilePath), Target, parameters, environmentVariables, restore: project.ShouldRestorePackageReferences, binlogName: Path.GetFileNameWithoutExtension (BuildLogFile)); - built_before = true; + BuiltBefore = true; if (CleanupAfterSuccessfulBuild) Cleanup (); @@ -194,7 +199,7 @@ public void Cleanup () //logs if (!last_build_result) return; - built_before = false; + BuiltBefore = false; var projectDirectory = Path.Combine (XABuildPaths.TestOutputDirectory, ProjectDirectory); if (Directory.Exists (projectDirectory)) { diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index e2cd6eeca42..84e1a46857c 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -1586,7 +1586,7 @@ because xbuild doesn't support framework reference assemblies. - + <_GeneratedAndroidEnvironment Include="mono.enable_assembly_preload=0" Condition=" '$(AndroidEnablePreloadAssemblies)' != 'True' " /> <_GeneratedAndroidEnvironment Include="DOTNET_MODIFIABLE_ASSEMBLIES=Debug" Condition=" '$(AndroidIncludeDebugSymbols)' == 'true' and '$(AndroidUseInterpreter)' == 'true' " /> diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index 650d9c972d2..03ff03895a0 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -201,6 +201,108 @@ public void DotNetRunWithDeviceParameter () Assert.IsTrue (foundMessage, $"Expected message '{logcatMessage}' was not found in output. See {logPath} for details."); } + [Test] + public void DotNetWatchHotReload () + { + const string initialMessage = "DOTNET_WATCH_INITIAL_12345"; + const string hotReloadMessage = "DOTNET_WATCH_HOT_RELOAD_APPLIED"; + + var proj = new XamarinAndroidApplicationProject (); + proj.SetRuntime (AndroidRuntime.CoreCLR); // CoreCLR only for now, as MonoVM requires: https://github.com/dotnet/runtime/commit/c8e2a6110c69601540c25f2099053505fa088b9e + + // Enable hot reload log messages from the delta client + proj.OtherBuildItems.Add (new BuildItem ("AndroidEnvironment", "env.txt") { + TextContent = () => "HOTRELOAD_DELTA_CLIENT_LOG_MESSAGES=[HotReload]", + }); + + // Call a helper method from OnCreate that will appear in logcat + proj.MainActivity = proj.DefaultMainActivity.Replace ( + "//${AFTER_ONCREATE}", + "UnderTest.AppHelper.PrintMessage ();"); + + // Add a vanilla C# helper class (no Java interop) that we'll hot-reload, + // and a MetadataUpdateHandler that logs when hot reload is applied. + string appHelperBody = $"""Console.WriteLine ("{initialMessage}");"""; + proj.Sources.Add (new BuildItem.Source ("AppHelper.cs") { + TextContent = () => GetAppHelperSource (appHelperBody, hotReloadMessage), + }); + + using var builder = CreateApkBuilder (); + builder.Save (proj); + + var dotnet = new DotNetCLI (Path.Combine (Root, builder.ProjectDirectory, proj.ProjectFilePath)); + + // Start dotnet watch which will build, deploy, and watch for changes + using var process = dotnet.StartWatch (); + + var locker = new Lock (); + var output = new StringBuilder (); + var initialMessageEvent = new ManualResetEventSlim (); + var hotReloadAppliedEvent = new ManualResetEventSlim (); + + process.OutputDataReceived += (sender, e) => { + if (e.Data != null) { + lock (locker) { + output.AppendLine (e.Data); + if (e.Data.Contains (initialMessage)) { + initialMessageEvent.Set (); + } + if (e.Data.Contains (hotReloadMessage)) { + hotReloadAppliedEvent.Set (); + } + } + } + }; + process.ErrorDataReceived += (sender, e) => { + if (e.Data != null) { + lock (locker) { + output.AppendLine ($"STDERR: {e.Data}"); + if (e.Data.Contains (hotReloadMessage)) { + hotReloadAppliedEvent.Set (); + } + } + } + }; + + process.BeginOutputReadLine (); + process.BeginErrorReadLine (); + + string logPath = Path.Combine (Root, builder.ProjectDirectory, "dotnet-watch-output.log"); + + try { + // Wait for the initial message to appear (app launched and running) + Assert.IsTrue (initialMessageEvent.Wait (TimeSpan.FromMinutes (5)), + $"Initial message '{initialMessage}' was not found in output. See {logPath} for details."); + + // Give dotnet watch time to finish post-deploy setup and start its file watcher. + // There is no explicit "ready" signal from dotnet watch after deploy completes. + Thread.Sleep (5000); + + // Modify the vanilla C# helper class (not the Java-interop MainActivity) + appHelperBody = $""" + Console.WriteLine ("{initialMessage}"); + Console.WriteLine ("MODIFIED_LINE"); + """; + proj.Touch ("AppHelper.cs"); + builder.BuiltBefore = true; // dotnet watch will build, not builder.Build() + builder.Save (proj, doNotCleanupOnUpdate: true, saveProject: false); + + // Wait for hot reload to apply (MetadataUpdateHandler fires Console.WriteLine) + Assert.IsTrue (hotReloadAppliedEvent.Wait (TimeSpan.FromMinutes (2)), + $"Hot reload message '{hotReloadMessage}' was not found in output. See {logPath} for details."); + } finally { + // Kill the process + if (!process.HasExited) { + process.Kill (entireProcessTree: true); + process.WaitForExit (); + } + + // Write the output to a log file for debugging + File.WriteAllText (logPath, output.ToString ()); + TestContext.AddTestAttachment (logPath); + } + } + [Test] [TestCase (true)] [TestCase (false)] @@ -1765,5 +1867,31 @@ public void StartAndroidActivityRespectsAndroidDeviceUserId () StringAssertEx.ContainsRegex (@"am start.*--user 0", builder.LastBuildOutput, "The 'am start' command should contain '--user 0' when AndroidDeviceUserId is set."); } + + static string GetAppHelperSource (string appHelperBody, string hotReloadMessage) => $$""" + using System; + + [assembly: System.Reflection.Metadata.MetadataUpdateHandlerAttribute (typeof (UnderTest.HotReloadService))] + + namespace UnderTest + { + public static class AppHelper + { + public static void PrintMessage () + { + {{appHelperBody}} + } + } + + public static class HotReloadService + { + internal static void ClearCache (Type[]? types) { } + internal static void UpdateApplication (Type[]? types) + { + Console.WriteLine ("{{hotReloadMessage}}"); + } + } + } + """; } } From 536af420dc73d3e776beb4ddf9f6ab51d70b9342 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Tue, 17 Mar 2026 13:24:42 -0500 Subject: [PATCH 3/3] Include $(AdbTarget) in adb reverse command When multiple devices/emulators are connected, the adb reverse command needs (e.g. -s emulator-5554) to target the correct device. Without it, the command fails with 'more than one device' or forwards on the wrong device. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../targets/Microsoft.Android.Sdk.HotReload.targets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.HotReload.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.HotReload.targets index 0e887dcf6c6..a1f29e119d7 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.HotReload.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.HotReload.targets @@ -76,7 +76,7 @@ See: https://github.com/dotnet/sdk/pull/52581 + Command=""$(_AdbToolPath)" $(AdbTarget) reverse tcp:$(_AndroidWebSocketPort) tcp:$(_AndroidWebSocketPort)" />