Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -266,7 +267,7 @@ static bool ShouldSkipAssembly (ITaskItem assembly)
LLVMIR.LlvmIrComposer appConfigAsmGen;

if (TargetsCLR) {
Dictionary<string, string>? runtimeProperties = RuntimePropertiesParser.ParseConfig (ProjectRuntimeConfigFilePath);
Dictionary<string, string>? runtimeProperties = RuntimePropertiesParser.ParseConfig (ProjectRuntimeConfigFilePath, ProjectRuntimeConfigDevFilePath);
appConfigAsmGen = new ApplicationConfigNativeAssemblyGeneratorCLR (envBuilder.EnvironmentVariables, envBuilder.SystemProperties, runtimeProperties, Log) {
UsesAssemblyPreload = envBuilder.Parser.UsesAssemblyPreload,
AndroidPackageName = AndroidPackageName,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string> 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.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,56 @@ namespace Xamarin.Android.Tasks;

class RuntimePropertiesParser
{
public static Dictionary<string, string>? ParseConfig (string projectRuntimeConfigFilePath)
/// <summary>
/// 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.
/// </summary>
public static Dictionary<string, string>? 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<string, string> (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<string, string> 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<string, string> (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) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1748,6 +1748,9 @@ because xbuild doesn't support framework reference assemblies.
<ItemGroup>
<_GeneratePackageManagerJavaInputs Include="@(_GenerateJavaStubsInputs)" />
<_GeneratePackageManagerJavaInputs Include="$(_XARemapMembersFilePath)" Condition=" '@(_AndroidRemapMembers->Count())' != '0' " />
<!-- `configProperties` from these files are baked into the generated native application config -->
<_GeneratePackageManagerJavaInputs Include="$(ProjectRuntimeConfigFilePath)" Condition="Exists('$(ProjectRuntimeConfigFilePath)')" />
<_GeneratePackageManagerJavaInputs Include="$(ProjectRuntimeConfigDevFilePath)" Condition="Exists('$(ProjectRuntimeConfigDevFilePath)')" />
</ItemGroup>
</Target>

Expand Down Expand Up @@ -1819,6 +1822,7 @@ because xbuild doesn't support framework reference assemblies.
TargetsCLR="$(_AndroidUseCLR)"
AndroidRuntime="$(_AndroidRuntime)"
ProjectRuntimeConfigFilePath="$(ProjectRuntimeConfigFilePath)"
ProjectRuntimeConfigDevFilePath="$(ProjectRuntimeConfigDevFilePath)"
>
</GenerateNativeApplicationConfigSources>

Expand Down
51 changes: 51 additions & 0 deletions tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, bool> CreateLineChecker (string expectedLogcatOutput)
{
Expand Down
Loading