diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 32b29dd094f..b472edfe8ea 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -150,6 +150,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 364ff7732a4..df2d125fd43 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 9cb93fb2e06..01a64d12687 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
@@ -33,4 +33,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 14edfd0270a..8d266e6b253 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
@@ -25,6 +25,7 @@ This file contains targets specific for Android application projects.
<_AndroidComputeRunArgumentsDependsOn Condition=" '$(_AndroidComputeRunArgumentsDependsOn)' == '' and $([MSBuild]::VersionGreaterThanOrEquals($(NetCoreSdkVersion), 10.0.300)) ">
_ResolveMonoAndroidSdks;
_GetAndroidPackageName;
+ _AndroidAdbToolPath;
<_AndroidComputeRunArgumentsDependsOn Condition=" '$(_AndroidComputeRunArgumentsDependsOn)' == '' ">
Install;
@@ -52,6 +53,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)'))
+
+
+
@@ -61,10 +71,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 9cc9b0e77b7..4c816f08a44 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
@@ -73,7 +73,7 @@ _ResolveAssemblies MSBuild target.
-
+
<_RIDs Include="$(RuntimeIdentifier)" Condition=" '$(RuntimeIdentifiers)' == '' " />
<_RIDs Include="$(RuntimeIdentifiers)" Condition=" '$(RuntimeIdentifiers)' != '' " />
@@ -119,6 +119,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 46fbc3ecae4..a3f0ae63fe8 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
@@ -31,7 +31,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");
@@ -40,6 +40,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");
p.StartInfo.SetEnvironmentVariable ("PATH", TestEnvironment.DotNetPreviewDirectory + Path.PathSeparator + Environment.GetEnvironmentVariable ("PATH"));
if (TestEnvironment.UseLocalBuildOutput) {
@@ -172,6 +175,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 f01393f6565..7a55d177bed 100644
--- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets
+++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets
@@ -1773,7 +1773,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 11be3075d1c..eeaa4f94702 100644
--- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs
+++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs
@@ -198,6 +198,121 @@ 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]",
+ });
+
+ // Add a Console.WriteLine that will appear in logcat
+ proj.MainActivity = proj.DefaultMainActivity.Replace (
+ "//${AFTER_ONCREATE}",
+ $"Console.WriteLine (\"{initialMessage}\");");
+
+ // Add a MetadataUpdateHandler that logs when hot reload is applied
+ proj.Sources.Add (new BuildItem.Source ("HotReloadService.cs") {
+ TextContent = () =>
+@"using System;
+
+[assembly: System.Reflection.Metadata.MetadataUpdateHandlerAttribute (typeof (UnderTest.HotReloadService))]
+
+namespace UnderTest
+{
+ public static class HotReloadService
+ {
+ internal static void ClearCache (Type[]? types) { }
+ internal static void UpdateApplication (Type[]? types)
+ {
+ Console.WriteLine (""" + 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 source file to trigger hot reload
+ proj.MainActivity = proj.MainActivity.Replace (
+ $"Console.WriteLine (\"{initialMessage}\");",
+ $"Console.WriteLine (\"{initialMessage}\");\n\t\t\tConsole.WriteLine (\"MODIFIED_LINE\");");
+ proj.Touch ("MainActivity.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)]