Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/Microsoft.Android.Run/Microsoft.Android.Run.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<Nullable>enable</Nullable>
<DebugType>portable</DebugType>
<RollForward>Major</RollForward>
<StartupHookSupport>false</StartupHookSupport>
</PropertyGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,5 @@ This file is imported *after* the Microsoft.NET.Sdk/Sdk.targets.
<Import Project="Microsoft.Android.Sdk.Publish.targets" />
<Import Project="Microsoft.Android.Sdk.RuntimeConfig.targets" />
<Import Project="Microsoft.Android.Sdk.Tooling.targets" />
<Import Project="Microsoft.Android.Sdk.HotReload.targets" Condition=" '$(AndroidApplication)' == 'true' " />
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -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>
<_AndroidComputeRunArgumentsDependsOn Condition=" '$(_AndroidComputeRunArgumentsDependsOn)' == '' ">
Install;
Expand Down Expand Up @@ -52,6 +53,15 @@ This file contains targets specific for Android application projects.
</GetAvailableAndroidDevices>
</Target>

<Target Name="_AndroidAdbToolPath">
<PropertyGroup>
<_AdbToolPath>$(AdbToolExe)</_AdbToolPath>
<_AdbToolPath Condition=" '$(_AdbToolPath)' == '' and $([MSBuild]::IsOSPlatform('windows')) ">adb.exe</_AdbToolPath>
<_AdbToolPath Condition=" '$(_AdbToolPath)' == '' and !$([MSBuild]::IsOSPlatform('windows')) ">adb</_AdbToolPath>
<_AdbToolPath>$([System.IO.Path]::Combine ('$(AdbToolPath)', '$(_AdbToolPath)'))</_AdbToolPath>
</PropertyGroup>
</Target>

<Target Name="_AndroidComputeRunArguments"
BeforeTargets="ComputeRunArguments"
DependsOnTargets="$(_AndroidComputeRunArgumentsDependsOn)">
Expand All @@ -61,10 +71,6 @@ This file contains targets specific for Android application projects.
<Output TaskParameter="ActivityName" PropertyName="AndroidLaunchActivity" />
</GetAndroidActivityName>
<PropertyGroup>
<_AdbToolPath>$(AdbToolExe)</_AdbToolPath>
<_AdbToolPath Condition=" '$(_AdbToolPath)' == '' and $([MSBuild]::IsOSPlatform('windows')) ">adb.exe</_AdbToolPath>
<_AdbToolPath Condition=" '$(_AdbToolPath)' == '' and !$([MSBuild]::IsOSPlatform('windows')) ">adb</_AdbToolPath>
<_AdbToolPath>$([System.IO.Path]::Combine ('$(AdbToolPath)', '$(_AdbToolPath)'))</_AdbToolPath>
<RunWorkingDirectory>$(MSBuildProjectDirectory)</RunWorkingDirectory>
</PropertyGroup>
<!-- By default, use `Microsoft.Android.Run` tool to stream logcat and handle Ctrl+C -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ _ResolveAssemblies MSBuild target.
</ItemGroup>
</Target>

<Target Name="_ResolveAssemblies">
<Target Name="_ResolveAssemblies" DependsOnTargets="_AndroidConfigureHotReloadEnvironment">
<ItemGroup>
<_RIDs Include="$(RuntimeIdentifier)" Condition=" '$(RuntimeIdentifiers)' == '' " />
<_RIDs Include="$(RuntimeIdentifiers)" Condition=" '$(RuntimeIdentifiers)' != '' " />
Expand Down Expand Up @@ -119,6 +119,7 @@ _ResolveAssemblies MSBuild target.
</ProcessRuntimePackLibraryDirectories>

