diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeApplicationConfigSources.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeApplicationConfigSources.cs index 17a4748d6fa..7d7b2eab820 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeApplicationConfigSources.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeApplicationConfigSources.cs @@ -61,6 +61,7 @@ public class GenerateNativeApplicationConfigSources : AndroidTask public bool AndroidEnableAssemblyStoreDecompressionCache { get; set; } public string? RuntimeConfigBinFilePath { get; set; } public string ProjectRuntimeConfigFilePath { get; set; } = String.Empty; + public string? ProjectRuntimeConfigDevFilePath { get; set; } public string? BoundExceptionType { get; set; } public string? PackageNamingPolicy { get; set; } @@ -266,7 +267,7 @@ static bool ShouldSkipAssembly (ITaskItem assembly) LLVMIR.LlvmIrComposer appConfigAsmGen; if (TargetsCLR) { - Dictionary? runtimeProperties = RuntimePropertiesParser.ParseConfig (ProjectRuntimeConfigFilePath); + Dictionary? runtimeProperties = RuntimePropertiesParser.ParseConfig (ProjectRuntimeConfigFilePath, ProjectRuntimeConfigDevFilePath); appConfigAsmGen = new ApplicationConfigNativeAssemblyGeneratorCLR (envBuilder.EnvironmentVariables, envBuilder.SystemProperties, runtimeProperties, Log) { UsesAssemblyPreload = envBuilder.Parser.UsesAssemblyPreload, AndroidPackageName = AndroidPackageName, diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RuntimePropertiesParserTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RuntimePropertiesParserTests.cs new file mode 100644 index 00000000000..a30ab349da1 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/RuntimePropertiesParserTests.cs @@ -0,0 +1,133 @@ +#nullable enable +using System.Collections.Generic; +using System.IO; + +using NUnit.Framework; +using Xamarin.Android.Tasks; + +namespace Xamarin.Android.Build.Tests.Tasks; + +[TestFixture] +public class RuntimePropertiesParserTests : BaseTest +{ + const string RuntimeConfigJson = """ + { + "runtimeOptions": { + "tfm": "net11.0", + "configProperties": { + "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, + "System.StartupHookProvider.IsSupported": false, + "Microsoft.Android.Runtime.RuntimeFeature.IsAssignableFromCheck": true + } + } + } + """; + + string directory = ""; + string runtimeConfigPath = ""; + string runtimeConfigDevPath = ""; + + [SetUp] + public void SetUp () + { + directory = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (directory); + runtimeConfigPath = Path.Combine (directory, "App.runtimeconfig.json"); + runtimeConfigDevPath = Path.Combine (directory, "App.runtimeconfig.dev.json"); + File.WriteAllText (runtimeConfigPath, RuntimeConfigJson); + } + + static Dictionary Parse (string runtimeConfigPath, string? runtimeConfigDevPath = null) => + RuntimePropertiesParser.ParseConfig (runtimeConfigPath, runtimeConfigDevPath) ?? + throw new AssertionException ($"Could not parse '{runtimeConfigPath}'."); + + [Test] + public void MissingConfigReturnsNull () + { + Assert.IsNull (RuntimePropertiesParser.ParseConfig (Path.Combine (directory, "does-not-exist.json"))); + } + + [Test] + public void ConfigPropertiesAreParsed () + { + var properties = Parse (runtimeConfigPath); + + Assert.AreEqual ("false", properties ["System.Reflection.Metadata.MetadataUpdater.IsSupported"]); + Assert.AreEqual ("true", properties ["Microsoft.Android.Runtime.RuntimeFeature.IsAssignableFromCheck"]); + } + + [Test] + public void DevConfigPropertiesWin () + { + // This is what the .NET SDK emits for `Debug` builds + File.WriteAllText (runtimeConfigDevPath, """ + { + "runtimeOptions": { + "configProperties": { + "System.Reflection.Metadata.MetadataUpdater.IsSupported": true, + "System.StartupHookProvider.IsSupported": true + } + } + } + """); + + var properties = Parse (runtimeConfigPath, runtimeConfigDevPath); + + Assert.AreEqual ("true", properties ["System.Reflection.Metadata.MetadataUpdater.IsSupported"], "Hot Reload should be enabled by the dev file."); + Assert.AreEqual ("true", properties ["System.StartupHookProvider.IsSupported"], "Startup hooks should be enabled by the dev file."); + Assert.AreEqual ("true", properties ["Microsoft.Android.Runtime.RuntimeFeature.IsAssignableFromCheck"], "Properties absent from the dev file should be preserved."); + } + + [Test] + public void DevConfigWithoutConfigPropertiesIsIgnored () + { + // Pre-net6.0 style dev file, which only carries probing paths + File.WriteAllText (runtimeConfigDevPath, """ + { + "runtimeOptions": { + "additionalProbingPaths": [ "/home/user/.nuget/packages" ] + } + } + """); + + var properties = Parse (runtimeConfigPath, runtimeConfigDevPath); + + Assert.AreEqual ("false", properties ["System.Reflection.Metadata.MetadataUpdater.IsSupported"]); + } + + [Test] + public void MissingDevConfigIsIgnored () + { + var properties = Parse (runtimeConfigPath, runtimeConfigDevPath); + + Assert.AreEqual ("false", properties ["System.StartupHookProvider.IsSupported"]); + } + + [Test] + public void ConfigWithoutConfigPropertiesStillGetsDevProperties () + { + // A project that sets no feature switches gets a `*.runtimeconfig.json` without `configProperties` + File.WriteAllText (runtimeConfigPath, """ + { + "runtimeOptions": { + "tfm": "net11.0" + } + } + """); + File.WriteAllText (runtimeConfigDevPath, """ + { + "runtimeOptions": { + "configProperties": { + "System.Reflection.Metadata.MetadataUpdater.IsSupported": true, + "System.StartupHookProvider.IsSupported": true + } + } + } + """); + + var properties = Parse (runtimeConfigPath, runtimeConfigDevPath); + + Assert.AreEqual ("true", properties ["System.Reflection.Metadata.MetadataUpdater.IsSupported"], "Hot Reload should be enabled by the dev file."); + Assert.AreEqual ("true", properties ["System.StartupHookProvider.IsSupported"], "Startup hooks should be enabled by the dev file."); + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/RuntimePropertiesParser.cs b/src/Xamarin.Android.Build.Tasks/Utilities/RuntimePropertiesParser.cs index bf47af55265..4683ad96522 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/RuntimePropertiesParser.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/RuntimePropertiesParser.cs @@ -7,30 +7,56 @@ namespace Xamarin.Android.Tasks; class RuntimePropertiesParser { - public static Dictionary? ParseConfig (string projectRuntimeConfigFilePath) + /// + /// Reads the `configProperties` from `*.runtimeconfig.json`, layering the ones from the + /// companion `*.runtimeconfig.dev.json` on top when it exists. + /// + /// `hostfxr` performs this merge when the app starts, but .NET for Android does not use + /// `hostfxr`: the properties are baked into the app at build time and handed to + /// `coreclr_initialize()`, so the merge has to happen here instead. The .NET SDK uses the + /// dev file to turn on Hot Reload switches for `Debug` builds. + /// + public static Dictionary? ParseConfig (string projectRuntimeConfigFilePath, string? projectRuntimeConfigDevFilePath = null) { if (String.IsNullOrEmpty (projectRuntimeConfigFilePath) || !File.Exists (projectRuntimeConfigFilePath)) { return null; } - using var fs = File.OpenRead (projectRuntimeConfigFilePath); + var ret = new Dictionary (StringComparer.OrdinalIgnoreCase); + AddConfigProperties (projectRuntimeConfigFilePath, ret); + + if (!projectRuntimeConfigDevFilePath.IsNullOrEmpty () && File.Exists (projectRuntimeConfigDevFilePath)) { + // Values from the dev file win, matching `hostfxr` + AddConfigProperties (projectRuntimeConfigDevFilePath, ret); + } + + return ret; + } + + static void AddConfigProperties (string path, Dictionary properties) + { + using var fs = File.OpenRead (path); var jsonOptions = new JsonDocumentOptions { AllowTrailingCommas = true, // yes, please! CommentHandling = JsonCommentHandling.Skip, }; using JsonDocument config = JsonDocument.Parse (fs, jsonOptions); - JsonElement runtimeOptions = config.RootElement.GetProperty ("runtimeOptions"); - JsonElement properties = runtimeOptions.GetProperty ("configProperties"); - var ret = new Dictionary (StringComparer.OrdinalIgnoreCase); - foreach (JsonProperty prop in properties.EnumerateObject ()) { + + // Either file may legitimately omit both keys: the dev file often only carries + // `additionalProbingPaths`, and a project that sets no feature switches produces a + // `*.runtimeconfig.json` without `configProperties`. Treat both as "no properties". + if (!config.RootElement.TryGetProperty ("runtimeOptions", out JsonElement runtimeOptions) || + !runtimeOptions.TryGetProperty ("configProperties", out JsonElement configProperties)) { + return; + } + + foreach (JsonProperty prop in configProperties.EnumerateObject ()) { string? value = GetJsonValueAsString (prop.Value); if (value is not null) { - ret[prop.Name] = value; + properties[prop.Name] = value; } } - - return ret; } static string? GetJsonValueAsString (JsonElement element) => diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index 1e57801422e..a7584e36eef 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -1748,6 +1748,9 @@ because xbuild doesn't support framework reference assemblies. <_GeneratePackageManagerJavaInputs Include="@(_GenerateJavaStubsInputs)" /> <_GeneratePackageManagerJavaInputs Include="$(_XARemapMembersFilePath)" Condition=" '@(_AndroidRemapMembers->Count())' != '0' " /> + + <_GeneratePackageManagerJavaInputs Include="$(ProjectRuntimeConfigFilePath)" Condition="Exists('$(ProjectRuntimeConfigFilePath)')" /> + <_GeneratePackageManagerJavaInputs Include="$(ProjectRuntimeConfigDevFilePath)" Condition="Exists('$(ProjectRuntimeConfigDevFilePath)')" /> @@ -1819,6 +1822,7 @@ because xbuild doesn't support framework reference assemblies. TargetsCLR="$(_AndroidUseCLR)" AndroidRuntime="$(_AndroidRuntime)" ProjectRuntimeConfigFilePath="$(ProjectRuntimeConfigFilePath)" + ProjectRuntimeConfigDevFilePath="$(ProjectRuntimeConfigDevFilePath)" > diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index e339ca33708..7ee5928f1e7 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -1000,6 +1000,57 @@ public void SubscribeToAppDomainUnhandledException ([Values (AndroidRuntime.Core $"Output did not contain {expectedLogcatOutput}!"); } + [Test] + public void RuntimeConfigDevJsonIsApplied () + { + var proj = new XamarinAndroidApplicationProject (packageName: PackageUtils.MakePackageName (AndroidRuntime.CoreCLR)) { + IsRelease = false, + }; + proj.SetRuntime (AndroidRuntime.CoreCLR); + proj.SetRuntimeIdentifiers (new [] {"arm64-v8a", "x86_64"}); + + // The .NET SDK writes these two switches into `*.runtimeconfig.dev.json` for `Debug` builds and + // nowhere else. `hostfxr` layers that file over `*.runtimeconfig.json` at startup, but .NET for + // Android bakes the properties into the app at build time, so seeing them here proves the dev + // file was picked up. Asking for `$(StartupHookSupport)` to be `false` also proves the dev file + // wins over `*.runtimeconfig.json`. + // + // The SDK we currently build against sets `$(GenerateRuntimeConfigDevFile)` to `false` for + // `net6.0`+, so it does not emit the file and leaves `$(ProjectRuntimeConfigDevFilePath)` empty. + // Write the file and point the property at it ourselves. Both of these can be dropped once we + // pick up an SDK containing https://github.com/dotnet/sdk/pull/53715. + const string devFileName = "runtimeconfig.dev.json"; + proj.OtherBuildItems.Add (new BuildItem ("None", devFileName) { + TextContent = () => """ + { + "runtimeOptions": { + "configProperties": { + "System.Reflection.Metadata.MetadataUpdater.IsSupported": true, + "System.StartupHookProvider.IsSupported": true + } + } + } + """, + }); + proj.SetProperty ("ProjectRuntimeConfigDevFilePath", $"$(MSBuildProjectDirectory)/{devFileName}"); + proj.SetProperty ("StartupHookSupport", "false"); + + proj.MainActivity = proj.DefaultMainActivity.Replace ("//${AFTER_ONCREATE}", +@" AppContext.TryGetSwitch (""System.StartupHookProvider.IsSupported"", out bool startupHookProvider); + AppContext.TryGetSwitch (""System.Reflection.Metadata.MetadataUpdater.IsSupported"", out bool metadataUpdater); + Console.WriteLine (""# runtimeconfig.dev.json: StartupHookProvider={0}; MetadataUpdater={1}"", + startupHookProvider, metadataUpdater); +"); + builder = CreateApkBuilder (); + Assert.IsTrue (builder.Install (proj), "Install should have succeeded."); + RunProjectAndAssert (proj, builder); + + const string expectedLogcatOutput = "# runtimeconfig.dev.json: StartupHookProvider=True; MetadataUpdater=True"; + Assert.IsTrue ( + MonitorAdbLogcat (CreateLineChecker (expectedLogcatOutput), + logcatFilePath: Path.Combine (Root, builder.ProjectDirectory, "runtimeconfig-logcat.log"), timeout: 60), + $"Output did not contain {expectedLogcatOutput}!"); + } public static Func CreateLineChecker (string expectedLogcatOutput) {