<ItemGroup>
<ResolvedFileToPublish Include="$(_AndroidHotReloadAgentAssemblyPath)" Condition="Exists('$(_AndroidHotReloadAgentAssemblyPath)')" RuntimeIdentifier="%(_RIDs.Identity)" />
<ResolvedFileToPublish Remove="@(_NativeLibraryToRemove)" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ properties that determine build ordering.
SignAndroidPackage;
_DeployApk;
_DeployAppBundle;
_AndroidConfigureAdbReverse;
</InstallDependsOnTargets>
<UninstallDependsOnTargets>
AndroidPrepareForBuild;
Expand Down Expand Up @@ -127,12 +128,16 @@ properties that determine build ordering.
<!-- This should function similar to SignAndroidPackage inside VS plus also deploy -->
<DeployToDeviceDependsOnTargets Condition=" '$(_AndroidFastDeploymentSupported)' == 'true' ">
$(_MinimalSignAndroidPackageDependsOn);
_GenerateEnvironmentFiles;
_Upload;
_AndroidConfigureAdbReverse;
</DeployToDeviceDependsOnTargets>
<DeployToDeviceDependsOnTargets Condition=" '$(_AndroidFastDeploymentSupported)' != 'true' ">
$(_MinimalSignAndroidPackageDependsOn);
_GenerateEnvironmentFiles;
_DeployApk;
_DeployAppBundle;
_AndroidConfigureAdbReverse;
</DeployToDeviceDependsOnTargets>
</PropertyGroup>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<!--
***********************************************************************************************
Microsoft.Android.Sdk.HotReload.targets

This file contains targets for Hot Reload support in .NET for Android.
These targets are invoked by dotnet-watch when Hot Reload starts.

dotnet-watch passes environment variables via @(RuntimeEnvironmentVariable) items:
- DOTNET_STARTUP_HOOKS: Path to Microsoft.Extensions.DotNetDeltaApplier.dll
- DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT: WebSocket endpoint URL for hot reload communication

The environment variables are written to the app by the _GenerateEnvironmentFiles target
in Xamarin.Android.Common.targets, which includes @(RuntimeEnvironmentVariable) items.

See: https://github.com/dotnet/sdk/issues/52492
See: https://github.com/dotnet/sdk/pull/52581

***********************************************************************************************
-->

<Project>

<!--
_AndroidConfigureHotReloadEnvironment:
Configures Hot Reload when dotnet-watch passes environment variables via @(RuntimeEnvironmentVariable).
- Adds the Hot Reload agent DLL as a reference so it gets deployed
- Updates DOTNET_STARTUP_HOOKS to use just the assembly name (not the full path)
- Sets up STARTUP_HOOKS via RuntimeHostConfigurationOption for MonoVM
-->
<Target Name="_AndroidConfigureHotReloadEnvironment"
Condition=" '@(RuntimeEnvironmentVariable)' != '' ">

<!-- Extract the startup hooks value from @(RuntimeEnvironmentVariable) -->
<PropertyGroup>
<_AndroidHotReloadAgentAssemblyPath>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_STARTUP_HOOKS')->'%(Value)'->Exists())</_AndroidHotReloadAgentAssemblyPath>
</PropertyGroup>

<!-- Extract just the assembly name for STARTUP_HOOKS config -->
<PropertyGroup Condition=" '$(_AndroidHotReloadAgentAssemblyPath)' != '' ">
<_AndroidHotReloadAgentAssemblyName>$([System.IO.Path]::GetFileNameWithoutExtension('$(_AndroidHotReloadAgentAssemblyPath)'))</_AndroidHotReloadAgentAssemblyName>
</PropertyGroup>

<!--
Update DOTNET_STARTUP_HOOKS in @(RuntimeEnvironmentVariable) to use just the assembly name.
The full path doesn't work on Android since the DLL is deployed alongside the app.
-->
<ItemGroup Condition=" '$(_AndroidHotReloadAgentAssemblyName)' != '' ">
<RuntimeEnvironmentVariable Remove="DOTNET_STARTUP_HOOKS" />
<RuntimeEnvironmentVariable Include="DOTNET_STARTUP_HOOKS" Value="$(_AndroidHotReloadAgentAssemblyName)" />
<!-- Set STARTUP_HOOKS via RuntimeHostConfigurationOption for MonoVM (read by Mono runtime) -->
<RuntimeHostConfigurationOption Include="STARTUP_HOOKS" Value="$(_AndroidHotReloadAgentAssemblyName)" Condition=" '$(UseMonoRuntime)' == 'true' " />
Comment on lines +50 to +51
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

</ItemGroup>

</Target>

<!--
_AndroidConfigureAdbReverse:
Sets up adb reverse port forwarding when using WebSocket endpoint from @(RuntimeEnvironmentVariable).
Extracts the port from DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT and sets up reverse forwarding.
This allows the device/emulator to connect back to the host machine.
-->
<Target Name="_AndroidConfigureAdbReverse"
Condition=" '@(RuntimeEnvironmentVariable)' != '' "
DependsOnTargets="_AndroidAdbToolPath">

<!-- Extract the WebSocket endpoint URL and parse the port -->
<PropertyGroup>
<_AndroidWebSocketEndpoint>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT')->'%(Value)')</_AndroidWebSocketEndpoint>
</PropertyGroup>

<!-- Parse port from WebSocket URL (e.g., ws://localhost:9000 or http://localhost:9000) -->
<PropertyGroup Condition=" '$(_AndroidWebSocketEndpoint)' != '' ">
<_AndroidWebSocketPort>$([System.UriBuilder]::new('$(_AndroidWebSocketEndpoint)').Port)</_AndroidWebSocketPort>
<!-- UriBuilder.Port returns -1 when no port is specified -->
<_AndroidWebSocketPort Condition=" '$(_AndroidWebSocketPort)' == '-1' "></_AndroidWebSocketPort>
</PropertyGroup>

<Exec Condition=" '$(_AndroidWebSocketPort)' != '' "
Command="&quot;$(_AdbToolPath)&quot; reverse tcp:$(_AndroidWebSocketPort) tcp:$(_AndroidWebSocketPort)" />

</Target>

</Project>

Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Docs about @(ProjectCapability):
<ProjectCapability Include="Mobile" />
<ProjectCapability Include="Android" />
<ProjectCapability Include="AndroidApplication" Condition="$(AndroidApplication)" />
<ProjectCapability Include="HotReloadWebSockets" Condition="$(AndroidApplication)" />
<ProjectCapability Include="RuntimeEnvironmentVariableSupport" Condition="$(AndroidApplication)" />
<ProjectCapability Condition="'$(_KeepLaunchProfiles)' != 'true'" Remove="LaunchProfiles" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public DotNetCLI (string projectOrSolution)
/// </summary>
/// <param name="args">command arguments</param>
/// <returns>A started Process instance. Caller is responsible for disposing.</returns>
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");
Expand All @@ -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) {
Expand Down Expand Up @@ -172,6 +175,29 @@ public Process StartRun (bool waitForExit = true, string [] parameters = null)
return ExecuteProcess (arguments.ToArray ());
}

/// <summary>
/// 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.
/// </summary>
/// <param name="parameters">Additional arguments to pass to `dotnet watch`.</param>
/// <returns>A running Process instance. Caller is responsible for disposing.</returns>
public Process StartWatch (string [] parameters = null)
{
var arguments = new List<string> {
"watch",
"--project", $"\"{projectOrSolution}\"",
"--non-interactive",
"--verbose",
"--verbosity", "diag",
"-bl",
};
if (parameters != null) {
arguments.AddRange (parameters);
}

return ExecuteProcess (arguments.ToArray (), workingDirectory: ProjectDirectory);
}

public IEnumerable<string> LastBuildOutput {
get {
if (!string.IsNullOrEmpty (BuildLogFile) && File.Exists (BuildLogFile)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,14 @@ protected override void Dispose (bool disposing)
Cleanup ();
}

bool built_before;
bool last_build_result;

/// <summary>
/// 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).
/// </summary>
public bool BuiltBefore { get; set; }

/// <summary>
/// Gets the build output from the last build operation.
/// </summary>
Expand All @@ -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);
Expand Down Expand Up @@ -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 ();
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1773,7 +1773,7 @@ because xbuild doesn't support framework reference assemblies.
</PrepareAbiItems>
</Target>

<Target Name="_GenerateEnvironmentFiles">
<Target Name="_GenerateEnvironmentFiles" DependsOnTargets="_AndroidConfigureHotReloadEnvironment">
<ItemGroup>
<_GeneratedAndroidEnvironment Include="mono.enable_assembly_preload=0" Condition=" '$(AndroidEnablePreloadAssemblies)' != 'True' " />
<_GeneratedAndroidEnvironment Include="DOTNET_MODIFIABLE_ASSEMBLIES=Debug" Condition=" '$(AndroidIncludeDebugSymbols)' == 'true' and '$(AndroidUseInterpreter)' == 'true' " />
Expand Down
Loading